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
4 changes: 2 additions & 2 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/shell` | Open a shell |
| `/star` | Star/unstar the current session |
| `/context` | Show a context-window breakdown: estimated tokens per category (system prompt, tool definitions, prompt files, messages, tool results, compaction summary), a team-level **Live sessions** view (the current session plus every running sub-agent session with its agent, short session ID, and context budget), plus a per-file inventory of attached files and prompt files. Use the arrow keys to select a row: press <kbd>Enter</kbd> on a live session to explicitly compact it, or <kbd>d</kbd> on an attached file to drop it |
| `/drop` | Remove an attached file from the session context (`/drop <path>`, or `/drop` alone to review and drop from the `/context` dialog) |
| `/drop` | Remove an attached file from the session context (`/drop <path>`, or `/drop` alone to review and drop from the `/context` dialog). Press <kbd>Tab</kbd> 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. |
Expand Down Expand Up @@ -261,7 +261,7 @@ Explain what the code in @pkg/agent/agent.go does

The agent receives the full file contents in a structured `<attachments>` 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 <kbd>↑</kbd>/<kbd>↓</kbd> to select an attached file and press <kbd>d</kbd> (or <kbd>x</kbd>/<kbd>Del</kbd>) to drop it, or run `/drop <path>` 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 <kbd>↑</kbd>/<kbd>↓</kbd> to select an attached file and press <kbd>d</kbd> (or <kbd>x</kbd>/<kbd>Del</kbd>) to drop it, or run `/drop <path>` directly — press <kbd>Tab</kbd> 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

Expand Down
12 changes: 12 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
{
Expand Down
90 changes: 90 additions & 0 deletions pkg/tui/commands/commands_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading