#!/usr/bin/env bash
# fzf-ai: fuzzy browser for AI coding sessions across claude, codex,
# opencode, droid, and pi. Selecting an item resumes it in its native CLI.
#
# Keybinds (shown in fzf header):
#   enter          resume the session in its tool
#   ctrl-o         cd into the session's working directory in a new shell
#   ctrl-e         open the raw session file in $EDITOR
#   ctrl-y         copy session id to the clipboard (when supported)
#   ctrl-k         copy the exact resume command to the clipboard
#   ctrl-p/n       move up / down
#   ctrl-r         reload the index
#   ctrl-s         cycle search scope
#   ?              toggle preview
#   alt-p          cycle preview position
#   alt-w          toggle preview wrap
#   shift-up/down  scroll preview half a page
#   ctrl-shift-up/down  scroll preview a full page
#   alt-</alt->    jump to first / last session
#   alt-1..5       filter to one agent only
#   alt-0          clear filter
set -uo pipefail

SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
INDEX="$SCRIPT_DIR/fzf-ai-index"
PREVIEW="$SCRIPT_DIR/fzf-ai-preview"
RESUME="$SCRIPT_DIR/fzf-ai-resume"
UI="$SCRIPT_DIR/fzf-ai-ui"

version_num() {
  local major minor patch
  IFS=. read -r major minor patch <<<"${1%%[^0-9.]*}"
  major=${major:-0}
  minor=${minor:-0}
  patch=${patch:-0}
  printf '%03d%03d%03d\n' "$major" "$minor" "$patch"
}

version_ge() {
  [[ $(version_num "$1") -ge $(version_num "$2") ]]
}

if ! command -v fzf >/dev/null 2>&1; then
  echo "fzf-ai: fzf is required (https://github.com/junegunn/fzf)" >&2
  exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
  echo "fzf-ai: python3 is required" >&2
  exit 1
fi
fzf_version=$(fzf --version | awk '{print $1}')
if ! version_ge "$fzf_version" "0.63.0"; then
  echo "fzf-ai: fzf >= 0.63.0 is required (found $fzf_version)" >&2
  exit 1
fi

# --- Index column layout -------------------------------------------------
# The indexer emits 9 TAB-separated columns per line:
#   1 agent-raw   2 session_id  3 source        (hidden, machine-only)
#   4 agent-badge 5 updated     6 msgs          (visible, padded)
#   7 cwd         8 title                       (visible)
#   9 search-blob (rendered but off-screen)     -- hidden session content
# fzf's --nth indexes into the --with-nth view, so the visible columns
# are: 4,5,6,7,8,9 = positions 1,2,3,4,5,6 in the transformed view.
# Column 9 is deliberately pushed off-screen so session content is searchable
# without cluttering the list rows.
# --------------------------------------------------------------------------

copy_cmd() {
  if command -v pbcopy >/dev/null 2>&1; then echo pbcopy; return; fi
  if command -v wl-copy >/dev/null 2>&1; then echo wl-copy; return; fi
  if command -v xclip  >/dev/null 2>&1; then echo "xclip -selection clipboard"; return; fi
  echo ""
}
COPY=$(copy_cmd)

quote_cmd() {
  printf '%q ' "$@"
}

build_border_label() {
  if [[ $# -eq 0 ]]; then
    printf ' fzf-ai '
    return
  fi
  printf ' fzf-ai · %s ' "$*"
}

# Agents can be filtered with args: `fzf-ai claude codex`
AGENTS=("$@")
BORDER_LABEL=$(build_border_label "${AGENTS[@]}")
STATE_HOME=${XDG_STATE_HOME:-$HOME/.local/state}
HISTORY_DIR="$STATE_HOME/fzf-ai"
HISTORY_FILE="$HISTORY_DIR/query-history"
mkdir -p "$HISTORY_DIR"

# Export the children scripts so fzf bindings can call them.
export FZFAI_PREVIEW="$PREVIEW"
export FZFAI_RESUME="$RESUME"
export FZFAI_INDEX="$INDEX"
export FZFAI_UI="$UI"
export FZFAI_AGENTS="${AGENTS[*]:-}"
export FZFAI_COPY="$COPY"

# Preview command: index record passed via positional expansion.
#   {1}=agent {2}=sid {3}=source {7}=cwd {8}=title
PREVIEW_CMD='"$FZFAI_PREVIEW" {1} {2} {3} {q}'

# Reload command re-runs the indexer, optionally filtered to named agents.
RELOAD_CMD=$(quote_cmd "$FZFAI_INDEX" "${AGENTS[@]}")
RELOAD_CLAUDE_CMD=$(quote_cmd "$FZFAI_INDEX" claude)
RELOAD_CODEX_CMD=$(quote_cmd "$FZFAI_INDEX" codex)
RELOAD_OPENCODE_CMD=$(quote_cmd "$FZFAI_INDEX" opencode)
RELOAD_DROID_CMD=$(quote_cmd "$FZFAI_INDEX" droid)
RELOAD_PI_CMD=$(quote_cmd "$FZFAI_INDEX" pi)

HEADER=$'scope: [all] [agent] [cwd] [title] [content]  ·  click header or ctrl-s to change\nenter: resume  ·  ctrl-o: shell  ·  ctrl-e: edit  ·  ctrl-r: reload  ·  ?: preview  ·  alt-p: layout  ·  alt-w: wrap  ·  alt-1..5: agent'
if [[ -n "$COPY" ]]; then
  HEADER+=$'  ·  ctrl-y: copy id  ·  ctrl-k: copy cmd'
fi

# Neutralize user's global FZF_DEFAULT_OPTS so our carefully-tuned
# flags don't collide with conflicting defaults (e.g. a user-set
# --tiebreak=length would nullify our history scheme tuning).
#unset FZF_DEFAULT_OPTS
#unset FZF_DEFAULT_OPTS_FILE

# -------------------------------------------------------------------------
# Run fzf and capture the selected row. We deliberately do NOT use the
# `become` action here. `become` exec()s the target command from inside
# fzf, which we observed leaving the terminal in a state that breaks
# cleanly-written TUIs (codex hangs, opencode input freezes). Capturing
# the selection instead lets fzf fully tear down its altscreen and
# restore the terminal before we launch the target CLI.
# -------------------------------------------------------------------------
FZF_ARGS=(
  --ansi
  --delimiter=$'\t'
  --with-nth='4,5,6,7,8,9'
  --nth='1,4,5,6'
  # Exact matching is the default because the content-scope search
  # blob (col 9) is a 4000-char concatenation of prompts. Fuzzy
  # matching against that blob produces a flood of low-signal
  # "characters-in-order" matches whenever a query shares common
  # letters with filler text. --exact keeps the picker precise; for
  # a single fuzzy term, users can still prefix it with ': 'word.
  --exact
  --scheme=history
  --smart-case
  --no-sort
  --track
  --id-nth=2
  # history scheme sets --tiebreak=index on its own. We keep that:
  # the index is our mtime ordering, so recency automatically wins
  # ties without fighting the scheme. fzf requires 'index' to be the
  # last tiebreak and rejects combining it with others.
  --tiebreak=index
  --cycle
  --scroll-off=3
  --info=inline-right
  --highlight-line
  --filepath-word
  --no-hscroll
  --keep-right
  --history="$HISTORY_FILE"
  --history-size=2000
  --height=100%
  --min-height=20+
  --tmux='90%,90%'
  --layout=reverse
  --style='full:line'
  --border=rounded
  --border-label="$BORDER_LABEL"
  --border-label-pos=3
  --list-border=rounded
  --list-label=' Sessions '
  --input-border=rounded
  --input-label=' Search '
  --header-border=line
  --header-label=' Scope '
  --footer=' Indexing session stores… '
  --footer-border=line
  --footer-label=' Summary '
  --prompt='all> '
  --ghost="type to search · 'word = fuzzy · !word = exclude · ^word/word\$ = anchor"
  --pointer='▶'
  --marker='✓'
  --scrollbar='▎▎'
  --ellipsis='…'
  --separator='─'
  --color='fg:dim,nth:regular,query:regular,border:magenta,separator:240,preview-border:240,label:bold,spinner:208,info:240'
  --header="$HEADER"
  --header-lines=0
  --preview="$PREVIEW_CMD"
  --preview-label=' Preview '
  --preview-window='right,60%,border-left,wrap,~4,<120(down,55%,border-top,wrap,~4)'
  --accept-nth=1,2,3,7
  --expect=enter
  --bind="start:change-list-label( Loading sessions )+change-footer( Indexing session stores… )+reload-sync($RELOAD_CMD)"
  --bind="load:bg-cancel+transform-prompt[$UI prompt]+bg-transform-list-label[$UI list-label]+bg-transform-footer[$UI footer {*f}]"
  --bind="result:bg-cancel+transform-prompt[$UI prompt]+bg-transform-list-label[$UI list-label]+bg-transform-footer[$UI footer {*f}]"
  --bind="focus:bg-transform-preview-label[$UI preview-label {1} {2} {8}]"
  --bind="zero:change-list-label( No matching sessions )+change-footer( no results — try a looser query or ctrl-s to change scope )"
  --bind="click-header:transform[$UI click-header-action]"
  --bind='ctrl-p:up'
  --bind='ctrl-n:down'
  --bind="ctrl-s:transform[$UI cycle-scope-action]"
  --bind="ctrl-r:change-list-label( Reindexing sessions )+change-footer( Rebuilding session index… )+track-current+reload-sync($RELOAD_CMD)"
  --bind='?:toggle-preview'
  --bind='alt-p:change-preview-window(right,60%,border-left,wrap,~4|down,55%,border-top,wrap,~4|hidden|)'
  --bind='alt-w:toggle-preview-wrap'
  # Preview scrolling: shift-{up,down} scrolls the preview by half a
  # page, ctrl-shift-{up,down} by a full page. pgup/pgdn stay bound to
  # their default list-page actions so paging through the session list
  # still works the way users expect.
  --bind='shift-up:preview-half-page-up'
  --bind='shift-down:preview-half-page-down'
  --bind='ctrl-shift-up:preview-page-up'
  --bind='ctrl-shift-down:preview-page-down'
  # home/end default to beginning/end of the query input line,
  # which is what you want while editing a query. Use alt-</> to jump
  # to the first / last session instead.
  --bind='alt-<:first'
  --bind='alt->:last'
  --bind="ctrl-o:execute(cwd={7}; case \"\$cwd\" in '~') cwd=\"\$HOME\" ;; '~/'*) cwd=\"\$HOME/\${cwd#\~/}\" ;; esac; cd \"\$cwd\" 2>/dev/null && \$SHELL -i)"
  --bind="ctrl-e:execute(\${EDITOR:-vi} {3})"
  --bind="alt-1:change-border-label( fzf-ai · claude )+reload($RELOAD_CLAUDE_CMD)"
  --bind="alt-2:change-border-label( fzf-ai · codex )+reload($RELOAD_CODEX_CMD)"
  --bind="alt-3:change-border-label( fzf-ai · opencode )+reload($RELOAD_OPENCODE_CMD)"
  --bind="alt-4:change-border-label( fzf-ai · droid )+reload($RELOAD_DROID_CMD)"
  --bind="alt-5:change-border-label( fzf-ai · pi )+reload($RELOAD_PI_CMD)"
  --bind="alt-0:change-border-label($BORDER_LABEL)+reload($RELOAD_CMD)"
  --bind='double-click:accept'
  --bind='ctrl-w:backward-kill-word'
)

if [[ -n "$COPY" ]]; then
  FZF_ARGS+=(
    --bind="ctrl-y:execute-silent(printf %s {2} | \$FZFAI_COPY)"
    --bind="ctrl-k:execute-silent(\"\$FZFAI_RESUME\" --print-cmd {1} {2} {3} {7} | \$FZFAI_COPY)"
  )
fi

selection=$(fzf "${FZF_ARGS[@]}")
fzf_rc=$?

# Exit gracefully on ESC / ctrl-c / no match.
if [[ $fzf_rc -ne 0 && $fzf_rc -ne 1 ]]; then
  exit $fzf_rc
fi
[[ -z "$selection" ]] && exit 0

# --expect=enter prints the key name on the first line, selected fields on
# the second. With --accept-nth=1,2,3,7 only agent sid source cwd are output.
row=$(printf '%s\n' "$selection" | sed -n '2p')
[[ -z "$row" ]] && exit 0

IFS=$'\t' read -r agent sid source cwd <<<"$row"
# The cwd column is space-padded for UI alignment; strip trailing spaces.
cwd=${cwd%"${cwd##*[![:space:]]}"}

if [[ -z "$agent" || -z "$sid" ]]; then
  echo "fzf-ai: could not parse selected row" >&2
  exit 2
fi

# With fzf cleanly torn down, the terminal is back in a normal state
# before we exec the AI CLI. This is what fixes the "input freeze" and
# "codex hangs" issues we saw with the `become` approach.
exec "$RESUME" "$agent" "$sid" "$source" "$cwd"
