Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changes/unreleased/1642-tab-slash-completion.md
Original file line number Diff line number Diff line change
@@ -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."
---
9 changes: 5 additions & 4 deletions internal/manual/chat/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>`,
- Enter or Tab takes the selected row. A command that takes words, such as `/model <slug>`,
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
Expand Down
4 changes: 2 additions & 2 deletions internal/manual/chat/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions internal/tui3/commandtab_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
10 changes: 9 additions & 1 deletion internal/tui3/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Expand Down
9 changes: 9 additions & 0 deletions internal/tui3/placekeys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading