Vectrum
Sign In

tui: run a command yourself with ! or $, and let the agent see it

The prompt box has only ever pointed at the model, so checking a test
meant leaving for another terminal — and then describing what you saw
instead of showing it.

! on an empty prompt points the box at a shell, $ at python3. The line
runs where the agent's own bash tool would have run it, and the command
and its capped output are appended to the conversation as a user
message: run !go test, then say "fix that", and the model already has
the failure. Nothing is sent at that moment; it waits in history and
goes up with the next real turn.

The process-group kill and output capping the bash tool had are now
tools.RunCommand, with the tool a thin wrapper over it, so what the
prompt box runs and what the model runs cannot drift apart.

One command at a time — two in flight would land in whatever order they
finished and attach the earlier one's output to the later one's entry.
While it runs, the activity line names it, esc interrupts it, and a
message you type waits rather than jumping ahead of the output it is
about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent e76b36ccommit4f35549

Showing 11 changed files with 860 additions and 51 deletions.

+46−0README.mdView File
@@ -97,6 +97,8 @@ to six lines.
9797 | --- | --- |
9898 | `enter` | send |
9999 | `/` | open the command menu — see below |
100+ | `!` | run a shell command yourself — see below |
101+ | `$` | the same, through `python3` |
100102 | `ctrl+j` | newline without sending |
101103 | `shift+tab` | cycle the approval mode |
102104 | `ctrl+r` | expand elided tool output |
@@ -126,6 +128,50 @@ level for `/effort` — and `esc` dismisses the menu without touching what you
126128 typed. The menu offers exactly the commands the TUI answers, so nothing it
127129 suggests can come back as "unknown command".
128130
131+ ### Running something yourself
132+
133+ `!` on an empty prompt points the box at a shell instead of at the model; `$`
134+ points it at `python3`. The marker replaces the `›`, the frame changes colour,
135+ and `enter` runs the line where the agent's own `bash` tool would have run it —
136+ from the working root, with the same timeout and the same output cap.
137+
138+ ```
139+ ╭──────────────────────────────────────────────────────────────────────────────╮
140+ │ ! go test ./internal/agent/ │
141+ ╰──────────────────────────────────────────────────────────────────────────────╯
142+ ```
143+
144+ ```
145+ ⏺ ! go test ./internal/agent/
146+ ⎿ exit code: 1 (2.3s)
147+ --- stdout ---
148+ --- FAIL: TestCompactKeepsTheLastExchange (0.00s)
149+ compact_test.go:88: history has 2 messages, want 4
150+ … +9 more lines (ctrl+r)
151+ ```
152+
153+ **The agent is shown what you saw.** The command and its output are appended to
154+ the conversation as a user message, so the next thing you type can be "fix
155+ that" and the model already has the failure. Nothing is sent at that moment —
156+ it waits in the history and goes up with your next real turn, so several
157+ commands can be run before saying anything about them.
158+
159+ What reaches the model is the capped report, not the raw firehose: a noisy
160+ command cannot cost more context from the prompt box than it would through the
161+ tool. The transcript collapses it further to six lines, and `ctrl+r` expands to
162+ exactly what the model was given, so the two never drift.
163+
164+ `esc` leaves the mode, as does `backspace` over the marker. The mode is
165+ per-command — after a run the prompt returns to normal, the way a shell prompt
166+ comes back. One command runs at a time; while it does, `esc` interrupts it and
167+ a message you type waits rather than jumping ahead of the output it is about.
168+ A `!` typed mid-sentence is an exclamation mark.
169+
170+ These bypass the approval policy by design: you typed the command, so there is
171+ nobody left to ask. They are also the one part of the TUI that runs something
172+ without the model's involvement — worth remembering when the root is not a
173+ throwaway clone.
174+
129175 `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and
130176 opens a picker on the answer:
131177
+73−48internal/tools/bash.goView File
@@ -11,14 +11,82 @@ import (
1111 )
1212
1313 const (
14- defaultBashTimeout = 60 * time.Second
15- maxBashTimeout = 10 * time.Minute
14+ // DefaultCommandTimeout is how long a command gets when nothing says
15+ // otherwise, and MaxCommandTimeout is the ceiling on asking for more.
16+ DefaultCommandTimeout = 60 * time.Second
17+ MaxCommandTimeout = 10 * time.Minute
1618 // Output is capped head and tail. Head-only truncation would cut off the
1719 // end of a test run, which is exactly where the failure is.
1820 bashHeadLines = 100
1921 bashTailLines = 100
2022 )
2123
24+ // RunCommand runs argv in dir and returns the same report the bash tool
25+ // reports: the exit code and elapsed time, then whatever each stream produced,
26+ // capped at both ends. failed says the command did not exit zero — whether it
27+ // was killed by the timeout, never started, or simply returned non-zero.
28+ //
29+ // It lives apart from the tool because the tool is not the only caller: the
30+ // TUI's shell mode runs what the user types at the prompt, and the awkward
31+ // parts here — a process group so children die with the parent, a wait delay
32+ // so a killed command cannot hold the pipes open — are worth having once.
33+ func RunCommand(ctx context.Context, dir string, argv []string, timeout time.Duration) (report string, failed bool) {
34+ if len(argv) == 0 {
35+ return "failed to run: no command", true
36+ }
37+ if timeout <= 0 {
38+ timeout = DefaultCommandTimeout
39+ }
40+ if timeout > MaxCommandTimeout {
41+ timeout = MaxCommandTimeout
42+ }
43+
44+ ctx, cancel := context.WithTimeout(ctx, timeout)
45+ defer cancel()
46+
47+ cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
48+ cmd.Dir = resolveBase(dir, "")
49+ // A new process group, so a command that spawns children can be killed
50+ // whole. Killing only the leader leaves orphans holding the pipes open and
51+ // the loop hangs waiting on them.
52+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
53+ cmd.Cancel = func() error {
54+ if cmd.Process == nil {
55+ return nil
56+ }
57+ return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
58+ }
59+ cmd.WaitDelay = 2 * time.Second
60+
61+ var stdout, stderr bytes.Buffer
62+ cmd.Stdout = &stdout
63+ cmd.Stderr = &stderr
64+
65+ start := time.Now()
66+ runErr := cmd.Run()
67+ elapsed := time.Since(start)
68+
69+ exitCode := -1
70+ if cmd.ProcessState != nil {
71+ exitCode = cmd.ProcessState.ExitCode()
72+ }
73+ timedOut := ctx.Err() == context.DeadlineExceeded
74+
75+ var b strings.Builder
76+ switch {
77+ case timedOut:
78+ fmt.Fprintf(&b, "timed out after %s (killed)\n", timeout)
79+ case runErr != nil && exitCode < 0:
80+ fmt.Fprintf(&b, "failed to run: %v\n", runErr)
81+ default:
82+ fmt.Fprintf(&b, "exit code: %d (%s)\n", exitCode, elapsed.Round(time.Millisecond))
83+ }
84+ writeStream(&b, "stdout", stdout.String())
85+ writeStream(&b, "stderr", stderr.String())
86+
87+ return strings.TrimRight(b.String(), "\n"), timedOut || exitCode != 0
88+ }
89+
2290 // BashArgs is the bash input.
2391 type BashArgs struct {
2492 Command string `json:"command" jsonschema:"description=Shell command to run, from the working root"`
@@ -36,58 +104,15 @@ are faster and their output is bounded.`),
36104 if strings.TrimSpace(in.Command) == "" {
37105 return "", fmt.Errorf("command is required")
38106 }
39- timeout := defaultBashTimeout
107+ timeout := DefaultCommandTimeout
40108 if in.TimeoutMS > 0 {
41109 timeout = time.Duration(in.TimeoutMS) * time.Millisecond
42110 }
43- if timeout > maxBashTimeout {
44- timeout = maxBashTimeout
45- }
46-
47- ctx, cancel := context.WithTimeout(ctx, timeout)
48- defer cancel()
49-
50- cmd := exec.CommandContext(ctx, "bash", "-c", in.Command)
51- cmd.Dir = resolveBase(root, "")
52- // A new process group, so a command that spawns children can be
53- // killed whole. Killing only the leader leaves orphans holding the
54- // pipes open and the loop hangs waiting on them.
55- cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
56- cmd.Cancel = func() error {
57- if cmd.Process == nil {
58- return nil
59- }
60- return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
61- }
62- cmd.WaitDelay = 2 * time.Second
63-
64- var stdout, stderr bytes.Buffer
65- cmd.Stdout = &stdout
66- cmd.Stderr = &stderr
67-
68- start := time.Now()
69- runErr := cmd.Run()
70- elapsed := time.Since(start)
71-
72- exitCode := cmd.ProcessState.ExitCode()
73- timedOut := ctx.Err() == context.DeadlineExceeded
74-
75- var b strings.Builder
76- switch {
77- case timedOut:
78- fmt.Fprintf(&b, "timed out after %s (killed)\n", timeout)
79- case runErr != nil && exitCode < 0:
80- fmt.Fprintf(&b, "failed to run: %v\n", runErr)
81- default:
82- fmt.Fprintf(&b, "exit code: %d (%s)\n", exitCode, elapsed.Round(time.Millisecond))
83- }
84- writeStream(&b, "stdout", stdout.String())
85- writeStream(&b, "stderr", stderr.String())
86111
87- out := strings.TrimRight(b.String(), "\n")
112+ out, failed := RunCommand(ctx, root, []string{"bash", "-c", in.Command}, timeout)
88113 // A non-zero exit is reported as an error so the model sees it as a
89114 // failure it has to respond to, with the output still attached.
90- if timedOut || exitCode != 0 {
115+ if failed {
91116 return "", fmt.Errorf("%s", out)
92117 }
93118 return out, nil
+36−0internal/tools/exec_test.goView File
@@ -195,3 +195,39 @@ func TestWriteFilePreservesCRLFOnRewrite(t *testing.T) {
195195 t.Errorf("got %q, want CRLF preserved", got)
196196 }
197197 }
198+
199+ func TestRunCommandReportsExitCodeAndBothStreams(t *testing.T) {
200+ report, failed := RunCommand(context.Background(), t.TempDir(),
201+ []string{"bash", "-c", "echo out; echo problem >&2; exit 3"}, time.Minute)
202+ if !failed {
203+ t.Error("a non-zero exit is a failure the caller has to be able to see")
204+ }
205+ for _, want := range []string{"exit code: 3", "--- stdout ---\nout", "--- stderr ---\nproblem"} {
206+ if !strings.Contains(report, want) {
207+ t.Errorf("missing %q in:\n%s", want, report)
208+ }
209+ }
210+ }
211+
212+ func TestRunCommandSucceedsInTheGivenDirectory(t *testing.T) {
213+ dir := t.TempDir()
214+ writeFile(t, dir, "hello.txt", "hi\n")
215+ report, failed := RunCommand(context.Background(), dir, []string{"bash", "-c", "cat hello.txt"}, time.Minute)
216+ if failed {
217+ t.Errorf("want success, got:\n%s", report)
218+ }
219+ if !strings.Contains(report, "--- stdout ---\nhi") {
220+ t.Errorf("got:\n%s", report)
221+ }
222+ }
223+
224+ func TestRunCommandReportsAMissingBinaryRatherThanAnExitCode(t *testing.T) {
225+ report, failed := RunCommand(context.Background(), t.TempDir(),
226+ []string{"definitely-not-a-real-binary-xyz"}, time.Minute)
227+ if !failed {
228+ t.Fatal("a binary that is not there is a failure")
229+ }
230+ if !strings.Contains(report, "failed to run") {
231+ t.Errorf("want a run failure, got:\n%s", report)
232+ }
233+ }
+2−0internal/tui/commands.goView File
@@ -79,6 +79,8 @@ func (m *model) helpText() string {
7979 fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them")
8080 }
8181 b.WriteString("\nkeys: enter send · ctrl+j newline · shift+tab cycle approval mode" +
82+ "\n ! runs a shell command · $ runs python — both on an empty prompt" +
83+ "\n what they print goes to the agent too, so you can then say \"fix that\"" +
8284 "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" +
8385 "\n ctrl+r toggle full tool output · pgup/pgdn scroll" +
8486 "\n esc or ctrl+c interrupt a turn · ctrl+c again to exit")
+246−0internal/tui/local.goaddedView File
@@ -0,0 +1,246 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "strings"
8+ "time"
9+
10+ tea "github.com/charmbracelet/bubbletea"
11+ "github.com/charmbracelet/lipgloss"
12+
13+ "harness/internal/llm"
14+ "harness/internal/tools"
15+ )
16+
17+ // localMode is which interpreter the prompt box is pointed at. Off is the
18+ // normal case: what you type goes to the model.
19+ type localMode int
20+
21+ const (
22+ localOff localMode = iota
23+ localShell
24+ localPython
25+ )
26+
27+ // localSpec is one mode's whole definition — the character that opens it, the
28+ // binary it needs, and how a typed line becomes an argv. Keeping the three
29+ // together is what stops the key, the prompt marker and the interpreter from
30+ // drifting apart as modes are added.
31+ type localSpec struct {
32+ marker string
33+ bin string
34+ hint string
35+ argv func(line string) []string
36+ }
37+
38+ var localSpecs = map[localMode]localSpec{
39+ localShell: {
40+ marker: "!",
41+ bin: "bash",
42+ hint: "shell — enter runs it here, esc to leave",
43+ argv: func(line string) []string { return []string{"bash", "-c", line} },
44+ },
45+ localPython: {
46+ marker: "$",
47+ bin: "python3",
48+ hint: "python — enter runs it here, esc to leave",
49+ argv: func(line string) []string { return []string{"python3", "-c", line} },
50+ },
51+ }
52+
53+ // localFor maps an opening keystroke to its mode.
54+ func localFor(key string) localMode {
55+ for mode, spec := range localSpecs {
56+ if key == spec.marker {
57+ return mode
58+ }
59+ }
60+ return localOff
61+ }
62+
63+ // localRun is the command in flight. Only ever one: two at once would land in
64+ // whatever order they finished, and the earlier one's output would be attached
65+ // to the later one's transcript entry.
66+ type localRun struct {
67+ spec localSpec
68+ command string
69+ started time.Time
70+ cancel context.CancelFunc
71+ }
72+
73+ // localDone carries a finished local run back to the update loop. The command
74+ // runs on its own goroutine but lands here, so the transcript and the
75+ // conversation are only ever touched from the single-threaded loop.
76+ type localDone struct {
77+ spec localSpec
78+ command string
79+ report string
80+ failed bool
81+ }
82+
83+ // onLocalKey handles the keys that belong to the local-command modes and says
84+ // whether it took the key. Everything it does not take falls through to the
85+ // prompt, which is what keeps typing a command rather than a set of controls.
86+ func (m *model) onLocalKey(msg tea.KeyMsg) (tea.Cmd, bool) {
87+ key := msg.String()
88+
89+ if m.local == localOff {
90+ // The marker opens a mode only from an empty prompt that is yours to
91+ // type into. Mid-sentence it is prose, and mid-turn there is nothing
92+ // to type into at all.
93+ if m.phase != phaseIdle || m.running != nil || m.input.Value() != "" {
94+ return nil, false
95+ }
96+ mode := localFor(key)
97+ if mode == localOff {
98+ return nil, false
99+ }
100+ m.setLocal(mode)
101+ m.closeMenu()
102+ return nil, true
103+ }
104+
105+ switch key {
106+ case "esc":
107+ m.setLocal(localOff)
108+ return nil, true
109+
110+ case "backspace":
111+ // Backspacing over the marker undoes it, the way it looks like it
112+ // should. With a command half-typed it is still just backspace.
113+ if m.input.Value() == "" {
114+ m.setLocal(localOff)
115+ return nil, true
116+ }
117+ return nil, false
118+
119+ case "enter":
120+ line := strings.TrimSpace(m.input.Value())
121+ if line == "" || m.running != nil {
122+ return nil, true
123+ }
124+ mode := m.local
125+ m.input.Reset()
126+ m.growInput()
127+ return m.runLocal(mode, line), true
128+ }
129+ return nil, false
130+ }
131+
132+ // setLocal switches the prompt box into a mode, or out of every mode. The box
133+ // is emptied either way: a command typed at the shell prompt is not a sentence
134+ // you meant to send to the model.
135+ func (m *model) setLocal(mode localMode) {
136+ m.local = mode
137+ m.input.Reset()
138+ m.growInput()
139+
140+ if mode == localOff {
141+ m.input.Prompt = "› "
142+ m.input.Placeholder = defaultPlaceholder
143+ m.input.FocusedStyle.Prompt = styCoral
144+ return
145+ }
146+ spec := localSpecs[mode]
147+ m.input.Prompt = spec.marker + " "
148+ m.input.Placeholder = spec.hint
149+ m.input.FocusedStyle.Prompt = localStyle(mode)
150+ }
151+
152+ // localStyle is the mode's colour, carried by the prompt marker and the
153+ // transcript entry so the two read as the same thing.
154+ func localStyle(mode localMode) lipgloss.Style {
155+ if mode == localPython {
156+ return styLocalPython
157+ }
158+ return styLocalShell
159+ }
160+
161+ // runLocal starts one command. It returns to the normal prompt immediately:
162+ // the mode is per-command, like a shell prompt coming back, so a run is never
163+ // followed by a box you thought you had left.
164+ func (m *model) runLocal(mode localMode, line string) tea.Cmd {
165+ spec := localSpecs[mode]
166+ m.setLocal(localOff)
167+
168+ // Looking the binary up first turns "python3 is not installed" into a
169+ // sentence rather than an exec error the shell spells its own way.
170+ if _, err := exec.LookPath(spec.bin); err != nil {
171+ m.tr.addError(spec.bin + " is not on your PATH, so " + spec.marker + " has nothing to run")
172+ return m.sync()
173+ }
174+
175+ m.tr.startLocal(spec, line)
176+ ctx, cancel := context.WithCancel(m.parent)
177+ m.running = &localRun{spec: spec, command: line, started: time.Now(), cancel: cancel}
178+
179+ root, argv := m.opts.Root, spec.argv(line)
180+ return tea.Batch(m.sync(), m.spin.Tick, func() tea.Msg {
181+ report, failed := tools.RunCommand(ctx, root, argv, tools.DefaultCommandTimeout)
182+ cancel()
183+ return localDone{spec: spec, command: line, report: report, failed: failed}
184+ })
185+ }
186+
187+ // interruptLocal stops the command in flight. Its output still comes back and
188+ // still reaches the model: what a command managed to print before you stopped
189+ // it is evidence, not noise.
190+ func (m *model) interruptLocal() tea.Cmd {
191+ if m.running != nil {
192+ m.running.cancel()
193+ }
194+ return nil
195+ }
196+
197+ // localActivity is the line above the prompt while a command runs: which one,
198+ // and for how long. Without it a slow command looks like a frozen prompt.
199+ func (m *model) localActivity() string {
200+ r := m.running
201+ detail := fmt.Sprintf("%ds · esc to interrupt", int(time.Since(r.started).Seconds()))
202+ return m.spin.View() + " " +
203+ localStyle(localOf(r.spec)).Render("Running "+r.spec.marker+" "+r.command) +
204+ styDim.Render(" ("+detail+")")
205+ }
206+
207+ // localOf recovers a mode from its spec, so the running line can be painted in
208+ // the same colour the prompt marker was.
209+ func localOf(spec localSpec) localMode {
210+ for mode, s := range localSpecs {
211+ if s.marker == spec.marker {
212+ return mode
213+ }
214+ }
215+ return localShell
216+ }
217+
218+ // finishLocal lands a completed run: into the scrollback for you, and into the
219+ // conversation for the model. The second half is the point of the feature —
220+ // running `!go test` and then saying "fix that" only works if the agent was
221+ // shown the same output you were.
222+ //
223+ // Nothing is sent now. The message sits in history and goes up with the next
224+ // real turn, so several commands can be run before saying anything about them.
225+ func (m *model) finishLocal(msg localDone) tea.Cmd {
226+ if m.running != nil {
227+ m.running.cancel()
228+ m.running = nil
229+ }
230+ m.tr.finishLocal(msg.report, msg.failed)
231+ if m.opts.Agent != nil {
232+ m.opts.Agent.State().Append(llm.Message{
233+ Role: llm.RoleUser,
234+ Content: []llm.ContentBlock{llm.TextBlock(localMessage(msg))},
235+ })
236+ }
237+ return m.sync()
238+ }
239+
240+ // localMessage is what the model is told. It is the capped report the tool
241+ // would have produced, not the raw output: a noisy command should not be able
242+ // to cost more context from the prompt box than it would through the tool.
243+ func localMessage(msg localDone) string {
244+ return fmt.Sprintf("<local-command shell=%q>%s</local-command>\n<local-command-output>\n%s\n</local-command-output>",
245+ msg.spec.bin, msg.command, msg.report)
246+ }
+353−0internal/tui/local_test.goaddedView File
@@ -0,0 +1,353 @@
1+ package tui
2+
3+ import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/charmbracelet/bubbles/spinner"
10+ tea "github.com/charmbracelet/bubbletea"
11+
12+ "harness/internal/agent"
13+ "harness/internal/approval"
14+ "harness/internal/llm"
15+ "harness/internal/tools"
16+ )
17+
18+ // newLocalModel builds a model with a real agent behind it, so a local
19+ // command has somewhere to leave its transcript for the next turn.
20+ func newLocalModel(t *testing.T) *model {
21+ t.Helper()
22+ root := t.TempDir()
23+ client := llm.New(llm.WithAPIKey("x"))
24+ a := agent.New(client, tools.NewRegistry(nil), agent.NewState(), nil, agent.Options{})
25+ m := newModel(context.Background(), Options{
26+ Agent: a,
27+ Client: client,
28+ Root: root,
29+ Policy: approval.New(nil),
30+ })
31+ m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
32+ return m
33+ }
34+
35+ // drain runs a command the way Bubble Tea would and feeds every message it
36+ // produces back into the model, flattening batches. Returns what was seen.
37+ //
38+ // Spinner ticks are dropped rather than followed: each one schedules the next,
39+ // so a real program stops animating when the frame does and a test that
40+ // followed them would never return.
41+ func drain(t *testing.T, m *model, c tea.Cmd) []tea.Msg {
42+ t.Helper()
43+ if c == nil {
44+ return nil
45+ }
46+ msg := c()
47+ if batch, ok := msg.(tea.BatchMsg); ok {
48+ var out []tea.Msg
49+ for _, sub := range batch {
50+ out = append(out, drain(t, m, sub)...)
51+ }
52+ return out
53+ }
54+ if msg == nil {
55+ return nil
56+ }
57+ if _, tick := msg.(spinner.TickMsg); tick {
58+ return nil
59+ }
60+ _, next := m.Update(msg)
61+ return append([]tea.Msg{msg}, drain(t, m, next)...)
62+ }
63+
64+ func TestBangOnAnEmptyPromptEntersShellMode(t *testing.T) {
65+ m := newTestModel(t)
66+ m.Update(key("!"))
67+
68+ if m.local != localShell {
69+ t.Errorf("local = %v, want shell mode", m.local)
70+ }
71+ if v := m.input.Value(); v != "" {
72+ t.Errorf("prompt = %q: the ! is the mode, not the first character of the command", v)
73+ }
74+ }
75+
76+ func TestDollarOnAnEmptyPromptEntersPythonMode(t *testing.T) {
77+ m := newTestModel(t)
78+ m.Update(key("$"))
79+
80+ if m.local != localPython {
81+ t.Errorf("local = %v, want python mode", m.local)
82+ }
83+ if v := m.input.Value(); v != "" {
84+ t.Errorf("prompt = %q, want empty", v)
85+ }
86+ }
87+
88+ func TestBangAfterTextIsJustAnExclamationMark(t *testing.T) {
89+ m := newTestModel(t)
90+ typeIn(m, "it works!")
91+
92+ if m.local != localOff {
93+ t.Errorf("local = %v: a ! mid-sentence is prose, not a mode", m.local)
94+ }
95+ if v := m.input.Value(); v != "it works!" {
96+ t.Errorf("prompt = %q, want %q", v, "it works!")
97+ }
98+ }
99+
100+ func TestLocalModeIsNotOfferedWhileTheAgentIsWorking(t *testing.T) {
101+ m := newTestModel(t)
102+ m.phase = phaseRunning
103+ m.Update(key("!"))
104+
105+ if m.local != localOff {
106+ t.Errorf("local = %v: a turn is running, so there is nothing to type into", m.local)
107+ }
108+ }
109+
110+ func TestEscLeavesLocalMode(t *testing.T) {
111+ m := newTestModel(t)
112+ m.Update(key("!"))
113+ typeIn(m, "rm -rf /")
114+ m.Update(key("esc"))
115+
116+ if m.local != localOff {
117+ t.Errorf("local = %v, want off", m.local)
118+ }
119+ if v := m.input.Value(); v != "" {
120+ t.Errorf("prompt = %q: leaving the mode drops what was typed into it", v)
121+ }
122+ }
123+
124+ func TestBackspaceOnAnEmptyPromptLeavesLocalMode(t *testing.T) {
125+ m := newTestModel(t)
126+ m.Update(key("!"))
127+ m.Update(key("backspace"))
128+
129+ if m.local != localOff {
130+ t.Errorf("local = %v: backspacing over the ! undoes it", m.local)
131+ }
132+ }
133+
134+ func TestBackspaceWithTextTypedStaysInLocalMode(t *testing.T) {
135+ m := newTestModel(t)
136+ m.Update(key("!"))
137+ typeIn(m, "ls")
138+ m.Update(key("backspace"))
139+
140+ if m.local != localShell {
141+ t.Errorf("local = %v: backspace was editing the command, not leaving the mode", m.local)
142+ }
143+ if v := m.input.Value(); v != "l" {
144+ t.Errorf("prompt = %q, want %q", v, "l")
145+ }
146+ }
147+
148+ func TestShellModeMarksThePromptSoTheModeIsVisible(t *testing.T) {
149+ m := newTestModel(t)
150+ m.Update(key("!"))
151+ if !strings.HasPrefix(m.input.Prompt, "!") {
152+ t.Errorf("prompt marker = %q, want it to start with !", m.input.Prompt)
153+ }
154+
155+ m.Update(key("esc"))
156+ if strings.HasPrefix(m.input.Prompt, "!") {
157+ t.Errorf("prompt marker = %q, want the normal one back", m.input.Prompt)
158+ }
159+ }
160+
161+ func TestShellCommandRunsAndItsOutputReachesTheAgent(t *testing.T) {
162+ m := newLocalModel(t)
163+ m.Update(key("!"))
164+ typeIn(m, "echo hello-from-the-shell")
165+ _, cmd := m.Update(key("enter"))
166+ drain(t, m, cmd)
167+
168+ history := m.opts.Agent.State().History()
169+ if len(history) != 1 {
170+ t.Fatalf("history has %d messages, want the one local run", len(history))
171+ }
172+ msg := history[0]
173+ if msg.Role != llm.RoleUser {
174+ t.Errorf("role = %q, want user: it is something the human did", msg.Role)
175+ }
176+ text := msg.Content[0].Text
177+ if !strings.Contains(text, "echo hello-from-the-shell") {
178+ t.Errorf("the command itself is missing:\n%s", text)
179+ }
180+ if !strings.Contains(text, "hello-from-the-shell") {
181+ t.Errorf("the output is missing:\n%s", text)
182+ }
183+ if !strings.Contains(text, "exit code: 0") {
184+ t.Errorf("the exit code is missing:\n%s", text)
185+ }
186+ }
187+
188+ func TestAFailingShellCommandKeepsItsOutputAndExitCode(t *testing.T) {
189+ m := newLocalModel(t)
190+ m.Update(key("!"))
191+ typeIn(m, "echo nope >&2; exit 2")
192+ _, cmd := m.Update(key("enter"))
193+ drain(t, m, cmd)
194+
195+ text := m.opts.Agent.State().History()[0].Content[0].Text
196+ for _, want := range []string{"exit code: 2", "nope"} {
197+ if !strings.Contains(text, want) {
198+ t.Errorf("missing %q in:\n%s", want, text)
199+ }
200+ }
201+ }
202+
203+ func TestPythonModeRunsThroughTheInterpreter(t *testing.T) {
204+ m := newLocalModel(t)
205+ m.Update(key("$"))
206+ typeIn(m, "print(6 * 7)")
207+ _, cmd := m.Update(key("enter"))
208+ drain(t, m, cmd)
209+
210+ text := m.opts.Agent.State().History()[0].Content[0].Text
211+ if !strings.Contains(text, "42") {
212+ t.Errorf("want the interpreter's answer, got:\n%s", text)
213+ }
214+ }
215+
216+ func TestAMissingInterpreterSaysSoRatherThanFailingObscurely(t *testing.T) {
217+ t.Setenv("PATH", t.TempDir())
218+ m := newLocalModel(t)
219+ m.Update(key("$"))
220+ typeIn(m, "print(1)")
221+ _, cmd := m.Update(key("enter"))
222+ drain(t, m, cmd)
223+
224+ rendered := m.tr.render()
225+ if !strings.Contains(rendered, "python3") || !strings.Contains(rendered, "not on your PATH") {
226+ t.Errorf("want a plain explanation of the missing interpreter, got:\n%s", rendered)
227+ }
228+ if n := len(m.opts.Agent.State().History()); n != 0 {
229+ t.Errorf("history has %d messages: a command that never ran has nothing to tell the model", n)
230+ }
231+ }
232+
233+ func TestARunReturnsThePromptToNormalSoTheNextThingTypedIsAMessage(t *testing.T) {
234+ m := newLocalModel(t)
235+ m.Update(key("!"))
236+ typeIn(m, "true")
237+ _, cmd := m.Update(key("enter"))
238+ drain(t, m, cmd)
239+
240+ if m.local != localOff {
241+ t.Errorf("local = %v: the mode is per-command, like a shell prompt returning", m.local)
242+ }
243+ if v := m.input.Value(); v != "" {
244+ t.Errorf("prompt = %q, want it emptied", v)
245+ }
246+ }
247+
248+ func TestTheRunShowsUpInTheTranscriptWithItsOutput(t *testing.T) {
249+ m := newLocalModel(t)
250+ m.Update(key("!"))
251+ typeIn(m, "echo visible-in-scrollback")
252+ _, cmd := m.Update(key("enter"))
253+ drain(t, m, cmd)
254+
255+ rendered := m.tr.render()
256+ for _, want := range []string{"! echo visible-in-scrollback", "visible-in-scrollback"} {
257+ if !strings.Contains(rendered, want) {
258+ t.Errorf("missing %q in transcript:\n%s", want, rendered)
259+ }
260+ }
261+ }
262+
263+ func TestAnEmptyLocalCommandRunsNothing(t *testing.T) {
264+ m := newLocalModel(t)
265+ m.Update(key("!"))
266+ m.Update(key("enter"))
267+
268+ if n := len(m.opts.Agent.State().History()); n != 0 {
269+ t.Errorf("history has %d messages, want none", n)
270+ }
271+ if m.local != localShell {
272+ t.Errorf("local = %v: an empty enter is a no-op, not a way out of the mode", m.local)
273+ }
274+ }
275+
276+ // Two runs in flight at once would land out of order, and finishing one would
277+ // attach its output to the other's transcript entry.
278+ func TestASecondLocalCommandIsRefusedWhileOneIsStillRunning(t *testing.T) {
279+ m := newLocalModel(t)
280+ m.Update(key("!"))
281+ typeIn(m, "sleep 5")
282+ m.Update(key("enter")) // deliberately not drained: still in flight
283+
284+ m.Update(key("!"))
285+ typeIn(m, "echo second")
286+ m.Update(key("enter"))
287+
288+ var runs int
289+ for _, e := range m.tr.entries {
290+ if e.kind == entryLocal {
291+ runs++
292+ }
293+ }
294+ if runs != 1 {
295+ t.Errorf("%d local entries, want only the one still running", runs)
296+ }
297+ }
298+
299+ func TestAMessageToTheAgentWaitsForTheLocalCommandToFinish(t *testing.T) {
300+ m := newLocalModel(t)
301+ m.Update(key("!"))
302+ typeIn(m, "sleep 5")
303+ m.Update(key("enter"))
304+
305+ typeIn(m, "now fix that")
306+ m.Update(key("enter"))
307+
308+ if m.phase == phaseRunning {
309+ t.Error("the turn started before the command it is about had landed in the history")
310+ }
311+ if v := m.input.Value(); v != "now fix that" {
312+ t.Errorf("prompt = %q: the message was dropped rather than held", v)
313+ }
314+ }
315+
316+ func TestTheRunningCommandIsNamedWhileItWorks(t *testing.T) {
317+ m := newLocalModel(t)
318+ m.Update(key("!"))
319+ typeIn(m, "sleep 5")
320+ m.Update(key("enter"))
321+
322+ if !strings.Contains(m.activity(), "sleep 5") {
323+ t.Errorf("activity line = %q, want the running command named", m.activity())
324+ }
325+ }
326+
327+ func TestEscInterruptsARunningLocalCommand(t *testing.T) {
328+ m := newLocalModel(t)
329+ m.Update(key("!"))
330+ typeIn(m, "sleep 30")
331+ _, cmd := m.Update(key("enter"))
332+
333+ m.Update(key("esc"))
334+
335+ start := time.Now()
336+ drain(t, m, cmd)
337+ if elapsed := time.Since(start); elapsed > 3*time.Second {
338+ t.Errorf("the command ran for %s: esc never reached it", elapsed)
339+ }
340+ if m.running != nil {
341+ t.Error("the run is still marked in flight after it came back")
342+ }
343+ }
344+
345+ // A key with no command to complete it is invisible unless /help says so.
346+ func TestHelpNamesTheLocalCommandKeys(t *testing.T) {
347+ help := newTestModel(t).helpText()
348+ for _, want := range []string{"!", "$", "shell", "python"} {
349+ if !strings.Contains(help, want) {
350+ t.Errorf("/help does not mention %q:\n%s", want, help)
351+ }
352+ }
353+ }
+41−3internal/tui/model.goView File
@@ -76,6 +76,10 @@ const (
7676 // about six lines the box is eating the transcript it exists to talk about.
7777 const inputMaxLines = 6
7878
79+ // defaultPlaceholder is what the prompt box says when it is pointed at the
80+ // model, which the local modes replace with their own.
81+ const defaultPlaceholder = "ask for a change, /help, ! for a shell"
82+
7983 type model struct {
8084 opts Options
8185 bridge *bridge
@@ -114,6 +118,12 @@ type model struct {
114118 // menu is the slash-command completion list under the prompt.
115119 menu menu
116120
121+ // local is which interpreter the prompt box is pointed at: off, "!" for
122+ // the shell, "$" for python. running is the command one of them started,
123+ // or nil.
124+ local localMode
125+ running *localRun
126+
117127 // effortIdx is where the marker sits while the effort slider is up.
118128 effortIdx int
119129
@@ -169,7 +179,7 @@ func newModel(ctx context.Context, opts Options) *model {
169179 applyTheme(resolveTheme(opts.Config))
170180
171181 ta := textarea.New()
172- ta.Placeholder = "ask for a change, or /help"
182+ ta.Placeholder = defaultPlaceholder
173183 ta.Prompt = "› "
174184 ta.ShowLineNumbers = false
175185 ta.CharLimit = 0
@@ -347,7 +357,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
347357 return m, m.onModels(msg)
348358
349359 case spinner.TickMsg:
350- if m.phase == phaseRunning || m.modelsLoading {
360+ if m.phase == phaseRunning || m.modelsLoading || m.running != nil {
351361 var cmd tea.Cmd
352362 m.spin, cmd = m.spin.Update(msg)
353363 return m, cmd
@@ -393,6 +403,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
393403
394404 case turnDone:
395405 return m, m.finishTurn(msg)
406+
407+ case localDone:
408+ return m, m.finishLocal(msg)
396409 }
397410
398411 return m, nil
@@ -488,6 +501,12 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
488501 }
489502 }
490503
504+ // The local-command modes own their opening marker, and once one is open,
505+ // the keys that run and leave it.
506+ if cmd, took := m.onLocalKey(msg); took {
507+ return m, cmd
508+ }
509+
491510 switch msg.String() {
492511 case "ctrl+c":
493512 // While a turn runs, ctrl-c interrupts it and leaves the session
@@ -502,6 +521,9 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
502521 if m.phase == phaseRunning {
503522 return m, m.interrupt()
504523 }
524+ if m.running != nil {
525+ return m, m.interruptLocal()
526+ }
505527 return m, nil
506528
507529 case "ctrl+d":
@@ -529,6 +551,13 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
529551 if m.phase == phaseRunning {
530552 return m, nil // the agent is busy; queueing input would be a lie
531553 }
554+ if m.running != nil {
555+ // A local command is still running and its output is the next
556+ // thing to reach the model. Sending now would put the message
557+ // ahead of the output it is almost certainly about, so the text
558+ // is held in the box rather than dropped.
559+ return m, nil
560+ }
532561 line := strings.TrimSpace(m.input.Value())
533562 if line == "" {
534563 return m, nil
@@ -825,6 +854,10 @@ func (m *model) bottom() string {
825854 // it reports the level this session is running at, and the command that
826855 // changes that is the one worth naming beside it.
827856 func (m *model) activity() string {
857+ if m.running != nil {
858+ return spread(m.localActivity(), "", m.width)
859+ }
860+
828861 var left string
829862 switch m.phase {
830863 case phaseRunning:
@@ -859,8 +892,13 @@ func (m *model) boxWidth() int { return maxInt(14, m.width) }
859892
860893 func (m *model) inputBox() string {
861894 style := styBoxFocus
862- if m.phase == phaseRunning {
895+ switch {
896+ case m.phase == phaseRunning:
863897 style = styBoxBlur
898+ case m.local != localOff:
899+ // The frame is the always-present chrome, so it is where a mode that
900+ // changes what enter does has to be visible.
901+ style = styBoxFocus.BorderForeground(localStyle(m.local).GetForeground())
864902 }
865903 return style.Width(m.boxWidth() - 2).Render(m.input.View())
866904 }
+2−0internal/tui/model_test.goView File
@@ -40,6 +40,8 @@ func key(s string) tea.KeyMsg {
4040 return tea.KeyMsg{Type: tea.KeyShiftTab}
4141 case "tab":
4242 return tea.KeyMsg{Type: tea.KeyTab}
43+ case "backspace":
44+ return tea.KeyMsg{Type: tea.KeyBackspace}
4345 case "up":
4446 return tea.KeyMsg{Type: tea.KeyUp}
4547 case "down":
+6−0internal/tui/styles.goView File
@@ -50,6 +50,12 @@ var (
5050 styToolArgs lipgloss.Style
5151 styResult lipgloss.Style
5252
53+ // The local-command modes: one colour each, carried by both the prompt
54+ // marker and the transcript entry so a run is recognisably the same thing
55+ // in both places.
56+ styLocalShell lipgloss.Style
57+ styLocalPython lipgloss.Style
58+
5359 styDiffAdd lipgloss.Style
5460 styDiffDel lipgloss.Style
5561 styDiffHunk lipgloss.Style
+3−0internal/tui/theme.goView File
@@ -156,6 +156,9 @@ func applyTheme(name string) {
156156 styToolArgs = lipgloss.NewStyle().Foreground(colDim)
157157 styResult = lipgloss.NewStyle().Foreground(colDim)
158158
159+ styLocalShell = lipgloss.NewStyle().Foreground(colOK)
160+ styLocalPython = lipgloss.NewStyle().Foreground(colBlue)
161+
159162 styDiffAdd = lipgloss.NewStyle().Foreground(colOK)
160163 styDiffDel = lipgloss.NewStyle().Foreground(colErr)
161164 styDiffHunk = lipgloss.NewStyle().Foreground(colCoral)
+52−0internal/tui/transcript.goView File
@@ -21,6 +21,9 @@ const (
2121 // entryRaw is already-styled text the transcript passes through
2222 // untouched: the welcome block, which owns its own layout.
2323 entryRaw
24+ // entryLocal is a command the human ran from the prompt box rather than
25+ // something the model asked for.
26+ entryLocal
2427 )
2528
2629 // entry is one thing that happened, kept structured rather than pre-rendered
@@ -122,6 +125,30 @@ func (t *transcript) appendDelta(s string) {
122125 t.dirty = true
123126 }
124127
128+ // startLocal opens an entry for a command the human is running themselves.
129+ // It reuses the tool entry's shape — the marker where the tool name goes, the
130+ // command where its arguments go — so the result renders through the same
131+ // collapsing and the same ctrl+r.
132+ func (t *transcript) startLocal(spec localSpec, command string) {
133+ t.closeOpen()
134+ t.entries = append(t.entries, entry{kind: entryLocal, tool: spec.marker, input: command, open: true})
135+ t.dirty = true
136+ }
137+
138+ // finishLocal attaches the report to the run still waiting for one.
139+ func (t *transcript) finishLocal(result string, isErr bool) {
140+ for i := len(t.entries) - 1; i >= 0; i-- {
141+ if t.entries[i].kind != entryLocal || !t.entries[i].open {
142+ continue
143+ }
144+ t.entries[i].result, t.entries[i].isError, t.entries[i].open = result, isErr, false
145+ t.dirty = true
146+ return
147+ }
148+ t.entries = append(t.entries, entry{kind: entryLocal, result: result, isError: isErr})
149+ t.dirty = true
150+ }
151+
125152 func (t *transcript) startTool(name, input string) {
126153 t.closeOpen()
127154 t.entries = append(t.entries, entry{kind: entryTool, tool: name, input: input, open: true})
@@ -200,6 +227,9 @@ func (t *transcript) renderEntry(e entry) string {
200227
201228 case entryTool:
202229 return t.renderTool(e, width)
230+
231+ case entryLocal:
232+ return t.renderLocal(e, width)
203233 }
204234 return ""
205235 }
@@ -231,6 +261,28 @@ func (t *transcript) renderTool(e entry, width int) string {
231261 if e.note != "" {
232262 b.WriteString(styDim.Render(gutter+branch+" "+e.note) + "\n")
233263 }
264+ b.WriteString(t.renderResult(e, width))
265+ return b.String()
266+ }
267+
268+ // renderLocal draws a command the human ran: the marker and the command in
269+ // the mode's colour, then the same result body a tool call gets. The colour is
270+ // the whole header rather than the marker alone, so a run is scannable in a
271+ // wall of tool calls without reading it.
272+ func (t *transcript) renderLocal(e entry, width int) string {
273+ style := styLocalShell
274+ if e.tool == localSpecs[localPython].marker {
275+ style = styLocalPython
276+ }
277+ head := truncate(e.tool+" "+e.input, maxInt(10, width-2))
278+ return style.Render(dot+" "+head) + "\n" + t.renderResult(e, width)
279+ }
280+
281+ // renderResult draws what came back from a call — the waiting marker, a diff
282+ // in colour, or the output collapsed to a few lines with ctrl+r to see it all.
283+ func (t *transcript) renderResult(e entry, width int) string {
284+ var b strings.Builder
285+
234286 if e.open {
235287 b.WriteString(styFaint.Render(gutter+branch+" …") + "\n")
236288 return b.String()