🛟 Updated.
9136e38 parent: 9073268 modified
.tickets/issues/0022-add-a-version-flag.yaml +2 -2 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 22 |
| 3 | 3 | title: Add a version flag |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-17T13:35:08.191Z |
| 9 | -updatedAt: 2026-09-18T18:50:47.630Z | |
| 9 | +updatedAt: 2026-09-20T03:20:04.331Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.3 |
| 12 | 12 | 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: 22 | 2 | id: 22 |
| 3 | title: Add a version flag | 3 | title: Add a version flag |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-17T13:35:08.191Z | 8 | createdAt: 2026-09-17T13:35:08.191Z |
| 9 | -updatedAt: 2026-09-18T18:50:47.630Z | 9 | +updatedAt: 2026-09-20T03:20:04.331Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.3 | 11 | 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: | ||
| 40 | 40 | color: "#fbca04" |
| 41 | 41 | - name: builtin tool |
| 42 | 42 | color: "#5319e7" |
| 43 | + - name: To be tested | |
| 44 | + color: "#e99695" | |
| @@ -40,3 +40,5 @@ labels: | |||
| 40 | color: "#fbca04" | 40 | color: "#fbca04" |
| 41 | - name: builtin tool | 41 | - 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: | ||
| 6 | 6 | - title: v0.0.3 |
| 7 | 7 | state: open |
| 8 | 8 | 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.3 | 6 | - title: v0.0.3 |
| 7 | state: open | 7 | state: open |
| 8 | dueOn: 2026-09-30 | 8 | 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}" | ||
| 52 | 52 | RELEASES_DIR="release/${TAG}" |
| 53 | 53 | |
| 54 | 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 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}") | |
| 59 | 60 | |
| 60 | 61 | echo "🚀 Building Mini Me ${TAG} — ${ABOUT}" |
| 61 | 62 | echo "🐹 $(go version)" |
| @@ -102,7 +103,15 @@ if [ -x "${RELEASES_DIR}/${HOST_ASSET}" ]; then | ||
| 102 | 103 | echo " ❌ ${HOST_ASSET} does not start (mm -h failed)" |
| 103 | 104 | exit 1 |
| 104 | 105 | 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 | 115 | fi |
| 107 | 116 | |
| 108 | 117 | # 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 no | 55 | +# -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 the | 56 | +# 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 1 | 104 | exit 1 |
| 104 | fi | 105 | 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 | fi | 115 | fi |
| 107 | 116 | ||
| 108 | # checksum runs whichever of the two tools this machine has: sha256sum on | 117 | # 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 | ||
| 26 | 26 | git clone https://git.rickub.com/bots-garden/mini-me.git |
| 27 | 27 | cd mini-me |
| 28 | 28 | ./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 | |
| 30 | 31 | ``` |
| 31 | 32 | > `install.sh` works only on macOS and Linux |
| 32 | 33 | |
| @@ -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.git | 26 | git clone https://git.rickub.com/bots-garden/mini-me.git |
| 27 | cd mini-me | 27 | cd mini-me |
| 28 | ./install.sh # go build, then copies mm to /usr/local/bin | 28 | ./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 | ||
| 30 | ``` | 31 | ``` |
| 31 | > `install.sh` works only on macOS and Linux | 32 | > `install.sh` works only on macOS and Linux |
| 32 | 33 | ||
modified
demo/agent.lsp.yaml +6 -1 | @@ -10,6 +10,9 @@ | ||
| 10 | 10 | provider: dmr |
| 11 | 11 | |
| 12 | 12 | 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 | 16 | baseUrl: http://localhost:12434/engines/v1 |
| 14 | 17 | fallback: http://host.docker.internal:12434/engines/v1 |
| 15 | 18 | |
| @@ -24,7 +27,9 @@ maxOutput: 16000 | ||
| 24 | 27 | |
| 25 | 28 | # Max number of model <-> tools round trips for a single question. |
| 26 | 29 | # Raise it for tasks that need long chains of commands. |
| 27 | -maxTurns: 40 | |
| 30 | +#maxTurns: 40 | |
| 31 | +maxTurns: 200 | |
| 32 | + | |
| 28 | 33 | |
| 29 | 34 | # The `bash` tool. |
| 30 | 35 | # `false` removes it: the model can still read and edit files (editTools) and load skills, |
| @@ -10,6 +10,9 @@ | |||
| 10 | provider: dmr | 10 | provider: dmr |
| 11 | 11 | ||
| 12 | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | 12 | 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/v1 | 16 | baseUrl: http://localhost:12434/engines/v1 |
| 14 | fallback: http://host.docker.internal:12434/engines/v1 | 17 | 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: 40 | 30 | +#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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" 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="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" 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="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" 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="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" 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="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" 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="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" 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="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" 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="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" 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="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" 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="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools, lsp</font>" 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="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" 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="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" 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="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" 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="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" 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="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" 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="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" 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="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" 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="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" 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="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" 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="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" 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="<b>internal/version</b><br/><font style="font-size:10px">which mm this is: build stamp, else the commit</font>" 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="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" 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="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" 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="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" 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="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" 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="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" 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="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" 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="<b>internal/lsp</b><br/><font style="font-size:10px">language-server client: definition, references, hover, symbols, diagnostics, rename</font>" 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="<b>go.lsp.dev/protocol + jsonrpc2</b><br/><font style="font-size:10px">LSP types and JSON-RPC 2.0 over stdio</font>" 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="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." 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 | ||
| 21 | 21 | | `detector` | Detection of the same action repeated with the same result. | |
| 22 | 22 | | `compact` | Compression of the history into a model-written summary. | |
| 23 | 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 | 25 | | `ui` | Where human-facing output goes, and the event sink the front ends share. | |
| 25 | 26 | | `agent` | The terminal REPL. | |
| 26 | 27 | | `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/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 | ||
| 20 | 20 | |
| 21 | 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 | 27 | ## Variants |
| 26 | 28 | |
| @@ -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 | ## Variants | 27 | ## Variants |
| 26 | 28 | ||
modified
docs/en/reference/cli.md +22 -4 | @@ -5,7 +5,7 @@ | ||
| 5 | 5 | ## Invocation |
| 6 | 6 | |
| 7 | 7 | ``` |
| 8 | -mm [-config <path>] [-acp] [<path>] | |
| 8 | +mm [-config <path>] [-acp] [-tui] [--version] [<path>] | |
| 9 | 9 | ``` |
| 10 | 10 | |
| 11 | 11 | | Element | Type | Default | Description | |
| @@ -14,8 +14,26 @@ mm [-config <path>] [-acp] [<path>] | ||
| 14 | 14 | | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. | |
| 15 | 15 | | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. | |
| 16 | 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 | 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 | 37 | ## Configuration file resolution |
| 20 | 38 | |
| 21 | 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 | 62 | | Line | Content | |
| 45 | 63 | |------|---------| |
| 46 | 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 | 66 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 49 | 67 | | `Shortcut: Ctrl+C …` | Terminal mode only. | |
| 50 | 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 | 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: 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 | |
| 57 | 75 | ``` |
| 58 | 76 | |
| 59 | 77 | ## ACP mode specifics |
| @@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b- | ||
| 74 | 92 | | Screen | The terminal's alternate screen, released on exit; the terminal's own scrollback is not written to. | |
| 75 | 93 | | Banner | Not printed on stdout; shown as the first line of the conversation. | |
| 76 | 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 | 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 | 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 | 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. | |
| @@ -5,7 +5,7 @@ | |||
| 5 | ## Invocation | 5 | ## 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 resolution | 37 | ## 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: 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 |
| 57 | ``` | 75 | ``` |
| 58 | 76 | ||
| 59 | ## ACP mode specifics | 77 | ## 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- | ||
| 41 | 41 | |
| 42 | 42 | ## 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 | 50 | | Key | Action | |
| 47 | 51 | |-----|--------| |
| 48 | 52 | | `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. | |
| 49 | 53 | | `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. | | |
| 52 | 56 | | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. | |
| 53 | 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 | 58 | | `PgUp` `PgDn` | Scroll one page. | |
| @@ -61,6 +65,22 @@ In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No gener | ||
| 61 | 65 | | Mouse wheel | Scrolls the conversation — only while the mouse is captured (F2). | |
| 62 | 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 | 84 | ## Messages printed at the end of a turn |
| 65 | 85 | |
| 66 | 86 | | 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 keys | 42 | ## 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 turn | 84 | ## 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). | ||
| 15 | 15 | | Shell | `bash -c <command>`, a new shell per call, no stdin. | |
| 16 | 16 | | Working directory | The process directory in terminal mode; the session `cwd` in ACP mode. | |
| 17 | 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 | 21 | | Output | stdout and stderr merged, truncated to `maxOutput` characters. | |
| 19 | 22 | | Non-zero exit | Output ends with `[exit code: exit status N]`. | |
| 20 | 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 | 83 | | `path` | string | — | Path of an existing file. | |
| 81 | 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 | 86 | | `edits[].new` | string | — | Replacement; empty deletes the matched text. | |
| 84 | 87 | | `dry_run` | bool | `false` | Report what would change, write nothing. | |
| 85 | 88 | |
| @@ -92,9 +95,41 @@ Declared when `editTools: true`. | ||
| 92 | 95 | |
| 93 | 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 | 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 | 133 | ## `lsp` |
| 99 | 134 | |
| 100 | 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. |
| @@ -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: | ||
| 32 | 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: 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 | |
| 36 | 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 | 37 | Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. |
| 38 | 38 | |
| @@ -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: 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 |
| 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 | ||
| 20 | 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 | 21 | | `detector` | La détection d'une même action répétée avec le même résultat. | |
| 22 | 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 | 24 | | `spinner` | L'indicateur « toujours en cours » sur un terminal. | |
| 24 | 25 | | `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. | |
| 25 | 26 | | `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/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 | ||
| 20 | 20 | |
| 21 | 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 | 27 | ## Variantes |
| 26 | 28 | |
| @@ -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 | ## Variantes | 27 | ## Variantes |
| 26 | 28 | ||
modified
docs/fr/reference/cli.md +22 -4 | @@ -5,7 +5,7 @@ | ||
| 5 | 5 | ## Invocation |
| 6 | 6 | |
| 7 | 7 | ``` |
| 8 | -mm [-config <chemin>] [-acp] [<chemin>] | |
| 8 | +mm [-config <chemin>] [-acp] [-tui] [--version] [<chemin>] | |
| 9 | 9 | ``` |
| 10 | 10 | |
| 11 | 11 | | Élément | Type | Défaut | Description | |
| @@ -14,8 +14,26 @@ mm [-config <chemin>] [-acp] [<chemin>] | ||
| 14 | 14 | | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. | |
| 15 | 15 | | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. | |
| 16 | 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 | 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 | 37 | ## Résolution du fichier de configuration |
| 20 | 38 | |
| 21 | 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 | 62 | | Ligne | Contenu | |
| 45 | 63 | |-------|---------| |
| 46 | 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 | 66 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 49 | 67 | | `Shortcut: Ctrl+C …` | Mode terminal seulement. | |
| 50 | 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 | 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: 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 | |
| 57 | 75 | ``` |
| 58 | 76 | |
| 59 | 77 | ## Spécificités du mode ACP |
| @@ -74,7 +92,7 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b- | ||
| 74 | 92 | | Écran | L'écran alternatif du terminal, rendu à la sortie ; l'historique de défilement du terminal n'est pas écrit. | |
| 75 | 93 | | Bannière | Non affichée sur stdout ; montrée comme première ligne de la conversation. | |
| 76 | 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 | 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 | 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 | 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. | |
| @@ -5,7 +5,7 @@ | |||
| 5 | ## Invocation | 5 | ## 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 configuration | 37 | ## 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: 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 |
| 57 | ``` | 75 | ``` |
| 58 | 76 | ||
| 59 | ## Spécificités du mode ACP | 77 | ## 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 | ||
| 41 | 41 | |
| 42 | 42 | ## 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 | 50 | | Touche | Action | |
| 47 | 51 | |--------|--------| |
| 48 | 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 | 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. | | |
| 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. | | |
| 52 | 56 | | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. | |
| 53 | 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 | 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 | 65 | | Molette | Fait défiler la conversation — seulement quand la souris est capturée (F2). | |
| 62 | 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 | 84 | ## Messages affichés en fin de tour |
| 65 | 85 | |
| 66 | 86 | | 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être | 42 | ## 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 tour | 84 | ## 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). | ||
| 15 | 15 | | Shell | `bash -c <command>`, un nouveau shell par appel, pas de stdin. | |
| 16 | 16 | | Répertoire de travail | Le répertoire du processus en mode terminal ; le `cwd` de la session en mode ACP. | |
| 17 | 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 | 21 | | Sortie | stdout et stderr fusionnés, tronqués à `maxOutput` caractères. | |
| 19 | 22 | | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. | |
| 20 | 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 | 83 | | `path` | chaîne | — | Chemin d'un fichier existant. | |
| 81 | 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 | 86 | | `edits[].new` | chaîne | — | Remplacement ; vide supprime le texte trouvé. | |
| 84 | 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 | 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 | 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 | 133 | ## `lsp` |
| 99 | 134 | |
| 100 | 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. |
| @@ -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 : | ||
| 32 | 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: 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 | |
| 36 | 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 | 37 | Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. |
| 38 | 38 | |
| @@ -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: 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 |
| 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 @@ | ||
| 1 | 1 | package fileedit |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "errors" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "sort" |
| 6 | 7 | "strconv" |
| @@ -14,10 +15,14 @@ type Edit struct { | ||
| 14 | 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 | 21 | type span struct { |
| 19 | 22 | start, end int |
| 20 | 23 | 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 | 28 | // apply applies every edit to the body and returns the result. |
| @@ -29,16 +34,20 @@ type span struct { | ||
| 29 | 34 | // overlapping regions — would give a result that depends on application order, |
| 30 | 35 | // hence a file nobody has read. Nothing is written when one edit fails: a |
| 31 | 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 | 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 | 43 | spans := make([]span, 0, len(list)) |
| 38 | 44 | for i, e := range list { |
| 39 | 45 | s, err := locate(body, e, i) |
| 40 | 46 | if err != nil { |
| 41 | - return "", err | |
| 47 | + return "", 0, err | |
| 48 | + } | |
| 49 | + if s.loose { | |
| 50 | + loose++ | |
| 42 | 51 | } |
| 43 | 52 | spans = append(spans, s) |
| 44 | 53 | } |
| @@ -49,7 +58,7 @@ func apply(body string, list []Edit) (string, error) { | ||
| 49 | 58 | for i := 1; i < len(spans); i++ { |
| 50 | 59 | if spans[i].start < spans[i-1].end { |
| 51 | 60 | a, b := spans[i-1], spans[i] |
| 52 | - return "", fmt.Errorf( | |
| 61 | + return "", 0, fmt.Errorf( | |
| 53 | 62 | "edits #%d and #%d overlap (lines %d-%d and %d-%d): merge them into a single old/new pair", |
| 54 | 63 | a.index+1, b.index+1, |
| 55 | 64 | lineOf(body, a.start), lineOf(body, a.end), |
| @@ -61,11 +70,11 @@ func apply(body string, list []Edit) (string, error) { | ||
| 61 | 70 | at := 0 |
| 62 | 71 | for _, s := range spans { |
| 63 | 72 | b.WriteString(body[at:s.start]) |
| 64 | - b.WriteString(list[s.index].New) | |
| 73 | + b.WriteString(s.text) | |
| 65 | 74 | at = s.end |
| 66 | 75 | } |
| 67 | 76 | b.WriteString(body[at:]) |
| 68 | - return b.String(), nil | |
| 77 | + return b.String(), loose, nil | |
| 69 | 78 | } |
| 70 | 79 | |
| 71 | 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 | 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); { | |
| 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 | |
| 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) | |
| 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) | |
| 98 | 119 | } |
| 99 | 120 | |
| 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 { | |
| 103 | 124 | 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))) | |
| 112 | 127 | } |
| 113 | 128 | return strings.Join(lines, ", ") |
| 114 | 129 | } |
| @@ -137,3 +152,23 @@ func quote(s string) string { | ||
| 137 | 152 | } |
| 138 | 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 | +} | |
| @@ -1,6 +1,7 @@ | |||
| 1 | package fileedit | 1 | 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 int | 22 | start, end int |
| 20 | index int // rank of the edit, for error messages | 23 | 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: a | 35 | // 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 "", err | 47 | + 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 := 0 | 70 | 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.end | 74 | at = s.end |
| 66 | } | 75 | } |
| 67 | b.WriteString(body[at:]) | 76 | b.WriteString(body[at:]) |
| 68 | - return b.String(), nil | 77 | + 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}, nil | 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) | ||
| 98 | } | 119 | } |
| 99 | 120 | ||
| 100 | -// occurrenceLines lists the 1-based lines where text occurs in body, as | 121 | +// 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 []string | 124 | 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 ( | ||
| 10 | 10 | // patch, the first changed line. Headline + Diff is what goes back to the |
| 11 | 11 | // model; the caller decides what the screen shows. |
| 12 | 12 | 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 | |
| 18 | 22 | Added, Deleted int |
| 19 | 23 | FirstChangedLine int |
| 20 | 24 | Headline string |
| @@ -112,13 +116,13 @@ func Replace(path string, edits []Edit, dryRun bool) (Result, error) { | ||
| 112 | 116 | if f.isNew { |
| 113 | 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 | 120 | if err != nil { |
| 117 | 121 | // The error carries the file name: it is read out of context, in a |
| 118 | 122 | // tool result among others. |
| 119 | 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 | 128 | // finish compares, writes if needed, and reports. Write and Replace both end |
| @@ -159,6 +163,9 @@ func headline(r Result) string { | ||
| 159 | 163 | if r.Edits > 0 { |
| 160 | 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 | 169 | if r.DryRun { |
| 163 | 170 | return fmt.Sprintf("%s: dry run — nothing written (%s)", r.Path, what) |
| 164 | 171 | } |
| @@ -10,11 +10,15 @@ import ( | |||
| 10 | // patch, the first changed line. Headline + Diff is what goes back to the | 10 | // 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 string | 13 | + Path string |
| 14 | - Created bool | 14 | + Created bool |
| 15 | - Changed bool | 15 | + Changed bool |
| 16 | - DryRun bool | 16 | + DryRun bool |
| 17 | - Edits int | 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 | ||
| 18 | Added, Deleted int | 22 | Added, Deleted int |
| 19 | FirstChangedLine int | 23 | FirstChangedLine int |
| 20 | Headline string | 24 | 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 a | 121 | // 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 end | 128 | // 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) { | ||
| 53 | 53 | } |
| 54 | 54 | for _, tc := range cases { |
| 55 | 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 | 57 | if err != nil { |
| 58 | 58 | t.Fatalf("apply: %v", err) |
| 59 | 59 | } |
| @@ -85,7 +85,7 @@ func TestApplyRefuses(t *testing.T) { | ||
| 85 | 85 | } |
| 86 | 86 | for _, tc := range cases { |
| 87 | 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 | 89 | if err == nil { |
| 90 | 90 | t.Fatalf("apply succeeded, want an error — result %q", got) |
| 91 | 91 | } |
| @@ -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 { | ||
| 144 | 144 | func EditFile(g *genkit.Genkit, d *detector.LoopDetector) ai.ToolRef { |
| 145 | 145 | description := `Change an existing file by EXACT text replacement — the way to modify a file without rewriting it. |
| 146 | 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 | 148 | 2. old must appear EXACTLY ONCE. If it appears twice the tool refuses: add the surrounding lines until the text is unique. |
| 149 | 149 | 3. Several edits are resolved against the ORIGINAL file, all at once; a replacement never sees what another one wrote. |
| 150 | 150 | 4. Two edits must not overlap or repeat: if two changes touch the same block, merge them into one edit. |
| 151 | 151 | 5. An empty new deletes the matched text. |
| 152 | 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 | 155 | return genkit.DefineTool(g, "edit_file", description, |
| 156 | 156 | 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 | ||
| 8 | 8 | |
| 9 | 9 | import ( |
| 10 | 10 | "context" |
| 11 | + "errors" | |
| 11 | 12 | "fmt" |
| 13 | + "io" | |
| 14 | + "os" | |
| 12 | 15 | "os/exec" |
| 13 | 16 | "strings" |
| 14 | 17 | "sync" |
| @@ -102,28 +105,97 @@ func echoOutput(out string) { | ||
| 102 | 105 | printMu.Unlock() |
| 103 | 106 | } |
| 104 | 107 | |
| 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) | |
| 111 | 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 | 159 | cmd := exec.CommandContext(ctx, "bash", "-c", command) |
| 114 | 160 | 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): | |
| 119 | 170 | 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: | |
| 122 | 174 | return output + fmt.Sprintf("\n[exit code: %v]", err), true |
| 123 | 175 | } |
| 124 | 176 | 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 | 199 | // Bash declares the `bash` tool. |
| 128 | 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 | 228 | // like a hung agent. Retitle for the duration, then hand the line |
| 157 | 229 | // back to the "Thinking" wait Suspend restored. |
| 158 | 230 | spinner.Start("Running") |
| 159 | - out, _ := runBash("", in.Command) | |
| 231 | + out, _ := runBash(tctx, "", in.Command) | |
| 160 | 232 | |
| 161 | 233 | // The output is shown to the user BEFORE going back to the model: |
| 162 | 234 | // 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 | 278 | s.ToolRunning(ev.ID) |
| 207 | 279 | // The session's working directory, not the process's: in an editor, the |
| 208 | 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 | 282 | s.ToolEnd(ev.ID, ui.ToolResult{Output: out, Failed: failed}) |
| 211 | 283 | |
| 212 | 284 | 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, but | 110 | +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 = dir | 160 | 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]", true | 170 | 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), true | 174 | return output + fmt.Sprintf("\n[exit code: %v]", err), true |
| 123 | } | 175 | } |
| 124 | return output, false | 176 | 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 line | 228 | // 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 model | 234 | // 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, the | 279 | // 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) { | ||
| 164 | 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 | 168 | func (v *View) drawStatus(p painter) { |
| 169 | 169 | p.fill(' ', stBar) |
| 170 | 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 | 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 | 197 | // 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 row | 197 | // 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 { | ||
| 104 | 104 | func (v *View) handleWindowKey(ev *tcell.EventKey) bool { |
| 105 | 105 | switch { |
| 106 | 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 | 112 | if v.busy == "" || !v.cancelTurn() { |
| 109 | 113 | v.on.quit() |
| 110 | 114 | } |
| 111 | 115 | case ev.Key() == tcell.KeyEscape: |
| 112 | - v.cancelTurn() | |
| 116 | + v.abortTurn() | |
| 113 | 117 | case ev.Key() == tcell.KeyCtrlL: |
| 114 | 118 | v.clearScreen() |
| 115 | 119 | case ev.Key() == tcell.KeyF1: |
| @@ -138,11 +142,22 @@ func (v *View) handleWindowKey(ev *tcell.EventKey) bool { | ||
| 138 | 142 | 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 | 147 | func (v *View) cancelTurn() bool { |
| 143 | 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 | 161 | // handleInputKey edits what is being typed. |
| 147 | 162 | func (v *View) handleInputKey(ev *tcell.EventKey) bool { |
| 148 | 163 | 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 true | 142 | 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 { | ||
| 67 | 67 | v := newView(screen, t, opts.AgentName, hooks{ |
| 68 | 68 | send: r.submit, |
| 69 | 69 | cancel: r.cancelTurn, |
| 70 | + abort: r.abort, | |
| 70 | 71 | quit: r.quitNow, |
| 71 | 72 | help: r.help, |
| 72 | 73 | answer: r.answer, |
| @@ -128,6 +129,7 @@ func (a *app) loop(ctx context.Context) error { | ||
| 128 | 129 | func (a *app) frame() { |
| 129 | 130 | a.r.settle() |
| 130 | 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 | 133 | a.v.draw() |
| 132 | 134 | } |
| 133 | 135 | |
| @@ -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 ( | ||
| 7 | 7 | "sync" |
| 8 | 8 | "sync/atomic" |
| 9 | 9 | |
| 10 | - "mm/internal/command" | |
| 11 | 10 | "mm/internal/compact" |
| 12 | 11 | "mm/internal/config" |
| 13 | 12 | "mm/internal/detector" |
| @@ -44,6 +43,10 @@ type Options struct { | ||
| 44 | 43 | AgentName string // "mm" |
| 45 | 44 | ModelName string |
| 46 | 45 | 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 | 50 | // Banner is the start-up text main printed (or would have); shown as |
| 48 | 51 | // the first notice, since stdout is the window now. |
| 49 | 52 | Banner string |
| @@ -90,8 +93,12 @@ type runner struct { | ||
| 90 | 93 | busy atomic.Value // string: "", "thinking", "compressing" |
| 91 | 94 | quit atomic.Bool |
| 92 | 95 | 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 | |
| 95 | 102 | |
| 96 | 103 | askMu sync.Mutex |
| 97 | 104 | ask *permission |
| @@ -123,90 +130,6 @@ func (r *runner) busyLabel() string { return r.busy.Load().(string) } | ||
| 123 | 130 | // isBusy reports whether a job is running. |
| 124 | 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 | 133 | // startTurn appends the question and generates on a goroutine. The |
| 211 | 134 | // automatic compaction runs there too, before the question is appended, so |
| 212 | 135 | // the window stays responsive while the summary is written. |
| @@ -224,6 +147,7 @@ func (r *runner) startTurn(p Prompt) { | ||
| 224 | 147 | } |
| 225 | 148 | ctx, cancel := context.WithCancel(context.Background()) |
| 226 | 149 | r.cancel = cancel |
| 150 | + r.cancelled.Store(false) | |
| 227 | 151 | r.busy.Store("thinking") |
| 228 | 152 | r.streamed.Store(false) |
| 229 | 153 | msgs := append([]*ai.Message(nil), r.messages...) |
| @@ -282,6 +206,7 @@ func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, | ||
| 282 | 206 | func (r *runner) startCompact() { |
| 283 | 207 | ctx, cancel := context.WithCancel(context.Background()) |
| 284 | 208 | r.cancel = cancel |
| 209 | + r.cancelled.Store(false) | |
| 285 | 210 | r.busy.Store("compressing") |
| 286 | 211 | msgs := append([]*ai.Message(nil), r.messages...) |
| 287 | 212 | go func() { |
| @@ -309,6 +234,7 @@ func (r *runner) settle() { | ||
| 309 | 234 | } |
| 310 | 235 | r.busy.Store("") |
| 311 | 236 | r.cancel = nil |
| 237 | + r.cancelled.Store(false) | |
| 312 | 238 | if p := r.pending; p.Typed != "" { |
| 313 | 239 | r.pending = Prompt{} |
| 314 | 240 | r.dispatch(p) |
| @@ -421,7 +347,9 @@ func (r *runner) header() string { | ||
| 421 | 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 | 353 | func (r *runner) status() string { |
| 426 | 354 | return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools()) |
| 427 | 355 | } |
| @@ -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 string | 44 | ModelName string |
| 46 | Provider string | 45 | 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 as | 50 | // 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 string | 52 | 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.Bool | 94 | quit atomic.Bool |
| 92 | cancel context.CancelFunc | 95 | cancel context.CancelFunc |
| 93 | - results chan outcome | 96 | + // cancelled says the job in progress has already been told to stop. A |
| 94 | - pending Prompt // input to run once the cancelled turn has settled | 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 | ||
| 95 | 102 | ||
| 96 | askMu sync.Mutex | 103 | askMu sync.Mutex |
| 97 | ask *permission | 104 | 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. The | 133 | // startTurn appends the question and generates on a goroutine. The |
| 211 | // automatic compaction runs there too, before the question is appended, so | 134 | // 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 = cancel | 149 | 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 = cancel | 208 | 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 = nil | 236 | 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 ( | ||
| 24 | 24 | type fakeModel struct { |
| 25 | 25 | answer string |
| 26 | 26 | 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 | |
| 31 | 36 | } |
| 32 | 37 | |
| 33 | 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 | 44 | <-ctx.Done() |
| 36 | 45 | return nil, nil, ctx.Err() |
| 37 | 46 | } |
| @@ -425,3 +434,87 @@ func TestRunnerFailureIsRecordedInTheSession(t *testing.T) { | ||
| 425 | 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 | +} | |
| @@ -24,14 +24,23 @@ import ( | |||
| 24 | type fakeModel struct { | 24 | type fakeModel struct { |
| 25 | answer string | 25 | answer string |
| 26 | blockFirst bool | 26 | blockFirst bool |
| 27 | - fail error | 27 | + // deaf makes the first call block for good, context or not: the turn |
| 28 | - summary string | 28 | + // that will not settle (ticket 26 — a bash tool blocked on a pipe a |
| 29 | - tokens int | 29 | + // background process holds open, an engine that stopped answering). |
| 30 | - calls atomic.Int32 | 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 | ||
| 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 { | ||
| 40 | 40 | // send hands a finished input over: a question or a slash command, with |
| 41 | 41 | // the pastes collapsed in the box behind their placeholders. |
| 42 | 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 | 46 | cancel func() bool |
| 47 | + // abort is Escape and /abort: cancel, and say what happened. | |
| 48 | + abort func() | |
| 45 | 49 | // quit ends the window. |
| 46 | 50 | quit func() |
| 47 | 51 | // help is F1. |
| @@ -64,10 +68,11 @@ type View struct { | ||
| 64 | 68 | previewLines int |
| 65 | 69 | |
| 66 | 70 | // 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 | |
| 71 | 76 | |
| 72 | 77 | // input is what has been typed but not sent, as lines. A long paste is |
| 73 | 78 | // 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, with | 40 | // 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() bool | 46 | 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 int | 68 | 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 line | 71 | + header string // the header line |
| 68 | - status string // right-hand side of the status bar | 72 | + 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 *permission | 74 | + busy string // "", "thinking", "compressing" |
| 75 | + ask *permission | ||
| 71 | 76 | ||
| 72 | // input is what has been typed but not sent, as lines. A long paste is | 77 | // 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) { | ||
| 515 | 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 | +} | |
| @@ -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 ( | ||
| 46 | 46 | "mm/internal/tools" |
| 47 | 47 | "mm/internal/tui" |
| 48 | 48 | "mm/internal/ui" |
| 49 | + "mm/internal/version" | |
| 49 | 50 | |
| 50 | 51 | "github.com/firebase/genkit/go/ai" |
| 51 | 52 | ) |
| @@ -65,7 +66,16 @@ func main() { | ||
| 65 | 66 | configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)") |
| 66 | 67 | acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL") |
| 67 | 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 | 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 | 79 | if *configPath == "" && flag.NArg() > 0 { |
| 70 | 80 | *configPath = flag.Arg(0) |
| 71 | 81 | } |
| @@ -153,6 +163,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | ||
| 153 | 163 | AgentName: "mm", |
| 154 | 164 | ModelName: config.Cfg.Model, |
| 155 | 165 | Provider: e.Backend.Provider, |
| 166 | + Version: version.Short(), | |
| 156 | 167 | Banner: banner, |
| 157 | 168 | }) |
| 158 | 169 | if err == nil { |
| @@ -276,8 +287,11 @@ func printBanner(ctx context.Context, e *engine.Engine, path string, set toolSet | ||
| 276 | 287 | if toolCol == "" { |
| 277 | 288 | toolCol = "none" |
| 278 | 289 | } |
| 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) | |
| 281 | 295 | } |
| 282 | 296 | |
| 283 | 297 | // 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 banner | 297 | // 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 | + | ||