Skip to content
Closed
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
8 changes: 8 additions & 0 deletions docs/changes/unreleased/1563-slash-command-bare-completion.md
Original file line number Diff line number Diff line change
@@ -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."
---
5 changes: 3 additions & 2 deletions internal/manual/chat/starting-codeaf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<path>` 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 <path>` 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
Expand Down
30 changes: 2 additions & 28 deletions internal/tui3/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7514,35 +7514,9 @@ func (a *app) slash(line string) tea.Cmd {

case "workspace":
if rest == "" {
a.note("usage: /workspace <path>")
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
Expand Down
10 changes: 7 additions & 3 deletions internal/tui3/commandorder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,20 @@ 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)
got, ok := m.choice()
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)
}
}
}
3 changes: 3 additions & 0 deletions internal/tui3/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>"},
{name: "workspace", args: "<path>", 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
Expand Down Expand Up @@ -293,13 +294,15 @@ 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: "<rung>", 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: "<brief>", desc: "start work you can walk away from", door: sendDoorTask},
{name: "task", args: "solo <brief>", 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
// (crew.go): --best puts the strongest crew the allowed models make on it,
// --cheap the cheapest, and neither moves the next task.
{name: "task", args: "--best <brief>", desc: "…on the strongest crew allowed, this task only", door: sendDoorTask},
{name: "task", args: "--cheap <brief>", 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 <brief>`, which opened a planner that drew the whole graph before
Expand Down
3 changes: 3 additions & 0 deletions internal/tui3/contextmodal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
6 changes: 6 additions & 0 deletions internal/tui3/crew_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}
Expand All @@ -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")
Expand Down
15 changes: 15 additions & 0 deletions internal/tui3/folderact.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions internal/tui3/folderpick.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 3 additions & 3 deletions internal/tui3/folderplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/tui3/taskcommand.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions internal/tui3/workspacepick.go
Original file line number Diff line number Diff line change
@@ -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
}
55 changes: 55 additions & 0 deletions internal/tui3/workspacepick_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading