From e0bb7f75ff064e1487865f281a61fbbe449e1b82 Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:33:43 -0400 Subject: [PATCH 1/2] Let Tab choose slash command menu entries --- internal/manual/chat/commands.md | 9 +-- internal/manual/chat/keys.md | 4 +- internal/tui3/commandtab_test.go | 100 +++++++++++++++++++++++++++++++ internal/tui3/input.go | 10 +++- internal/tui3/placekeys.go | 9 +++ 5 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 internal/tui3/commandtab_test.go diff --git a/internal/manual/chat/commands.md b/internal/manual/chat/commands.md index 9830bad720..ad19d68576 100644 --- a/internal/manual/chat/commands.md +++ b/internal/manual/chat/commands.md @@ -15,18 +15,19 @@ For example, `/res` selects `/resume`, although `/new` also matches its `reset` A filter with no matches shows `no commands match` and keeps unrelated results hidden. - ↑ / ctrl+p and ↓ / ctrl+n choose a row. PgUp / PgDown and the mouse wheel scroll. -- Enter takes the selected row. A command that takes words, such as `/model `, +- Enter or Tab takes the selected row. A command that takes words, such as `/model `, leaves `/model ` in the box ready for its argument. A bare command runs. - In a conversation, Esc dismisses the list and leaves the typed word; the list stays dismissed while you continue that token. On home, Esc clears the draft as usual. The list follows the caret as well as edits. The box remains editable while it is open. -A command chosen inside a sentence completes its token rather than running on its own; -The `/task` send tag retains its submission behavior. +A command chosen inside a sentence completes its token rather than running on its own. +Tab never submits that sentence or a finished `/task` or `/standing` tag; Enter retains +its send behavior. With no matching command, Tab leaves the draft and screen unchanged. On home, command rows describe what they will do there, including commands that open a conversation first. See *What each command does on home*. In a conversation, pointer -hover highlights a row but clicking does not execute it. Enter confirms the keyboard +hover highlights a row but clicking does not execute it. Enter or Tab confirms the keyboard selection. Commands entered in a conversation are kept in its ↑-history. ## Why a file path does not pop up the command list diff --git a/internal/manual/chat/keys.md b/internal/manual/chat/keys.md index b7296c2613..19e6c256bb 100644 --- a/internal/manual/chat/keys.md +++ b/internal/manual/chat/keys.md @@ -618,7 +618,7 @@ key arrives as ordinary `enter` and the message steers instead. | `ctrl+g` | A foreground command that can be kept takes the key first. Otherwise it does what `alt+l` does: close the column, or bring it back; the column stands even with no tasks in it. Remembered for the next session | | `ctrl+e` | Empty box: open or close the running conversation’s compact steps first; otherwise the newest `▸ worked` chip onto its outline of captions — the latest completed turn's out here, the newest completed work inside a task's page — or the most recent thinking block when there is no chip. A caption is a short status line per step; its tool rows are one expand further. Otherwise: go to end of line | | `pgup` / `pgdown` | Scroll one page — the height of the view minus one, never less than one row | -| `tab` | Open or commit path completion, over a command's path argument only — and over an **empty** box with no completion showing, go back to the last conversation. Does nothing when this terminal holds only one | +| `tab` | Take the highlighted slash command on Home or in a conversation; otherwise open or commit path completion over a command's path argument — and over an **empty** box with no completion showing, go back to the last conversation. Does nothing when this terminal holds only one | ## Keys in the message box: moving the caret @@ -1604,7 +1604,7 @@ follows what you type. Only these keys are taken from you: | `down` / `ctrl+n` | Move the list cursor down | | `esc` | Close the list. For the command list it also **seals that word** — the list does not reopen on the next letter of it. It does **not** interrupt a running turn | | `enter` | Command list: take the highlighted command. At the start of an otherwise empty box that **runs** it; anywhere else it replaces just that word with the command's name and runs nothing. If nothing matched, the line is sent as typed. `@` list: insert the highlighted team, conversation, task or file; if nothing is picked, the line is sent | -| `tab` | Read **before** the list. It only opens or commits an *argument* completion, over `/attach ` or `/export `. With nothing to complete and an empty box it goes back to the last conversation | +| `tab` | Command list on Home or in a conversation: take the highlighted command, as Enter does. Mid-sentence it only completes the word; a finished send tag is not submitted. No match leaves the draft alone. Otherwise opens or commits path completion over `/attach ` or `/export `; with an empty conversation box it goes back to the last conversation | | `enter`, with an argument completion open | Closes the list and runs the line **as typed**. Your path is never swapped for the top-ranked row | ## Keys in the model picker and the sessions roster diff --git a/internal/tui3/commandtab_test.go b/internal/tui3/commandtab_test.go new file mode 100644 index 0000000000..f60d8ecf08 --- /dev/null +++ b/internal/tui3/commandtab_test.go @@ -0,0 +1,100 @@ +package tui3 + +import "testing" + +// These keys enter through the app router so page navigation cannot silently +// take Tab before either composer's visible command list receives it. +func TestTabChoosesSlashCommandsInBothComposers(t *testing.T) { + for _, home := range []bool{false, true} { + place := "conversation" + if home { + place = "home" + } + for _, tc := range []struct { + name, typed, want string + settings bool + }{ + {name: "bare", typed: "/set", settings: true}, + {name: "argument", typed: "/mod", want: "/model "}, + {name: "mid sentence", typed: "look at /comp", want: "look at /compact"}, + {name: "finished tag", typed: "do this /task", want: "do this /task"}, + {name: "no match", typed: "/zzzzmissing", want: "/zzzzmissing"}, + } { + t.Run(place+"/"+tc.name, func(t *testing.T) { + agent := &fakeAgent{model: "m"} + a := newTestApp(agent) + if home { + a.openHome() + } + typeInto(t, a, tc.typed) + if tc.name == "argument" { + drive(t, a, key("down")) + } + drive(t, a, key("tab")) + if tc.settings { + if !a.at(pageSettings) { + t.Fatal("Tab did not open the highlighted settings command") + } + return + } + e := &a.input + if home { + if !a.at(pageHome) { + t.Fatal("Tab left Home while completing a command") + } + e = &a.home.box + } + if got := e.String(); got != tc.want { + t.Fatalf("draft = %q, want %q", got, tc.want) + } + if len(agent.sent) != 0 || agent.packs != 0 { + t.Fatal("completing a word submitted the draft or ran its embedded command") + } + }) + } + } +} + +func TestTabChoosesTheMovedSlashSelection(t *testing.T) { + for _, home := range []bool{false, true} { + a := newTestApp(&fakeAgent{model: "m"}) + if home { + a.openHome() + } + typeInto(t, a, "please explain /") + drive(t, a, key("down")) + chosen, ok := a.menu.choice() + if home { + line, found := a.home.focusedLine() + if !found || line.kind != homeCommand { + t.Fatal("Home did not select a command") + } + chosen, ok = *line.cmd, true + } + if !ok { + t.Fatal("no command selected") + } + drive(t, a, key("tab")) + got := a.input.String() + if home { + got = a.home.box.String() + } + if want := "please explain /" + chosen.name; got != want { + t.Fatalf("draft = %q, want moved selection %q", got, want) + } + } +} + +func TestTabCommandOnNewChatLeavesThePreviousChatAlone(t *testing.T) { + lab := newStartLab(t) + drive(t, lab.a, key("ctrl+t")) + typeInto(t, lab.a, "/mod") + drive(t, lab.a, key("down")) + drive(t, lab.a, key("tab")) + if got := lab.a.input.String(); got != "/model " { + t.Fatalf("draft = %q, want completed argument command", got) + } + if !lab.a.startingChat() || lab.made != 0 || len(lab.agent.sent) != 0 { + t.Fatal("Tab created or changed a conversation before the draft was sent") + } +} diff --git a/internal/tui3/input.go b/internal/tui3/input.go index 58f33d0d97..8c42895d58 100644 --- a/internal/tui3/input.go +++ b/internal/tui3/input.go @@ -735,7 +735,7 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { return cmd } - // tab is the path completion's key: "/image " with tab after it offers this + // Tab chooses an open command row first. Otherwise, "/attach " offers this // directory's files, and tab again takes the one under the cursor // (files.go). It is read before the lists below because everything above it // has already had its say. @@ -754,6 +754,14 @@ func (a *app) key(msg tea.KeyPressMsg) tea.Cmd { // things — one of them irreversible — and a person would arrive in another // conversation with a rail focus they cannot see. if msg.String() == "tab" { + if a.menu.open { + // Tab completes the chosen token without submitting a sentence or + // a live send tag. Bare commands still run through the menu's door. + if a.startingChat() { + return a.startMenuEnter() + } + return a.runMenu() + } if cmd := a.completePath(); cmd != nil { return cmd } diff --git a/internal/tui3/placekeys.go b/internal/tui3/placekeys.go index e2b7a4e6fe..c898d077c4 100644 --- a/internal/tui3/placekeys.go +++ b/internal/tui3/placekeys.go @@ -202,6 +202,15 @@ func (a *app) placeKey(msg tea.KeyPressMsg) (tea.Cmd, bool) { return nil, true case "tab": + if a.at(pageHome) && a.home.cmd.open { + // The visible command list owns completion before page navigation. + // An unmatched token stays editable instead of leaving Home. + if line, ok := a.home.focusedLine(); ok && line.kind == homeCommand { + a.touch() + return a.homeRunCommand(line), true + } + return nil, true + } // THE NEXT PLACE A PERSON CAN ACTUALLY GET INTO. A place that refuses to // open is walked past rather than walked into ([app.walkPage] tells the // whole story of what pressing `tab` on a fresh machine used to do). From 9d943d6df2ad5759444184361f6cb6f22aecc3cb Mon Sep 17 00:00:00 2001 From: ZeroPoint95 <329227198+ZeroPoint95@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:47:51 -0400 Subject: [PATCH 2/2] Document Tab completion in changelog for #1642 --- docs/changes/unreleased/1642-tab-slash-completion.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 docs/changes/unreleased/1642-tab-slash-completion.md diff --git a/docs/changes/unreleased/1642-tab-slash-completion.md b/docs/changes/unreleased/1642-tab-slash-completion.md new file mode 100644 index 0000000000..5961896425 --- /dev/null +++ b/docs/changes/unreleased/1642-tab-slash-completion.md @@ -0,0 +1,9 @@ +--- +kind: fixed +title: Tab selects the highlighted slash command on Home and in conversations +pr: 1642 +surface: [chat, docs] +invalidates: + - "Tab previously ignored the slash command list in a conversation and navigated away from Home. It now selects the highlighted command in both composers, including the new-chat screen: bare commands run, argument rows complete their prefix, and words inside a sentence complete without submitting it." + - "Tab with an unmatched slash command on Home previously switched pages. It now leaves the draft and screen unchanged, while Enter retains its existing submission behavior." +---