This commit is contained in:
xinming 2026-08-06 10:16:30 +02:00 committed by GitHub
commit 0f702c2b55
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 494 additions and 0 deletions

View file

@ -0,0 +1,78 @@
# ai-session-search
fzf-style fuzzy search across the session histories of multiple AI CLIs:
**Claude Code / Codex / Copilot / Gemini**. Hit a hotkey to pop up a list, type
`claude` / `codex` / `copilot` / `gemini` to filter by tool, and press Enter to
`resume` the session right in its original directory.
To use it, add `ai-session-search` to the plugins array in your zshrc file:
```zsh
plugins=(... ai-session-search)
```
## Requirements
This plugin requires [`fzf`](https://github.com/junegunn/fzf) (the picker, 0.35+
recommended) and [`jq`](https://stedolan.github.io/jq/) (to parse the session
files):
```sh
brew install fzf jq # macOS
sudo apt install fzf jq # Debian/Ubuntu
```
Browsing and previewing work with just `fzf` + `jq` — scanning reads the session
files directly. To actually *resume* a session you also need the matching CLI on
your `PATH` (`claude` / `codex` / `copilot` / `gemini`).
## Usage
Default keybinding: <kbd>Ctrl-X</kbd> <kbd>Ctrl-W</kbd> (does not clobber zsh's
built-in <kbd>Ctrl-W</kbd> backward-kill-word). You can also run it as a command:
`ai-sessions` or `ai-sessions codex` (with an initial filter term).
In the picker:
| Key | Action |
|---------|--------|
| type | fuzzy-filter (the provider name is part of each row, so typing `claude` narrows to Claude) |
| Enter | resume the selected session in its original directory |
| Ctrl-O | open the raw `.jsonl` in `$EDITOR` |
| Ctrl-/ | toggle the preview pane |
| Ctrl-R | rescan |
## How it works
- **Claude**: scans `~/.claude/projects/<dir>/<uuid>.jsonl` (skips `subagents/`
sub-agent sessions); `cwd` is read from the `cwd` field inside the file;
resume uses `claude --resume <uuid>`.
- **Codex**: scans `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`; `cwd` and `id`
come from `session_meta`; resume uses `codex resume <uuid>`.
- **Copilot**: scans `~/.copilot/session-state/<uuid>/events.jsonl`; `cwd` /
model / version come from the `session.start` event, the `id` is the directory
name; resume uses `copilot --resume=<uuid>`.
- **Gemini**: scans `~/.gemini/tmp/<hash>/logs.json` (gemini-cli format,
**experimental**; empty if gemini-cli isn't installed; gemini-cli has no
resume-by-id, so it just opens in the directory).
Each row's preview is the first real user message, with injected context like
`<environment_context>` and `AGENTS.md` filtered out. Sessions whose original
directory no longer exists are hidden (set `AISS_SHOW_MISSING=1` to keep them).
At this scale (dozens of files) live scanning is plenty — no cache needed.
## Settings
All these settings should go in your zshrc file, before Oh My Zsh is sourced.
| Variable | Default | Description |
|----------|---------|-------------|
| `AISS_KEYBIND` | `^X^W` | Keybinding for the picker widget (e.g. `^G`) |
| `AISS_CLAUDE_DIR` | `~/.claude/projects` | Claude Code sessions directory |
| `AISS_CODEX_DIR` | `~/.codex/sessions` | Codex sessions directory |
| `AISS_COPILOT_DIR` | `~/.copilot/session-state` | Copilot CLI sessions directory |
| `AISS_GEMINI_DIR` | `~/.gemini/tmp` | Gemini CLI sessions directory |
| `AISS_PREVIEW_LEN` | `100` | Chars of the preview snippet shown in the list |
| `AISS_SHOW_MISSING` | unset | Set to `1` to keep sessions whose directory was deleted |
> **Note:** `AISS_KEYBIND` must be set *before* Oh My Zsh sources the plugin.

View file

@ -0,0 +1,117 @@
# ai-session-search.plugin.zsh
# fzf-style fuzzy search over AI CLI session histories (Claude / Codex / Gemini).
#
# Install: source this file from ~/.zshrc, e.g.
# source ~/plugins/ai-session-search/ai-session-search.plugin.zsh
# (oh-my-zsh: drop the folder in ~/.oh-my-zsh/custom/plugins/ and add
# `ai-session-search` to your plugins=(...) array.)
#
# Default keybinding: Ctrl-X Ctrl-W (does NOT clobber Ctrl-W / backward-kill-word)
# Change it by setting AISS_KEYBIND before sourcing, e.g. AISS_KEYBIND='^G'.
#
# In the picker:
# - type to fuzzy-filter (the provider name is part of each row, so typing
# "claude" / "codex" / "copilot" / "gemini" narrows to that tool)
# - Enter resume the selected session in its original directory
# - Ctrl-O open the raw .jsonl in $EDITOR
# - Ctrl-/ toggle the preview pane
# - Ctrl-R rescan
#
# Override scan locations with AISS_CLAUDE_DIR / AISS_CODEX_DIR / AISS_GEMINI_DIR.
AISS_DIR=${0:A:h}
AISS_HELPER="$AISS_DIR/aiss-helper.zsh"
# Hard dependencies (fzf for the picker, jq for parsing the session files).
# Checked lazily so we don't nag on every shell startup: nothing is printed at
# load time, and the keybinding is still registered (so it works the moment the
# user installs the missing tool — no re-source needed). `${+commands[x]}` reads
# zsh's command hash table, so this never forks a subprocess.
_aiss_missing_deps() {
local -a missing
(( ${+commands[fzf]} )) || missing+=(fzf)
(( ${+commands[jq]} )) || missing+=(jq)
(( ${#missing} )) || return 1
print -u2 "ai-session-search: missing required command(s): ${missing[*]}." \
"Install them first, e.g.: brew install ${missing[*]} (or your package manager)."
return 0
}
# Core picker. Prints a ready-to-run resume command to stdout (does not run it),
# so it can drive both the ZLE widget and direct invocation.
_aiss_select() {
emulate -L zsh
setopt pipefail 2>/dev/null
_aiss_missing_deps && return 1
local sel
sel=$(
zsh "$AISS_HELPER" scan | fzf \
--ansi \
--delimiter=$'\t' --with-nth=1 --nth=1 \
--no-sort \
--height=90% --layout=reverse --border \
--prompt='ai-sessions ' \
--query="${1:-}" \
--header='Enter: resume Ctrl-O: open Ctrl-/: preview Ctrl-R: rescan' \
--preview="zsh ${(q)AISS_HELPER} preview {2} {5}" \
--preview-window='right:55%:wrap:border-left' \
--bind='ctrl-/:toggle-preview' \
--bind="ctrl-r:reload(zsh ${(q)AISS_HELPER} scan)" \
--bind='ctrl-o:execute(${EDITOR:-vi} {5} < /dev/tty > /dev/tty)'
) || return 1
[[ -z $sel ]] && return 1
local -a F
F=("${(@s: :)sel}") # split selected row on tabs
local provider=$F[2] id=$F[3] cwd=$F[4] file=$F[5]
[[ -z $id ]] && return 1
# Resume must run in the session's ORIGINAL directory: claude --resume locates
# the project by the current working directory. If that dir was deleted, recreate
# it (empty) so the project lookup still matches; never silently fall back to $PWD.
local cdpart
if [[ -z $cwd ]]; then
cdpart="cd ${(q)PWD}"
elif [[ -d $cwd ]]; then
cdpart="cd ${(q)cwd}"
else
cdpart="mkdir -p ${(q)cwd} && cd ${(q)cwd}"
fi
case $provider in
claude) print -r -- "( $cdpart && claude --resume ${(q)id} )" ;;
codex)
# Pin the model the session was recorded with, so `codex resume` doesn't
# warn about / silently switch to a different default model.
local model=""
[[ -r $file ]] && model=$(jq -rs 'first(.[] | .payload.model // empty) // empty' -- "$file" 2>/dev/null)
print -r -- "( $cdpart && codex resume ${(q)id}${model:+ -m ${(q)model}} )"
;;
copilot) print -r -- "( $cdpart && copilot --resume=${(q)id} )" ;;
gemini) print -r -- "( $cdpart && gemini ) # gemini-cli has no resume-by-id; opens in dir" ;;
*) return 1 ;;
esac
}
# Direct command: ai-sessions [initial-query]
ai-sessions() {
local cmd
cmd=$(_aiss_select "$@") || return 0
print -r -- "$cmd"
eval "$cmd"
}
# ZLE widget: pick, then drop the resume command on the command line and run it.
_aiss_widget() {
local cmd
cmd=$(_aiss_select) || { zle reset-prompt; return 0; }
BUFFER=$cmd
zle reset-prompt
zle accept-line
}
zle -N _aiss_widget
# Bind only in interactive shells (the fzf preview child shell sources nothing,
# but guard anyway so non-interactive sourcing stays silent).
if [[ -o interactive ]]; then
bindkey "${AISS_KEYBIND:-^X^W}" _aiss_widget
fi

View file

@ -0,0 +1,299 @@
#!/usr/bin/env zsh
# aiss-helper.zsh — scanning + preview rendering for ai-session-search.
# Invoked as a standalone script (not sourced):
# zsh aiss-helper.zsh scan
# zsh aiss-helper.zsh preview <provider> <file>
#
# Kept separate from the plugin so the fzf child shell can call back into it
# without needing the plugin's zsh functions in scope.
emulate -L zsh
setopt null_glob extended_glob pipefail 2>/dev/null
zmodload zsh/datetime 2>/dev/null
zmodload -F zsh/stat b:zstat 2>/dev/null
CLAUDE_DIR=${AISS_CLAUDE_DIR:-$HOME/.claude/projects}
CODEX_DIR=${AISS_CODEX_DIR:-$HOME/.codex/sessions}
GEMINI_DIR=${AISS_GEMINI_DIR:-$HOME/.gemini/tmp}
COPILOT_DIR=${AISS_COPILOT_DIR:-$HOME/.copilot/session-state}
PREVIEW_MAX=${AISS_PREVIEW_LEN:-100} # chars of preview shown in the list
# ---- jq filters (single-pass, slurped) ------------------------------------
#
# NOTE: assign via $(cat <<'JQ' ... JQ) — NOT `read -r -d '' VAR <<'JQ'`.
# The `read` builtin consuming a heredoc HANGS when this script runs as an fzf
# `--preview` command (fzf runs previews in a background process group, where
# the builtin blocks). Forking `cat` sidesteps it. See README "Gotchas".
# Claude: emit cwd \t first-real-user-prompt
JQ_CLAUDE=$(cat <<'JQ'
. as $a
| (first($a[] | .cwd? // empty) // "") as $cwd
| (first(
$a[]
| select(.type=="user")
| (.message.content) as $c
| (if ($c|type)=="string" then $c
elif ($c|type)=="array" then (first($c[] | select(.type=="text") | .text) // empty)
else empty end)
| select(type=="string" and . != "")
| select(test("^<")|not) # skip <command-name>, <local-command...> etc.
| select(test("^Caveat:")|not)
) // "") as $p
| [ $cwd, ($p | gsub("[\\n\\t\\r]+"; " ")) ] | @tsv
JQ
)
# Codex: emit id \t cwd \t first-real-user-prompt
JQ_CODEX=$(cat <<'JQ'
. as $a
| (first($a[] | select(.type=="session_meta") | .payload)) as $m
| (first(
$a[]
| select((.payload.type? // "")=="message" and (.payload.role? // "")=="user")
| .payload.content[]?
| (.text // empty)
| select(type=="string" and . != "")
| select(test("^(<(environment_context|user_instructions|permissions|INSTRUCTIONS)|# AGENTS\\.md)")|not)
) // "") as $p
| [ ($m.id // ""), ($m.cwd // ""), ($p | gsub("[\\n\\t\\r]+"; " ")) ] | @tsv
JQ
)
# Copilot CLI: emit cwd \t first-real-user-prompt (events.jsonl event stream)
JQ_COPILOT=$(cat <<'JQ'
. as $a
| (first($a[] | select(.type=="session.start") | .data.context.cwd) // "") as $cwd
| (first(
$a[] | select(.type=="user.message") | .data.content
| select(type=="string" and . != "")
| select(test("^<")|not)
) // "") as $p
| [ $cwd, ($p | gsub("[\\n\\t\\r]+"; " ")) ] | @tsv
JQ
)
# ---- preview renderers ----------------------------------------------------
JQ_CLAUDE_PREVIEW=$(cat <<'JQ'
.[]
| select(.type=="user" or .type=="assistant")
| (.type) as $role
| (.message.content) as $c
| (if ($c|type)=="string" then $c
elif ($c|type)=="array" then
([ $c[]
| (.text
// (if .type=="tool_use" then "🔧 " + (.name // "tool")
elif .type=="tool_result" then "↩ [tool result]"
else empty end)) ] | join("\n"))
else "" end) as $t
| select($t != "")
| (if $role=="user" then "\u001b[1;36m▶ USER\u001b[0m" else "\u001b[1;32m◀ ASSISTANT\u001b[0m" end)
+ "\n" + $t + "\n"
JQ
)
JQ_CODEX_PREVIEW=$(cat <<'JQ'
.[]
| select((.payload.type? // "")=="message")
| (.payload.role) as $role
| select($role=="user" or $role=="assistant")
| ([ .payload.content[]? | (.text // empty) ] | join("\n")) as $t
| select($t != "")
| select(($role=="user" and ($t | test("^(<(environment_context|user_instructions|permissions|INSTRUCTIONS)|# AGENTS\\.md)"))) | not)
| (if $role=="user" then "\u001b[1;36m▶ USER\u001b[0m" else "\u001b[1;32m◀ ASSISTANT\u001b[0m" end)
+ "\n" + $t + "\n"
JQ
)
JQ_COPILOT_PREVIEW=$(cat <<'JQ'
.[]
| select(.type=="user.message" or .type=="assistant.message")
| (.type) as $ty
| (if $ty=="user.message" then (.data.content // "")
else ((.data.content // "")
+ ([ (.data.toolRequests // [])[] | "\n🔧 " + (.name // "tool") ] | join(""))) end) as $t
| select($t != "")
| (if $ty=="user.message" then "\u001b[1;36m▶ USER\u001b[0m" else "\u001b[1;32m◀ ASSISTANT\u001b[0m" end)
+ "\n" + $t + "\n"
JQ
)
# Session metadata (for the preview header), emitted as one line whose fields
# are joined with U+001F (unit separator) so empty fields don't collapse.
JQ_CLAUDE_META=$(cat <<'JQ'
. as $a
| [ $a[] | .timestamp // empty ] as $ts
| [ (($ts[0] // ""), ($ts[-1] // "")) | (sub("\\.[0-9]+Z$";"Z") | fromdateiso8601?) // 0 ] as $ep
| [ (first($a[] | select(.type=="assistant") | .message.model // empty | select(test("^<")|not)) // "?"),
(first($a[] | .version // empty) // ""),
((first($a[] | .gitBranch // empty) // "") | if . == "HEAD" then "" else . end),
(first($a[] | .cwd // empty) // ""),
($ep[0]|tostring), ($ep[1]|tostring),
([$a[] | select(.type=="user")] | length | tostring),
([$a[] | select(.type=="assistant")] | length | tostring)
] | join("\u001f")
JQ
)
JQ_CODEX_META=$(cat <<'JQ'
. as $a
| [ $a[] | .timestamp // empty ] as $ts
| [ (($ts[0] // ""), ($ts[-1] // "")) | (sub("\\.[0-9]+Z$";"Z") | fromdateiso8601?) // 0 ] as $ep
| (first($a[] | select(.type=="session_meta") | .payload) // {}) as $m
| [ (first($a[] | .payload.model // empty) // "?"),
($m.model_provider // ""),
($m.cli_version // ""),
($m.cwd // ""),
($ep[0]|tostring), ($ep[1]|tostring),
([$a[] | select((.payload.role? // "")=="user")] | length | tostring),
([$a[] | select((.payload.role? // "")=="assistant")] | length | tostring)
] | join("\u001f")
JQ
)
JQ_COPILOT_META=$(cat <<'JQ'
. as $a
| [ $a[] | .timestamp // empty ] as $ts
| [ (($ts[0] // ""), ($ts[-1] // "")) | (sub("\\.[0-9]+Z$";"Z") | fromdateiso8601?) // 0 ] as $ep
| (first($a[] | select(.type=="session.start") | .data)) as $s
| [ ((last($a[] | select(.type=="assistant.message") | .data.model)) // (last($a[] | select(.type=="session.model_change") | .data.newModel)) // "?"),
($s.copilotVersion // ""),
"",
($s.context.cwd // ""),
($ep[0]|tostring), ($ep[1]|tostring),
([$a[] | select(.type=="user.message")] | length | tostring),
([$a[] | select(.type=="assistant.message")] | length | tostring)
] | join("\u001f")
JQ
)
# ---------------------------------------------------------------------------
# Render a boxed metadata header for the preview pane.
# _aiss_header <provider> <cwd> <branch> <model> <ver-line> <t0epoch> <t1epoch> <nUser> <nAsst>
_aiss_header() {
local provider=$1 cwd=$2 branch=$3 model=$4 verline=$5 t0=$6 t1=$7 nu=$8 na=$9
local cwd_disp=${cwd/#$HOME/\~} d0 d1 dur day0 day1
if [[ $t0 == <-> ]] && (( t0 > 0 )); then
strftime -s d0 '%Y-%m-%d %H:%M' $t0; strftime -s day0 '%Y-%m-%d' $t0
else d0='?'; fi
if [[ $t1 == <-> ]] && (( t1 > 0 )); then
strftime -s day1 '%Y-%m-%d' $t1
# show the end date too when the session spans more than one day
if [[ $day1 == $day0 ]]; then strftime -s d1 '%H:%M' $t1
else strftime -s d1 '%m-%d %H:%M' $t1; fi
else d1='?'; fi
if [[ $t0 == <-> && $t1 == <-> ]] && (( t0 > 0 && t1 >= t0 )); then
local s=$(( t1 - t0 ))
if (( s >= 86400 )); then dur=$(printf '%dd%dh' $((s/86400)) $(((s%86400)/3600)))
elif (( s >= 3600 )); then dur=$(printf '%dh%dm' $((s/3600)) $(((s%3600)/60)))
elif (( s >= 60 )); then dur=$(printf '%dm' $((s/60)))
else dur=$(printf '%ds' $s); fi
else dur='?'; fi
local C=$'\e[36m' B=$'\e[1m' D=$'\e[2m' R=$'\e[0m'
print -r -- "${C}╭─ ${B}${provider}${R}${C} · ${model}${R}"
print -r -- "${C}${R} 📁 ${cwd_disp}${branch:+ ${D}${branch}${R}}"
print -r -- "${C}${R} 🕐 ${d0}${d1} ${D}(${dur})${R}"
print -r -- "${C}${R} 💬 ${nu} user · ${na} assistant${verline:+ ${D}${verline}${R}}"
print -r -- "${C}╰──────────────────────────────────────────${R}"
print -r -- ""
}
_emit() {
# _emit <epoch> <provider> <id> <cwd> <preview> <file>
local epoch=$1 provider=$2 id=$3 cwd=$4 preview=$5 file=$6
[[ -z $preview ]] && preview="(no prompt)"
local cwd_disp=${cwd/#$HOME/\~}
# epoch is kept only as a sort key (stripped before fzf sees it); the visible
# row is: provider cwd preview
# field layout: epoch \t display \t provider \t id \t cwd \t file
printf '%s\t%-6s %-34.34s %.*s\t%s\t%s\t%s\t%s\n' \
"$epoch" "$provider" "$cwd_disp" "$PREVIEW_MAX" "$preview" \
"$provider" "$id" "$cwd" "$file"
}
scan() {
local f epoch out cwd id preview
local -a parts
# --- Claude ---
for f in $CLAUDE_DIR/*/*.jsonl(.N); do
zstat -A epoch +mtime -- $f 2>/dev/null || continue
out=$(jq -rs "$JQ_CLAUDE" -- "$f" 2>/dev/null) || continue
parts=("${(@s: :)out}") # split on literal tab
cwd=$parts[1]; preview=$parts[2]
[[ -z $cwd ]] && cwd=$HOME
# skip sessions whose original project dir no longer exists (set
# AISS_SHOW_MISSING=1 to keep them)
[[ -z $AISS_SHOW_MISSING && ! -d $cwd ]] && continue
id=${f:t:r} # filename minus .jsonl
_emit "$epoch" claude "$id" "$cwd" "$preview" "$f"
done
# --- Codex ---
for f in $CODEX_DIR/**/rollout-*.jsonl(.N); do
zstat -A epoch +mtime -- $f 2>/dev/null || continue
out=$(jq -rs "$JQ_CODEX" -- "$f" 2>/dev/null) || continue
parts=("${(@s: :)out}")
id=$parts[1]; cwd=$parts[2]; preview=$parts[3]
[[ -z $id ]] && id=${f:t:r#rollout-*-} # fallback: trailing uuid in name
[[ -z $AISS_SHOW_MISSING && -n $cwd && ! -d $cwd ]] && continue
_emit "$epoch" codex "$id" "$cwd" "$preview" "$f"
done
# --- Copilot CLI (~/.copilot/session-state/<uuid>/events.jsonl) ---
for f in $COPILOT_DIR/*/events.jsonl(.N); do
zstat -A epoch +mtime -- $f 2>/dev/null || continue
out=$(jq -rs "$JQ_COPILOT" -- "$f" 2>/dev/null) || continue
parts=("${(@s: :)out}")
cwd=$parts[1]; preview=$parts[2]
[[ -z $AISS_SHOW_MISSING && -n $cwd && ! -d $cwd ]] && continue
id=${f:h:t} # parent dir name = session UUID
_emit "$epoch" copilot "$id" "$cwd" "$preview" "$f"
done
# --- Gemini (experimental: gemini-cli stores ~/.gemini/tmp/<hash>/logs.json) ---
for f in $GEMINI_DIR/*/logs.json(.N); do
zstat -A epoch +mtime -- $f 2>/dev/null || continue
preview=$(jq -r 'try (map(select(.type=="user")) | .[0].message) // "" catch ""' -- "$f" 2>/dev/null)
cwd=${f:h:t}
_emit "$epoch" gemini "${f:h:t}" "$cwd" "${preview//[$'\n\t']/ }" "$f"
done
}
preview() {
local provider=$1 file=$2
[[ -r $file ]] || { print -r -- "(file not found: $file)"; return 0; }
local meta model mp ver branch cwd t0 t1 nu na
case $provider in
claude)
meta=$(jq -rs "$JQ_CLAUDE_META" -- "$file" 2>/dev/null)
IFS=$'\x1f' read -r model ver branch cwd t0 t1 nu na <<<"$meta"
_aiss_header claude "$cwd" "$branch" "$model" "${ver:+Claude Code $ver}" "$t0" "$t1" "$nu" "$na"
jq -rs "$JQ_CLAUDE_PREVIEW" -- "$file" 2>/dev/null
;;
codex)
meta=$(jq -rs "$JQ_CODEX_META" -- "$file" 2>/dev/null)
IFS=$'\x1f' read -r model mp ver cwd t0 t1 nu na <<<"$meta"
_aiss_header codex "$cwd" "" "$model" "codex ${ver}${mp:+ · $mp}" "$t0" "$t1" "$nu" "$na"
jq -rs "$JQ_CODEX_PREVIEW" -- "$file" 2>/dev/null
;;
copilot)
meta=$(jq -rs "$JQ_COPILOT_META" -- "$file" 2>/dev/null)
IFS=$'\x1f' read -r model ver branch cwd t0 t1 nu na <<<"$meta"
_aiss_header copilot "$cwd" "$branch" "$model" "${ver:+Copilot CLI $ver}" "$t0" "$t1" "$nu" "$na"
jq -rs "$JQ_COPILOT_PREVIEW" -- "$file" 2>/dev/null
;;
gemini) jq -r '.[]? | "\u001b[1;36m" + (.type // "?") + "\u001b[0m\n" + (.message // (.|tostring)) + "\n"' -- "$file" 2>/dev/null ;;
*) cat -- "$file" ;;
esac
}
case $1 in
scan) scan | sort -t$'\t' -k1,1rn | cut -f2- ;;
preview) shift; preview "$@" ;;
*) print -u2 "usage: aiss-helper.zsh {scan|preview <provider> <file>}"; return 2 ;;
esac