bots-garden/mini-mepublic Fork
⑂ 9136e38
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-09-21T05:47:47+02:00 Browse files
9136e38 parent: 9073268
modified .tickets/issues/0022-add-a-version-flag.yaml +2 -2
@@ -1,12 +1,12 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 22
33 title: Add a version flag
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-17T13:35:08.191Z
9-updatedAt: 2026-09-18T18:50:47.630Z
9+updatedAt: 2026-09-20T03:20:04.331Z
1010 labels: []
1111 milestone: v0.0.3
1212 body: ""
@@ -1,12 +1,12 @@
1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.1 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2 id: 222 id: 22
3 title: Add a version flag3 title: Add a version flag
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-17T13:35:08.191Z8 createdAt: 2026-09-17T13:35:08.191Z
9-updatedAt: 2026-09-18T18:50:47.630Z9+updatedAt: 2026-09-20T03:20:04.331Z
10 labels: []10 labels: []
11 milestone: v0.0.311 milestone: v0.0.3
12 body: ""12 body: ""
added .tickets/issues/0025-tasks-plan-builtin.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 25
3+title: Tasks / Plan builtin
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-19T16:06:33.119Z
9+updatedAt: 2026-09-19T16:06:33.119Z
10+labels: []
11+milestone: v0.04
12+body: ""
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 25
3+title: Tasks / Plan builtin
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-19T16:06:33.119Z
9+updatedAt: 2026-09-19T16:06:33.119Z
10+labels: []
11+milestone: v0.04
12+body: ""
13+tasks: []
14+comments: []
added .tickets/issues/0026-unable-to-quit-mini-me-when-completion-is-stuck.yaml +110 -0
new file mode 100644
@@ -0,0 +1,110 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 26
3+title: unable to quit Mini Me when completion is stuck (TUI mode)
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-19T20:02:05.331Z
9+updatedAt: 2026-09-20T08:16:40.710Z
10+labels:
11+ - priority::urgent
12+ - kind::bug
13+ - To be tested
14+milestone: v0.0.3
15+body: |
16+ ## Problem
17+
18+ In the window mode (`mm -tui`), when the completion is stuck — the engine stops answering, or a tool never returns — the window stays perfectly responsive: the box takes text, `/` opens the picker, `/quit` and `/abort` can be typed. They simply have no effect. `Ctrl-C` has none either, however many times it is pressed. The only way out is to kill `mm` from another terminal.
19+
20+ ## Cause (Claude, 2026-09-20)
21+
22+ Two independent defects, one behind the other.
23+
24+ **1. The `bash` tool could block for good.** `runBash` collected the output with `cmd.CombinedOutput()` — a pipe — and `os/exec` waits for EVERY process holding the write end. A command that backgrounds a job without redirecting BOTH streams (`nohup sleep 600 & echo started`; the demo's own prompts ask for exactly that kind of server) hands that end to a process that outlives the shell, so `cmd.Wait()` waits for an EOF that never comes. Killing bash does not help — the grandchild holds the descriptor. Measured under a 5s context: still blocked after 60s. The turn's goroutine therefore never reported back, and nothing that waited for it could ever run. It also hung off `context.Background()`, so cancelling a turn never reached the command that was actually running.
25+
26+ **2. The window queued the way out behind the turn.** `runner.submit` put EVERY input into `pending` while busy, `/quit` and `/abort` included, to be dispatched once the turn had settled — which is exactly what a stuck turn never does. And `handleWindowKey` read `Ctrl-C` as "cancel if there is a turn, quit otherwise": `cancelTurn` reported true forever, so Ctrl-C could never reach the quit. The line-mode REPL had neither defect (`agent.go` returns on `/quit` from inside the waiting room), which is why the bug is window-only.
27+
28+ ## Fix
29+
30+ `internal/tools/tools.go` — `runBash(ctx, dir, command)` takes the turn's context (30s budget as a child of it), so a cancelled turn kills the command (`[error: cancelled]`). The output now goes to an unlinked temporary file rather than a pipe: `os/exec` hands a descriptor to the child as-is, so `Wait` returns as soon as bash exits (measured: 1 ms on the command that used to block for ever) and the background job keeps running — which is the point, since the user asked for that server. `collected` reads the file back, keeping both ends of a very large output. `cmd.WaitDelay = 2s` stays as a bound on a process that will not die.
31+
32+ Rejected: `WaitDelay` alone. It does fix the hang, but it closes the pipes, and the background server then dies of SIGPIPE on its next write — measured: the `python3 -m http.server` the model had just been asked to start was gone, so the `curl` of the next step would have failed.
33+
34+ `internal/tui/turn.go` → the command layer moved to a new `internal/tui/commands.go` (`turn.go` crossed qlty's file-complexity threshold otherwise — the split, not a threshold change, is the fix). `submit` runs `/quit` and `/abort` through a new `control` step BEFORE the busy check, so they never queue. `abort` says which of the three things happened: nothing running, cancelled, or the turn is not listening. `cancelTurn` returns false on the SECOND cancellation of the same job (`runner.cancelled`, reset per job), which is what turns the second `Ctrl-C` into a quit in `events.go`. Escape goes through the new `abort` hook so it says so on screen.
35+
36+ ## Verified
37+
38+ Unit: `TestQuitLeavesATurnThatWillNotSettle`, `TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing`, `TestCtrlCEscalatesOnATurnThatWillNotStop`, `TestCancellationIsForgottenBetweenTurns` (a `deaf` fake model that ignores its context), `TestBashDoesNotWaitForABackgroundJob` (returns fast AND the job is still serving), `TestBashKeepsBothEndsOfAHugeOutput`, `TestBashStopsWithTheTurn`, `TestBashReportsOutputAndExitCode`. All three window tests fail on the pre-fix code.
39+
40+ Real DMR (`qwen3.5-4b`, `watchdogTimeout: 120s`), asked to start `nohup python3 -m http.server 8099 & echo started`: the tool answered `started` at once, the model reported success, the turn completed.
41+
42+ End to end, window mode on a pseudo-terminal, config derived from `demo/agent.lsp.yaml` against a fake engine that asks for `nohup sleep 600 & echo started`:
43+
44+ | Binary | `/quit` | 2× `Ctrl-C` |
45+ |--------|---------|-------------|
46+ | before | no effect | no effect — SIGKILL from outside |
47+ | after (both fixes) | exits; the tool returns in 0.00s, output `started`, and the background job is still alive | n/a |
48+ | after (window fix only, old bash — a genuinely stuck turn) | exits | exits; Esc twice shows `🛑 Aborting…` then `⚠️ The turn is not answering the cancellation …` |
49+
50+ Quality gate: run 26 — 10 warnings, 20 smells, the same list as the last clean runs (20/22/23/24). `go test ./...`, `gofmt`, `go vet` green.
51+tasks:
52+ - id: 1
53+ title: "bash: run under the turn's context so a cancellation reaches the command"
54+ state: closed
55+ priority: high
56+ author:
57+ name: k33g
58+ email: ph.charriere@gmail.com
59+ createdAt: 2026-09-20T04:00:00.000Z
60+ updatedAt: 2026-09-20T04:30:00.000Z
61+ closedAt: 2026-09-20T04:30:00.000Z
62+ - id: 2
63+ title: "bash: collect the output through a file, not a pipe, so a background job cannot block the turn for ever"
64+ state: closed
65+ priority: high
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-09-20T04:00:00.000Z
70+ updatedAt: 2026-09-20T04:30:00.000Z
71+ closedAt: 2026-09-20T04:30:00.000Z
72+ - id: 3
73+ title: "TUI: /quit and /abort act immediately instead of queueing behind the running turn"
74+ state: closed
75+ priority: high
76+ author:
77+ name: k33g
78+ email: ph.charriere@gmail.com
79+ createdAt: 2026-09-20T04:00:00.000Z
80+ updatedAt: 2026-09-20T04:30:00.000Z
81+ closedAt: 2026-09-20T04:30:00.000Z
82+ - id: 4
83+ title: "TUI: a second Ctrl-C leaves a turn that ignores its cancellation; Escape says so"
84+ state: closed
85+ priority: high
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-09-20T04:00:00.000Z
90+ updatedAt: 2026-09-20T04:30:00.000Z
91+ closedAt: 2026-09-20T04:30:00.000Z
92+ - id: 5
93+ title: Tests and EN+FR docs (slash-commands, tools, use-the-window-mode)
94+ state: closed
95+ priority: none
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-09-20T04:00:00.000Z
100+ updatedAt: 2026-09-20T04:30:00.000Z
101+ closedAt: 2026-09-20T04:30:00.000Z
102+ - id: 6
103+ title: Check on macOS with the installed binary, in a real terminal
104+ state: open
105+ priority: medium
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-09-20T04:00:00.000Z
110+comments: []
new file mode 100644
@@ -0,0 +1,110 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 26
3+title: unable to quit Mini Me when completion is stuck (TUI mode)
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-19T20:02:05.331Z
9+updatedAt: 2026-09-20T08:16:40.710Z
10+labels:
11+ - priority::urgent
12+ - kind::bug
13+ - To be tested
14+milestone: v0.0.3
15+body: |
16+ ## Problem
17+
18+ In the window mode (`mm -tui`), when the completion is stuck — the engine stops answering, or a tool never returns — the window stays perfectly responsive: the box takes text, `/` opens the picker, `/quit` and `/abort` can be typed. They simply have no effect. `Ctrl-C` has none either, however many times it is pressed. The only way out is to kill `mm` from another terminal.
19+
20+ ## Cause (Claude, 2026-09-20)
21+
22+ Two independent defects, one behind the other.
23+
24+ **1. The `bash` tool could block for good.** `runBash` collected the output with `cmd.CombinedOutput()` — a pipe — and `os/exec` waits for EVERY process holding the write end. A command that backgrounds a job without redirecting BOTH streams (`nohup sleep 600 & echo started`; the demo's own prompts ask for exactly that kind of server) hands that end to a process that outlives the shell, so `cmd.Wait()` waits for an EOF that never comes. Killing bash does not help — the grandchild holds the descriptor. Measured under a 5s context: still blocked after 60s. The turn's goroutine therefore never reported back, and nothing that waited for it could ever run. It also hung off `context.Background()`, so cancelling a turn never reached the command that was actually running.
25+
26+ **2. The window queued the way out behind the turn.** `runner.submit` put EVERY input into `pending` while busy, `/quit` and `/abort` included, to be dispatched once the turn had settled — which is exactly what a stuck turn never does. And `handleWindowKey` read `Ctrl-C` as "cancel if there is a turn, quit otherwise": `cancelTurn` reported true forever, so Ctrl-C could never reach the quit. The line-mode REPL had neither defect (`agent.go` returns on `/quit` from inside the waiting room), which is why the bug is window-only.
27+
28+ ## Fix
29+
30+ `internal/tools/tools.go` — `runBash(ctx, dir, command)` takes the turn's context (30s budget as a child of it), so a cancelled turn kills the command (`[error: cancelled]`). The output now goes to an unlinked temporary file rather than a pipe: `os/exec` hands a descriptor to the child as-is, so `Wait` returns as soon as bash exits (measured: 1 ms on the command that used to block for ever) and the background job keeps running — which is the point, since the user asked for that server. `collected` reads the file back, keeping both ends of a very large output. `cmd.WaitDelay = 2s` stays as a bound on a process that will not die.
31+
32+ Rejected: `WaitDelay` alone. It does fix the hang, but it closes the pipes, and the background server then dies of SIGPIPE on its next write — measured: the `python3 -m http.server` the model had just been asked to start was gone, so the `curl` of the next step would have failed.
33+
34+ `internal/tui/turn.go` → the command layer moved to a new `internal/tui/commands.go` (`turn.go` crossed qlty's file-complexity threshold otherwise — the split, not a threshold change, is the fix). `submit` runs `/quit` and `/abort` through a new `control` step BEFORE the busy check, so they never queue. `abort` says which of the three things happened: nothing running, cancelled, or the turn is not listening. `cancelTurn` returns false on the SECOND cancellation of the same job (`runner.cancelled`, reset per job), which is what turns the second `Ctrl-C` into a quit in `events.go`. Escape goes through the new `abort` hook so it says so on screen.
35+
36+ ## Verified
37+
38+ Unit: `TestQuitLeavesATurnThatWillNotSettle`, `TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing`, `TestCtrlCEscalatesOnATurnThatWillNotStop`, `TestCancellationIsForgottenBetweenTurns` (a `deaf` fake model that ignores its context), `TestBashDoesNotWaitForABackgroundJob` (returns fast AND the job is still serving), `TestBashKeepsBothEndsOfAHugeOutput`, `TestBashStopsWithTheTurn`, `TestBashReportsOutputAndExitCode`. All three window tests fail on the pre-fix code.
39+
40+ Real DMR (`qwen3.5-4b`, `watchdogTimeout: 120s`), asked to start `nohup python3 -m http.server 8099 & echo started`: the tool answered `started` at once, the model reported success, the turn completed.
41+
42+ End to end, window mode on a pseudo-terminal, config derived from `demo/agent.lsp.yaml` against a fake engine that asks for `nohup sleep 600 & echo started`:
43+
44+ | Binary | `/quit` | 2× `Ctrl-C` |
45+ |--------|---------|-------------|
46+ | before | no effect | no effect — SIGKILL from outside |
47+ | after (both fixes) | exits; the tool returns in 0.00s, output `started`, and the background job is still alive | n/a |
48+ | after (window fix only, old bash — a genuinely stuck turn) | exits | exits; Esc twice shows `🛑 Aborting…` then `⚠️ The turn is not answering the cancellation …` |
49+
50+ Quality gate: run 26 — 10 warnings, 20 smells, the same list as the last clean runs (20/22/23/24). `go test ./...`, `gofmt`, `go vet` green.
51+tasks:
52+ - id: 1
53+ title: "bash: run under the turn's context so a cancellation reaches the command"
54+ state: closed
55+ priority: high
56+ author:
57+ name: k33g
58+ email: ph.charriere@gmail.com
59+ createdAt: 2026-09-20T04:00:00.000Z
60+ updatedAt: 2026-09-20T04:30:00.000Z
61+ closedAt: 2026-09-20T04:30:00.000Z
62+ - id: 2
63+ title: "bash: collect the output through a file, not a pipe, so a background job cannot block the turn for ever"
64+ state: closed
65+ priority: high
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-09-20T04:00:00.000Z
70+ updatedAt: 2026-09-20T04:30:00.000Z
71+ closedAt: 2026-09-20T04:30:00.000Z
72+ - id: 3
73+ title: "TUI: /quit and /abort act immediately instead of queueing behind the running turn"
74+ state: closed
75+ priority: high
76+ author:
77+ name: k33g
78+ email: ph.charriere@gmail.com
79+ createdAt: 2026-09-20T04:00:00.000Z
80+ updatedAt: 2026-09-20T04:30:00.000Z
81+ closedAt: 2026-09-20T04:30:00.000Z
82+ - id: 4
83+ title: "TUI: a second Ctrl-C leaves a turn that ignores its cancellation; Escape says so"
84+ state: closed
85+ priority: high
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-09-20T04:00:00.000Z
90+ updatedAt: 2026-09-20T04:30:00.000Z
91+ closedAt: 2026-09-20T04:30:00.000Z
92+ - id: 5
93+ title: Tests and EN+FR docs (slash-commands, tools, use-the-window-mode)
94+ state: closed
95+ priority: none
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-09-20T04:00:00.000Z
100+ updatedAt: 2026-09-20T04:30:00.000Z
101+ closedAt: 2026-09-20T04:30:00.000Z
102+ - id: 6
103+ title: Check on macOS with the installed binary, in a real terminal
104+ state: open
105+ priority: medium
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-09-20T04:00:00.000Z
110+comments: []
added .tickets/issues/0027-better-edit-tool.yaml +149 -0
new file mode 100644
@@ -0,0 +1,149 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 27
3+title: Better edit tool
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-20T03:21:28.320Z
9+updatedAt: 2026-09-20T08:16:48.323Z
10+labels:
11+ - priority::medium
12+ - kind::bug
13+ - To be tested
14+milestone: v0.0.3
15+body: |
16+ Sometimes, the edit file tool is strugling
17+
18+ ```
19+ {
20+ "content": [
21+ {
22+ "toolResponse": {
23+ "name": "edit_file",
24+ "output": "/Users/k33g/kDrive/Rickub/turbo-editors/demo-golang/main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:\n │ func main() {\n │ fmt.Println(\"Hello, World!\")\n │ }",
25+ "ref": "f0vERJyzCB8X1LJFFJNw10bxuiVxW6QR"
26+ }
27+ }
28+ ],
29+ "role": "tool"
30+ },
31+ ```
32+
33+ ## Cause (Claude, 2026-09-20)
34+
35+ The model sent **four spaces** where the Go file has a **tab**. `fileedit.locate` matched with `strings.Count(body, old)` — byte for byte — so the edit was refused although nothing about its intent was ambiguous.
36+
37+ That rule ("old must match EXACTLY, including indentation") is the right one: it is what makes an edit verifiable. But a model retypes the block it is editing, and the one thing it retypes wrong is whitespace. Refusing that is the tool being right about the wrong thing, and it costs a turn — or, as the demo audit of 2026-09-18 showed, thirty of them.
38+
39+ A second defect made it worse: `not found` showed only what the MODEL sent. A model shown its own text retypes it; a model shown the file's lines fixes them.
40+
41+ ## Fix
42+
43+ `internal/fileedit/match.go` — `findAll` tries three passes, strictest first, and **the first pass that finds anything at all wins**, so a file containing the text verbatim is never matched any other way:
44+
45+ 1. **Exact** — unchanged.
46+ 2. **Edges** — the whitespace *around* `old` (an indent kept on a single line, a leading or trailing newline). The same padding comes off `new` when `new` carries it, so a `new` that deliberately adds a blank line still adds it.
47+ 3. **Indent** — every line's leading and trailing whitespace, for an `old` of two lines or more. This is the case reported here.
48+
49+ Two things are deliberately NOT relaxed. **Uniqueness**: a loose match must still be the only one, and two of them are refused exactly as two exact ones are, with the same message and the same line numbers. **The file's indentation**: `internal/fileedit/indent.go` re-indents `new` to the file's own base indent and unit before writing, so a tab-indented file does not end up with a block of spaces in it, and a blank line stays empty rather than becoming a line of spaces.
50+
51+ A loose match is reported, not hidden: the headline ends with ` · N matched ignoring whitespace`, and the `edit_file` description tells the model to copy the text rather than count on it.
52+
53+ `nearest` + `quoteFile` — a `not found` now shows the region of the file that came closest, with real line numbers. Half the lines must line up before an excerpt is offered: pointing at an innocent part of the file is worse than saying nothing.
54+
55+ ```
56+ main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
57+ │ func main() {
58+ │ count := 2
59+ │ print(count)
60+ │ }
61+ the closest the file comes to it — copy these lines:
62+ │ 3 │ func main() {
63+ │ 4 │ count := 1
64+ │ 5 │ print(count)
65+ │ 6 │ }
66+ ```
67+
68+ ## Verified
69+
70+ Unit (`internal/fileedit/match_test.go`): `TestTicket27TabsVersusSpaces` replays this report byte for byte; `TestExactWinsOverLoose`, `TestLooseMatchesMustStillBeUnique`, `TestEdgeWhitespaceIsIgnored` (3 cases), `TestLooseDeletionLeavesNoBlankLine`, `TestReindent`, `TestNotFoundShowsTheClosestLines`, `TestNotFoundStaysQuietWithoutANearMiss`, `TestIndentUnit`. The existing `TestApply` / `TestApplyRefuses` are unchanged in substance.
71+
72+ End to end, line mode on a pseudo-terminal, against a fake engine that emits exactly the `edit_file` call above on a real tab-indented `main.go`:
73+
74+ | Binary | Result | File afterwards |
75+ |---|---|---|
76+ | before | `edit #1: old text not found …` | untouched |
77+ | after | `main.go: 1 edit(s) applied, +1 -1, first change at line 6 · 1 matched ignoring whitespace` | changed, and still indented with a TAB |
78+
79+ Quality gate: run 29 — 10 warnings, 20 smells, the project's baseline (run 28 had one more, `match.go` file-complexity, cleared by splitting the whitespace half into `indent.go`). `go test ./...`, `gofmt`, `go vet` green.
80+tasks:
81+ - id: 1
82+ title: Match old ignoring the whitespace around it, and ignoring per-line indentation for a multi-line block
83+ state: closed
84+ priority: medium
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-09-20T03:30:00.000Z
89+ updatedAt: 2026-09-20T03:40:00.000Z
90+ closedAt: 2026-09-20T03:40:00.000Z
91+ - id: 2
92+ title: "Keep uniqueness: a loose match that is not the only one is refused like an exact one"
93+ state: closed
94+ priority: high
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-09-20T03:30:00.000Z
99+ updatedAt: 2026-09-20T03:40:00.000Z
100+ closedAt: 2026-09-20T03:40:00.000Z
101+ - id: 3
102+ title: Re-indent new to the file's own indentation before writing it
103+ state: closed
104+ priority: high
105+ author:
106+ name: k33g
107+ email: ph.charriere@gmail.com
108+ createdAt: 2026-09-20T03:30:00.000Z
109+ updatedAt: 2026-09-20T03:40:00.000Z
110+ closedAt: 2026-09-20T03:40:00.000Z
111+ - id: 4
112+ title: Report a loose match in the headline and in the edit_file description
113+ state: closed
114+ priority: none
115+ author:
116+ name: k33g
117+ email: ph.charriere@gmail.com
118+ createdAt: 2026-09-20T03:30:00.000Z
119+ updatedAt: 2026-09-20T03:40:00.000Z
120+ closedAt: 2026-09-20T03:40:00.000Z
121+ - id: 5
122+ title: Show the closest lines of the file when old is not found
123+ state: closed
124+ priority: medium
125+ author:
126+ name: k33g
127+ email: ph.charriere@gmail.com
128+ createdAt: 2026-09-20T03:30:00.000Z
129+ updatedAt: 2026-09-20T03:40:00.000Z
130+ closedAt: 2026-09-20T03:40:00.000Z
131+ - id: 6
132+ title: "Tests and EN+FR docs (reference/tools.md: How old is matched)"
133+ state: closed
134+ priority: none
135+ author:
136+ name: k33g
137+ email: ph.charriere@gmail.com
138+ createdAt: 2026-09-20T03:30:00.000Z
139+ updatedAt: 2026-09-20T03:40:00.000Z
140+ closedAt: 2026-09-20T03:40:00.000Z
141+ - id: 7
142+ title: Check with a real model on the turbo-editors/demo-golang project that reported it
143+ state: open
144+ priority: medium
145+ author:
146+ name: k33g
147+ email: ph.charriere@gmail.com
148+ createdAt: 2026-09-20T03:30:00.000Z
149+comments: []
new file mode 100644
@@ -0,0 +1,149 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 27
3+title: Better edit tool
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-20T03:21:28.320Z
9+updatedAt: 2026-09-20T08:16:48.323Z
10+labels:
11+ - priority::medium
12+ - kind::bug
13+ - To be tested
14+milestone: v0.0.3
15+body: |
16+ Sometimes, the edit file tool is strugling
17+
18+ ```
19+ {
20+ "content": [
21+ {
22+ "toolResponse": {
23+ "name": "edit_file",
24+ "output": "/Users/k33g/kDrive/Rickub/turbo-editors/demo-golang/main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:\n │ func main() {\n │ fmt.Println(\"Hello, World!\")\n │ }",
25+ "ref": "f0vERJyzCB8X1LJFFJNw10bxuiVxW6QR"
26+ }
27+ }
28+ ],
29+ "role": "tool"
30+ },
31+ ```
32+
33+ ## Cause (Claude, 2026-09-20)
34+
35+ The model sent **four spaces** where the Go file has a **tab**. `fileedit.locate` matched with `strings.Count(body, old)` — byte for byte — so the edit was refused although nothing about its intent was ambiguous.
36+
37+ That rule ("old must match EXACTLY, including indentation") is the right one: it is what makes an edit verifiable. But a model retypes the block it is editing, and the one thing it retypes wrong is whitespace. Refusing that is the tool being right about the wrong thing, and it costs a turn — or, as the demo audit of 2026-09-18 showed, thirty of them.
38+
39+ A second defect made it worse: `not found` showed only what the MODEL sent. A model shown its own text retypes it; a model shown the file's lines fixes them.
40+
41+ ## Fix
42+
43+ `internal/fileedit/match.go` — `findAll` tries three passes, strictest first, and **the first pass that finds anything at all wins**, so a file containing the text verbatim is never matched any other way:
44+
45+ 1. **Exact** — unchanged.
46+ 2. **Edges** — the whitespace *around* `old` (an indent kept on a single line, a leading or trailing newline). The same padding comes off `new` when `new` carries it, so a `new` that deliberately adds a blank line still adds it.
47+ 3. **Indent** — every line's leading and trailing whitespace, for an `old` of two lines or more. This is the case reported here.
48+
49+ Two things are deliberately NOT relaxed. **Uniqueness**: a loose match must still be the only one, and two of them are refused exactly as two exact ones are, with the same message and the same line numbers. **The file's indentation**: `internal/fileedit/indent.go` re-indents `new` to the file's own base indent and unit before writing, so a tab-indented file does not end up with a block of spaces in it, and a blank line stays empty rather than becoming a line of spaces.
50+
51+ A loose match is reported, not hidden: the headline ends with ` · N matched ignoring whitespace`, and the `edit_file` description tells the model to copy the text rather than count on it.
52+
53+ `nearest` + `quoteFile` — a `not found` now shows the region of the file that came closest, with real line numbers. Half the lines must line up before an excerpt is offered: pointing at an innocent part of the file is worse than saying nothing.
54+
55+ ```
56+ main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
57+ │ func main() {
58+ │ count := 2
59+ │ print(count)
60+ │ }
61+ the closest the file comes to it — copy these lines:
62+ │ 3 │ func main() {
63+ │ 4 │ count := 1
64+ │ 5 │ print(count)
65+ │ 6 │ }
66+ ```
67+
68+ ## Verified
69+
70+ Unit (`internal/fileedit/match_test.go`): `TestTicket27TabsVersusSpaces` replays this report byte for byte; `TestExactWinsOverLoose`, `TestLooseMatchesMustStillBeUnique`, `TestEdgeWhitespaceIsIgnored` (3 cases), `TestLooseDeletionLeavesNoBlankLine`, `TestReindent`, `TestNotFoundShowsTheClosestLines`, `TestNotFoundStaysQuietWithoutANearMiss`, `TestIndentUnit`. The existing `TestApply` / `TestApplyRefuses` are unchanged in substance.
71+
72+ End to end, line mode on a pseudo-terminal, against a fake engine that emits exactly the `edit_file` call above on a real tab-indented `main.go`:
73+
74+ | Binary | Result | File afterwards |
75+ |---|---|---|
76+ | before | `edit #1: old text not found …` | untouched |
77+ | after | `main.go: 1 edit(s) applied, +1 -1, first change at line 6 · 1 matched ignoring whitespace` | changed, and still indented with a TAB |
78+
79+ Quality gate: run 29 — 10 warnings, 20 smells, the project's baseline (run 28 had one more, `match.go` file-complexity, cleared by splitting the whitespace half into `indent.go`). `go test ./...`, `gofmt`, `go vet` green.
80+tasks:
81+ - id: 1
82+ title: Match old ignoring the whitespace around it, and ignoring per-line indentation for a multi-line block
83+ state: closed
84+ priority: medium
85+ author:
86+ name: k33g
87+ email: ph.charriere@gmail.com
88+ createdAt: 2026-09-20T03:30:00.000Z
89+ updatedAt: 2026-09-20T03:40:00.000Z
90+ closedAt: 2026-09-20T03:40:00.000Z
91+ - id: 2
92+ title: "Keep uniqueness: a loose match that is not the only one is refused like an exact one"
93+ state: closed
94+ priority: high
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-09-20T03:30:00.000Z
99+ updatedAt: 2026-09-20T03:40:00.000Z
100+ closedAt: 2026-09-20T03:40:00.000Z
101+ - id: 3
102+ title: Re-indent new to the file's own indentation before writing it
103+ state: closed
104+ priority: high
105+ author:
106+ name: k33g
107+ email: ph.charriere@gmail.com
108+ createdAt: 2026-09-20T03:30:00.000Z
109+ updatedAt: 2026-09-20T03:40:00.000Z
110+ closedAt: 2026-09-20T03:40:00.000Z
111+ - id: 4
112+ title: Report a loose match in the headline and in the edit_file description
113+ state: closed
114+ priority: none
115+ author:
116+ name: k33g
117+ email: ph.charriere@gmail.com
118+ createdAt: 2026-09-20T03:30:00.000Z
119+ updatedAt: 2026-09-20T03:40:00.000Z
120+ closedAt: 2026-09-20T03:40:00.000Z
121+ - id: 5
122+ title: Show the closest lines of the file when old is not found
123+ state: closed
124+ priority: medium
125+ author:
126+ name: k33g
127+ email: ph.charriere@gmail.com
128+ createdAt: 2026-09-20T03:30:00.000Z
129+ updatedAt: 2026-09-20T03:40:00.000Z
130+ closedAt: 2026-09-20T03:40:00.000Z
131+ - id: 6
132+ title: "Tests and EN+FR docs (reference/tools.md: How old is matched)"
133+ state: closed
134+ priority: none
135+ author:
136+ name: k33g
137+ email: ph.charriere@gmail.com
138+ createdAt: 2026-09-20T03:30:00.000Z
139+ updatedAt: 2026-09-20T03:40:00.000Z
140+ closedAt: 2026-09-20T03:40:00.000Z
141+ - id: 7
142+ title: Check with a real model on the turbo-editors/demo-golang project that reported it
143+ state: open
144+ priority: medium
145+ author:
146+ name: k33g
147+ email: ph.charriere@gmail.com
148+ createdAt: 2026-09-20T03:30:00.000Z
149+comments: []
added .tickets/issues/0028-collapsed-paste-tokens-in-the-input-box-spec-of.yaml +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 28
3+title: Collapsed paste tokens in the input box — spec of the feature as built in Mini Me
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-20T04:05:00.000Z
9+updatedAt: 2026-09-20T04:00:34.891Z
10+labels:
11+ - documentation
12+ - priority::low
13+body: |
14+ Reference description of a feature **already implemented in Mini Me**, written so it can be reimplemented in another tool. Nothing to do here; this is the spec, plus a map of where each part lives in this repository.
15+
16+ ## The problem it solves
17+
18+ A terminal agent's input box is a few rows tall — three, in Mini Me. Paste forty lines of a log into it and three things go wrong at once: the box is unreadable, what was typed around the paste is pushed off screen, and the conversation afterwards is mostly the paste rather than the exchange.
19+
20+ Deleting the paste is no better: the text is what the model needs.
21+
22+ So the box shows a **token** in its place and keeps the text aside:
23+
24+ ```
25+ > here is the trace [Pasted #1 · 6 lines · 700 chars] what is failing?
26+ ```
27+
28+ and the model receives the message with the token replaced by the 700 characters, byte for byte.
29+
30+ ## One message, three forms
31+
32+ This is the whole idea. A message exists in three forms at once, and each consumer gets the right one:
33+
34+ | Form | Who reads it | What a token becomes |
35+ |---|---|---|
36+ | **Typed** | the editing code (cursor, Backspace, wrapping) | one placeholder character |
37+ | **Display** | the input box, and the conversation once sent | `[Pasted #1 · 6 lines · 700 chars]` |
38+ | **Text** | the model | the pasted text, verbatim, newlines included |
39+
40+ The conversation keeps *both*: it shows Display, and holds Text aside so "copy the whole conversation" yields what was really sent. A reimplementation that keeps only one form will fail on one of the three consumers — usually the clipboard, which is noticed late.
41+
42+ ## When a paste is collapsed
43+
44+ ```
45+ collapse ⟺ lines(text) > 3 OR runes(text) > 200
46+ ```
47+
48+ where 3 is the box's own height, in rows. Anything smaller is inserted as ordinary text and behaves exactly as if it had been typed — including its newlines, which become real line breaks and never a send.
49+
50+ The character count is in **runes**, not bytes: a paste of accented text or CJK must not collapse earlier than the same length in ASCII.
51+
52+ ## The token
53+
54+ Format, exactly:
55+
56+ ```
57+ [Pasted #<n> · <lines> lines · <chars> chars]
58+ ```
59+
60+ - `<n>` counts up **within one message**, starting at 1, and starts again once the message is sent. It is not a global counter; the user reads `#1`, `#2` against the box in front of them.
61+ - `<lines>` is `count("\n") + 1` — a paste with no newline is 1 line.
62+ - `<chars>` is the rune count, abbreviated past a thousand: `700`, then `1.2k`. Mini Me reuses the same abbreviation as the context display, so one convention is learned once.
63+
64+ ## The placeholder, and why it is one character
65+
66+ In the typed form a collapsed paste is **a single rune** taken from a Supplementary Private Use Area (Mini Me uses `U+F0000 + n`): no keyboard produces it, no font draws it, and nothing a user can type can collide with it.
67+
68+ That choice is what makes the editing work with no special cases at all:
69+
70+ - the cursor steps over a whole paste in one Left/Right;
71+ - one Backspace or Delete removes the whole block;
72+ - word wrap, selection, line splitting treat it as one character.
73+
74+ The only place that knows better is **measurement for layout**: when the box computes how wide a line is, a placeholder counts as the width of its label, not as one cell. Get this wrong and the box wraps in the wrong place — it is the one line of special-casing the design needs.
75+
76+ When a placeholder is deleted, the text behind it is dropped too. When the box is replaced wholesale — a completion, a send — every block whose placeholder is no longer present is dropped, and the counter resets once none is left.
77+
78+ ## Getting the text out of the terminal
79+
80+ The terminal must be in **bracketed paste** mode. The pasted text then arrives as a start marker, ordinary key events, and an end marker.
81+
82+ The keys are **buffered** until the end marker, because the decision — collapse or insert — needs the total length, which is only known then. Two key events need care inside the brackets:
83+
84+ - **Enter** is a newline in the buffer, never a send;
85+ - **Tab** is a tab character, never a focus change.
86+
87+ A paste arriving while some modal is up (a permission prompt, in Mini Me) is still buffered rather than dropped: the paste-in-progress check comes first in the key handler.
88+
89+ ## Interactions worth copying
90+
91+ - **`@path` / mention expansion is looked up in the typed text only.** A pasted block is data the user brought, not a request to attach whatever paths it happens to name. This is a security-flavoured choice, not a convenience one.
92+ - **A slash command is read from the typed text only**, so a paste can never turn into a command.
93+ - **Clipboard**: "copy the whole conversation" uses the Text form. The user gets back what was sent, not `[Pasted #1 · …]`.
94+
95+ ## Where it is, in this repository
96+
97+ | Part | File |
98+ |---|---|
99+ | Thresholds, token format, placeholder, the `Prompt` with its three forms | `internal/tui/paste.go` |
100+ | Buffering the bracketed paste, inserting or collapsing it | `internal/tui/paste.go` (`bufferPasteKey`, `finishPaste`) |
101+ | Paste-in-progress first in the key handler | `internal/tui/events.go` (`handleKey`) |
102+ | Label width in cells when wrapping | `internal/tui/inputwrap.go` (`cellWidth`) |
103+ | Drawing the label in place of the rune | `internal/tui/draw.go` |
104+ | Send path, pruning, the snapshot handed over | `internal/tui/view.go` (`send`, `setInput`, `Display`) |
105+ | Display kept with Text alongside it | `internal/tui/transcript.go` (`AddUserFull`, `Entry.Full`) |
106+ | `@path` on the typed text only | `internal/tui/turn.go` (`startTurn`) |
107+ | Tests | `internal/tui/view_test.go` (insertion, atomic Backspace/Delete, reset after send) |
108+
109+ User documentation: `docs/en/reference/slash-commands.md` and `docs/fr/…`, "Bracketed paste" row of the window-keys table.
110+
111+ ## Decisions, and what was rejected
112+
113+ - **Collapse, do not truncate.** The model gets everything; only the screen is spared. A tool that truncates the paste makes the user's problem worse in a way they cannot see.
114+ - **One rune, not a marked-up region.** A `[[paste:1]]` string in the buffer would need every cursor movement, deletion and wrap to know about it. A private-use rune needs exactly one exception, in width measurement.
115+ - **Numbering per message, not global.** `#37` means nothing to someone looking at a box with two pastes in it.
116+ - **No preview command.** Mini Me has no `/paste 1` to show a block's content; it was considered and left out. Worth adding if the other tool's users edit long messages over several minutes.
117+tasks: []
118+comments: []
new file mode 100644
@@ -0,0 +1,118 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 28
3+title: Collapsed paste tokens in the input box — spec of the feature as built in Mini Me
4+state: closed
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-20T04:05:00.000Z
9+updatedAt: 2026-09-20T04:00:34.891Z
10+labels:
11+ - documentation
12+ - priority::low
13+body: |
14+ Reference description of a feature **already implemented in Mini Me**, written so it can be reimplemented in another tool. Nothing to do here; this is the spec, plus a map of where each part lives in this repository.
15+
16+ ## The problem it solves
17+
18+ A terminal agent's input box is a few rows tall — three, in Mini Me. Paste forty lines of a log into it and three things go wrong at once: the box is unreadable, what was typed around the paste is pushed off screen, and the conversation afterwards is mostly the paste rather than the exchange.
19+
20+ Deleting the paste is no better: the text is what the model needs.
21+
22+ So the box shows a **token** in its place and keeps the text aside:
23+
24+ ```
25+ > here is the trace [Pasted #1 · 6 lines · 700 chars] what is failing?
26+ ```
27+
28+ and the model receives the message with the token replaced by the 700 characters, byte for byte.
29+
30+ ## One message, three forms
31+
32+ This is the whole idea. A message exists in three forms at once, and each consumer gets the right one:
33+
34+ | Form | Who reads it | What a token becomes |
35+ |---|---|---|
36+ | **Typed** | the editing code (cursor, Backspace, wrapping) | one placeholder character |
37+ | **Display** | the input box, and the conversation once sent | `[Pasted #1 · 6 lines · 700 chars]` |
38+ | **Text** | the model | the pasted text, verbatim, newlines included |
39+
40+ The conversation keeps *both*: it shows Display, and holds Text aside so "copy the whole conversation" yields what was really sent. A reimplementation that keeps only one form will fail on one of the three consumers — usually the clipboard, which is noticed late.
41+
42+ ## When a paste is collapsed
43+
44+ ```
45+ collapse ⟺ lines(text) > 3 OR runes(text) > 200
46+ ```
47+
48+ where 3 is the box's own height, in rows. Anything smaller is inserted as ordinary text and behaves exactly as if it had been typed — including its newlines, which become real line breaks and never a send.
49+
50+ The character count is in **runes**, not bytes: a paste of accented text or CJK must not collapse earlier than the same length in ASCII.
51+
52+ ## The token
53+
54+ Format, exactly:
55+
56+ ```
57+ [Pasted #<n> · <lines> lines · <chars> chars]
58+ ```
59+
60+ - `<n>` counts up **within one message**, starting at 1, and starts again once the message is sent. It is not a global counter; the user reads `#1`, `#2` against the box in front of them.
61+ - `<lines>` is `count("\n") + 1` — a paste with no newline is 1 line.
62+ - `<chars>` is the rune count, abbreviated past a thousand: `700`, then `1.2k`. Mini Me reuses the same abbreviation as the context display, so one convention is learned once.
63+
64+ ## The placeholder, and why it is one character
65+
66+ In the typed form a collapsed paste is **a single rune** taken from a Supplementary Private Use Area (Mini Me uses `U+F0000 + n`): no keyboard produces it, no font draws it, and nothing a user can type can collide with it.
67+
68+ That choice is what makes the editing work with no special cases at all:
69+
70+ - the cursor steps over a whole paste in one Left/Right;
71+ - one Backspace or Delete removes the whole block;
72+ - word wrap, selection, line splitting treat it as one character.
73+
74+ The only place that knows better is **measurement for layout**: when the box computes how wide a line is, a placeholder counts as the width of its label, not as one cell. Get this wrong and the box wraps in the wrong place — it is the one line of special-casing the design needs.
75+
76+ When a placeholder is deleted, the text behind it is dropped too. When the box is replaced wholesale — a completion, a send — every block whose placeholder is no longer present is dropped, and the counter resets once none is left.
77+
78+ ## Getting the text out of the terminal
79+
80+ The terminal must be in **bracketed paste** mode. The pasted text then arrives as a start marker, ordinary key events, and an end marker.
81+
82+ The keys are **buffered** until the end marker, because the decision — collapse or insert — needs the total length, which is only known then. Two key events need care inside the brackets:
83+
84+ - **Enter** is a newline in the buffer, never a send;
85+ - **Tab** is a tab character, never a focus change.
86+
87+ A paste arriving while some modal is up (a permission prompt, in Mini Me) is still buffered rather than dropped: the paste-in-progress check comes first in the key handler.
88+
89+ ## Interactions worth copying
90+
91+ - **`@path` / mention expansion is looked up in the typed text only.** A pasted block is data the user brought, not a request to attach whatever paths it happens to name. This is a security-flavoured choice, not a convenience one.
92+ - **A slash command is read from the typed text only**, so a paste can never turn into a command.
93+ - **Clipboard**: "copy the whole conversation" uses the Text form. The user gets back what was sent, not `[Pasted #1 · …]`.
94+
95+ ## Where it is, in this repository
96+
97+ | Part | File |
98+ |---|---|
99+ | Thresholds, token format, placeholder, the `Prompt` with its three forms | `internal/tui/paste.go` |
100+ | Buffering the bracketed paste, inserting or collapsing it | `internal/tui/paste.go` (`bufferPasteKey`, `finishPaste`) |
101+ | Paste-in-progress first in the key handler | `internal/tui/events.go` (`handleKey`) |
102+ | Label width in cells when wrapping | `internal/tui/inputwrap.go` (`cellWidth`) |
103+ | Drawing the label in place of the rune | `internal/tui/draw.go` |
104+ | Send path, pruning, the snapshot handed over | `internal/tui/view.go` (`send`, `setInput`, `Display`) |
105+ | Display kept with Text alongside it | `internal/tui/transcript.go` (`AddUserFull`, `Entry.Full`) |
106+ | `@path` on the typed text only | `internal/tui/turn.go` (`startTurn`) |
107+ | Tests | `internal/tui/view_test.go` (insertion, atomic Backspace/Delete, reset after send) |
108+
109+ User documentation: `docs/en/reference/slash-commands.md` and `docs/fr/…`, "Bracketed paste" row of the window-keys table.
110+
111+ ## Decisions, and what was rejected
112+
113+ - **Collapse, do not truncate.** The model gets everything; only the screen is spared. A tool that truncates the paste makes the user's problem worse in a way they cannot see.
114+ - **One rune, not a marked-up region.** A `[[paste:1]]` string in the buffer would need every cursor movement, deletion and wrap to know about it. A private-use rune needs exactly one exception, in width measurement.
115+ - **Numbering per message, not global.** `#37` means nothing to someone looking at a box with two pastes in it.
116+ - **No preview command.** Mini Me has no `/paste 1` to show a block's content; it was considered and left out. Worth adding if the other tool's users edit long messages over several minutes.
117+tasks: []
118+comments: []
added .tickets/issues/0029-thinking-mode.yaml +14 -0
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 29
3+title: thinking mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T03:43:16.681Z
9+updatedAt: 2026-09-21T03:43:16.681Z
10+labels: []
11+milestone: v0.0.3
12+body: ""
13+tasks: []
14+comments: []
new file mode 100644
@@ -0,0 +1,14 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 29
3+title: thinking mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-21T03:43:16.681Z
9+updatedAt: 2026-09-21T03:43:16.681Z
10+labels: []
11+milestone: v0.0.3
12+body: ""
13+tasks: []
14+comments: []
modified .tickets/labels.yaml +2 -0
@@ -40,3 +40,5 @@ labels:
4040 color: "#fbca04"
4141 - name: builtin tool
4242 color: "#5319e7"
43+ - name: To be tested
44+ color: "#e99695"
@@ -40,3 +40,5 @@ labels:
40 color: "#fbca04"40 color: "#fbca04"
41 - name: builtin tool41 - name: builtin tool
42 color: "#5319e7"42 color: "#5319e7"
43+ - name: To be tested
44+ color: "#e99695"
modified .tickets/milestones.yaml +3 -0
@@ -6,3 +6,6 @@ milestones:
66 - title: v0.0.3
77 state: open
88 dueOn: 2026-09-30
9+ - title: v0.04
10+ state: open
11+ dueOn: 2026-10-15
@@ -6,3 +6,6 @@ milestones:
6 - title: v0.0.36 - title: v0.0.3
7 state: open7 state: open
8 dueOn: 2026-09-308 dueOn: 2026-09-30
9+ - title: v0.04
10+ state: open
11+ dueOn: 2026-10-15
modified 02-build-releases.sh +14 -5
@@ -52,10 +52,11 @@ VERSION="${TAG#v}"
5252 RELEASES_DIR="release/${TAG}"
5353
5454 # -trimpath keeps this machine's paths out of binaries that go to strangers;
55-# -s -w drops the symbol table and DWARF, about a third of the size. mm has no
56-# version variable to stamp, so there is no -X here: the file name and the
57-# SHA256SUMS are what tie a binary to its release.
58-BUILD_FLAGS=(-trimpath -ldflags "-s -w")
55+# -s -w drops the symbol table and DWARF, about a third of the size. -X stamps
56+# the tag into the binary, which is what `mm --version`, the start-up banner
57+# and the window's status bar report: without it they fall back to the commit
58+# Go embeds, and a downloaded release would not know its own name.
59+BUILD_FLAGS=(-trimpath -ldflags "-s -w -X mm/internal/version.stamped=${TAG}")
5960
6061 echo "🚀 Building Mini Me ${TAG} — ${ABOUT}"
6162 echo "🐹 $(go version)"
@@ -102,7 +103,15 @@ if [ -x "${RELEASES_DIR}/${HOST_ASSET}" ]; then
102103 echo " ❌ ${HOST_ASSET} does not start (mm -h failed)"
103104 exit 1
104105 fi
105- echo " ✅ ${HOST_ASSET} starts"
106+ # The stamp is a string passed to the linker: a renamed package or variable
107+ # does not fail the build, it silently leaves the binary reporting a commit
108+ # hash. Ask the binary itself, here, while the release can still be stopped.
109+ if ! "${RELEASES_DIR}/${HOST_ASSET}" --version | grep -q -- "${TAG}"; then
110+ echo " ❌ ${HOST_ASSET} does not report ${TAG}: $("${RELEASES_DIR}/${HOST_ASSET}" --version)"
111+ echo " 💡 check -X mm/internal/version.stamped in BUILD_FLAGS"
112+ exit 1
113+ fi
114+ echo " ✅ ${HOST_ASSET} starts and reports ${TAG}"
106115 fi
107116
108117 # checksum runs whichever of the two tools this machine has: sha256sum on
@@ -52,10 +52,11 @@ VERSION="${TAG#v}"
52 RELEASES_DIR="release/${TAG}"52 RELEASES_DIR="release/${TAG}"
53 53
54 # -trimpath keeps this machine's paths out of binaries that go to strangers;54 # -trimpath keeps this machine's paths out of binaries that go to strangers;
55-# -s -w drops the symbol table and DWARF, about a third of the size. mm has no55+# -s -w drops the symbol table and DWARF, about a third of the size. -X stamps
56-# version variable to stamp, so there is no -X here: the file name and the56+# the tag into the binary, which is what `mm --version`, the start-up banner
57-# SHA256SUMS are what tie a binary to its release.57+# and the window's status bar report: without it they fall back to the commit
58-BUILD_FLAGS=(-trimpath -ldflags "-s -w")58+# Go embeds, and a downloaded release would not know its own name.
59+BUILD_FLAGS=(-trimpath -ldflags "-s -w -X mm/internal/version.stamped=${TAG}")
59 60
60 echo "🚀 Building Mini Me ${TAG} — ${ABOUT}"61 echo "🚀 Building Mini Me ${TAG} — ${ABOUT}"
61 echo "🐹 $(go version)"62 echo "🐹 $(go version)"
@@ -102,7 +103,15 @@ if [ -x "${RELEASES_DIR}/${HOST_ASSET}" ]; then
102 echo " ❌ ${HOST_ASSET} does not start (mm -h failed)"103 echo " ❌ ${HOST_ASSET} does not start (mm -h failed)"
103 exit 1104 exit 1
104 fi105 fi
105- echo " ✅ ${HOST_ASSET} starts"106+ # The stamp is a string passed to the linker: a renamed package or variable
107+ # does not fail the build, it silently leaves the binary reporting a commit
108+ # hash. Ask the binary itself, here, while the release can still be stopped.
109+ if ! "${RELEASES_DIR}/${HOST_ASSET}" --version | grep -q -- "${TAG}"; then
110+ echo " ❌ ${HOST_ASSET} does not report ${TAG}: $("${RELEASES_DIR}/${HOST_ASSET}" --version)"
111+ echo " 💡 check -X mm/internal/version.stamped in BUILD_FLAGS"
112+ exit 1
113+ fi
114+ echo " ✅ ${HOST_ASSET} starts and reports ${TAG}"
106 fi115 fi
107 116
108 # checksum runs whichever of the two tools this machine has: sha256sum on117 # checksum runs whichever of the two tools this machine has: sha256sum on
modified README.md +2 -1
@@ -26,7 +26,8 @@ You need Go 1.26+ and a model server that can emit tool calls: Docker Model Runn
2626 git clone https://git.rickub.com/bots-garden/mini-me.git
2727 cd mini-me
2828 ./install.sh # go build, then copies mm to /usr/local/bin
29-mm -h
29+mm -h # the flags
30+mm --version # which mm this is
3031 ```
3132 > `install.sh` works only on macOS and Linux
3233
@@ -26,7 +26,8 @@ You need Go 1.26+ and a model server that can emit tool calls: Docker Model Runn
26 git clone https://git.rickub.com/bots-garden/mini-me.git26 git clone https://git.rickub.com/bots-garden/mini-me.git
27 cd mini-me27 cd mini-me
28 ./install.sh # go build, then copies mm to /usr/local/bin28 ./install.sh # go build, then copies mm to /usr/local/bin
29-mm -h29+mm -h # the flags
30+mm --version # which mm this is
30 ```31 ```
31 > `install.sh` works only on macOS and Linux32 > `install.sh` works only on macOS and Linux
32 33
modified demo/agent.lsp.yaml +6 -1
@@ -10,6 +10,9 @@
1010 provider: dmr
1111
1212 model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+#model: huggingface.co/unsloth/gemma-4-26b-a4b-it-gguf:UD-Q4_K_M
14+#model: huggingface.co/ornith-ai/ornith-1.5-35b-a3b-gguf:Q4_K_M
15+#model: huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M
1316 baseUrl: http://localhost:12434/engines/v1
1417 fallback: http://host.docker.internal:12434/engines/v1
1518
@@ -24,7 +27,9 @@ maxOutput: 16000
2427
2528 # Max number of model <-> tools round trips for a single question.
2629 # Raise it for tasks that need long chains of commands.
27-maxTurns: 40
30+#maxTurns: 40
31+maxTurns: 200
32+
2833
2934 # The `bash` tool.
3035 # `false` removes it: the model can still read and edit files (editTools) and load skills,
@@ -10,6 +10,9 @@
10 provider: dmr10 provider: dmr
11 11
12 model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M12 model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M
13+#model: huggingface.co/unsloth/gemma-4-26b-a4b-it-gguf:UD-Q4_K_M
14+#model: huggingface.co/ornith-ai/ornith-1.5-35b-a3b-gguf:Q4_K_M
15+#model: huggingface.co/ornith-ai/ornith-1.5-9b-gguf:Q4_K_M
13 baseUrl: http://localhost:12434/engines/v116 baseUrl: http://localhost:12434/engines/v1
14 fallback: http://host.docker.internal:12434/engines/v117 fallback: http://host.docker.internal:12434/engines/v1
15 18
@@ -24,7 +27,9 @@ maxOutput: 16000
24 27
25 # Max number of model <-> tools round trips for a single question. 28 # Max number of model <-> tools round trips for a single question.
26 # Raise it for tasks that need long chains of commands.29 # Raise it for tasks that need long chains of commands.
27-maxTurns: 4030+#maxTurns: 40
31+maxTurns: 200
32+
28 33
29 # The `bash` tool. 34 # The `bash` tool.
30 # `false` removes it: the model can still read and edit files (editTools) and load skills, 35 # `false` removes it: the model can still read and edit files (editTools) and load skills,
deleted demo/main.go +0 -21
deleted file mode 100644
@@ -1,21 +0,0 @@
1-package main
2-
3-import "fmt"
4-
5-// Human represents a person with a name
6-type Human struct {
7- Name string
8-}
9-
10-// Hello returns a greeting message from the human
11-func (h Human) Hello() string {
12- return fmt.Sprintf("Hello, %s!", h.Name)
13-}
14-
15-func main() {
16- // Create a new human
17- person := Human{Name: "World"}
18-
19- // Print the greeting
20- fmt.Println(person.Hello())
21-}
deleted file mode 100644
@@ -1,21 +0,0 @@
1-package main
2-
3-import "fmt"
4-
5-// Human represents a person with a name
6-type Human struct {
7- Name string
8-}
9-
10-// Hello returns a greeting message from the human
11-func (h Human) Hello() string {
12- return fmt.Sprintf("Hello, %s!", h.Name)
13-}
14-
15-func main() {
16- // Create a new human
17- person := Human{Name: "World"}
18-
19- // Print the greeting
20- fmt.Println(person.Hello())
21-}
added demo/prompt.md +100 -0
new file mode 100644
@@ -0,0 +1,100 @@
1+
2+**Étape 1 — Squelette du projet**
3+```
4+Crée un projet Go pour une application web de gestion d'issues (comme un mini Jira).
5+Structure attendue :
6+- go.mod (module "issue-tracker")
7+- cmd/server/main.go : lance un serveur HTTP sur le port 8080
8+- Une seule route "/" qui répond "OK" en texte brut
9+Pas de base de données, pas de HTML pour l'instant. Vérifie que "go run cmd/server/main.go" démarre sans erreur et que curl localhost:8080 répond "OK".
10+```
11+
12+**Étape 2 — Modèle de données + stockage mémoire**
13+```
14+Contexte : projet Go "issue-tracker" avec cmd/server/main.go qui lance un serveur sur le port 8080.
15+Ajoute un package internal/store avec :
16+- une struct Issue (ID int, Title string, Description string, Status string, Priority string, CreatedAt time.Time)
17+- un store en mémoire (map protégée par un sync.Mutex)
18+- des fonctions Create(issue) Issue, Get(id) (Issue, bool), List() []Issue, Update(id, issue) bool, Delete(id) bool
19+N'ajoute aucune route HTTP pour l'instant. Ajoute juste un test unitaire simple qui vérifie Create + Get + List.
20+```
21+
22+**Étape 3 — Routes CRUD en JSON**
23+```
24+Contexte : projet Go "issue-tracker" avec un package internal/store (CRUD en mémoire sur des Issue).
25+Ajoute dans main.go les routes HTTP suivantes qui utilisent le store, en JSON :
26+- GET /issues : liste toutes les issues
27+- POST /issues : crée une issue à partir du JSON envoyé
28+- GET /issues/{id} : retourne une issue
29+- PUT /issues/{id} : met à jour une issue
30+- DELETE /issues/{id} : supprime une issue
31+Utilise net/http standard (pas de framework externe). Donne-moi ensuite 4 commandes curl pour tester chaque route.
32+```
33+
34+**Étape 4 — Première page HTML**
35+```
36+Contexte : projet Go "issue-tracker" avec routes JSON /issues (GET/POST/PUT/DELETE) basées sur internal/store.
37+Ajoute le rendu HTML avec html/template :
38+- un dossier web/templates avec un layout.html (header/footer minimal) et une page index.html
39+- une route GET / qui affiche la liste des issues dans un tableau HTML (ID, titre, statut, priorité)
40+Ne touche pas encore aux routes JSON existantes, elles doivent continuer à fonctionner. Vérifie que localhost:8080/ affiche bien un tableau.
41+```
42+
43+**Étape 5 — Création d'issue avec HTMX**
44+```
45+Contexte : projet Go "issue-tracker" avec une page / qui affiche un tableau d'issues (web/templates), et un store en mémoire.
46+Ajoute HTMX (via CDN dans le layout) et :
47+- un formulaire de création d'issue (titre, description, priorité) au-dessus du tableau
48+- le formulaire poste en hx-post vers /issues/new, qui crée l'issue et renvoie le HTML d'une seule ligne <tr>
49+- le tableau a un <tbody id="issues-list"> et le formulaire utilise hx-target="#issues-list" hx-swap="afterbegin"
50+Vérifie que créer une issue l'ajoute dans le tableau sans recharger la page.
51+```
52+
53+**Étape 6 — Édition inline**
54+```
55+Contexte : projet Go "issue-tracker", page / avec tableau d'issues et création via HTMX (route POST /issues/new).
56+Ajoute l'édition d'une issue directement dans le tableau :
57+- chaque ligne a un bouton "Éditer" avec hx-get vers /issues/{id}/edit qui remplace la ligne par un formulaire inline (hx-target="closest tr" hx-swap="outerHTML")
58+- le formulaire d'édition poste en hx-put vers /issues/{id} et remplace la ligne par la version mise à jour
59+Vérifie que l'édition fonctionne sans rechargement de page et sans casser la création.
60+```
61+
62+**Étape 7 — Suppression**
63+```
64+Contexte : projet Go "issue-tracker", tableau d'issues avec création et édition inline via HTMX.
65+Ajoute un bouton "Supprimer" sur chaque ligne :
66+- hx-delete vers /issues/{id}
67+- hx-confirm="Confirmer la suppression ?"
68+- hx-target="closest tr" hx-swap="outerHTML swap:1s" (ou équivalent) pour retirer la ligne du tableau
69+Vérifie que la suppression fonctionne et que les autres actions (création, édition) marchent toujours.
70+```
71+
72+**Étape 8 — Filtrage / recherche**
73+```
74+Contexte : projet Go "issue-tracker" avec CRUD complet en HTMX sur les issues.
75+Ajoute une barre de recherche au-dessus du tableau :
76+- un input avec hx-get vers /issues/search, hx-trigger="keyup changed delay:300ms", hx-target="#issues-list"
77+- la route /issues/search filtre les issues par titre (insensible à la casse) et renvoie uniquement les <tr> correspondants
78+Vérifie que taper dans la barre filtre la liste en direct.
79+```
80+
81+**Étape 9 — Persistance SQLite**
82+```
83+Contexte : projet Go "issue-tracker" avec store en mémoire (internal/store) et CRUD HTMX complet.
84+Remplace le store en mémoire par SQLite :
85+- utilise la lib modernc.org/sqlite (pure Go, pas besoin de cgo)
86+- crée un fichier issues.db, table issues avec les mêmes champs que la struct Issue
87+- garde exactement la même interface publique du package store (Create, Get, List, Update, Delete) pour ne rien casser côté routes
88+Vérifie que les issues survivent à un redémarrage du serveur.
89+```
90+
91+**Étape 10 — Finitions**
92+```
93+Contexte : projet Go "issue-tracker" complet (CRUD HTMX + SQLite).
94+Ajoute :
95+- un fichier web/static/style.css minimal (tableau propre, badges colorés selon le statut : open=vert, in_progress=orange, closed=gris)
96+- une route pour servir web/static/
97+- un favicon simple si possible
98+N'ajoute rien d'autre, pas de nouvelle fonctionnalité.
99+```
100+
new file mode 100644
@@ -0,0 +1,100 @@
1+
2+**Étape 1 — Squelette du projet**
3+```
4+Crée un projet Go pour une application web de gestion d'issues (comme un mini Jira).
5+Structure attendue :
6+- go.mod (module "issue-tracker")
7+- cmd/server/main.go : lance un serveur HTTP sur le port 8080
8+- Une seule route "/" qui répond "OK" en texte brut
9+Pas de base de données, pas de HTML pour l'instant. Vérifie que "go run cmd/server/main.go" démarre sans erreur et que curl localhost:8080 répond "OK".
10+```
11+
12+**Étape 2 — Modèle de données + stockage mémoire**
13+```
14+Contexte : projet Go "issue-tracker" avec cmd/server/main.go qui lance un serveur sur le port 8080.
15+Ajoute un package internal/store avec :
16+- une struct Issue (ID int, Title string, Description string, Status string, Priority string, CreatedAt time.Time)
17+- un store en mémoire (map protégée par un sync.Mutex)
18+- des fonctions Create(issue) Issue, Get(id) (Issue, bool), List() []Issue, Update(id, issue) bool, Delete(id) bool
19+N'ajoute aucune route HTTP pour l'instant. Ajoute juste un test unitaire simple qui vérifie Create + Get + List.
20+```
21+
22+**Étape 3 — Routes CRUD en JSON**
23+```
24+Contexte : projet Go "issue-tracker" avec un package internal/store (CRUD en mémoire sur des Issue).
25+Ajoute dans main.go les routes HTTP suivantes qui utilisent le store, en JSON :
26+- GET /issues : liste toutes les issues
27+- POST /issues : crée une issue à partir du JSON envoyé
28+- GET /issues/{id} : retourne une issue
29+- PUT /issues/{id} : met à jour une issue
30+- DELETE /issues/{id} : supprime une issue
31+Utilise net/http standard (pas de framework externe). Donne-moi ensuite 4 commandes curl pour tester chaque route.
32+```
33+
34+**Étape 4 — Première page HTML**
35+```
36+Contexte : projet Go "issue-tracker" avec routes JSON /issues (GET/POST/PUT/DELETE) basées sur internal/store.
37+Ajoute le rendu HTML avec html/template :
38+- un dossier web/templates avec un layout.html (header/footer minimal) et une page index.html
39+- une route GET / qui affiche la liste des issues dans un tableau HTML (ID, titre, statut, priorité)
40+Ne touche pas encore aux routes JSON existantes, elles doivent continuer à fonctionner. Vérifie que localhost:8080/ affiche bien un tableau.
41+```
42+
43+**Étape 5 — Création d'issue avec HTMX**
44+```
45+Contexte : projet Go "issue-tracker" avec une page / qui affiche un tableau d'issues (web/templates), et un store en mémoire.
46+Ajoute HTMX (via CDN dans le layout) et :
47+- un formulaire de création d'issue (titre, description, priorité) au-dessus du tableau
48+- le formulaire poste en hx-post vers /issues/new, qui crée l'issue et renvoie le HTML d'une seule ligne <tr>
49+- le tableau a un <tbody id="issues-list"> et le formulaire utilise hx-target="#issues-list" hx-swap="afterbegin"
50+Vérifie que créer une issue l'ajoute dans le tableau sans recharger la page.
51+```
52+
53+**Étape 6 — Édition inline**
54+```
55+Contexte : projet Go "issue-tracker", page / avec tableau d'issues et création via HTMX (route POST /issues/new).
56+Ajoute l'édition d'une issue directement dans le tableau :
57+- chaque ligne a un bouton "Éditer" avec hx-get vers /issues/{id}/edit qui remplace la ligne par un formulaire inline (hx-target="closest tr" hx-swap="outerHTML")
58+- le formulaire d'édition poste en hx-put vers /issues/{id} et remplace la ligne par la version mise à jour
59+Vérifie que l'édition fonctionne sans rechargement de page et sans casser la création.
60+```
61+
62+**Étape 7 — Suppression**
63+```
64+Contexte : projet Go "issue-tracker", tableau d'issues avec création et édition inline via HTMX.
65+Ajoute un bouton "Supprimer" sur chaque ligne :
66+- hx-delete vers /issues/{id}
67+- hx-confirm="Confirmer la suppression ?"
68+- hx-target="closest tr" hx-swap="outerHTML swap:1s" (ou équivalent) pour retirer la ligne du tableau
69+Vérifie que la suppression fonctionne et que les autres actions (création, édition) marchent toujours.
70+```
71+
72+**Étape 8 — Filtrage / recherche**
73+```
74+Contexte : projet Go "issue-tracker" avec CRUD complet en HTMX sur les issues.
75+Ajoute une barre de recherche au-dessus du tableau :
76+- un input avec hx-get vers /issues/search, hx-trigger="keyup changed delay:300ms", hx-target="#issues-list"
77+- la route /issues/search filtre les issues par titre (insensible à la casse) et renvoie uniquement les <tr> correspondants
78+Vérifie que taper dans la barre filtre la liste en direct.
79+```
80+
81+**Étape 9 — Persistance SQLite**
82+```
83+Contexte : projet Go "issue-tracker" avec store en mémoire (internal/store) et CRUD HTMX complet.
84+Remplace le store en mémoire par SQLite :
85+- utilise la lib modernc.org/sqlite (pure Go, pas besoin de cgo)
86+- crée un fichier issues.db, table issues avec les mêmes champs que la struct Issue
87+- garde exactement la même interface publique du package store (Create, Get, List, Update, Delete) pour ne rien casser côté routes
88+Vérifie que les issues survivent à un redémarrage du serveur.
89+```
90+
91+**Étape 10 — Finitions**
92+```
93+Contexte : projet Go "issue-tracker" complet (CRUD HTMX + SQLite).
94+Ajoute :
95+- un fichier web/static/style.css minimal (tableau propre, badges colorés selon le statut : open=vert, in_progress=orange, closed=gris)
96+- une route pour servir web/static/
97+- un favicon simple si possible
98+N'ajoute rien d'autre, pas de nouvelle fonctionnalité.
99+```
100+
modified docs/diagrams/packages.drawio +3 -1
@@ -1 +1,3 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell>
2+ <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell>
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1 +1,3 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools, lsp&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="version" value="&lt;b&gt;internal/version&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;which mm this is: build stamp, else the commit&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1380" y="600" width="200" height="60" as="geometry"/></mxCell>
2+ <mxCell id="e61" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="version"><mxGeometry relative="1" as="geometry"/></mxCell>
3+ <mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="lsp" value="&lt;b&gt;internal/lsp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;language-server client: definition, references, hover, symbols, diagnostics, rename&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1160" y="600" width="220" height="60" as="geometry"/></mxCell><mxCell id="lspdev" value="&lt;b&gt;go.lsp.dev/protocol + jsonrpc2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LSP types and JSON-RPC 2.0 over stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="1160" y="700" width="220" height="60" as="geometry"/></mxCell><mxCell id="e57" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e58" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="lsp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e59" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="lsp" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e60" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="lsp" target="lspdev"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/explanation/architecture.md +1 -0
@@ -21,6 +21,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
2121 | `detector` | Detection of the same action repeated with the same result. |
2222 | `compact` | Compression of the history into a model-written summary. |
2323 | `spinner` | The "still working" indicator on a terminal. |
24+| `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
2425 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
2526 | `agent` | The terminal REPL. |
2627 | `acp` | The Agent Client Protocol façade. |
@@ -21,6 +21,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
21 | `detector` | Detection of the same action repeated with the same result. |21 | `detector` | Detection of the same action repeated with the same result. |
22 | `compact` | Compression of the history into a model-written summary. |22 | `compact` | Compression of the history into a model-written summary. |
23 | `spinner` | The "still working" indicator on a terminal. |23 | `spinner` | The "still working" indicator on a terminal. |
24+| `version` | Which `mm` this is: the tag stamped at build time, or the commit Go embeds otherwise. Read by `--version`, by the start-up banner and by the window's status bar. |
24 | `ui` | Where human-facing output goes, and the event sink the front ends share. |25 | `ui` | Where human-facing output goes, and the event sink the front ends share. |
25 | `agent` | The terminal REPL. |26 | `agent` | The terminal REPL. |
26 | `acp` | The Agent Client Protocol façade. |27 | `acp` | The Agent Client Protocol façade. |
modified docs/en/how-to/navigate-code-with-lsp.md +1 -1
@@ -71,7 +71,7 @@ This guide shows how to give `mm` a language server, so the model can ask the co
7171
7272 ```text
7373 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74- config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
74+ mm v0.0.3 | config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
7575 ```
7676
7777 `lsp` in the `tools:` column means at least one server was found. Servers start on their first use, not here.
@@ -71,7 +71,7 @@ This guide shows how to give `mm` a language server, so the model can ask the co
71 71
72 ```text72 ```text
73 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]73 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74- config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 474+ mm v0.0.3 | config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75 ```75 ```
76 76
77 `lsp` in the `tools:` column means at least one server was found. Servers start on their first use, not here.77 `lsp` in the `tools:` column means at least one server was found. Servers start on their first use, not here.
modified docs/en/how-to/use-the-window-mode.md +3 -1
@@ -20,7 +20,9 @@ This guide shows how to run mini-me as a single full-screen window — the conve
2020
2121 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.
2222
23-6. Leave with `/quit`, or **Ctrl-C** when nothing is running.
23+6. Leave with `/quit`, at any moment — during a turn too. **Ctrl-C** cancels a running turn and quits when nothing is running.
24+
25+7. If a turn will not stop — the engine stopped answering, a tool is blocked — press **Esc** once more: the window says `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` Then `/quit`, or a second **Ctrl-C**, closes the window without waiting for it. The session file keeps everything up to the last completed turn.
2426
2527 ## Variants
2628
@@ -20,7 +20,9 @@ This guide shows how to run mini-me as a single full-screen window — the conve
20 20
21 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.21 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.
22 22
23-6. Leave with `/quit`, or **Ctrl-C** when nothing is running.23+6. Leave with `/quit`, at any moment — during a turn too. **Ctrl-C** cancels a running turn and quits when nothing is running.
24+
25+7. If a turn will not stop — the engine stopped answering, a tool is blocked — press **Esc** once more: the window says `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` Then `/quit`, or a second **Ctrl-C**, closes the window without waiting for it. The session file keeps everything up to the last completed turn.
24 26
25 ## Variants27 ## Variants
26 28
modified docs/en/reference/cli.md +22 -4
@@ -5,7 +5,7 @@
55 ## Invocation
66
77 ```
8-mm [-config <path>] [-acp] [<path>]
8+mm [-config <path>] [-acp] [-tui] [--version] [<path>]
99 ```
1010
1111 | Element | Type | Default | Description |
@@ -14,8 +14,26 @@ mm [-config <path>] [-acp] [<path>]
1414 | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |
1515 | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |
1616 | `-tui` | bool | `false` | Open the single-window mode (conversation above, input box below). Same effect as `tui: true` in the YAML; ignored with `-acp`. Falls back to the line mode when stdin or stdout is not a terminal. |
17+| `--version` | bool | `false` | Print the version and exit, before the configuration is read. `-version` is the same flag. |
1718 | `-h` | — | — | Print the flag usage and exit. |
1819
20+## Version
21+
22+`mm --version` (or `-version`) prints one line and exits, before the configuration file is looked for — so it answers in any directory, with no model server running:
23+
24+```
25+mm v0.0.3 (go1.26.5, darwin/arm64)
26+```
27+
28+The same version opens the start-up banner (`mm v0.0.3 | config: …`), which the window mode keeps as the conversation's first line, and sits in the window's status bar before the counters.
29+
30+| Where it comes from | What you see |
31+|---|---|
32+| A release binary, stamped at build time by `02-build-releases.sh` | The tag: `v0.0.3` |
33+| `go install`ed from a tagged version | That tag |
34+| Built by hand from a working tree (`go build`, `./install.sh`) | The short commit, `9073268`, with `+dirty` when the tree had uncommitted changes |
35+| Built with `-buildvcs=false` | `unknown` |
36+
1937 ## Configuration file resolution
2038
2139 The first existing source wins.
@@ -44,7 +62,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
4462 | Line | Content |
4563 |------|---------|
4664 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all. |
47-| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |
65+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |
4866 | `Agent ready. Commands: …` | Terminal mode only. |
4967 | `Shortcut: Ctrl+C …` | Terminal mode only. |
5068 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |
@@ -53,7 +71,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
5371
5472 ```
5573 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
56-config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
74+mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
5775 ```
5876
5977 ## ACP mode specifics
@@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
7492 | Screen | The terminal's alternate screen, released on exit; the terminal's own scrollback is not written to. |
7593 | Banner | Not printed on stdout; shown as the first line of the conversation. |
7694 | Spinner | Disabled; the rule under the conversation animates instead. |
77-| Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Esc cancel · Ctrl-L clear · Ctrl-C quit`, `N msgs · N tools`). |
95+| Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` on the left, `<version> · N msgs · N tools` on the right — the version is dropped when the two sides would not fit, so the key hints are never truncated to make room for it). |
7896 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |
7997 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
8098 | Permissions | With `permissions: ask`, the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject` until answered; a turn cancelled while asking rejects. |
@@ -5,7 +5,7 @@
5 ## Invocation5 ## Invocation
6 6
7 ```7 ```
8-mm [-config <path>] [-acp] [<path>]8+mm [-config <path>] [-acp] [-tui] [--version] [<path>]
9 ```9 ```
10 10
11 | Element | Type | Default | Description |11 | Element | Type | Default | Description |
@@ -14,8 +14,26 @@ mm [-config <path>] [-acp] [<path>]
14 | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |14 | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |
15 | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |15 | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |
16 | `-tui` | bool | `false` | Open the single-window mode (conversation above, input box below). Same effect as `tui: true` in the YAML; ignored with `-acp`. Falls back to the line mode when stdin or stdout is not a terminal. |16 | `-tui` | bool | `false` | Open the single-window mode (conversation above, input box below). Same effect as `tui: true` in the YAML; ignored with `-acp`. Falls back to the line mode when stdin or stdout is not a terminal. |
17+| `--version` | bool | `false` | Print the version and exit, before the configuration is read. `-version` is the same flag. |
17 | `-h` | — | — | Print the flag usage and exit. |18 | `-h` | — | — | Print the flag usage and exit. |
18 19
20+## Version
21+
22+`mm --version` (or `-version`) prints one line and exits, before the configuration file is looked for — so it answers in any directory, with no model server running:
23+
24+```
25+mm v0.0.3 (go1.26.5, darwin/arm64)
26+```
27+
28+The same version opens the start-up banner (`mm v0.0.3 | config: …`), which the window mode keeps as the conversation's first line, and sits in the window's status bar before the counters.
29+
30+| Where it comes from | What you see |
31+|---|---|
32+| A release binary, stamped at build time by `02-build-releases.sh` | The tag: `v0.0.3` |
33+| `go install`ed from a tagged version | That tag |
34+| Built by hand from a working tree (`go build`, `./install.sh`) | The short commit, `9073268`, with `+dirty` when the tree had uncommitted changes |
35+| Built with `-buildvcs=false` | `unknown` |
36+
19 ## Configuration file resolution37 ## Configuration file resolution
20 38
21 The first existing source wins.39 The first existing source wins.
@@ -44,7 +62,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
44 | Line | Content |62 | Line | Content |
45 |------|---------|63 |------|---------|
46 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all. |64 | `[warning: …]` | Zero or more probe warnings: server unreachable, model not in the server's list, no chat template, compression that can never trigger, no tool declared at all. |
47-| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |65+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (see above), configuration path, provider name, model identifier, context window with its origin (`config`, `/props`) or `unknown`, declared tool names, number of skills found. |
48 | `Agent ready. Commands: …` | Terminal mode only. |66 | `Agent ready. Commands: …` | Terminal mode only. |
49 | `Shortcut: Ctrl+C …` | Terminal mode only. |67 | `Shortcut: Ctrl+C …` | Terminal mode only. |
50 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |68 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Terminal mode, `sessions.enabled` only: the resolved sessions directory and how many sessions it already holds. |
@@ -53,7 +71,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
53 71
54 ```72 ```
55 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]73 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
56-config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 074+mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
57 ```75 ```
58 76
59 ## ACP mode specifics77 ## ACP mode specifics
@@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
74 | Screen | The terminal's alternate screen, released on exit; the terminal's own scrollback is not written to. |92 | Screen | The terminal's alternate screen, released on exit; the terminal's own scrollback is not written to. |
75 | Banner | Not printed on stdout; shown as the first line of the conversation. |93 | Banner | Not printed on stdout; shown as the first line of the conversation. |
76 | Spinner | Disabled; the rule under the conversation animates instead. |94 | Spinner | Disabled; the rule under the conversation animates instead. |
77-| Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Esc cancel · Ctrl-L clear · Ctrl-C quit`, `N msgs · N tools`). |95+| Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` on the left, `<version> · N msgs · N tools` on the right — the version is dropped when the two sides would not fit, so the key hints are never truncated to make room for it). |
78 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |96 | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. |
79 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |97 | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
80 | Permissions | With `permissions: ask`, the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject` until answered; a turn cancelled while asking rejects. |98 | Permissions | With `permissions: ask`, the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject` until answered; a turn cancelled while asking rejects. |
modified docs/en/reference/slash-commands.md +23 -3
@@ -41,14 +41,18 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
4141
4242 ## Window mode keys
4343
44-In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them. The keys:
44+In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them.
45+
46+`/quit` and `/abort` take effect **immediately**, even while a turn is running. Every other command and every question waits for the turn to end, because it needs the history the turn is still writing. See [Leaving a turn that will not stop](#leaving-a-turn-that-will-not-stop).
47+
48+The keys:
4549
4650 | Key | Action |
4751 |-----|--------|
4852 | `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. |
4953 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
50-| `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. |
51-| `Ctrl-C` | Cancels the running turn; quits when idle. |
54+| `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. |
55+| `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
5256 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. |
5357 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |
5458 | `PgUp` `PgDn` | Scroll one page. |
@@ -61,6 +65,22 @@ In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No gener
6165 | Mouse wheel | Scrolls the conversation — only while the mouse is captured (F2). |
6266 | Bracketed paste | Inserted as one block; its line breaks become newlines, never a send. Longer than 3 lines or 200 characters: collapsed into one `[Pasted #n · L lines · C chars]` token that edits as a single character; Enter sends the full text, the conversation keeps the token. `@path` inside a paste is not looked up. |
6367
68+## Leaving a turn that will not stop
69+
70+A turn normally ends when it is cancelled. A turn that ignores the cancellation — an engine that stopped answering, a tool blocked on something that never returns — used to trap the window: `/quit` and `/abort` waited for the turn to end, so they never ran, and `Ctrl-C` read "there is a turn to cancel" as "so do not quit". The only way out was to kill `mm` from another terminal.
71+
72+The way out, in order:
73+
74+| You do | The window does |
75+|--------|-----------------|
76+| `Esc` or `/abort` | Cancels the turn, and says `🛑 Aborting…`. |
77+| `Esc` or `/abort` again | The turn is still there: `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
78+| `Ctrl-C` | Cancels, as above. |
79+| `Ctrl-C` again | Leaves `mm`, turn or no turn. |
80+| `/quit` | Leaves `mm` at once, at any moment. |
81+
82+Leaving this way does not wait for the stuck job: the session file holds everything saved up to the last completed turn.
83+
6484 ## Messages printed at the end of a turn
6585
6686 | Message | Meaning |
@@ -41,14 +41,18 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
41 41
42 ## Window mode keys42 ## Window mode keys
43 43
44-In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them. The keys:44+In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them.
45+
46+`/quit` and `/abort` take effect **immediately**, even while a turn is running. Every other command and every question waits for the turn to end, because it needs the history the turn is still writing. See [Leaving a turn that will not stop](#leaving-a-turn-that-will-not-stop).
47+
48+The keys:
45 49
46 | Key | Action |50 | Key | Action |
47 |-----|--------|51 |-----|--------|
48 | `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. |52 | `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. |
49 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |53 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
50-| `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. |54+| `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. |
51-| `Ctrl-C` | Cancels the running turn; quits when idle. |55+| `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
52 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. |56 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. |
53 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |57 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |
54 | `PgUp` `PgDn` | Scroll one page. |58 | `PgUp` `PgDn` | Scroll one page. |
@@ -61,6 +65,22 @@ In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No gener
61 | Mouse wheel | Scrolls the conversation — only while the mouse is captured (F2). |65 | Mouse wheel | Scrolls the conversation — only while the mouse is captured (F2). |
62 | Bracketed paste | Inserted as one block; its line breaks become newlines, never a send. Longer than 3 lines or 200 characters: collapsed into one `[Pasted #n · L lines · C chars]` token that edits as a single character; Enter sends the full text, the conversation keeps the token. `@path` inside a paste is not looked up. |66 | Bracketed paste | Inserted as one block; its line breaks become newlines, never a send. Longer than 3 lines or 200 characters: collapsed into one `[Pasted #n · L lines · C chars]` token that edits as a single character; Enter sends the full text, the conversation keeps the token. `@path` inside a paste is not looked up. |
63 67
68+## Leaving a turn that will not stop
69+
70+A turn normally ends when it is cancelled. A turn that ignores the cancellation — an engine that stopped answering, a tool blocked on something that never returns — used to trap the window: `/quit` and `/abort` waited for the turn to end, so they never ran, and `Ctrl-C` read "there is a turn to cancel" as "so do not quit". The only way out was to kill `mm` from another terminal.
71+
72+The way out, in order:
73+
74+| You do | The window does |
75+|--------|-----------------|
76+| `Esc` or `/abort` | Cancels the turn, and says `🛑 Aborting…`. |
77+| `Esc` or `/abort` again | The turn is still there: `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
78+| `Ctrl-C` | Cancels, as above. |
79+| `Ctrl-C` again | Leaves `mm`, turn or no turn. |
80+| `/quit` | Leaves `mm` at once, at any moment. |
81+
82+Leaving this way does not wait for the stuck job: the session file holds everything saved up to the last completed turn.
83+
64 ## Messages printed at the end of a turn84 ## Messages printed at the end of a turn
65 85
66 | Message | Meaning |86 | Message | Meaning |
modified docs/en/reference/tools.md +37 -2
@@ -15,6 +15,9 @@ Declared when `bashTool: true` (the default).
1515 | Shell | `bash -c <command>`, a new shell per call, no stdin. |
1616 | Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |
1717 | Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |
18+| Cancellation | The command belongs to the turn: cancelling the turn (`Esc`, `Ctrl-C`, `/abort`, or asking something else) kills it, and the output ends with `[error: cancelled]`. |
19+| Background jobs | The output is collected through a temporary file, not a pipe, so the tool returns as soon as the shell exits even when the command left a job running (`nohup serve &`). The job keeps running — that is usually the point — and writes into a file nobody reads. Through a pipe, `os/exec` waits for *every* process holding the write end, so such a command blocked the turn for ever. |
20+| Very large output | Read back from both ends (1 MiB each) before `maxOutput` truncation, with `[... output too large to read back in full ...]` between them. |
1821 | Output | stdout and stderr merged, truncated to `maxOutput` characters. |
1922 | Non-zero exit | Output ends with `[exit code: exit status N]`. |
2023 | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
@@ -79,7 +82,7 @@ Declared when `editTools: true`.
7982 |-------|------|---------|-------------|
8083 | `path` | string | — | Path of an existing file. |
8184 | `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |
82-| `edits[].old` | string | — | Exact text to find, present exactly once. |
85+| `edits[].old` | string | — | Exact text to find, present exactly once. Whitespace is forgiven — see *How `old` is matched*. |
8386 | `edits[].new` | string | — | Replacement; empty deletes the matched text. |
8487 | `dry_run` | bool | `false` | Report what would change, write nothing. |
8588
@@ -92,9 +95,41 @@ Declared when `editTools: true`.
9295
9396 | Aspect | Behaviour |
9497 |--------|-----------|
95-| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |
98+| Output | A one-line headline (edits applied, lines added and deleted, first changed line, and ` · N matched ignoring whitespace` when any edit needed a relaxed match) and the diff with line numbers. |
9699 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
97100
101+### How `old` is matched
102+
103+Three passes, strictest first; **the first pass that finds anything at all wins**, so a file that contains the text verbatim is never matched any other way.
104+
105+| Pass | What it forgives | Reported as |
106+|---|---|---|
107+| 1. Exact | Nothing. `old` is searched byte for byte. | — |
108+| 2. Edges | The whitespace *around* `old`: an indent the model kept on a single line, a leading or trailing newline. The same padding comes off `new` when `new` carries it, so a `new` that deliberately adds a blank line still adds it. | `matched ignoring whitespace` |
109+| 3. Indent | Every line's leading and trailing whitespace, for an `old` spanning two lines or more. This is the tabs-versus-spaces case: a model retypes a Go block with four spaces where the file has a tab. | `matched ignoring whitespace` |
110+
111+Two things are **not** relaxed:
112+
113+- **Uniqueness.** A loose match must still be the only one. Two of them are refused exactly as two exact ones are, with the same message and the same line numbers — that rule is what makes an edit verifiable.
114+- **The file's indentation.** Before writing, `new` is re-indented to the file's own base indent and unit, so a tab-indented file does not end up with a block of spaces in the middle of it. A blank line stays empty rather than becoming a line of spaces.
115+
116+The headline then ends with ` · N matched ignoring whitespace`. It is reported rather than hidden: a model told its text did not match exactly writes the next one better.
117+
118+When nothing matches, the refusal shows the region of the file that came closest, with real line numbers — half the lines have to line up before an excerpt is offered, since pointing at an innocent part of the file is worse than saying nothing:
119+
120+```
121+main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
122+ │ func main() {
123+ │ count := 2
124+ │ print(count)
125+ │ }
126+the closest the file comes to it — copy these lines:
127+ │ 3 │ func main() {
128+ │ 4 │ count := 1
129+ │ 5 │ print(count)
130+ │ 6 │ }
131+```
132+
98133 ## `lsp`
99134
100135 Declared when `lsp.servers` lists at least one server whose command is on the `PATH` at start-up. A listed server that is not found is skipped with a `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; when none is found the tool is not declared and the banner says so.
@@ -15,6 +15,9 @@ Declared when `bashTool: true` (the default).
15 | Shell | `bash -c <command>`, a new shell per call, no stdin. |15 | Shell | `bash -c <command>`, a new shell per call, no stdin. |
16 | Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |16 | Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. |
17 | Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |17 | Timeout | 30 s; the output then ends with `[error: 30s timeout exceeded]`. |
18+| Cancellation | The command belongs to the turn: cancelling the turn (`Esc`, `Ctrl-C`, `/abort`, or asking something else) kills it, and the output ends with `[error: cancelled]`. |
19+| Background jobs | The output is collected through a temporary file, not a pipe, so the tool returns as soon as the shell exits even when the command left a job running (`nohup serve &`). The job keeps running — that is usually the point — and writes into a file nobody reads. Through a pipe, `os/exec` waits for *every* process holding the write end, so such a command blocked the turn for ever. |
20+| Very large output | Read back from both ends (1 MiB each) before `maxOutput` truncation, with `[... output too large to read back in full ...]` between them. |
18 | Output | stdout and stderr merged, truncated to `maxOutput` characters. |21 | Output | stdout and stderr merged, truncated to `maxOutput` characters. |
19 | Non-zero exit | Output ends with `[exit code: exit status N]`. |22 | Non-zero exit | Output ends with `[exit code: exit status N]`. |
20 | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |23 | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
@@ -79,7 +82,7 @@ Declared when `editTools: true`.
79 |-------|------|---------|-------------|82 |-------|------|---------|-------------|
80 | `path` | string | — | Path of an existing file. |83 | `path` | string | — | Path of an existing file. |
81 | `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |84 | `edits` | array of `{old, new}` | — | Replacements, all resolved against the original file. |
82-| `edits[].old` | string | — | Exact text to find, present exactly once. |85+| `edits[].old` | string | — | Exact text to find, present exactly once. Whitespace is forgiven — see *How `old` is matched*. |
83 | `edits[].new` | string | — | Replacement; empty deletes the matched text. |86 | `edits[].new` | string | — | Replacement; empty deletes the matched text. |
84 | `dry_run` | bool | `false` | Report what would change, write nothing. |87 | `dry_run` | bool | `false` | Report what would change, write nothing. |
85 88
@@ -92,9 +95,41 @@ Declared when `editTools: true`.
92 95
93 | Aspect | Behaviour |96 | Aspect | Behaviour |
94 |--------|-----------|97 |--------|-----------|
95-| Output | A one-line headline (edits applied, lines added and deleted, first changed line) and the diff with line numbers. |98+| Output | A one-line headline (edits applied, lines added and deleted, first changed line, and ` · N matched ignoring whitespace` when any edit needed a relaxed match) and the diff with line numbers. |
96 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |99 | ACP | Kind `edit`; permission requested; the change travels as a diff with old and new text. |
97 100
101+### How `old` is matched
102+
103+Three passes, strictest first; **the first pass that finds anything at all wins**, so a file that contains the text verbatim is never matched any other way.
104+
105+| Pass | What it forgives | Reported as |
106+|---|---|---|
107+| 1. Exact | Nothing. `old` is searched byte for byte. | — |
108+| 2. Edges | The whitespace *around* `old`: an indent the model kept on a single line, a leading or trailing newline. The same padding comes off `new` when `new` carries it, so a `new` that deliberately adds a blank line still adds it. | `matched ignoring whitespace` |
109+| 3. Indent | Every line's leading and trailing whitespace, for an `old` spanning two lines or more. This is the tabs-versus-spaces case: a model retypes a Go block with four spaces where the file has a tab. | `matched ignoring whitespace` |
110+
111+Two things are **not** relaxed:
112+
113+- **Uniqueness.** A loose match must still be the only one. Two of them are refused exactly as two exact ones are, with the same message and the same line numbers — that rule is what makes an edit verifiable.
114+- **The file's indentation.** Before writing, `new` is re-indented to the file's own base indent and unit, so a tab-indented file does not end up with a block of spaces in the middle of it. A blank line stays empty rather than becoming a line of spaces.
115+
116+The headline then ends with ` · N matched ignoring whitespace`. It is reported rather than hidden: a model told its text did not match exactly writes the next one better.
117+
118+When nothing matches, the refusal shows the region of the file that came closest, with real line numbers — half the lines have to line up before an excerpt is offered, since pointing at an innocent part of the file is worse than saying nothing:
119+
120+```
121+main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
122+ │ func main() {
123+ │ count := 2
124+ │ print(count)
125+ │ }
126+the closest the file comes to it — copy these lines:
127+ │ 3 │ func main() {
128+ │ 4 │ count := 1
129+ │ 5 │ print(count)
130+ │ 6 │ }
131+```
132+
98 ## `lsp`133 ## `lsp`
99 134
100 Declared when `lsp.servers` lists at least one server whose command is on the `PATH` at start-up. A listed server that is not found is skipped with a `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; when none is found the tool is not declared and the banner says so.135 Declared when `lsp.servers` lists at least one server whose command is on the `PATH` at start-up. A listed server that is not found is skipped with a `[warning: lsp server <name> (<command>) not found on the PATH — skipped]`; when none is found the tool is not declared and the banner says so.
modified docs/en/tutorials/getting-started.md +1 -1
@@ -32,7 +32,7 @@ Type:
3232 You should see a banner like this one (the model name is the one from `agent.yaml`):
3333
3434 ```
35-config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
35+mm v0.0.3 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
3636 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.
3737 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
3838
@@ -32,7 +32,7 @@ Type:
32 You should see a banner like this one (the model name is the one from `agent.yaml`):32 You should see a banner like this one (the model name is the one from `agent.yaml`):
33 33
34 ```34 ```
35-config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 035+mm v0.0.3 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.36 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.
37 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.37 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38 38
modified docs/fr/explanation/architecture.md +1 -0
@@ -20,6 +20,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
2020 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
2121 | `detector` | La détection d'une même action répétée avec le même résultat. |
2222 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |
23+| `version` | Quel `mm` c'est : le tag estampillé à la construction, ou le commit que Go embarque sinon. Lu par `--version`, par la bannière de démarrage et par la barre d'état de la fenêtre. |
2324 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
2425 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
2526 | `agent` | Le REPL du terminal. |
@@ -20,6 +20,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
20 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |20 | `mention` | La notation `@chemin` d'une question : les chemins existants deviennent des lignes `[attached file: …]`, la même forme que le `resource_link` d'un éditeur. Utilisé par les deux façades. |
21 | `detector` | La détection d'une même action répétée avec le même résultat. |21 | `detector` | La détection d'une même action répétée avec le même résultat. |
22 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |22 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |
23+| `version` | Quel `mm` c'est : le tag estampillé à la construction, ou le commit que Go embarque sinon. Lu par `--version`, par la bannière de démarrage et par la barre d'état de la fenêtre. |
23 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |24 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
24 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |25 | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
25 | `agent` | Le REPL du terminal. |26 | `agent` | Le REPL du terminal. |
modified docs/fr/how-to/navigate-code-with-lsp.md +1 -1
@@ -71,7 +71,7 @@ Ce guide montre comment donner un serveur de langage à `mm`, pour que le modèl
7171
7272 ```text
7373 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74- config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
74+ mm v0.0.3 | config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
7575 ```
7676
7777 `lsp` dans la colonne `tools:` signifie qu'au moins un serveur a été trouvé. Les serveurs démarrent à leur premier usage, pas ici.
@@ -71,7 +71,7 @@ Ce guide montre comment donner un serveur de langage à `mm`, pour que le modèl
71 71
72 ```text72 ```text
73 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]73 [warning: lsp server ghost (no-such-lsp-server) not found on the PATH — skipped]
74- config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 474+ mm v0.0.3 | config: agent.lsp.yaml | provider: dmr | model: … | ctx: unknown | tools: bash, read_skill, read_file, write_file, edit_file, lsp | skills: 4
75 ```75 ```
76 76
77 `lsp` dans la colonne `tools:` signifie qu'au moins un serveur a été trouvé. Les serveurs démarrent à leur premier usage, pas ici.77 `lsp` dans la colonne `tools:` signifie qu'au moins un serveur a été trouvé. Les serveurs démarrent à leur premier usage, pas ici.
modified docs/fr/how-to/use-the-window-mode.md +3 -1
@@ -20,7 +20,9 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
2020
2121 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.
2222
23-6. Quittez avec `/quit`, ou **Ctrl-C** quand rien ne tourne.
23+6. Quittez avec `/quit`, à n'importe quel moment — pendant un tour aussi. **Ctrl-C** annule un tour en cours et quitte quand rien ne tourne.
24+
25+7. Si un tour refuse de s'arrêter — le moteur ne répond plus, un outil est bloqué — appuyez une fois de plus sur **Échap** : la fenêtre affiche `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` Ensuite `/quit`, ou un second **Ctrl-C**, ferme la fenêtre sans l'attendre. Le fichier de session conserve tout jusqu'au dernier tour terminé.
2426
2527 ## Variantes
2628
@@ -20,7 +20,9 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
20 20
21 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.21 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.
22 22
23-6. Quittez avec `/quit`, ou **Ctrl-C** quand rien ne tourne.23+6. Quittez avec `/quit`, à n'importe quel moment — pendant un tour aussi. **Ctrl-C** annule un tour en cours et quitte quand rien ne tourne.
24+
25+7. Si un tour refuse de s'arrêter — le moteur ne répond plus, un outil est bloqué — appuyez une fois de plus sur **Échap** : la fenêtre affiche `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` Ensuite `/quit`, ou un second **Ctrl-C**, ferme la fenêtre sans l'attendre. Le fichier de session conserve tout jusqu'au dernier tour terminé.
24 26
25 ## Variantes27 ## Variantes
26 28
modified docs/fr/reference/cli.md +22 -4
@@ -5,7 +5,7 @@
55 ## Invocation
66
77 ```
8-mm [-config <chemin>] [-acp] [<chemin>]
8+mm [-config <chemin>] [-acp] [-tui] [--version] [<chemin>]
99 ```
1010
1111 | Élément | Type | Défaut | Description |
@@ -14,8 +14,26 @@ mm [-config <chemin>] [-acp] [<chemin>]
1414 | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |
1515 | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |
1616 | `-tui` | booléen | `false` | Ouvre le mode fenêtre unique (conversation en haut, zone de saisie en bas). Même effet que `tui: true` dans le YAML ; ignoré avec `-acp`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal. |
17+| `--version` | bool | `false` | Affiche la version et quitte, avant la lecture de la configuration. `-version` est la même option. |
1718 | `-h` | — | — | Affiche l'usage des options et quitte. |
1819
20+## Version
21+
22+`mm --version` (ou `-version`) affiche une ligne puis quitte, avant même de chercher le fichier de configuration — la réponse arrive donc depuis n'importe quel répertoire, sans serveur de modèle démarré :
23+
24+```
25+mm v0.0.3 (go1.26.5, darwin/arm64)
26+```
27+
28+La même version ouvre la bannière de démarrage (`mm v0.0.3 | config: …`), que le mode fenêtre conserve comme première ligne de la conversation, et figure dans la barre d'état de la fenêtre avant les compteurs.
29+
30+| D'où elle vient | Ce que vous voyez |
31+|---|---|
32+| Un binaire de release, estampillé à la construction par `02-build-releases.sh` | Le tag : `v0.0.3` |
33+| Installé par `go install` depuis une version taguée | Ce tag |
34+| Construit à la main depuis un arbre de travail (`go build`, `./install.sh`) | Le commit court, `9073268`, suivi de `+dirty` si l'arbre avait des modifications non validées |
35+| Construit avec `-buildvcs=false` | `unknown` |
36+
1937 ## Résolution du fichier de configuration
2038
2139 La première source existante gagne.
@@ -44,7 +62,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
4462 | Ligne | Contenu |
4563 |-------|---------|
4664 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré. |
47-| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |
65+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |
4866 | `Agent ready. Commands: …` | Mode terminal seulement. |
4967 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
5068 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |
@@ -53,7 +71,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
5371
5472 ```
5573 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
56-config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
74+mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
5775 ```
5876
5977 ## Spécificités du mode ACP
@@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
7492 | Écran | L'écran alternatif du terminal, rendu à la sortie ; l'historique de défilement du terminal n'est pas écrit. |
7593 | Bannière | Non affichée sur stdout ; montrée comme première ligne de la conversation. |
7694 | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. |
77-| Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Esc cancel · Ctrl-L clear · Ctrl-C quit`, `N msgs · N tools`). |
95+| Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` à gauche, `<version> · N msgs · N tools` à droite — la version disparaît quand les deux côtés ne tiennent pas, pour que les rappels de touches ne soient jamais tronqués à cause d'elle). |
7896 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |
7997 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
8098 | Permissions | Avec `permissions: ask`, la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject` jusqu'à la réponse ; un tour annulé pendant la question refuse. |
@@ -5,7 +5,7 @@
5 ## Invocation5 ## Invocation
6 6
7 ```7 ```
8-mm [-config <chemin>] [-acp] [<chemin>]8+mm [-config <chemin>] [-acp] [-tui] [--version] [<chemin>]
9 ```9 ```
10 10
11 | Élément | Type | Défaut | Description |11 | Élément | Type | Défaut | Description |
@@ -14,8 +14,26 @@ mm [-config <chemin>] [-acp] [<chemin>]
14 | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |14 | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |
15 | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |15 | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |
16 | `-tui` | booléen | `false` | Ouvre le mode fenêtre unique (conversation en haut, zone de saisie en bas). Même effet que `tui: true` dans le YAML ; ignoré avec `-acp`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal. |16 | `-tui` | booléen | `false` | Ouvre le mode fenêtre unique (conversation en haut, zone de saisie en bas). Même effet que `tui: true` dans le YAML ; ignoré avec `-acp`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal. |
17+| `--version` | bool | `false` | Affiche la version et quitte, avant la lecture de la configuration. `-version` est la même option. |
17 | `-h` | — | — | Affiche l'usage des options et quitte. |18 | `-h` | — | — | Affiche l'usage des options et quitte. |
18 19
20+## Version
21+
22+`mm --version` (ou `-version`) affiche une ligne puis quitte, avant même de chercher le fichier de configuration — la réponse arrive donc depuis n'importe quel répertoire, sans serveur de modèle démarré :
23+
24+```
25+mm v0.0.3 (go1.26.5, darwin/arm64)
26+```
27+
28+La même version ouvre la bannière de démarrage (`mm v0.0.3 | config: …`), que le mode fenêtre conserve comme première ligne de la conversation, et figure dans la barre d'état de la fenêtre avant les compteurs.
29+
30+| D'où elle vient | Ce que vous voyez |
31+|---|---|
32+| Un binaire de release, estampillé à la construction par `02-build-releases.sh` | Le tag : `v0.0.3` |
33+| Installé par `go install` depuis une version taguée | Ce tag |
34+| Construit à la main depuis un arbre de travail (`go build`, `./install.sh`) | Le commit court, `9073268`, suivi de `+dirty` si l'arbre avait des modifications non validées |
35+| Construit avec `-buildvcs=false` | `unknown` |
36+
19 ## Résolution du fichier de configuration37 ## Résolution du fichier de configuration
20 38
21 La première source existante gagne.39 La première source existante gagne.
@@ -44,7 +62,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
44 | Ligne | Contenu |62 | Ligne | Contenu |
45 |-------|---------|63 |-------|---------|
46 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré. |64 | `[warning: …]` | Zéro ou plusieurs avertissements de la sonde : serveur injoignable, modèle absent de la liste du serveur, pas de template de chat, compression qui ne peut jamais se déclencher, aucun outil déclaré. |
47-| `config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |65+| `mm <version> \| config: … \| provider: … \| model: … \| ctx: … \| tools: … \| skills: N` | Version (voir ci-dessus), chemin de la configuration, nom du fournisseur, identifiant du modèle, fenêtre de contexte avec son origine (`config`, `/props`) ou `unknown`, noms des outils déclarés, nombre de skills trouvés. |
48 | `Agent ready. Commands: …` | Mode terminal seulement. |66 | `Agent ready. Commands: …` | Mode terminal seulement. |
49 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |67 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |
50 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |68 | `sessions: saved under <dir>[ · N earlier — /resume to continue the latest, /sessions to list]` | Mode terminal, avec `sessions.enabled` seulement : le répertoire de sessions résolu et le nombre de sessions qu'il contient déjà. |
@@ -53,7 +71,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
53 71
54 ```72 ```
55 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]73 [warning: llamacpp: nothing answers at http://127.0.0.1:8080/v1 — start it: llama-server -m <model.gguf> --jinja --port 8080]
56-config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 074+mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
57 ```75 ```
58 76
59 ## Spécificités du mode ACP77 ## Spécificités du mode ACP
@@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
74 | Écran | L'écran alternatif du terminal, rendu à la sortie ; l'historique de défilement du terminal n'est pas écrit. |92 | Écran | L'écran alternatif du terminal, rendu à la sortie ; l'historique de défilement du terminal n'est pas écrit. |
75 | Bannière | Non affichée sur stdout ; montrée comme première ligne de la conversation. |93 | Bannière | Non affichée sur stdout ; montrée comme première ligne de la conversation. |
76 | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. |94 | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. |
77-| Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Esc cancel · Ctrl-L clear · Ctrl-C quit`, `N msgs · N tools`). |95+| Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` à gauche, `<version> · N msgs · N tools` à droite — la version disparaît quand les deux côtés ne tiennent pas, pour que les rappels de touches ne soient jamais tronqués à cause d'elle). |
78 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |96 | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. |
79 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |97 | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. |
80 | Permissions | Avec `permissions: ask`, la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject` jusqu'à la réponse ; un tour annulé pendant la question refuse. |98 | Permissions | Avec `permissions: ask`, la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject` jusqu'à la réponse ; un tour annulé pendant la question refuse. |
modified docs/fr/reference/slash-commands.md +23 -3
@@ -41,14 +41,18 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
4141
4242 ## Touches du mode fenêtre
4343
44-Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. Les touches :
44+Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste.
45+
46+`/quit` et `/abort` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas).
47+
48+Les touches :
4549
4650 | Touche | Action |
4751 |--------|--------|
4852 | `Entrée` | Envoie la zone (rien sur une zone vide). Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |
4953 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
50-| `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. |
51-| `Ctrl-C` | Annule le tour en cours ; quitte au repos. |
54+| `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. |
55+| `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
5256 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. |
5357 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
5458 | `PgUp` `PgDn` | Défilent d'une page. |
@@ -61,6 +65,22 @@ Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️
6165 | Molette | Fait défiler la conversation — seulement quand la souris est capturée (F2). |
6266 | Collage encadré | Inséré en un bloc ; ses sauts de ligne deviennent des sauts de ligne, jamais un envoi. Au-delà de 3 lignes ou de 200 caractères : replié en un jeton `[Pasted #n · L lines · C chars]` qui s'édite comme un seul caractère ; Entrée envoie le texte complet, la conversation garde le jeton. Un `@chemin` dans un collage n'est pas résolu. |
6367
68+## Sortir d'un tour qui ne s'arrête pas
69+
70+Un tour se termine normalement quand on l'annule. Un tour qui ignore l'annulation — un moteur qui ne répond plus, un outil bloqué sur quelque chose qui ne revient jamais — piégeait la fenêtre : `/quit` et `/abort` attendaient la fin du tour, donc ne s'exécutaient jamais, et `Ctrl-C` comprenait « il y a un tour à annuler » comme « donc ne pas quitter ». La seule issue était de tuer `mm` depuis un autre terminal.
71+
72+La sortie, dans l'ordre :
73+
74+| Vous faites | La fenêtre fait |
75+|-------------|-----------------|
76+| `Échap` ou `/abort` | Annule le tour, et affiche `🛑 Aborting…`. |
77+| `Échap` ou `/abort` à nouveau | Le tour est toujours là : `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
78+| `Ctrl-C` | Annule, comme ci-dessus. |
79+| `Ctrl-C` à nouveau | Quitte `mm`, tour en cours ou non. |
80+| `/quit` | Quitte `mm` tout de suite, à n'importe quel moment. |
81+
82+Sortir ainsi n'attend pas le travail bloqué : le fichier de session contient tout ce qui a été enregistré jusqu'au dernier tour terminé.
83+
6484 ## Messages affichés en fin de tour
6585
6686 | Message | Signification |
@@ -41,14 +41,18 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
41 41
42 ## Touches du mode fenêtre42 ## Touches du mode fenêtre
43 43
44-Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. Les touches :44+Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste.
45+
46+`/quit` et `/abort` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas).
47+
48+Les touches :
45 49
46 | Touche | Action |50 | Touche | Action |
47 |--------|--------|51 |--------|--------|
48 | `Entrée` | Envoie la zone (rien sur une zone vide). Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |52 | `Entrée` | Envoie la zone (rien sur une zone vide). Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |
49 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |53 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
50-| `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. |54+| `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. |
51-| `Ctrl-C` | Annule le tour en cours ; quitte au repos. |55+| `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
52 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. |56 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. |
53 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |57 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
54 | `PgUp` `PgDn` | Défilent d'une page. |58 | `PgUp` `PgDn` | Défilent d'une page. |
@@ -61,6 +65,22 @@ Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️
61 | Molette | Fait défiler la conversation — seulement quand la souris est capturée (F2). |65 | Molette | Fait défiler la conversation — seulement quand la souris est capturée (F2). |
62 | Collage encadré | Inséré en un bloc ; ses sauts de ligne deviennent des sauts de ligne, jamais un envoi. Au-delà de 3 lignes ou de 200 caractères : replié en un jeton `[Pasted #n · L lines · C chars]` qui s'édite comme un seul caractère ; Entrée envoie le texte complet, la conversation garde le jeton. Un `@chemin` dans un collage n'est pas résolu. |66 | Collage encadré | Inséré en un bloc ; ses sauts de ligne deviennent des sauts de ligne, jamais un envoi. Au-delà de 3 lignes ou de 200 caractères : replié en un jeton `[Pasted #n · L lines · C chars]` qui s'édite comme un seul caractère ; Entrée envoie le texte complet, la conversation garde le jeton. Un `@chemin` dans un collage n'est pas résolu. |
63 67
68+## Sortir d'un tour qui ne s'arrête pas
69+
70+Un tour se termine normalement quand on l'annule. Un tour qui ignore l'annulation — un moteur qui ne répond plus, un outil bloqué sur quelque chose qui ne revient jamais — piégeait la fenêtre : `/quit` et `/abort` attendaient la fin du tour, donc ne s'exécutaient jamais, et `Ctrl-C` comprenait « il y a un tour à annuler » comme « donc ne pas quitter ». La seule issue était de tuer `mm` depuis un autre terminal.
71+
72+La sortie, dans l'ordre :
73+
74+| Vous faites | La fenêtre fait |
75+|-------------|-----------------|
76+| `Échap` ou `/abort` | Annule le tour, et affiche `🛑 Aborting…`. |
77+| `Échap` ou `/abort` à nouveau | Le tour est toujours là : `⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.` |
78+| `Ctrl-C` | Annule, comme ci-dessus. |
79+| `Ctrl-C` à nouveau | Quitte `mm`, tour en cours ou non. |
80+| `/quit` | Quitte `mm` tout de suite, à n'importe quel moment. |
81+
82+Sortir ainsi n'attend pas le travail bloqué : le fichier de session contient tout ce qui a été enregistré jusqu'au dernier tour terminé.
83+
64 ## Messages affichés en fin de tour84 ## Messages affichés en fin de tour
65 85
66 | Message | Signification |86 | Message | Signification |
modified docs/fr/reference/tools.md +37 -2
@@ -15,6 +15,9 @@ Déclaré quand `bashTool: true` (la valeur par défaut).
1515 | Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. |
1616 | Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. |
1717 | Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. |
18+| Annulation | La commande appartient au tour : annuler le tour (`Échap`, `Ctrl-C`, `/abort`, ou poser une autre question) la tue, et la sortie se termine par `[error: cancelled]`. |
19+| Tâches de fond | La sortie est collectée dans un fichier temporaire, pas dans un tube : l'outil rend la main dès que le shell se termine, même si la commande a laissé une tâche en cours (`nohup serve &`). La tâche continue de tourner — c'est en général le but — et écrit dans un fichier que personne ne lit. Par un tube, `os/exec` attend *tous* les processus qui détiennent l'extrémité d'écriture, et une telle commande bloquait le tour pour toujours. |
20+| Sortie très volumineuse | Relue par ses deux extrémités (1 Mio chacune) avant la troncature à `maxOutput`, avec `[... output too large to read back in full ...]` entre les deux. |
1821 | Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. |
1922 | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |
2023 | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
@@ -79,7 +82,7 @@ Déclaré quand `editTools: true`.
7982 |-------|------|--------|-------------|
8083 | `path` | chaîne | — | Chemin d'un fichier existant. |
8184 | `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. |
82-| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. |
85+| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. Les espaces sont pardonnés — voir *Comment `old` est mis en correspondance*. |
8386 | `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. |
8487 | `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. |
8588
@@ -92,9 +95,41 @@ Déclaré quand `editTools: true`.
9295
9396 | Aspect | Comportement |
9497 |--------|--------------|
95-| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |
98+| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée, et ` · N matched ignoring whitespace` quand une édition a eu besoin d'une correspondance tolérante) et le diff avec numéros de ligne. |
9699 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
97100
101+### Comment `old` est mis en correspondance
102+
103+Trois passes, de la plus stricte à la plus tolérante ; **la première passe qui trouve quoi que ce soit gagne**, donc un fichier qui contient le texte à l'octet près n'est jamais mis en correspondance autrement.
104+
105+| Passe | Ce qu'elle pardonne | Signalé par |
106+|---|---|---|
107+| 1. Exacte | Rien. `old` est cherché octet pour octet. | — |
108+| 2. Bords | Les espaces *autour* de `old` : une indentation que le modèle a conservée sur une ligne seule, un saut de ligne en tête ou en queue. Le même rembourrage est retiré de `new` quand `new` le porte, donc un `new` qui ajoute volontairement une ligne vide l'ajoute toujours. | `matched ignoring whitespace` |
109+| 3. Indentation | Les espaces de début et de fin de chaque ligne, pour un `old` d'au moins deux lignes. C'est le cas tabulations contre espaces : un modèle retape un bloc Go avec quatre espaces là où le fichier a une tabulation. | `matched ignoring whitespace` |
110+
111+Deux choses ne sont **pas** assouplies :
112+
113+- **L'unicité.** Une correspondance tolérante doit rester la seule. Deux d'entre elles sont refusées exactement comme deux exactes, avec le même message et les mêmes numéros de ligne — c'est cette règle qui rend une édition vérifiable.
114+- **L'indentation du fichier.** Avant écriture, `new` est ré-indenté avec l'indentation de base et l'unité du fichier, pour qu'un fichier indenté aux tabulations ne se retrouve pas avec un bloc d'espaces au milieu. Une ligne vide reste vide au lieu de devenir une ligne d'espaces.
115+
116+L'en-tête se termine alors par ` · N matched ignoring whitespace`. C'est signalé plutôt que caché : un modèle à qui l'on dit que son texte ne correspondait pas exactement écrit mieux le suivant.
117+
118+Quand rien ne correspond, le refus montre la région du fichier qui s'en approche le plus, avec les vrais numéros de ligne — la moitié des lignes doit coïncider avant qu'un extrait soit proposé, car désigner une partie innocente du fichier est pire que de ne rien dire :
119+
120+```
121+main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
122+ │ func main() {
123+ │ count := 2
124+ │ print(count)
125+ │ }
126+the closest the file comes to it — copy these lines:
127+ │ 3 │ func main() {
128+ │ 4 │ count := 1
129+ │ 5 │ print(count)
130+ │ 6 │ }
131+```
132+
98133 ## `lsp`
99134
100135 Déclaré quand `lsp.servers` liste au moins un serveur dont la commande est sur le `PATH` au démarrage. Un serveur listé mais introuvable est ignoré avec un `[warning: lsp server <nom> (<commande>) not found on the PATH — skipped]` ; quand aucun n'est trouvé, l'outil n'est pas déclaré et la bannière le dit.
@@ -15,6 +15,9 @@ Déclaré quand `bashTool: true` (la valeur par défaut).
15 | Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. |15 | Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. |
16 | Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. |16 | Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. |
17 | Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. |17 | Délai | 30 s ; la sortie se termine alors par `[error: 30s timeout exceeded]`. |
18+| Annulation | La commande appartient au tour : annuler le tour (`Échap`, `Ctrl-C`, `/abort`, ou poser une autre question) la tue, et la sortie se termine par `[error: cancelled]`. |
19+| Tâches de fond | La sortie est collectée dans un fichier temporaire, pas dans un tube : l'outil rend la main dès que le shell se termine, même si la commande a laissé une tâche en cours (`nohup serve &`). La tâche continue de tourner — c'est en général le but — et écrit dans un fichier que personne ne lit. Par un tube, `os/exec` attend *tous* les processus qui détiennent l'extrémité d'écriture, et une telle commande bloquait le tour pour toujours. |
20+| Sortie très volumineuse | Relue par ses deux extrémités (1 Mio chacune) avant la troncature à `maxOutput`, avec `[... output too large to read back in full ...]` entre les deux. |
18 | Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. |21 | Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. |
19 | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |22 | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |
20 | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |23 | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
@@ -79,7 +82,7 @@ Déclaré quand `editTools: true`.
79 |-------|------|--------|-------------|82 |-------|------|--------|-------------|
80 | `path` | chaîne | — | Chemin d'un fichier existant. |83 | `path` | chaîne | — | Chemin d'un fichier existant. |
81 | `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. |84 | `edits` | tableau de `{old, new}` | — | Remplacements, tous résolus par rapport au fichier original. |
82-| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. |85+| `edits[].old` | chaîne | — | Texte exact à trouver, présent exactement une fois. Les espaces sont pardonnés — voir *Comment `old` est mis en correspondance*. |
83 | `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. |86 | `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. |
84 | `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. |87 | `dry_run` | booléen | `false` | Rapporte ce qui changerait, n'écrit rien. |
85 88
@@ -92,9 +95,41 @@ Déclaré quand `editTools: true`.
92 95
93 | Aspect | Comportement |96 | Aspect | Comportement |
94 |--------|--------------|97 |--------|--------------|
95-| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée) et le diff avec numéros de ligne. |98+| Sortie | Un en-tête d'une ligne (éditions appliquées, lignes ajoutées et supprimées, première ligne modifiée, et ` · N matched ignoring whitespace` quand une édition a eu besoin d'une correspondance tolérante) et le diff avec numéros de ligne. |
96 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |99 | ACP | Genre `edit` ; permission demandée ; le changement voyage comme un diff avec ancien et nouveau texte. |
97 100
101+### Comment `old` est mis en correspondance
102+
103+Trois passes, de la plus stricte à la plus tolérante ; **la première passe qui trouve quoi que ce soit gagne**, donc un fichier qui contient le texte à l'octet près n'est jamais mis en correspondance autrement.
104+
105+| Passe | Ce qu'elle pardonne | Signalé par |
106+|---|---|---|
107+| 1. Exacte | Rien. `old` est cherché octet pour octet. | — |
108+| 2. Bords | Les espaces *autour* de `old` : une indentation que le modèle a conservée sur une ligne seule, un saut de ligne en tête ou en queue. Le même rembourrage est retiré de `new` quand `new` le porte, donc un `new` qui ajoute volontairement une ligne vide l'ajoute toujours. | `matched ignoring whitespace` |
109+| 3. Indentation | Les espaces de début et de fin de chaque ligne, pour un `old` d'au moins deux lignes. C'est le cas tabulations contre espaces : un modèle retape un bloc Go avec quatre espaces là où le fichier a une tabulation. | `matched ignoring whitespace` |
110+
111+Deux choses ne sont **pas** assouplies :
112+
113+- **L'unicité.** Une correspondance tolérante doit rester la seule. Deux d'entre elles sont refusées exactement comme deux exactes, avec le même message et les mêmes numéros de ligne — c'est cette règle qui rend une édition vérifiable.
114+- **L'indentation du fichier.** Avant écriture, `new` est ré-indenté avec l'indentation de base et l'unité du fichier, pour qu'un fichier indenté aux tabulations ne se retrouve pas avec un bloc d'espaces au milieu. Une ligne vide reste vide au lieu de devenir une ligne d'espaces.
115+
116+L'en-tête se termine alors par ` · N matched ignoring whitespace`. C'est signalé plutôt que caché : un modèle à qui l'on dit que son texte ne correspondait pas exactement écrit mieux le suivant.
117+
118+Quand rien ne correspond, le refus montre la région du fichier qui s'en approche le plus, avec les vrais numéros de ligne — la moitié des lignes doit coïncider avant qu'un extrait soit proposé, car désigner une partie innocente du fichier est pire que de ne rien dire :
119+
120+```
121+main.go: edit #1: old text not found — it must match the file exactly, including indentation and line breaks:
122+ │ func main() {
123+ │ count := 2
124+ │ print(count)
125+ │ }
126+the closest the file comes to it — copy these lines:
127+ │ 3 │ func main() {
128+ │ 4 │ count := 1
129+ │ 5 │ print(count)
130+ │ 6 │ }
131+```
132+
98 ## `lsp`133 ## `lsp`
99 134
100 Déclaré quand `lsp.servers` liste au moins un serveur dont la commande est sur le `PATH` au démarrage. Un serveur listé mais introuvable est ignoré avec un `[warning: lsp server <nom> (<commande>) not found on the PATH — skipped]` ; quand aucun n'est trouvé, l'outil n'est pas déclaré et la bannière le dit.135 Déclaré quand `lsp.servers` liste au moins un serveur dont la commande est sur le `PATH` au démarrage. Un serveur listé mais introuvable est ignoré avec un `[warning: lsp server <nom> (<commande>) not found on the PATH — skipped]` ; quand aucun n'est trouvé, l'outil n'est pas déclaré et la bannière le dit.
modified docs/fr/tutorials/getting-started.md +1 -1
@@ -32,7 +32,7 @@ Tapez :
3232 Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) :
3333
3434 ```
35-config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
35+mm v0.0.3 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
3636 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.
3737 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
3838
@@ -32,7 +32,7 @@ Tapez :
32 Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) :32 Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`agent.yaml`) :
33 33
34 ```34 ```
35-config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 035+mm v0.0.3 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0
36 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.36 Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session, "/sessions" and "/resume" for stored sessions, "/help" to list them.
37 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.37 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
38 38
modified internal/fileedit/edits.go +64 -29
@@ -1,6 +1,7 @@
11 package fileedit
22
33 import (
4+ "errors"
45 "fmt"
56 "sort"
67 "strconv"
@@ -14,10 +15,14 @@ type Edit struct {
1415 New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"`
1516 }
1617
17-// span is the region an edit occupies in the ORIGINAL body.
18+// span is the region an edit occupies in the ORIGINAL body, and what goes
19+// there. The text is the edit's `new` — re-indented when the match was loose,
20+// so the file keeps its own indentation (see match.go).
1821 type span struct {
1922 start, end int
2023 index int // rank of the edit, for error messages
24+ text string
25+ loose bool // matched only by relaxing the whitespace
2126 }
2227
2328 // apply applies every edit to the body and returns the result.
@@ -29,16 +34,20 @@ type span struct {
2934 // overlapping regions — would give a result that depends on application order,
3035 // hence a file nobody has read. Nothing is written when one edit fails: a
3136 // half-edited file is the worst outcome, because it looks like a success.
32-func apply(body string, list []Edit) (string, error) {
37+func apply(body string, list []Edit) (string, int, error) {
3338 if len(list) == 0 {
34- return "", fmt.Errorf("no edit given: each edit needs an old and a new text")
39+ return "", 0, fmt.Errorf("no edit given: each edit needs an old and a new text")
3540 }
3641
42+ loose := 0
3743 spans := make([]span, 0, len(list))
3844 for i, e := range list {
3945 s, err := locate(body, e, i)
4046 if err != nil {
41- return "", err
47+ return "", 0, err
48+ }
49+ if s.loose {
50+ loose++
4251 }
4352 spans = append(spans, s)
4453 }
@@ -49,7 +58,7 @@ func apply(body string, list []Edit) (string, error) {
4958 for i := 1; i < len(spans); i++ {
5059 if spans[i].start < spans[i-1].end {
5160 a, b := spans[i-1], spans[i]
52- return "", fmt.Errorf(
61+ return "", 0, fmt.Errorf(
5362 "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair",
5463 a.index+1, b.index+1,
5564 lineOf(body, a.start), lineOf(body, a.end),
@@ -61,11 +70,11 @@ func apply(body string, list []Edit) (string, error) {
6170 at := 0
6271 for _, s := range spans {
6372 b.WriteString(body[at:s.start])
64- b.WriteString(list[s.index].New)
73+ b.WriteString(s.text)
6574 at = s.end
6675 }
6776 b.WriteString(body[at:])
68- return b.String(), nil
77+ return b.String(), loose, nil
6978 }
7079
7180 // locate finds the single occurrence of an `old` text.
@@ -82,33 +91,39 @@ func locate(body string, e Edit, i int) (span, error) {
8291 return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1)
8392 }
8493
85- switch n := strings.Count(body, e.Old); {
86- case n == 0:
87- return span{}, fmt.Errorf(
88- "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",
89- i+1, quote(e.Old))
90- case n > 1:
91- return span{}, fmt.Errorf(
92- "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
93- i+1, n, occurrenceLines(body, e.Old), quote(e.Old))
94+ spans, loose := findAll(body, e)
95+ switch len(spans) {
96+ case 0:
97+ return span{}, notFound(body, e, i)
98+ case 1:
99+ m := spans[0]
100+ return span{start: m.start, end: m.end, index: i, text: m.text, loose: loose}, nil
94101 }
102+ return span{}, fmt.Errorf(
103+ "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
104+ i+1, len(spans), occurrenceLines(body, spans), quote(e.Old))
105+}
95106
96- start := strings.Index(body, e.Old)
97- return span{start: start, end: start + len(e.Old), index: i}, nil
107+// notFound explains a miss, and shows the file where it can. "Not found" on
108+// its own tells a model nothing it can act on — it retypes the same text; the
109+// lines it nearly matched, with their numbers, are what it needs to fix the
110+// call. See nearest.
111+func notFound(body string, e Edit, i int) error {
112+ msg := fmt.Sprintf(
113+ "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",
114+ i+1, quote(e.Old))
115+ if near := nearest(body, e.Old); len(near) > 0 {
116+ msg += "\nthe closest the file comes to it — copy these lines:\n" + quoteFile(body, near)
117+ }
118+ return errors.New(msg)
98119 }
99120
100-// occurrenceLines lists the 1-based lines where text occurs in body, as
101-// "11, 20": the model has to pick one, and a line number is what lets it.
102-func occurrenceLines(body, text string) string {
121+// occurrenceLines lists the 1-based lines of the matches found, as "11, 20":
122+// the model has to pick one, and a line number is what lets it.
123+func occurrenceLines(body string, spans []match) string {
103124 var lines []string
104- for at, from := 0, 0; ; {
105- i := strings.Index(body[from:], text)
106- if i < 0 {
107- break
108- }
109- at = from + i
110- lines = append(lines, strconv.Itoa(lineOf(body, at)))
111- from = at + len(text)
125+ for _, m := range spans {
126+ lines = append(lines, strconv.Itoa(lineOf(body, m.start)))
112127 }
113128 return strings.Join(lines, ", ")
114129 }
@@ -137,3 +152,23 @@ func quote(s string) string {
137152 }
138153 return strings.TrimRight(b.String(), "\n")
139154 }
155+
156+// quoteFile shows a region of the file with its real line numbers, in the same
157+// frame as quote: the model compares the two blocks side by side and sees what
158+// it got wrong — almost always the indentation.
159+func quoteFile(body string, lines []lineSpan) string {
160+ const maxLines = 8
161+
162+ shown := lines
163+ if len(shown) > maxLines {
164+ shown = shown[:maxLines]
165+ }
166+ var b strings.Builder
167+ for i, s := range shown {
168+ fmt.Fprintf(&b, " │ %d │ %s\n", lineOf(body, lines[0].start)+i, body[s.start:s.end])
169+ }
170+ if len(lines) > len(shown) {
171+ fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown))
172+ }
173+ return strings.TrimRight(b.String(), "\n")
174+}
@@ -1,6 +1,7 @@
1 package fileedit1 package fileedit
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "sort"6 "sort"
6 "strconv"7 "strconv"
@@ -14,10 +15,14 @@ type Edit struct {
14 New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"`15 New string `json:"new" jsonschema_description:"Replacement text; empty deletes the matched text"`
15 }16 }
16 17
17-// span is the region an edit occupies in the ORIGINAL body.18+// span is the region an edit occupies in the ORIGINAL body, and what goes
19+// there. The text is the edit's `new` — re-indented when the match was loose,
20+// so the file keeps its own indentation (see match.go).
18 type span struct {21 type span struct {
19 start, end int22 start, end int
20 index int // rank of the edit, for error messages23 index int // rank of the edit, for error messages
24+ text string
25+ loose bool // matched only by relaxing the whitespace
21 }26 }
22 27
23 // apply applies every edit to the body and returns the result.28 // apply applies every edit to the body and returns the result.
@@ -29,16 +34,20 @@ type span struct {
29 // overlapping regions — would give a result that depends on application order,34 // overlapping regions — would give a result that depends on application order,
30 // hence a file nobody has read. Nothing is written when one edit fails: a35 // hence a file nobody has read. Nothing is written when one edit fails: a
31 // half-edited file is the worst outcome, because it looks like a success.36 // half-edited file is the worst outcome, because it looks like a success.
32-func apply(body string, list []Edit) (string, error) {37+func apply(body string, list []Edit) (string, int, error) {
33 if len(list) == 0 {38 if len(list) == 0 {
34- return "", fmt.Errorf("no edit given: each edit needs an old and a new text")39+ return "", 0, fmt.Errorf("no edit given: each edit needs an old and a new text")
35 }40 }
36 41
42+ loose := 0
37 spans := make([]span, 0, len(list))43 spans := make([]span, 0, len(list))
38 for i, e := range list {44 for i, e := range list {
39 s, err := locate(body, e, i)45 s, err := locate(body, e, i)
40 if err != nil {46 if err != nil {
41- return "", err47+ return "", 0, err
48+ }
49+ if s.loose {
50+ loose++
42 }51 }
43 spans = append(spans, s)52 spans = append(spans, s)
44 }53 }
@@ -49,7 +58,7 @@ func apply(body string, list []Edit) (string, error) {
49 for i := 1; i < len(spans); i++ {58 for i := 1; i < len(spans); i++ {
50 if spans[i].start < spans[i-1].end {59 if spans[i].start < spans[i-1].end {
51 a, b := spans[i-1], spans[i]60 a, b := spans[i-1], spans[i]
52- return "", fmt.Errorf(61+ return "", 0, fmt.Errorf(
53 "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair",62 "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair",
54 a.index+1, b.index+1,63 a.index+1, b.index+1,
55 lineOf(body, a.start), lineOf(body, a.end),64 lineOf(body, a.start), lineOf(body, a.end),
@@ -61,11 +70,11 @@ func apply(body string, list []Edit) (string, error) {
61 at := 070 at := 0
62 for _, s := range spans {71 for _, s := range spans {
63 b.WriteString(body[at:s.start])72 b.WriteString(body[at:s.start])
64- b.WriteString(list[s.index].New)73+ b.WriteString(s.text)
65 at = s.end74 at = s.end
66 }75 }
67 b.WriteString(body[at:])76 b.WriteString(body[at:])
68- return b.String(), nil77+ return b.String(), loose, nil
69 }78 }
70 79
71 // locate finds the single occurrence of an `old` text.80 // locate finds the single occurrence of an `old` text.
@@ -82,33 +91,39 @@ func locate(body string, e Edit, i int) (span, error) {
82 return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1)91 return span{}, fmt.Errorf("edit #%d changes nothing: old and new are identical", i+1)
83 }92 }
84 93
85- switch n := strings.Count(body, e.Old); {94+ spans, loose := findAll(body, e)
86- case n == 0:95+ switch len(spans) {
87- return span{}, fmt.Errorf(96+ case 0:
88- "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",97+ return span{}, notFound(body, e, i)
89- i+1, quote(e.Old))98+ case 1:
90- case n > 1:99+ m := spans[0]
91- return span{}, fmt.Errorf(100+ return span{start: m.start, end: m.end, index: i, text: m.text, loose: loose}, nil
92- "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
93- i+1, n, occurrenceLines(body, e.Old), quote(e.Old))
94 }101 }
102+ return span{}, fmt.Errorf(
103+ "edit #%d: old text appears %d times (lines %s) — to change ONE of them, include its surrounding lines in old until the text is unique; to change several, send one edit per place, each with its own surrounding lines:\n%s",
104+ i+1, len(spans), occurrenceLines(body, spans), quote(e.Old))
105+}
95 106
96- start := strings.Index(body, e.Old)107+// notFound explains a miss, and shows the file where it can. "Not found" on
97- return span{start: start, end: start + len(e.Old), index: i}, nil108+// its own tells a model nothing it can act on — it retypes the same text; the
109+// lines it nearly matched, with their numbers, are what it needs to fix the
110+// call. See nearest.
111+func notFound(body string, e Edit, i int) error {
112+ msg := fmt.Sprintf(
113+ "edit #%d: old text not found — it must match the file exactly, including indentation and line breaks:\n%s",
114+ i+1, quote(e.Old))
115+ if near := nearest(body, e.Old); len(near) > 0 {
116+ msg += "\nthe closest the file comes to it — copy these lines:\n" + quoteFile(body, near)
117+ }
118+ return errors.New(msg)
98 }119 }
99 120
100-// occurrenceLines lists the 1-based lines where text occurs in body, as121+// occurrenceLines lists the 1-based lines of the matches found, as "11, 20":
101-// "11, 20": the model has to pick one, and a line number is what lets it.122+// the model has to pick one, and a line number is what lets it.
102-func occurrenceLines(body, text string) string {123+func occurrenceLines(body string, spans []match) string {
103 var lines []string124 var lines []string
104- for at, from := 0, 0; ; {125+ for _, m := range spans {
105- i := strings.Index(body[from:], text)126+ lines = append(lines, strconv.Itoa(lineOf(body, m.start)))
106- if i < 0 {
107- break
108- }
109- at = from + i
110- lines = append(lines, strconv.Itoa(lineOf(body, at)))
111- from = at + len(text)
112 }127 }
113 return strings.Join(lines, ", ")128 return strings.Join(lines, ", ")
114 }129 }
@@ -137,3 +152,23 @@ func quote(s string) string {
137 }152 }
138 return strings.TrimRight(b.String(), "\n")153 return strings.TrimRight(b.String(), "\n")
139 }154 }
155+
156+// quoteFile shows a region of the file with its real line numbers, in the same
157+// frame as quote: the model compares the two blocks side by side and sees what
158+// it got wrong — almost always the indentation.
159+func quoteFile(body string, lines []lineSpan) string {
160+ const maxLines = 8
161+
162+ shown := lines
163+ if len(shown) > maxLines {
164+ shown = shown[:maxLines]
165+ }
166+ var b strings.Builder
167+ for i, s := range shown {
168+ fmt.Fprintf(&b, " │ %d │ %s\n", lineOf(body, lines[0].start)+i, body[s.start:s.end])
169+ }
170+ if len(lines) > len(shown) {
171+ fmt.Fprintf(&b, " └ … %d more line(s)\n", len(lines)-len(shown))
172+ }
173+ return strings.TrimRight(b.String(), "\n")
174+}
modified internal/fileedit/fileedit.go +14 -7
@@ -10,11 +10,15 @@ import (
1010 // patch, the first changed line. Headline + Diff is what goes back to the
1111 // model; the caller decides what the screen shows.
1212 type Result struct {
13- Path string
14- Created bool
15- Changed bool
16- DryRun bool
17- Edits int
13+ Path string
14+ Created bool
15+ Changed bool
16+ DryRun bool
17+ Edits int
18+ // Loose counts the edits whose old text only matched once the whitespace
19+ // was relaxed. Reported, not hidden: a model told so writes the next one
20+ // right, and a user reading the headline knows the tool met it halfway.
21+ Loose int
1822 Added, Deleted int
1923 FirstChangedLine int
2024 Headline string
@@ -112,13 +116,13 @@ func Replace(path string, edits []Edit, dryRun bool) (Result, error) {
112116 if f.isNew {
113117 return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)
114118 }
115- body, err := apply(f.body, edits)
119+ body, loose, err := apply(f.body, edits)
116120 if err != nil {
117121 // The error carries the file name: it is read out of context, in a
118122 // tool result among others.
119123 return Result{}, fmt.Errorf("%s: %v", path, err)
120124 }
121- return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body)
125+ return finish(Result{Path: path, Edits: len(edits), Loose: loose, DryRun: dryRun}, f, body)
122126 }
123127
124128 // finish compares, writes if needed, and reports. Write and Replace both end
@@ -159,6 +163,9 @@ func headline(r Result) string {
159163 if r.Edits > 0 {
160164 what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)
161165 }
166+ if r.Loose > 0 {
167+ what += fmt.Sprintf(" · %d matched ignoring whitespace", r.Loose)
168+ }
162169 if r.DryRun {
163170 return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)
164171 }
@@ -10,11 +10,15 @@ import (
10 // patch, the first changed line. Headline + Diff is what goes back to the10 // patch, the first changed line. Headline + Diff is what goes back to the
11 // model; the caller decides what the screen shows.11 // model; the caller decides what the screen shows.
12 type Result struct {12 type Result struct {
13- Path string13+ Path string
14- Created bool14+ Created bool
15- Changed bool15+ Changed bool
16- DryRun bool16+ DryRun bool
17- Edits int17+ Edits int
18+ // Loose counts the edits whose old text only matched once the whitespace
19+ // was relaxed. Reported, not hidden: a model told so writes the next one
20+ // right, and a user reading the headline knows the tool met it halfway.
21+ Loose int
18 Added, Deleted int22 Added, Deleted int
19 FirstChangedLine int23 FirstChangedLine int
20 Headline string24 Headline string
@@ -112,13 +116,13 @@ func Replace(path string, edits []Edit, dryRun bool) (Result, error) {
112 if f.isNew {116 if f.isNew {
113 return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)117 return Result{}, fmt.Errorf("%s does not exist — nothing to replace. Create it with write_file", path)
114 }118 }
115- body, err := apply(f.body, edits)119+ body, loose, err := apply(f.body, edits)
116 if err != nil {120 if err != nil {
117 // The error carries the file name: it is read out of context, in a121 // The error carries the file name: it is read out of context, in a
118 // tool result among others.122 // tool result among others.
119 return Result{}, fmt.Errorf("%s: %v", path, err)123 return Result{}, fmt.Errorf("%s: %v", path, err)
120 }124 }
121- return finish(Result{Path: path, Edits: len(edits), DryRun: dryRun}, f, body)125+ return finish(Result{Path: path, Edits: len(edits), Loose: loose, DryRun: dryRun}, f, body)
122 }126 }
123 127
124 // finish compares, writes if needed, and reports. Write and Replace both end128 // finish compares, writes if needed, and reports. Write and Replace both end
@@ -159,6 +163,9 @@ func headline(r Result) string {
159 if r.Edits > 0 {163 if r.Edits > 0 {
160 what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)164 what = fmt.Sprintf("%d edit(s) applied, %s", r.Edits, what)
161 }165 }
166+ if r.Loose > 0 {
167+ what += fmt.Sprintf(" · %d matched ignoring whitespace", r.Loose)
168+ }
162 if r.DryRun {169 if r.DryRun {
163 return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)170 return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what)
164 }171 }
modified internal/fileedit/fileedit_test.go +2 -2
@@ -53,7 +53,7 @@ func TestApply(t *testing.T) {
5353 }
5454 for _, tc := range cases {
5555 t.Run(tc.name, func(t *testing.T) {
56- got, err := apply(tc.body, tc.edits)
56+ got, _, err := apply(tc.body, tc.edits)
5757 if err != nil {
5858 t.Fatalf("apply: %v", err)
5959 }
@@ -85,7 +85,7 @@ func TestApplyRefuses(t *testing.T) {
8585 }
8686 for _, tc := range cases {
8787 t.Run(tc.name, func(t *testing.T) {
88- got, err := apply(tc.body, tc.edits)
88+ got, _, err := apply(tc.body, tc.edits)
8989 if err == nil {
9090 t.Fatalf("apply succeeded, want an error — result %q", got)
9191 }
@@ -53,7 +53,7 @@ func TestApply(t *testing.T) {
53 }53 }
54 for _, tc := range cases {54 for _, tc := range cases {
55 t.Run(tc.name, func(t *testing.T) {55 t.Run(tc.name, func(t *testing.T) {
56- got, err := apply(tc.body, tc.edits)56+ got, _, err := apply(tc.body, tc.edits)
57 if err != nil {57 if err != nil {
58 t.Fatalf("apply: %v", err)58 t.Fatalf("apply: %v", err)
59 }59 }
@@ -85,7 +85,7 @@ func TestApplyRefuses(t *testing.T) {
85 }85 }
86 for _, tc := range cases {86 for _, tc := range cases {
87 t.Run(tc.name, func(t *testing.T) {87 t.Run(tc.name, func(t *testing.T) {
88- got, err := apply(tc.body, tc.edits)88+ got, _, err := apply(tc.body, tc.edits)
89 if err == nil {89 if err == nil {
90 t.Fatalf("apply succeeded, want an error — result %q", got)90 t.Fatalf("apply succeeded, want an error — result %q", got)
91 }91 }
added internal/fileedit/indent.go +128 -0
new file mode 100644
@@ -0,0 +1,128 @@
1+// The whitespace half of a loose match: how a text is cut into the lines it
2+// means, and how the indentation of one is measured and rewritten as another's.
3+// Split out of match.go, which keeps the passes themselves.
4+
5+package fileedit
6+
7+import "strings"
8+
9+// lineSpan is one line of the body, without its newline.
10+type lineSpan struct{ start, end int }
11+
12+// lineSpans indexes the body's lines. A trailing newline does not open a last,
13+// empty line: there is nothing there to match.
14+func lineSpans(body string) []lineSpan {
15+ var out []lineSpan
16+ for at := 0; at <= len(body); {
17+ i := strings.IndexByte(body[at:], '\n')
18+ if i < 0 {
19+ if at < len(body) {
20+ out = append(out, lineSpan{at, len(body)})
21+ }
22+ return out
23+ }
24+ out = append(out, lineSpan{at, at + i})
25+ at += i + 1
26+ }
27+ return out
28+}
29+
30+// contentLines is a text as the lines it means: trimmed of whitespace, with
31+// the blank lines at either end dropped — a model's `old` very often carries
32+// one, and a file's block does not start or end with one.
33+func contentLines(text string) []string {
34+ lines := strings.Split(text, "\n")
35+ for i := range lines {
36+ lines[i] = strings.TrimSpace(lines[i])
37+ }
38+ for len(lines) > 0 && lines[0] == "" {
39+ lines = lines[1:]
40+ }
41+ for len(lines) > 0 && lines[len(lines)-1] == "" {
42+ lines = lines[:len(lines)-1]
43+ }
44+ return lines
45+}
46+
47+// sameContent reports whether a run of body lines says what want says.
48+func sameContent(body string, got []lineSpan, want []string) bool {
49+ for i, s := range got {
50+ if strings.TrimSpace(body[s.start:s.end]) != want[i] {
51+ return false
52+ }
53+ }
54+ return true
55+}
56+
57+// reindent rewrites `new` in the FILE's indentation rather than the model's.
58+// Two things are swapped: the base indent — the matched block's own, in place
59+// of the one the model wrote — and the unit, so four spaces become a tab in a
60+// file that indents with tabs. A blank line stays empty rather than becoming a
61+// line of trailing spaces.
62+//
63+// Everything here is best effort by construction: it only runs when the match
64+// was already loose, and the alternative is the file keeping the indentation
65+// the model invented.
66+func reindent(newText, oldText, fileText string) string {
67+ from, to := indentUnit(oldText), indentUnit(fileText)
68+ oldBase, fileBase := baseIndent(oldText), baseIndent(fileText)
69+ if from == to && oldBase == fileBase {
70+ return newText
71+ }
72+ lines := strings.Split(newText, "\n")
73+ for i, l := range lines {
74+ lead, content := splitIndent(l)
75+ if content == "" {
76+ lines[i] = ""
77+ continue
78+ }
79+ lead = strings.TrimPrefix(lead, oldBase)
80+ if from != "" && to != "" && from != to {
81+ depth := 0
82+ for strings.HasPrefix(lead, from) {
83+ lead, depth = lead[len(from):], depth+1
84+ }
85+ lead = strings.Repeat(to, depth) + lead
86+ }
87+ lines[i] = fileBase + lead + content
88+ }
89+ return strings.Join(lines, "\n")
90+}
91+
92+// indentUnit is the one indentation step a text uses: "\t", or the run of
93+// spaces of its shallowest indented line. "" when nothing in it is indented,
94+// which is the signal to leave the depths alone.
95+func indentUnit(text string) string {
96+ unit := ""
97+ for _, l := range strings.Split(text, "\n") {
98+ lead, content := splitIndent(l)
99+ if content == "" || lead == "" {
100+ continue
101+ }
102+ if unit == "" || len(lead) < len(unit) {
103+ unit = lead
104+ }
105+ }
106+ if strings.HasPrefix(unit, "\t") {
107+ return "\t" // a tab is its own unit, whatever a terminal renders it as
108+ }
109+ return unit
110+}
111+
112+// baseIndent is the indentation of a text's first line that says something —
113+// the depth the whole block sits at.
114+func baseIndent(text string) string {
115+ for _, l := range strings.Split(text, "\n") {
116+ if lead, content := splitIndent(l); content != "" {
117+ return lead
118+ }
119+ }
120+ return ""
121+}
122+
123+// splitIndent cuts a line into its leading whitespace and the rest, with the
124+// trailing whitespace dropped: it is never what an edit is about.
125+func splitIndent(line string) (lead, content string) {
126+ content = strings.TrimLeft(line, " \t")
127+ return line[:len(line)-len(content)], strings.TrimRight(content, " \t\r")
128+}
new file mode 100644
@@ -0,0 +1,128 @@
1+// The whitespace half of a loose match: how a text is cut into the lines it
2+// means, and how the indentation of one is measured and rewritten as another's.
3+// Split out of match.go, which keeps the passes themselves.
4+
5+package fileedit
6+
7+import "strings"
8+
9+// lineSpan is one line of the body, without its newline.
10+type lineSpan struct{ start, end int }
11+
12+// lineSpans indexes the body's lines. A trailing newline does not open a last,
13+// empty line: there is nothing there to match.
14+func lineSpans(body string) []lineSpan {
15+ var out []lineSpan
16+ for at := 0; at <= len(body); {
17+ i := strings.IndexByte(body[at:], '\n')
18+ if i < 0 {
19+ if at < len(body) {
20+ out = append(out, lineSpan{at, len(body)})
21+ }
22+ return out
23+ }
24+ out = append(out, lineSpan{at, at + i})
25+ at += i + 1
26+ }
27+ return out
28+}
29+
30+// contentLines is a text as the lines it means: trimmed of whitespace, with
31+// the blank lines at either end dropped — a model's `old` very often carries
32+// one, and a file's block does not start or end with one.
33+func contentLines(text string) []string {
34+ lines := strings.Split(text, "\n")
35+ for i := range lines {
36+ lines[i] = strings.TrimSpace(lines[i])
37+ }
38+ for len(lines) > 0 && lines[0] == "" {
39+ lines = lines[1:]
40+ }
41+ for len(lines) > 0 && lines[len(lines)-1] == "" {
42+ lines = lines[:len(lines)-1]
43+ }
44+ return lines
45+}
46+
47+// sameContent reports whether a run of body lines says what want says.
48+func sameContent(body string, got []lineSpan, want []string) bool {
49+ for i, s := range got {
50+ if strings.TrimSpace(body[s.start:s.end]) != want[i] {
51+ return false
52+ }
53+ }
54+ return true
55+}
56+
57+// reindent rewrites `new` in the FILE's indentation rather than the model's.
58+// Two things are swapped: the base indent — the matched block's own, in place
59+// of the one the model wrote — and the unit, so four spaces become a tab in a
60+// file that indents with tabs. A blank line stays empty rather than becoming a
61+// line of trailing spaces.
62+//
63+// Everything here is best effort by construction: it only runs when the match
64+// was already loose, and the alternative is the file keeping the indentation
65+// the model invented.
66+func reindent(newText, oldText, fileText string) string {
67+ from, to := indentUnit(oldText), indentUnit(fileText)
68+ oldBase, fileBase := baseIndent(oldText), baseIndent(fileText)
69+ if from == to && oldBase == fileBase {
70+ return newText
71+ }
72+ lines := strings.Split(newText, "\n")
73+ for i, l := range lines {
74+ lead, content := splitIndent(l)
75+ if content == "" {
76+ lines[i] = ""
77+ continue
78+ }
79+ lead = strings.TrimPrefix(lead, oldBase)
80+ if from != "" && to != "" && from != to {
81+ depth := 0
82+ for strings.HasPrefix(lead, from) {
83+ lead, depth = lead[len(from):], depth+1
84+ }
85+ lead = strings.Repeat(to, depth) + lead
86+ }
87+ lines[i] = fileBase + lead + content
88+ }
89+ return strings.Join(lines, "\n")
90+}
91+
92+// indentUnit is the one indentation step a text uses: "\t", or the run of
93+// spaces of its shallowest indented line. "" when nothing in it is indented,
94+// which is the signal to leave the depths alone.
95+func indentUnit(text string) string {
96+ unit := ""
97+ for _, l := range strings.Split(text, "\n") {
98+ lead, content := splitIndent(l)
99+ if content == "" || lead == "" {
100+ continue
101+ }
102+ if unit == "" || len(lead) < len(unit) {
103+ unit = lead
104+ }
105+ }
106+ if strings.HasPrefix(unit, "\t") {
107+ return "\t" // a tab is its own unit, whatever a terminal renders it as
108+ }
109+ return unit
110+}
111+
112+// baseIndent is the indentation of a text's first line that says something —
113+// the depth the whole block sits at.
114+func baseIndent(text string) string {
115+ for _, l := range strings.Split(text, "\n") {
116+ if lead, content := splitIndent(l); content != "" {
117+ return lead
118+ }
119+ }
120+ return ""
121+}
122+
123+// splitIndent cuts a line into its leading whitespace and the rest, with the
124+// trailing whitespace dropped: it is never what an edit is about.
125+func splitIndent(line string) (lead, content string) {
126+ content = strings.TrimLeft(line, " \t")
127+ return line[:len(line)-len(content)], strings.TrimRight(content, " \t\r")
128+}
added internal/fileedit/match.go +152 -0
new file mode 100644
@@ -0,0 +1,152 @@
1+package fileedit
2+
3+import "strings"
4+
5+// Finding an `old` text is done in three passes, strictest first, and the
6+// FIRST pass that finds anything at all wins. A file that contains the text
7+// exactly is therefore never matched any other way: what follows only ever
8+// comes into play where the strict rule has already failed.
9+//
10+// Why relax at all. "old must match the file exactly, including indentation"
11+// is the right rule, and it is what makes an edit verifiable. But a model
12+// retypes the block it is editing, and the one thing it retypes wrong is
13+// whitespace. Ticket 27, a 12B model against a Go file — which indents with a
14+// TAB — sent:
15+//
16+// func main() {
17+// fmt.Println("Hello, World!")
18+// }
19+//
20+// Nothing about the intent is ambiguous there; only the bytes differ. Refusing
21+// it is the tool being right about the wrong thing, and it costs a turn — or,
22+// as the demo audit of 2026-09-18 showed, thirty of them.
23+//
24+// What is NOT relaxed is what makes an edit safe. A loose match must still be
25+// UNIQUE: two of them are refused exactly as two exact ones are, with the same
26+// message and the same line numbers. And a loose match re-indents `new` before
27+// writing it, so a tab-indented file does not end up with a block of spaces in
28+// the middle of it.
29+
30+// ws is the whitespace an edit may differ by. Not \v or \f: those are content.
31+const ws = " \t\r\n"
32+
33+// A match is a region of the body to replace and the text to put there. The
34+// text is not always the model's `new` — see reindent.
35+type match struct {
36+ start, end int
37+ text string
38+}
39+
40+// findAll returns every place e.Old matches, and whether the whitespace had to
41+// be relaxed to find them. The caller reports that: a model told its text did
42+// not match exactly writes the next one better.
43+func findAll(body string, e Edit) (spans []match, loose bool) {
44+ if s := exactly(body, e.Old, e.New); len(s) > 0 {
45+ return s, false
46+ }
47+ if s := ignoringEdges(body, e); len(s) > 0 {
48+ return s, true
49+ }
50+ return ignoringIndent(body, e), true
51+}
52+
53+// exactly is the strict rule: every occurrence of old, replaced by new.
54+func exactly(body, old, new string) []match {
55+ var out []match
56+ for from := 0; from <= len(body); {
57+ i := strings.Index(body[from:], old)
58+ if i < 0 {
59+ return out
60+ }
61+ at := from + i
62+ out = append(out, match{start: at, end: at + len(old), text: new})
63+ from = at + len(old)
64+ }
65+ return out
66+}
67+
68+// ignoringEdges drops the whitespace AROUND old — a leading newline, the
69+// indentation of a single line, a trailing blank line — and searches for the
70+// rest exactly.
71+//
72+// The same padding comes off new when new actually carries it, which is what
73+// keeps this honest: the file's own indentation now sits outside the matched
74+// region, so it must not be written again, and a new that deliberately adds a
75+// blank line still adds it.
76+func ignoringEdges(body string, e Edit) []match {
77+ i := len(e.Old) - len(strings.TrimLeft(e.Old, ws))
78+ j := len(strings.TrimRight(e.Old, ws))
79+ if i >= j {
80+ return nil // all whitespace: locate() has already refused it
81+ }
82+ core, lead, tail := e.Old[i:j], e.Old[:i], e.Old[j:]
83+ if core == e.Old {
84+ return nil // nothing to trim — this would just be the strict pass again
85+ }
86+ text := strings.TrimSuffix(strings.TrimPrefix(e.New, lead), tail)
87+ return exactly(body, core, text)
88+}
89+
90+// ignoringIndent matches a block of whole lines on their CONTENT alone, each
91+// line compared with its leading and trailing whitespace removed. This is the
92+// pass ticket 27 needed: spaces where the file has tabs.
93+//
94+// Only for a text spanning several lines. One line compared on its content
95+// alone would match that line at any depth anywhere in the file, and the pass
96+// above already covers the one-line case — it is the same text with its indent
97+// trimmed off, which is both safer and more precise.
98+func ignoringIndent(body string, e Edit) []match {
99+ want := contentLines(e.Old)
100+ if len(want) < 2 {
101+ return nil
102+ }
103+ lines := lineSpans(body)
104+ var out []match
105+ for i := 0; i+len(want) <= len(lines); i++ {
106+ if !sameContent(body, lines[i:i+len(want)], want) {
107+ continue
108+ }
109+ start, end := lines[i].start, lines[i+len(want)-1].end
110+ // A deletion takes the newline with it, as an exact `old` ending in \n
111+ // would: otherwise the loose path leaves a blank line where the strict
112+ // one leaves nothing, and the two would not agree.
113+ if e.New == "" && end < len(body) && body[end] == '\n' {
114+ end++
115+ }
116+ out = append(out, match{start: start, end: end, text: reindent(e.New, e.Old, body[start:end])})
117+ }
118+ return out
119+}
120+
121+// nearest is the region of the file that comes closest to an `old` that was
122+// not found, or "" when nothing comes close enough to be worth showing.
123+//
124+// It exists because "not found" alone tells a model nothing it can act on: it
125+// retypes the same text. Shown the four lines it nearly matched — with their
126+// line numbers — it fixes them. Half the lines have to line up before the
127+// excerpt is offered; below that it is noise pointing at an innocent part of
128+// the file.
129+func nearest(body, old string) []lineSpan {
130+ want := contentLines(old)
131+ lines := lineSpans(body)
132+ if len(want) < 2 || len(lines) == 0 {
133+ return nil
134+ }
135+ n := min(len(want), len(lines))
136+ at, best := -1, 0
137+ for i := 0; i+n <= len(lines); i++ {
138+ score := 0
139+ for j := range n {
140+ if strings.TrimSpace(body[lines[i+j].start:lines[i+j].end]) == want[j] {
141+ score++
142+ }
143+ }
144+ if score > best {
145+ at, best = i, score
146+ }
147+ }
148+ if at < 0 || best*2 < n {
149+ return nil
150+ }
151+ return lines[at : at+n]
152+}
new file mode 100644
@@ -0,0 +1,152 @@
1+package fileedit
2+
3+import "strings"
4+
5+// Finding an `old` text is done in three passes, strictest first, and the
6+// FIRST pass that finds anything at all wins. A file that contains the text
7+// exactly is therefore never matched any other way: what follows only ever
8+// comes into play where the strict rule has already failed.
9+//
10+// Why relax at all. "old must match the file exactly, including indentation"
11+// is the right rule, and it is what makes an edit verifiable. But a model
12+// retypes the block it is editing, and the one thing it retypes wrong is
13+// whitespace. Ticket 27, a 12B model against a Go file — which indents with a
14+// TAB — sent:
15+//
16+// func main() {
17+// fmt.Println("Hello, World!")
18+// }
19+//
20+// Nothing about the intent is ambiguous there; only the bytes differ. Refusing
21+// it is the tool being right about the wrong thing, and it costs a turn — or,
22+// as the demo audit of 2026-09-18 showed, thirty of them.
23+//
24+// What is NOT relaxed is what makes an edit safe. A loose match must still be
25+// UNIQUE: two of them are refused exactly as two exact ones are, with the same
26+// message and the same line numbers. And a loose match re-indents `new` before
27+// writing it, so a tab-indented file does not end up with a block of spaces in
28+// the middle of it.
29+
30+// ws is the whitespace an edit may differ by. Not \v or \f: those are content.
31+const ws = " \t\r\n"
32+
33+// A match is a region of the body to replace and the text to put there. The
34+// text is not always the model's `new` — see reindent.
35+type match struct {
36+ start, end int
37+ text string
38+}
39+
40+// findAll returns every place e.Old matches, and whether the whitespace had to
41+// be relaxed to find them. The caller reports that: a model told its text did
42+// not match exactly writes the next one better.
43+func findAll(body string, e Edit) (spans []match, loose bool) {
44+ if s := exactly(body, e.Old, e.New); len(s) > 0 {
45+ return s, false
46+ }
47+ if s := ignoringEdges(body, e); len(s) > 0 {
48+ return s, true
49+ }
50+ return ignoringIndent(body, e), true
51+}
52+
53+// exactly is the strict rule: every occurrence of old, replaced by new.
54+func exactly(body, old, new string) []match {
55+ var out []match
56+ for from := 0; from <= len(body); {
57+ i := strings.Index(body[from:], old)
58+ if i < 0 {
59+ return out
60+ }
61+ at := from + i
62+ out = append(out, match{start: at, end: at + len(old), text: new})
63+ from = at + len(old)
64+ }
65+ return out
66+}
67+
68+// ignoringEdges drops the whitespace AROUND old — a leading newline, the
69+// indentation of a single line, a trailing blank line — and searches for the
70+// rest exactly.
71+//
72+// The same padding comes off new when new actually carries it, which is what
73+// keeps this honest: the file's own indentation now sits outside the matched
74+// region, so it must not be written again, and a new that deliberately adds a
75+// blank line still adds it.
76+func ignoringEdges(body string, e Edit) []match {
77+ i := len(e.Old) - len(strings.TrimLeft(e.Old, ws))
78+ j := len(strings.TrimRight(e.Old, ws))
79+ if i >= j {
80+ return nil // all whitespace: locate() has already refused it
81+ }
82+ core, lead, tail := e.Old[i:j], e.Old[:i], e.Old[j:]
83+ if core == e.Old {
84+ return nil // nothing to trim — this would just be the strict pass again
85+ }
86+ text := strings.TrimSuffix(strings.TrimPrefix(e.New, lead), tail)
87+ return exactly(body, core, text)
88+}
89+
90+// ignoringIndent matches a block of whole lines on their CONTENT alone, each
91+// line compared with its leading and trailing whitespace removed. This is the
92+// pass ticket 27 needed: spaces where the file has tabs.
93+//
94+// Only for a text spanning several lines. One line compared on its content
95+// alone would match that line at any depth anywhere in the file, and the pass
96+// above already covers the one-line case — it is the same text with its indent
97+// trimmed off, which is both safer and more precise.
98+func ignoringIndent(body string, e Edit) []match {
99+ want := contentLines(e.Old)
100+ if len(want) < 2 {
101+ return nil
102+ }
103+ lines := lineSpans(body)
104+ var out []match
105+ for i := 0; i+len(want) <= len(lines); i++ {
106+ if !sameContent(body, lines[i:i+len(want)], want) {
107+ continue
108+ }
109+ start, end := lines[i].start, lines[i+len(want)-1].end
110+ // A deletion takes the newline with it, as an exact `old` ending in \n
111+ // would: otherwise the loose path leaves a blank line where the strict
112+ // one leaves nothing, and the two would not agree.
113+ if e.New == "" && end < len(body) && body[end] == '\n' {
114+ end++
115+ }
116+ out = append(out, match{start: start, end: end, text: reindent(e.New, e.Old, body[start:end])})
117+ }
118+ return out
119+}
120+
121+// nearest is the region of the file that comes closest to an `old` that was
122+// not found, or "" when nothing comes close enough to be worth showing.
123+//
124+// It exists because "not found" alone tells a model nothing it can act on: it
125+// retypes the same text. Shown the four lines it nearly matched — with their
126+// line numbers — it fixes them. Half the lines have to line up before the
127+// excerpt is offered; below that it is noise pointing at an innocent part of
128+// the file.
129+func nearest(body, old string) []lineSpan {
130+ want := contentLines(old)
131+ lines := lineSpans(body)
132+ if len(want) < 2 || len(lines) == 0 {
133+ return nil
134+ }
135+ n := min(len(want), len(lines))
136+ at, best := -1, 0
137+ for i := 0; i+n <= len(lines); i++ {
138+ score := 0
139+ for j := range n {
140+ if strings.TrimSpace(body[lines[i+j].start:lines[i+j].end]) == want[j] {
141+ score++
142+ }
143+ }
144+ if score > best {
145+ at, best = i, score
146+ }
147+ }
148+ if at < 0 || best*2 < n {
149+ return nil
150+ }
151+ return lines[at : at+n]
152+}
added internal/fileedit/match_test.go +173 -0
new file mode 100644
@@ -0,0 +1,173 @@
1+package fileedit
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// The report of ticket 27, to the byte: a Go file indents with a TAB, the
9+// model sent four spaces. Nothing about the intent is ambiguous.
10+func TestTicket27TabsVersusSpaces(t *testing.T) {
11+ body := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n}\n"
12+ edits := []Edit{{
13+ Old: "func main() {\n fmt.Println(\"Hello, World!\")\n}",
14+ New: "func main() {\n fmt.Println(\"Hello, Bob!\")\n}",
15+ }}
16+
17+ got, loose, err := apply(body, edits)
18+ if err != nil {
19+ t.Fatalf("apply refused the edit ticket 27 is about: %v", err)
20+ }
21+ if loose != 1 {
22+ t.Errorf("loose = %d, want 1: the edit did not match exactly and the caller must be told", loose)
23+ }
24+ want := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, Bob!\")\n}\n"
25+ if got != want {
26+ t.Errorf("the file did not keep its tabs:\n got %q\nwant %q", got, want)
27+ }
28+}
29+
30+// An exact match must never be beaten by a loose one: the strict pass runs
31+// first and, when it finds something, the others never run.
32+func TestExactWinsOverLoose(t *testing.T) {
33+ // " b()" exists verbatim on line 3; line 2 has the same content one level
34+ // deeper. A loose pass would call that ambiguous; the strict one must not.
35+ body := "a()\n\t\tb()\n b()\n"
36+ got, loose, err := apply(body, []Edit{{Old: " b()", New: " c()"}})
37+ if err != nil {
38+ t.Fatalf("apply: %v", err)
39+ }
40+ if loose != 0 {
41+ t.Errorf("loose = %d: the text was in the file verbatim", loose)
42+ }
43+ if got != "a()\n\t\tb()\n c()\n" {
44+ t.Errorf("the exact occurrence was not the one changed: %q", got)
45+ }
46+}
47+
48+// Relaxing whitespace does NOT relax uniqueness — the rule that makes an edit
49+// verifiable. Two loose matches are refused like two exact ones, with the
50+// lines, so the model can pick one.
51+func TestLooseMatchesMustStillBeUnique(t *testing.T) {
52+ body := "func a() {\n\tdo()\n}\n\nfunc b() {\n do()\n}\n"
53+ _, _, err := apply(body, []Edit{{Old: " do()\n", New: " done()\n"}})
54+ if err == nil {
55+ t.Fatal("two whitespace-only matches were applied: an edit must be unique")
56+ }
57+ if !strings.Contains(err.Error(), "appears 2 times (lines 2, 6)") {
58+ t.Errorf("the refusal does not say where the two are: %v", err)
59+ }
60+}
61+
62+func TestEdgeWhitespaceIsIgnored(t *testing.T) {
63+ cases := []struct {
64+ name string
65+ body, want string
66+ edit Edit
67+ }{
68+ {"an indent the model kept on a single line",
69+ "func f() {\n\treturn 1\n}\n", "func f() {\n\treturn 2\n}\n",
70+ Edit{Old: " return 1", New: " return 2"}},
71+ {"a trailing newline the file does not have there",
72+ "a = 1\nb = 2\n", "a = 9\nb = 2\n",
73+ Edit{Old: "a = 1\n\n", New: "a = 9\n\n"}},
74+ {"a new that deliberately adds a blank line",
75+ "a = 1\nb = 2\n", "a = 9\n\nb = 2\n",
76+ Edit{Old: " a = 1\n", New: " a = 9\n\n"}},
77+ }
78+ for _, tc := range cases {
79+ t.Run(tc.name, func(t *testing.T) {
80+ got, loose, err := apply(tc.body, []Edit{tc.edit})
81+ if err != nil {
82+ t.Fatalf("apply: %v", err)
83+ }
84+ if loose != 1 {
85+ t.Errorf("loose = %d, want 1", loose)
86+ }
87+ if got != tc.want {
88+ t.Errorf("apply = %q, want %q", got, tc.want)
89+ }
90+ })
91+ }
92+}
93+
94+// A loose deletion takes the newline with it, as an exact one ending in \n
95+// does: the two paths must not disagree about what "delete these lines" means.
96+func TestLooseDeletionLeavesNoBlankLine(t *testing.T) {
97+ body := "keep\nfunc gone() {\n\tx()\n}\nkeep too\n"
98+ got, _, err := apply(body, []Edit{{Old: "func gone() {\n x()\n}\n", New: ""}})
99+ if err != nil {
100+ t.Fatalf("apply: %v", err)
101+ }
102+ if got != "keep\nkeep too\n" {
103+ t.Errorf("apply = %q, want %q", got, "keep\nkeep too\n")
104+ }
105+}
106+
107+// The model's four spaces must not be written into a tab-indented file, at
108+// any depth, and a blank line must not become a line of spaces.
109+func TestReindent(t *testing.T) {
110+ body := "func f() {\n\tif x {\n\t\ty()\n\n\t\tz()\n\t}\n}\n"
111+ edits := []Edit{{
112+ Old: " if x {\n y()\n\n z()\n }",
113+ New: " if x {\n y2()\n\n z2()\n }",
114+ }}
115+ got, _, err := apply(body, edits)
116+ if err != nil {
117+ t.Fatalf("apply: %v", err)
118+ }
119+ want := "func f() {\n\tif x {\n\t\ty2()\n\n\t\tz2()\n\t}\n}\n"
120+ if got != want {
121+ t.Errorf("reindent:\n got %q\nwant %q", got, want)
122+ }
123+ if strings.Contains(got, " ") {
124+ t.Errorf("four spaces were written into a tab-indented file: %q", got)
125+ }
126+}
127+
128+// A miss shows the file, with line numbers. "Not found" alone makes a model
129+// retype the same text; the lines it nearly matched make it fix them.
130+func TestNotFoundShowsTheClosestLines(t *testing.T) {
131+ body := "package main\n\nfunc main() {\n\tcount := 1\n\tprint(count)\n}\n"
132+ _, _, err := apply(body, []Edit{{
133+ Old: "func main() {\n\tcount := 2\n\tprint(count)\n}",
134+ New: "func main() {}",
135+ }})
136+ if err == nil {
137+ t.Fatal("apply accepted an edit whose text is not in the file")
138+ }
139+ msg := err.Error()
140+ for _, want := range []string{"not found", "the closest the file comes to it", "│ 3 │ func main() {", "count := 1"} {
141+ if !strings.Contains(msg, want) {
142+ t.Errorf("the refusal does not contain %q:\n%s", want, msg)
143+ }
144+ }
145+}
146+
147+// Nothing close enough: the excerpt is not offered, because pointing at an
148+// innocent part of the file is worse than saying nothing.
149+func TestNotFoundStaysQuietWithoutANearMiss(t *testing.T) {
150+ body := "alpha\nbeta\ngamma\n"
151+ _, _, err := apply(body, []Edit{{Old: "one\ntwo\nthree\n", New: "x\n"}})
152+ if err == nil {
153+ t.Fatal("apply accepted an edit whose text is not in the file")
154+ }
155+ if strings.Contains(err.Error(), "the closest the file comes to it") {
156+ t.Errorf("an unrelated region was offered as a near miss:\n%s", err)
157+ }
158+}
159+
160+func TestIndentUnit(t *testing.T) {
161+ cases := []struct{ text, want string }{
162+ {"func f() {\n\treturn 1\n}", "\t"},
163+ {"func f() {\n return 1\n}", " "},
164+ {"func f() {\n return 1\n}", " "},
165+ {"a\nb\n", ""},
166+ {"a\n\t\t\tdeep\n\tone\n", "\t"},
167+ }
168+ for _, tc := range cases {
169+ if got := indentUnit(tc.text); got != tc.want {
170+ t.Errorf("indentUnit(%q) = %q, want %q", tc.text, got, tc.want)
171+ }
172+ }
173+}
new file mode 100644
@@ -0,0 +1,173 @@
1+package fileedit
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// The report of ticket 27, to the byte: a Go file indents with a TAB, the
9+// model sent four spaces. Nothing about the intent is ambiguous.
10+func TestTicket27TabsVersusSpaces(t *testing.T) {
11+ body := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, World!\")\n}\n"
12+ edits := []Edit{{
13+ Old: "func main() {\n fmt.Println(\"Hello, World!\")\n}",
14+ New: "func main() {\n fmt.Println(\"Hello, Bob!\")\n}",
15+ }}
16+
17+ got, loose, err := apply(body, edits)
18+ if err != nil {
19+ t.Fatalf("apply refused the edit ticket 27 is about: %v", err)
20+ }
21+ if loose != 1 {
22+ t.Errorf("loose = %d, want 1: the edit did not match exactly and the caller must be told", loose)
23+ }
24+ want := "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello, Bob!\")\n}\n"
25+ if got != want {
26+ t.Errorf("the file did not keep its tabs:\n got %q\nwant %q", got, want)
27+ }
28+}
29+
30+// An exact match must never be beaten by a loose one: the strict pass runs
31+// first and, when it finds something, the others never run.
32+func TestExactWinsOverLoose(t *testing.T) {
33+ // " b()" exists verbatim on line 3; line 2 has the same content one level
34+ // deeper. A loose pass would call that ambiguous; the strict one must not.
35+ body := "a()\n\t\tb()\n b()\n"
36+ got, loose, err := apply(body, []Edit{{Old: " b()", New: " c()"}})
37+ if err != nil {
38+ t.Fatalf("apply: %v", err)
39+ }
40+ if loose != 0 {
41+ t.Errorf("loose = %d: the text was in the file verbatim", loose)
42+ }
43+ if got != "a()\n\t\tb()\n c()\n" {
44+ t.Errorf("the exact occurrence was not the one changed: %q", got)
45+ }
46+}
47+
48+// Relaxing whitespace does NOT relax uniqueness — the rule that makes an edit
49+// verifiable. Two loose matches are refused like two exact ones, with the
50+// lines, so the model can pick one.
51+func TestLooseMatchesMustStillBeUnique(t *testing.T) {
52+ body := "func a() {\n\tdo()\n}\n\nfunc b() {\n do()\n}\n"
53+ _, _, err := apply(body, []Edit{{Old: " do()\n", New: " done()\n"}})
54+ if err == nil {
55+ t.Fatal("two whitespace-only matches were applied: an edit must be unique")
56+ }
57+ if !strings.Contains(err.Error(), "appears 2 times (lines 2, 6)") {
58+ t.Errorf("the refusal does not say where the two are: %v", err)
59+ }
60+}
61+
62+func TestEdgeWhitespaceIsIgnored(t *testing.T) {
63+ cases := []struct {
64+ name string
65+ body, want string
66+ edit Edit
67+ }{
68+ {"an indent the model kept on a single line",
69+ "func f() {\n\treturn 1\n}\n", "func f() {\n\treturn 2\n}\n",
70+ Edit{Old: " return 1", New: " return 2"}},
71+ {"a trailing newline the file does not have there",
72+ "a = 1\nb = 2\n", "a = 9\nb = 2\n",
73+ Edit{Old: "a = 1\n\n", New: "a = 9\n\n"}},
74+ {"a new that deliberately adds a blank line",
75+ "a = 1\nb = 2\n", "a = 9\n\nb = 2\n",
76+ Edit{Old: " a = 1\n", New: " a = 9\n\n"}},
77+ }
78+ for _, tc := range cases {
79+ t.Run(tc.name, func(t *testing.T) {
80+ got, loose, err := apply(tc.body, []Edit{tc.edit})
81+ if err != nil {
82+ t.Fatalf("apply: %v", err)
83+ }
84+ if loose != 1 {
85+ t.Errorf("loose = %d, want 1", loose)
86+ }
87+ if got != tc.want {
88+ t.Errorf("apply = %q, want %q", got, tc.want)
89+ }
90+ })
91+ }
92+}
93+
94+// A loose deletion takes the newline with it, as an exact one ending in \n
95+// does: the two paths must not disagree about what "delete these lines" means.
96+func TestLooseDeletionLeavesNoBlankLine(t *testing.T) {
97+ body := "keep\nfunc gone() {\n\tx()\n}\nkeep too\n"
98+ got, _, err := apply(body, []Edit{{Old: "func gone() {\n x()\n}\n", New: ""}})
99+ if err != nil {
100+ t.Fatalf("apply: %v", err)
101+ }
102+ if got != "keep\nkeep too\n" {
103+ t.Errorf("apply = %q, want %q", got, "keep\nkeep too\n")
104+ }
105+}
106+
107+// The model's four spaces must not be written into a tab-indented file, at
108+// any depth, and a blank line must not become a line of spaces.
109+func TestReindent(t *testing.T) {
110+ body := "func f() {\n\tif x {\n\t\ty()\n\n\t\tz()\n\t}\n}\n"
111+ edits := []Edit{{
112+ Old: " if x {\n y()\n\n z()\n }",
113+ New: " if x {\n y2()\n\n z2()\n }",
114+ }}
115+ got, _, err := apply(body, edits)
116+ if err != nil {
117+ t.Fatalf("apply: %v", err)
118+ }
119+ want := "func f() {\n\tif x {\n\t\ty2()\n\n\t\tz2()\n\t}\n}\n"
120+ if got != want {
121+ t.Errorf("reindent:\n got %q\nwant %q", got, want)
122+ }
123+ if strings.Contains(got, " ") {
124+ t.Errorf("four spaces were written into a tab-indented file: %q", got)
125+ }
126+}
127+
128+// A miss shows the file, with line numbers. "Not found" alone makes a model
129+// retype the same text; the lines it nearly matched make it fix them.
130+func TestNotFoundShowsTheClosestLines(t *testing.T) {
131+ body := "package main\n\nfunc main() {\n\tcount := 1\n\tprint(count)\n}\n"
132+ _, _, err := apply(body, []Edit{{
133+ Old: "func main() {\n\tcount := 2\n\tprint(count)\n}",
134+ New: "func main() {}",
135+ }})
136+ if err == nil {
137+ t.Fatal("apply accepted an edit whose text is not in the file")
138+ }
139+ msg := err.Error()
140+ for _, want := range []string{"not found", "the closest the file comes to it", "│ 3 │ func main() {", "count := 1"} {
141+ if !strings.Contains(msg, want) {
142+ t.Errorf("the refusal does not contain %q:\n%s", want, msg)
143+ }
144+ }
145+}
146+
147+// Nothing close enough: the excerpt is not offered, because pointing at an
148+// innocent part of the file is worse than saying nothing.
149+func TestNotFoundStaysQuietWithoutANearMiss(t *testing.T) {
150+ body := "alpha\nbeta\ngamma\n"
151+ _, _, err := apply(body, []Edit{{Old: "one\ntwo\nthree\n", New: "x\n"}})
152+ if err == nil {
153+ t.Fatal("apply accepted an edit whose text is not in the file")
154+ }
155+ if strings.Contains(err.Error(), "the closest the file comes to it") {
156+ t.Errorf("an unrelated region was offered as a near miss:\n%s", err)
157+ }
158+}
159+
160+func TestIndentUnit(t *testing.T) {
161+ cases := []struct{ text, want string }{
162+ {"func f() {\n\treturn 1\n}", "\t"},
163+ {"func f() {\n return 1\n}", " "},
164+ {"func f() {\n return 1\n}", " "},
165+ {"a\nb\n", ""},
166+ {"a\n\t\t\tdeep\n\tone\n", "\t"},
167+ }
168+ for _, tc := range cases {
169+ if got := indentUnit(tc.text); got != tc.want {
170+ t.Errorf("indentUnit(%q) = %q, want %q", tc.text, got, tc.want)
171+ }
172+ }
173+}
added internal/tools/bash_test.go +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
1+package tools
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "mm/internal/config"
10+)
11+
12+// TestBashDoesNotWaitForABackgroundJob is the hang that made the window
13+// impossible to leave (ticket 26): with the output on a pipe, os/exec waits
14+// for every holder of the write end, and a job the command backgrounded
15+// without redirecting both streams holds it for as long as it runs. Measured
16+// before the fix: still blocked after 60s, under a 5s context.
17+//
18+// The job must also SURVIVE: the user asked for that server, and the next
19+// command is usually the curl that checks it.
20+func TestBashDoesNotWaitForABackgroundJob(t *testing.T) {
21+ port := "8097"
22+ t.Cleanup(func() { _, _ = runBash(context.Background(), "", "pkill -f 'http.server "+port+"'") })
23+
24+ start := time.Now()
25+ out, failed := runBash(context.Background(), "", "nohup python3 -m http.server "+port+" & echo started")
26+ elapsed := time.Since(start)
27+
28+ if elapsed > 10*time.Second {
29+ t.Fatalf("runBash waited %v for a background job: the turn cannot settle", elapsed)
30+ }
31+ if failed || !strings.Contains(out, "started") {
32+ t.Fatalf("the command's own output was lost: failed=%v out=%q", failed, out)
33+ }
34+
35+ time.Sleep(1500 * time.Millisecond)
36+ alive, _ := runBash(context.Background(), "", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:"+port+"/")
37+ if !strings.Contains(alive, "200") {
38+ t.Fatalf("the background job the user asked for was killed: %q", alive)
39+ }
40+}
41+
42+// A very large output is read back from both ends rather than in full.
43+func TestBashKeepsBothEndsOfAHugeOutput(t *testing.T) {
44+ saved := config.Cfg.MaxOutput
45+ config.Cfg.MaxOutput = 200
46+ t.Cleanup(func() { config.Cfg.MaxOutput = saved })
47+
48+ out, failed := runBash(context.Background(), "", "echo FIRST; head -c 3000000 /dev/zero | tr '\\0' 'x'; echo; echo LAST")
49+ if failed {
50+ t.Fatalf("the command failed: %q", out)
51+ }
52+ if !strings.Contains(out, "FIRST") || !strings.Contains(out, "LAST") {
53+ t.Fatalf("an end of the output was lost: %q", out)
54+ }
55+ if len([]rune(out)) > 1000 {
56+ t.Fatalf("the output was not capped: %d runes", len([]rune(out)))
57+ }
58+}
59+
60+// TestBashStopsWithTheTurn: cancelling the turn reaches the command, which it
61+// could not while runBash hung off context.Background().
62+func TestBashStopsWithTheTurn(t *testing.T) {
63+ ctx, cancel := context.WithCancel(context.Background())
64+ go func() {
65+ time.Sleep(200 * time.Millisecond)
66+ cancel()
67+ }()
68+
69+ start := time.Now()
70+ out, failed := runBash(ctx, "", "sleep 120")
71+ elapsed := time.Since(start)
72+
73+ if elapsed > 10*time.Second {
74+ t.Fatalf("the cancelled command ran for %v", elapsed)
75+ }
76+ if !failed || !strings.Contains(out, "cancelled") {
77+ t.Fatalf("a cancelled command was not reported as one: failed=%v out=%q", failed, out)
78+ }
79+}
80+
81+// The ordinary paths are unchanged.
82+func TestBashReportsOutputAndExitCode(t *testing.T) {
83+ if out, failed := runBash(context.Background(), "", "echo hello"); failed || strings.TrimSpace(out) != "hello" {
84+ t.Fatalf("plain command: failed=%v out=%q", failed, out)
85+ }
86+ out, failed := runBash(context.Background(), "", "exit 3")
87+ if !failed || !strings.Contains(out, "exit code") {
88+ t.Fatalf("a failing command was not reported: failed=%v out=%q", failed, out)
89+ }
90+}
new file mode 100644
@@ -0,0 +1,90 @@
1+package tools
2+
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "mm/internal/config"
10+)
11+
12+// TestBashDoesNotWaitForABackgroundJob is the hang that made the window
13+// impossible to leave (ticket 26): with the output on a pipe, os/exec waits
14+// for every holder of the write end, and a job the command backgrounded
15+// without redirecting both streams holds it for as long as it runs. Measured
16+// before the fix: still blocked after 60s, under a 5s context.
17+//
18+// The job must also SURVIVE: the user asked for that server, and the next
19+// command is usually the curl that checks it.
20+func TestBashDoesNotWaitForABackgroundJob(t *testing.T) {
21+ port := "8097"
22+ t.Cleanup(func() { _, _ = runBash(context.Background(), "", "pkill -f 'http.server "+port+"'") })
23+
24+ start := time.Now()
25+ out, failed := runBash(context.Background(), "", "nohup python3 -m http.server "+port+" & echo started")
26+ elapsed := time.Since(start)
27+
28+ if elapsed > 10*time.Second {
29+ t.Fatalf("runBash waited %v for a background job: the turn cannot settle", elapsed)
30+ }
31+ if failed || !strings.Contains(out, "started") {
32+ t.Fatalf("the command's own output was lost: failed=%v out=%q", failed, out)
33+ }
34+
35+ time.Sleep(1500 * time.Millisecond)
36+ alive, _ := runBash(context.Background(), "", "curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:"+port+"/")
37+ if !strings.Contains(alive, "200") {
38+ t.Fatalf("the background job the user asked for was killed: %q", alive)
39+ }
40+}
41+
42+// A very large output is read back from both ends rather than in full.
43+func TestBashKeepsBothEndsOfAHugeOutput(t *testing.T) {
44+ saved := config.Cfg.MaxOutput
45+ config.Cfg.MaxOutput = 200
46+ t.Cleanup(func() { config.Cfg.MaxOutput = saved })
47+
48+ out, failed := runBash(context.Background(), "", "echo FIRST; head -c 3000000 /dev/zero | tr '\\0' 'x'; echo; echo LAST")
49+ if failed {
50+ t.Fatalf("the command failed: %q", out)
51+ }
52+ if !strings.Contains(out, "FIRST") || !strings.Contains(out, "LAST") {
53+ t.Fatalf("an end of the output was lost: %q", out)
54+ }
55+ if len([]rune(out)) > 1000 {
56+ t.Fatalf("the output was not capped: %d runes", len([]rune(out)))
57+ }
58+}
59+
60+// TestBashStopsWithTheTurn: cancelling the turn reaches the command, which it
61+// could not while runBash hung off context.Background().
62+func TestBashStopsWithTheTurn(t *testing.T) {
63+ ctx, cancel := context.WithCancel(context.Background())
64+ go func() {
65+ time.Sleep(200 * time.Millisecond)
66+ cancel()
67+ }()
68+
69+ start := time.Now()
70+ out, failed := runBash(ctx, "", "sleep 120")
71+ elapsed := time.Since(start)
72+
73+ if elapsed > 10*time.Second {
74+ t.Fatalf("the cancelled command ran for %v", elapsed)
75+ }
76+ if !failed || !strings.Contains(out, "cancelled") {
77+ t.Fatalf("a cancelled command was not reported as one: failed=%v out=%q", failed, out)
78+ }
79+}
80+
81+// The ordinary paths are unchanged.
82+func TestBashReportsOutputAndExitCode(t *testing.T) {
83+ if out, failed := runBash(context.Background(), "", "echo hello"); failed || strings.TrimSpace(out) != "hello" {
84+ t.Fatalf("plain command: failed=%v out=%q", failed, out)
85+ }
86+ out, failed := runBash(context.Background(), "", "exit 3")
87+ if !failed || !strings.Contains(out, "exit code") {
88+ t.Fatalf("a failing command was not reported: failed=%v out=%q", failed, out)
89+ }
90+}
modified internal/tools/files.go +2 -2
@@ -144,13 +144,13 @@ type editFileInput struct {
144144 func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
145145 description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.
146146 Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
147-1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.
147+1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. When only the whitespace differs the edit is still applied, the file keeps ITS indentation, and the summary says "matched ignoring whitespace" — copy the text rather than count on that.
148148 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
149149 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
150150 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
151151 5. An empty new deletes the matched text.
152152 6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
153-Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.`
153+Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through. A "not found" shows the lines of the file that came closest, with their numbers: copy them.`
154154
155155 return genkit.DefineTool(g, "edit_file", description,
156156 func(tctx *ai.ToolContext, in editFileInput) (string, error) {
@@ -144,13 +144,13 @@ type editFileInput struct {
144 func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {144 func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
145 description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.145 description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it.
146 Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:146 Each edit has an old text and a new text. The rules, which are what makes an edit verifiable:
147-1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file.147+1. old must match the file EXACTLY: same spaces, same indentation, same line breaks. Read the file first with read_file. When only the whitespace differs the edit is still applied, the file keeps ITS indentation, and the summary says "matched ignoring whitespace" — copy the text rather than count on that.
148 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.148 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique.
149 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.149 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote.
150 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.150 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit.
151 5. An empty new deletes the matched text.151 5. An empty new deletes the matched text.
152 6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.152 6. Keep old as short as possible while still unique; use two edits rather than one that bridges unchanged lines.
153-Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through.`153+Nothing is written when any edit fails. Returns a one-line summary and the diff with line numbers: read it, it shows exactly what changed. If the tool refuses (text not found, ambiguous, overlapping), read the file again and fix old; do not switch to write_file to force the change through. A "not found" shows the lines of the file that came closest, with their numbers: copy them.`
154 154
155 return genkit.DefineTool(g, "edit_file", description,155 return genkit.DefineTool(g, "edit_file", description,
156 func(tctx *ai.ToolContext, in editFileInput) (string, error) {156 func(tctx *ai.ToolContext, in editFileInput) (string, error) {
modified internal/tools/tools.go +86 -14
@@ -8,7 +8,10 @@ package tools
88
99 import (
1010 "context"
11+ "errors"
1112 "fmt"
13+ "io"
14+ "os"
1215 "os/exec"
1316 "strings"
1417 "sync"
@@ -102,28 +105,97 @@ func echoOutput(out string) {
102105 printMu.Unlock()
103106 }
104107
105-// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` —
106-// "" meaning the process's own directory, which is the terminal behaviour.
107-// `failed` reports a non-zero exit or a timeout; the text already says so, but
108-// a front end that renders statuses (ACP) needs it as a value, not a substring.
109-func runBash(dir, command string) (output string, failed bool) {
110- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
108+// bashBudget is how long one command may run; waitDelay bounds the wait for a
109+// process that will not die once the budget or the turn is over.
110+const (
111+ bashBudget = 30 * time.Second
112+ waitDelay = 2 * time.Second
113+)
114+
115+// outputCap is how much of a command's output is read back from disk, per end.
116+// truncate() keeps a head and a tail, so those are what we read: a command
117+// that writes gigabytes cannot pull them all into memory.
118+const outputCap = 1 << 20
119+
120+// runBash runs a shell command (stdout+stderr merged) in `dir` — "" meaning
121+// the process's own directory, which is the terminal behaviour. `failed`
122+// reports a non-zero exit, a timeout or a cancellation; the text already says
123+// so, but a front end that renders statuses (ACP) needs it as a value, not a
124+// substring.
125+//
126+// Two things here are not obvious, and both are ticket 26.
127+//
128+// The command runs under the TURN's context, with 30s of its own as a child of
129+// it: cancelling the turn — Escape, Ctrl-C, /abort, or asking something else —
130+// now kills the command. It used to hang off context.Background(), so a
131+// cancellation never reached the command that was actually running.
132+//
133+// The output goes to a temporary FILE, not through cmd.CombinedOutput(). With
134+// a pipe, os/exec waits for the write end to be closed by EVERY process
135+// holding it — and a command that backgrounds a job without redirecting both
136+// streams (`nohup serve &`, which the demo's own prompts ask for) hands that
137+// end to a process that outlives the shell. cmd.Wait() then blocks FOREVER:
138+// measured, still blocked after 60s under a 5s context, and killing bash does
139+// not help because the grandchild holds the descriptor. That is what made the
140+// window impossible to leave — the turn never settled, so /quit and /abort,
141+// which waited for it, never ran. A file descriptor has no such contract:
142+// os/exec hands it to the child as-is and Wait returns as soon as bash exits
143+// (measured: 1ms on the same command). The background job keeps the
144+// descriptor and keeps working, which is the point — the user asked for that
145+// server — and writes into a file nobody reads.
146+func runBash(ctx context.Context, dir, command string) (output string, failed bool) {
147+ ctx, cancel := context.WithTimeout(ctx, bashBudget)
111148 defer cancel()
112149
150+ sink, err := os.CreateTemp("", "mm-bash-*")
151+ if err != nil {
152+ return fmt.Sprintf("[error: no place to collect the output: %v]", err), true
153+ }
154+ // Unlinked at once: the descriptor stays valid for us and for whatever the
155+ // command leaves running, and nothing is left in /tmp if mm is killed.
156+ _ = os.Remove(sink.Name())
157+ defer sink.Close()
158+
113159 cmd := exec.CommandContext(ctx, "bash", "-c", command)
114160 cmd.Dir = dir
115- out, err := cmd.CombinedOutput()
116- output = truncate(string(out))
117-
118- if ctx.Err() == context.DeadlineExceeded {
161+ // The same *os.File on both: one descriptor, one offset, so stdout and
162+ // stderr interleave in the order they were written.
163+ cmd.Stdout, cmd.Stderr = sink, sink
164+ cmd.WaitDelay = waitDelay
165+ err = cmd.Run()
166+ output = truncate(collected(sink))
167+
168+ switch {
169+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
119170 return output + "\n[error: 30s timeout exceeded]", true
120- }
121- if err != nil {
171+ case errors.Is(ctx.Err(), context.Canceled):
172+ return output + "\n[error: cancelled]", true
173+ case err != nil:
122174 return output + fmt.Sprintf("\n[exit code: %v]", err), true
123175 }
124176 return output, false
125177 }
126178
179+// collected reads back what the command wrote, keeping both ends of a very
180+// large output — the shape truncate() wants — rather than all of it.
181+func collected(f *os.File) string {
182+ info, err := f.Stat()
183+ if err != nil {
184+ return ""
185+ }
186+ if info.Size() <= 2*outputCap {
187+ if _, err := f.Seek(0, io.SeekStart); err != nil {
188+ return ""
189+ }
190+ b, _ := io.ReadAll(f)
191+ return string(b)
192+ }
193+ head, tail := make([]byte, outputCap), make([]byte, outputCap)
194+ n, _ := f.ReadAt(head, 0)
195+ m, _ := f.ReadAt(tail, info.Size()-outputCap)
196+ return string(head[:n]) + "\n\n[... output too large to read back in full ...]\n\n" + string(tail[:m])
197+}
198+
127199 // Bash declares the `bash` tool.
128200 func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
129201
@@ -156,7 +228,7 @@ Read before you write: if you do not know a path or the content of a file, run a
156228 // like a hung agent. Retitle for the duration, then hand the line
157229 // back to the "Thinking" wait Suspend restored.
158230 spinner.Start("Running")
159- out, _ := runBash("", in.Command)
231+ out, _ := runBash(tctx, "", in.Command)
160232
161233 // The output is shown to the user BEFORE going back to the model:
162234 // what the command answered no longer depends on what the model
@@ -206,7 +278,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
206278 s.ToolRunning(ev.ID)
207279 // The session's working directory, not the process's: in an editor, the
208280 // project the user opened is where the commands belong.
209- out, failed := runBash(s.WorkDir(), in.Command)
281+ out, failed := runBash(ctx, s.WorkDir(), in.Command)
210282 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
211283
212284 return record(d, "bash", in.Command, out)
@@ -8,7 +8,10 @@ package tools
8 8
9 import (9 import (
10 "context"10 "context"
11+ "errors"
11 "fmt"12 "fmt"
13+ "io"
14+ "os"
12 "os/exec"15 "os/exec"
13 "strings"16 "strings"
14 "sync"17 "sync"
@@ -102,28 +105,97 @@ func echoOutput(out string) {
102 printMu.Unlock()105 printMu.Unlock()
103 }106 }
104 107
105-// runBash runs a shell command (stdout+stderr merged, 30s timeout) in `dir` —108+// bashBudget is how long one command may run; waitDelay bounds the wait for a
106-// "" meaning the process's own directory, which is the terminal behaviour.109+// process that will not die once the budget or the turn is over.
107-// `failed` reports a non-zero exit or a timeout; the text already says so, but110+const (
108-// a front end that renders statuses (ACP) needs it as a value, not a substring.111+ bashBudget = 30 * time.Second
109-func runBash(dir, command string) (output string, failed bool) {112+ waitDelay = 2 * time.Second
110- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)113+)
114+
115+// outputCap is how much of a command's output is read back from disk, per end.
116+// truncate() keeps a head and a tail, so those are what we read: a command
117+// that writes gigabytes cannot pull them all into memory.
118+const outputCap = 1 << 20
119+
120+// runBash runs a shell command (stdout+stderr merged) in `dir` — "" meaning
121+// the process's own directory, which is the terminal behaviour. `failed`
122+// reports a non-zero exit, a timeout or a cancellation; the text already says
123+// so, but a front end that renders statuses (ACP) needs it as a value, not a
124+// substring.
125+//
126+// Two things here are not obvious, and both are ticket 26.
127+//
128+// The command runs under the TURN's context, with 30s of its own as a child of
129+// it: cancelling the turn — Escape, Ctrl-C, /abort, or asking something else —
130+// now kills the command. It used to hang off context.Background(), so a
131+// cancellation never reached the command that was actually running.
132+//
133+// The output goes to a temporary FILE, not through cmd.CombinedOutput(). With
134+// a pipe, os/exec waits for the write end to be closed by EVERY process
135+// holding it — and a command that backgrounds a job without redirecting both
136+// streams (`nohup serve &`, which the demo's own prompts ask for) hands that
137+// end to a process that outlives the shell. cmd.Wait() then blocks FOREVER:
138+// measured, still blocked after 60s under a 5s context, and killing bash does
139+// not help because the grandchild holds the descriptor. That is what made the
140+// window impossible to leave — the turn never settled, so /quit and /abort,
141+// which waited for it, never ran. A file descriptor has no such contract:
142+// os/exec hands it to the child as-is and Wait returns as soon as bash exits
143+// (measured: 1ms on the same command). The background job keeps the
144+// descriptor and keeps working, which is the point — the user asked for that
145+// server — and writes into a file nobody reads.
146+func runBash(ctx context.Context, dir, command string) (output string, failed bool) {
147+ ctx, cancel := context.WithTimeout(ctx, bashBudget)
111 defer cancel()148 defer cancel()
112 149
150+ sink, err := os.CreateTemp("", "mm-bash-*")
151+ if err != nil {
152+ return fmt.Sprintf("[error: no place to collect the output: %v]", err), true
153+ }
154+ // Unlinked at once: the descriptor stays valid for us and for whatever the
155+ // command leaves running, and nothing is left in /tmp if mm is killed.
156+ _ = os.Remove(sink.Name())
157+ defer sink.Close()
158+
113 cmd := exec.CommandContext(ctx, "bash", "-c", command)159 cmd := exec.CommandContext(ctx, "bash", "-c", command)
114 cmd.Dir = dir160 cmd.Dir = dir
115- out, err := cmd.CombinedOutput()161+ // The same *os.File on both: one descriptor, one offset, so stdout and
116- output = truncate(string(out))162+ // stderr interleave in the order they were written.
117-163+ cmd.Stdout, cmd.Stderr = sink, sink
118- if ctx.Err() == context.DeadlineExceeded {164+ cmd.WaitDelay = waitDelay
165+ err = cmd.Run()
166+ output = truncate(collected(sink))
167+
168+ switch {
169+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
119 return output + "\n[error: 30s timeout exceeded]", true170 return output + "\n[error: 30s timeout exceeded]", true
120- }171+ case errors.Is(ctx.Err(), context.Canceled):
121- if err != nil {172+ return output + "\n[error: cancelled]", true
173+ case err != nil:
122 return output + fmt.Sprintf("\n[exit code: %v]", err), true174 return output + fmt.Sprintf("\n[exit code: %v]", err), true
123 }175 }
124 return output, false176 return output, false
125 }177 }
126 178
179+// collected reads back what the command wrote, keeping both ends of a very
180+// large output — the shape truncate() wants — rather than all of it.
181+func collected(f *os.File) string {
182+ info, err := f.Stat()
183+ if err != nil {
184+ return ""
185+ }
186+ if info.Size() <= 2*outputCap {
187+ if _, err := f.Seek(0, io.SeekStart); err != nil {
188+ return ""
189+ }
190+ b, _ := io.ReadAll(f)
191+ return string(b)
192+ }
193+ head, tail := make([]byte, outputCap), make([]byte, outputCap)
194+ n, _ := f.ReadAt(head, 0)
195+ m, _ := f.ReadAt(tail, info.Size()-outputCap)
196+ return string(head[:n]) + "\n\n[... output too large to read back in full ...]\n\n" + string(tail[:m])
197+}
198+
127 // Bash declares the `bash` tool.199 // Bash declares the `bash` tool.
128 func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {200 func Bash(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef {
129 201
@@ -156,7 +228,7 @@ Read before you write: if you do not know a path or the content of a file, run a
156 // like a hung agent. Retitle for the duration, then hand the line228 // like a hung agent. Retitle for the duration, then hand the line
157 // back to the "Thinking" wait Suspend restored.229 // back to the "Thinking" wait Suspend restored.
158 spinner.Start("Running")230 spinner.Start("Running")
159- out, _ := runBash("", in.Command)231+ out, _ := runBash(tctx, "", in.Command)
160 232
161 // The output is shown to the user BEFORE going back to the model:233 // The output is shown to the user BEFORE going back to the model:
162 // what the command answered no longer depends on what the model234 // what the command answered no longer depends on what the model
@@ -206,7 +278,7 @@ func bashEvented(ctx context.Context, s ui.Sink, d *detector.LoopDetector, in ba
206 s.ToolRunning(ev.ID)278 s.ToolRunning(ev.ID)
207 // The session's working directory, not the process's: in an editor, the279 // The session's working directory, not the process's: in an editor, the
208 // project the user opened is where the commands belong.280 // project the user opened is where the commands belong.
209- out, failed := runBash(s.WorkDir(), in.Command)281+ out, failed := runBash(ctx, s.WorkDir(), in.Command)
210 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})282 s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed})
211 283
212 return record(d, "bash", in.Command, out)284 return record(d, "bash", in.Command, out)
added internal/tui/commands.go +149 -0
new file mode 100644
@@ -0,0 +1,149 @@
1+// The window's command layer: what Enter does with the box, and the slash
2+// commands behind it. Split out of turn.go, which keeps the session and the
3+// jobs — same reason the rest of this package is one concern per file.
4+
5+package tui
6+
7+import (
8+ "fmt"
9+
10+ "mm/internal/command"
11+ "mm/internal/session"
12+ "mm/internal/store"
13+)
14+
15+// submit is what Enter does with the box: a slash command or a question.
16+// During a turn it cancels the turn and keeps the text for when the turn has
17+// settled — the REPL's "new command received" rule.
18+//
19+// The two control commands are the exception, and it is the whole point of
20+// ticket 26: /quit and /abort are about the turn in progress, so queueing
21+// them behind it is a deadlock. A turn that never settles — a tool blocked
22+// on a pipe, an engine that stopped answering — used to swallow both, and
23+// the only way out of the window was to kill mm from another terminal.
24+func (r *runner) submit(p Prompt) {
25+ if r.control(p) {
26+ return
27+ }
28+ if r.isBusy() {
29+ r.pending = p
30+ r.cancelTurn()
31+ return
32+ }
33+ r.dispatch(p)
34+}
35+
36+// control runs the commands that must never wait for the turn in progress,
37+// and reports whether it ran one. Everything else needs the session the turn
38+// is still writing to, so it keeps queueing.
39+func (r *runner) control(p Prompt) bool {
40+ cmd, _, ok := command.Parse(p.Typed)
41+ if !ok {
42+ return false
43+ }
44+ switch cmd.Name {
45+ case command.Quit:
46+ r.quitNow()
47+ case command.Abort:
48+ r.abort()
49+ default:
50+ return false
51+ }
52+ return true
53+}
54+
55+// abort is /abort and Escape: stop the turn, and say what happened — including
56+// the case the user hit in ticket 26, where the job does not answer at all.
57+func (r *runner) abort() {
58+ switch {
59+ case !r.isBusy():
60+ r.t.Notice("⚠️ No generation in progress.")
61+ case r.cancelTurn():
62+ r.t.Notice("🛑 Aborting…")
63+ default:
64+ r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.")
65+ }
66+}
67+
68+// dispatch runs a slash command, or starts a turn. A command is read from
69+// the typed text: a paste token is never part of a command name.
70+func (r *runner) dispatch(p Prompt) {
71+ cmd, arg, ok := command.Parse(p.Typed)
72+ if !ok {
73+ r.startTurn(p)
74+ return
75+ }
76+ switch cmd.Name {
77+ case command.Quit:
78+ r.quitNow()
79+ case command.Abort:
80+ r.abort()
81+ case command.Compact:
82+ r.startCompact()
83+ case command.New:
84+ r.newSession()
85+ case command.Sessions:
86+ r.t.Notice(r.keep.Listing())
87+ case command.Resume:
88+ r.resume(arg)
89+ case command.Help:
90+ r.help()
91+ case command.Copy:
92+ r.copy(arg)
93+ }
94+}
95+
96+// help lists the window's commands and keys.
97+func (r *runner) help() {
98+ r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
99+}
100+
101+// quitNow ends the loop on its next turn.
102+func (r *runner) quitNow() {
103+ r.quit.Store(true)
104+ r.wake()
105+}
106+
107+// cancelTurn stops the job in progress and reports whether the window still
108+// has a way to stop it.
109+//
110+// A SECOND cancellation of the same job returns false. The context is already
111+// cut; asking again changes nothing, and the caller — Ctrl-C in the view —
112+// must quit instead. Without that escalation a job that ignores its context
113+// (ticket 26: a bash tool blocked on a pipe a background process holds open)
114+// made Ctrl-C a no-op forever, since it read "there was a turn to cancel" as
115+// "so do not quit".
116+func (r *runner) cancelTurn() bool {
117+ if !r.isBusy() || r.cancel == nil {
118+ return false
119+ }
120+ if !r.cancelled.CompareAndSwap(false, true) {
121+ return false
122+ }
123+ r.cancel()
124+ return true
125+}
126+
127+// newSession is /new: the same three resets as the REPL's startNewSession.
128+func (r *runner) newSession() {
129+ forgotten := session.Forgotten(r.messages)
130+ r.messages = session.Fresh(r.opts.System)
131+ r.opts.Model.ForgetInputTokens()
132+ r.opts.Detector.Reset()
133+ r.keep.Renew()
134+ r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
135+}
136+
137+// resume is /resume [id]: a stored session becomes the current one.
138+func (r *runner) resume(id string) {
139+ msgs, line, err := r.keep.Resume(id, r.opts.System)
140+ if err != nil {
141+ r.t.Error("⚠️ " + err.Error())
142+ r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
143+ return
144+ }
145+ r.messages = msgs
146+ r.opts.Detector.Reset()
147+ r.opts.Model.ForgetInputTokens()
148+ r.t.Notice(line)
149+}
new file mode 100644
@@ -0,0 +1,149 @@
1+// The window's command layer: what Enter does with the box, and the slash
2+// commands behind it. Split out of turn.go, which keeps the session and the
3+// jobs — same reason the rest of this package is one concern per file.
4+
5+package tui
6+
7+import (
8+ "fmt"
9+
10+ "mm/internal/command"
11+ "mm/internal/session"
12+ "mm/internal/store"
13+)
14+
15+// submit is what Enter does with the box: a slash command or a question.
16+// During a turn it cancels the turn and keeps the text for when the turn has
17+// settled — the REPL's "new command received" rule.
18+//
19+// The two control commands are the exception, and it is the whole point of
20+// ticket 26: /quit and /abort are about the turn in progress, so queueing
21+// them behind it is a deadlock. A turn that never settles — a tool blocked
22+// on a pipe, an engine that stopped answering — used to swallow both, and
23+// the only way out of the window was to kill mm from another terminal.
24+func (r *runner) submit(p Prompt) {
25+ if r.control(p) {
26+ return
27+ }
28+ if r.isBusy() {
29+ r.pending = p
30+ r.cancelTurn()
31+ return
32+ }
33+ r.dispatch(p)
34+}
35+
36+// control runs the commands that must never wait for the turn in progress,
37+// and reports whether it ran one. Everything else needs the session the turn
38+// is still writing to, so it keeps queueing.
39+func (r *runner) control(p Prompt) bool {
40+ cmd, _, ok := command.Parse(p.Typed)
41+ if !ok {
42+ return false
43+ }
44+ switch cmd.Name {
45+ case command.Quit:
46+ r.quitNow()
47+ case command.Abort:
48+ r.abort()
49+ default:
50+ return false
51+ }
52+ return true
53+}
54+
55+// abort is /abort and Escape: stop the turn, and say what happened — including
56+// the case the user hit in ticket 26, where the job does not answer at all.
57+func (r *runner) abort() {
58+ switch {
59+ case !r.isBusy():
60+ r.t.Notice("⚠️ No generation in progress.")
61+ case r.cancelTurn():
62+ r.t.Notice("🛑 Aborting…")
63+ default:
64+ r.t.Notice("⚠️ The turn is not answering the cancellation — press Ctrl-C (or /quit) to leave mm.")
65+ }
66+}
67+
68+// dispatch runs a slash command, or starts a turn. A command is read from
69+// the typed text: a paste token is never part of a command name.
70+func (r *runner) dispatch(p Prompt) {
71+ cmd, arg, ok := command.Parse(p.Typed)
72+ if !ok {
73+ r.startTurn(p)
74+ return
75+ }
76+ switch cmd.Name {
77+ case command.Quit:
78+ r.quitNow()
79+ case command.Abort:
80+ r.abort()
81+ case command.Compact:
82+ r.startCompact()
83+ case command.New:
84+ r.newSession()
85+ case command.Sessions:
86+ r.t.Notice(r.keep.Listing())
87+ case command.Resume:
88+ r.resume(arg)
89+ case command.Help:
90+ r.help()
91+ case command.Copy:
92+ r.copy(arg)
93+ }
94+}
95+
96+// help lists the window's commands and keys.
97+func (r *runner) help() {
98+ r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
99+}
100+
101+// quitNow ends the loop on its next turn.
102+func (r *runner) quitNow() {
103+ r.quit.Store(true)
104+ r.wake()
105+}
106+
107+// cancelTurn stops the job in progress and reports whether the window still
108+// has a way to stop it.
109+//
110+// A SECOND cancellation of the same job returns false. The context is already
111+// cut; asking again changes nothing, and the caller — Ctrl-C in the view —
112+// must quit instead. Without that escalation a job that ignores its context
113+// (ticket 26: a bash tool blocked on a pipe a background process holds open)
114+// made Ctrl-C a no-op forever, since it read "there was a turn to cancel" as
115+// "so do not quit".
116+func (r *runner) cancelTurn() bool {
117+ if !r.isBusy() || r.cancel == nil {
118+ return false
119+ }
120+ if !r.cancelled.CompareAndSwap(false, true) {
121+ return false
122+ }
123+ r.cancel()
124+ return true
125+}
126+
127+// newSession is /new: the same three resets as the REPL's startNewSession.
128+func (r *runner) newSession() {
129+ forgotten := session.Forgotten(r.messages)
130+ r.messages = session.Fresh(r.opts.System)
131+ r.opts.Model.ForgetInputTokens()
132+ r.opts.Detector.Reset()
133+ r.keep.Renew()
134+ r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
135+}
136+
137+// resume is /resume [id]: a stored session becomes the current one.
138+func (r *runner) resume(id string) {
139+ msgs, line, err := r.keep.Resume(id, r.opts.System)
140+ if err != nil {
141+ r.t.Error("⚠️ " + err.Error())
142+ r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
143+ return
144+ }
145+ r.messages = msgs
146+ r.opts.Detector.Reset()
147+ r.opts.Model.ForgetInputTokens()
148+ r.t.Notice(line)
149+}
modified internal/tui/draw.go +23 -3
@@ -164,14 +164,34 @@ func (v *View) drawPermission(p painter) {
164164 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject", stBody)
165165 }
166166
167-// drawStatus is the bottom bar: key hints left, counters right.
167+// drawStatus is the bottom bar: key hints left, version and counters right.
168168 func (v *View) drawStatus(p painter) {
169169 p.fill(' ', stBar)
170170 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
171- p.textLimited(0, 0, max(p.width()-len([]rune(v.status))-2, 0), left, stBar)
171+ right := v.rightBar(len([]rune(left)), p.width())
172+ p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar)
173+ if right != "" {
174+ p.text(max(p.width()-len([]rune(right))-1, 0), 0, right, stBar)
175+ }
176+}
177+
178+// rightBar is the version and the counters, or the counters alone on a window
179+// too narrow for both. The version is what gives way: the key hints on the
180+// left are what someone reads in the moment they need them, and losing the
181+// end of "Ctrl-C quit" to make room for a version string is the wrong trade —
182+// that hint is how you leave a turn that will not stop.
183+func (v *View) rightBar(leftWidth, width int) string {
184+ if v.version == "" {
185+ return v.status
186+ }
187+ both := v.version
172188 if v.status != "" {
173- p.text(max(p.width()-len([]rune(v.status))-1, 0), 0, v.status, stBar)
189+ both += " · " + v.status
190+ }
191+ if leftWidth+len([]rune(both))+2 <= width {
192+ return both
174193 }
194+ return v.status
175195 }
176196
177197 // drawPicker paints the popup over the bottom of the conversation: one row
@@ -164,14 +164,34 @@ func (v *View) drawPermission(p painter) {
164 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject", stBody)164 p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject", stBody)
165 }165 }
166 166
167-// drawStatus is the bottom bar: key hints left, counters right.167+// drawStatus is the bottom bar: key hints left, version and counters right.
168 func (v *View) drawStatus(p painter) {168 func (v *View) drawStatus(p painter) {
169 p.fill(' ', stBar)169 p.fill(' ', stBar)
170 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"170 left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
171- p.textLimited(0, 0, max(p.width()-len([]rune(v.status))-2, 0), left, stBar)171+ right := v.rightBar(len([]rune(left)), p.width())
172+ p.textLimited(0, 0, max(p.width()-len([]rune(right))-2, 0), left, stBar)
173+ if right != "" {
174+ p.text(max(p.width()-len([]rune(right))-1, 0), 0, right, stBar)
175+ }
176+}
177+
178+// rightBar is the version and the counters, or the counters alone on a window
179+// too narrow for both. The version is what gives way: the key hints on the
180+// left are what someone reads in the moment they need them, and losing the
181+// end of "Ctrl-C quit" to make room for a version string is the wrong trade —
182+// that hint is how you leave a turn that will not stop.
183+func (v *View) rightBar(leftWidth, width int) string {
184+ if v.version == "" {
185+ return v.status
186+ }
187+ both := v.version
172 if v.status != "" {188 if v.status != "" {
173- p.text(max(p.width()-len([]rune(v.status))-1, 0), 0, v.status, stBar)189+ both += " · " + v.status
190+ }
191+ if leftWidth+len([]rune(both))+2 <= width {
192+ return both
174 }193 }
194+ return v.status
175 }195 }
176 196
177 // drawPicker paints the popup over the bottom of the conversation: one row197 // drawPicker paints the popup over the bottom of the conversation: one row
modified internal/tui/events.go +18 -3
@@ -104,12 +104,16 @@ func (v *View) handlePickerKey(ev *tcell.EventKey) bool {
104104 func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
105105 switch {
106106 case ev.Key() == tcell.KeyCtrlC:
107- // The REPL's meaning, kept: cancel a running turn, quit when idle.
107+ // The REPL's meaning, kept: cancel a running turn, quit when idle —
108+ // and quit too when the turn has already been asked to stop and has
109+ // not. cancelTurn returns false the second time round, so a job that
110+ // ignores its context costs one extra Ctrl-C, not the whole window
111+ // (ticket 26).
108112 if v.busy == "" || !v.cancelTurn() {
109113 v.on.quit()
110114 }
111115 case ev.Key() == tcell.KeyEscape:
112- v.cancelTurn()
116+ v.abortTurn()
113117 case ev.Key() == tcell.KeyCtrlL:
114118 v.clearScreen()
115119 case ev.Key() == tcell.KeyF1:
@@ -138,11 +142,22 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
138142 return true
139143 }
140144
141-// cancelTurn stops the turn in progress and reports whether there was one.
145+// cancelTurn stops the turn in progress and reports whether there is still a
146+// way to stop it.
142147 func (v *View) cancelTurn() bool {
143148 return v.on.cancel != nil && v.on.cancel()
144149 }
145150
151+// abortTurn is Escape: the same cancellation, with the runner saying what
152+// happened — including that the turn is not listening any more.
153+func (v *View) abortTurn() {
154+ if v.on.abort != nil {
155+ v.on.abort()
156+ return
157+ }
158+ v.cancelTurn()
159+}
160+
146161 // handleInputKey edits what is being typed.
147162 func (v *View) handleInputKey(ev *tcell.EventKey) bool {
148163 switch ev.Key() {
@@ -104,12 +104,16 @@ func (v *View) handlePickerKey(ev *tcell.EventKey) bool {
104 func (v *View) handleWindowKey(ev *tcell.EventKey) bool {104 func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
105 switch {105 switch {
106 case ev.Key() == tcell.KeyCtrlC:106 case ev.Key() == tcell.KeyCtrlC:
107- // The REPL's meaning, kept: cancel a running turn, quit when idle.107+ // The REPL's meaning, kept: cancel a running turn, quit when idle —
108+ // and quit too when the turn has already been asked to stop and has
109+ // not. cancelTurn returns false the second time round, so a job that
110+ // ignores its context costs one extra Ctrl-C, not the whole window
111+ // (ticket 26).
108 if v.busy == "" || !v.cancelTurn() {112 if v.busy == "" || !v.cancelTurn() {
109 v.on.quit()113 v.on.quit()
110 }114 }
111 case ev.Key() == tcell.KeyEscape:115 case ev.Key() == tcell.KeyEscape:
112- v.cancelTurn()116+ v.abortTurn()
113 case ev.Key() == tcell.KeyCtrlL:117 case ev.Key() == tcell.KeyCtrlL:
114 v.clearScreen()118 v.clearScreen()
115 case ev.Key() == tcell.KeyF1:119 case ev.Key() == tcell.KeyF1:
@@ -138,11 +142,22 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
138 return true142 return true
139 }143 }
140 144
141-// cancelTurn stops the turn in progress and reports whether there was one.145+// cancelTurn stops the turn in progress and reports whether there is still a
146+// way to stop it.
142 func (v *View) cancelTurn() bool {147 func (v *View) cancelTurn() bool {
143 return v.on.cancel != nil && v.on.cancel()148 return v.on.cancel != nil && v.on.cancel()
144 }149 }
145 150
151+// abortTurn is Escape: the same cancellation, with the runner saying what
152+// happened — including that the turn is not listening any more.
153+func (v *View) abortTurn() {
154+ if v.on.abort != nil {
155+ v.on.abort()
156+ return
157+ }
158+ v.cancelTurn()
159+}
160+
146 // handleInputKey edits what is being typed.161 // handleInputKey edits what is being typed.
147 func (v *View) handleInputKey(ev *tcell.EventKey) bool {162 func (v *View) handleInputKey(ev *tcell.EventKey) bool {
148 switch ev.Key() {163 switch ev.Key() {
modified internal/tui/tui.go +2 -0
@@ -67,6 +67,7 @@ func newApp(screen tcell.Screen, opts Options) *app {
6767 v := newView(screen, t, opts.AgentName, hooks{
6868 send: r.submit,
6969 cancel: r.cancelTurn,
70+ abort: r.abort,
7071 quit: r.quitNow,
7172 help: r.help,
7273 answer: r.answer,
@@ -128,6 +129,7 @@ func (a *app) loop(ctx context.Context) error {
128129 func (a *app) frame() {
129130 a.r.settle()
130131 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()
132+ a.v.version = a.r.opts.Version
131133 a.v.draw()
132134 }
133135
@@ -67,6 +67,7 @@ func newApp(screen tcell.Screen, opts Options) *app {
67 v := newView(screen, t, opts.AgentName, hooks{67 v := newView(screen, t, opts.AgentName, hooks{
68 send: r.submit,68 send: r.submit,
69 cancel: r.cancelTurn,69 cancel: r.cancelTurn,
70+ abort: r.abort,
70 quit: r.quitNow,71 quit: r.quitNow,
71 help: r.help,72 help: r.help,
72 answer: r.answer,73 answer: r.answer,
@@ -128,6 +129,7 @@ func (a *app) loop(ctx context.Context) error {
128 func (a *app) frame() {129 func (a *app) frame() {
129 a.r.settle()130 a.r.settle()
130 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()131 a.v.header, a.v.status, a.v.busy, a.v.ask = a.r.header(), a.r.status(), a.r.busyLabel(), a.r.currentAsk()
132+ a.v.version = a.r.opts.Version
131 a.v.draw()133 a.v.draw()
132 }134 }
133 135
modified internal/tui/turn.go +16 -88
@@ -7,7 +7,6 @@ import (
77 "sync"
88 "sync/atomic"
99
10- "mm/internal/command"
1110 "mm/internal/compact"
1211 "mm/internal/config"
1312 "mm/internal/detector"
@@ -44,6 +43,10 @@ type Options struct {
4443 AgentName string // "mm"
4544 ModelName string
4645 Provider string
46+ // Version is what the status bar shows — the window's only place for it,
47+ // since the banner scrolls away with the conversation. Empty is allowed:
48+ // the bar then shows the counters alone.
49+ Version string
4750 // Banner is the start-up text main printed (or would have); shown as
4851 // the first notice, since stdout is the window now.
4952 Banner string
@@ -90,8 +93,12 @@ type runner struct {
9093 busy atomic.Value // string: "", "thinking", "compressing"
9194 quit atomic.Bool
9295 cancel context.CancelFunc
93- results chan outcome
94- pending Prompt // input to run once the cancelled turn has settled
96+ // cancelled says the job in progress has already been told to stop. A
97+ // second request means it is not answering, and the window must offer a
98+ // way out rather than cancelling a context that is already cut.
99+ cancelled atomic.Bool
100+ results chan outcome
101+ pending Prompt // input to run once the cancelled turn has settled
95102
96103 askMu sync.Mutex
97104 ask *permission
@@ -123,90 +130,6 @@ func (r *runner) busyLabel() string { return r.busy.Load().(string) }
123130 // isBusy reports whether a job is running.
124131 func (r *runner) isBusy() bool { return r.busyLabel() != "" }
125132
126-// submit is what Enter does with the box: a slash command or a question.
127-// During a turn it cancels the turn and keeps the text for when the turn has
128-// settled — the REPL's "new command received" rule.
129-func (r *runner) submit(p Prompt) {
130- if r.isBusy() {
131- r.pending = p
132- r.cancelTurn()
133- return
134- }
135- r.dispatch(p)
136-}
137-
138-// dispatch runs a slash command, or starts a turn. A command is read from
139-// the typed text: a paste token is never part of a command name.
140-func (r *runner) dispatch(p Prompt) {
141- cmd, arg, ok := command.Parse(p.Typed)
142- if !ok {
143- r.startTurn(p)
144- return
145- }
146- switch cmd.Name {
147- case command.Quit:
148- r.quitNow()
149- case command.Abort:
150- r.t.Notice("⚠️ No generation in progress.")
151- case command.Compact:
152- r.startCompact()
153- case command.New:
154- r.newSession()
155- case command.Sessions:
156- r.t.Notice(r.keep.Listing())
157- case command.Resume:
158- r.resume(arg)
159- case command.Help:
160- r.help()
161- case command.Copy:
162- r.copy(arg)
163- }
164-}
165-
166-// help lists the window's commands and keys.
167-func (r *runner) help() {
168- r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn · Ctrl-C quits · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
169-}
170-
171-// quitNow ends the loop on its next turn.
172-func (r *runner) quitNow() {
173- r.quit.Store(true)
174- r.wake()
175-}
176-
177-// cancelTurn stops the job in progress and reports whether there was one.
178-func (r *runner) cancelTurn() bool {
179- if !r.isBusy() || r.cancel == nil {
180- return false
181- }
182- r.cancel()
183- return true
184-}
185-
186-// newSession is /new: the same three resets as the REPL's startNewSession.
187-func (r *runner) newSession() {
188- forgotten := session.Forgotten(r.messages)
189- r.messages = session.Fresh(r.opts.System)
190- r.opts.Model.ForgetInputTokens()
191- r.opts.Detector.Reset()
192- r.keep.Renew()
193- r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
194-}
195-
196-// resume is /resume [id]: a stored session becomes the current one.
197-func (r *runner) resume(id string) {
198- msgs, line, err := r.keep.Resume(id, r.opts.System)
199- if err != nil {
200- r.t.Error("⚠️ " + err.Error())
201- r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
202- return
203- }
204- r.messages = msgs
205- r.opts.Detector.Reset()
206- r.opts.Model.ForgetInputTokens()
207- r.t.Notice(line)
208-}
209-
210133 // startTurn appends the question and generates on a goroutine. The
211134 // automatic compaction runs there too, before the question is appended, so
212135 // the window stays responsive while the summary is written.
@@ -224,6 +147,7 @@ func (r *runner) startTurn(p Prompt) {
224147 }
225148 ctx, cancel := context.WithCancel(context.Background())
226149 r.cancel = cancel
150+ r.cancelled.Store(false)
227151 r.busy.Store("thinking")
228152 r.streamed.Store(false)
229153 msgs := append([]*ai.Message(nil), r.messages...)
@@ -282,6 +206,7 @@ func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string,
282206 func (r *runner) startCompact() {
283207 ctx, cancel := context.WithCancel(context.Background())
284208 r.cancel = cancel
209+ r.cancelled.Store(false)
285210 r.busy.Store("compressing")
286211 msgs := append([]*ai.Message(nil), r.messages...)
287212 go func() {
@@ -309,6 +234,7 @@ func (r *runner) settle() {
309234 }
310235 r.busy.Store("")
311236 r.cancel = nil
237+ r.cancelled.Store(false)
312238 if p := r.pending; p.Typed != "" {
313239 r.pending = Prompt{}
314240 r.dispatch(p)
@@ -421,7 +347,9 @@ func (r *runner) header() string {
421347 return b.String()
422348 }
423349
424-// status is the right-hand side of the bottom bar.
350+// status is the counters on the right of the bottom bar. The version sits in
351+// front of them when there is room; drawStatus decides, since only it knows
352+// how wide the window is.
425353 func (r *runner) status() string {
426354 return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools())
427355 }
@@ -7,7 +7,6 @@ import (
7 "sync"7 "sync"
8 "sync/atomic"8 "sync/atomic"
9 9
10- "mm/internal/command"
11 "mm/internal/compact"10 "mm/internal/compact"
12 "mm/internal/config"11 "mm/internal/config"
13 "mm/internal/detector"12 "mm/internal/detector"
@@ -44,6 +43,10 @@ type Options struct {
44 AgentName string // "mm"43 AgentName string // "mm"
45 ModelName string44 ModelName string
46 Provider string45 Provider string
46+ // Version is what the status bar shows — the window's only place for it,
47+ // since the banner scrolls away with the conversation. Empty is allowed:
48+ // the bar then shows the counters alone.
49+ Version string
47 // Banner is the start-up text main printed (or would have); shown as50 // Banner is the start-up text main printed (or would have); shown as
48 // the first notice, since stdout is the window now.51 // the first notice, since stdout is the window now.
49 Banner string52 Banner string
@@ -90,8 +93,12 @@ type runner struct {
90 busy atomic.Value // string: "", "thinking", "compressing"93 busy atomic.Value // string: "", "thinking", "compressing"
91 quit atomic.Bool94 quit atomic.Bool
92 cancel context.CancelFunc95 cancel context.CancelFunc
93- results chan outcome96+ // cancelled says the job in progress has already been told to stop. A
94- pending Prompt // input to run once the cancelled turn has settled97+ // second request means it is not answering, and the window must offer a
98+ // way out rather than cancelling a context that is already cut.
99+ cancelled atomic.Bool
100+ results chan outcome
101+ pending Prompt // input to run once the cancelled turn has settled
95 102
96 askMu sync.Mutex103 askMu sync.Mutex
97 ask *permission104 ask *permission
@@ -123,90 +130,6 @@ func (r *runner) busyLabel() string { return r.busy.Load().(string) }
123 // isBusy reports whether a job is running.130 // isBusy reports whether a job is running.
124 func (r *runner) isBusy() bool { return r.busyLabel() != "" }131 func (r *runner) isBusy() bool { return r.busyLabel() != "" }
125 132
126-// submit is what Enter does with the box: a slash command or a question.
127-// During a turn it cancels the turn and keeps the text for when the turn has
128-// settled — the REPL's "new command received" rule.
129-func (r *runner) submit(p Prompt) {
130- if r.isBusy() {
131- r.pending = p
132- r.cancelTurn()
133- return
134- }
135- r.dispatch(p)
136-}
137-
138-// dispatch runs a slash command, or starts a turn. A command is read from
139-// the typed text: a paste token is never part of a command name.
140-func (r *runner) dispatch(p Prompt) {
141- cmd, arg, ok := command.Parse(p.Typed)
142- if !ok {
143- r.startTurn(p)
144- return
145- }
146- switch cmd.Name {
147- case command.Quit:
148- r.quitNow()
149- case command.Abort:
150- r.t.Notice("⚠️ No generation in progress.")
151- case command.Compact:
152- r.startCompact()
153- case command.New:
154- r.newSession()
155- case command.Sessions:
156- r.t.Notice(r.keep.Listing())
157- case command.Resume:
158- r.resume(arg)
159- case command.Help:
160- r.help()
161- case command.Copy:
162- r.copy(arg)
163- }
164-}
165-
166-// help lists the window's commands and keys.
167-func (r *runner) help() {
168- r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn · Ctrl-C quits · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
169-}
170-
171-// quitNow ends the loop on its next turn.
172-func (r *runner) quitNow() {
173- r.quit.Store(true)
174- r.wake()
175-}
176-
177-// cancelTurn stops the job in progress and reports whether there was one.
178-func (r *runner) cancelTurn() bool {
179- if !r.isBusy() || r.cancel == nil {
180- return false
181- }
182- r.cancel()
183- return true
184-}
185-
186-// newSession is /new: the same three resets as the REPL's startNewSession.
187-func (r *runner) newSession() {
188- forgotten := session.Forgotten(r.messages)
189- r.messages = session.Fresh(r.opts.System)
190- r.opts.Model.ForgetInputTokens()
191- r.opts.Detector.Reset()
192- r.keep.Renew()
193- r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
194-}
195-
196-// resume is /resume [id]: a stored session becomes the current one.
197-func (r *runner) resume(id string) {
198- msgs, line, err := r.keep.Resume(id, r.opts.System)
199- if err != nil {
200- r.t.Error("⚠️ " + err.Error())
201- r.record(store.NewFailure(r.messages, store.FailResume, err.Error(), err))
202- return
203- }
204- r.messages = msgs
205- r.opts.Detector.Reset()
206- r.opts.Model.ForgetInputTokens()
207- r.t.Notice(line)
208-}
209-
210 // startTurn appends the question and generates on a goroutine. The133 // startTurn appends the question and generates on a goroutine. The
211 // automatic compaction runs there too, before the question is appended, so134 // automatic compaction runs there too, before the question is appended, so
212 // the window stays responsive while the summary is written.135 // the window stays responsive while the summary is written.
@@ -224,6 +147,7 @@ func (r *runner) startTurn(p Prompt) {
224 }147 }
225 ctx, cancel := context.WithCancel(context.Background())148 ctx, cancel := context.WithCancel(context.Background())
226 r.cancel = cancel149 r.cancel = cancel
150+ r.cancelled.Store(false)
227 r.busy.Store("thinking")151 r.busy.Store("thinking")
228 r.streamed.Store(false)152 r.streamed.Store(false)
229 msgs := append([]*ai.Message(nil), r.messages...)153 msgs := append([]*ai.Message(nil), r.messages...)
@@ -282,6 +206,7 @@ func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string,
282 func (r *runner) startCompact() {206 func (r *runner) startCompact() {
283 ctx, cancel := context.WithCancel(context.Background())207 ctx, cancel := context.WithCancel(context.Background())
284 r.cancel = cancel208 r.cancel = cancel
209+ r.cancelled.Store(false)
285 r.busy.Store("compressing")210 r.busy.Store("compressing")
286 msgs := append([]*ai.Message(nil), r.messages...)211 msgs := append([]*ai.Message(nil), r.messages...)
287 go func() {212 go func() {
@@ -309,6 +234,7 @@ func (r *runner) settle() {
309 }234 }
310 r.busy.Store("")235 r.busy.Store("")
311 r.cancel = nil236 r.cancel = nil
237+ r.cancelled.Store(false)
312 if p := r.pending; p.Typed != "" {238 if p := r.pending; p.Typed != "" {
313 r.pending = Prompt{}239 r.pending = Prompt{}
314 r.dispatch(p)240 r.dispatch(p)
@@ -421,7 +347,9 @@ func (r *runner) header() string {
421 return b.String()347 return b.String()
422 }348 }
423 349
424-// status is the right-hand side of the bottom bar.350+// status is the counters on the right of the bottom bar. The version sits in
351+// front of them when there is room; drawStatus decides, since only it knows
352+// how wide the window is.
425 func (r *runner) status() string {353 func (r *runner) status() string {
426 return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools())354 return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools())
427 }355 }
modified internal/tui/turn_test.go +98 -5
@@ -24,14 +24,23 @@ import (
2424 type fakeModel struct {
2525 answer string
2626 blockFirst bool
27- fail error
28- summary string
29- tokens int
30- calls atomic.Int32
27+ // deaf makes the first call block for good, context or not: the turn
28+ // that will not settle (ticket 26 — a bash tool blocked on a pipe a
29+ // background process holds open, an engine that stopped answering).
30+ deaf bool
31+ release chan struct{} // closed by the test to let a deaf call return
32+ fail error
33+ summary string
34+ tokens int
35+ calls atomic.Int32
3136 }
3237
3338 func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Message, error) {
34- if m.calls.Add(1) == 1 && m.blockFirst {
39+ if m.calls.Add(1) == 1 && m.deaf {
40+ <-m.release
41+ return nil, nil, errors.New("too late")
42+ }
43+ if m.calls.Load() == 1 && m.blockFirst {
3544 <-ctx.Done()
3645 return nil, nil, ctx.Err()
3746 }
@@ -425,3 +434,87 @@ func TestRunnerFailureIsRecordedInTheSession(t *testing.T) {
425434 t.Errorf("detail %q repeats the message", rec.Errors[0].Detail)
426435 }
427436 }
437+
438+// --- a turn that will not settle (ticket 26) ---------------------------------
439+
440+// deafRunner starts a turn whose generation ignores its context, the shape of
441+// the hang the user hit: the window stays responsive, but the job never
442+// reports back, so nothing that waits for it can ever run.
443+func deafRunner(t *testing.T) (*runner, *Transcript, *fakeModel) {
444+ t.Helper()
445+ m := &fakeModel{deaf: true, release: make(chan struct{})}
446+ t.Cleanup(func() { close(m.release) })
447+ r, tr := newTestRunner(t, m)
448+ r.submit(Prompt{Typed: "hello"})
449+ if !r.isBusy() {
450+ t.Fatal("the turn did not start")
451+ }
452+ r.settle() // takes nothing: the job is deaf
453+ if !r.isBusy() {
454+ t.Fatal("the deaf turn settled, so it is not deaf")
455+ }
456+ return r, tr, m
457+}
458+
459+func TestQuitLeavesATurnThatWillNotSettle(t *testing.T) {
460+ r, _, _ := deafRunner(t)
461+
462+ r.submit(Prompt{Typed: "/quit"})
463+
464+ if !r.quit.Load() {
465+ t.Fatal("/quit during a stuck turn did not ask the window to close")
466+ }
467+ if r.pending.Typed != "" {
468+ t.Fatalf("/quit was queued behind the turn instead of running: %q", r.pending.Typed)
469+ }
470+}
471+
472+func TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing(t *testing.T) {
473+ r, tr, _ := deafRunner(t)
474+
475+ r.submit(Prompt{Typed: "/abort"}) // the cancellation it can still do
476+ if r.pending.Typed != "" {
477+ t.Fatalf("/abort was queued behind the turn: %q", r.pending.Typed)
478+ }
479+ if got := tr.Plain(); !strings.Contains(got, "Aborting") {
480+ t.Fatalf("the first /abort said nothing about aborting:\n%s", got)
481+ }
482+
483+ r.submit(Prompt{Typed: "/abort"}) // and the one it cannot
484+ if got := tr.Plain(); !strings.Contains(got, "not answering the cancellation") {
485+ t.Fatalf("the second /abort did not say the turn is deaf:\n%s", got)
486+ }
487+}
488+
489+// TestCtrlCEscalatesOnATurnThatWillNotStop is the view's side: cancelTurn
490+// answers true once and false after, which is what turns the second Ctrl-C
491+// into a quit (events.go).
492+func TestCtrlCEscalatesOnATurnThatWillNotStop(t *testing.T) {
493+ r, _, _ := deafRunner(t)
494+
495+ if !r.cancelTurn() {
496+ t.Fatal("the first cancellation should report that it had something to cancel")
497+ }
498+ if r.cancelTurn() {
499+ t.Fatal("the second cancellation still claims it can stop the turn: Ctrl-C would never quit")
500+ }
501+}
502+
503+// A turn that DOES stop must keep the old behaviour: the flag is per job, so
504+// the next turn can be cancelled again.
505+func TestCancellationIsForgottenBetweenTurns(t *testing.T) {
506+ m := &fakeModel{answer: "hi", blockFirst: true}
507+ r, _ := newTestRunner(t, m)
508+
509+ r.submit(Prompt{Typed: "first"})
510+ if !r.cancelTurn() {
511+ t.Fatal("the first turn could not be cancelled")
512+ }
513+ waitIdle(t, r)
514+
515+ r.submit(Prompt{Typed: "second"})
516+ if !r.cancelTurn() {
517+ t.Fatal("the second turn was refused a cancellation the first one used up")
518+ }
519+ waitIdle(t, r)
520+}
@@ -24,14 +24,23 @@ import (
24 type fakeModel struct {24 type fakeModel struct {
25 answer string25 answer string
26 blockFirst bool26 blockFirst bool
27- fail error27+ // deaf makes the first call block for good, context or not: the turn
28- summary string28+ // that will not settle (ticket 26 — a bash tool blocked on a pipe a
29- tokens int29+ // background process holds open, an engine that stopped answering).
30- calls atomic.Int3230+ deaf bool
31+ release chan struct{} // closed by the test to let a deaf call return
32+ fail error
33+ summary string
34+ tokens int
35+ calls atomic.Int32
31 }36 }
32 37
33 func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Message, error) {38 func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Message, error) {
34- if m.calls.Add(1) == 1 && m.blockFirst {39+ if m.calls.Add(1) == 1 && m.deaf {
40+ <-m.release
41+ return nil, nil, errors.New("too late")
42+ }
43+ if m.calls.Load() == 1 && m.blockFirst {
35 <-ctx.Done()44 <-ctx.Done()
36 return nil, nil, ctx.Err()45 return nil, nil, ctx.Err()
37 }46 }
@@ -425,3 +434,87 @@ func TestRunnerFailureIsRecordedInTheSession(t *testing.T) {
425 t.Errorf("detail %q repeats the message", rec.Errors[0].Detail)434 t.Errorf("detail %q repeats the message", rec.Errors[0].Detail)
426 }435 }
427 }436 }
437+
438+// --- a turn that will not settle (ticket 26) ---------------------------------
439+
440+// deafRunner starts a turn whose generation ignores its context, the shape of
441+// the hang the user hit: the window stays responsive, but the job never
442+// reports back, so nothing that waits for it can ever run.
443+func deafRunner(t *testing.T) (*runner, *Transcript, *fakeModel) {
444+ t.Helper()
445+ m := &fakeModel{deaf: true, release: make(chan struct{})}
446+ t.Cleanup(func() { close(m.release) })
447+ r, tr := newTestRunner(t, m)
448+ r.submit(Prompt{Typed: "hello"})
449+ if !r.isBusy() {
450+ t.Fatal("the turn did not start")
451+ }
452+ r.settle() // takes nothing: the job is deaf
453+ if !r.isBusy() {
454+ t.Fatal("the deaf turn settled, so it is not deaf")
455+ }
456+ return r, tr, m
457+}
458+
459+func TestQuitLeavesATurnThatWillNotSettle(t *testing.T) {
460+ r, _, _ := deafRunner(t)
461+
462+ r.submit(Prompt{Typed: "/quit"})
463+
464+ if !r.quit.Load() {
465+ t.Fatal("/quit during a stuck turn did not ask the window to close")
466+ }
467+ if r.pending.Typed != "" {
468+ t.Fatalf("/quit was queued behind the turn instead of running: %q", r.pending.Typed)
469+ }
470+}
471+
472+func TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing(t *testing.T) {
473+ r, tr, _ := deafRunner(t)
474+
475+ r.submit(Prompt{Typed: "/abort"}) // the cancellation it can still do
476+ if r.pending.Typed != "" {
477+ t.Fatalf("/abort was queued behind the turn: %q", r.pending.Typed)
478+ }
479+ if got := tr.Plain(); !strings.Contains(got, "Aborting") {
480+ t.Fatalf("the first /abort said nothing about aborting:\n%s", got)
481+ }
482+
483+ r.submit(Prompt{Typed: "/abort"}) // and the one it cannot
484+ if got := tr.Plain(); !strings.Contains(got, "not answering the cancellation") {
485+ t.Fatalf("the second /abort did not say the turn is deaf:\n%s", got)
486+ }
487+}
488+
489+// TestCtrlCEscalatesOnATurnThatWillNotStop is the view's side: cancelTurn
490+// answers true once and false after, which is what turns the second Ctrl-C
491+// into a quit (events.go).
492+func TestCtrlCEscalatesOnATurnThatWillNotStop(t *testing.T) {
493+ r, _, _ := deafRunner(t)
494+
495+ if !r.cancelTurn() {
496+ t.Fatal("the first cancellation should report that it had something to cancel")
497+ }
498+ if r.cancelTurn() {
499+ t.Fatal("the second cancellation still claims it can stop the turn: Ctrl-C would never quit")
500+ }
501+}
502+
503+// A turn that DOES stop must keep the old behaviour: the flag is per job, so
504+// the next turn can be cancelled again.
505+func TestCancellationIsForgottenBetweenTurns(t *testing.T) {
506+ m := &fakeModel{answer: "hi", blockFirst: true}
507+ r, _ := newTestRunner(t, m)
508+
509+ r.submit(Prompt{Typed: "first"})
510+ if !r.cancelTurn() {
511+ t.Fatal("the first turn could not be cancelled")
512+ }
513+ waitIdle(t, r)
514+
515+ r.submit(Prompt{Typed: "second"})
516+ if !r.cancelTurn() {
517+ t.Fatal("the second turn was refused a cancellation the first one used up")
518+ }
519+ waitIdle(t, r)
520+}
modified internal/tui/view.go +10 -5
@@ -40,8 +40,12 @@ type hooks struct {
4040 // send hands a finished input over: a question or a slash command, with
4141 // the pastes collapsed in the box behind their placeholders.
4242 send func(p Prompt)
43- // cancel stops the turn in progress and reports whether there was one.
43+ // cancel stops the turn in progress and reports whether the window still
44+ // has a way to stop it — false on a second try, which is Ctrl-C's cue to
45+ // quit rather than cancel again.
4446 cancel func() bool
47+ // abort is Escape and /abort: cancel, and say what happened.
48+ abort func()
4549 // quit ends the window.
4650 quit func()
4751 // help is F1.
@@ -64,10 +68,11 @@ type View struct {
6468 previewLines int
6569
6670 // What the runner reports for the chrome, refreshed before each frame.
67- header string // the header line
68- status string // right-hand side of the status bar
69- busy string // "", "thinking", "compressing"
70- ask *permission
71+ header string // the header line
72+ status string // the counters on the right of the status bar
73+ version string // shown before them while the bar is wide enough
74+ busy string // "", "thinking", "compressing"
75+ ask *permission
7176
7277 // input is what has been typed but not sent, as lines. A long paste is
7378 // one placeholder rune in there; pastes holds the text it stands for.
@@ -40,8 +40,12 @@ type hooks struct {
40 // send hands a finished input over: a question or a slash command, with40 // send hands a finished input over: a question or a slash command, with
41 // the pastes collapsed in the box behind their placeholders.41 // the pastes collapsed in the box behind their placeholders.
42 send func(p Prompt)42 send func(p Prompt)
43- // cancel stops the turn in progress and reports whether there was one.43+ // cancel stops the turn in progress and reports whether the window still
44+ // has a way to stop it — false on a second try, which is Ctrl-C's cue to
45+ // quit rather than cancel again.
44 cancel func() bool46 cancel func() bool
47+ // abort is Escape and /abort: cancel, and say what happened.
48+ abort func()
45 // quit ends the window.49 // quit ends the window.
46 quit func()50 quit func()
47 // help is F1.51 // help is F1.
@@ -64,10 +68,11 @@ type View struct {
64 previewLines int68 previewLines int
65 69
66 // What the runner reports for the chrome, refreshed before each frame.70 // What the runner reports for the chrome, refreshed before each frame.
67- header string // the header line71+ header string // the header line
68- status string // right-hand side of the status bar72+ status string // the counters on the right of the status bar
69- busy string // "", "thinking", "compressing"73+ version string // shown before them while the bar is wide enough
70- ask *permission74+ busy string // "", "thinking", "compressing"
75+ ask *permission
71 76
72 // input is what has been typed but not sent, as lines. A long paste is77 // input is what has been typed but not sent, as lines. A long paste is
73 // one placeholder rune in there; pastes holds the text it stands for.78 // one placeholder rune in there; pastes holds the text it stands for.
modified internal/tui/view_test.go +43 -0
@@ -515,3 +515,46 @@ func TestInputWordWrap(t *testing.T) {
515515 t.Errorf("cursor row %d, want the box's last row (10)", y)
516516 }
517517 }
518+
519+// The version sits in the status bar with the counters — and steps aside when
520+// the two together would not fit, rather than eating the key hints. "Ctrl-C
521+// quit" is how you leave a turn that will not stop; it outranks a version.
522+func TestStatusBarShowsTheVersionWhileItFits(t *testing.T) {
523+ // 80 columns: the hints plus "v0.0.3 · 2 msgs · 0 tools" fit.
524+ wide := newHarness(t, 80, 14)
525+ wide.v.status, wide.v.version = "2 msgs · 0 tools", "v0.0.3"
526+ bar := wide.rows()[13]
527+ if !strings.HasSuffix(bar, "v0.0.3 · 2 msgs · 0 tools") {
528+ t.Errorf("wide bar does not end with the version and the counters: %q", bar)
529+ }
530+ if !strings.Contains(bar, "Ctrl-C quit") {
531+ t.Errorf("wide bar lost the key hints: %q", bar)
532+ }
533+
534+ // Same width, a longer version: it no longer fits and must cost nothing.
535+ // Compared against the bar with no version at all, which is the promise:
536+ // showing a version never makes the bar worse than not showing one.
537+ long := newHarness(t, 80, 14)
538+ long.v.status, long.v.version = "2 msgs · 0 tools", "9073268+dirty"
539+ got := long.rows()[13]
540+
541+ none := newHarness(t, 80, 14)
542+ none.v.status = "2 msgs · 0 tools"
543+ want := none.rows()[13]
544+
545+ if got != want {
546+ t.Errorf("a version that does not fit changed the bar:\n got %q\nwant %q", got, want)
547+ }
548+ if !strings.Contains(got, "Ctrl-C quit") {
549+ t.Errorf("bar truncated the way out: %q", got)
550+ }
551+}
552+
553+// No version wired (a test, an embedder): the bar is what it always was.
554+func TestStatusBarWithoutAVersion(t *testing.T) {
555+ hs := newHarness(t, 80, 14)
556+ hs.v.status = "2 msgs · 0 tools"
557+ if bar := hs.rows()[13]; !strings.HasSuffix(bar, "2 msgs · 0 tools") {
558+ t.Errorf("bar %q", bar)
559+ }
560+}
@@ -515,3 +515,46 @@ func TestInputWordWrap(t *testing.T) {
515 t.Errorf("cursor row %d, want the box's last row (10)", y)515 t.Errorf("cursor row %d, want the box's last row (10)", y)
516 }516 }
517 }517 }
518+
519+// The version sits in the status bar with the counters — and steps aside when
520+// the two together would not fit, rather than eating the key hints. "Ctrl-C
521+// quit" is how you leave a turn that will not stop; it outranks a version.
522+func TestStatusBarShowsTheVersionWhileItFits(t *testing.T) {
523+ // 80 columns: the hints plus "v0.0.3 · 2 msgs · 0 tools" fit.
524+ wide := newHarness(t, 80, 14)
525+ wide.v.status, wide.v.version = "2 msgs · 0 tools", "v0.0.3"
526+ bar := wide.rows()[13]
527+ if !strings.HasSuffix(bar, "v0.0.3 · 2 msgs · 0 tools") {
528+ t.Errorf("wide bar does not end with the version and the counters: %q", bar)
529+ }
530+ if !strings.Contains(bar, "Ctrl-C quit") {
531+ t.Errorf("wide bar lost the key hints: %q", bar)
532+ }
533+
534+ // Same width, a longer version: it no longer fits and must cost nothing.
535+ // Compared against the bar with no version at all, which is the promise:
536+ // showing a version never makes the bar worse than not showing one.
537+ long := newHarness(t, 80, 14)
538+ long.v.status, long.v.version = "2 msgs · 0 tools", "9073268+dirty"
539+ got := long.rows()[13]
540+
541+ none := newHarness(t, 80, 14)
542+ none.v.status = "2 msgs · 0 tools"
543+ want := none.rows()[13]
544+
545+ if got != want {
546+ t.Errorf("a version that does not fit changed the bar:\n got %q\nwant %q", got, want)
547+ }
548+ if !strings.Contains(got, "Ctrl-C quit") {
549+ t.Errorf("bar truncated the way out: %q", got)
550+ }
551+}
552+
553+// No version wired (a test, an embedder): the bar is what it always was.
554+func TestStatusBarWithoutAVersion(t *testing.T) {
555+ hs := newHarness(t, 80, 14)
556+ hs.v.status = "2 msgs · 0 tools"
557+ if bar := hs.rows()[13]; !strings.HasSuffix(bar, "2 msgs · 0 tools") {
558+ t.Errorf("bar %q", bar)
559+ }
560+}
added internal/version/version.go +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+// Package version is the one place that knows which mm this is.
2+//
3+// Three sources, in order, so that every way of getting an mm answers the
4+// question without anyone having to remember a step:
5+//
6+// 1. A release binary carries its tag, stamped at link time by
7+// 02-build-releases.sh:
8+//
9+// go build -ldflags "-X mm/internal/version.stamped=v0.0.3"
10+//
11+// 2. A binary built by hand — `go build .`, `./install.sh` — has no stamp,
12+// so the answer comes from the build information Go embeds on its own:
13+// the module version when mm was fetched with `go install mm@v0.0.3`,
14+// and otherwise the commit the working tree was on, with `+dirty` when
15+// it had uncommitted changes.
16+//
17+// 3. `unknown`, when even that was stripped (`-buildvcs=false`).
18+//
19+// Nothing here reads a file or runs a command: the version of a binary is a
20+// property of the binary, and must be the same answer wherever it runs.
21+package version
22+
23+import (
24+ "fmt"
25+ "regexp"
26+ "runtime"
27+ "runtime/debug"
28+ "strings"
29+)
30+
31+// stamped is set at link time on a release build. It is deliberately not
32+// exported: -X addresses a symbol, not an identifier, so an unexported
33+// variable is stamped just as well, and nothing else can assign it.
34+var stamped = ""
35+
36+const unknown = "unknown"
37+
38+// pseudo matches Go's pseudo-versions — the ones the toolchain invents for a
39+// commit that carries no tag of its own. All three shapes end the same way,
40+// with the commit's timestamp and its hash:
41+//
42+// v0.0.0-20260918190133-907326826f1a (no tag before it)
43+// v0.0.3-0.20260918190133-907326826f1a (v0.0.2 before it)
44+// v0.1.0-rc.1.0.20260918190133-907326826f1a (a prerelease before it)
45+//
46+// Hence the [-.]: the separator is a dot in the last two. Such a version says
47+// "somewhere after v0.0.2", which is what the hash says in a quarter of the
48+// characters — so we show the hash instead.
49+var pseudo = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}$`)
50+
51+// Short is what the banner and the window's status bar show: "v0.0.3" for a
52+// release, "9073268" for a build from a working tree, either with "+dirty"
53+// appended when that tree had uncommitted changes.
54+func Short() string {
55+ if stamped != "" {
56+ return stamped
57+ }
58+ return fromBuild()
59+}
60+
61+// Long is what `mm --version` prints: the short form plus the toolchain and
62+// the platform — the three things a bug report needs and nobody remembers to
63+// include.
64+func Long() string {
65+ return fmt.Sprintf("mm %s (%s, %s/%s)", Short(), runtime.Version(), runtime.GOOS, runtime.GOARCH)
66+}
67+
68+func fromBuild() string {
69+ info, ok := debug.ReadBuildInfo()
70+ if !ok {
71+ return unknown
72+ }
73+ // "v0.0.3-0.2026…-907326…+dirty": the build metadata after "+" is Go's own
74+ // way of saying the tree was not clean, and it must come off before the
75+ // version itself can be judged.
76+ tag, meta, _ := strings.Cut(info.Main.Version, "+")
77+ dirty := meta == "dirty"
78+ rev := ""
79+ for _, s := range info.Settings {
80+ switch s.Key {
81+ case "vcs.revision":
82+ rev = s.Value
83+ case "vcs.modified":
84+ dirty = dirty || s.Value == "true"
85+ }
86+ }
87+
88+ name := ""
89+ switch {
90+ case tag != "" && tag != "(devel)" && !pseudo.MatchString(tag):
91+ name = tag
92+ case len(rev) >= 7:
93+ name = rev[:7]
94+ default:
95+ return unknown
96+ }
97+ if dirty {
98+ name += "+dirty"
99+ }
100+ return name
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+// Package version is the one place that knows which mm this is.
2+//
3+// Three sources, in order, so that every way of getting an mm answers the
4+// question without anyone having to remember a step:
5+//
6+// 1. A release binary carries its tag, stamped at link time by
7+// 02-build-releases.sh:
8+//
9+// go build -ldflags "-X mm/internal/version.stamped=v0.0.3"
10+//
11+// 2. A binary built by hand — `go build .`, `./install.sh` — has no stamp,
12+// so the answer comes from the build information Go embeds on its own:
13+// the module version when mm was fetched with `go install mm@v0.0.3`,
14+// and otherwise the commit the working tree was on, with `+dirty` when
15+// it had uncommitted changes.
16+//
17+// 3. `unknown`, when even that was stripped (`-buildvcs=false`).
18+//
19+// Nothing here reads a file or runs a command: the version of a binary is a
20+// property of the binary, and must be the same answer wherever it runs.
21+package version
22+
23+import (
24+ "fmt"
25+ "regexp"
26+ "runtime"
27+ "runtime/debug"
28+ "strings"
29+)
30+
31+// stamped is set at link time on a release build. It is deliberately not
32+// exported: -X addresses a symbol, not an identifier, so an unexported
33+// variable is stamped just as well, and nothing else can assign it.
34+var stamped = ""
35+
36+const unknown = "unknown"
37+
38+// pseudo matches Go's pseudo-versions — the ones the toolchain invents for a
39+// commit that carries no tag of its own. All three shapes end the same way,
40+// with the commit's timestamp and its hash:
41+//
42+// v0.0.0-20260918190133-907326826f1a (no tag before it)
43+// v0.0.3-0.20260918190133-907326826f1a (v0.0.2 before it)
44+// v0.1.0-rc.1.0.20260918190133-907326826f1a (a prerelease before it)
45+//
46+// Hence the [-.]: the separator is a dot in the last two. Such a version says
47+// "somewhere after v0.0.2", which is what the hash says in a quarter of the
48+// characters — so we show the hash instead.
49+var pseudo = regexp.MustCompile(`[-.]\d{14}-[0-9a-f]{12}$`)
50+
51+// Short is what the banner and the window's status bar show: "v0.0.3" for a
52+// release, "9073268" for a build from a working tree, either with "+dirty"
53+// appended when that tree had uncommitted changes.
54+func Short() string {
55+ if stamped != "" {
56+ return stamped
57+ }
58+ return fromBuild()
59+}
60+
61+// Long is what `mm --version` prints: the short form plus the toolchain and
62+// the platform — the three things a bug report needs and nobody remembers to
63+// include.
64+func Long() string {
65+ return fmt.Sprintf("mm %s (%s, %s/%s)", Short(), runtime.Version(), runtime.GOOS, runtime.GOARCH)
66+}
67+
68+func fromBuild() string {
69+ info, ok := debug.ReadBuildInfo()
70+ if !ok {
71+ return unknown
72+ }
73+ // "v0.0.3-0.2026…-907326…+dirty": the build metadata after "+" is Go's own
74+ // way of saying the tree was not clean, and it must come off before the
75+ // version itself can be judged.
76+ tag, meta, _ := strings.Cut(info.Main.Version, "+")
77+ dirty := meta == "dirty"
78+ rev := ""
79+ for _, s := range info.Settings {
80+ switch s.Key {
81+ case "vcs.revision":
82+ rev = s.Value
83+ case "vcs.modified":
84+ dirty = dirty || s.Value == "true"
85+ }
86+ }
87+
88+ name := ""
89+ switch {
90+ case tag != "" && tag != "(devel)" && !pseudo.MatchString(tag):
91+ name = tag
92+ case len(rev) >= 7:
93+ name = rev[:7]
94+ default:
95+ return unknown
96+ }
97+ if dirty {
98+ name += "+dirty"
99+ }
100+ return name
101+}
added internal/version/version_test.go +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+package version
2+
3+import (
4+ "runtime"
5+ "strings"
6+ "testing"
7+)
8+
9+// The stamp a release build sets wins over everything Go embeds.
10+func TestStampWins(t *testing.T) {
11+ saved := stamped
12+ t.Cleanup(func() { stamped = saved })
13+ stamped = "v1.2.3"
14+ if got := Short(); got != "v1.2.3" {
15+ t.Fatalf("Short() = %q, want the stamp", got)
16+ }
17+ if got := Long(); !strings.HasPrefix(got, "mm v1.2.3 (") || !strings.Contains(got, runtime.GOOS+"/"+runtime.GOARCH) {
18+ t.Fatalf("Long() = %q", got)
19+ }
20+}
21+
22+// A pseudo-version is the toolchain saying "an untagged commit"; the commit
23+// itself says it shorter, so that is what is shown.
24+func TestPseudoVersionsAreRecognised(t *testing.T) {
25+ yes := []string{
26+ "v0.0.0-20260918190133-907326826f1a",
27+ "v0.0.3-0.20260918190133-907326826f1a",
28+ "v0.1.0-rc.1.0.20260918190133-907326826f1a",
29+ }
30+ for _, v := range yes {
31+ if !pseudo.MatchString(v) {
32+ t.Errorf("%q should be read as a pseudo-version", v)
33+ }
34+ }
35+ no := []string{"v0.0.3", "v1.2.3-rc.1", "v0.0.2"}
36+ for _, v := range no {
37+ if pseudo.MatchString(v) {
38+ t.Errorf("%q is a real tag, not a pseudo-version", v)
39+ }
40+ }
41+}
42+
43+// Whatever the build, Short() answers something printable: the banner and the
44+// status bar have no branch for "no version".
45+func TestShortIsAlwaysUsable(t *testing.T) {
46+ saved := stamped
47+ t.Cleanup(func() { stamped = saved })
48+ stamped = ""
49+ got := Short()
50+ if got == "" || strings.ContainsAny(got, " \t\n") {
51+ t.Fatalf("Short() = %q: not usable in a one-line banner", got)
52+ }
53+}
new file mode 100644
@@ -0,0 +1,53 @@
1+package version
2+
3+import (
4+ "runtime"
5+ "strings"
6+ "testing"
7+)
8+
9+// The stamp a release build sets wins over everything Go embeds.
10+func TestStampWins(t *testing.T) {
11+ saved := stamped
12+ t.Cleanup(func() { stamped = saved })
13+ stamped = "v1.2.3"
14+ if got := Short(); got != "v1.2.3" {
15+ t.Fatalf("Short() = %q, want the stamp", got)
16+ }
17+ if got := Long(); !strings.HasPrefix(got, "mm v1.2.3 (") || !strings.Contains(got, runtime.GOOS+"/"+runtime.GOARCH) {
18+ t.Fatalf("Long() = %q", got)
19+ }
20+}
21+
22+// A pseudo-version is the toolchain saying "an untagged commit"; the commit
23+// itself says it shorter, so that is what is shown.
24+func TestPseudoVersionsAreRecognised(t *testing.T) {
25+ yes := []string{
26+ "v0.0.0-20260918190133-907326826f1a",
27+ "v0.0.3-0.20260918190133-907326826f1a",
28+ "v0.1.0-rc.1.0.20260918190133-907326826f1a",
29+ }
30+ for _, v := range yes {
31+ if !pseudo.MatchString(v) {
32+ t.Errorf("%q should be read as a pseudo-version", v)
33+ }
34+ }
35+ no := []string{"v0.0.3", "v1.2.3-rc.1", "v0.0.2"}
36+ for _, v := range no {
37+ if pseudo.MatchString(v) {
38+ t.Errorf("%q is a real tag, not a pseudo-version", v)
39+ }
40+ }
41+}
42+
43+// Whatever the build, Short() answers something printable: the banner and the
44+// status bar have no branch for "no version".
45+func TestShortIsAlwaysUsable(t *testing.T) {
46+ saved := stamped
47+ t.Cleanup(func() { stamped = saved })
48+ stamped = ""
49+ got := Short()
50+ if got == "" || strings.ContainsAny(got, " \t\n") {
51+ t.Fatalf("Short() = %q: not usable in a one-line banner", got)
52+ }
53+}
modified main.go +16 -2
@@ -46,6 +46,7 @@ import (
4646 "mm/internal/tools"
4747 "mm/internal/tui"
4848 "mm/internal/ui"
49+ "mm/internal/version"
4950
5051 "github.com/firebase/genkit/go/ai"
5152 )
@@ -65,7 +66,16 @@ func main() {
6566 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
6667 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
6768 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal")
69+ showVersion := flag.Bool("version", false, "print the version and exit")
6870 flag.Parse()
71+ // Answered before anything can go wrong: asking which mm this is must
72+ // work without a config file, without a model server, and in a directory
73+ // mm knows nothing about. Straight to stdout, not ui.Out — this is the
74+ // answer to a question, not part of a session's output.
75+ if *showVersion {
76+ fmt.Println(version.Long())
77+ return
78+ }
6979 if *configPath == "" && flag.NArg() > 0 {
7080 *configPath = flag.Arg(0)
7181 }
@@ -153,6 +163,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
153163 AgentName: "mm",
154164 ModelName: config.Cfg.Model,
155165 Provider: e.Backend.Provider,
166+ Version: version.Short(),
156167 Banner: banner,
157168 })
158169 if err == nil {
@@ -276,8 +287,11 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
276287 if toolCol == "" {
277288 toolCol = "none"
278289 }
279- say("%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",
280- dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)
290+ // The version leads the line: it is the first thing anyone is asked for
291+ // when something behaves oddly, and the banner is the one output every
292+ // front end shows — the window keeps it as the conversation's first line.
293+ say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",
294+ dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)
281295 }
282296
283297 // windowMode decides whether the window front end runs, and where the banner
@@ -46,6 +46,7 @@ import (
46 "mm/internal/tools"46 "mm/internal/tools"
47 "mm/internal/tui"47 "mm/internal/tui"
48 "mm/internal/ui"48 "mm/internal/ui"
49+ "mm/internal/version"
49 50
50 "github.com/firebase/genkit/go/ai"51 "github.com/firebase/genkit/go/ai"
51 )52 )
@@ -65,7 +66,16 @@ func main() {
65 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")66 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
66 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")67 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
67 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal")68 tuiMode := flag.Bool("tui", false, "open the single-window mode (conversation above, input box below); falls back to the line mode when stdout is not a terminal")
69+ showVersion := flag.Bool("version", false, "print the version and exit")
68 flag.Parse()70 flag.Parse()
71+ // Answered before anything can go wrong: asking which mm this is must
72+ // work without a config file, without a model server, and in a directory
73+ // mm knows nothing about. Straight to stdout, not ui.Out — this is the
74+ // answer to a question, not part of a session's output.
75+ if *showVersion {
76+ fmt.Println(version.Long())
77+ return
78+ }
69 if *configPath == "" && flag.NArg() > 0 {79 if *configPath == "" && flag.NArg() > 0 {
70 *configPath = flag.Arg(0)80 *configPath = flag.Arg(0)
71 }81 }
@@ -153,6 +163,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
153 AgentName: "mm",163 AgentName: "mm",
154 ModelName: config.Cfg.Model,164 ModelName: config.Cfg.Model,
155 Provider: e.Backend.Provider,165 Provider: e.Backend.Provider,
166+ Version: version.Short(),
156 Banner: banner,167 Banner: banner,
157 })168 })
158 if err == nil {169 if err == nil {
@@ -276,8 +287,11 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet
276 if toolCol == "" {287 if toolCol == "" {
277 toolCol = "none"288 toolCol = "none"
278 }289 }
279- say("%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",290+ // The version leads the line: it is the first thing anyone is asked for
280- dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)291+ // when something behaves oddly, and the banner is the one output every
292+ // front end shows — the window keeps it as the conversation's first line.
293+ say("%smm %s | config: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",
294+ dim, version.Short(), path, e.Backend.Provider, config.Cfg.Model, ctxCol, toolCol, skillCount, off)
281 }295 }
282 296
283 // windowMode decides whether the window front end runs, and where the banner297 // windowMode decides whether the window front end runs, and where the banner
added tests/agent.dmr-gemma.yaml +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+model: huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M
6+baseUrl: http://localhost:12434/engines/v1
7+fallback: http://host.docker.internal:12434/engines/v1
8+
9+bashTool: true
10+# The built-in file tools: read_file, write_file, edit_file.
11+editTools: true
12+
13+lsp:
14+ servers:
15+ - name: gopls
16+ command: gopls
17+ extensions: [".go"]
18+
19+# How many tokens the server actually serves.
20+# 0 = ask the server (llama-server tells it on /props)
21+contextWindow: 0
22+
23+# Max number of characters a tool returns to the model.
24+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
25+maxOutput: 16000
26+
27+# Max number of model <-> tools round trips for a single question.
28+# Raise it for tasks that need long chains of commands.
29+maxTurns: 40
30+
31+# Persistent sessions, per project.
32+# When enabled, every conversation is saved as one JSON file under `dir`.
33+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
34+sessions:
35+ enabled: true
36+ dir: .mm/sessions
37+
38+# How many lines of a command's output are echoed to the terminal.
39+# 0 disables it.
40+previewLines: 20
41+
42+displayCommands: true
43+
44+# The system prompt: what the agent is, and what it is allowed to do.
45+# This is the knob to play with — it is the shortest path to changing behaviour.
46+system: |
47+ Your name is Riker.
48+ You are a coding agent working in a terminal.
49+ You have a "bash" tool to run shell commands.
50+ Use it to explore files, run tests, inspect the repository, etc.
51+ Chain several commands if needed, then answer clearly in English.
52+
53+ FILE EDITING
54+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
55+ They are how a file gets read and changed here: each change is exact,
56+ checked before it is written, and comes back as a diff with line numbers.
57+ bash is for running things — building, testing, listing, searching.
58+
59+ RULES
60+ - Keep everything the file already does, unless the user asked to remove it.
61+ - Touch only the files the request is about. Do not add tests, files or
62+ features that were not asked for.
63+ - Never run a git command unless the user says git, commit or push.
64+ - Never move, rename or delete a file unless the user asked for it.
65+ - Then answer in English, in a few lines.
66+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
67+ to understand the options, then run the command.
68+
69+ BACKGROUND JOBS
70+ Never let a command block the answer. Anything that serves, watches or runs
71+ long goes to the background, with BOTH streams redirected and its pid kept:
72+
73+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
74+
75+
76+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
77+# we want precise and reproducible answers, not creativity.
78+sampling:
79+ temperature: 0.0
80+ parallel_tool_calls: false
81+ top_p: 0.9
82+ max_tokens: 4096
83+
84+# llama-server processes the whole prompt before the first token;
85+# on a laptop a 32k context can take a while. Raise it if it fires.
86+watchdogTimeout: 30s
87+
88+# Context compression.
89+# ON here: the agent knows the window (read from /props), so `threshold` has a number to apply to.
90+context:
91+ enabled: true
92+
93+ # Compress when the history reaches this share of the window.
94+ threshold: 75
95+
96+ # Fallback on the message count, for when the window is unknown.
97+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
98+ maxMessages: 80
99+
100+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
101+ # The model works on the last few turns, so summarising them would make it redo what it just did.
102+ keepLastTurns: 3
103+
104+ # max_tokens of the summary request.
105+ summaryMaxTokens: 1200
106+
107+ showStats: true
108+
new file mode 100644
@@ -0,0 +1,108 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+model: huggingface.co/unsloth/gemma-4-e2b-it-gguf:Q4_K_M
6+baseUrl: http://localhost:12434/engines/v1
7+fallback: http://host.docker.internal:12434/engines/v1
8+
9+bashTool: true
10+# The built-in file tools: read_file, write_file, edit_file.
11+editTools: true
12+
13+lsp:
14+ servers:
15+ - name: gopls
16+ command: gopls
17+ extensions: [".go"]
18+
19+# How many tokens the server actually serves.
20+# 0 = ask the server (llama-server tells it on /props)
21+contextWindow: 0
22+
23+# Max number of characters a tool returns to the model.
24+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
25+maxOutput: 16000
26+
27+# Max number of model <-> tools round trips for a single question.
28+# Raise it for tasks that need long chains of commands.
29+maxTurns: 40
30+
31+# Persistent sessions, per project.
32+# When enabled, every conversation is saved as one JSON file under `dir`.
33+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
34+sessions:
35+ enabled: true
36+ dir: .mm/sessions
37+
38+# How many lines of a command's output are echoed to the terminal.
39+# 0 disables it.
40+previewLines: 20
41+
42+displayCommands: true
43+
44+# The system prompt: what the agent is, and what it is allowed to do.
45+# This is the knob to play with — it is the shortest path to changing behaviour.
46+system: |
47+ Your name is Riker.
48+ You are a coding agent working in a terminal.
49+ You have a "bash" tool to run shell commands.
50+ Use it to explore files, run tests, inspect the repository, etc.
51+ Chain several commands if needed, then answer clearly in English.
52+
53+ FILE EDITING
54+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
55+ They are how a file gets read and changed here: each change is exact,
56+ checked before it is written, and comes back as a diff with line numbers.
57+ bash is for running things — building, testing, listing, searching.
58+
59+ RULES
60+ - Keep everything the file already does, unless the user asked to remove it.
61+ - Touch only the files the request is about. Do not add tests, files or
62+ features that were not asked for.
63+ - Never run a git command unless the user says git, commit or push.
64+ - Never move, rename or delete a file unless the user asked for it.
65+ - Then answer in English, in a few lines.
66+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
67+ to understand the options, then run the command.
68+
69+ BACKGROUND JOBS
70+ Never let a command block the answer. Anything that serves, watches or runs
71+ long goes to the background, with BOTH streams redirected and its pid kept:
72+
73+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
74+
75+
76+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
77+# we want precise and reproducible answers, not creativity.
78+sampling:
79+ temperature: 0.0
80+ parallel_tool_calls: false
81+ top_p: 0.9
82+ max_tokens: 4096
83+
84+# llama-server processes the whole prompt before the first token;
85+# on a laptop a 32k context can take a while. Raise it if it fires.
86+watchdogTimeout: 30s
87+
88+# Context compression.
89+# ON here: the agent knows the window (read from /props), so `threshold` has a number to apply to.
90+context:
91+ enabled: true
92+
93+ # Compress when the history reaches this share of the window.
94+ threshold: 75
95+
96+ # Fallback on the message count, for when the window is unknown.
97+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
98+ maxMessages: 80
99+
100+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
101+ # The model works on the last few turns, so summarising them would make it redo what it just did.
102+ keepLastTurns: 3
103+
104+ # max_tokens of the summary request.
105+ summaryMaxTokens: 1200
106+
107+ showStats: true
108+
added tests/agent.dmr-qwen.yaml +108 -0
new file mode 100644
@@ -0,0 +1,108 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+model: huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M
6+baseUrl: http://localhost:12434/engines/v1
7+fallback: http://host.docker.internal:12434/engines/v1
8+
9+bashTool: true
10+# The built-in file tools: read_file, write_file, edit_file.
11+editTools: true
12+
13+lsp:
14+ servers:
15+ - name: gopls
16+ command: gopls
17+ extensions: [".go"]
18+
19+# How many tokens the server actually serves.
20+# 0 = ask the server (llama-server tells it on /props)
21+contextWindow: 0
22+
23+# Max number of characters a tool returns to the model.
24+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
25+maxOutput: 16000
26+
27+# Max number of model <-> tools round trips for a single question.
28+# Raise it for tasks that need long chains of commands.
29+maxTurns: 40
30+
31+# Persistent sessions, per project.
32+# When enabled, every conversation is saved as one JSON file under `dir`.
33+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
34+sessions:
35+ enabled: true
36+ dir: .mm/sessions
37+
38+# How many lines of a command's output are echoed to the terminal.
39+# 0 disables it.
40+previewLines: 20
41+
42+displayCommands: true
43+
44+# The system prompt: what the agent is, and what it is allowed to do.
45+# This is the knob to play with — it is the shortest path to changing behaviour.
46+system: |
47+ Your name is Riker.
48+ You are a coding agent working in a terminal.
49+ You have a "bash" tool to run shell commands.
50+ Use it to explore files, run tests, inspect the repository, etc.
51+ Chain several commands if needed, then answer clearly in English.
52+
53+ FILE EDITING
54+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
55+ They are how a file gets read and changed here: each change is exact,
56+ checked before it is written, and comes back as a diff with line numbers.
57+ bash is for running things — building, testing, listing, searching.
58+
59+ RULES
60+ - Keep everything the file already does, unless the user asked to remove it.
61+ - Touch only the files the request is about. Do not add tests, files or
62+ features that were not asked for.
63+ - Never run a git command unless the user says git, commit or push.
64+ - Never move, rename or delete a file unless the user asked for it.
65+ - Then answer in English, in a few lines.
66+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
67+ to understand the options, then run the command.
68+
69+ BACKGROUND JOBS
70+ Never let a command block the answer. Anything that serves, watches or runs
71+ long goes to the background, with BOTH streams redirected and its pid kept:
72+
73+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
74+
75+
76+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
77+# we want precise and reproducible answers, not creativity.
78+sampling:
79+ temperature: 0.0
80+ parallel_tool_calls: false
81+ top_p: 0.9
82+ max_tokens: 4096
83+
84+# llama-server processes the whole prompt before the first token;
85+# on a laptop a 32k context can take a while. Raise it if it fires.
86+watchdogTimeout: 30s
87+
88+# Context compression.
89+# ON here: the agent knows the window (read from /props), so `threshold` has a number to apply to.
90+context:
91+ enabled: true
92+
93+ # Compress when the history reaches this share of the window.
94+ threshold: 75
95+
96+ # Fallback on the message count, for when the window is unknown.
97+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
98+ maxMessages: 80
99+
100+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
101+ # The model works on the last few turns, so summarising them would make it redo what it just did.
102+ keepLastTurns: 3
103+
104+ # max_tokens of the summary request.
105+ summaryMaxTokens: 1200
106+
107+ showStats: true
108+
new file mode 100644
@@ -0,0 +1,108 @@
1+# Agent settings.
2+#
3+provider: dmr
4+
5+model: huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M
6+baseUrl: http://localhost:12434/engines/v1
7+fallback: http://host.docker.internal:12434/engines/v1
8+
9+bashTool: true
10+# The built-in file tools: read_file, write_file, edit_file.
11+editTools: true
12+
13+lsp:
14+ servers:
15+ - name: gopls
16+ command: gopls
17+ extensions: [".go"]
18+
19+# How many tokens the server actually serves.
20+# 0 = ask the server (llama-server tells it on /props)
21+contextWindow: 0
22+
23+# Max number of characters a tool returns to the model.
24+# Context safeguard: beyond that, the output is truncated (beginning + end kept).
25+maxOutput: 16000
26+
27+# Max number of model <-> tools round trips for a single question.
28+# Raise it for tasks that need long chains of commands.
29+maxTurns: 40
30+
31+# Persistent sessions, per project.
32+# When enabled, every conversation is saved as one JSON file under `dir`.
33+# `/sessions` lists them, `/resume` continues one; in an editor, "Reload Agent" replays the thread.
34+sessions:
35+ enabled: true
36+ dir: .mm/sessions
37+
38+# How many lines of a command's output are echoed to the terminal.
39+# 0 disables it.
40+previewLines: 20
41+
42+displayCommands: true
43+
44+# The system prompt: what the agent is, and what it is allowed to do.
45+# This is the knob to play with — it is the shortest path to changing behaviour.
46+system: |
47+ Your name is Riker.
48+ You are a coding agent working in a terminal.
49+ You have a "bash" tool to run shell commands.
50+ Use it to explore files, run tests, inspect the repository, etc.
51+ Chain several commands if needed, then answer clearly in English.
52+
53+ FILE EDITING
54+ You have three tools for files: `read_file`, `edit_file` and `write_file`.
55+ They are how a file gets read and changed here: each change is exact,
56+ checked before it is written, and comes back as a diff with line numbers.
57+ bash is for running things — building, testing, listing, searching.
58+
59+ RULES
60+ - Keep everything the file already does, unless the user asked to remove it.
61+ - Touch only the files the request is about. Do not add tests, files or
62+ features that were not asked for.
63+ - Never run a git command unless the user says git, commit or push.
64+ - Never move, rename or delete a file unless the user asked for it.
65+ - Then answer in English, in a few lines.
66+ - If you don't know how to use a <cli>, run `<cli> --help` (or `<cli> help`)
67+ to understand the options, then run the command.
68+
69+ BACKGROUND JOBS
70+ Never let a command block the answer. Anything that serves, watches or runs
71+ long goes to the background, with BOTH streams redirected and its pid kept:
72+
73+ nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid
74+
75+
76+# Generation settings (OpenAI API keys). Low temperature for a coding agent:
77+# we want precise and reproducible answers, not creativity.
78+sampling:
79+ temperature: 0.0
80+ parallel_tool_calls: false
81+ top_p: 0.9
82+ max_tokens: 4096
83+
84+# llama-server processes the whole prompt before the first token;
85+# on a laptop a 32k context can take a while. Raise it if it fires.
86+watchdogTimeout: 30s
87+
88+# Context compression.
89+# ON here: the agent knows the window (read from /props), so `threshold` has a number to apply to.
90+context:
91+ enabled: true
92+
93+ # Compress when the history reaches this share of the window.
94+ threshold: 75
95+
96+ # Fallback on the message count, for when the window is unknown.
97+ # One command costs 2 messages (call + response), so 80 is roughly 30 commands of history.
98+ maxMessages: 80
99+
100+ # How many recent questions (with their commands and answers) are kept as-is instead of being summarised.
101+ # The model works on the last few turns, so summarising them would make it redo what it just did.
102+ keepLastTurns: 3
103+
104+ # max_tokens of the summary request.
105+ summaryMaxTokens: 1200
106+
107+ showStats: true
108+