From dc9b950a03c69e9a295c5626894dc655720f50e6 Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Fri, 3 Jul 2026 16:49:59 +0200 Subject: [PATCH] feat(tui): add /context inventory and /drop to remove attached files List attached files and prompt files with per-item token estimates in a new /context dialog, and allow dropping attachments from the session (d key in the dialog, or /drop ). Adds Session.RemoveAttachedFile and resolves the current agent's add_prompt_files entries through the same lookup rules as the turn-start hook. Closes #3435 --- docs/features/tui/index.md | 2 + pkg/app/app.go | 19 ++ pkg/runtime/runtime.go | 19 ++ pkg/session/session.go | 15 ++ pkg/session/session_options_test.go | 25 +++ pkg/tui/commands/commands.go | 27 +++ pkg/tui/dialog/context.go | 274 ++++++++++++++++++++++++++++ pkg/tui/dialog/context_test.go | 162 ++++++++++++++++ pkg/tui/handlers.go | 41 +++++ pkg/tui/messages/input.go | 4 + pkg/tui/messages/toggle.go | 4 + pkg/tui/tui.go | 6 + 12 files changed, 598 insertions(+) create mode 100644 pkg/tui/dialog/context.go create mode 100644 pkg/tui/dialog/context_test.go diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md index a2ab3837d7..567bf8bb24 100644 --- a/docs/features/tui/index.md +++ b/docs/features/tui/index.md @@ -79,6 +79,8 @@ Type `/` during a session to see available commands, or press Ctrl+d to drop the selected attachment | +| `/drop` | Remove an attached file from context (`/drop `, or `/drop` alone to pick from the list) | | `/shell` | Open a shell | | `/star` | Star/unstar the current session | | `/cost` | Show cost breakdown for this session | diff --git a/pkg/app/app.go b/pkg/app/app.go index cedc7cfb39..8c031d3e0d 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -267,6 +267,25 @@ func (a *App) CurrentAgentSkills() []skills.Skill { return st.Skills() } +// promptFilesProvider is an optional runtime capability: resolving the +// current agent's add_prompt_files entries to on-disk paths. Only the local +// runtime implements it; remote runtimes don't, so the /context dialog +// simply omits the prompt-files section for them. +type promptFilesProvider interface { + CurrentAgentPromptFiles() []string +} + +// CurrentAgentPromptFiles returns the resolved prompt-file paths injected +// into the current agent's context at each turn, or nil when the runtime +// cannot resolve them (remote runtime or no prompt files configured). +func (a *App) CurrentAgentPromptFiles() []string { + p, ok := a.runtime.(promptFilesProvider) + if !ok { + return nil + } + return p.CurrentAgentPromptFiles() +} + // ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args). // If matched, it reads the skill content and returns the resolved prompt. Otherwise returns "". // diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 56a49d13f9..8b9f0df10a 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -28,6 +28,7 @@ import ( "github.com/docker/docker-agent/pkg/model/provider" "github.com/docker/docker-agent/pkg/model/provider/dmr" "github.com/docker/docker-agent/pkg/modelsdev" + "github.com/docker/docker-agent/pkg/promptfiles" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/sessiontitle" "github.com/docker/docker-agent/pkg/team" @@ -1193,6 +1194,24 @@ func (r *LocalRuntime) CurrentAgentSkillsToolset() *skills.ToolSet { return nil } +// CurrentAgentPromptFiles resolves the current agent's add_prompt_files +// entries (AGENTS.md, CLAUDE.md, ...) to on-disk paths using the same lookup +// rules as the add_prompt_files turn-start hook: the workdir hierarchy plus +// the user's home directory (or the staged kit inside a sandbox). Used by +// the TUI /context dialog to show which prompt files enter the context. +func (r *LocalRuntime) CurrentAgentPromptFiles() []string { + a := r.CurrentAgent() + if a == nil { + return nil + } + home, _ := os.UserHomeDir() // empty string disables the home-dir lookup + var paths []string + for _, name := range a.AddPromptFiles() { + paths = append(paths, promptfiles.PathsFromEnv(r.workingDir, home, name)...) + } + return paths +} + // ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content. func (r *LocalRuntime) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) { currentAgent := r.CurrentAgent() diff --git a/pkg/session/session.go b/pkg/session/session.go index 2cbf79f08e..35730d33a1 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -745,6 +745,21 @@ func (s *Session) AttachedFilesSnapshot() []string { return slices.Clone(s.AttachedFiles) } +// RemoveAttachedFile removes absPath from the session's attached files so +// that sub-sessions created afterwards no longer inherit it. It reports +// whether the path was present. Re-attaching later (e.g. via a new @-mention) +// simply calls AddAttachedFile again. +func (s *Session) RemoveAttachedFile(absPath string) bool { + s.mu.Lock() + defer s.mu.Unlock() + i := slices.Index(s.AttachedFiles, absPath) + if i < 0 { + return false + } + s.AttachedFiles = slices.Delete(s.AttachedFiles, i, i+1) + return true +} + type Opt func(s *Session) func WithUserMessage(content string) Opt { diff --git a/pkg/session/session_options_test.go b/pkg/session/session_options_test.go index 9bf533b341..3fc259baa0 100644 --- a/pkg/session/session_options_test.go +++ b/pkg/session/session_options_test.go @@ -114,3 +114,28 @@ func TestWithAttachedFiles(t *testing.T) { s := New(WithAttachedFiles([]string{"/abs/foo.go", "", "relative/path.go", "/abs/bar.go", "/abs/foo.go"})) assert.Equal(t, []string{"/abs/foo.go", "/abs/bar.go"}, s.AttachedFilesSnapshot()) } + +func TestRemoveAttachedFile(t *testing.T) { + t.Parallel() + t.Run("removes and preserves order of remaining files", func(t *testing.T) { + t.Parallel() + s := New(WithAttachedFiles([]string{"/abs/foo.go", "/abs/bar.go", "/abs/baz.go"})) + assert.True(t, s.RemoveAttachedFile("/abs/bar.go")) + assert.Equal(t, []string{"/abs/foo.go", "/abs/baz.go"}, s.AttachedFilesSnapshot()) + }) + + t.Run("reports false for unknown path", func(t *testing.T) { + t.Parallel() + s := New(WithAttachedFiles([]string{"/abs/foo.go"})) + assert.False(t, s.RemoveAttachedFile("/abs/other.go")) + assert.Equal(t, []string{"/abs/foo.go"}, s.AttachedFilesSnapshot()) + }) + + t.Run("allows re-attaching after removal", func(t *testing.T) { + t.Parallel() + s := New(WithAttachedFiles([]string{"/abs/foo.go"})) + assert.True(t, s.RemoveAttachedFile("/abs/foo.go")) + s.AddAttachedFile("/abs/foo.go") + assert.Equal(t, []string{"/abs/foo.go"}, s.AttachedFilesSnapshot()) + }) +} diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index edc4c81b1d..cb6b6005b2 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -117,6 +117,33 @@ func builtInSessionCommands() []Item { return core.CmdHandler(messages.ShowSnapshotsDialogMsg{}) }, }, + { + ID: "session.context", + Label: "Context", + SlashCommand: "/context", + Description: "List attached and prompt files in context with token estimates", + Category: "Session", + Immediate: true, + Execute: func(string) tea.Cmd { + return core.CmdHandler(messages.ShowContextDialogMsg{}) + }, + }, + { + ID: "session.drop", + Label: "Drop", + SlashCommand: "/drop", + Description: "Remove an attached file from context (usage: /drop [path])", + Category: "Session", + Immediate: true, + Execute: func(arg string) tea.Cmd { + arg = strings.TrimSpace(arg) + if arg == "" { + // No argument: open the context inventory to pick a file. + return core.CmdHandler(messages.ShowContextDialogMsg{}) + } + return core.CmdHandler(messages.DropAttachedFileMsg{FilePath: arg}) + }, + }, { ID: "session.cost", Label: "Cost", diff --git a/pkg/tui/dialog/context.go b/pkg/tui/dialog/context.go new file mode 100644 index 0000000000..ed46cbb192 --- /dev/null +++ b/pkg/tui/dialog/context.go @@ -0,0 +1,274 @@ +package dialog + +import ( + "os" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + pathx "github.com/docker/docker-agent/pkg/path" + "github.com/docker/docker-agent/pkg/tui/components/notification" + "github.com/docker/docker-agent/pkg/tui/components/toolcommon" + "github.com/docker/docker-agent/pkg/tui/core" + "github.com/docker/docker-agent/pkg/tui/core/layout" + "github.com/docker/docker-agent/pkg/tui/messages" + "github.com/docker/docker-agent/pkg/tui/styles" +) + +const ( + contextDialogWidthPercent = 70 + contextDialogMinWidth = 50 + contextDialogMaxWidth = 100 +) + +// ContextFile is one entry in the /context inventory dialog. +type ContextFile struct { + // Path is the absolute path of the file. + Path string + // Prompt marks files injected via the agent's add_prompt_files config; + // they come from the agent configuration and cannot be dropped. + Prompt bool + // Tokens is the approximate token count of the file, or -1 when the + // file could not be stat-ed (e.g. deleted since it was attached). + Tokens int64 +} + +// BuildContextFiles builds the /context dialog entries: attached files first, +// then prompt files, each with a token estimate derived from its on-disk size. +func BuildContextFiles(attached, promptFiles []string) []ContextFile { + files := make([]ContextFile, 0, len(attached)+len(promptFiles)) + for _, p := range attached { + files = append(files, ContextFile{Path: p, Tokens: approxFileTokens(p)}) + } + for _, p := range promptFiles { + files = append(files, ContextFile{Path: p, Prompt: true, Tokens: approxFileTokens(p)}) + } + return files +} + +// approxFileTokens estimates a file's token count from its size using the +// same ~4 chars/token rule of thumb the session uses for truncation budgets. +// Returns -1 when the file cannot be stat-ed or is not a regular file. +func approxFileTokens(path string) int64 { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return -1 + } + return info.Size() / 4 +} + +// contextDialog lists every file currently entering the session context +// (attachments and prompt files) and lets the user drop attachments. +type contextDialog struct { + BaseDialog + + files []ContextFile + selected int +} + +// NewContextDialog creates the /context dialog. files must contain attached +// files first, then prompt files (the order BuildContextFiles produces). +func NewContextDialog(files []ContextFile) Dialog { + return &contextDialog{files: files} +} + +func (d *contextDialog) Init() tea.Cmd { return nil } + +func (d *contextDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + cmd := d.SetSize(msg.Width, msg.Height) + return d, cmd + + case tea.KeyPressMsg: + if cmd := HandleQuit(msg); cmd != nil { + return d, cmd + } + cmd := d.handleKey(msg) + return d, cmd + } + return d, nil +} + +func (d *contextDialog) handleKey(msg tea.KeyPressMsg) tea.Cmd { + switch msg.String() { + case "esc", "q", "enter": + return core.CmdHandler(CloseDialogMsg{}) + case "up", "k": + if d.selected > 0 { + d.selected-- + } + case "down", "j": + if d.selected < len(d.files)-1 { + d.selected++ + } + case "home", "g": + d.selected = 0 + case "end", "G": + d.selected = max(0, len(d.files)-1) + case "d", "x", "delete", "backspace": + return d.dropSelected() + } + return nil +} + +// dropSelected removes the selected attachment from the dialog's local list +// and asks the app to remove it from the session. Prompt files cannot be +// dropped: they are re-resolved from the agent configuration at every turn. +func (d *contextDialog) dropSelected() tea.Cmd { + if d.selected < 0 || d.selected >= len(d.files) { + return nil + } + file := d.files[d.selected] + if file.Prompt { + return notification.InfoCmd("Prompt files come from the agent configuration and cannot be dropped") + } + d.files = append(d.files[:d.selected], d.files[d.selected+1:]...) + if d.selected >= len(d.files) { + d.selected = max(0, len(d.files)-1) + } + return core.CmdHandler(messages.DropAttachedFileMsg{FilePath: file.Path}) +} + +func (d *contextDialog) Position() (row, col int) { + return d.CenterDialog(d.View()) +} + +func (d *contextDialog) View() string { + width := d.ComputeDialogWidth(contextDialogWidthPercent, contextDialogMinWidth, contextDialogMaxWidth) + inner := d.ContentWidth(width, 2) + + content := NewContent(inner).AddTitle("Context Files").AddSeparator().AddSpace() + + if summary := d.summaryLine(); summary != "" { + content = content. + AddContent(styles.DialogOptionsStyle.Width(inner).Render(summary)). + AddSpace() + } + + body := content. + AddContent(d.bodyContent(inner)). + AddSpace(). + AddHelpKeys(d.helpKeys()...). + Build() + + return styles.DialogStyle.Width(width).Render(body) +} + +// summaryLine returns the "N attached • M prompt files • ~X tokens" header, +// or "" when there are no files. +func (d *contextDialog) summaryLine() string { + attached, prompts, tokens := 0, 0, int64(0) + for _, f := range d.files { + if f.Prompt { + prompts++ + } else { + attached++ + } + if f.Tokens > 0 { + tokens += f.Tokens + } + } + if attached+prompts == 0 { + return "" + } + + summary := pluralize(attached, "attached file", "attached files") + if prompts > 0 { + summary += " • " + pluralize(prompts, "prompt file", "prompt files") + } + if tokens > 0 { + summary += " • ~" + toolcommon.FormatTokenCount(tokens) + " tokens" + } + return summary +} + +// bodyContent returns either the empty-state line or the grouped file list. +func (d *contextDialog) bodyContent(inner int) string { + if len(d.files) == 0 { + return styles.DialogContentStyle. + Italic(true). + Foreground(styles.TextMuted). + Width(inner). + Align(lipgloss.Center). + Render("No files in context. Attach files with @path or /attach.") + } + + gl := newGroupedList() + prevPrompt := -1 // tri-state: -1 = no group yet, 0 = attached, 1 = prompt + for i, f := range d.files { + group := 0 + if f.Prompt { + group = 1 + } + if group != prevPrompt { + if group == 0 { + gl.AddNonItem(RenderGroupSeparator("Attached files", inner)) + } else { + gl.AddNonItem(RenderGroupSeparator("Prompt files (agent config)", inner)) + } + prevPrompt = group + } + gl.AddItem(d.renderRow(f, i == d.selected, inner)) + } + return lipgloss.JoinVertical(lipgloss.Left, d.visibleLines(gl)...) +} + +// visibleLines applies a sliding window over the rendered lines so that long +// file lists fit the screen while the selected item stays visible. +func (d *contextDialog) visibleLines(gl *groupedList) []string { + lines := gl.Lines() + maxVisible := d.maxVisibleLines() + if len(lines) <= maxVisible { + return lines + } + + selectedLine := gl.LineForItem(d.selected) + start := min(max(0, selectedLine-maxVisible/2), len(lines)-maxVisible) + return lines[start : start+maxVisible] +} + +// maxVisibleLines returns how many list lines fit in the dialog, leaving +// room for the frame, title, separator, summary, and help rows. +func (d *contextDialog) maxVisibleLines() int { + const chromeLines = 10 + return max(3, d.Height()*70/100-chromeLines) +} + +func (d *contextDialog) helpKeys() []string { + if len(d.files) == 0 { + return []string{"esc", "close"} + } + return []string{"↑/↓", "navigate", "d", "drop", "esc", "close"} +} + +// renderRow draws one file entry: the display path on the left and the +// token estimate right-aligned. +func (d *contextDialog) renderRow(f ContextFile, selected bool, width int) string { + nameStyle, descStyle := styles.PaletteUnselectedActionStyle, styles.PaletteUnselectedDescStyle + if selected { + nameStyle, descStyle = styles.PaletteSelectedActionStyle, styles.PaletteSelectedDescStyle + } + + tokens := "missing" + if f.Tokens >= 0 { + tokens = "~" + toolcommon.FormatTokenCount(f.Tokens) + " tokens" + } + + right := descStyle.Render(" " + tokens + " ") + maxPathWidth := width - lipgloss.Width(right) - 2 + left := nameStyle.Render(" " + toolcommon.TruncateText(displayContextPath(f.Path), maxPathWidth) + " ") + gap := max(0, width-lipgloss.Width(left)) + return left + lipgloss.PlaceHorizontal(gap, lipgloss.Right, right, + lipgloss.WithWhitespaceStyle(descStyle)) +} + +// displayContextPath shortens an absolute path for display: relative to the +// current working directory when inside it, otherwise with ~ for the home +// directory. +func displayContextPath(p string) string { + if cwd, err := os.Getwd(); err == nil && pathx.IsWithin(p, cwd) { + return pathx.RelativeTo(p, cwd) + } + return pathx.ShortenHome(p) +} diff --git a/pkg/tui/dialog/context_test.go b/pkg/tui/dialog/context_test.go new file mode 100644 index 0000000000..e824e8a4ea --- /dev/null +++ b/pkg/tui/dialog/context_test.go @@ -0,0 +1,162 @@ +package dialog + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/tui/messages" +) + +func writeTempFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +func newTestContextDialog(t *testing.T, files []ContextFile) *contextDialog { + t.Helper() + d, ok := NewContextDialog(files).(*contextDialog) + require.True(t, ok) + d.Init() + d.Update(tea.WindowSizeMsg{Width: 100, Height: 40}) + return d +} + +func TestBuildContextFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + attached := writeTempFile(t, dir, "notes.md", strings.Repeat("a", 400)) + prompt := writeTempFile(t, dir, "AGENTS.md", strings.Repeat("b", 40)) + missing := filepath.Join(dir, "deleted.go") + + files := BuildContextFiles([]string{attached, missing}, []string{prompt}) + + require.Len(t, files, 3) + assert.Equal(t, ContextFile{Path: attached, Prompt: false, Tokens: 100}, files[0]) + assert.Equal(t, ContextFile{Path: missing, Prompt: false, Tokens: -1}, files[1]) + assert.Equal(t, ContextFile{Path: prompt, Prompt: true, Tokens: 10}, files[2]) +} + +func TestContextDialog_EmptyState(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, nil) + out := d.View() + assert.Contains(t, out, "Context Files") + assert.Contains(t, out, "No files in context") +} + +func TestContextDialog_RendersSectionsAndTokens(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{ + {Path: "/abs/src/main.go", Tokens: 1200}, + {Path: "/abs/missing.md", Tokens: -1}, + {Path: "/abs/AGENTS.md", Prompt: true, Tokens: 100}, + }) + + out := d.View() + assert.Contains(t, out, "2 attached files") + assert.Contains(t, out, "1 prompt file") + assert.Contains(t, out, "~1.3K tokens") // summary total: 1200 + 100 + assert.Contains(t, out, "Attached files") + assert.Contains(t, out, "Prompt files (agent config)") + assert.Contains(t, out, "main.go") + assert.Contains(t, out, "~1.2K tokens") + assert.Contains(t, out, "missing") + assert.Contains(t, out, "AGENTS.md") +} + +func TestContextDialog_Navigation(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{ + {Path: "/abs/a.go"}, + {Path: "/abs/b.go"}, + {Path: "/abs/AGENTS.md", Prompt: true}, + }) + + down := tea.KeyPressMsg{Code: tea.KeyDown} + up := tea.KeyPressMsg{Code: tea.KeyUp} + + require.Equal(t, 0, d.selected) + d.Update(down) + require.Equal(t, 1, d.selected) + d.Update(down) + require.Equal(t, 2, d.selected) + d.Update(down) + require.Equal(t, 2, d.selected, "down must not move past the last file") + d.Update(up) + d.Update(up) + d.Update(up) + require.Equal(t, 0, d.selected, "up must not move before the first file") +} + +func TestContextDialog_DropEmitsMsgAndRemovesItem(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{ + {Path: "/abs/a.go"}, + {Path: "/abs/b.go"}, + }) + + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + require.NotNil(t, cmd) + msg := cmd() + require.Equal(t, messages.DropAttachedFileMsg{FilePath: "/abs/a.go"}, msg) + + require.Len(t, d.files, 1) + assert.Equal(t, "/abs/b.go", d.files[0].Path) + assert.Equal(t, 0, d.selected) +} + +func TestContextDialog_DropLastItemClampsSelection(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{ + {Path: "/abs/a.go"}, + {Path: "/abs/b.go"}, + }) + + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + require.Equal(t, 1, d.selected) + + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + require.NotNil(t, cmd) + require.Equal(t, messages.DropAttachedFileMsg{FilePath: "/abs/b.go"}, cmd()) + assert.Equal(t, 0, d.selected) + + _, cmd = d.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + require.NotNil(t, cmd) + require.Equal(t, messages.DropAttachedFileMsg{FilePath: "/abs/a.go"}, cmd()) + assert.Empty(t, d.files) + + _, cmd = d.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + assert.Nil(t, cmd, "drop on an empty list must be a no-op") +} + +func TestContextDialog_PromptFilesCannotBeDropped(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{ + {Path: "/abs/a.go"}, + {Path: "/abs/AGENTS.md", Prompt: true}, + }) + + d.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + _, cmd := d.Update(tea.KeyPressMsg{Code: 'd', Text: "d"}) + require.NotNil(t, cmd, "dropping a prompt file should surface a notification") + _, isDrop := cmd().(messages.DropAttachedFileMsg) + assert.False(t, isDrop, "prompt files must not emit a drop message") + assert.Len(t, d.files, 2, "prompt files must stay listed") +} + +func TestContextDialog_EscCloses(t *testing.T) { + t.Parallel() + d := newTestContextDialog(t, []ContextFile{{Path: "/abs/a.go"}}) + + _, cmd := d.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + require.NotNil(t, cmd) + assert.Equal(t, CloseDialogMsg{}, cmd()) +} diff --git a/pkg/tui/handlers.go b/pkg/tui/handlers.go index f871603b95..7bd3ae602b 100644 --- a/pkg/tui/handlers.go +++ b/pkg/tui/handlers.go @@ -7,6 +7,7 @@ import ( neturl "net/url" "os" "os/exec" + "path/filepath" goruntime "runtime" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/evaluation" "github.com/docker/docker-agent/pkg/modelinfo" + pathx "github.com/docker/docker-agent/pkg/path" "github.com/docker/docker-agent/pkg/runtime" "github.com/docker/docker-agent/pkg/session" "github.com/docker/docker-agent/pkg/shellpath" @@ -476,6 +478,45 @@ func (m *appModel) handleShowSkillsDialog() (tea.Model, tea.Cmd) { }) } +func (m *appModel) handleShowContextDialog() (tea.Model, tea.Cmd) { + var attached []string + if sess := m.application.Session(); sess != nil { + attached = sess.AttachedFilesSnapshot() + } + return m, core.CmdHandler(dialog.OpenDialogMsg{ + Model: dialog.NewContextDialog( + dialog.BuildContextFiles(attached, m.application.CurrentAgentPromptFiles()), + ), + }) +} + +// handleDropAttachedFile removes a previously attached file from the session +// and persists the change so a later resume does not resurrect it. The path +// is expanded (~, env vars) and made absolute to match the fully qualified +// paths stored in the session; entries dropped from the /context dialog +// arrive already absolute so resolution is a no-op for them. +func (m *appModel) handleDropAttachedFile(path string) (tea.Model, tea.Cmd) { + sess := m.application.Session() + if sess == nil { + return m, notification.ErrorCmd("No active session") + } + + absPath := pathx.ExpandPath(path) + if resolved, err := filepath.Abs(absPath); err == nil { + absPath = resolved + } + if !sess.RemoveAttachedFile(absPath) { + return m, notification.WarningCmd("Not attached: " + path) + } + + if store := m.application.SessionStore(); store != nil { + if err := store.UpdateSession(m.ctx(), sess); err != nil { + slog.Warn("failed to persist dropped attachment", "path", absPath, "error", err) + } + } + return m, notification.SuccessCmd("Dropped " + pathx.ShortenHome(absPath)) +} + // handleRestartToolset asks the runtime to restart the named toolset. // The actual call can block for up to ~35s (the supervisor's // reconnect timeout), so we run it inside a tea.Cmd goroutine and diff --git a/pkg/tui/messages/input.go b/pkg/tui/messages/input.go index 6b4326be74..dbb4b3daa9 100644 --- a/pkg/tui/messages/input.go +++ b/pkg/tui/messages/input.go @@ -5,6 +5,10 @@ type ( // AttachFileMsg attaches a file directly or opens file picker if empty/directory. AttachFileMsg struct{ FilePath string } + // DropAttachedFileMsg removes a previously attached file from the session + // so it no longer enters the context of future turns. + DropAttachedFileMsg struct{ FilePath string } + // InsertFileRefMsg inserts @filepath reference into editor. InsertFileRefMsg struct{ FilePath string } diff --git a/pkg/tui/messages/toggle.go b/pkg/tui/messages/toggle.go index 8d070e0181..6238570881 100644 --- a/pkg/tui/messages/toggle.go +++ b/pkg/tui/messages/toggle.go @@ -24,6 +24,10 @@ type ( // ShowCostDialogMsg shows the cost/usage dialog. ShowCostDialogMsg struct{} + // ShowContextDialogMsg shows the context inventory dialog: attached + // files and prompt files with per-item token estimates. + ShowContextDialogMsg struct{} + // ShowPermissionsDialogMsg shows the permissions dialog. ShowPermissionsDialogMsg struct{} diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index db5a5c0f59..322a4f9774 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -1083,6 +1083,9 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.ShowCostDialogMsg: return m.handleShowCostDialog() + case messages.ShowContextDialogMsg: + return m.handleShowContextDialog() + case messages.ShowPermissionsDialogMsg: return m.handleShowPermissionsDialog() @@ -1161,6 +1164,9 @@ func (m *appModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.AttachFileMsg: return m.handleAttachFile(msg.FilePath) + case messages.DropAttachedFileMsg: + return m.handleDropAttachedFile(msg.FilePath) + case messages.SendAttachmentMsg: if m.application.IsReadOnly() { return m, notification.WarningCmd("Session is read-only. No new messages can be sent.")