diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index 493310c5a..f5f2f94b4 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -86,7 +86,7 @@ Type `/` during a session to see available commands, or press Ctrl+Enter on a live session to explicitly compact it, or d on an attached file to drop it | -| `/drop` | Remove an attached file from the session context (`/drop `, or `/drop` alone to review and drop from the `/context` dialog) | +| `/drop` | Remove an attached file from the session context (`/drop `, or `/drop` alone to review and drop from the `/context` dialog). Press Tab after `/drop` and a space to complete an attached file's path | | `/cost` | Show cost breakdown for this session. Includes a **By Agent** section (alongside **By Model**) showing cumulative cost per agent; unattributed usage and compaction spend appear in their own buckets. | | `/eval` | Create an evaluation report | | `/pause` | Pause/resume the runtime loop. While the agent is mid-request, the resize handle shows "Pausing…" until the in-flight request completes; once the loop is blocked the indicator changes to "⏸ Paused". Run `/pause` again to resume. | @@ -261,7 +261,7 @@ Explain what the code in @pkg/agent/agent.go does The agent receives the full file contents in a structured `` block, while the UI shows just the reference. -Attached files are also recorded on the session so sub-agents spawned by task transfer can read them. To review what is attached, open `/context`: the dialog lists every attached file (and resolved prompt file) with a per-file token estimate. Use / to select an attached file and press d (or x/Del) to drop it, or run `/drop ` directly. Dropping stops sharing the file with sub-agents and skills; content already inlined in earlier messages stays in the conversation until compaction, and the file can always be re-attached with `@` or `/attach`. +Attached files are also recorded on the session so sub-agents spawned by task transfer can read them. To review what is attached, open `/context`: the dialog lists every attached file (and resolved prompt file) with a per-file token estimate. Use / to select an attached file and press d (or x/Del) to drop it, or run `/drop ` directly — press Tab after `/drop` and a space to complete the path from the currently attached files. Dropping stops sharing the file with sub-agents and skills; content already inlined in earlier messages stays in the conversation until compaction, and the file can always be re-attached with `@` or `/attach`. ### Team Context Budgets and Targeted Compaction diff --git a/pkg/app/app.go b/pkg/app/app.go index 8968f3af2..8bf99901f 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -1172,6 +1172,18 @@ func (a *App) CompactLiveSession(ctx context.Context, sessionID, additionalPromp return compactor.CompactLiveSession(ctx, sessionID, additionalPrompt, sink) } +// AttachedFiles returns the current session's attached file paths, the same +// inventory DropAttachedFile resolves against. Backs /drop argument +// completion; nil when there is no active session. Attachments are +// in-memory client-side session state, so this works on remote runtimes too. +func (a *App) AttachedFiles() []string { + sess := a.Session() + if sess == nil { + return nil + } + return sess.AttachedFilesSnapshot() +} + // DropAttachedFile removes a file from the current session's attached files // and returns the absolute path that was dropped. The path is resolved // against the session's attachment list: exact match first, then the diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index c9ab6aa86..614085c5e 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -695,6 +695,44 @@ func attachEffortCompletion(items []Item, ctx context.Context, source effortLeve } } +// attachedFilesSource is the minimal surface dropCandidates needs. *app.App +// satisfies it; tests can supply a stub instead of constructing a full +// application. +type attachedFilesSource interface { + AttachedFiles() []string +} + +// dropCandidates returns one ArgumentCandidate per file currently attached to +// the session, in attachment order. Labels are the exact recorded (absolute) +// paths: completing them hits the exact-match branch of resolveAttachedFile, +// and paths containing spaces stay safe because the completion Value carries +// the full command line, not just the label. Every attached file is +// droppable, so Disabled never applies here. +func dropCandidates(source attachedFilesSource) []ArgumentCandidate { + attached := source.AttachedFiles() + candidates := make([]ArgumentCandidate, 0, len(attached)) + for _, path := range attached { + candidates = append(candidates, ArgumentCandidate{Label: path}) + } + return candidates +} + +// attachDropCompletion wires the attached-file argument completer onto the +// /drop item. Attached post-hoc (rather than inline in +// builtInSessionCommands) so that function stays free of any +// attached-files-source dependency. +func attachDropCompletion(items []Item, source attachedFilesSource) { + for i := range items { + if items[i].ID != "session.drop" { + continue + } + items[i].CompleteArgument = func() []ArgumentCandidate { + return dropCandidates(source) + } + return + } +} + // BuildCommandCategories builds the list of command categories for the command palette func BuildCommandCategories(ctx context.Context, application *app.App) []Category { // Get session commands and filter based on model capabilities @@ -704,6 +742,7 @@ func BuildCommandCategories(ctx context.Context, application *app.App) []Categor } attachToolsetRestartCompletion(sessionCommands, application) attachEffortCompletion(sessionCommands, ctx, application) + attachDropCompletion(sessionCommands, application) categories := []Category{ { diff --git a/pkg/tui/commands/commands_test.go b/pkg/tui/commands/commands_test.go index 78260e3d3..c62f4fc51 100644 --- a/pkg/tui/commands/commands_test.go +++ b/pkg/tui/commands/commands_test.go @@ -497,3 +497,93 @@ func TestAttachEffortCompletion_QueriesFreshOnEachCall(t *testing.T) { assert.Equal(t, "none", second[0].Label) assert.Equal(t, "xhigh", second[5].Label) } + +// stubAttachedFilesSource is a minimal attachedFilesSource for exercising +// dropCandidates without a real *app.App. +type stubAttachedFilesSource struct { + paths []string +} + +func (s *stubAttachedFilesSource) AttachedFiles() []string { + return s.paths +} + +func TestDropCandidates_ReturnsAttachedFilesInOrder(t *testing.T) { + t.Parallel() + + source := &stubAttachedFilesSource{paths: []string{"/repo/notes.md", "/repo/path with spaces.txt"}} + + candidates := dropCandidates(source) + require.Len(t, candidates, 2) + assert.Equal(t, "/repo/notes.md", candidates[0].Label) + assert.False(t, candidates[0].Disabled, "every attached file is droppable") + assert.Equal(t, "/repo/path with spaces.txt", candidates[1].Label) + assert.False(t, candidates[1].Disabled) +} + +func TestDropCandidates_NoAttachmentsIsEmpty(t *testing.T) { + t.Parallel() + + assert.Empty(t, dropCandidates(&stubAttachedFilesSource{})) +} + +func TestAttachDropCompletion(t *testing.T) { + t.Parallel() + + items := builtInSessionCommands() + source := &stubAttachedFilesSource{paths: []string{"/repo/notes.md"}} + attachDropCompletion(items, source) + + var drop, other *Item + for i := range items { + switch items[i].ID { + case "session.drop": + drop = &items[i] + case "session.exit": + other = &items[i] + } + } + require.NotNil(t, drop) + require.NotNil(t, drop.CompleteArgument, "the drop item must get a completer") + candidates := drop.CompleteArgument() + require.Len(t, candidates, 1) + assert.Equal(t, "/repo/notes.md", candidates[0].Label) + + require.NotNil(t, other) + assert.Nil(t, other.CompleteArgument, "commands without a provider keep a nil CompleteArgument") +} + +// TestAttachDropCompletion_QueriesFreshOnEachCall is a regression test for +// bug 2 (PR #3728, mirrored for /drop): the attached CompleteArgument +// closure must query the attached-files source live on every call, not +// capture a snapshot at attach time. Attachments change between /attach and +// /drop calls in the same session. +func TestAttachDropCompletion_QueriesFreshOnEachCall(t *testing.T) { + t.Parallel() + + items := builtInSessionCommands() + source := &stubAttachedFilesSource{paths: []string{"/repo/notes.md"}} + attachDropCompletion(items, source) + + var drop *Item + for i := range items { + if items[i].ID == "session.drop" { + drop = &items[i] + } + } + require.NotNil(t, drop) + + first := drop.CompleteArgument() + require.Len(t, first, 1) + assert.Equal(t, "/repo/notes.md", first[0].Label) + + // A file gets attached after the completer was attached (e.g. via /attach + // or @). A second call must reflect that change rather than replaying the + // first snapshot. + source.paths = []string{"/repo/notes.md", "/repo/plan.md"} + + second := drop.CompleteArgument() + require.Len(t, second, 2, "must reflect the current attachment list, not the one captured at attach time") + assert.Equal(t, "/repo/notes.md", second[0].Label) + assert.Equal(t, "/repo/plan.md", second[1].Label) +}