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)
+}