Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ Type `/` during a session to see available commands, or press <kbd>Ctrl</kbd>+<k
| `/yolo` | Toggle automatic tool call approval |
| `/title` | Set or regenerate session title |
| `/attach` | Attach a file to your message |
| `/context` | List attached files and prompt files in context, each with a token estimate; press <kbd>d</kbd> to drop the selected attachment |
| `/drop` | Remove an attached file from context (`/drop <path>`, 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 |
Expand Down
19 changes: 19 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "".
//
Expand Down
19 changes: 19 additions & 0 deletions pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions pkg/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions pkg/session/session_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
}
27 changes: 27 additions & 0 deletions pkg/tui/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading