Commit eb8e005f2beb

Vincent Demeester <vincent@sbr.pm>
2026-03-23 10:55:39
feat: add pir-fork and update pi keybindings
Added pir-fork zsh function with fzf session picker for forking pi sessions. Includes fast gawk-based session scanner and preview with conversation history. Migrated keybindings to namespaced IDs for pi 0.61+ compatibility and added alt+shift+k for session fork.
1 parent 7094c56
Changed files (3)
dots/config/zsh/tools/_pi-session-helper
@@ -0,0 +1,133 @@
+#!/usr/bin/env bash
+# Helper for pir-fork: list and preview pi sessions.
+# Called by the pir-fork zsh function and by fzf --preview/--bind reload.
+#
+# Usage:
+#   _pi-session-helper list [--all] [--days N] [--cwd DIR]
+#   _pi-session-helper preview UUID
+set -euo pipefail
+
+SESSION_DIR="${HOME}/.pi/agent/sessions/"
+
+cmd_list() {
+  local show_all="false"
+  local days=""
+  local filter_cwd=""
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --all)       show_all="true"; shift ;;
+      --days)      days="$2"; shift 2 ;;
+      --cwd)       filter_cwd="$2"; shift 2 ;;
+      *)           shift ;;
+    esac
+  done
+
+  # Compute cutoff date if --days is set
+  local date_cutoff=""
+  if [[ -n "$days" ]]; then
+    date_cutoff=$(date -d "-${days} days" +%Y-%m-%d 2>/dev/null || date -v-${days}d +%Y-%m-%d 2>/dev/null || echo "")
+  fi
+
+  find "$SESSION_DIR" -name '*.jsonl' -type f -print0 \
+    | xargs -0 -P8 -n100 gawk \
+        -v home="$HOME" \
+        -v show_all="$show_all" \
+        -v filter_cwd="$filter_cwd" \
+        -v date_cutoff="$date_cutoff" '
+    FNR==1 {
+      id = ""; ts = ""; cwd = ""; msg = ""; found_user = 0
+      if (match($0, /"id":"([^"]+)"/, m)) id = m[1]
+      if (match($0, /"timestamp":"([^"]+)"/, m)) ts = m[1]
+      if (match($0, /"cwd":"([^"]+)"/, m)) cwd = m[1]
+      if (id == "") nextfile
+      if (show_all != "true" && filter_cwd != "" && index(cwd, filter_cwd) != 1) {
+        id = ""; nextfile
+      }
+      next
+    }
+    !found_user && /"role":"user"/ {
+      found_user = 1
+      if (match($0, /"text":"([^"]{1,80})/, m)) {
+        msg = m[1]
+        gsub(/\\n/, " ", msg)
+      }
+      nextfile
+    }
+    ENDFILE {
+      if (id != "" && ts != "") {
+        date_part = ts
+        sub(/T.*/, "", date_part)
+        if (date_part ~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/) {
+          if (date_cutoff == "" || date_part >= date_cutoff) {
+            short_cwd = cwd
+            gsub(home, "~", short_cwd)
+            printf "%s\t%s\t%s\t%s\n", date_part, short_cwd, msg, id
+          }
+        }
+      }
+    }
+  ' \
+    | grep -P '^\d{4}-\d{2}-\d{2}\t' \
+    | sort -t$'\t' -rk1,1
+}
+
+cmd_preview() {
+  local uuid="$1"
+  local f
+  f=$(find "$SESSION_DIR" -name "*_${uuid}.jsonl" -type f | head -1)
+
+  [[ -z "$f" ]] && { echo "Session file not found"; return 1; }
+
+  # Session metadata from header
+  local header
+  header=$(head -1 "$f")
+  local ts cwd
+  ts=$(echo "$header" | grep -oP '"timestamp":"\K[^"]+' || true)
+  cwd=$(echo "$header" | grep -oP '"cwd":"\K[^"]+' || true)
+  cwd="${cwd/$HOME/\~}"
+
+  # Count messages
+  local msg_count
+  msg_count=$(grep -c '"type":"message"' "$f" 2>/dev/null || echo 0)
+
+  # Model info
+  local model
+  model=$(grep -oP '"modelId":"\K[^"]+' "$f" 2>/dev/null | tail -1 || true)
+
+  # Print metadata header
+  echo "๐Ÿ“… ${ts}"
+  echo "๐Ÿ“ ${cwd}"
+  echo "๐Ÿ’ฌ ${msg_count} messages"
+  [[ -n "$model" ]] && echo "๐Ÿค– ${model}"
+  echo "โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€"
+
+  # Show conversation exchanges
+  grep '"type":"message"' "$f" 2>/dev/null \
+    | head -30 \
+    | jq -r '
+      (.message.role // "?") as $role |
+      (
+        if .message.content | type == "array" then
+          [.message.content[] | select(.type == "text") | .text] | join(" ")
+        elif .message.content | type == "string" then
+          .message.content
+        else
+          ""
+        end
+      ) as $text |
+      if $role == "user" then
+        "โ–ถ " + ($text | gsub("\n"; " ") | .[0:200])
+      elif $role == "assistant" then
+        "  " + ($text | gsub("\n"; " ") | .[0:200])
+      else
+        empty
+      end
+    ' 2>/dev/null
+}
+
+case "${1:-}" in
+  list)    shift; cmd_list "$@" ;;
+  preview) shift; cmd_preview "$@" ;;
+  *)       echo "Usage: _pi-session-helper {list|preview} [args...]" >&2; exit 1 ;;
+esac
dots/config/zsh/tools/pi.zsh
@@ -0,0 +1,100 @@
+# Pi coding agent helpers
+has pi || return
+
+# Path to the session helper script (sourced from same directory as this file)
+_PI_SESSION_HELPER="${0:A:h}/_pi-session-helper"
+
+# pir-fork: fuzzy-pick a pi session and fork it into a new session
+#
+# By default shows sessions from the current project directory, all time.
+#   --all / -a     show sessions from all projects
+#   --week / -w    last 7 days only
+#   --month / -m   last 30 days only
+#   --days N       last N days
+#
+# Inside fzf:
+#   ctrl-a   show all projects
+#   ctrl-p   show current project only
+#   alt-w    show last 7 days (week)
+#   alt-m    show last 30 days (month)
+#   alt-t    show all time (total)
+function pir-fork() {
+  local show_all=false
+  local days=""
+  local filter_cwd="$(pwd)"
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --all|-a)   show_all=true; shift ;;
+      --week|-w)  days=7; shift ;;
+      --month|-m) days=30; shift ;;
+      --days)     days="$2"; shift 2 ;;
+      *)          shift ;;
+    esac
+  done
+
+  [[ -d "${HOME}/.pi/agent/sessions/" ]] || { echo "No sessions found" >&2; return 1; }
+
+  # Build initial list command
+  local list_base="${_PI_SESSION_HELPER} list"
+  local list_cmd="$list_base"
+  [[ "$show_all" == "true" ]] && list_cmd+=" --all" || list_cmd+=" --cwd ${(q)filter_cwd}"
+  [[ -n "$days" ]] && list_cmd+=" --days $days"
+
+  local entries
+  entries=$(eval "$list_cmd")
+
+  if [[ -z "$entries" ]]; then
+    if [[ "$show_all" == "false" ]]; then
+      echo "No sessions found for $(pwd). Try: pir-fork --all" >&2
+    else
+      echo "No sessions found" >&2
+    fi
+    return 1
+  fi
+
+  # Precompute reload commands for fzf bindings
+  # Scope toggles (preserve no time filter โ€” user can combine with ctrl-1/2/3)
+  local rld_all="${list_base} --all"
+  local rld_project="${list_base} --cwd ${(q)filter_cwd}"
+  local rld_all_7d="${rld_all} --days 7"
+  local rld_all_30d="${rld_all} --days 30"
+  local rld_proj_7d="${rld_project} --days 7"
+  local rld_proj_30d="${rld_project} --days 30"
+
+  # Initial state for header
+  local scope_label
+  [[ "$show_all" == "true" ]] && scope_label="all projects" || scope_label="${filter_cwd/$HOME/\~}"
+  local time_label
+  [[ -n "$days" ]] && time_label="last ${days}d" || time_label="all time"
+
+  local selected
+  selected=$(printf "%s\n" "$entries" \
+    | fzf --delimiter='\t' \
+           --with-nth=1..3 \
+           --preview="${_PI_SESSION_HELPER} preview {4}" \
+           --preview-window=right:50%:wrap \
+           --header="scope: ${scope_label} โ”‚ time: ${time_label}
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork" \
+           --prompt="fork> " \
+           --bind="ctrl-j:accept" \
+           --bind="ctrl-a:reload(${rld_all})+change-header(scope: all projects โ”‚ time: all
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork)" \
+           --bind="ctrl-p:reload(${rld_project})+change-header(scope: ${scope_label} โ”‚ time: all
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork)" \
+           --bind="alt-w:reload(${rld_proj_7d})+change-header(scope: ${scope_label} โ”‚ time: last 7d
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork)" \
+           --bind="alt-m:reload(${rld_proj_30d})+change-header(scope: ${scope_label} โ”‚ time: last 30d
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork)" \
+           --bind="alt-t:reload(${rld_project})+change-header(scope: ${scope_label} โ”‚ time: all
+ctrl-a/p: scope โ”‚ alt-w/m/t: week/month/all โ”‚ ctrl-j: fork)"
+  )
+
+  [[ -z "$selected" ]] && return 0
+
+  local uuid
+  uuid=$(printf "%s" "$selected" | awk -F'\t' '{print $NF}')
+
+  echo "Forking session ${uuid}..."
+  pi --fork "$uuid"
+}
dots/pi/agent/keybindings.json
@@ -1,13 +1,14 @@
 {
-  "cursorUp": ["up", "ctrl+p"],
-  "cursorDown": ["down", "ctrl+n"],
-  "cursorLeft": ["left", "ctrl+b"],
-  "cursorRight": ["right", "ctrl+f"],
-  "cursorWordLeft": ["alt+left", "alt+b"],
-  "cursorWordRight": ["alt+right", "alt+f"],
-  "deleteCharForward": ["delete", "ctrl+d"],
-  "deleteCharBackward": ["backspace", "ctrl+h"],
-  "newLine": ["shift+enter", "ctrl+j"],
-  "cycleModelForward": ["alt+m"],
-  "cycleModelBackward": ["alt+shift+m"]
+  "tui.editor.cursorUp": ["up", "ctrl+p"],
+  "tui.editor.cursorDown": ["down", "ctrl+n"],
+  "tui.editor.cursorLeft": ["left", "ctrl+b"],
+  "tui.editor.cursorRight": ["right", "ctrl+f"],
+  "tui.editor.cursorWordLeft": ["alt+left", "alt+b"],
+  "tui.editor.cursorWordRight": ["alt+right", "alt+f"],
+  "tui.editor.deleteCharForward": ["delete", "ctrl+d"],
+  "tui.editor.deleteCharBackward": ["backspace", "ctrl+h"],
+  "tui.input.newLine": ["shift+enter", "ctrl+j"],
+  "app.model.cycleForward": ["alt+m"],
+  "app.model.cycleBackward": ["alt+shift+m"],
+  "app.session.fork": ["alt+shift+k"]
 }