diff --git a/docs/changes/unreleased/1563-slash-command-bare-completion.md b/docs/changes/unreleased/1563-slash-command-bare-completion.md new file mode 100644 index 0000000000..4b70fd04aa --- /dev/null +++ b/docs/changes/unreleased/1563-slash-command-bare-completion.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: Slash command completion executes bare /task, /redo, and /workspace immediately +pr: 1563 +surface: [chat] +invalidates: + - "Selecting /task, /redo, or /workspace from the slash command completion menu previously inserted the command prefix with a trailing space into the prompt instead of executing or opening the destination view. Selecting bare /task now opens the task roster immediately, bare /redo reruns on a stronger crew immediately, and bare /workspace opens a picker that anchors a project-less conversation through the same action as a typed workspace path." +--- diff --git a/internal/manual/chat/starting-codeaf.md b/internal/manual/chat/starting-codeaf.md index cb788ebb46..ebebdb55b9 100644 --- a/internal/manual/chat/starting-codeaf.md +++ b/internal/manual/chat/starting-codeaf.md @@ -710,8 +710,9 @@ were made. It is not a way to rescue the work. ## Anchor a conversation to a repository or folder — /workspace and the workspace tool -When a conversation says `codeaf` because it opened with no project, type `/workspace -` to make the repository or folder at that path its project. The path may begin with +When a conversation says `codeaf` because it opened with no project, type `/workspace` +to choose a folder and confirm **set workspace**, or `/workspace ` to name it +directly. Cancelling the picker changes nothing; files cannot be workspace anchors. The path may begin with `~`; a path inside a Git repository resolves to the repository root. The place line changes, the project's `AGENTS.md` and `CLAUDE.md` are loaded into the conversation instructions, and future tasks cut their working copies from that repository rather than from the diff --git a/internal/tui3/app.go b/internal/tui3/app.go index d63a807534..1c62bb7475 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -7514,35 +7514,9 @@ func (a *app) slash(line string) tea.Cmd { case "workspace": if rest == "" { - a.note("usage: /workspace ") - return nil - } - if a.anchorWorkspace == nil { - a.note("this conversation already has a workspace") - return nil + return a.openWorkspacePick() } - resolved, err := a.anchorWorkspace(rest) - if err != nil { - a.note("could not set the workspace: " + err.Error()) - return nil - } - a.workspace = resolved - a.owned = false - a.place = placeShown(resolved, false, a.host) - // THE BRANCH IS ASKED FOR, NOT WAITED ON. This used to call the probe - // straight — two `git` processes under one four-hundred-millisecond - // ceiling, run on the update loop, so a person who typed `/workspace` - // into a large repository watched the whole surface stop for up to four - // tenths of a second before their own keystroke was drawn. It takes the - // road every other reading of the repository takes ([app.probeGit], - // armed at `open` and at every turn end): the command runs off the loop - // and the branch arrives as a gitMsg, which is exactly the same nothing - // the legend draws until a probe answers. - a.branch, a.branchDirty = "", false - a.anchorWorkspace = nil - a.note("workspace · " + a.hostedPath(resolved)) - a.touch() - return a.probeGit() + return a.setWorkspace(rest) case "folder": // WHICH FOLDER DO YOU MEAN, asked at any moment. Bare, it is the picker diff --git a/internal/tui3/commandorder_test.go b/internal/tui3/commandorder_test.go index 4e1636f42b..f3a92f0876 100644 --- a/internal/tui3/commandorder_test.go +++ b/internal/tui3/commandorder_test.go @@ -23,7 +23,7 @@ func TestStandingOffersTheWordsFormFirstWhileTyping(t *testing.T) { } func TestAFinishedCommandWordChoosesItsBareForm(t *testing.T) { - for _, word := range []string{"standing", "orders"} { + for _, word := range []string{"standing", "orders", "task", "redo", "workspace"} { var m menu m.open = true m.rank(word) @@ -31,8 +31,12 @@ func TestAFinishedCommandWordChoosesItsBareForm(t *testing.T) { if !ok { t.Fatalf("%q matched nothing", word) } - if got.name != "standing" || got.args != "" { - t.Fatalf("enter on the finished word %q would take /%s %q — want the bare form, the page", word, got.name, got.args) + want := word + if word == "orders" { + want = "standing" + } + if got.name != want || got.args != "" { + t.Fatalf("enter on the finished word %q would take /%s %q — want the bare form", word, got.name, got.args) } } } diff --git a/internal/tui3/commands.go b/internal/tui3/commands.go index 07b9c5a081..729b5164fb 100644 --- a/internal/tui3/commands.go +++ b/internal/tui3/commands.go @@ -90,6 +90,7 @@ var commands = []command{ // A project-less conversation needs this once, while /compact is a daily // command everywhere. Keep the one-shot anchor immediately below the eight // always-visible rows so adding it does not hide /compact behind a scroll. + {name: "workspace", desc: "choose a folder to anchor this conversation · or /workspace "}, {name: "workspace", args: "", desc: "anchor this conversation to a project"}, // AND THE OTHER HALF OF THE SAME ERRAND, directly under it: /workspace is // the one-shot anchor a project-less conversation needs once, and this is @@ -293,6 +294,7 @@ var commands = []command{ {name: "effort", desc: "how hard this conversation thinks · the five rungs, and what each buys", alias: []string{"think", "thinking"}}, {name: "effort", args: "", desc: "…set it outright · " + effortKey + " walks it, or press it on the seam"}, + {name: "task", desc: "every task this machine has run · ctrl+.", door: sendDoorTask}, {name: "task", args: "", desc: "start work you can walk away from", door: sendDoorTask}, {name: "task", args: "solo ", desc: "…with one worker, and no sizing call before it", door: sendDoorTask}, // HOW HARD TO TRY THIS ONE TASK is said in the ask and sticks to nothing @@ -300,6 +302,7 @@ var commands = []command{ // --cheap the cheapest, and neither moves the next task. {name: "task", args: "--best ", desc: "…on the strongest crew allowed, this task only", door: sendDoorTask}, {name: "task", args: "--cheap ", desc: "…on the cheapest crew allowed, this task only", door: sendDoorTask}, + {name: "redo", desc: "the last task again, on a stronger crew"}, {name: "redo", args: "stronger", desc: "the last task again, on a stronger crew"}, // THE THIRD ROW IS GONE, AND ITS ABSENCE IS THE FEATURE. It typed // `adaptive `, which opened a planner that drew the whole graph before diff --git a/internal/tui3/contextmodal.go b/internal/tui3/contextmodal.go index 8bd8e9fb5f..250367b7cd 100644 --- a/internal/tui3/contextmodal.go +++ b/internal/tui3/contextmodal.go @@ -344,6 +344,9 @@ func contextGlyphs(pal palette) contextGlyph { // manual the same question. func (a *app) contextHeadRule(inner int, glyph contextGlyph) string { title := contextTitleWord + if a.folder.forWorkspace { + title = "choose workspace" + } if a.folder.forTarget { // THE SHEET HOME OPENED IS NOT ADDING CONTEXT TO ANYTHING. It is choosing // where the next conversation opens (folderpick.go's [folderPick.forTarget]), diff --git a/internal/tui3/crew_test.go b/internal/tui3/crew_test.go index ba51365216..c2683e2770 100644 --- a/internal/tui3/crew_test.go +++ b/internal/tui3/crew_test.go @@ -223,6 +223,7 @@ func TestTaskEffortWordsReachTheSession(t *testing.T) { } // `/redo stronger` is the one form of /redo, and it reaches the session. +// Bare `/redo` defaults to stronger. func TestRedoStrongerReachesTheSession(t *testing.T) { a, _ := sheetApp(t) door := &crewEffortAgent{fakeAgent: &fakeAgent{model: "openai/gpt-4.1-mini"}} @@ -232,6 +233,11 @@ func TestRedoStrongerReachesTheSession(t *testing.T) { t.Fatal("/redo stronger never reached the session") } door.redone = false + runCmd(a.runRedo("")) + if !door.redone { + t.Fatal("bare /redo never reached the session") + } + door.redone = false runCmd(a.runRedo("harder")) if door.redone { t.Fatal("/redo with another word reached the session") diff --git a/internal/tui3/folderact.go b/internal/tui3/folderact.go index 56f598c3ca..ec8c9a73d0 100644 --- a/internal/tui3/folderact.go +++ b/internal/tui3/folderact.go @@ -104,6 +104,9 @@ func (f *folderPick) markAt(path string) int { // THE ORDER IS THE ORDER THEY WERE MARKED IN and marks are appended rather than // sorted, for the reason this file's header gives about `[image #2]`. func (f *folderPick) mark() string { + if f.forWorkspace { + return "choose one workspace folder with enter" + } path, dir, ok := f.hereKind() if !ok { return "" @@ -223,6 +226,18 @@ type folderAdded struct { // what it would do. It is one function because the key and the row must not be // able to drift into meaning two different things. func (a *app) folderConfirm() tea.Cmd { + if a.folder.forWorkspace { + path, dir, ok := a.folder.hereKind() + if !ok || !dir { + a.note("choose a folder for the workspace") + return nil + } + cmd := a.setWorkspace(path) + if a.anchorWorkspace != nil { + return cmd + } // Keep the choice on a refused anchor. + return tea.Batch(a.closeFolderSheet(), cmd) + } takes := a.folder.takes() if len(takes) == 0 { return a.closeFolderSheet() diff --git a/internal/tui3/folderpick.go b/internal/tui3/folderpick.go index 87a2f01c46..a84e303bd3 100644 --- a/internal/tui3/folderpick.go +++ b/internal/tui3/folderpick.go @@ -207,6 +207,8 @@ type folderPick struct { // never offers to remove one ([app.openTargetContextPick] leaves the map // empty for exactly that reason). forTarget bool + // forWorkspace chooses the current conversation's one-shot project anchor. + forWorkspace bool // hidden reveals the dot-directories and the names the `@` walk prunes. // It is off by default and it is a person's own act — alt+h, or a name @@ -1427,6 +1429,11 @@ func (g folderGeom) actionAt() int { return g.action } // matters most here (folderact.go's header). func (f *folderPick) actionWord(path string, dir bool) string { switch { + case f.forWorkspace: + if dir { + return "set workspace · " + } + return "choose a folder · " case dir && f.forTarget: // THE SHEET OPENED FROM HOME IS ABOUT A CONVERSATION THAT DOES NOT EXIST // YET, and `add this folder` would be a promise about the one behind home @@ -1731,6 +1738,9 @@ func (f *folderPick) folderHintAt(room int) string { // controlLegend keeps cancel and the narrow preview door ahead of optional hints. func (f *folderPick) controlLegend(room int) string { + if f.forWorkspace && f.pane != folderPaneWide { + return rowTail([]rowField{rowSay("esc cancel"), rowSay("enter set workspace"), rowSay("←→ walk"), rowSay("ctrl+u search")}, room) + } if !f.browsing { return rowTail([]rowField{rowSay("esc cancel"), rowSay("→ open"), rowSay("enter add")}, room) } diff --git a/internal/tui3/folderplace.go b/internal/tui3/folderplace.go index f3d6cc134c..34669f863b 100644 --- a/internal/tui3/folderplace.go +++ b/internal/tui3/folderplace.go @@ -1189,7 +1189,7 @@ func (a *app) tookFolderStore(msg folderStoreMsg) tea.Cmd { hidden, gen, cols := a.folder.hidden, a.folder.gen, a.folder.cols marks, pane := a.folder.marks, a.folder.pane paneTop, paneLeft := a.folder.paneTop, a.folder.paneLeft - forTarget := a.folder.forTarget + forTarget, forWorkspace := a.folder.forTarget, a.folder.forWorkspace a.folder.start(a.folderCandidates(), a.tilde) a.folder.filter, a.folder.facts = filter, facts a.folder.kids, a.folder.asking, a.folder.hidden = kids, asking, hidden @@ -1201,8 +1201,8 @@ func (a *app) tookFolderStore(msg folderStoreMsg) tea.Cmd { // of a launch into `add context` a second after it opened, so the folder // chosen on it went to the conversation BEHIND home. Its sheet holds nothing // either, for the reason that function gives. - a.folder.forTarget = forTarget - if !forTarget { + a.folder.forTarget, a.folder.forWorkspace = forTarget, forWorkspace + if !forTarget && !forWorkspace { a.markFolderHeld() } // The COLUMNS are kept whole and not re-seated: which level they are on and diff --git a/internal/tui3/taskcommand.go b/internal/tui3/taskcommand.go index 353c3ae155..7b030884a5 100644 --- a/internal/tui3/taskcommand.go +++ b/internal/tui3/taskcommand.go @@ -46,7 +46,11 @@ type redoAgent interface { // told the first crew under-served this kind of work here, so the next task // like it starts a step higher until enough accepted work decays it back. func (a *app) runRedo(arg string) tea.Cmd { - if strings.ToLower(strings.TrimSpace(arg)) != "stronger" { + arg = strings.ToLower(strings.TrimSpace(arg)) + if arg == "" { + arg = "stronger" + } + if arg != "stronger" { a.note("usage: /redo stronger") return nil } diff --git a/internal/tui3/workspacepick.go b/internal/tui3/workspacepick.go new file mode 100644 index 0000000000..de3a561a52 --- /dev/null +++ b/internal/tui3/workspacepick.go @@ -0,0 +1,50 @@ +package tui3 + +import tea "charm.land/bubbletea/v2" + +// setWorkspace is shared by a typed path and the workspace picker. +func (a *app) setWorkspace(rest string) tea.Cmd { + if a.anchorWorkspace == nil { + a.note("this conversation already has a workspace") + return nil + } + resolved, err := a.anchorWorkspace(rest) + if err != nil { + a.note("could not set the workspace: " + err.Error()) + return nil + } + a.workspace = resolved + a.owned = false + a.place = placeShown(resolved, false, a.host) + // THE BRANCH IS ASKED FOR, NOT WAITED ON. This used to call the probe + // straight — two `git` processes under one four-hundred-millisecond + // ceiling, run on the update loop, so a person who typed `/workspace` + // into a large repository watched the whole surface stop for up to four + // tenths of a second before their own keystroke was drawn. It takes the + // road every other reading of the repository takes ([app.probeGit], + // armed at `open` and at every turn end): the command runs off the loop + // and the branch arrives as a gitMsg, which is exactly the same nothing + // the legend draws until a probe answers. + a.branch, a.branchDirty = "", false + a.anchorWorkspace = nil + a.note("workspace · " + a.hostedPath(resolved)) + a.touch() + return a.probeGit() +} + +func (a *app) openWorkspacePick() tea.Cmd { + if a.anchorWorkspace == nil { + a.note("this conversation already has a workspace") + return nil + } + if a.hosted() { + a.note(folderRemoteWord) + return nil + } + cmd := a.openContextPick("", false) + if a.folder.open { + a.folder.forWorkspace = true + a.folder.held = nil + } + return cmd +} diff --git a/internal/tui3/workspacepick_test.go b/internal/tui3/workspacepick_test.go new file mode 100644 index 0000000000..be75612b74 --- /dev/null +++ b/internal/tui3/workspacepick_test.go @@ -0,0 +1,55 @@ +package tui3 + +import ( + "errors" + "path/filepath" + "testing" +) + +func TestWorkspacePickerAnchorsThroughWorkspaceDoor(t *testing.T) { + a, root := folderLab(t) + target := filepath.Join(root, "sibling") + var anchored string + a.anchorWorkspace = func(path string) (string, error) { anchored = path; return path, nil } + a.slash("/workspace") + if !a.folder.open || !a.folder.forWorkspace { + t.Fatal("workspace picker did not open") + } + a.tookFolderStore(folderStoreMsg{}) + if !a.folder.forWorkspace { + t.Fatal("late store changed picker purpose") + } + a.folder.browsing = false + for i, hit := range a.folder.hits { + if a.folder.all[hit].path == target { + a.folder.cursor = i + break + } + } + if got := a.folder.actionWord(target, true); got != "set workspace · " { + t.Fatalf("wrong action: %s", got) + } + a.folderConfirm() + if anchored != target || a.workspace != target || a.anchorWorkspace != nil || a.folder.open { + t.Fatalf("anchor=%q workspace=%q open=%v", anchored, a.workspace, a.folder.open) + } + a.slash("/workspace") + if a.folder.open { + t.Fatal("already anchored conversation offered another anchor") + } +} + +func TestWorkspacePickerRefusalKeepsChoice(t *testing.T) { + a, _ := folderLab(t) + a.anchorWorkspace = func(string) (string, error) { return "", errors.New("cannot anchor") } + original := a.workspace + a.slash("/workspace") + a.folderConfirm() + if !a.folder.open || a.workspace != original || a.anchorWorkspace == nil { + t.Fatal("failed anchor changed workspace or discarded choice") + } + a.closeFolderSheet() + if a.workspace != original { + t.Fatal("cancel changed workspace") + } +}