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>
Showing 11 changed files with 860 additions and 51 deletions.
| @@ -97,6 +97,8 @@ to six lines. | ||
| 97 | 97 | | --- | --- | |
| 98 | 98 | | `enter` | send | |
| 99 | 99 | | `/` | open the command menu — see below | |
| 100 | + | `!` | run a shell command yourself — see below | | |
| 101 | + | `$` | the same, through `python3` | | |
| 100 | 102 | | `ctrl+j` | newline without sending | |
| 101 | 103 | | `shift+tab` | cycle the approval mode | |
| 102 | 104 | | `ctrl+r` | expand elided tool output | |
| @@ -126,6 +128,50 @@ level for `/effort` — and `esc` dismisses the menu without touching what you | ||
| 126 | 128 | typed. The menu offers exactly the commands the TUI answers, so nothing it |
| 127 | 129 | suggests can come back as "unknown command". |
| 128 | 130 | |
| 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 | + | |
| 129 | 175 | `/model` asks the endpoint what it serves — `GET /v1/models`, paginated — and |
| 130 | 176 | opens a picker on the answer: |
| 131 | 177 | |
| @@ -11,14 +11,82 @@ import ( | ||
| 11 | 11 | ) |
| 12 | 12 | |
| 13 | 13 | 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 | |
| 16 | 18 | // Output is capped head and tail. Head-only truncation would cut off the |
| 17 | 19 | // end of a test run, which is exactly where the failure is. |
| 18 | 20 | bashHeadLines = 100 |
| 19 | 21 | bashTailLines = 100 |
| 20 | 22 | ) |
| 21 | 23 | |
| 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 | + | |
| 22 | 90 | // BashArgs is the bash input. |
| 23 | 91 | type BashArgs struct { |
| 24 | 92 | 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.`), | ||
| 36 | 104 | if strings.TrimSpace(in.Command) == "" { |
| 37 | 105 | return "", fmt.Errorf("command is required") |
| 38 | 106 | } |
| 39 | - timeout := defaultBashTimeout | |
| 107 | + timeout := DefaultCommandTimeout | |
| 40 | 108 | if in.TimeoutMS > 0 { |
| 41 | 109 | timeout = time.Duration(in.TimeoutMS) * time.Millisecond |
| 42 | 110 | } |
| 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()) | |
| 86 | 111 | |
| 87 | - out := strings.TrimRight(b.String(), "\n") | |
| 112 | + out, failed := RunCommand(ctx, root, []string{"bash", "-c", in.Command}, timeout) | |
| 88 | 113 | // A non-zero exit is reported as an error so the model sees it as a |
| 89 | 114 | // failure it has to respond to, with the output still attached. |
| 90 | - if timedOut || exitCode != 0 { | |
| 115 | + if failed { | |
| 91 | 116 | return "", fmt.Errorf("%s", out) |
| 92 | 117 | } |
| 93 | 118 | return out, nil |
| @@ -195,3 +195,39 @@ func TestWriteFilePreservesCRLFOnRewrite(t *testing.T) { | ||
| 195 | 195 | t.Errorf("got %q, want CRLF preserved", got) |
| 196 | 196 | } |
| 197 | 197 | } |
| 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 | + } | |
| @@ -79,6 +79,8 @@ func (m *model) helpText() string { | ||
| 79 | 79 | fmt.Fprintf(&b, "\n %-18s %s", "/<skill>", plural(n, "discovered skill")+" — /skills lists them") |
| 80 | 80 | } |
| 81 | 81 | 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\"" + | |
| 82 | 84 | "\n / opens the command menu · ↑↓ move · tab or enter complete · esc dismiss" + |
| 83 | 85 | "\n ctrl+r toggle full tool output · pgup/pgdn scroll" + |
| 84 | 86 | "\n esc or ctrl+c interrupt a turn · ctrl+c again to exit") |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -76,6 +76,10 @@ const ( | ||
| 76 | 76 | // about six lines the box is eating the transcript it exists to talk about. |
| 77 | 77 | const inputMaxLines = 6 |
| 78 | 78 | |
| 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 | + | |
| 79 | 83 | type model struct { |
| 80 | 84 | opts Options |
| 81 | 85 | bridge *bridge |
| @@ -114,6 +118,12 @@ type model struct { | ||
| 114 | 118 | // menu is the slash-command completion list under the prompt. |
| 115 | 119 | menu menu |
| 116 | 120 | |
| 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 | + | |
| 117 | 127 | // effortIdx is where the marker sits while the effort slider is up. |
| 118 | 128 | effortIdx int |
| 119 | 129 | |
| @@ -169,7 +179,7 @@ func newModel(ctx context.Context, opts Options) *model { | ||
| 169 | 179 | applyTheme(resolveTheme(opts.Config)) |
| 170 | 180 | |
| 171 | 181 | ta := textarea.New() |
| 172 | - ta.Placeholder = "ask for a change, or /help" | |
| 182 | + ta.Placeholder = defaultPlaceholder | |
| 173 | 183 | ta.Prompt = "› " |
| 174 | 184 | ta.ShowLineNumbers = false |
| 175 | 185 | ta.CharLimit = 0 |
| @@ -347,7 +357,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 347 | 357 | return m, m.onModels(msg) |
| 348 | 358 | |
| 349 | 359 | case spinner.TickMsg: |
| 350 | - if m.phase == phaseRunning || m.modelsLoading { | |
| 360 | + if m.phase == phaseRunning || m.modelsLoading || m.running != nil { | |
| 351 | 361 | var cmd tea.Cmd |
| 352 | 362 | m.spin, cmd = m.spin.Update(msg) |
| 353 | 363 | return m, cmd |
| @@ -393,6 +403,9 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | ||
| 393 | 403 | |
| 394 | 404 | case turnDone: |
| 395 | 405 | return m, m.finishTurn(msg) |
| 406 | + | |
| 407 | + case localDone: | |
| 408 | + return m, m.finishLocal(msg) | |
| 396 | 409 | } |
| 397 | 410 | |
| 398 | 411 | return m, nil |
| @@ -488,6 +501,12 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 488 | 501 | } |
| 489 | 502 | } |
| 490 | 503 | |
| 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 | + | |
| 491 | 510 | switch msg.String() { |
| 492 | 511 | case "ctrl+c": |
| 493 | 512 | // 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) { | ||
| 502 | 521 | if m.phase == phaseRunning { |
| 503 | 522 | return m, m.interrupt() |
| 504 | 523 | } |
| 524 | + if m.running != nil { | |
| 525 | + return m, m.interruptLocal() | |
| 526 | + } | |
| 505 | 527 | return m, nil |
| 506 | 528 | |
| 507 | 529 | case "ctrl+d": |
| @@ -529,6 +551,13 @@ func (m *model) onKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { | ||
| 529 | 551 | if m.phase == phaseRunning { |
| 530 | 552 | return m, nil // the agent is busy; queueing input would be a lie |
| 531 | 553 | } |
| 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 | + } | |
| 532 | 561 | line := strings.TrimSpace(m.input.Value()) |
| 533 | 562 | if line == "" { |
| 534 | 563 | return m, nil |
| @@ -825,6 +854,10 @@ func (m *model) bottom() string { | ||
| 825 | 854 | // it reports the level this session is running at, and the command that |
| 826 | 855 | // changes that is the one worth naming beside it. |
| 827 | 856 | func (m *model) activity() string { |
| 857 | + if m.running != nil { | |
| 858 | + return spread(m.localActivity(), "", m.width) | |
| 859 | + } | |
| 860 | + | |
| 828 | 861 | var left string |
| 829 | 862 | switch m.phase { |
| 830 | 863 | case phaseRunning: |
| @@ -859,8 +892,13 @@ func (m *model) boxWidth() int { return maxInt(14, m.width) } | ||
| 859 | 892 | |
| 860 | 893 | func (m *model) inputBox() string { |
| 861 | 894 | style := styBoxFocus |
| 862 | - if m.phase == phaseRunning { | |
| 895 | + switch { | |
| 896 | + case m.phase == phaseRunning: | |
| 863 | 897 | 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()) | |
| 864 | 902 | } |
| 865 | 903 | return style.Width(m.boxWidth() - 2).Render(m.input.View()) |
| 866 | 904 | } |
| @@ -40,6 +40,8 @@ func key(s string) tea.KeyMsg { | ||
| 40 | 40 | return tea.KeyMsg{Type: tea.KeyShiftTab} |
| 41 | 41 | case "tab": |
| 42 | 42 | return tea.KeyMsg{Type: tea.KeyTab} |
| 43 | + case "backspace": | |
| 44 | + return tea.KeyMsg{Type: tea.KeyBackspace} | |
| 43 | 45 | case "up": |
| 44 | 46 | return tea.KeyMsg{Type: tea.KeyUp} |
| 45 | 47 | case "down": |
| @@ -50,6 +50,12 @@ var ( | ||
| 50 | 50 | styToolArgs lipgloss.Style |
| 51 | 51 | styResult lipgloss.Style |
| 52 | 52 | |
| 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 | + | |
| 53 | 59 | styDiffAdd lipgloss.Style |
| 54 | 60 | styDiffDel lipgloss.Style |
| 55 | 61 | styDiffHunk lipgloss.Style |
| @@ -156,6 +156,9 @@ func applyTheme(name string) { | ||
| 156 | 156 | styToolArgs = lipgloss.NewStyle().Foreground(colDim) |
| 157 | 157 | styResult = lipgloss.NewStyle().Foreground(colDim) |
| 158 | 158 | |
| 159 | + styLocalShell = lipgloss.NewStyle().Foreground(colOK) | |
| 160 | + styLocalPython = lipgloss.NewStyle().Foreground(colBlue) | |
| 161 | + | |
| 159 | 162 | styDiffAdd = lipgloss.NewStyle().Foreground(colOK) |
| 160 | 163 | styDiffDel = lipgloss.NewStyle().Foreground(colErr) |
| 161 | 164 | styDiffHunk = lipgloss.NewStyle().Foreground(colCoral) |
| @@ -21,6 +21,9 @@ const ( | ||
| 21 | 21 | // entryRaw is already-styled text the transcript passes through |
| 22 | 22 | // untouched: the welcome block, which owns its own layout. |
| 23 | 23 | entryRaw |
| 24 | + // entryLocal is a command the human ran from the prompt box rather than | |
| 25 | + // something the model asked for. | |
| 26 | + entryLocal | |
| 24 | 27 | ) |
| 25 | 28 | |
| 26 | 29 | // entry is one thing that happened, kept structured rather than pre-rendered |
| @@ -122,6 +125,30 @@ func (t *transcript) appendDelta(s string) { | ||
| 122 | 125 | t.dirty = true |
| 123 | 126 | } |
| 124 | 127 | |
| 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 | + | |
| 125 | 152 | func (t *transcript) startTool(name, input string) { |
| 126 | 153 | t.closeOpen() |
| 127 | 154 | 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 { | ||
| 200 | 227 | |
| 201 | 228 | case entryTool: |
| 202 | 229 | return t.renderTool(e, width) |
| 230 | + | |
| 231 | + case entryLocal: | |
| 232 | + return t.renderLocal(e, width) | |
| 203 | 233 | } |
| 204 | 234 | return "" |
| 205 | 235 | } |
| @@ -231,6 +261,28 @@ func (t *transcript) renderTool(e entry, width int) string { | ||
| 231 | 261 | if e.note != "" { |
| 232 | 262 | b.WriteString(styDim.Render(gutter+branch+" "+e.note) + "\n") |
| 233 | 263 | } |
| 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 | + | |
| 234 | 286 | if e.open { |
| 235 | 287 | b.WriteString(styFaint.Render(gutter+branch+" …") + "\n") |
| 236 | 288 | return b.String() |