From b024b94de1beca928c081eca8bbce98f77925982 Mon Sep 17 00:00:00 2001 From: karan925 Date: Wed, 25 Feb 2026 00:02:11 -0800 Subject: [PATCH 1/3] fix(init): use fzf --expect for editor/ai actions so TUI apps get full terminal access --- lib/commands/init.sh | 65 +++++++++---- tests/init.bats | 213 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 255 insertions(+), 23 deletions(-) diff --git a/lib/commands/init.sh b/lib/commands/init.sh index d29f02f..ab335e8 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -83,7 +83,7 @@ __FUNC__() { shift local dir if [ "$#" -eq 0 ] && command -v fzf >/dev/null 2>&1; then - local _gtr_selection + local _gtr_selection _gtr_key _gtr_line _gtr_selection="$(command git gtr list --porcelain | fzf \ --delimiter=$'\t' \ --with-nth=2 \ @@ -94,13 +94,23 @@ __FUNC__() { --header='enter:cd │ ctrl-e:editor │ ctrl-a:ai │ ctrl-d:delete │ ctrl-y:copy │ ctrl-r:refresh' \ --preview='git -C {1} log --oneline --graph --color=always -15 2>/dev/null; echo "---"; git -C {1} status --short 2>/dev/null' \ --preview-window=right:50% \ - --bind='ctrl-e:execute(git gtr editor {2})' \ - --bind='ctrl-a:execute(git gtr ai {2})' \ - --bind='ctrl-d:execute(git gtr rm {2})+reload(git gtr list --porcelain)' \ - --bind='ctrl-y:execute(git gtr copy {2})' \ + --expect=ctrl-a,ctrl-e \ + --bind='ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)' \ + --bind='ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)' \ --bind='ctrl-r:reload(git gtr list --porcelain)')" || return 0 [ -z "$_gtr_selection" ] && return 0 - dir="$(printf '%s' "$_gtr_selection" | cut -f1)" + _gtr_key="$(head -1 <<< "$_gtr_selection")" + _gtr_line="$(sed -n '2p' <<< "$_gtr_selection")" + [ -z "$_gtr_line" ] && return 0 + # ctrl-a/ctrl-e: run after fzf exits (needs full terminal for TUI apps) + if [ "$_gtr_key" = "ctrl-a" ]; then + command git gtr ai "$(printf '%s' "$_gtr_line" | cut -f2)" + return $? + elif [ "$_gtr_key" = "ctrl-e" ]; then + command git gtr editor "$(printf '%s' "$_gtr_line" | cut -f2)" + return $? + fi + dir="$(printf '%s' "$_gtr_line" | cut -f1)" elif [ "$#" -eq 0 ]; then echo "Usage: __FUNC__ cd " >&2 echo "Tip: Install fzf for an interactive picker (https://github.com/junegunn/fzf)" >&2 @@ -183,7 +193,7 @@ __FUNC__() { shift local dir if [ "$#" -eq 0 ] && command -v fzf >/dev/null 2>&1; then - local _gtr_selection + local _gtr_selection _gtr_key _gtr_line _gtr_selection="$(command git gtr list --porcelain | fzf \ --delimiter=$'\t' \ --with-nth=2 \ @@ -194,13 +204,23 @@ __FUNC__() { --header='enter:cd │ ctrl-e:editor │ ctrl-a:ai │ ctrl-d:delete │ ctrl-y:copy │ ctrl-r:refresh' \ --preview='git -C {1} log --oneline --graph --color=always -15 2>/dev/null; echo "---"; git -C {1} status --short 2>/dev/null' \ --preview-window=right:50% \ - --bind='ctrl-e:execute(git gtr editor {2})' \ - --bind='ctrl-a:execute(git gtr ai {2})' \ - --bind='ctrl-d:execute(git gtr rm {2})+reload(git gtr list --porcelain)' \ - --bind='ctrl-y:execute(git gtr copy {2})' \ + --expect=ctrl-a,ctrl-e \ + --bind='ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)' \ + --bind='ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)' \ --bind='ctrl-r:reload(git gtr list --porcelain)')" || return 0 [ -z "$_gtr_selection" ] && return 0 - dir="$(printf '%s' "$_gtr_selection" | cut -f1)" + _gtr_key="$(head -1 <<< "$_gtr_selection")" + _gtr_line="$(sed -n '2p' <<< "$_gtr_selection")" + [ -z "$_gtr_line" ] && return 0 + # ctrl-a/ctrl-e: run after fzf exits (needs full terminal for TUI apps) + if [ "$_gtr_key" = "ctrl-a" ]; then + command git gtr ai "$(printf '%s' "$_gtr_line" | cut -f2)" + return $? + elif [ "$_gtr_key" = "ctrl-e" ]; then + command git gtr editor "$(printf '%s' "$_gtr_line" | cut -f2)" + return $? + fi + dir="$(printf '%s' "$_gtr_line" | cut -f1)" elif [ "$#" -eq 0 ]; then echo "Usage: __FUNC__ cd " >&2 echo "Tip: Install fzf for an interactive picker (https://github.com/junegunn/fzf)" >&2 @@ -297,14 +317,25 @@ function __FUNC__ --header='enter:cd │ ctrl-e:editor │ ctrl-a:ai │ ctrl-d:delete │ ctrl-y:copy │ ctrl-r:refresh' \ --preview='git -C {1} log --oneline --graph --color=always -15 2>/dev/null; echo "---"; git -C {1} status --short 2>/dev/null' \ --preview-window=right:50% \ - --bind='ctrl-e:execute(git gtr editor {2})' \ - --bind='ctrl-a:execute(git gtr ai {2})' \ - --bind='ctrl-d:execute(git gtr rm {2})+reload(git gtr list --porcelain)' \ - --bind='ctrl-y:execute(git gtr copy {2})' \ + --expect=ctrl-a,ctrl-e \ + --bind='ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)' \ + --bind='ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)' \ --bind='ctrl-r:reload(git gtr list --porcelain)') or return 0 test -z "$_gtr_selection"; and return 0 - set dir (string split \t -- "$_gtr_selection")[1] + # --expect gives two lines: key (index 1) and selection (index 2) + set -l _gtr_key "$_gtr_selection[1]" + set -l _gtr_line "$_gtr_selection[2]" + test -z "$_gtr_line"; and return 0 + # ctrl-a/ctrl-e: run after fzf exits (needs full terminal for TUI apps) + if test "$_gtr_key" = "ctrl-a" + command git gtr ai (string split \t -- "$_gtr_line")[2] + return $status + else if test "$_gtr_key" = "ctrl-e" + command git gtr editor (string split \t -- "$_gtr_line")[2] + return $status + end + set dir (string split \t -- "$_gtr_line")[1] else if test (count $argv) -eq 1 echo "Usage: __FUNC__ cd " >&2 echo "Tip: Install fzf for an interactive picker (https://github.com/junegunn/fzf)" >&2 diff --git a/tests/init.bats b/tests/init.bats index 2c5f844..7e4bbab 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -160,39 +160,240 @@ setup() { # ── fzf interactive picker ─────────────────────────────────────────────────── -@test "bash output includes fzf picker for cd with no args" { +# ── fzf: general setup ────────────────────────────────────────────────────── + +@test "bash output includes fzf detection for cd with no args" { run cmd_init bash [ "$status" -eq 0 ] [[ "$output" == *"command -v fzf"* ]] [[ "$output" == *"--prompt='Worktree> '"* ]] [[ "$output" == *"--with-nth=2"* ]] - [[ "$output" == *"ctrl-e:execute"* ]] } -@test "zsh output includes fzf picker for cd with no args" { +@test "zsh output includes fzf detection for cd with no args" { run cmd_init zsh [ "$status" -eq 0 ] [[ "$output" == *"command -v fzf"* ]] [[ "$output" == *"--prompt='Worktree> '"* ]] [[ "$output" == *"--with-nth=2"* ]] - [[ "$output" == *"ctrl-e:execute"* ]] } -@test "fish output includes fzf picker for cd with no args" { +@test "fish output includes fzf detection for cd with no args" { run cmd_init fish [ "$status" -eq 0 ] [[ "$output" == *"type -q fzf"* ]] [[ "$output" == *"--prompt='Worktree> '"* ]] [[ "$output" == *"--with-nth=2"* ]] - [[ "$output" == *"ctrl-e:execute"* ]] } +# ── fzf: header shows all keybindings ─────────────────────────────────────── + +@test "bash fzf header lists all keybindings" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"enter:cd"* ]] + [[ "$output" == *"ctrl-e:editor"* ]] + [[ "$output" == *"ctrl-a:ai"* ]] + [[ "$output" == *"ctrl-d:delete"* ]] + [[ "$output" == *"ctrl-y:copy"* ]] + [[ "$output" == *"ctrl-r:refresh"* ]] +} + +@test "zsh fzf header lists all keybindings" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"enter:cd"* ]] + [[ "$output" == *"ctrl-e:editor"* ]] + [[ "$output" == *"ctrl-a:ai"* ]] + [[ "$output" == *"ctrl-d:delete"* ]] + [[ "$output" == *"ctrl-y:copy"* ]] + [[ "$output" == *"ctrl-r:refresh"* ]] +} + +@test "fish fzf header lists all keybindings" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"enter:cd"* ]] + [[ "$output" == *"ctrl-e:editor"* ]] + [[ "$output" == *"ctrl-a:ai"* ]] + [[ "$output" == *"ctrl-d:delete"* ]] + [[ "$output" == *"ctrl-y:copy"* ]] + [[ "$output" == *"ctrl-r:refresh"* ]] +} + +# ── fzf: enter (cd) ───────────────────────────────────────────────────────── + +@test "bash fzf enter extracts path from selection field 1 and cd" { + run cmd_init bash + [ "$status" -eq 0 ] + # Selection is parsed with cut -f1 to get path, then cd + [[ "$output" == *'cut -f1'* ]] + [[ "$output" == *'cd "$dir"'* ]] +} + +@test "zsh fzf enter extracts path from selection field 1 and cd" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *'cut -f1'* ]] + [[ "$output" == *'cd "$dir"'* ]] +} + +@test "fish fzf enter extracts path from selection and cd" { + run cmd_init fish + [ "$status" -eq 0 ] + # Fish uses string split or cut to extract path + [[ "$output" == *'cd '* ]] +} + +# ── fzf: ctrl-e (editor) — via --expect ────────────────────────────────────── + +@test "bash fzf ctrl-e handled via --expect for full terminal access" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr editor'* ]] +} + +@test "zsh fzf ctrl-e handled via --expect for full terminal access" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr editor'* ]] +} + +@test "fish fzf ctrl-e handled via --expect for full terminal access" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr editor'* ]] +} + +# ── fzf: ctrl-a (ai) — via --expect ───────────────────────────────────────── + +@test "bash fzf ctrl-a runs git gtr ai after fzf exits" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr ai'* ]] +} + +@test "zsh fzf ctrl-a runs git gtr ai after fzf exits" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr ai'* ]] +} + +@test "fish fzf ctrl-a runs git gtr ai after fzf exits" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"--expect=ctrl-a,ctrl-e"* ]] + [[ "$output" == *'git gtr ai'* ]] +} + +# ── fzf: ctrl-d (delete + reload) ─────────────────────────────────────────── + +@test "bash fzf ctrl-d runs git gtr rm and reloads list" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)"* ]] +} + +@test "zsh fzf ctrl-d runs git gtr rm and reloads list" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)"* ]] +} + +@test "fish fzf ctrl-d runs git gtr rm and reloads list" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-d:execute(git gtr rm {2} > /dev/tty 2>&1 < /dev/tty)+reload(git gtr list --porcelain)"* ]] +} + +# ── fzf: ctrl-y (copy) ────────────────────────────────────────────────────── + +@test "bash fzf ctrl-y runs git gtr copy on selected branch" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)"* ]] +} + +@test "zsh fzf ctrl-y runs git gtr copy on selected branch" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)"* ]] +} + +@test "fish fzf ctrl-y runs git gtr copy on selected branch" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-y:execute(git gtr copy {2} > /dev/tty 2>&1 < /dev/tty)"* ]] +} + +# ── fzf: ctrl-r (refresh) ─────────────────────────────────────────────────── + +@test "bash fzf ctrl-r reloads worktree list" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-r:reload(git gtr list --porcelain)"* ]] +} + +@test "zsh fzf ctrl-r reloads worktree list" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-r:reload(git gtr list --porcelain)"* ]] +} + +@test "fish fzf ctrl-r reloads worktree list" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"ctrl-r:reload(git gtr list --porcelain)"* ]] +} + +# ── fzf: preview window ───────────────────────────────────────────────────── + +@test "bash fzf preview shows git log and status" { + run cmd_init bash + [ "$status" -eq 0 ] + [[ "$output" == *"--preview="* ]] + [[ "$output" == *"git -C {1} log --oneline --graph --color=always"* ]] + [[ "$output" == *"git -C {1} status --short"* ]] + [[ "$output" == *"--preview-window=right:50%"* ]] +} + +@test "zsh fzf preview shows git log and status" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *"--preview="* ]] + [[ "$output" == *"git -C {1} log --oneline --graph --color=always"* ]] + [[ "$output" == *"git -C {1} status --short"* ]] + [[ "$output" == *"--preview-window=right:50%"* ]] +} + +@test "fish fzf preview shows git log and status" { + run cmd_init fish + [ "$status" -eq 0 ] + [[ "$output" == *"--preview="* ]] + [[ "$output" == *"git -C {1} log --oneline --graph --color=always"* ]] + [[ "$output" == *"git -C {1} status --short"* ]] + [[ "$output" == *"--preview-window=right:50%"* ]] +} + +# ── fzf: fallback messages ────────────────────────────────────────────────── + @test "bash output shows fzf install hint when no args and no fzf" { run cmd_init bash [ "$status" -eq 0 ] [[ "$output" == *'Install fzf for an interactive picker'* ]] } +@test "zsh output shows fzf install hint when no args and no fzf" { + run cmd_init zsh + [ "$status" -eq 0 ] + [[ "$output" == *'Install fzf for an interactive picker'* ]] +} + @test "fish output shows fzf install hint when no args and no fzf" { run cmd_init fish [ "$status" -eq 0 ] From bb0a3a2bcf65fdc2ac903fc12240cc451dd03628 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 25 Feb 2026 16:13:08 -0800 Subject: [PATCH 2/3] fix(init): resolve broken merge in bash/zsh fzf blocks The merge of main into fix-open-ai inserted new --expect code above the existing porcelain/empty-state logic instead of replacing it, leaving a dangling _gtr_selection subshell and duplicate variable declarations that produced syntax errors in the generated shell code. Move the _gtr_key/_gtr_line declarations to after the empty-state guard and remove the orphaned half-finished assignment lines so bash -n passes cleanly on the generated init output. --- lib/commands/init.sh | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/commands/init.sh b/lib/commands/init.sh index 58e1e57..1b3f6e5 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -83,15 +83,13 @@ __FUNC__() { shift local dir if [ "$#" -eq 0 ] && command -v fzf >/dev/null 2>&1; then - local _gtr_selection _gtr_key _gtr_line - _gtr_selection="$(command git gtr list --porcelain | fzf \ local _gtr_porcelain _gtr_porcelain="$(command git gtr list --porcelain)" if [ "$(printf '%s\n' "$_gtr_porcelain" | wc -l)" -le 1 ]; then echo "No worktrees to pick from. Create one with: git gtr new " >&2 return 0 fi - local _gtr_selection + local _gtr_selection _gtr_key _gtr_line _gtr_selection="$(printf '%s\n' "$_gtr_porcelain" | fzf \ --delimiter=$'\t' \ --with-nth=2 \ @@ -201,15 +199,13 @@ __FUNC__() { shift local dir if [ "$#" -eq 0 ] && command -v fzf >/dev/null 2>&1; then - local _gtr_selection _gtr_key _gtr_line - _gtr_selection="$(command git gtr list --porcelain | fzf \ local _gtr_porcelain _gtr_porcelain="$(command git gtr list --porcelain)" if [ "$(printf '%s\n' "$_gtr_porcelain" | wc -l)" -le 1 ]; then echo "No worktrees to pick from. Create one with: git gtr new " >&2 return 0 fi - local _gtr_selection + local _gtr_selection _gtr_key _gtr_line _gtr_selection="$(printf '%s\n' "$_gtr_porcelain" | fzf \ --delimiter=$'\t' \ --with-nth=2 \ From 02e692cb0c71e4cba76578f82965833e1f370ea8 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Wed, 25 Feb 2026 16:31:30 -0800 Subject: [PATCH 3/3] fix(init): handle Fish empty-line collapse in fzf --expect output Fish command substitution collapses empty lines, so when Enter is pressed with --expect, the empty key line disappears and the array has only 1 element instead of 2. Detect this by checking count and adjust indices accordingly. Also strengthen the fish enter test to validate string split + set dir parsing rather than just checking for 'cd '. --- lib/commands/init.sh | 11 +++++++++-- tests/init.bats | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/commands/init.sh b/lib/commands/init.sh index 1b3f6e5..2c6fedc 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -341,8 +341,15 @@ function __FUNC__ or return 0 test -z "$_gtr_selection"; and return 0 # --expect gives two lines: key (index 1) and selection (index 2) - set -l _gtr_key "$_gtr_selection[1]" - set -l _gtr_line "$_gtr_selection[2]" + # Fish collapses empty lines in command substitution, so when Enter + # is pressed the empty key line disappears and count drops to 1. + if test (count $_gtr_selection) -eq 1 + set -l _gtr_key "" + set -l _gtr_line "$_gtr_selection[1]" + else + set -l _gtr_key "$_gtr_selection[1]" + set -l _gtr_line "$_gtr_selection[2]" + end test -z "$_gtr_line"; and return 0 # ctrl-a/ctrl-e: run after fzf exits (needs full terminal for TUI apps) if test "$_gtr_key" = "ctrl-a" diff --git a/tests/init.bats b/tests/init.bats index 7e4bbab..8741fac 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -241,8 +241,10 @@ setup() { @test "fish fzf enter extracts path from selection and cd" { run cmd_init fish [ "$status" -eq 0 ] - # Fish uses string split or cut to extract path - [[ "$output" == *'cd '* ]] + # Fish uses string split to extract path, then cd + [[ "$output" == *'string split'* ]] + [[ "$output" == *'set dir'* ]] + [[ "$output" == *'cd $dir'* ]] } # ── fzf: ctrl-e (editor) — via --expect ──────────────────────────────────────