diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
index a2ab3837d..567bf8bb2 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 cedc7cfb3..8c031d3e0 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 56a49d13f..8b9f0df10 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 2cbf79f08..35730d33a 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 9bf533b34..3fc259baa 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 edc4c81b1..cb6b6005b 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 000000000..ed46cbb19
--- /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 000000000..e824e8a4e
--- /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 f871603b9..7bd3ae602 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 6b4326be7..dbb4b3daa 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 8d070e018..623857088 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 db5a5c0f5..322a4f977 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.")