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

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

Download ZIP

💾 Saved.

k33g committed 2026-09-16T08:42:05+02:00 Browse files
45c23b4 parent: d722711
modified .gitignore +2 -0
@@ -2,3 +2,5 @@ kits
22 notes.txt
33 tmp
44 mm
5+.mm/
6+blog
@@ -2,3 +2,5 @@ kits
2 notes.txt2 notes.txt
3 tmp3 tmp
4 mm4 mm
5+.mm/
6+blog
added .memory/handoffs/2026-09-16-blog-acp.md +23 -0
new file mode 100644
@@ -0,0 +1,23 @@
1+# Handoff — 2026-09-16 — blog articles on ACP (agent "milo")
2+
3+## Where it stopped
4+
5+Two French articles are complete in `blog/`, plus the tutorial's Go module in `blog/fake-agent/` (its own `go.mod`, so it is invisible to mini-me's build). Nothing committed.
6+
7+## Facts established (from the web, 2026-09-16)
8+
9+- ACP was created by Zed Industries, announced 2025-08-27 with Google (Gemini CLI as reference implementation); Claude Code beta in Zed on 2025-09-03; JetBrains announced co-development and support across its IDEs on 2025-10-06. Protocol version 1. Apache 2.0. Official SDKs: Rust, TypeScript, Kotlin, Java, Python; Go SDK (`coder/acp-go-sdk`) is community.
10+- The registry at agentclientprotocol.com lists ~40 agents and ~20 clients; both tables in article 1 are snapshots of it and say so.
11+
12+## Traps for the tutorial's readers (already in the article)
13+
14+- `available_commands_update` must be sent after the `session/new` response (Zed drops it otherwise); the tutorial uses `time.AfterFunc(50ms)` and points at mini-me's `responseWriter` for the robust version.
15+- Zed's `agent_servers` entry needs `"type": "custom"`.
16+- Keep stdin open (`sleep`) when piping JSON-RPC by hand.
17+- In acp-go-sdk v0.13.5, `LoadSession` is in a separate `AgentLoader` interface, not in `Agent`.
18+
19+## To double-check by the user
20+
21+- The agent/editor tables against the live registry before publishing.
22+- Rendering of the mermaid diagram on the target blog platform.
23+- Whether to publish `blog/fake-agent/go.sum` (generated in the sandbox).
new file mode 100644
@@ -0,0 +1,23 @@
1+# Handoff — 2026-09-16 — blog articles on ACP (agent "milo")
2+
3+## Where it stopped
4+
5+Two French articles are complete in `blog/`, plus the tutorial's Go module in `blog/fake-agent/` (its own `go.mod`, so it is invisible to mini-me's build). Nothing committed.
6+
7+## Facts established (from the web, 2026-09-16)
8+
9+- ACP was created by Zed Industries, announced 2025-08-27 with Google (Gemini CLI as reference implementation); Claude Code beta in Zed on 2025-09-03; JetBrains announced co-development and support across its IDEs on 2025-10-06. Protocol version 1. Apache 2.0. Official SDKs: Rust, TypeScript, Kotlin, Java, Python; Go SDK (`coder/acp-go-sdk`) is community.
10+- The registry at agentclientprotocol.com lists ~40 agents and ~20 clients; both tables in article 1 are snapshots of it and say so.
11+
12+## Traps for the tutorial's readers (already in the article)
13+
14+- `available_commands_update` must be sent after the `session/new` response (Zed drops it otherwise); the tutorial uses `time.AfterFunc(50ms)` and points at mini-me's `responseWriter` for the robust version.
15+- Zed's `agent_servers` entry needs `"type": "custom"`.
16+- Keep stdin open (`sleep`) when piping JSON-RPC by hand.
17+- In acp-go-sdk v0.13.5, `LoadSession` is in a separate `AgentLoader` interface, not in `Agent`.
18+
19+## To double-check by the user
20+
21+- The agent/editor tables against the live registry before publishing.
22+- Rendering of the mermaid diagram on the target blog platform.
23+- Whether to publish `blog/fake-agent/go.sum` (generated in the sandbox).
added .memory/handoffs/2026-09-16-context-compression-review.md +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+# Handoff — 2026-09-16 — context compression review (agent "riker")
2+
3+## Where it stopped
4+
5+Read-only review; the result is the body of `.tickets/issues/0010-check-how-the-context-compression-is-working.yaml` (mechanism, triggers, defaults, before/after, estimate limits, failure paths, config table, gaps, doc check, cagent comparison). Nothing else was written.
6+
7+## Facts a future session should not re-derive
8+
9+- Automatic compaction runs only in `internal/agent/agent.go` (single `compact.Decide` call site). ACP has `/compact` only.
10+- `engine.Summarize` uses a watchdog of `2 × watchdogTimeout` (40 s default), `max_tokens = context.summaryMaxTokens`, and drops `parallel_tool_calls`.
11+- The summary pair is tagged `Metadata["compact"] = "summary"`; `compact.IsSummary` recognises it and a later compaction merges rather than re-summarises.
12+
13+## Open items (for the user to decide)
14+
15+1. Add automatic compaction to `acp.Prompt` (same rule as the REPL; helper `compactHistory` already exists in `internal/acp/acp.go`).
16+2. Fix the "before the first question" wording in `docs/{en,fr}/how-to/manage-the-context-window.md` and say in `explanation/context-compression.md` that the automatic trigger is terminal-only (until 1 is done).
17+3. Persistent sessions (bob, in flight): store the post-compaction history (rewrite, not append); decide how the summary pair is replayed on `session/load`.
new file mode 100644
@@ -0,0 +1,17 @@
1+# Handoff — 2026-09-16 — context compression review (agent "riker")
2+
3+## Where it stopped
4+
5+Read-only review; the result is the body of `.tickets/issues/0010-check-how-the-context-compression-is-working.yaml` (mechanism, triggers, defaults, before/after, estimate limits, failure paths, config table, gaps, doc check, cagent comparison). Nothing else was written.
6+
7+## Facts a future session should not re-derive
8+
9+- Automatic compaction runs only in `internal/agent/agent.go` (single `compact.Decide` call site). ACP has `/compact` only.
10+- `engine.Summarize` uses a watchdog of `2 × watchdogTimeout` (40 s default), `max_tokens = context.summaryMaxTokens`, and drops `parallel_tool_calls`.
11+- The summary pair is tagged `Metadata["compact"] = "summary"`; `compact.IsSummary` recognises it and a later compaction merges rather than re-summarises.
12+
13+## Open items (for the user to decide)
14+
15+1. Add automatic compaction to `acp.Prompt` (same rule as the REPL; helper `compactHistory` already exists in `internal/acp/acp.go`).
16+2. Fix the "before the first question" wording in `docs/{en,fr}/how-to/manage-the-context-window.md` and say in `explanation/context-compression.md` that the automatic trigger is terminal-only (until 1 is done).
17+3. Persistent sessions (bob, in flight): store the post-compaction history (rewrite, not append); decide how the summary pair is replayed on `session/load`.
added .memory/handoffs/2026-09-16-persistent-sessions.md +28 -0
new file mode 100644
@@ -0,0 +1,28 @@
1+# Handoff — 2026-09-16 — persistent per-project sessions
2+
3+## State
4+
5+Done and green (13 packages, `go test -race ./...`): `sessions.enabled`/`sessions.dir` in the YAML; `internal/store` files under `<project>/.mm/sessions/<id>.json`; terminal `/sessions`, `/resume [id]`, `/new` (new file); ACP `loadSession: true` + `session/load` replaying the whole conversation (tool calls as completed records, never re-run) before the response; `/new` over ACP archives the old conversation under a new id. Docs EN+FR, diagram, ticket 0008 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing. Feature complete pending the user's manual check.
10+
11+## Next steps
12+
13+1. User: rebuild natively on the Mac (`sudo rm /usr/local/bin/mm && ./install.sh`), set `sessions.enabled: true` in the YAML `AGENT_CONFIG` points at, open a thread in Zed, ask something, then **Reload Agent** — the thread must be rebuilt (question, tool calls, answers). Report what Zed shows if not.
14+2. User: decide whether the `.mm/` line added to this repo's `.gitignore` is wanted (it is, unless sessions should be versioned).
15+3. Optional: `blog/fake-agent/main.go` (user's new untracked dir) adds one smell to the gate; either exclude `blog/**` with the user's agreement or refactor `Prompt` there.
16+
17+## Open questions / blockers
18+
19+- Zed rendering of the replay is unverified (no Zed in the sandbox). If Zed shows an empty thread after reload, check the ACP logs for `[acp] session <id> loaded from …: N message(s) replayed`; if the line is there, the issue is on the rendering side (e.g. Zed needing `tool_call` before `tool_call_update` shapes — we send one `tool_call` with status completed, which the spec allows).
20+- Whether `/sessions` and `/resume` should also be offered over ACP (currently not: the editor lists its own threads). The store supports it if asked.
21+
22+## Watch out for
23+
24+- `sdk.AgentLoader` is a separate interface: `LoadSession` only gets routed because `*front` implements it (`var _ sdk.AgentLoader = (*front)(nil)` in `internal/acp/persist.go`).
25+- A `session/load` response carries no `sessionId`, so the `responseWriter` hook cannot order the commands notification after it; it is sent with the replay instead (accepted: the editor knows the id it asked for).
26+- The terminal `keeper` creates the file lazily (first save); ACP creates it at `session/new`. `hint()` counts stored files, `latestOther()` skips 0-turn sessions.
27+- Never `go build -o mm .` in the sandbox (`./mm` is the user's Mach-O); `/tmp/mm-linux` for checks. `/tmp/drive_sessions.py` is a reusable ACP driver (initialize, session/new, prompt, seeded session/load).
28+- `config.Cfg` is a package global: tests that toggle `Sessions` must restore it (see `withSessions` helpers in `internal/acp/persist_test.go` and `internal/agent/persist_test.go`).
new file mode 100644
@@ -0,0 +1,28 @@
1+# Handoff — 2026-09-16 — persistent per-project sessions
2+
3+## State
4+
5+Done and green (13 packages, `go test -race ./...`): `sessions.enabled`/`sessions.dir` in the YAML; `internal/store` files under `<project>/.mm/sessions/<id>.json`; terminal `/sessions`, `/resume [id]`, `/new` (new file); ACP `loadSession: true` + `session/load` replaying the whole conversation (tool calls as completed records, never re-run) before the response; `/new` over ACP archives the old conversation under a new id. Docs EN+FR, diagram, ticket 0008 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing. Feature complete pending the user's manual check.
10+
11+## Next steps
12+
13+1. User: rebuild natively on the Mac (`sudo rm /usr/local/bin/mm && ./install.sh`), set `sessions.enabled: true` in the YAML `AGENT_CONFIG` points at, open a thread in Zed, ask something, then **Reload Agent** — the thread must be rebuilt (question, tool calls, answers). Report what Zed shows if not.
14+2. User: decide whether the `.mm/` line added to this repo's `.gitignore` is wanted (it is, unless sessions should be versioned).
15+3. Optional: `blog/fake-agent/main.go` (user's new untracked dir) adds one smell to the gate; either exclude `blog/**` with the user's agreement or refactor `Prompt` there.
16+
17+## Open questions / blockers
18+
19+- Zed rendering of the replay is unverified (no Zed in the sandbox). If Zed shows an empty thread after reload, check the ACP logs for `[acp] session <id> loaded from …: N message(s) replayed`; if the line is there, the issue is on the rendering side (e.g. Zed needing `tool_call` before `tool_call_update` shapes — we send one `tool_call` with status completed, which the spec allows).
20+- Whether `/sessions` and `/resume` should also be offered over ACP (currently not: the editor lists its own threads). The store supports it if asked.
21+
22+## Watch out for
23+
24+- `sdk.AgentLoader` is a separate interface: `LoadSession` only gets routed because `*front` implements it (`var _ sdk.AgentLoader = (*front)(nil)` in `internal/acp/persist.go`).
25+- A `session/load` response carries no `sessionId`, so the `responseWriter` hook cannot order the commands notification after it; it is sent with the replay instead (accepted: the editor knows the id it asked for).
26+- The terminal `keeper` creates the file lazily (first save); ACP creates it at `session/new`. `hint()` counts stored files, `latestOther()` skips 0-turn sessions.
27+- Never `go build -o mm .` in the sandbox (`./mm` is the user's Mach-O); `/tmp/mm-linux` for checks. `/tmp/drive_sessions.py` is a reusable ACP driver (initialize, session/new, prompt, seeded session/load).
28+- `config.Cfg` is a package global: tests that toggle `Sessions` must restore it (see `withSessions` helpers in `internal/acp/persist_test.go` and `internal/agent/persist_test.go`).
added .memory/handoffs/2026-09-16-session-close-out.md +19 -0
new file mode 100644
@@ -0,0 +1,19 @@
1+# Handoff — 2026-09-16 — session close-out (orchestrator)
2+
3+## Where it stopped
4+
5+Nothing in flight. Five named agents (bob, riker, milo, homer, orchestrator) delivered over two days; every deliverable is in the working tree, **uncommitted**. Read `summary.md` for the current architecture (three front ends: REPL, ACP, `-tui` window; shared command table; persistent sessions; `@path`; skills layouts) and the per-topic handoffs in this directory for details.
6+
7+## Start here next time
8+
9+1. `git status` — if still uncommitted, propose one commit per topic or one big one; the user commits.
10+2. Ask the user for the results of the manual Mac checks (Zed Reload Agent with `sessions.enabled: true`; window mode keys F2 / Ctrl-J / Esc in Terminal.app, iTerm2, Ghostty, tmux; `/copy` → `(pbcopy)`).
11+3. Open work, by ticket: 0012 phase 3 (in-window drag selection, session browser, Markdown rendering); 0003 remaining tier-1/2 items; 0005/0006 token consumption (partly covered by the window header); 0009 release process; 0011 auto-start llama.cpp; 0004 MCP question; 0007 skills read-not-executed.
12+4. Quality debt (out of scope so far): osv findings on `go.mod` (`go 1.25.0` directive + indirect deps), errcheck in engine/fileedit, `agent.Run` complexity (~110). `hello-world/main.go` is the user's stray file — ask before deleting.
13+
14+## Traps (all recorded in summary.md too)
15+
16+- Never `go build -o mm .` in the sandbox (`./mm` is the Mac binary); build to `/tmp/mm-linux`.
17+- IssueSpec tickets: tasks only in the extension's item format; `comments: []`.
18+- `~/.qlty` volume can fill up and make qlty report 0 warnings — check plugin outputs are non-empty.
19+- `available_commands_update` must go out after the `session/new` response (Zed drops it otherwise).
new file mode 100644
@@ -0,0 +1,19 @@
1+# Handoff — 2026-09-16 — session close-out (orchestrator)
2+
3+## Where it stopped
4+
5+Nothing in flight. Five named agents (bob, riker, milo, homer, orchestrator) delivered over two days; every deliverable is in the working tree, **uncommitted**. Read `summary.md` for the current architecture (three front ends: REPL, ACP, `-tui` window; shared command table; persistent sessions; `@path`; skills layouts) and the per-topic handoffs in this directory for details.
6+
7+## Start here next time
8+
9+1. `git status` — if still uncommitted, propose one commit per topic or one big one; the user commits.
10+2. Ask the user for the results of the manual Mac checks (Zed Reload Agent with `sessions.enabled: true`; window mode keys F2 / Ctrl-J / Esc in Terminal.app, iTerm2, Ghostty, tmux; `/copy` → `(pbcopy)`).
11+3. Open work, by ticket: 0012 phase 3 (in-window drag selection, session browser, Markdown rendering); 0003 remaining tier-1/2 items; 0005/0006 token consumption (partly covered by the window header); 0009 release process; 0011 auto-start llama.cpp; 0004 MCP question; 0007 skills read-not-executed.
12+4. Quality debt (out of scope so far): osv findings on `go.mod` (`go 1.25.0` directive + indirect deps), errcheck in engine/fileedit, `agent.Run` complexity (~110). `hello-world/main.go` is the user's stray file — ask before deleting.
13+
14+## Traps (all recorded in summary.md too)
15+
16+- Never `go build -o mm .` in the sandbox (`./mm` is the Mac binary); build to `/tmp/mm-linux`.
17+- IssueSpec tickets: tasks only in the extension's item format; `comments: []`.
18+- `~/.qlty` volume can fill up and make qlty report 0 warnings — check plugin outputs are non-empty.
19+- `available_commands_update` must go out after the `session/new` response (Zed drops it otherwise).
added .memory/handoffs/2026-09-16-single-window-tui.md +23 -0
new file mode 100644
@@ -0,0 +1,23 @@
1+# Handoff — 2026-09-16 — single-window TUI proposal (agent "homer")
2+
3+## Where it stopped
4+
5+Ticket `.tickets/issues/0012-new-tui-for-mini-me.yaml` holds the full study of turbo-core / turbo-go's ACP agent window and a phased proposal for an optional `mm -tui` mode. Nothing implemented, nothing committed.
6+
7+## If someone picks this up
8+
9+- Read ticket 0012 §3–4 first: recommended **Option B** (tcell v2 directly, hand-rolled window copying `turbo-core/acp/view.go`, `view_draw.go`, `view_events.go`, `transcript.go`, `render.go`, `ui/painter.go` as blueprints). Option A (import `codeberg.org/turbo-editors/turbo-core`, MIT, v0.8.0 pre-1.0) is the fallback if the hand-rolled widgets cost more than expected.
10+- Do the **prerequisite** before any window code: extract the slash-command table out of `agent.Run` so REPL, ACP `availableCommands()` and the TUI picker share it. `agent.Run` is the gate's biggest complexity smell; it must shrink.
11+- The window is a `ui.Sink` implementation: tools already route through `ui.ActiveSink()` when one is installed (`internal/tools/tools.go:142,248`), so tool framing and the inline permission prompt need no tool changes.
12+- Draw only on the loop goroutine; engine/tool callbacks record a fact and `PostEvent(EventInterrupt)`; redraw is state-driven (Turbo's rule, `app/app.go:257-330`).
13+- Newline in the box = **Ctrl-J** (Shift-Enter is indistinguishable from Enter without the kitty protocol; Alt-Enter is not reported by every terminal).
14+- Keep line mode the default and fall back to it when stdout is not a TTY (`mm -tui | tee`).
15+- Tests: `tcell.SimulationScreen`, as Turbo's `ui/uitest_test.go` does.
16+
17+## Sources kept
18+
19+`/tmp/turbo/turbo-core` and `/tmp/turbo/turbo-go` — shallow clones, sandbox-local, will vanish with the sandbox; re-clone from Codeberg if needed. turbo-go's docs pages `agent-windows.md` / `talk-to-an-agent.md` were listed but not read (the code was the primary source).
20+
21+## Related
22+
23+Tickets 0003 (line-mode tiers; §3.1 is Option C here), 0001/0002 (`/help`, `/about` close in phase 2), 0005/0006 (token counters), 0008 (persistent sessions → phase 3 session browser).
new file mode 100644
@@ -0,0 +1,23 @@
1+# Handoff — 2026-09-16 — single-window TUI proposal (agent "homer")
2+
3+## Where it stopped
4+
5+Ticket `.tickets/issues/0012-new-tui-for-mini-me.yaml` holds the full study of turbo-core / turbo-go's ACP agent window and a phased proposal for an optional `mm -tui` mode. Nothing implemented, nothing committed.
6+
7+## If someone picks this up
8+
9+- Read ticket 0012 §3–4 first: recommended **Option B** (tcell v2 directly, hand-rolled window copying `turbo-core/acp/view.go`, `view_draw.go`, `view_events.go`, `transcript.go`, `render.go`, `ui/painter.go` as blueprints). Option A (import `codeberg.org/turbo-editors/turbo-core`, MIT, v0.8.0 pre-1.0) is the fallback if the hand-rolled widgets cost more than expected.
10+- Do the **prerequisite** before any window code: extract the slash-command table out of `agent.Run` so REPL, ACP `availableCommands()` and the TUI picker share it. `agent.Run` is the gate's biggest complexity smell; it must shrink.
11+- The window is a `ui.Sink` implementation: tools already route through `ui.ActiveSink()` when one is installed (`internal/tools/tools.go:142,248`), so tool framing and the inline permission prompt need no tool changes.
12+- Draw only on the loop goroutine; engine/tool callbacks record a fact and `PostEvent(EventInterrupt)`; redraw is state-driven (Turbo's rule, `app/app.go:257-330`).
13+- Newline in the box = **Ctrl-J** (Shift-Enter is indistinguishable from Enter without the kitty protocol; Alt-Enter is not reported by every terminal).
14+- Keep line mode the default and fall back to it when stdout is not a TTY (`mm -tui | tee`).
15+- Tests: `tcell.SimulationScreen`, as Turbo's `ui/uitest_test.go` does.
16+
17+## Sources kept
18+
19+`/tmp/turbo/turbo-core` and `/tmp/turbo/turbo-go` — shallow clones, sandbox-local, will vanish with the sandbox; re-clone from Codeberg if needed. turbo-go's docs pages `agent-windows.md` / `talk-to-an-agent.md` were listed but not read (the code was the primary source).
20+
21+## Related
22+
23+Tickets 0003 (line-mode tiers; §3.1 is Option C here), 0001/0002 (`/help`, `/about` close in phase 2), 0005/0006 (token counters), 0008 (persistent sessions → phase 3 session browser).
added .memory/handoffs/2026-09-16-tui-copy.md +27 -0
new file mode 100644
@@ -0,0 +1,27 @@
1+# Handoff — 2026-09-16 — copy text out of the window mode
2+
3+## State
4+
5+Done and green (16 packages, `-race`): in `mm -tui` the mouse is not captured at start-up, so the terminal's own click-and-drag selection and copy shortcut work again; **F2** captures/releases the mouse (wheel scrolling; Shift+drag still selects) with the state in the status bar; `/copy`, `/copy N`, `/copy all` and **Ctrl-Y** copy raw text through `internal/clipboard` (pbcopy / wl-copy / xclip / xsel, then OSC 52) and say which road was used; the REPL has `/copy [N]`. Side fix: `skills.List` ignores a `README.md` at the skills root. Docs EN+FR, diagram, ticket 0012 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing.
10+
11+## Next steps
12+
13+1. **User, on the Mac** after a native rebuild (`sudo rm /usr/local/bin/mm && ./install.sh`): in `mm -tui`, drag-select an answer and paste it elsewhere (Terminal.app, iTerm2, Ghostty); press F2 and check the wheel scrolls and Shift+drag selects; `/copy` then paste — the notice must say `(pbcopy)`; check F2 is reported by the terminal at all (tmux may eat function keys).
14+2. If a terminal does not report F2, add a second binding (e.g. Ctrl-B) in `handleWindowKey` — one line plus the docs.
15+3. Optional `tuiMouse: true` config key for people who want wheel scrolling always on: needs a top-level bool in `internal/config` (do not turn `tui` into a struct) and one line in `newApp`.
16+4. Phase 3 leftovers in ticket 0012: session browser, code fences, light Markdown; in-window drag selection (task 10) only if the user asks — it requires mouse capture.
17+
18+## Open questions / blockers
19+
20+- `hello-world/main.go` (user's, unformatted, inside the module) is still there; gofmt and the gate flag it. Not touched.
21+
22+## Watch out for
23+
24+- **`~/.qlty` is a 488 MB volume and fills up** (Go toolchain 251 MB + linters + caches). When full, every qlty plugin "FATAL"s, the invoke yamls are 0 bytes, and the report shows **0 warnings** — read that as a tooling failure. Free `~/.qlty/cache/repos/*/plugin_cachedir/*` and `…/out/invoke-*.yaml`, then re-run. `df -h ~/.qlty` tells.
25+- `tcell.SimulationScreen` records `EnableMouse`/`DisableMouse` in a private flag; tests assert the view's `mouse` field and the status label instead. The wire-level proof is the pty check in the history entry.
26+- `Transcript.Plain()` is the paste format of `/copy all`; `render.go`'s layout is the screen format — keep them apart on purpose.
27+- `/tmp/fakebin/pbcopy` + `PATH=/tmp/fakebin:$PATH` is the way to test the native road without a Mac.
new file mode 100644
@@ -0,0 +1,27 @@
1+# Handoff — 2026-09-16 — copy text out of the window mode
2+
3+## State
4+
5+Done and green (16 packages, `-race`): in `mm -tui` the mouse is not captured at start-up, so the terminal's own click-and-drag selection and copy shortcut work again; **F2** captures/releases the mouse (wheel scrolling; Shift+drag still selects) with the state in the status bar; `/copy`, `/copy N`, `/copy all` and **Ctrl-Y** copy raw text through `internal/clipboard` (pbcopy / wl-copy / xclip / xsel, then OSC 52) and say which road was used; the REPL has `/copy [N]`. Side fix: `skills.List` ignores a `README.md` at the skills root. Docs EN+FR, diagram, ticket 0012 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing.
10+
11+## Next steps
12+
13+1. **User, on the Mac** after a native rebuild (`sudo rm /usr/local/bin/mm && ./install.sh`): in `mm -tui`, drag-select an answer and paste it elsewhere (Terminal.app, iTerm2, Ghostty); press F2 and check the wheel scrolls and Shift+drag selects; `/copy` then paste — the notice must say `(pbcopy)`; check F2 is reported by the terminal at all (tmux may eat function keys).
14+2. If a terminal does not report F2, add a second binding (e.g. Ctrl-B) in `handleWindowKey` — one line plus the docs.
15+3. Optional `tuiMouse: true` config key for people who want wheel scrolling always on: needs a top-level bool in `internal/config` (do not turn `tui` into a struct) and one line in `newApp`.
16+4. Phase 3 leftovers in ticket 0012: session browser, code fences, light Markdown; in-window drag selection (task 10) only if the user asks — it requires mouse capture.
17+
18+## Open questions / blockers
19+
20+- `hello-world/main.go` (user's, unformatted, inside the module) is still there; gofmt and the gate flag it. Not touched.
21+
22+## Watch out for
23+
24+- **`~/.qlty` is a 488 MB volume and fills up** (Go toolchain 251 MB + linters + caches). When full, every qlty plugin "FATAL"s, the invoke yamls are 0 bytes, and the report shows **0 warnings** — read that as a tooling failure. Free `~/.qlty/cache/repos/*/plugin_cachedir/*` and `…/out/invoke-*.yaml`, then re-run. `df -h ~/.qlty` tells.
25+- `tcell.SimulationScreen` records `EnableMouse`/`DisableMouse` in a private flag; tests assert the view's `mouse` field and the status label instead. The wire-level proof is the pty check in the history entry.
26+- `Transcript.Plain()` is the paste format of `/copy all`; `render.go`'s layout is the screen format — keep them apart on purpose.
27+- `/tmp/fakebin/pbcopy` + `PATH=/tmp/fakebin:$PATH` is the way to test the native road without a Mac.
added .memory/handoffs/2026-09-16-tui-paste.md +25 -0
new file mode 100644
@@ -0,0 +1,25 @@
1+# Handoff — 2026-09-16 — long pastes in the window mode
2+
3+## State
4+
5+Done and green (16 packages, `-race`): in `mm -tui`, a bracketed paste longer than 3 lines or 200 characters becomes one `[Pasted #n · L lines · C chars]` token in the box; the model receives the full text on Enter; the conversation keeps the token; `/copy all` and the persisted session carry the full question; `@path` inside a paste is not looked up. Nothing committed.
6+
7+## In flight
8+
9+Nothing.
10+
11+## Next steps
12+
13+1. **User, on the Mac** after `sudo rm /usr/local/bin/mm && ./install.sh`: paste 50+ lines into `mm -tui` in Terminal.app, iTerm2 and Ghostty — the box must show one token, Enter must send the whole text (check the model's answer refers to the end of the block); try Backspace on the token; try two pastes in one message. In **tmux**, paste with the outer terminal's paste (Cmd-V): it must arrive bracketed; `tmux paste-buffer` without `-p` arrives as plain keys and is inserted character by character (documented).
14+2. Optional follow-up if asked: `/paste n` to show a collapsed block folded to `previewLines` — needs a hook from the runner to the View's `pastes` (or the View printing it into the transcript directly).
15+3. Phase 3 items unchanged (ticket 0012 tasks 6, 7, 10).
16+
17+## Open questions / blockers
18+
19+- Threshold values (3 lines / 200 chars) are constants in `paste.go`; a config key was not added (the user did not ask). Easy to add as `tuiPasteLines`/`tuiPasteChars` top-level bools/ints if wanted — do not turn `tui` into a struct.
20+
21+## Watch out for
22+
23+- tcell delivers a paste as `EventPaste(true)`, then ordinary `EventKey`s (Enter for newline, Tab for tab), then `EventPaste(false)`. The View buffers those keys in `pastes.buf` and only inserts at the end mark — any new key handling must keep the `if v.pasting` check first in `handleKey`.
24+- Placeholders are runes in `0xF0000+id`; `Prompt.replace` walks runes, so any code that hands the typed text to another package must go through `Prompt.Text()`/`Expand()` or the model would see private-use runes.
25+- `internal/tui/events.go` sits at file-complexity 44 (threshold 50): put new key handlers in their own file, as `paste.go` and `copy.go` do.
new file mode 100644
@@ -0,0 +1,25 @@
1+# Handoff — 2026-09-16 — long pastes in the window mode
2+
3+## State
4+
5+Done and green (16 packages, `-race`): in `mm -tui`, a bracketed paste longer than 3 lines or 200 characters becomes one `[Pasted #n · L lines · C chars]` token in the box; the model receives the full text on Enter; the conversation keeps the token; `/copy all` and the persisted session carry the full question; `@path` inside a paste is not looked up. Nothing committed.
6+
7+## In flight
8+
9+Nothing.
10+
11+## Next steps
12+
13+1. **User, on the Mac** after `sudo rm /usr/local/bin/mm && ./install.sh`: paste 50+ lines into `mm -tui` in Terminal.app, iTerm2 and Ghostty — the box must show one token, Enter must send the whole text (check the model's answer refers to the end of the block); try Backspace on the token; try two pastes in one message. In **tmux**, paste with the outer terminal's paste (Cmd-V): it must arrive bracketed; `tmux paste-buffer` without `-p` arrives as plain keys and is inserted character by character (documented).
14+2. Optional follow-up if asked: `/paste n` to show a collapsed block folded to `previewLines` — needs a hook from the runner to the View's `pastes` (or the View printing it into the transcript directly).
15+3. Phase 3 items unchanged (ticket 0012 tasks 6, 7, 10).
16+
17+## Open questions / blockers
18+
19+- Threshold values (3 lines / 200 chars) are constants in `paste.go`; a config key was not added (the user did not ask). Easy to add as `tuiPasteLines`/`tuiPasteChars` top-level bools/ints if wanted — do not turn `tui` into a struct.
20+
21+## Watch out for
22+
23+- tcell delivers a paste as `EventPaste(true)`, then ordinary `EventKey`s (Enter for newline, Tab for tab), then `EventPaste(false)`. The View buffers those keys in `pastes.buf` and only inserts at the end mark — any new key handling must keep the `if v.pasting` check first in `handleKey`.
24+- Placeholders are runes in `0xF0000+id`; `Prompt.replace` walks runes, so any code that hands the typed text to another package must go through `Prompt.Text()`/`Expand()` or the model would see private-use runes.
25+- `internal/tui/events.go` sits at file-complexity 44 (threshold 50): put new key handlers in their own file, as `paste.go` and `copy.go` do.
added .memory/handoffs/2026-09-16-tui-proposals.md +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+# Handoff — 2026-09-16 — TUI proposals (agent "homer")
2+
3+## Where it stopped
4+
5+Ticket `.tickets/issues/0003-improve-tui.yaml` holds the full analysis and 18 tiered proposals with a recommended order. Nothing implemented. Nothing committed.
6+
7+## If someone picks this up
8+
9+- Start with tier 1 items 1.1 (multi-line input), 1.2 (`/help`, `/about` — closes tickets 0001/0002), 1.4/1.5 (tokens in the `⚙` recap, `/status` — covers 0005; 0006 is the ACP twin). All land in `internal/agent/agent.go` and `main.go`, no dependency. `agent.Run` is already the biggest complexity smell in the quality gate: put new logic in helpers or a new package (`internal/render`, `internal/tui`), not in the loop.
10+- Any escape sequence must be gated by `spinner.Styled()` (pipe rule); the ACP front end must stay untouched (editor renders).
11+- Tier 2 decision to take with the user: readline via `golang.org/x/term` + own editor (minimal footprint) vs a readline library.
12+- The terminal permission prompt (2.2) should be a terminal implementation of `ui.Sink.Allow`, so tools keep one code path; today `ui.ActiveSink()` is nil in the terminal and tools print directly.
13+- cagent references: `tmp/docker-agent/pkg/leantui/doc.go` (lean mode idea), `pkg/tui/components/toolconfirm/toolconfirm.go` (permission options), `pkg/tui/components/markdown/incremental.go` (streaming Markdown), `pkg/tui/dialog/session_browser.go`.
14+
15+## Traps
16+
17+- `.tickets/labels.yaml` vocabulary: `kind::bug|feature|chore` — `kind::enhancement` does not exist.
18+- Ticket bodies are YAML block scalars; validate with `/tmp/yamlcheck` after hand edits.
new file mode 100644
@@ -0,0 +1,18 @@
1+# Handoff — 2026-09-16 — TUI proposals (agent "homer")
2+
3+## Where it stopped
4+
5+Ticket `.tickets/issues/0003-improve-tui.yaml` holds the full analysis and 18 tiered proposals with a recommended order. Nothing implemented. Nothing committed.
6+
7+## If someone picks this up
8+
9+- Start with tier 1 items 1.1 (multi-line input), 1.2 (`/help`, `/about` — closes tickets 0001/0002), 1.4/1.5 (tokens in the `⚙` recap, `/status` — covers 0005; 0006 is the ACP twin). All land in `internal/agent/agent.go` and `main.go`, no dependency. `agent.Run` is already the biggest complexity smell in the quality gate: put new logic in helpers or a new package (`internal/render`, `internal/tui`), not in the loop.
10+- Any escape sequence must be gated by `spinner.Styled()` (pipe rule); the ACP front end must stay untouched (editor renders).
11+- Tier 2 decision to take with the user: readline via `golang.org/x/term` + own editor (minimal footprint) vs a readline library.
12+- The terminal permission prompt (2.2) should be a terminal implementation of `ui.Sink.Allow`, so tools keep one code path; today `ui.ActiveSink()` is nil in the terminal and tools print directly.
13+- cagent references: `tmp/docker-agent/pkg/leantui/doc.go` (lean mode idea), `pkg/tui/components/toolconfirm/toolconfirm.go` (permission options), `pkg/tui/components/markdown/incremental.go` (streaming Markdown), `pkg/tui/dialog/session_browser.go`.
14+
15+## Traps
16+
17+- `.tickets/labels.yaml` vocabulary: `kind::bug|feature|chore` — `kind::enhancement` does not exist.
18+- Ticket bodies are YAML block scalars; validate with `/tmp/yamlcheck` after hand edits.
added .memory/handoffs/2026-09-16-tui-window.md +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+# Handoff — 2026-09-16 — window mode `mm -tui` (ticket 0012, option B)
2+
3+## State
4+
5+Phases 1 and 2 of ticket 0012 are done and green: `mm -tui` (or `tui: true`) opens a single tcell window — header, conversation with scroll bar, rule with spinner, 3-row input box, status bar, `/` picker — fed by a `ui.Sink` like the ACP façade; `permissions: ask` gates `bash`/file tools inline; `/help` exists in both terminal modes; the slash commands live in one table (`internal/command`). `go test -race ./...` 14 packages ok; quality gate fails only on pre-existing debt (63 warnings, 19 smells, none in the new code). Docs EN+FR, diagram and tickets 0012/0001 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing. Phase 3 (session browser, copy, Markdown) not started — open task 6 of ticket 0012.
10+
11+## Next steps
12+
13+1. **User, on the Mac**: rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh` — the in-place `cp` trap), then `mm -tui agent.dmr.yaml` from the repo root. Check: Ctrl-J inserts a newline (and whether Alt-Enter does), Esc stops a turn promptly, Ctrl-C quits when idle, resize redraws, colours read on a light theme, `mm -tui | tee x.log` falls back to the line mode. Try `permissions: ask` and a `bash` call: the box must turn into the `? bash: …` question, `a`/`s`/`r` answer it.
14+2. If Alt-Enter is not reported by the user's terminal, the docs already say Ctrl-J is the one to rely on; no code change needed.
15+3. Decide whether ticket 0001 can be closed (`/help` is done; the ticket was left `state: open`).
16+4. Phase 3 when wanted: F2 session browser over `store.Keeper` (list/resume/new), transcript selection + copy (OSC 52), code fences drawn distinctly. The seams are ready: `View` has `onInput` focus and `laidOut` lines; `Transcript` entries carry `Kind`.
17+
18+## Open questions / blockers
19+
20+- `hello-world/main.go` appeared in the repo mid-session (user's file, untracked, unformatted): it is inside the module, so `go build ./...` compiles it and the gate flags it. Not touched — the user should decide (delete, gitignore, or move under `tmp/`).
21+- The tcell dependency pulled `golang.org/x/sys` 0.34→0.38 and `x/text` 0.27→0.31; osv-scanner swapped one CVE for another on those (55→56 go.mod findings). Same debt class as before (the `go 1.25.0` directive is 46 of them).
22+
23+## Watch out for
24+
25+- Never `go build -o mm .` in the sandbox — `./mm` is the user's Mach-O. `/tmp/mm-linux` for checks. A pseudo-terminal check works with `python3 pty` (`fcntl TIOCSWINSZ` to size it); the window needs `TERM` set.
26+- `tcell.SimulationScreen.GetContents` is not safe while another goroutine draws: in tests, read the screen after the loop returned (see `TestRunLoopEndToEnd`) or drive `View.draw()` synchronously (see `view_test.go`'s harness).
27+- The runner's `messages` is owned by the loop goroutine; a turn works on a copy and hands the result back through `results` (`settle()`), so slash commands and turns never race. Keep it that way when adding phase 3.
28+- `config.Cfg` is a package global: tests that toggle `Permissions`/`Sessions` restore it (`newTestRunner` saves/restores the whole struct).
29+- `spinner.Disable()` must stay before `tui.Run`: `engine.Generate` starts the spinner unconditionally and it would draw over the window.
30+- Ticket YAML: tasks in IssueSpec format (`id/title/state/priority/author/createdAt`, plus `updatedAt`/`closedAt` when `closed`), `comments: []` untouched; validate with `/tmp/yamlcheck` (yaml.v3).
new file mode 100644
@@ -0,0 +1,30 @@
1+# Handoff — 2026-09-16 — window mode `mm -tui` (ticket 0012, option B)
2+
3+## State
4+
5+Phases 1 and 2 of ticket 0012 are done and green: `mm -tui` (or `tui: true`) opens a single tcell window — header, conversation with scroll bar, rule with spinner, 3-row input box, status bar, `/` picker — fed by a `ui.Sink` like the ACP façade; `permissions: ask` gates `bash`/file tools inline; `/help` exists in both terminal modes; the slash commands live in one table (`internal/command`). `go test -race ./...` 14 packages ok; quality gate fails only on pre-existing debt (63 warnings, 19 smells, none in the new code). Docs EN+FR, diagram and tickets 0012/0001 updated. Nothing committed.
6+
7+## In flight
8+
9+Nothing. Phase 3 (session browser, copy, Markdown) not started — open task 6 of ticket 0012.
10+
11+## Next steps
12+
13+1. **User, on the Mac**: rebuild natively (`sudo rm /usr/local/bin/mm && ./install.sh` — the in-place `cp` trap), then `mm -tui agent.dmr.yaml` from the repo root. Check: Ctrl-J inserts a newline (and whether Alt-Enter does), Esc stops a turn promptly, Ctrl-C quits when idle, resize redraws, colours read on a light theme, `mm -tui | tee x.log` falls back to the line mode. Try `permissions: ask` and a `bash` call: the box must turn into the `? bash: …` question, `a`/`s`/`r` answer it.
14+2. If Alt-Enter is not reported by the user's terminal, the docs already say Ctrl-J is the one to rely on; no code change needed.
15+3. Decide whether ticket 0001 can be closed (`/help` is done; the ticket was left `state: open`).
16+4. Phase 3 when wanted: F2 session browser over `store.Keeper` (list/resume/new), transcript selection + copy (OSC 52), code fences drawn distinctly. The seams are ready: `View` has `onInput` focus and `laidOut` lines; `Transcript` entries carry `Kind`.
17+
18+## Open questions / blockers
19+
20+- `hello-world/main.go` appeared in the repo mid-session (user's file, untracked, unformatted): it is inside the module, so `go build ./...` compiles it and the gate flags it. Not touched — the user should decide (delete, gitignore, or move under `tmp/`).
21+- The tcell dependency pulled `golang.org/x/sys` 0.34→0.38 and `x/text` 0.27→0.31; osv-scanner swapped one CVE for another on those (55→56 go.mod findings). Same debt class as before (the `go 1.25.0` directive is 46 of them).
22+
23+## Watch out for
24+
25+- Never `go build -o mm .` in the sandbox — `./mm` is the user's Mach-O. `/tmp/mm-linux` for checks. A pseudo-terminal check works with `python3 pty` (`fcntl TIOCSWINSZ` to size it); the window needs `TERM` set.
26+- `tcell.SimulationScreen.GetContents` is not safe while another goroutine draws: in tests, read the screen after the loop returned (see `TestRunLoopEndToEnd`) or drive `View.draw()` synchronously (see `view_test.go`'s harness).
27+- The runner's `messages` is owned by the loop goroutine; a turn works on a copy and hands the result back through `results` (`settle()`), so slash commands and turns never race. Keep it that way when adding phase 3.
28+- `config.Cfg` is a package global: tests that toggle `Permissions`/`Sessions` restore it (`newTestRunner` saves/restores the whole struct).
29+- `spinner.Disable()` must stay before `tui.Run`: `engine.Generate` starts the spinner unconditionally and it would draw over the window.
30+- Ticket YAML: tasks in IssueSpec format (`id/title/state/priority/author/createdAt`, plus `updatedAt`/`closedAt` when `closed`), `comments: []` untouched; validate with `/tmp/yamlcheck` (yaml.v3).
modified .memory/history.md +104 -0
@@ -67,3 +67,107 @@ One entry per session, appended, never rewritten.
6767 - **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp).
6868 - **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text).
6969 - **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path.
70+
71+## 2026-09-16 — Zed "Reload Agent" fails: "Loading or resuming sessions is not supported by this agent" (orchestrator, diagnosis only)
72+
73+- **Cause**: `Initialize` returns `AgentCapabilities{}` (no `loadSession`), and `LoadSession` / `ResumeSession` answer `method not found`. Zed's Reload Agent restarts the process and asks it to reload the current thread via `session/load` (or the unstable `session/resume`); mm keeps sessions in memory only, so it has nothing to give back.
74+- **Proposed, not applied**: persist each ACP session's history to disk and implement `session/load` (replay user/agent messages and tool calls as `session/update`, restore the in-memory history, set `loadSession: true`). Open choice for the user: storage location (`~/.mm/sessions/` vs `<cwd>/.mm/`) and whether to also replay tool calls.
75+
76+## 2026-09-16 — Ticket 0008 body written (orchestrator, as "bob")
77+
78+- **Asked**: put the Reload Agent explanation, in English, into `.tickets/issues/0008-why-in-zed-i-cannot-relaoad-the-agent.yaml`.
79+- **Changes**: `body` filled (symptom, cause, why not a flag, what it would take, open decisions) as a YAML block scalar; `updatedAt` bumped. Validated with yaml.v3. Clarified in the ticket and to the user that "replaying tool calls" re-sends recorded tool-call records for display and never re-executes tools.
80+
81+## 2026-09-16 — Context compression reviewed, ticket 0010 written (agent "riker", read-only on code)
82+
83+- **Asked**: understand how context compression works and write it into `.tickets/issues/0010-check-how-the-context-compression-is-working.yaml` (English).
84+- **Findings** (from `internal/compact`, `internal/agent`, `internal/acp`, `internal/engine`, `internal/config`): hybrid summary + `keepLastTurns` raw turns; two triggers in `compact.Decide` (`tokens×100 ≥ window×threshold` with `tokens = max(Estimate, server usage.input_tokens)`, or `len ≥ maxMessages`); **`Decide` has one call site, the terminal loop — no automatic compaction over ACP**, only `/compact`. Estimate = chars/3.5 + 4/message, ignores tool definitions and template. Failure or nothing-to-compact leaves the history untouched; measured count forgotten only after success; loop detector kept. Defaults: enabled false, threshold 75, maxMessages 80, keepLastTurns 3, summaryMaxTokens 1200, showStats true; `agent.llamacpp.yaml` enables it, `agent.dmr.yaml` does not.
85+- **Doc inaccuracy (not edited)**: `how-to/manage-the-context-window.md` (EN+FR) says the window is re-probed "before the first question"; the code re-probes before every question while unknown. Docs never say the automatic trigger is terminal-only.
86+- **cagent comparison** (`tmp/docker-agent/pkg/compaction`): threshold 0.9, calibrated estimator, token-budgeted keep suffix, reactive overflow compaction (retry once), optional dedicated compaction model.
87+- **Follow-ups suggested in the ticket**: auto-compaction in `acp.Prompt`; persistent sessions must rewrite the file after compaction (for bob's feature); silent nothing-to-compact on the automatic path.
88+- **Validation**: ticket YAML checked with yaml.v3 (`/tmp/yamlcheck`). No Go, YAML config or docs changed.
89+
90+## 2026-09-16 — Blog: two French articles on ACP (agent "milo")
91+
92+- **Asked**: in `blog/`, an explainer on the Agent Client Protocol (step by step, with the lists of ACP agents and editors) and a tutorial on writing a Go "fake agent" that talks to Zed over ACP.
93+- **Changes**: `blog/01-acp-agent-client-protocol.md` (lifecycle with real JSON, mermaid sequence diagram, agents/editors tables from the official registry as of 2026-09-16), `blog/02-fake-agent-acp-en-go.md` (tutorial + full source + real wire transcript), `blog/fake-agent/{main.go,go.mod,go.sum}` (separate Go module, builds standalone with acp-go-sdk v0.13.5; the root module ignores it — `go build ./... && go vet ./...` at the root still pass).
94+- **Verified**: fake agent driven by a scripted JSON-RPC client: initialize → session/new → available_commands_update AFTER the response → prompt with tool_call/tool_call_update + word-by-word chunks → `/hello` → prompt cancelled by session/cancel answered `cancelled`. Network was available: agentclientprotocol.com registry pages, Zed and JetBrains blogs fetched.
95+- **Not touched**: code, docs, config, tickets, summary.md.
96+
97+## 2026-09-16 — TUI improvement proposals written into ticket 0003 (agent "homer", read-only review)
98+
99+- **Asked**: think about improving mm's terminal UI, write proposals into `.tickets/issues/0003-improve-tui.yaml`, implement nothing.
100+- **Findings** (code): stdin read line by line with `bufio` (no editing, no history, multi-line paste = one question per line, each cancelling the previous turn); output is `fmt.Print` + two ANSI codes gated by `spinner.Styled()`; no permission step in the terminal while ACP has one (`sink.Allow`); recap counts commands but not tokens; only help is the banner. cagent: full Bubble Tea v2 TUI (`tmp/docker-agent/pkg/tui`) plus a `--lean` scrollback-friendly mode (`pkg/leantui`, lipgloss + x/ansi only, pinned status footer with tokens/cost).
101+- **Written**: ticket 0003 body — current state, 8 pain points, cagent comparison, 18 proposals in three tiers with size/dependency/landing package (tier 1 no-dependency: multi-line input, `/help` `/about`, tokens in recap, `/status`, tool framing, diff colours, light Markdown, `/clear`; tier 2: readline+history, terminal permission prompt via a terminal `ui.Sink`, session browser after 0008, glamour, lean status footer, `$EDITOR`, bell; tier 3: Bubble Tea — recommended "not now"), recommended order. Labels `kind::feature`, `priority::medium`. YAML validated with yaml.v3.
102+- **Not changed**: no Go code, config, docs.
103+
104+## 2026-09-16 — Persistent per-project sessions, ACP `session/load` with full replay (agent "bob", methodical-dev)
105+
106+- **Goal**: sessions saved per project, switchable in the YAML, usable over ACP (Zed Reload Agent), `/new` starts a new session, replay everything; decide the restart behaviour from docker agent's source.
107+- **docker agent facts** (`tmp/docker-agent`): SQLite store at `<data-dir>/session.db` = `~/.cagent/session.db` (`cmd/root/flags.go`, `pkg/paths/paths.go`); ACP `LoadSession: false` + `method not found`, advertises unstable `SessionCapabilities{List, Resume, Close}`; `ResumeSession` re-registers the stored session with a new runtime, replays nothing (`pkg/acp/agent.go`); `cagent run` never auto-resumes — `--session <id|-1>` flag, TUI `/sessions` browser (`cmd/root/run.go`, `pkg/tui/tui.go`).
108+- **Changes**: new `internal/store` (`Session`, `Summary`, `Open/New/Save/Load/List/Latest`, `NewID`, atomic write) + tests; `internal/config` `SessionsConfig{Enabled, Dir}` + validation + `config_test.go` (first tests for config); YAML `sessions:` block in `agent.dmr.yaml` and `agent.llamacpp.yaml`; `internal/session.WithSystem`; `internal/acp/persist.go` (`AgentLoader`, `LoadSession`, `storeFor`, `persistNew`, `persist`, `archive`), `internal/acp/replay.go` (`replayUpdates`, `toolPresentation`), `acp.go` (store ids, `loadSession` capability, save points, `slashCommand` extraction, `nextID` removed, `lastFront` test hook); `internal/agent/persist.go` (`keeper`: lazy file, `save`, `renew`, `resume`, `listing`, `hint`), `agent.go` (`loop.historyCommand` extraction, `/sessions`, `/resume`, banner text); `.gitignore` `.mm/`.
109+- **Decisions**: restart = new session + hint (docker agent parity, stale-context safety); terminal file created on first save; bare `/resume` skips the current and empty sessions; ACP `/new` archives under a new id (editor keeps its id); stored prompt replaced by the current one; summaries replayed as agent text; tool calls without recorded response replayed `failed`; write failures logged, never fatal; ids `YYYYMMDD-HHMMSS-<8 hex>` shared by both front ends. Rejected: auto-resume, append-only log, SQLite, unstable `session/resume`, text-only replay.
110+- **Tests**: `go test -race ./...` — 13 packages ok. New: store (5), config (1), session (1), acp replay (3) + persist (4 pipe-driven, incl. replay order and refusals), agent keeper (3). Real Linux build (`/tmp/mm-linux`, never `./mm`) driven by `/tmp/drive_sessions.py`: `loadSession: true`, file at `session/new`, `/new` on empty archives nothing, seeded `session/load` replays `user_message_chunk, tool_call[completed], agent_message_chunk, available_commands_update` then `{}`, `/new` after load keeps the old conversation under a new id; terminal `/sessions`, `/resume` exercised in `/tmp/mmproj`.
111+- **Quality**: gate FAIL, unchanged debt: warnings 67 (= before the feature), smells 19 = the 18 pre-existing + 1 in the user's new untracked `blog/fake-agent/main.go` (out of scope). This feature adds 0 warnings / 0 smells after three iterations (errcheck in a test, `store.Save` returns → `writeAtomic`, `historyCommand` params → `loop` struct, `acp.Prompt` returns → `slashCommand`); `agent.Run` complexity 121 → 120.
112+- **Docs (EN+FR)**: new `how-to/keep-sessions.md`, new `explanation/persistent-sessions.md`; updated `reference/configuration.md` (`sessions` keys + validation), `reference/acp.md` (capabilities, `session/new`, `session/load`, replay table, `/new`, notifications), `reference/slash-commands.md` (`/sessions`, `/resume`, `/new` file note), `reference/cli.md` (start-up line), `how-to/start-a-new-session.md`, `how-to/use-from-an-editor.md`, `explanation/architecture.md` (`store` row), both README indexes, `docs/diagrams/packages.drawio` (`store` node, edges from agent and acp). Ticket 0008: task list + comment.
113+
114+## 2026-09-16 — How to enable sessions, written into `agent.llamacpp.yaml` (orchestrator, as "bob")
115+
116+- **Asked**: explain in `agent.llamacpp.yaml` how to enable persistent sessions.
117+- **Changes**: the comment block above `sessions:` rewritten as a 4-step "how to turn them on" (set `enabled: true`, restart from the project dir / point Zed's `AGENT_CONFIG` at the file and reload, check the `sessions:` banner line, gitignore `.mm/`) followed by what the feature does. Values unchanged (`enabled: false`, `dir: .mm/sessions`). File still parses (banner shows `config: agent.llamacpp.yaml`). `agent.dmr.yaml` keeps bob's shorter comment.
118+
119+## 2026-09-16 — Single-window TUI proposal from the Turbo editors' agent window, ticket 0012 (agent "homer", read-only)
120+
121+- **Asked**: study turbo-core / turbo-go's UI and ACP agent window, propose a one-window mode with an input zone for mini-me in ticket 0012; implement nothing.
122+- **Findings**: both repos MIT; stack = `tcell/v2` + `BurntSushi/toml` + `x/sys` only, hand-written Turbo Vision widgets (`turbo-core/ui`, absolute bounds, clipping painters, `Widget` contract, state-driven event loop with `PostEvent` wake). The agent window is `turbo-core/acp/view*.go`: transcript above, rule with state/key hints, 3-row multi-line input box; Enter sends, Alt-Enter newline, Tab focus, Esc cancels the turn, Ctrl-C copies; `Transcript` coalesces chunks and folds tool updates; permissions are queued and answered by a modal dialog on the loop goroutine; `/` and `@` open an 8-row picker. turbo-go is only the profile + Go scanner; its `acp-agent/` is a cagent config used to test the window.
123+- **Proposal written**: optional `mm -tui` mode, third front end `internal/tui` implementing `ui.Sink`; ASCII mockup; keybindings (Ctrl-J for newline — Shift-Enter undetectable); three options (A import turbo-core, B tcell direct, C Bubble Tea) with a comparison table; **recommended B** with A as fallback; prerequisite = extract `agent.Run`'s command table; three phases; risks. Labels `kind::feature`, `priority::medium`. YAML validated with yaml.v3.
124+- Clones kept under `/tmp/turbo/` (not in the repo). No code, docs, config or other tickets touched.
125+
126+## 2026-09-16 — Ticket 0008 format repaired for IssueSpec (orchestrator, as "milo")
127+
128+- **Asked**: the IssueSpec VS Code extension no longer opened ticket 0008.
129+- **Cause (inferred, schema unavailable)**: the only structural change was bob's hand-written `tasks:` items (`text`/`done`) and a `comments:` item (`author.name`/`createdAt`/`body`) — a format no extension-generated ticket uses; the extension's schema for those arrays is unknown (not documented online, extension not installed in the sandbox).
130+- **Changes**: both arrays reset to `[]`; the six tasks became a `## Progress` checklist and the comment an `## Implementation note` section inside `body`. Top-level keys now match a pristine ticket (`id title state author createdAt updatedAt labels body tasks comments`); yaml.v3 validates. Bob (running on ticket 0012) was told to keep `tasks`/`comments` empty in every ticket.
131+- **Rule for future sessions**: never hand-write `tasks`/`comments` items; use the body. If the user adds a task through the extension, copy that format.
132+
133+## 2026-09-16 — IssueSpec task format learned, applied to ticket 0008 (orchestrator)
134+
135+- The user added a sample task through the extension: `id`, `title`, `state: open`, `priority: none`, `author {name, email}`, `createdAt`. Replaced the sample with the one real open task ("Verify in a real Zed…", `priority: medium`); done items stay as the body checklist because the completed-task `state` value is still unknown. `updatedAt` bumped; yaml.v3 ok. Rule updated in `summary.md`.
136+
137+## 2026-09-16 — Closed-task format learned; ticket 0008 tasks completed (orchestrator)
138+
139+- The user added a closed sample task: `state: closed` + `updatedAt` + `closedAt`. Ticket 0008 now has 5 closed tasks (the former body checklist, removed from the body) and 1 open task; sample removed; yaml.v3 ok. Rule in `summary.md` updated; bob informed.
140+
141+## 2026-09-16 — Window mode `mm -tui` (ticket 0012 option B), shared command table, `/help` (agent "bob", methodical-dev)
142+
143+- **Goal**: the user approved option B of ticket 0012: a single-window TUI on tcell, hand-rolled after turbo-core's agent window, plus the prerequisite refactor (one slash-command table) and phase 2 (tool framing, inline permissions, tokens, `/help`).
144+- **Changes**: new `internal/command` (table, `Parse/Is/For/Usage`); new `internal/tui` (11 files: `doc painter wrap transcript render style view draw events picker sink turn tui`); `internal/compact/apply.go` (`Apply` + `Outcome`, `Kilo` exported); `session.AfterTurn`; `store.Keeper` (moved from `internal/agent/persist.go`, tests moved to `store/keeper_test.go`); `internal/agent/agent.go` dispatches on the table and uses `AfterTurn`/`compact.Apply`/`store.Keeper` (`Run` 121 → 110 complexity); `internal/acp/acp.go` `availableCommands()` from the table, `AfterTurn`, `compactHistory` via `Apply`; `internal/config` `TUI`, `Permissions` (+validation, tests); `main.go` `-tui` flag, `windowMode`, `runWindow`, `assembleTools`, `printBanner`, `say`; `go.mod` + tcell v2.13.10 (x/sys 0.34→0.38, x/text 0.27→0.31 as a side effect). Tests: `command_test.go`, `compact/apply_test.go`, `session_test.go` (`TestAfterTurn`), `store/keeper_test.go`, `tui/{transcript,view,turn,tui}_test.go` (simulation screen, canned model, end-to-end loop), `config_test.go`.
145+- **Decisions**: tcell direct (option B) rather than importing turbo-core; `permissions` default `auto` so the line mode is untouched; Ctrl-C keeps the REPL's meaning; Ctrl-J is the documented newline; Enter during a turn cancels and queues; an unstreamed answer is shown from the response (`streamed` flag); banner captured and shown as the first notice; the `hello-world/` dir the user created mid-session is left alone (gofmt/gate flag it). Rejected: modal dialog for permissions (inline prompt replaces the box instead); `/compact` while busy (queued like any input).
146+- **Tests**: `go test -race ./...` — 14 packages ok. Real binary `/tmp/mm-linux`: `printf '/quit\n' | mm -tui` prints the fallback line and runs the REPL; under `python3 pty` the window draws (header, rule, `/help` output, status bar), `/quit` exits 0, alternate screen entered and released.
147+- **Quality**: FAIL on pre-existing debt only — runs 7→9: warnings 69→63 (below the 67 before the feature), smells 22→19 (= before). New-code smells fixed on the way: `styleOf` (table), `Keeper.Resume` (split `locate`), `main` (extracted `assembleTools`, `printBanner`, `windowMode`), De Morgan in a test.
148+- **Docs**: EN+FR `how-to/use-the-window-mode.md` (new); `reference/cli.md` (`-tui` row, "Window mode specifics" table), `reference/slash-commands.md` (`/help` row, "Window mode keys" table), `reference/configuration.md` (`tui`, `permissions`, validation row), `reference/tools.md` (window row for `bash`), `explanation/architecture.md` (three front ends, `command`/`tui` rows, three new paragraphs), `tutorials/getting-started.md` (banner line), both indexes, `docs/diagrams/packages.drawio` (`tui`, `command`, `tcell` nodes + edges). Tickets 0012 (progress body + 5 closed / 2 open tasks) and 0001 (done body + 2 closed tasks; ticket left `open` for the user to close) — IssueSpec task format as the coordinator specified.
149+
150+## 2026-09-16 — Window mode: select and copy text (agent "bob", methodical-dev)
151+
152+- **Goal**: in `mm -tui`, be able to select the generated text in the terminal and copy/paste it elsewhere.
153+- **Cause**: `loop()` called `screen.EnableMouse` at start-up, so the terminal handed every click to mm and its native click-and-drag selection stopped working.
154+- **Changes**: `internal/tui/tui.go` no longer captures the mouse; `view.go`/`events.go`/`draw.go`: **F2** toggles capture (`toggleMouse`), status bar shows `F2 mouse: off` / `mouse: on (Shift+drag selects)`, **Ctrl-Y** = copy hook; new `internal/tui/copy.go` (`copy`, `copyText`), `transcript.go` (`Answers`, `Plain`, `Entry.plain`); new package **`internal/clipboard`** (`Copier`, `New(osc52)`, `Copy` → native tool `pbcopy`/`wl-copy`/`xclip`/`xsel` first, then OSC 52; `OSC52()` helper; `ErrNoClipboard`); `internal/command`: `Copy` (`/copy`, Terminal|Window, TakesArg); new `internal/agent/copy.go` (`copyAnswer`, `modelAnswers`, `terminalOSC52`) wired in `historyCommand`; `internal/skills/skills.go`: `List` skips `README.md` (`isReadme`). Tests: `clipboard_test.go` (6), `transcript_test.go` (+1), `turn_test.go` (+2, `fakeCopier`), `view_test.go` (+2), `agent/copy_test.go` (2), `skills_test.go` (+1), expectations for 8 commands. Docs EN+FR: `how-to/use-the-window-mode.md` ("Copy text" section), `reference/slash-commands.md` (`/copy` row, F2, Ctrl-Y, wheel row), `reference/cli.md` (Mouse row), `reference/skill-format.md` (README skipped), `explanation/architecture.md` (`clipboard` row), `docs/diagrams/packages.drawio` (`clipboard` node, edges from `agent` and `tui`). Ticket 0012: tasks 8–9 closed, 10 open (in-window selection deferred), progress note.
155+- **Decisions**: native tool BEFORE OSC 52 (the brief said the reverse) because Terminal.app ignores OSC 52 silently and the confirmation must not lie; no `tuiMouse` config key (`tui` is a plain bool; F2 is enough) — recorded as an option; `/copy all` window-only (the REPL's scrollback is selectable); in-window drag selection deferred (needs mouse capture, which is the state that kills native selection, and ~300 lines of line↔entry mapping).
156+- **Tests**: `go test -race ./...` — 16 packages ok. Linux build `/tmp/mm-linux` under a Python pty: no mouse-enable sequence at start-up, F2 emits `?1000h/?1006h`, F2 again `?1000l…`, `/copy` on empty → error notice, `/copy all` → `📋 copied … (pbcopy)` with a fake `pbcopy` receiving the text; REPL `/copy`, `/copy all`, `/copy x` print their lines.
157+- **Quality**: gate FAIL on pre-existing debt only — 63 warnings (unchanged), 18 smells (19 → 18), none in the new code. A run in between showed 0 warnings: the `~/.qlty` volume (488 MB) was full and every plugin failed; freed the regenerable caches and re-measured (see summary trap).
158+- **Not verified**: real macOS terminals (Terminal.app / iTerm2 / Ghostty): native selection with the mouse off, Shift+drag with it on, `pbcopy` road, F2 key reporting (tcell reads `\e OQ` / `\e[12~`).
159+
160+## 2026-09-16 — Session close-out (orchestrator)
161+
162+- **Asked**: "sauve ta mémoire". Both memories brought up to date: this `.memory/` (all agents' entries and ten handoffs already present; `summary.md` last edited by bob for the window mode and clipboard) and the orchestrator's private notes.
163+- **State at close**: everything since the first commit is uncommitted in the working tree (`git status` shows the docs, code, tickets, `.memory/`, `blog/` as modified/untracked). `go vet` clean, `go test -race ./...` 16 packages ok, `/tmp/mm-linux` builds. Quality gate FAIL on pre-existing debt only (63 warnings, 18 smells) plus the user's untracked `hello-world/main.go`.
164+- **Pending for the user**: commit; native rebuild on the Mac (`sudo rm /usr/local/bin/mm && ./install.sh`); manual checks listed in `handoffs/2026-09-16-tui-copy.md`, `2026-09-16-tui-window.md` and `2026-09-16-persistent-sessions.md`; decide `hello-world/`; close ticket 0001 if `/help` satisfies it.
165+
166+## 2026-09-16 — Window mode: long pastes collapse into a token, full text to the model (agent "bob", methodical-dev)
167+
168+- **Goal**: in `-tui`, a paste too long for the box shows a `[Pasted]`-style placeholder while the whole text still reaches the agent.
169+- **Changes**: `internal/tui/paste.go` (new: thresholds, `paste`/`pastes`, private-use placeholder runes, `Prompt{Typed, Pastes}` with `Text`/`Display`/`Expand`, `bufferPasteKey`/`finishPaste`), `events.go` (paste keys buffered until the end mark; Backspace/Delete drop the paste behind a token), `view.go` (`hooks.send(Prompt)`, `pastes` field, `Display()`, `setInput` prunes), `draw.go` (`drawInputLine` rune-by-rune with the token style, `cursorX`), `style.go` (`stPaste`), `transcript.go` (`Entry.Full`, `AddUserFull`, `Plain` uses Full), `turn.go` (`submit`/`dispatch`/`startTurn` take a `Prompt`; mentions from `Typed` only). Tests: `view_test.go` (+3, paste helper, short-paste test now covers Tab), `turn_test.go` (+1, callers use `promptOf`). Docs EN+FR: `how-to/use-the-window-mode.md` ("Paste long text" section), `reference/slash-commands.md` (bracketed-paste row). Ticket 0012: Progress 3 + closed task 11.
170+- **Decisions**: threshold = more than `inputHeight` (3) lines or more than 200 chars; token kept in the transcript (readability), full text in history/session/`/copy all` (`Entry.Full`); `@path` inside a paste NOT expanded; pasted Tab inserted as a tab (previously moved the focus); no `/paste` peek command (View/runner split makes it non-trivial — offered as a follow-up); no renumbering after a token is deleted. Rejected: inlining a truncated preview in the box (hides the typed text), storing pastes in the runner (the box owns them until Enter).
171+- **Tests**: `go test -race ./...` 16 packages ok. Pty check with `/tmp/mm-linux -tui`: 50-line bracketed paste → box shows `[Pasted #1 · 50 lines · 1.0k chars]`, no pasted line drawn; Enter → conversation shows `explain [Pasted #1 …`.
172+- **Quality**: gate FAIL on pre-existing debt only — 63 warnings (unchanged), 18 smells (unchanged after moving the paste handlers out of `events.go`, which had hit file-complexity 50 for one run). Nothing excluded, no threshold changed.
173+- **Docs**: EN and FR pages above; no package README exists (rule inert); no new package, diagram unchanged.
@@ -67,3 +67,107 @@ One entry per session, appended, never rewritten.
67 - **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp).67 - **Changes**: new `internal/mention` (`Expand(input, cwd) (string, []Attachment)`, `Attachment.Line()`): mentions start the input or follow a space/bracket/quote, end at a space, lose trailing punctuation; resolved against cwd, absolute, or `~/`; only existing paths attach, once each, as `[attached file: …]` / `[attached directory: …]` lines appended to the text (same shape as ACP `resource_link`). Wired in `internal/agent/agent.go` (cwd captured once in `Run`; `📎 <abs>` dim line per attachment) and `internal/acp/acp.go` (after `flatten`, against `s.cwd`; `[acp] session <id>: attached <path>` on stderr). Tests: 4 in `mention_test.go`. Docs EN+FR: new `how-to/attach-a-file.md`, README indexes, `reference/slash-commands.md` (new `@path` section), `reference/acp.md` (prompt content row), `how-to/use-from-an-editor.md`, `explanation/architecture.md` package table, `docs/diagrams/packages.drawio` (node + edges from agent and acp).
68 - **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text).68 - **Decisions**: keep the sentence as typed and append the marker lines rather than rewriting the `@` token (mirrors what Zed sends: text + resource_link); no inlining of content (bash-first agent reads it); paths with spaces unsupported (documented); non-existing paths silently left alone rather than warned (a `@handle` in a sentence is normal text).
69 - **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path.69 - **Tests**: `go test -race ./...` all green. Linux build in `/tmp`: typing `explain @main.go and @internal, not @nothing.go` prints two `📎` lines and none for the missing path.
70+
71+## 2026-09-16 — Zed "Reload Agent" fails: "Loading or resuming sessions is not supported by this agent" (orchestrator, diagnosis only)
72+
73+- **Cause**: `Initialize` returns `AgentCapabilities{}` (no `loadSession`), and `LoadSession` / `ResumeSession` answer `method not found`. Zed's Reload Agent restarts the process and asks it to reload the current thread via `session/load` (or the unstable `session/resume`); mm keeps sessions in memory only, so it has nothing to give back.
74+- **Proposed, not applied**: persist each ACP session's history to disk and implement `session/load` (replay user/agent messages and tool calls as `session/update`, restore the in-memory history, set `loadSession: true`). Open choice for the user: storage location (`~/.mm/sessions/` vs `<cwd>/.mm/`) and whether to also replay tool calls.
75+
76+## 2026-09-16 — Ticket 0008 body written (orchestrator, as "bob")
77+
78+- **Asked**: put the Reload Agent explanation, in English, into `.tickets/issues/0008-why-in-zed-i-cannot-relaoad-the-agent.yaml`.
79+- **Changes**: `body` filled (symptom, cause, why not a flag, what it would take, open decisions) as a YAML block scalar; `updatedAt` bumped. Validated with yaml.v3. Clarified in the ticket and to the user that "replaying tool calls" re-sends recorded tool-call records for display and never re-executes tools.
80+
81+## 2026-09-16 — Context compression reviewed, ticket 0010 written (agent "riker", read-only on code)
82+
83+- **Asked**: understand how context compression works and write it into `.tickets/issues/0010-check-how-the-context-compression-is-working.yaml` (English).
84+- **Findings** (from `internal/compact`, `internal/agent`, `internal/acp`, `internal/engine`, `internal/config`): hybrid summary + `keepLastTurns` raw turns; two triggers in `compact.Decide` (`tokens×100 ≥ window×threshold` with `tokens = max(Estimate, server usage.input_tokens)`, or `len ≥ maxMessages`); **`Decide` has one call site, the terminal loop — no automatic compaction over ACP**, only `/compact`. Estimate = chars/3.5 + 4/message, ignores tool definitions and template. Failure or nothing-to-compact leaves the history untouched; measured count forgotten only after success; loop detector kept. Defaults: enabled false, threshold 75, maxMessages 80, keepLastTurns 3, summaryMaxTokens 1200, showStats true; `agent.llamacpp.yaml` enables it, `agent.dmr.yaml` does not.
85+- **Doc inaccuracy (not edited)**: `how-to/manage-the-context-window.md` (EN+FR) says the window is re-probed "before the first question"; the code re-probes before every question while unknown. Docs never say the automatic trigger is terminal-only.
86+- **cagent comparison** (`tmp/docker-agent/pkg/compaction`): threshold 0.9, calibrated estimator, token-budgeted keep suffix, reactive overflow compaction (retry once), optional dedicated compaction model.
87+- **Follow-ups suggested in the ticket**: auto-compaction in `acp.Prompt`; persistent sessions must rewrite the file after compaction (for bob's feature); silent nothing-to-compact on the automatic path.
88+- **Validation**: ticket YAML checked with yaml.v3 (`/tmp/yamlcheck`). No Go, YAML config or docs changed.
89+
90+## 2026-09-16 — Blog: two French articles on ACP (agent "milo")
91+
92+- **Asked**: in `blog/`, an explainer on the Agent Client Protocol (step by step, with the lists of ACP agents and editors) and a tutorial on writing a Go "fake agent" that talks to Zed over ACP.
93+- **Changes**: `blog/01-acp-agent-client-protocol.md` (lifecycle with real JSON, mermaid sequence diagram, agents/editors tables from the official registry as of 2026-09-16), `blog/02-fake-agent-acp-en-go.md` (tutorial + full source + real wire transcript), `blog/fake-agent/{main.go,go.mod,go.sum}` (separate Go module, builds standalone with acp-go-sdk v0.13.5; the root module ignores it — `go build ./... && go vet ./...` at the root still pass).
94+- **Verified**: fake agent driven by a scripted JSON-RPC client: initialize → session/new → available_commands_update AFTER the response → prompt with tool_call/tool_call_update + word-by-word chunks → `/hello` → prompt cancelled by session/cancel answered `cancelled`. Network was available: agentclientprotocol.com registry pages, Zed and JetBrains blogs fetched.
95+- **Not touched**: code, docs, config, tickets, summary.md.
96+
97+## 2026-09-16 — TUI improvement proposals written into ticket 0003 (agent "homer", read-only review)
98+
99+- **Asked**: think about improving mm's terminal UI, write proposals into `.tickets/issues/0003-improve-tui.yaml`, implement nothing.
100+- **Findings** (code): stdin read line by line with `bufio` (no editing, no history, multi-line paste = one question per line, each cancelling the previous turn); output is `fmt.Print` + two ANSI codes gated by `spinner.Styled()`; no permission step in the terminal while ACP has one (`sink.Allow`); recap counts commands but not tokens; only help is the banner. cagent: full Bubble Tea v2 TUI (`tmp/docker-agent/pkg/tui`) plus a `--lean` scrollback-friendly mode (`pkg/leantui`, lipgloss + x/ansi only, pinned status footer with tokens/cost).
101+- **Written**: ticket 0003 body — current state, 8 pain points, cagent comparison, 18 proposals in three tiers with size/dependency/landing package (tier 1 no-dependency: multi-line input, `/help` `/about`, tokens in recap, `/status`, tool framing, diff colours, light Markdown, `/clear`; tier 2: readline+history, terminal permission prompt via a terminal `ui.Sink`, session browser after 0008, glamour, lean status footer, `$EDITOR`, bell; tier 3: Bubble Tea — recommended "not now"), recommended order. Labels `kind::feature`, `priority::medium`. YAML validated with yaml.v3.
102+- **Not changed**: no Go code, config, docs.
103+
104+## 2026-09-16 — Persistent per-project sessions, ACP `session/load` with full replay (agent "bob", methodical-dev)
105+
106+- **Goal**: sessions saved per project, switchable in the YAML, usable over ACP (Zed Reload Agent), `/new` starts a new session, replay everything; decide the restart behaviour from docker agent's source.
107+- **docker agent facts** (`tmp/docker-agent`): SQLite store at `<data-dir>/session.db` = `~/.cagent/session.db` (`cmd/root/flags.go`, `pkg/paths/paths.go`); ACP `LoadSession: false` + `method not found`, advertises unstable `SessionCapabilities{List, Resume, Close}`; `ResumeSession` re-registers the stored session with a new runtime, replays nothing (`pkg/acp/agent.go`); `cagent run` never auto-resumes — `--session <id|-1>` flag, TUI `/sessions` browser (`cmd/root/run.go`, `pkg/tui/tui.go`).
108+- **Changes**: new `internal/store` (`Session`, `Summary`, `Open/New/Save/Load/List/Latest`, `NewID`, atomic write) + tests; `internal/config` `SessionsConfig{Enabled, Dir}` + validation + `config_test.go` (first tests for config); YAML `sessions:` block in `agent.dmr.yaml` and `agent.llamacpp.yaml`; `internal/session.WithSystem`; `internal/acp/persist.go` (`AgentLoader`, `LoadSession`, `storeFor`, `persistNew`, `persist`, `archive`), `internal/acp/replay.go` (`replayUpdates`, `toolPresentation`), `acp.go` (store ids, `loadSession` capability, save points, `slashCommand` extraction, `nextID` removed, `lastFront` test hook); `internal/agent/persist.go` (`keeper`: lazy file, `save`, `renew`, `resume`, `listing`, `hint`), `agent.go` (`loop.historyCommand` extraction, `/sessions`, `/resume`, banner text); `.gitignore` `.mm/`.
109+- **Decisions**: restart = new session + hint (docker agent parity, stale-context safety); terminal file created on first save; bare `/resume` skips the current and empty sessions; ACP `/new` archives under a new id (editor keeps its id); stored prompt replaced by the current one; summaries replayed as agent text; tool calls without recorded response replayed `failed`; write failures logged, never fatal; ids `YYYYMMDD-HHMMSS-<8 hex>` shared by both front ends. Rejected: auto-resume, append-only log, SQLite, unstable `session/resume`, text-only replay.
110+- **Tests**: `go test -race ./...` — 13 packages ok. New: store (5), config (1), session (1), acp replay (3) + persist (4 pipe-driven, incl. replay order and refusals), agent keeper (3). Real Linux build (`/tmp/mm-linux`, never `./mm`) driven by `/tmp/drive_sessions.py`: `loadSession: true`, file at `session/new`, `/new` on empty archives nothing, seeded `session/load` replays `user_message_chunk, tool_call[completed], agent_message_chunk, available_commands_update` then `{}`, `/new` after load keeps the old conversation under a new id; terminal `/sessions`, `/resume` exercised in `/tmp/mmproj`.
111+- **Quality**: gate FAIL, unchanged debt: warnings 67 (= before the feature), smells 19 = the 18 pre-existing + 1 in the user's new untracked `blog/fake-agent/main.go` (out of scope). This feature adds 0 warnings / 0 smells after three iterations (errcheck in a test, `store.Save` returns → `writeAtomic`, `historyCommand` params → `loop` struct, `acp.Prompt` returns → `slashCommand`); `agent.Run` complexity 121 → 120.
112+- **Docs (EN+FR)**: new `how-to/keep-sessions.md`, new `explanation/persistent-sessions.md`; updated `reference/configuration.md` (`sessions` keys + validation), `reference/acp.md` (capabilities, `session/new`, `session/load`, replay table, `/new`, notifications), `reference/slash-commands.md` (`/sessions`, `/resume`, `/new` file note), `reference/cli.md` (start-up line), `how-to/start-a-new-session.md`, `how-to/use-from-an-editor.md`, `explanation/architecture.md` (`store` row), both README indexes, `docs/diagrams/packages.drawio` (`store` node, edges from agent and acp). Ticket 0008: task list + comment.
113+
114+## 2026-09-16 — How to enable sessions, written into `agent.llamacpp.yaml` (orchestrator, as "bob")
115+
116+- **Asked**: explain in `agent.llamacpp.yaml` how to enable persistent sessions.
117+- **Changes**: the comment block above `sessions:` rewritten as a 4-step "how to turn them on" (set `enabled: true`, restart from the project dir / point Zed's `AGENT_CONFIG` at the file and reload, check the `sessions:` banner line, gitignore `.mm/`) followed by what the feature does. Values unchanged (`enabled: false`, `dir: .mm/sessions`). File still parses (banner shows `config: agent.llamacpp.yaml`). `agent.dmr.yaml` keeps bob's shorter comment.
118+
119+## 2026-09-16 — Single-window TUI proposal from the Turbo editors' agent window, ticket 0012 (agent "homer", read-only)
120+
121+- **Asked**: study turbo-core / turbo-go's UI and ACP agent window, propose a one-window mode with an input zone for mini-me in ticket 0012; implement nothing.
122+- **Findings**: both repos MIT; stack = `tcell/v2` + `BurntSushi/toml` + `x/sys` only, hand-written Turbo Vision widgets (`turbo-core/ui`, absolute bounds, clipping painters, `Widget` contract, state-driven event loop with `PostEvent` wake). The agent window is `turbo-core/acp/view*.go`: transcript above, rule with state/key hints, 3-row multi-line input box; Enter sends, Alt-Enter newline, Tab focus, Esc cancels the turn, Ctrl-C copies; `Transcript` coalesces chunks and folds tool updates; permissions are queued and answered by a modal dialog on the loop goroutine; `/` and `@` open an 8-row picker. turbo-go is only the profile + Go scanner; its `acp-agent/` is a cagent config used to test the window.
123+- **Proposal written**: optional `mm -tui` mode, third front end `internal/tui` implementing `ui.Sink`; ASCII mockup; keybindings (Ctrl-J for newline — Shift-Enter undetectable); three options (A import turbo-core, B tcell direct, C Bubble Tea) with a comparison table; **recommended B** with A as fallback; prerequisite = extract `agent.Run`'s command table; three phases; risks. Labels `kind::feature`, `priority::medium`. YAML validated with yaml.v3.
124+- Clones kept under `/tmp/turbo/` (not in the repo). No code, docs, config or other tickets touched.
125+
126+## 2026-09-16 — Ticket 0008 format repaired for IssueSpec (orchestrator, as "milo")
127+
128+- **Asked**: the IssueSpec VS Code extension no longer opened ticket 0008.
129+- **Cause (inferred, schema unavailable)**: the only structural change was bob's hand-written `tasks:` items (`text`/`done`) and a `comments:` item (`author.name`/`createdAt`/`body`) — a format no extension-generated ticket uses; the extension's schema for those arrays is unknown (not documented online, extension not installed in the sandbox).
130+- **Changes**: both arrays reset to `[]`; the six tasks became a `## Progress` checklist and the comment an `## Implementation note` section inside `body`. Top-level keys now match a pristine ticket (`id title state author createdAt updatedAt labels body tasks comments`); yaml.v3 validates. Bob (running on ticket 0012) was told to keep `tasks`/`comments` empty in every ticket.
131+- **Rule for future sessions**: never hand-write `tasks`/`comments` items; use the body. If the user adds a task through the extension, copy that format.
132+
133+## 2026-09-16 — IssueSpec task format learned, applied to ticket 0008 (orchestrator)
134+
135+- The user added a sample task through the extension: `id`, `title`, `state: open`, `priority: none`, `author {name, email}`, `createdAt`. Replaced the sample with the one real open task ("Verify in a real Zed…", `priority: medium`); done items stay as the body checklist because the completed-task `state` value is still unknown. `updatedAt` bumped; yaml.v3 ok. Rule updated in `summary.md`.
136+
137+## 2026-09-16 — Closed-task format learned; ticket 0008 tasks completed (orchestrator)
138+
139+- The user added a closed sample task: `state: closed` + `updatedAt` + `closedAt`. Ticket 0008 now has 5 closed tasks (the former body checklist, removed from the body) and 1 open task; sample removed; yaml.v3 ok. Rule in `summary.md` updated; bob informed.
140+
141+## 2026-09-16 — Window mode `mm -tui` (ticket 0012 option B), shared command table, `/help` (agent "bob", methodical-dev)
142+
143+- **Goal**: the user approved option B of ticket 0012: a single-window TUI on tcell, hand-rolled after turbo-core's agent window, plus the prerequisite refactor (one slash-command table) and phase 2 (tool framing, inline permissions, tokens, `/help`).
144+- **Changes**: new `internal/command` (table, `Parse/Is/For/Usage`); new `internal/tui` (11 files: `doc painter wrap transcript render style view draw events picker sink turn tui`); `internal/compact/apply.go` (`Apply` + `Outcome`, `Kilo` exported); `session.AfterTurn`; `store.Keeper` (moved from `internal/agent/persist.go`, tests moved to `store/keeper_test.go`); `internal/agent/agent.go` dispatches on the table and uses `AfterTurn`/`compact.Apply`/`store.Keeper` (`Run` 121 → 110 complexity); `internal/acp/acp.go` `availableCommands()` from the table, `AfterTurn`, `compactHistory` via `Apply`; `internal/config` `TUI`, `Permissions` (+validation, tests); `main.go` `-tui` flag, `windowMode`, `runWindow`, `assembleTools`, `printBanner`, `say`; `go.mod` + tcell v2.13.10 (x/sys 0.34→0.38, x/text 0.27→0.31 as a side effect). Tests: `command_test.go`, `compact/apply_test.go`, `session_test.go` (`TestAfterTurn`), `store/keeper_test.go`, `tui/{transcript,view,turn,tui}_test.go` (simulation screen, canned model, end-to-end loop), `config_test.go`.
145+- **Decisions**: tcell direct (option B) rather than importing turbo-core; `permissions` default `auto` so the line mode is untouched; Ctrl-C keeps the REPL's meaning; Ctrl-J is the documented newline; Enter during a turn cancels and queues; an unstreamed answer is shown from the response (`streamed` flag); banner captured and shown as the first notice; the `hello-world/` dir the user created mid-session is left alone (gofmt/gate flag it). Rejected: modal dialog for permissions (inline prompt replaces the box instead); `/compact` while busy (queued like any input).
146+- **Tests**: `go test -race ./...` — 14 packages ok. Real binary `/tmp/mm-linux`: `printf '/quit\n' | mm -tui` prints the fallback line and runs the REPL; under `python3 pty` the window draws (header, rule, `/help` output, status bar), `/quit` exits 0, alternate screen entered and released.
147+- **Quality**: FAIL on pre-existing debt only — runs 7→9: warnings 69→63 (below the 67 before the feature), smells 22→19 (= before). New-code smells fixed on the way: `styleOf` (table), `Keeper.Resume` (split `locate`), `main` (extracted `assembleTools`, `printBanner`, `windowMode`), De Morgan in a test.
148+- **Docs**: EN+FR `how-to/use-the-window-mode.md` (new); `reference/cli.md` (`-tui` row, "Window mode specifics" table), `reference/slash-commands.md` (`/help` row, "Window mode keys" table), `reference/configuration.md` (`tui`, `permissions`, validation row), `reference/tools.md` (window row for `bash`), `explanation/architecture.md` (three front ends, `command`/`tui` rows, three new paragraphs), `tutorials/getting-started.md` (banner line), both indexes, `docs/diagrams/packages.drawio` (`tui`, `command`, `tcell` nodes + edges). Tickets 0012 (progress body + 5 closed / 2 open tasks) and 0001 (done body + 2 closed tasks; ticket left `open` for the user to close) — IssueSpec task format as the coordinator specified.
149+
150+## 2026-09-16 — Window mode: select and copy text (agent "bob", methodical-dev)
151+
152+- **Goal**: in `mm -tui`, be able to select the generated text in the terminal and copy/paste it elsewhere.
153+- **Cause**: `loop()` called `screen.EnableMouse` at start-up, so the terminal handed every click to mm and its native click-and-drag selection stopped working.
154+- **Changes**: `internal/tui/tui.go` no longer captures the mouse; `view.go`/`events.go`/`draw.go`: **F2** toggles capture (`toggleMouse`), status bar shows `F2 mouse: off` / `mouse: on (Shift+drag selects)`, **Ctrl-Y** = copy hook; new `internal/tui/copy.go` (`copy`, `copyText`), `transcript.go` (`Answers`, `Plain`, `Entry.plain`); new package **`internal/clipboard`** (`Copier`, `New(osc52)`, `Copy` → native tool `pbcopy`/`wl-copy`/`xclip`/`xsel` first, then OSC 52; `OSC52()` helper; `ErrNoClipboard`); `internal/command`: `Copy` (`/copy`, Terminal|Window, TakesArg); new `internal/agent/copy.go` (`copyAnswer`, `modelAnswers`, `terminalOSC52`) wired in `historyCommand`; `internal/skills/skills.go`: `List` skips `README.md` (`isReadme`). Tests: `clipboard_test.go` (6), `transcript_test.go` (+1), `turn_test.go` (+2, `fakeCopier`), `view_test.go` (+2), `agent/copy_test.go` (2), `skills_test.go` (+1), expectations for 8 commands. Docs EN+FR: `how-to/use-the-window-mode.md` ("Copy text" section), `reference/slash-commands.md` (`/copy` row, F2, Ctrl-Y, wheel row), `reference/cli.md` (Mouse row), `reference/skill-format.md` (README skipped), `explanation/architecture.md` (`clipboard` row), `docs/diagrams/packages.drawio` (`clipboard` node, edges from `agent` and `tui`). Ticket 0012: tasks 8–9 closed, 10 open (in-window selection deferred), progress note.
155+- **Decisions**: native tool BEFORE OSC 52 (the brief said the reverse) because Terminal.app ignores OSC 52 silently and the confirmation must not lie; no `tuiMouse` config key (`tui` is a plain bool; F2 is enough) — recorded as an option; `/copy all` window-only (the REPL's scrollback is selectable); in-window drag selection deferred (needs mouse capture, which is the state that kills native selection, and ~300 lines of line↔entry mapping).
156+- **Tests**: `go test -race ./...` — 16 packages ok. Linux build `/tmp/mm-linux` under a Python pty: no mouse-enable sequence at start-up, F2 emits `?1000h/?1006h`, F2 again `?1000l…`, `/copy` on empty → error notice, `/copy all` → `📋 copied … (pbcopy)` with a fake `pbcopy` receiving the text; REPL `/copy`, `/copy all`, `/copy x` print their lines.
157+- **Quality**: gate FAIL on pre-existing debt only — 63 warnings (unchanged), 18 smells (19 → 18), none in the new code. A run in between showed 0 warnings: the `~/.qlty` volume (488 MB) was full and every plugin failed; freed the regenerable caches and re-measured (see summary trap).
158+- **Not verified**: real macOS terminals (Terminal.app / iTerm2 / Ghostty): native selection with the mouse off, Shift+drag with it on, `pbcopy` road, F2 key reporting (tcell reads `\e OQ` / `\e[12~`).
159+
160+## 2026-09-16 — Session close-out (orchestrator)
161+
162+- **Asked**: "sauve ta mémoire". Both memories brought up to date: this `.memory/` (all agents' entries and ten handoffs already present; `summary.md` last edited by bob for the window mode and clipboard) and the orchestrator's private notes.
163+- **State at close**: everything since the first commit is uncommitted in the working tree (`git status` shows the docs, code, tickets, `.memory/`, `blog/` as modified/untracked). `go vet` clean, `go test -race ./...` 16 packages ok, `/tmp/mm-linux` builds. Quality gate FAIL on pre-existing debt only (63 warnings, 18 smells) plus the user's untracked `hello-world/main.go`.
164+- **Pending for the user**: commit; native rebuild on the Mac (`sudo rm /usr/local/bin/mm && ./install.sh`); manual checks listed in `handoffs/2026-09-16-tui-copy.md`, `2026-09-16-tui-window.md` and `2026-09-16-persistent-sessions.md`; decide `hello-world/`; close ticket 0001 if `/help` satisfies it.
165+
166+## 2026-09-16 — Window mode: long pastes collapse into a token, full text to the model (agent "bob", methodical-dev)
167+
168+- **Goal**: in `-tui`, a paste too long for the box shows a `[Pasted]`-style placeholder while the whole text still reaches the agent.
169+- **Changes**: `internal/tui/paste.go` (new: thresholds, `paste`/`pastes`, private-use placeholder runes, `Prompt{Typed, Pastes}` with `Text`/`Display`/`Expand`, `bufferPasteKey`/`finishPaste`), `events.go` (paste keys buffered until the end mark; Backspace/Delete drop the paste behind a token), `view.go` (`hooks.send(Prompt)`, `pastes` field, `Display()`, `setInput` prunes), `draw.go` (`drawInputLine` rune-by-rune with the token style, `cursorX`), `style.go` (`stPaste`), `transcript.go` (`Entry.Full`, `AddUserFull`, `Plain` uses Full), `turn.go` (`submit`/`dispatch`/`startTurn` take a `Prompt`; mentions from `Typed` only). Tests: `view_test.go` (+3, paste helper, short-paste test now covers Tab), `turn_test.go` (+1, callers use `promptOf`). Docs EN+FR: `how-to/use-the-window-mode.md` ("Paste long text" section), `reference/slash-commands.md` (bracketed-paste row). Ticket 0012: Progress 3 + closed task 11.
170+- **Decisions**: threshold = more than `inputHeight` (3) lines or more than 200 chars; token kept in the transcript (readability), full text in history/session/`/copy all` (`Entry.Full`); `@path` inside a paste NOT expanded; pasted Tab inserted as a tab (previously moved the focus); no `/paste` peek command (View/runner split makes it non-trivial — offered as a follow-up); no renumbering after a token is deleted. Rejected: inlining a truncated preview in the box (hides the typed text), storing pastes in the runner (the box owns them until Enter).
171+- **Tests**: `go test -race ./...` 16 packages ok. Pty check with `/tmp/mm-linux -tui`: 50-line bracketed paste → box shows `[Pasted #1 · 50 lines · 1.0k chars]`, no pasted line drawn; Enter → conversation shows `explain [Pasted #1 …`.
172+- **Quality**: gate FAIL on pre-existing debt only — 63 warnings (unchanged), 18 smells (unchanged after moving the paste handlers out of `events.go`, which had hit file-complexity 50 for one run). Nothing excluded, no threshold changed.
173+- **Docs**: EN and FR pages above; no package README exists (rule inert); no new package, diagram unchanged.
modified .memory/summary.md +16 -6
@@ -2,11 +2,11 @@
22
33 ## What it is
44
5-A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.
5+A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Three front ends share one loop: a terminal REPL (`internal/agent`), a single-window terminal mode (`internal/tui`, `github.com/gdamore/tcell/v2` v2.13.10, selected with `mm -tui` or `tui: true`, added 2026-09-16) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.
66
77 ## Architecture (verified 2026-09-15)
88
9-`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history).
9+`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `clipboard` (system clipboard: native tool on PATH, then OSC 52; reports which), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history; also `AfterTurn`, the history-merge rule all front ends use), `mention` (`@path`, 2026-09-15), `store` (per-project session files, 2026-09-16; `store.Keeper` is the REPL's and the window's side of persistence — moved out of `internal/agent` on 2026-09-16 — the ACP façade has `acp/persist.go` + `acp/replay.go`), `command` (the one slash-command table with per-front-end bits, 2026-09-16), `tui` (the window: `painter/wrap/transcript/render/view/draw/events/picker/sink/turn/tui.go`, Turbo's file layout, 2026-09-16). `compact.Apply` is the one compaction-with-report entry point the three front ends call.
1010
1111 ## Commands
1212
@@ -15,8 +15,10 @@ A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26
1515 | Build | `go build -o mm .` |
1616 | Install | `./install.sh` (builds, copies to `/usr/local/bin`) |
1717 | Test | `go test ./...` |
18-| Run (DMR) | `./mm` (reads `./agent.yaml`) |
18+| Run (DMR) | `./mm` (reads `./agent.yaml`; the repo's file is `agent.dmr.yaml`, pass it explicitly) |
19+| Sessions (terminal) | `sessions.enabled: true` in the YAML, then `/sessions`, `/resume [id]`, `/new` |
1920 | Run (llama.cpp) | `./mm agent.llamacpp.yaml` |
21+| Window mode | `mm -tui agent.dmr.yaml` (or `tui: true`); `mm -tui | tee` falls back to the REPL |
2022 | Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` |
2123 | Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) |
2224
@@ -34,17 +36,22 @@ Use `bash -l -c "…"` in this sandbox so `go` is on the PATH.
3436 - `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct).
3537 - Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`.
3638 - Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block.
37-- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.
39+- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file; a `README.md` at the skills root is skipped (2026-09-16: the user's `skills/README.md` had been counted as a skill). A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.
3840 - `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends.
41+- Persistent sessions (2026-09-16, user's spec): per project, `sessions.enabled` (default false) + `sessions.dir` (default `.mm/sessions`, relative to the start directory / ACP session cwd), one JSON file per session (`internal/store`, id = UTC time + 4 random bytes, whole atomic rewrite after every turn, `/compact`, `/new`). Restart = NEW session (mirrors docker agent: no auto-resume); terminal `/resume [id]` (bare = latest other non-empty) and `/sessions`; the terminal creates the file on the first save, not at start. ACP: `loadSession: true` when enabled, `session/load` replays everything (user/agent text, tool calls as completed `tool_call` with recorded input/output; summaries as `🗜️` agent text; tools never re-run) BEFORE the response, then `available_commands_update`; `/new` over ACP archives the old conversation under a new id and reuses the ACP id's file. A resumed/loaded history runs under the CURRENT system prompt (`session.WithSystem`). `session/load` implemented via `sdk.AgentLoader` (separate interface from `sdk.Agent`). docker agent's approach (SQLite in `~/.cagent/session.db`, unstable `session/resume` without replay, `loadSession: false`) was read and deliberately not copied.
42+- Tickets (`.tickets/issues/*.yaml`, IssueSpec extension): edit `title`, `state`, `labels` (from `labels.yaml`), `updatedAt`, the Markdown `body`, and `tasks` in the extension's own item format (examples from the user, 2026-09-16): open task = `- id: <int>` / `title:` / `state: open` / `priority: none|low|medium|high` / `author: {name: k33g, email: ph.charriere@gmail.com}` / `createdAt: <ISO ms Z>`; closed task = same plus `state: closed`, `updatedAt:` and `closedAt:`. Quote titles containing `:` or `#`. `comments` item format still unknown: keep `comments: []`.
43+- Window mode (`internal/tui`, ticket 0012 option B, user's choice 2026-09-16): hand-rolled on tcell, no widget library, Turbo-core's agent window as blueprint. Rules: only the event loop goroutine draws; engine/tool goroutines write the mutex-guarded `Transcript` and `PostEvent(EventInterrupt)`; redraw is state-driven from the transcript every frame. The window is a `ui.Sink` (tools unchanged). Keys: Enter sends, **Ctrl-J**/Alt-Enter newline (Shift-Enter is not detectable), Esc cancels, Ctrl-C = REPL meaning (quit idle / cancel busy — not Turbo's copy), Tab focus, PgUp/PgDn, Ctrl-L clears the VIEW only, F1 = /help, **F2 toggles mouse capture (OFF at start-up so the terminal's own click-and-drag selection works; on = wheel scrolling, Shift+drag selects; state in the status bar)**, **Ctrl-Y = /copy**, `a`/`s`/`r` answer a permission. Enter during a turn cancels it and queues the text (REPL's re-inject rule). Fallback to the REPL when stdin/stdout is not a TTY, decided in `main.go:windowMode` (banner captured into a buffer and shown as the first notice; spinner disabled). `/copy [n|all]` (window: last answer / n-th / whole transcript as plain text; REPL: `/copy [n]` only) goes through `internal/clipboard` — native tool first, OSC 52 fallback (Terminal.app ignores OSC 52; iTerm2/Ghostty honour it) — and the notice names the road. Long pastes (2026-09-16): a bracketed paste is buffered until its end mark (`paste.go`); over 3 lines or 200 chars it becomes one private-use-rune token `[Pasted #n · L lines · C chars]` (atomic for the cursor/Backspace/Delete, numbered per message); Enter hands the runner a `Prompt{Typed, Pastes}` — `Text()` to the model (verbatim), `Display()` to the transcript, `Entry.Full` for `/copy all`/`Plain()`; `@path` is looked up in the typed text only. Not in scope yet: session browser, in-window drag selection, Markdown beyond fences, `/paste n` peek.
44+- `permissions: ask | auto` (default `auto`): read by the window mode only; `ask` gates `bash`/`write_file`/`edit_file` with an inline prompt replacing the box, "allow for this session" remembered per tool; a cancelled turn rejects. Line mode still has no gatekeeper; ACP keeps its dialog.
45+- Slash commands come from ONE table, `internal/command` (`Terminal | Editor | Window` bits): REPL and window get all seven (`quit abort compact new sessions resume help`), ACP advertises only `compact`, `new`. `/help` exists in the two terminal modes (ticket 0001 done). Add a command there, nowhere else.
3946 - The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired.
4047
4148 ## Known defects (verified 2026-09-15, not fixed)
4249
4350 - None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.)
4451
45-## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`)
52+## Quality gate state (2026-09-16, 9 runs in `.quality/history.jsonl`)
4653
47-FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code.
54+FAIL on pre-existing debt only: 63 warnings (was 67 before the window mode; `main.go`'s errcheck cleared by `say()`), 19 smells — none in `internal/tui`, `internal/command`, `internal/store`, `main.go`; `blog/fake-agent/main.go` (milo's) and `hello-world/main.go` (user's untracked file) are the only findings outside `internal/`. Earlier state, kept for the record: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code. 2026-09-16 (copy work): 63 warnings, 18 smells, none in `internal/clipboard`, `internal/tui/copy.go`, `internal/agent/copy.go`. **Trap**: the `~/.qlty` volume is 488 MB and was 100 % full (Go toolchain 251 MB + linters); every plugin then "FATAL"s and the report shows 0 warnings — a tooling failure that looks like an improvement. Fix: `rm -rf ~/.qlty/cache/repos/*/plugin_cachedir/* ~/.qlty/cache/repos/*/out/invoke-*.yaml` (regenerable caches), then re-run.
4855
4956 ## Repository oddities
5057
@@ -54,5 +61,8 @@ FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47
5461
5562 ## Not yet established
5663
64+- The window mode on a real macOS terminal (Terminal.app, iTerm2, Ghostty, Zed's terminal, tmux): Ctrl-J / Alt-Enter detection, Esc latency, resize, colours on light themes. Verified here only on a Linux pseudo-terminal (`python3 pty`) and `tcell.SimulationScreen`.
65+
5766 - Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised).
67+- Whether Zed's Reload Agent renders the `session/load` replay as expected (verified only with a scripted JSON-RPC client, `/tmp/drive_sessions.py` pattern in history 2026-09-16).
5868 - Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings.
@@ -2,11 +2,11 @@
2 2
3 ## What it is3 ## What it is
4 4
5-A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Two front ends share one loop: a terminal REPL (`internal/agent`) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.5+A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26.5) that drives a local LLM through the OpenAI-compatible chat-completions API with Genkit (`github.com/firebase/genkit/go` v1.10.0). Three front ends share one loop: a terminal REPL (`internal/agent`), a single-window terminal mode (`internal/tui`, `github.com/gdamore/tcell/v2` v2.13.10, selected with `mm -tui` or `tui: true`, added 2026-09-16) and an Agent Client Protocol façade (`internal/acp`, `github.com/coder/acp-go-sdk` v0.13.5) selected with `mm -acp`.
6 6
7 ## Architecture (verified 2026-09-15)7 ## Architecture (verified 2026-09-15)
8 8
9-`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history).9+`main.go` does the wiring only. Packages under `internal/`: `config` (defaults + YAML + `AGENT_*` env overrides), `clipboard` (system clipboard: native tool on PATH, then OSC 52; reports which), `engine` (provider registry `dmr`/`llamacpp`, streaming, watchdog, non-streaming retry, `Summarize`), `tools` (`bash`, `read_skill`, `read_file`, `write_file`, `edit_file`), `fileedit` (exact-replacement editing), `skills` (catalogue from `<skillsDir>/*.md` front matter), `detector` (repeated-action detection), `compact` (history compression), `spinner`, `ui` (`Out` writer + `Sink` event interface the ACP façade implements), `agent`, `acp`, `session` (added 2026-09-15 by the `/new` work — see history; also `AfterTurn`, the history-merge rule all front ends use), `mention` (`@path`, 2026-09-15), `store` (per-project session files, 2026-09-16; `store.Keeper` is the REPL's and the window's side of persistence — moved out of `internal/agent` on 2026-09-16 — the ACP façade has `acp/persist.go` + `acp/replay.go`), `command` (the one slash-command table with per-front-end bits, 2026-09-16), `tui` (the window: `painter/wrap/transcript/render/view/draw/events/picker/sink/turn/tui.go`, Turbo's file layout, 2026-09-16). `compact.Apply` is the one compaction-with-report entry point the three front ends call.
10 10
11 ## Commands11 ## Commands
12 12
@@ -15,8 +15,10 @@ A minimal Go coding agent (`module mm`, Go 1.25 in `go.mod`, builds with Go 1.26
15 | Build | `go build -o mm .` |15 | Build | `go build -o mm .` |
16 | Install | `./install.sh` (builds, copies to `/usr/local/bin`) |16 | Install | `./install.sh` (builds, copies to `/usr/local/bin`) |
17 | Test | `go test ./...` |17 | Test | `go test ./...` |
18-| Run (DMR) | `./mm` (reads `./agent.yaml`) |18+| Run (DMR) | `./mm` (reads `./agent.yaml`; the repo's file is `agent.dmr.yaml`, pass it explicitly) |
19+| Sessions (terminal) | `sessions.enabled: true` in the YAML, then `/sessions`, `/resume [id]`, `/new` |
19 | Run (llama.cpp) | `./mm agent.llamacpp.yaml` |20 | Run (llama.cpp) | `./mm agent.llamacpp.yaml` |
21+| Window mode | `mm -tui agent.dmr.yaml` (or `tui: true`); `mm -tui | tee` falls back to the REPL |
20 | Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` |22 | Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` |
21 | Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) |23 | Quality gate | `python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace .` (report under `.quality/`, exit 0 = pass) |
22 24
@@ -34,17 +36,22 @@ Use `bash -l -c "…"` in this sandbox so `go` is on the PATH.
34 - `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct).36 - `/new` resets the history (system prompt alone), the engine's last input-token count, and — terminal only — the loop detector (`detector.Reset`): a new session is a new task, unlike `/compact` which keeps the detector. Over ACP it keeps the session id, `cwd` and "allow always" grants. Shared logic lives in `internal/session`, imported as `history` inside `internal/acp` (name clash with its `session` struct).
35 - Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`.37 - Quality gate scope: `.qlty/qlty.toml` excludes `kits/**` and `tmp/**` (user's request, 2026-09-15), drops qlty's default `**/config/**` exclusion so `internal/config` is measured, and enables `gofmt`, `golangci-lint`, `osv-scanner` by hand (`qlty init` only saw tracked files, hence no Go). Do not re-run `qlty init`.
36 - Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block.38 - Zed `agent_servers` entry for mm must carry `"type": "custom"` alongside `command`/`args`/`env`; without it Zed rejects the entry (user report, 2026-09-15). Both `how-to/use-from-an-editor.md` pages show the full block.
37-- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file. A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.39+- Skills: two layouts, flat `<name>.md` and `<name>/SKILL.md` (Agent Skills convention, the shipped one); `Read` prefers the flat file; a `README.md` at the skills root is skipped (2026-09-16: the user's `skills/README.md` had been counted as a skill). A relative `skillsDir` is made absolute in `main.go` (`resolveSkillsDir`): from the current directory (terminal, or ACP without a config file) or next to the config file (ACP with one); the banner warns `no skills found in <abs path>` when the count is 0.
38 - `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends.40 - `@path` mentions (`internal/mention`, `Expand(input, cwd)`): a typed `@path` that exists (relative to the start directory / session cwd, absolute, or `~/`) appends `[attached file: <abs>]` / `[attached directory: <abs>]` to the message — the exact shape of an ACP `resource_link` — and is echoed (`📎` line in the terminal, `[acp] … attached` on stderr). No picker, no completion, content never inlined; a non-existing path or a `@` glued to a word is left alone. Used by both front ends.
41+- Persistent sessions (2026-09-16, user's spec): per project, `sessions.enabled` (default false) + `sessions.dir` (default `.mm/sessions`, relative to the start directory / ACP session cwd), one JSON file per session (`internal/store`, id = UTC time + 4 random bytes, whole atomic rewrite after every turn, `/compact`, `/new`). Restart = NEW session (mirrors docker agent: no auto-resume); terminal `/resume [id]` (bare = latest other non-empty) and `/sessions`; the terminal creates the file on the first save, not at start. ACP: `loadSession: true` when enabled, `session/load` replays everything (user/agent text, tool calls as completed `tool_call` with recorded input/output; summaries as `🗜️` agent text; tools never re-run) BEFORE the response, then `available_commands_update`; `/new` over ACP archives the old conversation under a new id and reuses the ACP id's file. A resumed/loaded history runs under the CURRENT system prompt (`session.WithSystem`). `session/load` implemented via `sdk.AgentLoader` (separate interface from `sdk.Agent`). docker agent's approach (SQLite in `~/.cagent/session.db`, unstable `session/resume` without replay, `loadSession: false`) was read and deliberately not copied.
42+- Tickets (`.tickets/issues/*.yaml`, IssueSpec extension): edit `title`, `state`, `labels` (from `labels.yaml`), `updatedAt`, the Markdown `body`, and `tasks` in the extension's own item format (examples from the user, 2026-09-16): open task = `- id: <int>` / `title:` / `state: open` / `priority: none|low|medium|high` / `author: {name: k33g, email: ph.charriere@gmail.com}` / `createdAt: <ISO ms Z>`; closed task = same plus `state: closed`, `updatedAt:` and `closedAt:`. Quote titles containing `:` or `#`. `comments` item format still unknown: keep `comments: []`.
43+- Window mode (`internal/tui`, ticket 0012 option B, user's choice 2026-09-16): hand-rolled on tcell, no widget library, Turbo-core's agent window as blueprint. Rules: only the event loop goroutine draws; engine/tool goroutines write the mutex-guarded `Transcript` and `PostEvent(EventInterrupt)`; redraw is state-driven from the transcript every frame. The window is a `ui.Sink` (tools unchanged). Keys: Enter sends, **Ctrl-J**/Alt-Enter newline (Shift-Enter is not detectable), Esc cancels, Ctrl-C = REPL meaning (quit idle / cancel busy — not Turbo's copy), Tab focus, PgUp/PgDn, Ctrl-L clears the VIEW only, F1 = /help, **F2 toggles mouse capture (OFF at start-up so the terminal's own click-and-drag selection works; on = wheel scrolling, Shift+drag selects; state in the status bar)**, **Ctrl-Y = /copy**, `a`/`s`/`r` answer a permission. Enter during a turn cancels it and queues the text (REPL's re-inject rule). Fallback to the REPL when stdin/stdout is not a TTY, decided in `main.go:windowMode` (banner captured into a buffer and shown as the first notice; spinner disabled). `/copy [n|all]` (window: last answer / n-th / whole transcript as plain text; REPL: `/copy [n]` only) goes through `internal/clipboard` — native tool first, OSC 52 fallback (Terminal.app ignores OSC 52; iTerm2/Ghostty honour it) — and the notice names the road. Long pastes (2026-09-16): a bracketed paste is buffered until its end mark (`paste.go`); over 3 lines or 200 chars it becomes one private-use-rune token `[Pasted #n · L lines · C chars]` (atomic for the cursor/Backspace/Delete, numbered per message); Enter hands the runner a `Prompt{Typed, Pastes}` — `Text()` to the model (verbatim), `Display()` to the transcript, `Entry.Full` for `/copy all`/`Plain()`; `@path` is looked up in the typed text only. Not in scope yet: session browser, in-window drag selection, Markdown beyond fences, `/paste n` peek.
44+- `permissions: ask | auto` (default `auto`): read by the window mode only; `ask` gates `bash`/`write_file`/`edit_file` with an inline prompt replacing the box, "allow for this session" remembered per tool; a cancelled turn rejects. Line mode still has no gatekeeper; ACP keeps its dialog.
45+- Slash commands come from ONE table, `internal/command` (`Terminal | Editor | Window` bits): REPL and window get all seven (`quit abort compact new sessions resume help`), ACP advertises only `compact`, `new`. `/help` exists in the two terminal modes (ticket 0001 done). Add a command there, nowhere else.
39 - The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired.46 - The package import graph is kept in `docs/diagrams/packages.drawio` (generated from `go list`); update it when a package is added, removed or re-wired.
40 47
41 ## Known defects (verified 2026-09-15, not fixed)48 ## Known defects (verified 2026-09-15, not fixed)
42 49
43 - None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.)50 - None open. (The skills-layout bug and the `demo/skills` test path were fixed on 2026-09-15; `go test ./...` passes.)
44 51
45-## Quality gate state (2026-09-15, 2 runs in `.quality/history.jsonl`)52+## Quality gate state (2026-09-16, 9 runs in `.quality/history.jsonl`)
46 53
47-FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code.54+FAIL on pre-existing debt only: 63 warnings (was 67 before the window mode; `main.go`'s errcheck cleared by `say()`), 19 smells — none in `internal/tui`, `internal/command`, `internal/store`, `main.go`; `blog/fake-agent/main.go` (milo's) and `hello-world/main.go` (user's untracked file) are the only findings outside `internal/`. Earlier state, kept for the record: 67 warnings (55 `osv-scanner` on `go.mod` — 47 against `stdlib@1.25.0`, i.e. the `go` directive, 8 against indirect deps `jsonparser`, `otel`, `x/sys`; 12 `golangci-lint:errcheck` in `main.go`, `internal/engine`, `internal/fileedit`) and 18 smells (complexity: `agent.Run` = 121, `fileedit/diff.go`, `compact.Valid`, `engine.Generate`, `config.validate`, `engine.Explain`). No finding in the `/new` code. 2026-09-16 (copy work): 63 warnings, 18 smells, none in `internal/clipboard`, `internal/tui/copy.go`, `internal/agent/copy.go`. **Trap**: the `~/.qlty` volume is 488 MB and was 100 % full (Go toolchain 251 MB + linters); every plugin then "FATAL"s and the report shows 0 warnings — a tooling failure that looks like an improvement. Fix: `rm -rf ~/.qlty/cache/repos/*/plugin_cachedir/* ~/.qlty/cache/repos/*/out/invoke-*.yaml` (regenerable caches), then re-run.
48 55
49 ## Repository oddities56 ## Repository oddities
50 57
@@ -54,5 +61,8 @@ FAIL on pre-existing debt only: 67 warnings (55 `osv-scanner` on `go.mod` — 47
54 61
55 ## Not yet established62 ## Not yet established
56 63
64+- The window mode on a real macOS terminal (Terminal.app, iTerm2, Ghostty, Zed's terminal, tmux): Ctrl-J / Alt-Enter detection, Esc latency, resize, colours on light themes. Verified here only on a Linux pseudo-terminal (`python3 pty`) and `tcell.SimulationScreen`.
65+
57 - Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised).66 - Whether a real DMR or llama-server run works end to end from this sandbox (no model server here; only the banner and `-h` were exercised).
67+- Whether Zed's Reload Agent renders the `session/load` replay as expected (verified only with a scripted JSON-RPC client, `/tmp/drive_sessions.py` pattern in history 2026-09-16).
58 - Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings.68 - Whether the project wants `go.mod`'s `go 1.25.0` bumped to 1.26 and the flagged indirect deps upgraded to clear the osv-scanner findings.
modified .quality/history.jsonl +12 -0
@@ -1,2 +1,14 @@
11 {"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"}
22 {"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"}
3+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 45, "complex": 770, "cyclo": 1251, "fields": 172, "funcs": 203, "lcom": 0, "lines": 5753, "loc": 3655}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 3, "smells": 20, "timestamp": "2026-09-16T04:25:47Z"}
4+{"branch": "main", "breaches": ["warning-level issues: 68 (max 0)", "code smells: 21 (max 0)"], "commit": "d722711", "counts": {"warning": 68}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 47, "complex": 777, "cyclo": 1277, "fields": 178, "funcs": 220, "lcom": 0, "lines": 5988, "loc": 3830}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 4, "smells": 21, "timestamp": "2026-09-16T04:26:38Z"}
5+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 48, "complex": 777, "cyclo": 1277, "fields": 183, "funcs": 220, "lcom": 0, "lines": 5998, "loc": 3837}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 5, "smells": 20, "timestamp": "2026-09-16T04:27:10Z"}
6+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 48, "complex": 778, "cyclo": 1278, "fields": 183, "funcs": 221, "lcom": 0, "lines": 6007, "loc": 3843}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 6, "smells": 19, "timestamp": "2026-09-16T04:31:40Z"}
7+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 22 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 999, "cyclo": 1842, "fields": 280, "funcs": 361, "lcom": 0, "lines": 8257, "loc": 5493}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 7, "smells": 22, "timestamp": "2026-09-16T05:25:29Z"}
8+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 997, "cyclo": 1836, "fields": 280, "funcs": 364, "lcom": 0, "lines": 8269, "loc": 5495}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 8, "smells": 20, "timestamp": "2026-09-16T05:26:49Z"}
9+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 997, "cyclo": 1836, "fields": 280, "funcs": 366, "lcom": 0, "lines": 8275, "loc": 5500}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 9, "smells": 19, "timestamp": "2026-09-16T05:27:55Z"}
10+{"branch": "main", "breaches": ["code smells: 19 (max 0)"], "commit": "d722711", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 70, "complex": 1028, "cyclo": 1897, "fields": 282, "funcs": 365, "lcom": 0, "lines": 8345, "loc": 5537}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 10, "smells": 19, "timestamp": "2026-09-16T06:09:34Z"}
11+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 18 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 70, "complex": 1029, "cyclo": 1899, "fields": 282, "funcs": 365, "lcom": 0, "lines": 8366, "loc": 5549}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 11, "smells": 18, "timestamp": "2026-09-16T06:12:48Z"}
12+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8660, "loc": 5735}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 12, "smells": 19, "timestamp": "2026-09-16T06:28:23Z"}
13+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8660, "loc": 5735}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 13, "smells": 19, "timestamp": "2026-09-16T06:28:41Z"}
14+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 18 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8662, "loc": 5736}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 14, "smells": 18, "timestamp": "2026-09-16T06:28:58Z"}
@@ -1,2 +1,14 @@
1 {"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"}1 {"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 620, "cyclo": 994, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4718, "loc": 2954}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 1, "smells": 18, "timestamp": "2026-09-15T22:32:19Z"}
2 {"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"}2 {"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 18 (max 0)"], "commit": "79fc998", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 38, "complex": 618, "cyclo": 992, "fields": 149, "funcs": 156, "lcom": 0, "lines": 4719, "loc": 2956}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 2, "smells": 18, "timestamp": "2026-09-15T22:33:07Z"}
3+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 45, "complex": 770, "cyclo": 1251, "fields": 172, "funcs": 203, "lcom": 0, "lines": 5753, "loc": 3655}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 3, "smells": 20, "timestamp": "2026-09-16T04:25:47Z"}
4+{"branch": "main", "breaches": ["warning-level issues: 68 (max 0)", "code smells: 21 (max 0)"], "commit": "d722711", "counts": {"warning": 68}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 47, "complex": 777, "cyclo": 1277, "fields": 178, "funcs": 220, "lcom": 0, "lines": 5988, "loc": 3830}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 4, "smells": 21, "timestamp": "2026-09-16T04:26:38Z"}
5+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 48, "complex": 777, "cyclo": 1277, "fields": 183, "funcs": 220, "lcom": 0, "lines": 5998, "loc": 3837}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 5, "smells": 20, "timestamp": "2026-09-16T04:27:10Z"}
6+{"branch": "main", "breaches": ["warning-level issues: 67 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 67}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 48, "complex": 778, "cyclo": 1278, "fields": 183, "funcs": 221, "lcom": 0, "lines": 6007, "loc": 3843}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 6, "smells": 19, "timestamp": "2026-09-16T04:31:40Z"}
7+{"branch": "main", "breaches": ["warning-level issues: 69 (max 0)", "code smells: 22 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 69}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 999, "cyclo": 1842, "fields": 280, "funcs": 361, "lcom": 0, "lines": 8257, "loc": 5493}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 7, "smells": 22, "timestamp": "2026-09-16T05:25:29Z"}
8+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 20 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 997, "cyclo": 1836, "fields": 280, "funcs": 364, "lcom": 0, "lines": 8269, "loc": 5495}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 8, "smells": 20, "timestamp": "2026-09-16T05:26:49Z"}
9+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"note": 2, "warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 69, "complex": 997, "cyclo": 1836, "fields": 280, "funcs": 366, "lcom": 0, "lines": 8275, "loc": 5500}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 9, "smells": 19, "timestamp": "2026-09-16T05:27:55Z"}
10+{"branch": "main", "breaches": ["code smells: 19 (max 0)"], "commit": "d722711", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 70, "complex": 1028, "cyclo": 1897, "fields": 282, "funcs": 365, "lcom": 0, "lines": 8345, "loc": 5537}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 10, "smells": 19, "timestamp": "2026-09-16T06:09:34Z"}
11+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 18 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 70, "complex": 1029, "cyclo": 1899, "fields": 282, "funcs": 365, "lcom": 0, "lines": 8366, "loc": 5549}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 11, "smells": 18, "timestamp": "2026-09-16T06:12:48Z"}
12+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8660, "loc": 5735}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 12, "smells": 19, "timestamp": "2026-09-16T06:28:23Z"}
13+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 19 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8660, "loc": 5735}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 13, "smells": 19, "timestamp": "2026-09-16T06:28:41Z"}
14+{"branch": "main", "breaches": ["warning-level issues: 63 (max 0)", "code smells: 18 (max 0)"], "commit": "d722711", "counts": {"warning": 63}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 73, "complex": 1072, "cyclo": 1952, "fields": 291, "funcs": 387, "lcom": 0, "lines": 8662, "loc": 5736}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 14, "smells": 18, "timestamp": "2026-09-16T06:28:58Z"}
added .quality/report-20260916T042547Z.md +117 -0
new file mode 100644
@@ -0,0 +1,117 @@
1+# Quality report — 2026-09-16T04:25:47Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #3 (previous: 2026-09-15T22:33:07Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | +2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 13 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/agent/persist_test.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **20** (vs previous: +2)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
58+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
59+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 243 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 252 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 323 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 140) |
66+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 130): Run |
67+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
68+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
71+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/store/store.go | 93 | Function with many returns (count = 6): Save |
73+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
74+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
75+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
76+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 203 | +47 |
83+| classes | 45 | +7 |
84+| fields | 172 | +23 |
85+| cyclo | 1251 | +259 |
86+| complex | 770 | +152 |
87+| lcom | 0 | ±0 |
88+| lines | 5753 | +1034 |
89+| loc | 3655 | +699 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 140 | 104 | 287 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/agent/persist.go | 29 | 53 | 117 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 28 | 51 | 147 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 25 | 36 | 96 |
107+| internal/acp/acp.go | 21 | 49 | 286 |
108+| internal/mention/mention.go | 21 | 38 | 79 |
109+| main.go | 18 | 35 | 108 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
new file mode 100644
@@ -0,0 +1,117 @@
1+# Quality report — 2026-09-16T04:25:47Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #3 (previous: 2026-09-15T22:33:07Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | +2 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 13 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/agent/persist_test.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **20** (vs previous: +2)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
58+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
59+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 243 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 252 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 323 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 140) |
66+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 130): Run |
67+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
68+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
71+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/store/store.go | 93 | Function with many returns (count = 6): Save |
73+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
74+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
75+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
76+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 203 | +47 |
83+| classes | 45 | +7 |
84+| fields | 172 | +23 |
85+| cyclo | 1251 | +259 |
86+| complex | 770 | +152 |
87+| lcom | 0 | ±0 |
88+| lines | 5753 | +1034 |
89+| loc | 3655 | +699 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 140 | 104 | 287 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/agent/persist.go | 29 | 53 | 117 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 28 | 51 | 147 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 25 | 36 | 96 |
107+| internal/acp/acp.go | 21 | 49 | 286 |
108+| internal/mention/mention.go | 21 | 38 | 79 |
109+| main.go | 18 | 35 | 108 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
added .quality/report-20260916T042638Z.md +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:26:38Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #4 (previous: 2026-09-16T04:25:47Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 68 (max 0)
11+- code smells: 21 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 68 | -1 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/agent/persist_test.go | 1 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **21** (vs previous: +1)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
58+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
59+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:function-parameters | internal/agent/agent.go | 438 | Function with many parameters (count = 6): historyCommand |
62+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
63+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
66+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
67+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
68+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
69+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
70+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
71+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
72+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
73+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
74+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
75+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
76+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 220 | +17 |
83+| classes | 47 | +2 |
84+| fields | 178 | +6 |
85+| cyclo | 1277 | +26 |
86+| complex | 777 | +7 |
87+| lcom | 0 | ±0 |
88+| lines | 5988 | +235 |
89+| loc | 3830 | +175 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 134 | 105 | 290 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/agent/persist.go | 29 | 53 | 117 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 25 | 36 | 96 |
107+| internal/acp/acp.go | 21 | 49 | 286 |
108+| internal/mention/mention.go | 21 | 38 | 79 |
109+| main.go | 18 | 35 | 108 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
118+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:26:38Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #4 (previous: 2026-09-16T04:25:47Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 68 (max 0)
11+- code smells: 21 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 68 | -1 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/agent/persist_test.go | 1 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **21** (vs previous: +1)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
58+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
59+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:function-parameters | internal/agent/agent.go | 438 | Function with many parameters (count = 6): historyCommand |
62+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
63+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
66+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
67+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
68+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
69+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
70+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
71+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
72+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
73+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
74+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
75+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
76+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 220 | +17 |
83+| classes | 47 | +2 |
84+| fields | 178 | +6 |
85+| cyclo | 1277 | +26 |
86+| complex | 777 | +7 |
87+| lcom | 0 | ±0 |
88+| lines | 5988 | +235 |
89+| loc | 3830 | +175 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 134 | 105 | 290 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/agent/persist.go | 29 | 53 | 117 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 25 | 36 | 96 |
107+| internal/acp/acp.go | 21 | 49 | 286 |
108+| internal/mention/mention.go | 21 | 38 | 79 |
109+| main.go | 18 | 35 | 108 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
118+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
added .quality/report-20260916T042710Z.md +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:27:10Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #5 (previous: 2026-09-16T04:26:38Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -1 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **20** (vs previous: -1)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
62+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
63+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
64+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
65+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
66+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
67+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
68+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
69+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
70+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
71+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
72+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
73+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
74+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
75+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 220 | ±0 |
82+| classes | 48 | +1 |
83+| fields | 183 | +5 |
84+| cyclo | 1277 | ±0 |
85+| complex | 777 | ±0 |
86+| lcom | 0 | ±0 |
87+| lines | 5998 | +10 |
88+| loc | 3837 | +7 |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 134 | 105 | 297 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 52 | 98 | 191 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
99+| internal/tools/files.go | 32 | 50 | 238 |
100+| internal/agent/persist.go | 29 | 53 | 117 |
101+| internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
103+| internal/tools/tools.go | 28 | 47 | 191 |
104+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
105+| internal/skills/skills.go | 25 | 36 | 96 |
106+| internal/acp/acp.go | 21 | 49 | 286 |
107+| internal/mention/mention.go | 21 | 38 | 79 |
108+| main.go | 18 | 35 | 108 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
115+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
116+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
117+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
118+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:27:10Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #5 (previous: 2026-09-16T04:26:38Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | -1 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **20** (vs previous: -1)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
60+| qlty:return-statements | internal/acp/acp.go | 271 | Function with many returns (count = 6): Prompt |
61+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
62+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
63+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
64+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
65+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
66+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
67+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
68+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
69+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
70+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
71+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
72+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
73+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
74+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
75+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 220 | ±0 |
82+| classes | 48 | +1 |
83+| fields | 183 | +5 |
84+| cyclo | 1277 | ±0 |
85+| complex | 777 | ±0 |
86+| lcom | 0 | ±0 |
87+| lines | 5998 | +10 |
88+| loc | 3837 | +7 |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 134 | 105 | 297 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 52 | 98 | 191 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
99+| internal/tools/files.go | 32 | 50 | 238 |
100+| internal/agent/persist.go | 29 | 53 | 117 |
101+| internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
103+| internal/tools/tools.go | 28 | 47 | 191 |
104+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
105+| internal/skills/skills.go | 25 | 36 | 96 |
106+| internal/acp/acp.go | 21 | 49 | 286 |
107+| internal/mention/mention.go | 21 | 38 | 79 |
108+| main.go | 18 | 35 | 108 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
115+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
116+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
117+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
118+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
added .quality/report-20260916T043140Z.md +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:31:40Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #6 (previous: 2026-09-16T04:27:10Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **19** (vs previous: -1)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
65+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
66+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
75+
76+## Metrics (`qlty metrics`)
77+
78+| metric | total | vs previous |
79+|---|---|---|
80+| funcs | 221 | +1 |
81+| classes | 48 | ±0 |
82+| fields | 183 | ±0 |
83+| cyclo | 1278 | +1 |
84+| complex | 778 | +1 |
85+| lcom | 0 | ±0 |
86+| lines | 6007 | +9 |
87+| loc | 3843 | +6 |
88+
89+### Most complex files
90+
91+| file | complex | cyclo | loc |
92+|---|---|---|---|
93+| internal/agent/agent.go | 134 | 105 | 297 |
94+| internal/engine/engine.go | 91 | 121 | 347 |
95+| internal/fileedit/diff.go | 89 | 177 | 271 |
96+| internal/compact/compact.go | 52 | 98 | 191 |
97+| internal/acp/replay.go | 33 | 43 | 95 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/agent/persist.go | 29 | 53 | 117 |
100+| internal/engine/openai_compat.go | 29 | 51 | 176 |
101+| internal/store/store.go | 29 | 53 | 152 |
102+| internal/tools/tools.go | 28 | 47 | 191 |
103+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
104+| internal/skills/skills.go | 25 | 36 | 96 |
105+| internal/acp/acp.go | 22 | 50 | 292 |
106+| internal/mention/mention.go | 21 | 38 | 79 |
107+| main.go | 18 | 35 | 108 |
108+
109+## Trend
110+
111+| run | timestamp | error | warning | smells | complex | gate |
112+|---|---|---|---|---|---|---|
113+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
114+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
115+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
116+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
117+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
118+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
new file mode 100644
@@ -0,0 +1,118 @@
1+# Quality report — 2026-09-16T04:31:40Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #6 (previous: 2026-09-16T04:27:10Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 67 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 67 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 12 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2025-47910 | 1 |
30+| osv-scanner:CVE-2025-47912 | 1 |
31+| osv-scanner:CVE-2025-58183 | 1 |
32+| osv-scanner:CVE-2025-58185 | 1 |
33+| osv-scanner:CVE-2025-58186 | 1 |
34+| osv-scanner:CVE-2025-58187 | 1 |
35+| osv-scanner:CVE-2025-58188 | 1 |
36+| osv-scanner:CVE-2025-58189 | 1 |
37+| osv-scanner:CVE-2025-61723 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 55 |
44+| main.go | 5 |
45+| internal/engine/openai_compat.go | 2 |
46+| internal/fileedit/fileedit_test.go | 2 |
47+| internal/fileedit/textfile.go | 2 |
48+| internal/engine/engine.go | 1 |
49+
50+## Code smells (`qlty smells`)
51+
52+Total: **19** (vs previous: -1)
53+
54+| smell | file | line | detail |
55+|---|---|---|---|
56+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59+| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60+| qlty:nested-control-flow | internal/agent/agent.go | 216 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 225 | Deeply nested control flow (level = 5) |
62+| qlty:nested-control-flow | internal/agent/agent.go | 296 | Deeply nested control flow (level = 5) |
63+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 134) |
64+| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 120): Run |
65+| qlty:return-statements | internal/config/config.go | 276 | Function with many returns (count = 12): validate |
66+| qlty:return-statements | internal/compact/compact.go | 127 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 209 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 209 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
75+
76+## Metrics (`qlty metrics`)
77+
78+| metric | total | vs previous |
79+|---|---|---|
80+| funcs | 221 | +1 |
81+| classes | 48 | ±0 |
82+| fields | 183 | ±0 |
83+| cyclo | 1278 | +1 |
84+| complex | 778 | +1 |
85+| lcom | 0 | ±0 |
86+| lines | 6007 | +9 |
87+| loc | 3843 | +6 |
88+
89+### Most complex files
90+
91+| file | complex | cyclo | loc |
92+|---|---|---|---|
93+| internal/agent/agent.go | 134 | 105 | 297 |
94+| internal/engine/engine.go | 91 | 121 | 347 |
95+| internal/fileedit/diff.go | 89 | 177 | 271 |
96+| internal/compact/compact.go | 52 | 98 | 191 |
97+| internal/acp/replay.go | 33 | 43 | 95 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/agent/persist.go | 29 | 53 | 117 |
100+| internal/engine/openai_compat.go | 29 | 51 | 176 |
101+| internal/store/store.go | 29 | 53 | 152 |
102+| internal/tools/tools.go | 28 | 47 | 191 |
103+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
104+| internal/skills/skills.go | 25 | 36 | 96 |
105+| internal/acp/acp.go | 22 | 50 | 292 |
106+| internal/mention/mention.go | 21 | 38 | 79 |
107+| main.go | 18 | 35 | 108 |
108+
109+## Trend
110+
111+| run | timestamp | error | warning | smells | complex | gate |
112+|---|---|---|---|---|---|---|
113+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
114+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
115+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
116+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
117+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
118+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
added .quality/report-20260916T052529Z.md +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+# Quality report — 2026-09-16T05:25:29Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #7 (previous: 2026-09-16T04:31:40Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 22 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | +2 |
18+| note | 2 | — |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 12 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| main.go | 5 |
46+| hello-world/main.go | 2 |
47+| internal/engine/openai_compat.go | 2 |
48+| internal/fileedit/fileedit_test.go | 2 |
49+| internal/fileedit/textfile.go | 2 |
50+| internal/engine/engine.go | 1 |
51+| internal/tui/tui_test.go | 1 |
52+
53+## Code smells (`qlty smells`)
54+
55+Total: **22** (vs previous: +3)
56+
57+| smell | file | line | detail |
58+|---|---|---|---|
59+| qlty:function-complexity | main.go | 53 | Function with high complexity (count = 22): main |
60+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
61+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
62+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
63+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
64+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
66+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
67+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
68+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
69+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
70+| qlty:return-statements | internal/tui/style.go | 30 | Function with many returns (count = 10): styleOf |
71+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
72+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
73+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
74+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
75+| qlty:return-statements | internal/store/keeper.go | 100 | Function with many returns (count = 6): Resume |
76+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
77+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
78+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
79+
80+## Metrics (`qlty metrics`)
81+
82+| metric | total | vs previous |
83+|---|---|---|
84+| funcs | 361 | +140 |
85+| classes | 69 | +21 |
86+| fields | 280 | +97 |
87+| cyclo | 1842 | +564 |
88+| complex | 999 | +221 |
89+| lcom | 0 | ±0 |
90+| lines | 8257 | +2250 |
91+| loc | 5493 | +1650 |
92+
93+### Most complex files
94+
95+| file | complex | cyclo | loc |
96+|---|---|---|---|
97+| internal/agent/agent.go | 126 | 92 | 296 |
98+| internal/engine/engine.go | 91 | 121 | 347 |
99+| internal/fileedit/diff.go | 89 | 177 | 271 |
100+| internal/compact/compact.go | 52 | 98 | 191 |
101+| internal/tui/turn.go | 42 | 87 | 337 |
102+| internal/tui/events.go | 39 | 123 | 233 |
103+| internal/acp/replay.go | 33 | 43 | 95 |
104+| internal/tools/files.go | 32 | 50 | 238 |
105+| internal/store/keeper.go | 31 | 58 | 128 |
106+| internal/engine/openai_compat.go | 29 | 51 | 176 |
107+| internal/store/store.go | 29 | 53 | 152 |
108+| internal/tools/tools.go | 28 | 47 | 191 |
109+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
110+| internal/tui/render.go | 27 | 55 | 150 |
111+| main.go | 27 | 44 | 152 |
112+
113+## Trend
114+
115+| run | timestamp | error | warning | smells | complex | gate |
116+|---|---|---|---|---|---|---|
117+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
118+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
119+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
120+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
121+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
122+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
123+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
new file mode 100644
@@ -0,0 +1,123 @@
1+# Quality report — 2026-09-16T05:25:29Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #7 (previous: 2026-09-16T04:31:40Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 69 (max 0)
11+- code smells: 22 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 69 | +2 |
18+| note | 2 | — |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 12 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| main.go | 5 |
46+| hello-world/main.go | 2 |
47+| internal/engine/openai_compat.go | 2 |
48+| internal/fileedit/fileedit_test.go | 2 |
49+| internal/fileedit/textfile.go | 2 |
50+| internal/engine/engine.go | 1 |
51+| internal/tui/tui_test.go | 1 |
52+
53+## Code smells (`qlty smells`)
54+
55+Total: **22** (vs previous: +3)
56+
57+| smell | file | line | detail |
58+|---|---|---|---|
59+| qlty:function-complexity | main.go | 53 | Function with high complexity (count = 22): main |
60+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
61+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
62+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
63+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
64+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
66+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
67+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
68+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
69+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
70+| qlty:return-statements | internal/tui/style.go | 30 | Function with many returns (count = 10): styleOf |
71+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
72+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
73+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
74+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
75+| qlty:return-statements | internal/store/keeper.go | 100 | Function with many returns (count = 6): Resume |
76+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
77+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
78+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
79+
80+## Metrics (`qlty metrics`)
81+
82+| metric | total | vs previous |
83+|---|---|---|
84+| funcs | 361 | +140 |
85+| classes | 69 | +21 |
86+| fields | 280 | +97 |
87+| cyclo | 1842 | +564 |
88+| complex | 999 | +221 |
89+| lcom | 0 | ±0 |
90+| lines | 8257 | +2250 |
91+| loc | 5493 | +1650 |
92+
93+### Most complex files
94+
95+| file | complex | cyclo | loc |
96+|---|---|---|---|
97+| internal/agent/agent.go | 126 | 92 | 296 |
98+| internal/engine/engine.go | 91 | 121 | 347 |
99+| internal/fileedit/diff.go | 89 | 177 | 271 |
100+| internal/compact/compact.go | 52 | 98 | 191 |
101+| internal/tui/turn.go | 42 | 87 | 337 |
102+| internal/tui/events.go | 39 | 123 | 233 |
103+| internal/acp/replay.go | 33 | 43 | 95 |
104+| internal/tools/files.go | 32 | 50 | 238 |
105+| internal/store/keeper.go | 31 | 58 | 128 |
106+| internal/engine/openai_compat.go | 29 | 51 | 176 |
107+| internal/store/store.go | 29 | 53 | 152 |
108+| internal/tools/tools.go | 28 | 47 | 191 |
109+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
110+| internal/tui/render.go | 27 | 55 | 150 |
111+| main.go | 27 | 44 | 152 |
112+
113+## Trend
114+
115+| run | timestamp | error | warning | smells | complex | gate |
116+|---|---|---|---|---|---|---|
117+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
118+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
119+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
120+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
121+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
122+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
123+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
added .quality/report-20260916T052649Z.md +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+# Quality report — 2026-09-16T05:26:49Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #8 (previous: 2026-09-16T05:25:29Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | -6 |
18+| note | 2 | ±0 |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 7 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| hello-world/main.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **20** (vs previous: -2)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:function-complexity | main.go | 52 | Function with high complexity (count = 18): main |
58+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
59+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
60+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
61+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
66+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
67+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
68+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
71+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
73+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
74+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
75+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
76+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 364 | +3 |
83+| classes | 69 | ±0 |
84+| fields | 280 | ±0 |
85+| cyclo | 1836 | -6 |
86+| complex | 997 | -2 |
87+| lcom | 0 | ±0 |
88+| lines | 8269 | +12 |
89+| loc | 5495 | +2 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 126 | 92 | 296 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/tui/turn.go | 42 | 87 | 337 |
100+| internal/tui/events.go | 39 | 123 | 233 |
101+| internal/acp/replay.go | 33 | 43 | 95 |
102+| internal/tools/files.go | 32 | 50 | 238 |
103+| internal/store/keeper.go | 31 | 60 | 131 |
104+| internal/engine/openai_compat.go | 29 | 51 | 176 |
105+| internal/store/store.go | 29 | 53 | 152 |
106+| internal/tools/tools.go | 28 | 47 | 191 |
107+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
108+| internal/tui/render.go | 27 | 55 | 150 |
109+| internal/tui/draw.go | 26 | 83 | 136 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
118+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
119+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
120+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
121+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
122+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
new file mode 100644
@@ -0,0 +1,122 @@
1+# Quality report — 2026-09-16T05:26:49Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #8 (previous: 2026-09-16T05:25:29Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 20 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | -6 |
18+| note | 2 | ±0 |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 7 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| hello-world/main.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **20** (vs previous: -2)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:function-complexity | main.go | 52 | Function with high complexity (count = 18): main |
58+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
59+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
60+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
61+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
62+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
63+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
65+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
66+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
67+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
68+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
69+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
70+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
71+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
72+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
73+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
74+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
75+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
76+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
77+
78+## Metrics (`qlty metrics`)
79+
80+| metric | total | vs previous |
81+|---|---|---|
82+| funcs | 364 | +3 |
83+| classes | 69 | ±0 |
84+| fields | 280 | ±0 |
85+| cyclo | 1836 | -6 |
86+| complex | 997 | -2 |
87+| lcom | 0 | ±0 |
88+| lines | 8269 | +12 |
89+| loc | 5495 | +2 |
90+
91+### Most complex files
92+
93+| file | complex | cyclo | loc |
94+|---|---|---|---|
95+| internal/agent/agent.go | 126 | 92 | 296 |
96+| internal/engine/engine.go | 91 | 121 | 347 |
97+| internal/fileedit/diff.go | 89 | 177 | 271 |
98+| internal/compact/compact.go | 52 | 98 | 191 |
99+| internal/tui/turn.go | 42 | 87 | 337 |
100+| internal/tui/events.go | 39 | 123 | 233 |
101+| internal/acp/replay.go | 33 | 43 | 95 |
102+| internal/tools/files.go | 32 | 50 | 238 |
103+| internal/store/keeper.go | 31 | 60 | 131 |
104+| internal/engine/openai_compat.go | 29 | 51 | 176 |
105+| internal/store/store.go | 29 | 53 | 152 |
106+| internal/tools/tools.go | 28 | 47 | 191 |
107+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
108+| internal/tui/render.go | 27 | 55 | 150 |
109+| internal/tui/draw.go | 26 | 83 | 136 |
110+
111+## Trend
112+
113+| run | timestamp | error | warning | smells | complex | gate |
114+|---|---|---|---|---|---|---|
115+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
116+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
117+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
118+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
119+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
120+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
121+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
122+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
added .quality/report-20260916T052755Z.md +122 -0
new file mode 100644
@@ -0,0 +1,122 @@
1+# Quality report — 2026-09-16T05:27:55Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #9 (previous: 2026-09-16T05:26:49Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+| note | 2 | ±0 |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 7 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| hello-world/main.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **19** (vs previous: -1)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
58+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
59+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
60+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
61+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
62+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
63+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
64+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
69+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
70+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
71+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
72+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
73+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
74+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
75+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 366 | +2 |
82+| classes | 69 | ±0 |
83+| fields | 280 | ±0 |
84+| cyclo | 1836 | ±0 |
85+| complex | 997 | ±0 |
86+| lcom | 0 | ±0 |
87+| lines | 8275 | +6 |
88+| loc | 5500 | +5 |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 126 | 92 | 296 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 52 | 98 | 191 |
98+| internal/tui/turn.go | 42 | 87 | 337 |
99+| internal/tui/events.go | 39 | 123 | 233 |
100+| internal/acp/replay.go | 33 | 43 | 95 |
101+| internal/tools/files.go | 32 | 50 | 238 |
102+| internal/store/keeper.go | 31 | 60 | 131 |
103+| internal/engine/openai_compat.go | 29 | 51 | 176 |
104+| internal/store/store.go | 29 | 53 | 152 |
105+| internal/tools/tools.go | 28 | 47 | 191 |
106+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
107+| internal/tui/render.go | 27 | 55 | 150 |
108+| internal/tui/draw.go | 26 | 83 | 136 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
115+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
116+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
117+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
118+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
119+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
120+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
121+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
122+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
new file mode 100644
@@ -0,0 +1,122 @@
1+# Quality report — 2026-09-16T05:27:55Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #9 (previous: 2026-09-16T05:26:49Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+| note | 2 | ±0 |
19+
20+### Top rules
21+
22+| rule | count |
23+|---|---|
24+| golangci-lint:errcheck | 7 |
25+| osv-scanner:CVE-2026-32285 | 2 |
26+| osv-scanner:CVE-2026-24051 | 2 |
27+| osv-scanner:CVE-2026-39883 | 2 |
28+| osv-scanner:CVE-2026-29181 | 2 |
29+| osv-scanner:CVE-2026-39824 | 1 |
30+| osv-scanner:CVE-2026-56852 | 1 |
31+| osv-scanner:CVE-2025-47910 | 1 |
32+| osv-scanner:CVE-2025-47912 | 1 |
33+| osv-scanner:CVE-2025-58183 | 1 |
34+| osv-scanner:CVE-2025-58185 | 1 |
35+| osv-scanner:CVE-2025-58186 | 1 |
36+| osv-scanner:CVE-2025-58187 | 1 |
37+| osv-scanner:CVE-2025-58188 | 1 |
38+| osv-scanner:CVE-2025-58189 | 1 |
39+
40+### Most affected files
41+
42+| file | issues |
43+|---|---|
44+| go.mod | 56 |
45+| hello-world/main.go | 2 |
46+| internal/engine/openai_compat.go | 2 |
47+| internal/fileedit/fileedit_test.go | 2 |
48+| internal/fileedit/textfile.go | 2 |
49+| internal/engine/engine.go | 1 |
50+
51+## Code smells (`qlty smells`)
52+
53+Total: **19** (vs previous: -1)
54+
55+| smell | file | line | detail |
56+|---|---|---|---|
57+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
58+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
59+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
60+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
61+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
62+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
63+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
64+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
69+| qlty:return-statements | internal/agent/agent.go | 38 | Function with many returns (count = 6): Run |
70+| qlty:nested-control-flow | internal/agent/agent.go | 212 | Deeply nested control flow (level = 5) |
71+| qlty:nested-control-flow | internal/agent/agent.go | 221 | Deeply nested control flow (level = 5) |
72+| qlty:nested-control-flow | internal/agent/agent.go | 293 | Deeply nested control flow (level = 5) |
73+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
74+| qlty:function-complexity | internal/agent/agent.go | 38 | Function with high complexity (count = 110): Run |
75+| qlty:return-statements | blog/fake-agent/main.go | 96 | Function with many returns (count = 6): Prompt |
76+
77+## Metrics (`qlty metrics`)
78+
79+| metric | total | vs previous |
80+|---|---|---|
81+| funcs | 366 | +2 |
82+| classes | 69 | ±0 |
83+| fields | 280 | ±0 |
84+| cyclo | 1836 | ±0 |
85+| complex | 997 | ±0 |
86+| lcom | 0 | ±0 |
87+| lines | 8275 | +6 |
88+| loc | 5500 | +5 |
89+
90+### Most complex files
91+
92+| file | complex | cyclo | loc |
93+|---|---|---|---|
94+| internal/agent/agent.go | 126 | 92 | 296 |
95+| internal/engine/engine.go | 91 | 121 | 347 |
96+| internal/fileedit/diff.go | 89 | 177 | 271 |
97+| internal/compact/compact.go | 52 | 98 | 191 |
98+| internal/tui/turn.go | 42 | 87 | 337 |
99+| internal/tui/events.go | 39 | 123 | 233 |
100+| internal/acp/replay.go | 33 | 43 | 95 |
101+| internal/tools/files.go | 32 | 50 | 238 |
102+| internal/store/keeper.go | 31 | 60 | 131 |
103+| internal/engine/openai_compat.go | 29 | 51 | 176 |
104+| internal/store/store.go | 29 | 53 | 152 |
105+| internal/tools/tools.go | 28 | 47 | 191 |
106+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
107+| internal/tui/render.go | 27 | 55 | 150 |
108+| internal/tui/draw.go | 26 | 83 | 136 |
109+
110+## Trend
111+
112+| run | timestamp | error | warning | smells | complex | gate |
113+|---|---|---|---|---|---|---|
114+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
115+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
116+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
117+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
118+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
119+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
120+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
121+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
122+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
added .quality/report-20260916T060934Z.md +105 -0
new file mode 100644
@@ -0,0 +1,105 @@
1+# Quality report — 2026-09-16T06:09:34Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #10 (previous: 2026-09-16T05:27:55Z)
7+
8+## Gate violations
9+
10+- code smells: 19 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+| level | count | vs previous |
15+|---|---|---|
16+| warning | 0 | -63 |
17+| note | 0 | -2 |
18+
19+### Top rules
20+
21+_none_
22+
23+### Most affected files
24+
25+_none_
26+
27+## Code smells (`qlty smells`)
28+
29+Total: **19** (vs previous: ±0)
30+
31+| smell | file | line | detail |
32+|---|---|---|---|
33+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
34+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
35+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
36+| qlty:return-statements | internal/agent/agent.go | 40 | Function with many returns (count = 6): Run |
37+| qlty:nested-control-flow | internal/agent/agent.go | 214 | Deeply nested control flow (level = 5) |
38+| qlty:nested-control-flow | internal/agent/agent.go | 223 | Deeply nested control flow (level = 5) |
39+| qlty:nested-control-flow | internal/agent/agent.go | 295 | Deeply nested control flow (level = 5) |
40+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 139) |
41+| qlty:function-complexity | internal/agent/agent.go | 40 | Function with high complexity (count = 110): Run |
42+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
43+| qlty:file-complexity | internal/tui/turn.go | 1 | High total complexity (count = 52) |
44+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
45+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
46+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
47+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
48+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
49+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
50+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
51+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
52+
53+## Metrics (`qlty metrics`)
54+
55+| metric | total | vs previous |
56+|---|---|---|
57+| funcs | 365 | -1 |
58+| classes | 70 | +1 |
59+| fields | 282 | +2 |
60+| cyclo | 1897 | +61 |
61+| complex | 1028 | +31 |
62+| lcom | 0 | ±0 |
63+| lines | 8345 | +70 |
64+| loc | 5537 | +37 |
65+
66+### Most complex files
67+
68+| file | complex | cyclo | loc |
69+|---|---|---|---|
70+| internal/agent/agent.go | 139 | 117 | 345 |
71+| internal/engine/engine.go | 91 | 121 | 347 |
72+| internal/fileedit/diff.go | 89 | 177 | 271 |
73+| internal/compact/compact.go | 52 | 98 | 191 |
74+| internal/tui/turn.go | 52 | 110 | 383 |
75+| internal/tui/events.go | 41 | 129 | 239 |
76+| internal/acp/replay.go | 33 | 43 | 95 |
77+| internal/tools/files.go | 32 | 50 | 238 |
78+| internal/store/keeper.go | 31 | 60 | 131 |
79+| internal/engine/openai_compat.go | 29 | 51 | 176 |
80+| internal/store/store.go | 29 | 53 | 152 |
81+| internal/tools/tools.go | 28 | 47 | 191 |
82+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
83+| internal/skills/skills.go | 27 | 37 | 102 |
84+| internal/tui/render.go | 27 | 55 | 150 |
85+
86+## Tooling notes
87+
88+- `qlty check` exit 99: ERROR
89+
90+ > FATAL error occurred running 4 invocations
91+
92+## Trend
93+
94+| run | timestamp | error | warning | smells | complex | gate |
95+|---|---|---|---|---|---|---|
96+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
97+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
98+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
99+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
100+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
101+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
102+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
103+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
104+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
105+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
new file mode 100644
@@ -0,0 +1,105 @@
1+# Quality report — 2026-09-16T06:09:34Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #10 (previous: 2026-09-16T05:27:55Z)
7+
8+## Gate violations
9+
10+- code smells: 19 (max 0)
11+
12+## Lint issues (`qlty check`)
13+
14+| level | count | vs previous |
15+|---|---|---|
16+| warning | 0 | -63 |
17+| note | 0 | -2 |
18+
19+### Top rules
20+
21+_none_
22+
23+### Most affected files
24+
25+_none_
26+
27+## Code smells (`qlty smells`)
28+
29+Total: **19** (vs previous: ±0)
30+
31+| smell | file | line | detail |
32+|---|---|---|---|
33+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
34+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
35+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
36+| qlty:return-statements | internal/agent/agent.go | 40 | Function with many returns (count = 6): Run |
37+| qlty:nested-control-flow | internal/agent/agent.go | 214 | Deeply nested control flow (level = 5) |
38+| qlty:nested-control-flow | internal/agent/agent.go | 223 | Deeply nested control flow (level = 5) |
39+| qlty:nested-control-flow | internal/agent/agent.go | 295 | Deeply nested control flow (level = 5) |
40+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 139) |
41+| qlty:function-complexity | internal/agent/agent.go | 40 | Function with high complexity (count = 110): Run |
42+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
43+| qlty:file-complexity | internal/tui/turn.go | 1 | High total complexity (count = 52) |
44+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
45+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
46+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
47+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
48+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
49+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
50+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
51+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
52+
53+## Metrics (`qlty metrics`)
54+
55+| metric | total | vs previous |
56+|---|---|---|
57+| funcs | 365 | -1 |
58+| classes | 70 | +1 |
59+| fields | 282 | +2 |
60+| cyclo | 1897 | +61 |
61+| complex | 1028 | +31 |
62+| lcom | 0 | ±0 |
63+| lines | 8345 | +70 |
64+| loc | 5537 | +37 |
65+
66+### Most complex files
67+
68+| file | complex | cyclo | loc |
69+|---|---|---|---|
70+| internal/agent/agent.go | 139 | 117 | 345 |
71+| internal/engine/engine.go | 91 | 121 | 347 |
72+| internal/fileedit/diff.go | 89 | 177 | 271 |
73+| internal/compact/compact.go | 52 | 98 | 191 |
74+| internal/tui/turn.go | 52 | 110 | 383 |
75+| internal/tui/events.go | 41 | 129 | 239 |
76+| internal/acp/replay.go | 33 | 43 | 95 |
77+| internal/tools/files.go | 32 | 50 | 238 |
78+| internal/store/keeper.go | 31 | 60 | 131 |
79+| internal/engine/openai_compat.go | 29 | 51 | 176 |
80+| internal/store/store.go | 29 | 53 | 152 |
81+| internal/tools/tools.go | 28 | 47 | 191 |
82+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
83+| internal/skills/skills.go | 27 | 37 | 102 |
84+| internal/tui/render.go | 27 | 55 | 150 |
85+
86+## Tooling notes
87+
88+- `qlty check` exit 99: ERROR
89+
90+ > FATAL error occurred running 4 invocations
91+
92+## Trend
93+
94+| run | timestamp | error | warning | smells | complex | gate |
95+|---|---|---|---|---|---|---|
96+| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
97+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
98+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
99+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
100+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
101+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
102+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
103+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
104+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
105+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
added .quality/report-20260916T061248Z.md +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+# Quality report — 2026-09-16T06:12:48Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #11 (previous: 2026-09-16T06:09:34Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | — |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **18** (vs previous: -1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
56+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
57+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
58+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
59+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
60+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
61+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
62+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
63+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
66+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
67+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
68+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
69+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
70+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
71+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
72+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
73+
74+## Metrics (`qlty metrics`)
75+
76+| metric | total | vs previous |
77+|---|---|---|
78+| funcs | 365 | ±0 |
79+| classes | 70 | ±0 |
80+| fields | 282 | ±0 |
81+| cyclo | 1899 | +2 |
82+| complex | 1029 | +1 |
83+| lcom | 0 | ±0 |
84+| lines | 8366 | +21 |
85+| loc | 5549 | +12 |
86+
87+### Most complex files
88+
89+| file | complex | cyclo | loc |
90+|---|---|---|---|
91+| internal/agent/agent.go | 126 | 93 | 299 |
92+| internal/engine/engine.go | 91 | 121 | 347 |
93+| internal/fileedit/diff.go | 89 | 177 | 271 |
94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/turn.go | 42 | 88 | 343 |
96+| internal/tui/events.go | 41 | 129 | 239 |
97+| internal/acp/replay.go | 33 | 43 | 95 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/store/keeper.go | 31 | 60 | 131 |
100+| internal/engine/openai_compat.go | 29 | 51 | 176 |
101+| internal/store/store.go | 29 | 53 | 152 |
102+| internal/tools/tools.go | 28 | 47 | 191 |
103+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
104+| internal/skills/skills.go | 27 | 37 | 102 |
105+| internal/tui/render.go | 27 | 55 | 150 |
106+
107+## Trend
108+
109+| run | timestamp | error | warning | smells | complex | gate |
110+|---|---|---|---|---|---|---|
111+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
112+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
113+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
114+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
115+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
116+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
117+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
118+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
119+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
120+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
new file mode 100644
@@ -0,0 +1,120 @@
1+# Quality report — 2026-09-16T06:12:48Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #11 (previous: 2026-09-16T06:09:34Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | — |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **18** (vs previous: -1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
56+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
57+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
58+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
59+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
60+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
61+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
62+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
63+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
64+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
65+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
66+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
67+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
68+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
69+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
70+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
71+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
72+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
73+
74+## Metrics (`qlty metrics`)
75+
76+| metric | total | vs previous |
77+|---|---|---|
78+| funcs | 365 | ±0 |
79+| classes | 70 | ±0 |
80+| fields | 282 | ±0 |
81+| cyclo | 1899 | +2 |
82+| complex | 1029 | +1 |
83+| lcom | 0 | ±0 |
84+| lines | 8366 | +21 |
85+| loc | 5549 | +12 |
86+
87+### Most complex files
88+
89+| file | complex | cyclo | loc |
90+|---|---|---|---|
91+| internal/agent/agent.go | 126 | 93 | 299 |
92+| internal/engine/engine.go | 91 | 121 | 347 |
93+| internal/fileedit/diff.go | 89 | 177 | 271 |
94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/turn.go | 42 | 88 | 343 |
96+| internal/tui/events.go | 41 | 129 | 239 |
97+| internal/acp/replay.go | 33 | 43 | 95 |
98+| internal/tools/files.go | 32 | 50 | 238 |
99+| internal/store/keeper.go | 31 | 60 | 131 |
100+| internal/engine/openai_compat.go | 29 | 51 | 176 |
101+| internal/store/store.go | 29 | 53 | 152 |
102+| internal/tools/tools.go | 28 | 47 | 191 |
103+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
104+| internal/skills/skills.go | 27 | 37 | 102 |
105+| internal/tui/render.go | 27 | 55 | 150 |
106+
107+## Trend
108+
109+| run | timestamp | error | warning | smells | complex | gate |
110+|---|---|---|---|---|---|---|
111+| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
112+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
113+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
114+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
115+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
116+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
117+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
118+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
119+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
120+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
added .quality/report-20260916T062823Z.md +121 -0
new file mode 100644
@@ -0,0 +1,121 @@
1+# Quality report — 2026-09-16T06:28:23Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #12 (previous: 2026-09-16T06:12:48Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **19** (vs previous: +1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:file-complexity | internal/tui/events.go | 1 | High total complexity (count = 50) |
66+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 387 | +22 |
80+| classes | 73 | +3 |
81+| fields | 291 | +9 |
82+| cyclo | 1952 | +53 |
83+| complex | 1072 | +43 |
84+| lcom | 0 | ±0 |
85+| lines | 8660 | +294 |
86+| loc | 5735 | +186 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 126 | 93 | 299 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 52 | 98 | 191 |
96+| internal/tui/events.go | 50 | 138 | 274 |
97+| internal/tui/draw.go | 42 | 98 | 175 |
98+| internal/tui/turn.go | 42 | 88 | 344 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/store/keeper.go | 31 | 60 | 131 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 27 | 37 | 102 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
113+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
114+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
115+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
116+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
117+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
118+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
119+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
120+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
121+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
new file mode 100644
@@ -0,0 +1,121 @@
1+# Quality report — 2026-09-16T06:28:23Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #12 (previous: 2026-09-16T06:12:48Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **19** (vs previous: +1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:file-complexity | internal/tui/events.go | 1 | High total complexity (count = 50) |
66+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 387 | +22 |
80+| classes | 73 | +3 |
81+| fields | 291 | +9 |
82+| cyclo | 1952 | +53 |
83+| complex | 1072 | +43 |
84+| lcom | 0 | ±0 |
85+| lines | 8660 | +294 |
86+| loc | 5735 | +186 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 126 | 93 | 299 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 52 | 98 | 191 |
96+| internal/tui/events.go | 50 | 138 | 274 |
97+| internal/tui/draw.go | 42 | 98 | 175 |
98+| internal/tui/turn.go | 42 | 88 | 344 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/store/keeper.go | 31 | 60 | 131 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 27 | 37 | 102 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 3 | 2026-09-16T04:25:47Z | 0 | 69 | 20 | 770 | FAIL |
113+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
114+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
115+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
116+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
117+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
118+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
119+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
120+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
121+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
added .quality/report-20260916T062841Z.md +121 -0
new file mode 100644
@@ -0,0 +1,121 @@
1+# Quality report — 2026-09-16T06:28:41Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #13 (previous: 2026-09-16T06:28:23Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **19** (vs previous: ±0)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:file-complexity | internal/tui/events.go | 1 | High total complexity (count = 50) |
66+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 387 | ±0 |
80+| classes | 73 | ±0 |
81+| fields | 291 | ±0 |
82+| cyclo | 1952 | ±0 |
83+| complex | 1072 | ±0 |
84+| lcom | 0 | ±0 |
85+| lines | 8660 | ±0 |
86+| loc | 5735 | ±0 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 126 | 93 | 299 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 52 | 98 | 191 |
96+| internal/tui/events.go | 50 | 138 | 274 |
97+| internal/tui/draw.go | 42 | 98 | 175 |
98+| internal/tui/turn.go | 42 | 88 | 344 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/store/keeper.go | 31 | 60 | 131 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 27 | 37 | 102 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
113+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
114+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
115+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
116+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
117+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
118+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
119+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
120+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
121+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
new file mode 100644
@@ -0,0 +1,121 @@
1+# Quality report — 2026-09-16T06:28:41Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #13 (previous: 2026-09-16T06:28:23Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 19 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **19** (vs previous: ±0)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:file-complexity | internal/tui/events.go | 1 | High total complexity (count = 50) |
66+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
67+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
68+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
70+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
73+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
74+
75+## Metrics (`qlty metrics`)
76+
77+| metric | total | vs previous |
78+|---|---|---|
79+| funcs | 387 | ±0 |
80+| classes | 73 | ±0 |
81+| fields | 291 | ±0 |
82+| cyclo | 1952 | ±0 |
83+| complex | 1072 | ±0 |
84+| lcom | 0 | ±0 |
85+| lines | 8660 | ±0 |
86+| loc | 5735 | ±0 |
87+
88+### Most complex files
89+
90+| file | complex | cyclo | loc |
91+|---|---|---|---|
92+| internal/agent/agent.go | 126 | 93 | 299 |
93+| internal/engine/engine.go | 91 | 121 | 347 |
94+| internal/fileedit/diff.go | 89 | 177 | 271 |
95+| internal/compact/compact.go | 52 | 98 | 191 |
96+| internal/tui/events.go | 50 | 138 | 274 |
97+| internal/tui/draw.go | 42 | 98 | 175 |
98+| internal/tui/turn.go | 42 | 88 | 344 |
99+| internal/acp/replay.go | 33 | 43 | 95 |
100+| internal/tools/files.go | 32 | 50 | 238 |
101+| internal/store/keeper.go | 31 | 60 | 131 |
102+| internal/engine/openai_compat.go | 29 | 51 | 176 |
103+| internal/store/store.go | 29 | 53 | 152 |
104+| internal/tools/tools.go | 28 | 47 | 191 |
105+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
106+| internal/skills/skills.go | 27 | 37 | 102 |
107+
108+## Trend
109+
110+| run | timestamp | error | warning | smells | complex | gate |
111+|---|---|---|---|---|---|---|
112+| 4 | 2026-09-16T04:26:38Z | 0 | 68 | 21 | 777 | FAIL |
113+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
114+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
115+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
116+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
117+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
118+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
119+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
120+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
121+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
added .quality/report-20260916T062858Z.md +120 -0
new file mode 100644
@@ -0,0 +1,120 @@
1+# Quality report — 2026-09-16T06:28:58Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #14 (previous: 2026-09-16T06:28:41Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **18** (vs previous: -1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
69+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
70+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
71+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
72+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
73+
74+## Metrics (`qlty metrics`)
75+
76+| metric | total | vs previous |
77+|---|---|---|
78+| funcs | 387 | ±0 |
79+| classes | 73 | ±0 |
80+| fields | 291 | ±0 |
81+| cyclo | 1952 | ±0 |
82+| complex | 1072 | ±0 |
83+| lcom | 0 | ±0 |
84+| lines | 8662 | +2 |
85+| loc | 5736 | +1 |
86+
87+### Most complex files
88+
89+| file | complex | cyclo | loc |
90+|---|---|---|---|
91+| internal/agent/agent.go | 126 | 93 | 299 |
92+| internal/engine/engine.go | 91 | 121 | 347 |
93+| internal/fileedit/diff.go | 89 | 177 | 271 |
94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/events.go | 44 | 129 | 246 |
96+| internal/tui/draw.go | 42 | 98 | 175 |
97+| internal/tui/turn.go | 42 | 88 | 344 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
99+| internal/tools/files.go | 32 | 50 | 238 |
100+| internal/store/keeper.go | 31 | 60 | 131 |
101+| internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
103+| internal/tools/tools.go | 28 | 47 | 191 |
104+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
105+| internal/skills/skills.go | 27 | 37 | 102 |
106+
107+## Trend
108+
109+| run | timestamp | error | warning | smells | complex | gate |
110+|---|---|---|---|---|---|---|
111+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
112+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
113+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
114+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
115+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
116+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
117+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
118+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
119+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
120+| 14 | 2026-09-16T06:28:58Z | 0 | 63 | 18 | 1072 | FAIL |
new file mode 100644
@@ -0,0 +1,120 @@
1+# Quality report — 2026-09-16T06:28:58Z
2+
3+- **Gate**: ❌ **FAIL**
4+- **Commit**: `d722711` on `main`
5+- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6+- **Run**: #14 (previous: 2026-09-16T06:28:41Z)
7+
8+## Gate violations
9+
10+- warning-level issues: 63 (max 0)
11+- code smells: 18 (max 0)
12+
13+## Lint issues (`qlty check`)
14+
15+| level | count | vs previous |
16+|---|---|---|
17+| warning | 63 | ±0 |
18+
19+### Top rules
20+
21+| rule | count |
22+|---|---|
23+| golangci-lint:errcheck | 7 |
24+| osv-scanner:CVE-2026-32285 | 2 |
25+| osv-scanner:CVE-2026-24051 | 2 |
26+| osv-scanner:CVE-2026-39883 | 2 |
27+| osv-scanner:CVE-2026-29181 | 2 |
28+| osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
30+| osv-scanner:CVE-2025-47910 | 1 |
31+| osv-scanner:CVE-2025-47912 | 1 |
32+| osv-scanner:CVE-2025-58183 | 1 |
33+| osv-scanner:CVE-2025-58185 | 1 |
34+| osv-scanner:CVE-2025-58186 | 1 |
35+| osv-scanner:CVE-2025-58187 | 1 |
36+| osv-scanner:CVE-2025-58188 | 1 |
37+| osv-scanner:CVE-2025-58189 | 1 |
38+
39+### Most affected files
40+
41+| file | issues |
42+|---|---|
43+| go.mod | 56 |
44+| internal/engine/openai_compat.go | 2 |
45+| internal/fileedit/fileedit_test.go | 2 |
46+| internal/fileedit/textfile.go | 2 |
47+| internal/engine/engine.go | 1 |
48+
49+## Code smells (`qlty smells`)
50+
51+Total: **18** (vs previous: -1)
52+
53+| smell | file | line | detail |
54+|---|---|---|---|
55+| qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
56+| qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
57+| qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
69+| qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
70+| qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
71+| qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
72+| qlty:return-statements | internal/engine/explain.go | 24 | Function with many returns (count = 9): Explain |
73+
74+## Metrics (`qlty metrics`)
75+
76+| metric | total | vs previous |
77+|---|---|---|
78+| funcs | 387 | ±0 |
79+| classes | 73 | ±0 |
80+| fields | 291 | ±0 |
81+| cyclo | 1952 | ±0 |
82+| complex | 1072 | ±0 |
83+| lcom | 0 | ±0 |
84+| lines | 8662 | +2 |
85+| loc | 5736 | +1 |
86+
87+### Most complex files
88+
89+| file | complex | cyclo | loc |
90+|---|---|---|---|
91+| internal/agent/agent.go | 126 | 93 | 299 |
92+| internal/engine/engine.go | 91 | 121 | 347 |
93+| internal/fileedit/diff.go | 89 | 177 | 271 |
94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/events.go | 44 | 129 | 246 |
96+| internal/tui/draw.go | 42 | 98 | 175 |
97+| internal/tui/turn.go | 42 | 88 | 344 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
99+| internal/tools/files.go | 32 | 50 | 238 |
100+| internal/store/keeper.go | 31 | 60 | 131 |
101+| internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
103+| internal/tools/tools.go | 28 | 47 | 191 |
104+| internal/fileedit/fileedit.go | 27 | 50 | 125 |
105+| internal/skills/skills.go | 27 | 37 | 102 |
106+
107+## Trend
108+
109+| run | timestamp | error | warning | smells | complex | gate |
110+|---|---|---|---|---|---|---|
111+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
112+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
113+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
114+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
115+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
116+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
117+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
118+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
119+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
120+| 14 | 2026-09-16T06:28:58Z | 0 | 63 | 18 | 1072 | FAIL |
modified .quality/report-latest.md +46 -39
@@ -1,31 +1,32 @@
1-# Quality report — 2026-09-15T22:33:07Z
1+# Quality report — 2026-09-16T06:28:58Z
22
33 - **Gate**: ❌ **FAIL**
4-- **Commit**: `79fc998` on `main`
4+- **Commit**: `d722711` on `main`
55 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6-- **Run**: #2 (previous: 2026-09-15T22:32:19Z)
6+- **Run**: #14 (previous: 2026-09-16T06:28:41Z)
77
88 ## Gate violations
99
10-- warning-level issues: 67 (max 0)
10+- warning-level issues: 63 (max 0)
1111 - code smells: 18 (max 0)
1212
1313 ## Lint issues (`qlty check`)
1414
1515 | level | count | vs previous |
1616 |---|---|---|
17-| warning | 67 | -2 |
17+| warning | 63 | ±0 |
1818
1919 ### Top rules
2020
2121 | rule | count |
2222 |---|---|
23-| golangci-lint:errcheck | 12 |
23+| golangci-lint:errcheck | 7 |
2424 | osv-scanner:CVE-2026-32285 | 2 |
2525 | osv-scanner:CVE-2026-24051 | 2 |
2626 | osv-scanner:CVE-2026-39883 | 2 |
2727 | osv-scanner:CVE-2026-29181 | 2 |
2828 | osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
2930 | osv-scanner:CVE-2025-47910 | 1 |
3031 | osv-scanner:CVE-2025-47912 | 1 |
3132 | osv-scanner:CVE-2025-58183 | 1 |
@@ -34,14 +35,12 @@
3435 | osv-scanner:CVE-2025-58187 | 1 |
3536 | osv-scanner:CVE-2025-58188 | 1 |
3637 | osv-scanner:CVE-2025-58189 | 1 |
37-| osv-scanner:CVE-2025-61723 | 1 |
3838
3939 ### Most affected files
4040
4141 | file | issues |
4242 |---|---|
43-| go.mod | 55 |
44-| main.go | 5 |
43+| go.mod | 56 |
4544 | internal/engine/openai_compat.go | 2 |
4645 | internal/fileedit/fileedit_test.go | 2 |
4746 | internal/fileedit/textfile.go | 2 |
@@ -49,24 +48,24 @@
4948
5049 ## Code smells (`qlty smells`)
5150
52-Total: **18** (vs previous: ±0)
51+Total: **18** (vs previous: -1)
5352
5453 | smell | file | line | detail |
5554 |---|---|---|---|
5655 | qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
5756 | qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
5857 | qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59-| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |
60-| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |
61-| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |
62-| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |
63-| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |
64-| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |
65-| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |
66-| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |
67-| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |
68-| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |
69-| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |
58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
7069 | qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
7170 | qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
7271 | qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
@@ -76,38 +75,46 @@ Total: **18** (vs previous: ±0)
7675
7776 | metric | total | vs previous |
7877 |---|---|---|
79-| funcs | 156 | ±0 |
80-| classes | 38 | ±0 |
81-| fields | 149 | ±0 |
82-| cyclo | 992 | -2 |
83-| complex | 618 | -2 |
78+| funcs | 387 | ±0 |
79+| classes | 73 | ±0 |
80+| fields | 291 | ±0 |
81+| cyclo | 1952 | ±0 |
82+| complex | 1072 | ±0 |
8483 | lcom | 0 | ±0 |
85-| lines | 4719 | +1 |
86-| loc | 2956 | +2 |
84+| lines | 8662 | +2 |
85+| loc | 5736 | +1 |
8786
8887 ### Most complex files
8988
9089 | file | complex | cyclo | loc |
9190 |---|---|---|---|
92-| internal/agent/agent.go | 131 | 97 | 270 |
91+| internal/agent/agent.go | 126 | 93 | 299 |
9392 | internal/engine/engine.go | 91 | 121 | 347 |
9493 | internal/fileedit/diff.go | 89 | 177 | 271 |
95-| internal/compact/compact.go | 50 | 92 | 175 |
94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/events.go | 44 | 129 | 246 |
96+| internal/tui/draw.go | 42 | 98 | 175 |
97+| internal/tui/turn.go | 42 | 88 | 344 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
9699 | internal/tools/files.go | 32 | 50 | 238 |
100+| internal/store/keeper.go | 31 | 60 | 131 |
97101 | internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
98103 | internal/tools/tools.go | 28 | 47 | 191 |
99104 | internal/fileedit/fileedit.go | 27 | 50 | 125 |
100-| internal/skills/skills.go | 20 | 28 | 79 |
101-| internal/fileedit/textfile.go | 15 | 27 | 76 |
102-| main.go | 15 | 31 | 92 |
103-| internal/fileedit/edits.go | 14 | 38 | 88 |
104-| internal/engine/explain.go | 13 | 31 | 58 |
105-| internal/spinner/spinner.go | 13 | 27 | 102 |
106-| internal/acp/acp.go | 11 | 28 | 183 |
105+| internal/skills/skills.go | 27 | 37 | 102 |
107106
108107 ## Trend
109108
110109 | run | timestamp | error | warning | smells | complex | gate |
111110 |---|---|---|---|---|---|---|
112-| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |
113-| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |
111+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
112+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
113+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
114+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
115+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
116+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
117+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
118+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
119+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
120+| 14 | 2026-09-16T06:28:58Z | 0 | 63 | 18 | 1072 | FAIL |
@@ -1,31 +1,32 @@
1-# Quality report — 2026-09-15T22:33:07Z1+# Quality report — 2026-09-16T06:28:58Z
2 2
3 - **Gate**: ❌ **FAIL**3 - **Gate**: ❌ **FAIL**
4-- **Commit**: `79fc998` on `main`4+- **Commit**: `d722711` on `main`
5 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)5 - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)
6-- **Run**: #2 (previous: 2026-09-15T22:32:19Z)6+- **Run**: #14 (previous: 2026-09-16T06:28:41Z)
7 7
8 ## Gate violations8 ## Gate violations
9 9
10-- warning-level issues: 67 (max 0)10+- warning-level issues: 63 (max 0)
11 - code smells: 18 (max 0)11 - code smells: 18 (max 0)
12 12
13 ## Lint issues (`qlty check`)13 ## Lint issues (`qlty check`)
14 14
15 | level | count | vs previous |15 | level | count | vs previous |
16 |---|---|---|16 |---|---|---|
17-| warning | 67 | -2 |17+| warning | 63 | ±0 |
18 18
19 ### Top rules19 ### Top rules
20 20
21 | rule | count |21 | rule | count |
22 |---|---|22 |---|---|
23-| golangci-lint:errcheck | 12 |23+| golangci-lint:errcheck | 7 |
24 | osv-scanner:CVE-2026-32285 | 2 |24 | osv-scanner:CVE-2026-32285 | 2 |
25 | osv-scanner:CVE-2026-24051 | 2 |25 | osv-scanner:CVE-2026-24051 | 2 |
26 | osv-scanner:CVE-2026-39883 | 2 |26 | osv-scanner:CVE-2026-39883 | 2 |
27 | osv-scanner:CVE-2026-29181 | 2 |27 | osv-scanner:CVE-2026-29181 | 2 |
28 | osv-scanner:CVE-2026-39824 | 1 |28 | osv-scanner:CVE-2026-39824 | 1 |
29+| osv-scanner:CVE-2026-56852 | 1 |
29 | osv-scanner:CVE-2025-47910 | 1 |30 | osv-scanner:CVE-2025-47910 | 1 |
30 | osv-scanner:CVE-2025-47912 | 1 |31 | osv-scanner:CVE-2025-47912 | 1 |
31 | osv-scanner:CVE-2025-58183 | 1 |32 | osv-scanner:CVE-2025-58183 | 1 |
@@ -34,14 +35,12 @@
34 | osv-scanner:CVE-2025-58187 | 1 |35 | osv-scanner:CVE-2025-58187 | 1 |
35 | osv-scanner:CVE-2025-58188 | 1 |36 | osv-scanner:CVE-2025-58188 | 1 |
36 | osv-scanner:CVE-2025-58189 | 1 |37 | osv-scanner:CVE-2025-58189 | 1 |
37-| osv-scanner:CVE-2025-61723 | 1 |
38 38
39 ### Most affected files39 ### Most affected files
40 40
41 | file | issues |41 | file | issues |
42 |---|---|42 |---|---|
43-| go.mod | 55 |43+| go.mod | 56 |
44-| main.go | 5 |
45 | internal/engine/openai_compat.go | 2 |44 | internal/engine/openai_compat.go | 2 |
46 | internal/fileedit/fileedit_test.go | 2 |45 | internal/fileedit/fileedit_test.go | 2 |
47 | internal/fileedit/textfile.go | 2 |46 | internal/fileedit/textfile.go | 2 |
@@ -49,24 +48,24 @@
49 48
50 ## Code smells (`qlty smells`)49 ## Code smells (`qlty smells`)
51 50
52-Total: **18** (vs previous: ±0)51+Total: **18** (vs previous: -1)
53 52
54 | smell | file | line | detail |53 | smell | file | line | detail |
55 |---|---|---|---|54 |---|---|---|---|
56 | qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |55 | qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write |
57 | qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |56 | qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) |
58 | qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |57 | qlty:function-complexity | internal/fileedit/diff.go | 136 | Function with high complexity (count = 20): middle |
59-| qlty:return-statements | internal/agent/agent.go | 37 | Function with many returns (count = 6): Run |58+| qlty:return-statements | internal/agent/agent.go | 39 | Function with many returns (count = 6): Run |
60-| qlty:nested-control-flow | internal/agent/agent.go | 199 | Deeply nested control flow (level = 5) |59+| qlty:nested-control-flow | internal/agent/agent.go | 213 | Deeply nested control flow (level = 5) |
61-| qlty:nested-control-flow | internal/agent/agent.go | 208 | Deeply nested control flow (level = 5) |60+| qlty:nested-control-flow | internal/agent/agent.go | 222 | Deeply nested control flow (level = 5) |
62-| qlty:nested-control-flow | internal/agent/agent.go | 279 | Deeply nested control flow (level = 5) |61+| qlty:nested-control-flow | internal/agent/agent.go | 294 | Deeply nested control flow (level = 5) |
63-| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 131) |62+| qlty:file-complexity | internal/agent/agent.go | 1 | High total complexity (count = 126) |
64-| qlty:function-complexity | internal/agent/agent.go | 37 | Function with high complexity (count = 119): Run |63+| qlty:function-complexity | internal/agent/agent.go | 39 | Function with high complexity (count = 110): Run |
65-| qlty:return-statements | internal/config/config.go | 254 | Function with many returns (count = 11): validate |64+| qlty:return-statements | internal/config/config.go | 293 | Function with many returns (count = 13): validate |
66-| qlty:return-statements | internal/compact/compact.go | 102 | Function with many returns (count = 6): Compact |65+| qlty:return-statements | internal/compact/compact.go | 129 | Function with many returns (count = 6): Compact |
67-| qlty:return-statements | internal/compact/compact.go | 184 | Function with many returns (count = 6): Valid |66+| qlty:return-statements | internal/compact/compact.go | 211 | Function with many returns (count = 6): Valid |
68-| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 50) |67+| qlty:file-complexity | internal/compact/compact.go | 1 | High total complexity (count = 52) |
69-| qlty:function-complexity | internal/compact/compact.go | 184 | Function with high complexity (count = 22): Valid |68+| qlty:function-complexity | internal/compact/compact.go | 211 | Function with high complexity (count = 22): Valid |
70 | qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |69 | qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate |
71 | qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |70 | qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) |
72 | qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |71 | qlty:function-complexity | internal/engine/engine.go | 226 | Function with high complexity (count = 18): Generate |
@@ -76,38 +75,46 @@ Total: **18** (vs previous: ±0)
76 75
77 | metric | total | vs previous |76 | metric | total | vs previous |
78 |---|---|---|77 |---|---|---|
79-| funcs | 156 | ±0 |78+| funcs | 387 | ±0 |
80-| classes | 38 | ±0 |79+| classes | 73 | ±0 |
81-| fields | 149 | ±0 |80+| fields | 291 | ±0 |
82-| cyclo | 992 | -2 |81+| cyclo | 1952 | ±0 |
83-| complex | 618 | -2 |82+| complex | 1072 | ±0 |
84 | lcom | 0 | ±0 |83 | lcom | 0 | ±0 |
85-| lines | 4719 | +1 |84+| lines | 8662 | +2 |
86-| loc | 2956 | +2 |85+| loc | 5736 | +1 |
87 86
88 ### Most complex files87 ### Most complex files
89 88
90 | file | complex | cyclo | loc |89 | file | complex | cyclo | loc |
91 |---|---|---|---|90 |---|---|---|---|
92-| internal/agent/agent.go | 131 | 97 | 270 |91+| internal/agent/agent.go | 126 | 93 | 299 |
93 | internal/engine/engine.go | 91 | 121 | 347 |92 | internal/engine/engine.go | 91 | 121 | 347 |
94 | internal/fileedit/diff.go | 89 | 177 | 271 |93 | internal/fileedit/diff.go | 89 | 177 | 271 |
95-| internal/compact/compact.go | 50 | 92 | 175 |94+| internal/compact/compact.go | 52 | 98 | 191 |
95+| internal/tui/events.go | 44 | 129 | 246 |
96+| internal/tui/draw.go | 42 | 98 | 175 |
97+| internal/tui/turn.go | 42 | 88 | 344 |
98+| internal/acp/replay.go | 33 | 43 | 95 |
96 | internal/tools/files.go | 32 | 50 | 238 |99 | internal/tools/files.go | 32 | 50 | 238 |
100+| internal/store/keeper.go | 31 | 60 | 131 |
97 | internal/engine/openai_compat.go | 29 | 51 | 176 |101 | internal/engine/openai_compat.go | 29 | 51 | 176 |
102+| internal/store/store.go | 29 | 53 | 152 |
98 | internal/tools/tools.go | 28 | 47 | 191 |103 | internal/tools/tools.go | 28 | 47 | 191 |
99 | internal/fileedit/fileedit.go | 27 | 50 | 125 |104 | internal/fileedit/fileedit.go | 27 | 50 | 125 |
100-| internal/skills/skills.go | 20 | 28 | 79 |105+| internal/skills/skills.go | 27 | 37 | 102 |
101-| internal/fileedit/textfile.go | 15 | 27 | 76 |
102-| main.go | 15 | 31 | 92 |
103-| internal/fileedit/edits.go | 14 | 38 | 88 |
104-| internal/engine/explain.go | 13 | 31 | 58 |
105-| internal/spinner/spinner.go | 13 | 27 | 102 |
106-| internal/acp/acp.go | 11 | 28 | 183 |
107 106
108 ## Trend107 ## Trend
109 108
110 | run | timestamp | error | warning | smells | complex | gate |109 | run | timestamp | error | warning | smells | complex | gate |
111 |---|---|---|---|---|---|---|110 |---|---|---|---|---|---|---|
112-| 1 | 2026-09-15T22:32:19Z | 0 | 69 | 18 | 620 | FAIL |111+| 5 | 2026-09-16T04:27:10Z | 0 | 67 | 20 | 777 | FAIL |
113-| 2 | 2026-09-15T22:33:07Z | 0 | 67 | 18 | 618 | FAIL |112+| 6 | 2026-09-16T04:31:40Z | 0 | 67 | 19 | 778 | FAIL |
113+| 7 | 2026-09-16T05:25:29Z | 0 | 69 | 22 | 999 | FAIL |
114+| 8 | 2026-09-16T05:26:49Z | 0 | 63 | 20 | 997 | FAIL |
115+| 9 | 2026-09-16T05:27:55Z | 0 | 63 | 19 | 997 | FAIL |
116+| 10 | 2026-09-16T06:09:34Z | 0 | 0 | 19 | 1028 | FAIL |
117+| 11 | 2026-09-16T06:12:48Z | 0 | 63 | 18 | 1029 | FAIL |
118+| 12 | 2026-09-16T06:28:23Z | 0 | 63 | 19 | 1072 | FAIL |
119+| 13 | 2026-09-16T06:28:41Z | 0 | 63 | 19 | 1072 | FAIL |
120+| 14 | 2026-09-16T06:28:58Z | 0 | 63 | 18 | 1072 | FAIL |
modified .tickets/issues/0001-add-a-help-command.yaml +45 -4
@@ -6,8 +6,49 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-15T22:22:54.491Z
9-updatedAt: 2026-09-15T22:22:54.491Z
10-labels: []
11-body: ""
12-tasks: []
9+updatedAt: 2026-09-16T05:40:00.000Z
10+labels:
11+ - kind::feature
12+ - priority::low
13+body: |
14+ ## Done (2026-09-16, agent bob, as part of ticket 0012)
15+
16+ `/help` exists in the two terminal front ends. It prints the shared command table (`internal/command`) for that front end, one command per line, aligned, with `[arg]` on `/resume`:
17+
18+ ```
19+ /quit Exit the agent
20+ /abort Stop the generation in progress
21+ /compact Compress the history now, keeping the last turns
22+ /new Clear the history and start a new session
23+ /sessions List the stored sessions of this project
24+ /resume [arg] Continue a stored session: the latest, or the given id
25+ /help List the commands
26+ ```
27+
28+ - Line mode: `/help` between two generations; the start-up banner now ends with `"/help" to list them`.
29+ - Window mode (`mm -tui`): `/help` or **F1**; the notice also lists the window's keys. The `/` picker shows the same table with descriptions.
30+ - ACP: not offered — the editor lists the agent's commands itself from `available_commands_update`.
31+
32+ Documented in `docs/{en,fr}/reference/slash-commands.md`; tests in `internal/command/command_test.go` (`TestUsageListsAlignedLines`) and `internal/tui/turn_test.go` (`TestRunnerCommands`).
33+tasks:
34+ - id: 1
35+ title: "/help in the line-mode REPL, printing the shared command table"
36+ state: closed
37+ priority: none
38+ author:
39+ name: k33g
40+ email: ph.charriere@gmail.com
41+ createdAt: 2026-09-16T05:40:00.000Z
42+ updatedAt: 2026-09-16T05:40:00.000Z
43+ closedAt: 2026-09-16T05:40:00.000Z
44+ - id: 2
45+ title: "/help and F1 in the window mode, with the key list"
46+ state: closed
47+ priority: none
48+ author:
49+ name: k33g
50+ email: ph.charriere@gmail.com
51+ createdAt: 2026-09-16T05:40:00.000Z
52+ updatedAt: 2026-09-16T05:40:00.000Z
53+ closedAt: 2026-09-16T05:40:00.000Z
1354 comments: []
@@ -6,8 +6,49 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-15T22:22:54.491Z8 createdAt: 2026-09-15T22:22:54.491Z
9-updatedAt: 2026-09-15T22:22:54.491Z9+updatedAt: 2026-09-16T05:40:00.000Z
10-labels: []10+labels:
11-body: ""11+ - kind::feature
12-tasks: []12+ - priority::low
13+body: |
14+ ## Done (2026-09-16, agent bob, as part of ticket 0012)
15+
16+ `/help` exists in the two terminal front ends. It prints the shared command table (`internal/command`) for that front end, one command per line, aligned, with `[arg]` on `/resume`:
17+
18+ ```
19+ /quit Exit the agent
20+ /abort Stop the generation in progress
21+ /compact Compress the history now, keeping the last turns
22+ /new Clear the history and start a new session
23+ /sessions List the stored sessions of this project
24+ /resume [arg] Continue a stored session: the latest, or the given id
25+ /help List the commands
26+ ```
27+
28+ - Line mode: `/help` between two generations; the start-up banner now ends with `"/help" to list them`.
29+ - Window mode (`mm -tui`): `/help` or **F1**; the notice also lists the window's keys. The `/` picker shows the same table with descriptions.
30+ - ACP: not offered — the editor lists the agent's commands itself from `available_commands_update`.
31+
32+ Documented in `docs/{en,fr}/reference/slash-commands.md`; tests in `internal/command/command_test.go` (`TestUsageListsAlignedLines`) and `internal/tui/turn_test.go` (`TestRunnerCommands`).
33+tasks:
34+ - id: 1
35+ title: "/help in the line-mode REPL, printing the shared command table"
36+ state: closed
37+ priority: none
38+ author:
39+ name: k33g
40+ email: ph.charriere@gmail.com
41+ createdAt: 2026-09-16T05:40:00.000Z
42+ updatedAt: 2026-09-16T05:40:00.000Z
43+ closedAt: 2026-09-16T05:40:00.000Z
44+ - id: 2
45+ title: "/help and F1 in the window mode, with the key list"
46+ state: closed
47+ priority: none
48+ author:
49+ name: k33g
50+ email: ph.charriere@gmail.com
51+ createdAt: 2026-09-16T05:40:00.000Z
52+ updatedAt: 2026-09-16T05:40:00.000Z
53+ closedAt: 2026-09-16T05:40:00.000Z
13 comments: []54 comments: []
modified .tickets/issues/0003-improve-tui.yaml +89 -3
@@ -6,8 +6,94 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-15T22:25:16.449Z
9-updatedAt: 2026-09-15T22:25:16.449Z
10-labels: []
11-body: ""
9+updatedAt: 2026-09-16T00:00:00.000Z
10+labels:
11+ - kind::feature
12+ - priority::medium
13+body: |
14+ ## Current state (from the code, 2026-09-16)
15+
16+ - **Input**: `internal/agent/agent.go` reads stdin line by line with `bufio.Reader.ReadString('\n')` in a goroutine. There is no line editing beyond what the terminal's cooked mode gives (backspace only), no input history (up arrow prints `^[[A`), no multi-line input (a pasted multi-line text is split into one prompt per line and each line is sent as a separate question), no completion. The prompt is a bare `> `.
17+ - **Output**: plain `fmt.Print` streaming of the model's text prefixed with `🤖 `, a braille spinner (`internal/spinner`, labels "Thinking" / "Running" / "Compressing"), tool echoes `🛠️ bash: <cmd>` then the first `previewLines` lines of output dimmed and indented with `│`, file diffs echoed the same way, a dimmed `⚙ N command(s) · 📝 N file op(s) · 📖 N skill(s)` recap, an optional green numbered command list (`displayCommands`), `📎 <path>` echoes for `@path` mentions, `🗜️` compaction report, `🆕` new-session line. Colour = two ANSI codes (dim, green), only when stdout is a character device and `NO_COLOR`/`TERM=dumb` are unset (`spinner.Styled()`).
18+ - **Commands**: `/quit /abort /compact /new`, plus `/sessions` and `/resume` being added by the persistent-sessions work (ticket 0008). The only help is the two-line banner at start-up. Errors are one line: `[error: <engine.Explain>]`.
19+ - **Permissions**: none in the terminal. The ACP front end asks the editor before `bash` and file tools (`internal/acp/sink.go` `Allow`: allow once / allow for this session / reject); the terminal runs everything the model asks.
20+ - **Dependencies**: Genkit, openai-go, yaml.v3, acp-go-sdk. Nothing for the terminal.
21+ - **Design constraints** (`.memory/summary.md`): minimal bash-first agent, piped output must stay clean (`mm | jq` receives no escape codes), two front ends share one engine and the `ui.Sink` event interface.
22+
23+ ## Pain points observed
24+
25+ 1. No history / editing: a typo means retyping the whole question; a previous question cannot be recalled.
26+ 2. Multi-line paste is destructive (one question per line, each cancelling the previous generation through the "new command received" branch).
27+ 3. Markdown from the model is shown raw: `**bold**`, `# headings`, fenced code blocks with backticks.
28+ 4. No permission step before a `bash` command in the terminal, while the editor gets one — the two front ends are not equal in safety.
29+ 5. The recap says how many commands ran but never how many tokens were used or how full the context is (tickets 0005/0006).
30+ 6. The prompt gives no context (which project, which model, which session).
31+ 7. Discoverability: the banner lists the commands once; there is no `/help` (ticket 0001) and no `/about` (ticket 0002).
32+ 8. Tool output framing relies on dim + `│`; on terminals without dim support everything looks the same weight.
33+
34+ ## Comparison: cagent (Docker agent) TUI, facts
35+
36+ - Full TUI on **Bubble Tea v2 + lipgloss v2 + glamour v2 + chroma** (`tmp/docker-agent/go.mod`), alternate screen, tabs, sidebar, dialogs. Components under `pkg/tui/components/`: `editor` (multi-line, paste buffered to a temp-file attachment above `maxInlinePasteLines`, suggestions), `markdown` (`fast_renderer.go`, `incremental.go`: incremental Markdown rendering while streaming, code blocks with syntax highlighting, mermaid/latex), `tool`, `toolconfirm` (allow / always allow with a pattern label / reject with preset reasons), `statusbar`, `scrollview`, `spinner`, `reasoningblock`, `notification`, `tour`. Session browser dialog filtered by workspace (`pkg/tui/dialog/session_browser.go`). External `$EDITOR` for long drafts (`pkg/tui/plans.go`). Keybindings table in `pkg/tui/core/keys.go` (Ctrl+k command palette, Ctrl+h help, Tab focus, Ctrl+j newline).
37+ - **`pkg/leantui`** (`--lean`, ~2.7k lines): "minimal, scrollback-friendly" mode that never enters the alternate screen; finished content is committed to normal scrollback and a small live region (input box + status footer with tokens and cost, `usage.go`) is redrawn in place. Own input parser, differential renderer and editor on top of lipgloss + x/ansi only.
38+ - Fit for mini-me: the **lean** philosophy (scrollback stays a transcript, small pinned status line) fits; the full Bubble Tea stack (alternate screen, tabs, sidebar, dialogs, mermaid) does not — it would multiply the dependency footprint and hide the plain transcript that `mm | tee` and the tutorials rely on.
39+
40+ ## Proposals
41+
42+ Sizes: S = a day or less, M = a few days, L = a week or more. "Lands in" names the package.
43+
44+ ### Tier 1 — cheap wins, no new dependency
45+
46+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
47+ |---|----------|--------------------|----------|------------|------|---------|
48+ | 1.1 | **Multi-line input with a terminator** | A line ending with `\` continues on the next line; or a `"""` fence opens/closes a block; a paste of N lines becomes one question. | `agent.go` input reader (assemble before sending on `inputCh`) | none | S | — |
49+ | 1.2 | **`/help` and `/about`** | `/help` lists commands, shortcuts, `@path`, current config keys that affect display; `/about` prints version, provider, model, ctx, tools, skills dir, sessions dir. | `agent.go` command switch; version const already in `internal/acp` (move to a shared place) | none | S | 0001, 0002 |
50+ | 1.3 | **Informative prompt** | `mm (project-dir · model) > ` or, dimmed, a line above the prompt with session id and context % (see 1.5). Off in pipes. | `agent.go` prompt print | none | S | 0008 |
51+ | 1.4 | **Status line after each turn** | Extend the `⚙` recap with `· 🧮 N tokens (in/out) · ctx 42%` using `engine.LastInputTokens()` and `ContextWindow`; show `?` when the window is unknown. | `agent.go` recap; engine already exposes the numbers | none | S | 0005 |
52+ | 1.5 | **`/status` (or `/tokens`) command** | On demand: messages in history, estimated tokens (`compact.Estimate`), last measured input tokens, window, % until compaction, session id and file. | `agent.go`; `compact.Estimate`, `engine` | none | S | 0005, 0006 (ACP twin) |
53+ | 1.6 | **Clearer tool framing** | A header line `┌ bash: <cmd>` and a footer `└ exit 0 · 12 lines · 0.3s` around the dimmed output; failures in red; `(no output)` kept. Also for file tools: `┌ edit_file: path (+3 −1)`. | `internal/tools` `echoOutput` / `report` | none | S | — |
54+ | 1.7 | **Diff colouring** | `+` lines green, `-` lines red in the echoed diff of `write_file`/`edit_file`; falls back to plain in pipes. | `internal/tools/files.go` `resultText`/`report` | none | S | — |
55+ | 1.8 | **Light Markdown to ANSI while streaming** | `# heading` bold, `**bold**` bold, `` `code` `` reverse or dim, fenced blocks indented with a `│` gutter and the language label; a small state machine on the streamed chunks. No tables, no links rewriting. | new `internal/render` used by `engine.emit` only when `Styled()`; ACP path untouched (editor renders) | none | M (S for headings/bold only) | — |
56+ | 1.9 | **Warnings block at start-up** | Group `[warning: …]` lines under one `⚠` header and colour them yellow; keep one-line form in pipes. | `main.go` | none | S | — |
57+ | 1.10 | **`/clear`** | Clears the screen (`\033[2J\033[H`) without touching the history. | `agent.go` | none | S | — |
58+
59+ ### Tier 2 — medium
60+
61+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
62+ |---|----------|--------------------|----------|------------|------|---------|
63+ | 2.1 | **Line editing + history** | Arrows move the cursor, up/down recall previous questions, Ctrl+A/E/W/U, history saved to `<project>/.mm/history` (or `sessions.dir`). | `agent.go` input reader replaced by a readline loop | `golang.org/x/term` (raw mode + own editor, ~400 lines) or `github.com/chzyer/readline` / `github.com/ergochat/readline` (one small dep) | M | 0008 |
64+ | 2.2 | **Permission prompt in the terminal** | Before `bash` / `write_file` / `edit_file`: `? run `rm -rf build`? [y]es / [a]lways for bash this session / [n]o`, mirroring the three ACP options; `permissions: ask | auto` config key, default `auto` to keep current behaviour and demos. | a terminal `ui.Sink` implementation (`internal/agent`) so tools keep calling `Allow`; today only ACP implements it | none | M | — |
65+ | 2.3 | **Session browser** | `/sessions` as a numbered, dated list with first question and message count; `/resume <n>` or `/resume` alone for the latest; a hint at start-up when stored sessions exist (bob's `keep.hint()` is the seed). | `agent.go` + the sessions package from 0008 | none | S once 0008 lands | 0008 |
66+ | 2.4 | **Streaming Markdown renderer** | Full Markdown (tables, lists, nested code, links) rendered as it streams, with syntax highlighting. | `internal/render` | `charm.land/glamour/v2` (+ chroma, lipgloss: a heavy tree) | M | — |
67+ | 2.5 | **Pinned status footer (lean mode)** | One live line at the bottom: spinner label, elapsed, tokens, ctx %, session id; the transcript above stays in normal scrollback (cagent `--lean` idea, without its editor). | `internal/spinner` grows into `internal/tui` (cursor save/restore, redraw on resize via `SIGWINCH`) | `golang.org/x/term` for width | M | 0005 |
68+ | 2.6 | **`$EDITOR` for long prompts** | `/edit` opens `$VISUAL`/`$EDITOR` on a temp file, the saved content is sent as one question. | `agent.go` (`exec.Command`, `spinner.Suspend`) | none | S | — |
69+ | 2.7 | **Notification on turn end** | Terminal bell (`\a`) or OSC 9 notification when a turn longer than N seconds finishes; config key. | `agent.go` | none | S | — |
70+
71+ ### Tier 3 — full TUI
72+
73+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
74+ |---|----------|--------------------|----------|------------|------|---------|
75+ | 3.1 | **Bubble Tea application** | Alternate screen, scrollable transcript, input box, status bar, dialogs for permissions and sessions, tabs per session — cagent's `pkg/tui` shape. | new `internal/tui`, `agent.Run` becomes a thin adapter of `ui.Sink` events to `tea.Msg` | `charm.land/bubbletea/v2`, `lipgloss/v2`, `glamour/v2`, `chroma`, `x/term` | L | all |
76+
77+ Cost and risk of 3.1: the dependency tree grows by an order of magnitude (cagent's `go.mod` has ~240 lines); `mm | tee log` and the tutorials' "what you see" sections stop being true (alternate screen leaves no transcript); the ACP façade would keep its own rendering path, so two UIs to maintain; the `ui.Sink` abstraction already carries every event a TUI would need, so nothing prevents doing it later. Recommendation: **not now**; revisit if tier 1 + 2 prove insufficient.
78+
79+ ## Deliberately left out
80+
81+ - Tabs / multiple concurrent sessions in one terminal (cagent tabs): the engine serialises turns and the REPL is single-session by design.
82+ - Mermaid / LaTeX rendering, image attachments: out of scope for a bash-first agent.
83+ - Mouse support, hover, click-to-load sessions.
84+ - A command palette (Ctrl+k): `/help` + slash commands cover it at this size.
85+
86+ ## Recommended order
87+
88+ 1. 1.1 multi-line input, 1.2 `/help` `/about`, 1.4 status in the recap, 1.5 `/status` — one small PR, closes 0001/0002 and most of 0005.
89+ 2. 1.6 tool framing + 1.7 diff colours + 1.9 warnings block — visual consistency, no dependency.
90+ 3. 2.3 session browser as soon as 0008 lands.
91+ 4. 2.1 line editing + history (decide the dependency: `x/term` + own editor keeps the footprint minimal; a readline lib is faster to ship).
92+ 5. 2.2 terminal permission prompt (`permissions: ask`), making both front ends equally safe.
93+ 6. 1.8 light Markdown, then evaluate whether 2.4 (glamour) is still wanted.
94+ 7. 2.5 pinned status footer, 2.6 `$EDITOR`, 2.7 notification — polish.
95+ 8. 3.1 only if a real need survives the above.
96+
97+ Every proposal must keep the pipe rule: no escape sequence when `spinner.Styled()` is false, and the ACP front end unchanged (the editor owns rendering there).
1298 tasks: []
1399 comments: []
@@ -6,8 +6,94 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-15T22:25:16.449Z8 createdAt: 2026-09-15T22:25:16.449Z
9-updatedAt: 2026-09-15T22:25:16.449Z9+updatedAt: 2026-09-16T00:00:00.000Z
10-labels: []10+labels:
11-body: ""11+ - kind::feature
12+ - priority::medium
13+body: |
14+ ## Current state (from the code, 2026-09-16)
15+
16+ - **Input**: `internal/agent/agent.go` reads stdin line by line with `bufio.Reader.ReadString('\n')` in a goroutine. There is no line editing beyond what the terminal's cooked mode gives (backspace only), no input history (up arrow prints `^[[A`), no multi-line input (a pasted multi-line text is split into one prompt per line and each line is sent as a separate question), no completion. The prompt is a bare `> `.
17+ - **Output**: plain `fmt.Print` streaming of the model's text prefixed with `🤖 `, a braille spinner (`internal/spinner`, labels "Thinking" / "Running" / "Compressing"), tool echoes `🛠️ bash: <cmd>` then the first `previewLines` lines of output dimmed and indented with `│`, file diffs echoed the same way, a dimmed `⚙ N command(s) · 📝 N file op(s) · 📖 N skill(s)` recap, an optional green numbered command list (`displayCommands`), `📎 <path>` echoes for `@path` mentions, `🗜️` compaction report, `🆕` new-session line. Colour = two ANSI codes (dim, green), only when stdout is a character device and `NO_COLOR`/`TERM=dumb` are unset (`spinner.Styled()`).
18+ - **Commands**: `/quit /abort /compact /new`, plus `/sessions` and `/resume` being added by the persistent-sessions work (ticket 0008). The only help is the two-line banner at start-up. Errors are one line: `[error: <engine.Explain>]`.
19+ - **Permissions**: none in the terminal. The ACP front end asks the editor before `bash` and file tools (`internal/acp/sink.go` `Allow`: allow once / allow for this session / reject); the terminal runs everything the model asks.
20+ - **Dependencies**: Genkit, openai-go, yaml.v3, acp-go-sdk. Nothing for the terminal.
21+ - **Design constraints** (`.memory/summary.md`): minimal bash-first agent, piped output must stay clean (`mm | jq` receives no escape codes), two front ends share one engine and the `ui.Sink` event interface.
22+
23+ ## Pain points observed
24+
25+ 1. No history / editing: a typo means retyping the whole question; a previous question cannot be recalled.
26+ 2. Multi-line paste is destructive (one question per line, each cancelling the previous generation through the "new command received" branch).
27+ 3. Markdown from the model is shown raw: `**bold**`, `# headings`, fenced code blocks with backticks.
28+ 4. No permission step before a `bash` command in the terminal, while the editor gets one — the two front ends are not equal in safety.
29+ 5. The recap says how many commands ran but never how many tokens were used or how full the context is (tickets 0005/0006).
30+ 6. The prompt gives no context (which project, which model, which session).
31+ 7. Discoverability: the banner lists the commands once; there is no `/help` (ticket 0001) and no `/about` (ticket 0002).
32+ 8. Tool output framing relies on dim + `│`; on terminals without dim support everything looks the same weight.
33+
34+ ## Comparison: cagent (Docker agent) TUI, facts
35+
36+ - Full TUI on **Bubble Tea v2 + lipgloss v2 + glamour v2 + chroma** (`tmp/docker-agent/go.mod`), alternate screen, tabs, sidebar, dialogs. Components under `pkg/tui/components/`: `editor` (multi-line, paste buffered to a temp-file attachment above `maxInlinePasteLines`, suggestions), `markdown` (`fast_renderer.go`, `incremental.go`: incremental Markdown rendering while streaming, code blocks with syntax highlighting, mermaid/latex), `tool`, `toolconfirm` (allow / always allow with a pattern label / reject with preset reasons), `statusbar`, `scrollview`, `spinner`, `reasoningblock`, `notification`, `tour`. Session browser dialog filtered by workspace (`pkg/tui/dialog/session_browser.go`). External `$EDITOR` for long drafts (`pkg/tui/plans.go`). Keybindings table in `pkg/tui/core/keys.go` (Ctrl+k command palette, Ctrl+h help, Tab focus, Ctrl+j newline).
37+ - **`pkg/leantui`** (`--lean`, ~2.7k lines): "minimal, scrollback-friendly" mode that never enters the alternate screen; finished content is committed to normal scrollback and a small live region (input box + status footer with tokens and cost, `usage.go`) is redrawn in place. Own input parser, differential renderer and editor on top of lipgloss + x/ansi only.
38+ - Fit for mini-me: the **lean** philosophy (scrollback stays a transcript, small pinned status line) fits; the full Bubble Tea stack (alternate screen, tabs, sidebar, dialogs, mermaid) does not — it would multiply the dependency footprint and hide the plain transcript that `mm | tee` and the tutorials rely on.
39+
40+ ## Proposals
41+
42+ Sizes: S = a day or less, M = a few days, L = a week or more. "Lands in" names the package.
43+
44+ ### Tier 1 — cheap wins, no new dependency
45+
46+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
47+ |---|----------|--------------------|----------|------------|------|---------|
48+ | 1.1 | **Multi-line input with a terminator** | A line ending with `\` continues on the next line; or a `"""` fence opens/closes a block; a paste of N lines becomes one question. | `agent.go` input reader (assemble before sending on `inputCh`) | none | S | — |
49+ | 1.2 | **`/help` and `/about`** | `/help` lists commands, shortcuts, `@path`, current config keys that affect display; `/about` prints version, provider, model, ctx, tools, skills dir, sessions dir. | `agent.go` command switch; version const already in `internal/acp` (move to a shared place) | none | S | 0001, 0002 |
50+ | 1.3 | **Informative prompt** | `mm (project-dir · model) > ` or, dimmed, a line above the prompt with session id and context % (see 1.5). Off in pipes. | `agent.go` prompt print | none | S | 0008 |
51+ | 1.4 | **Status line after each turn** | Extend the `⚙` recap with `· 🧮 N tokens (in/out) · ctx 42%` using `engine.LastInputTokens()` and `ContextWindow`; show `?` when the window is unknown. | `agent.go` recap; engine already exposes the numbers | none | S | 0005 |
52+ | 1.5 | **`/status` (or `/tokens`) command** | On demand: messages in history, estimated tokens (`compact.Estimate`), last measured input tokens, window, % until compaction, session id and file. | `agent.go`; `compact.Estimate`, `engine` | none | S | 0005, 0006 (ACP twin) |
53+ | 1.6 | **Clearer tool framing** | A header line `┌ bash: <cmd>` and a footer `└ exit 0 · 12 lines · 0.3s` around the dimmed output; failures in red; `(no output)` kept. Also for file tools: `┌ edit_file: path (+3 −1)`. | `internal/tools` `echoOutput` / `report` | none | S | — |
54+ | 1.7 | **Diff colouring** | `+` lines green, `-` lines red in the echoed diff of `write_file`/`edit_file`; falls back to plain in pipes. | `internal/tools/files.go` `resultText`/`report` | none | S | — |
55+ | 1.8 | **Light Markdown to ANSI while streaming** | `# heading` bold, `**bold**` bold, `` `code` `` reverse or dim, fenced blocks indented with a `│` gutter and the language label; a small state machine on the streamed chunks. No tables, no links rewriting. | new `internal/render` used by `engine.emit` only when `Styled()`; ACP path untouched (editor renders) | none | M (S for headings/bold only) | — |
56+ | 1.9 | **Warnings block at start-up** | Group `[warning: …]` lines under one `⚠` header and colour them yellow; keep one-line form in pipes. | `main.go` | none | S | — |
57+ | 1.10 | **`/clear`** | Clears the screen (`\033[2J\033[H`) without touching the history. | `agent.go` | none | S | — |
58+
59+ ### Tier 2 — medium
60+
61+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
62+ |---|----------|--------------------|----------|------------|------|---------|
63+ | 2.1 | **Line editing + history** | Arrows move the cursor, up/down recall previous questions, Ctrl+A/E/W/U, history saved to `<project>/.mm/history` (or `sessions.dir`). | `agent.go` input reader replaced by a readline loop | `golang.org/x/term` (raw mode + own editor, ~400 lines) or `github.com/chzyer/readline` / `github.com/ergochat/readline` (one small dep) | M | 0008 |
64+ | 2.2 | **Permission prompt in the terminal** | Before `bash` / `write_file` / `edit_file`: `? run `rm -rf build`? [y]es / [a]lways for bash this session / [n]o`, mirroring the three ACP options; `permissions: ask | auto` config key, default `auto` to keep current behaviour and demos. | a terminal `ui.Sink` implementation (`internal/agent`) so tools keep calling `Allow`; today only ACP implements it | none | M | — |
65+ | 2.3 | **Session browser** | `/sessions` as a numbered, dated list with first question and message count; `/resume <n>` or `/resume` alone for the latest; a hint at start-up when stored sessions exist (bob's `keep.hint()` is the seed). | `agent.go` + the sessions package from 0008 | none | S once 0008 lands | 0008 |
66+ | 2.4 | **Streaming Markdown renderer** | Full Markdown (tables, lists, nested code, links) rendered as it streams, with syntax highlighting. | `internal/render` | `charm.land/glamour/v2` (+ chroma, lipgloss: a heavy tree) | M | — |
67+ | 2.5 | **Pinned status footer (lean mode)** | One live line at the bottom: spinner label, elapsed, tokens, ctx %, session id; the transcript above stays in normal scrollback (cagent `--lean` idea, without its editor). | `internal/spinner` grows into `internal/tui` (cursor save/restore, redraw on resize via `SIGWINCH`) | `golang.org/x/term` for width | M | 0005 |
68+ | 2.6 | **`$EDITOR` for long prompts** | `/edit` opens `$VISUAL`/`$EDITOR` on a temp file, the saved content is sent as one question. | `agent.go` (`exec.Command`, `spinner.Suspend`) | none | S | — |
69+ | 2.7 | **Notification on turn end** | Terminal bell (`\a`) or OSC 9 notification when a turn longer than N seconds finishes; config key. | `agent.go` | none | S | — |
70+
71+ ### Tier 3 — full TUI
72+
73+ | # | Proposal | What the user sees | Lands in | Dependency | Size | Related |
74+ |---|----------|--------------------|----------|------------|------|---------|
75+ | 3.1 | **Bubble Tea application** | Alternate screen, scrollable transcript, input box, status bar, dialogs for permissions and sessions, tabs per session — cagent's `pkg/tui` shape. | new `internal/tui`, `agent.Run` becomes a thin adapter of `ui.Sink` events to `tea.Msg` | `charm.land/bubbletea/v2`, `lipgloss/v2`, `glamour/v2`, `chroma`, `x/term` | L | all |
76+
77+ Cost and risk of 3.1: the dependency tree grows by an order of magnitude (cagent's `go.mod` has ~240 lines); `mm | tee log` and the tutorials' "what you see" sections stop being true (alternate screen leaves no transcript); the ACP façade would keep its own rendering path, so two UIs to maintain; the `ui.Sink` abstraction already carries every event a TUI would need, so nothing prevents doing it later. Recommendation: **not now**; revisit if tier 1 + 2 prove insufficient.
78+
79+ ## Deliberately left out
80+
81+ - Tabs / multiple concurrent sessions in one terminal (cagent tabs): the engine serialises turns and the REPL is single-session by design.
82+ - Mermaid / LaTeX rendering, image attachments: out of scope for a bash-first agent.
83+ - Mouse support, hover, click-to-load sessions.
84+ - A command palette (Ctrl+k): `/help` + slash commands cover it at this size.
85+
86+ ## Recommended order
87+
88+ 1. 1.1 multi-line input, 1.2 `/help` `/about`, 1.4 status in the recap, 1.5 `/status` — one small PR, closes 0001/0002 and most of 0005.
89+ 2. 1.6 tool framing + 1.7 diff colours + 1.9 warnings block — visual consistency, no dependency.
90+ 3. 2.3 session browser as soon as 0008 lands.
91+ 4. 2.1 line editing + history (decide the dependency: `x/term` + own editor keeps the footprint minimal; a readline lib is faster to ship).
92+ 5. 2.2 terminal permission prompt (`permissions: ask`), making both front ends equally safe.
93+ 6. 1.8 light Markdown, then evaluate whether 2.4 (glamour) is still wanted.
94+ 7. 2.5 pinned status footer, 2.6 `$EDITOR`, 2.7 notification — polish.
95+ 8. 3.1 only if a real need survives the above.
96+
97+ Every proposal must keep the pipe rule: no escape sequence when `spinner.Styled()` is false, and the ACP front end unchanged (the editor owns rendering there).
12 tasks: []98 tasks: []
13 comments: []99 comments: []
modified .tickets/issues/0008-why-in-zed-i-cannot-relaoad-the-agent.yaml +98 -3
@@ -6,10 +6,105 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-15T23:34:42.762Z
9-updatedAt: 2026-09-15T23:34:54.131Z
9+updatedAt: 2026-09-16T05:30:00.000Z
1010 labels:
1111 - priority::medium
1212 - kind::bug
13-body: ""
14-tasks: []
13+body: |
14+ ## Symptom
15+
16+ In Zed, with mini-me connected over ACP, the **Reload Agent** action shows:
17+
18+ > Loading or resuming sessions is not supported by this agent.
19+
20+ ## Cause
21+
22+ The message is Zed's, and it is accurate. "Reload Agent" restarts the `mm` process, then asks the new process to bring the current thread back with `session/load` (or `session/resume` when an agent declares that still-unstable capability).
23+
24+ In `internal/acp/acp.go`, `initialize` answers with empty agent capabilities, so `loadSession` is `false`, and both `LoadSession` and `ResumeSession` answer `method not found`. Zed therefore reports that the agent cannot reload and can only open a new thread.
25+
26+ ## Why it is not just a flag
27+
28+ mini-me keeps its ACP sessions in memory only. After a restart there is nothing left to load. The protocol also requires `session/load` to **replay the conversation to the editor** before answering: one `session/update` notification per user message, agent message and tool call, so the thread is rebuilt on screen. Replaying means re-sending the recorded messages and tool-call records (name, input, output, status) for display; the tools are **not** executed again.
29+
30+ ## What it would take
31+
32+ 1. Persist every ACP session on disk as the turns go: session id, `cwd`, message history (the Genkit message types serialise to JSON).
33+ 2. Implement `session/load`: read the file back, replay user and agent messages (and tool calls) as `session/update` notifications, restore the in-memory history, answer; advertise `loadSession: true` in `initialize`.
34+ 3. Tests for the serialisation and the replay order; ACP reference docs in EN and FR; `.memory/` update.
35+
36+ Medium-sized feature, comparable to `/new` plus persistence.
37+
38+ ## Open decisions
39+
40+ - Where to store sessions: once per machine (`~/.mm/sessions/`) or inside the project (`<cwd>/.mm/sessions/`, gitignored).
41+ - Replay tool calls with their output in the reloaded thread, or only the text messages. Full replay is more faithful and more work.
42+
43+
44+ ## Implementation note (bob, 2026-09-16)
45+
46+ Implemented 2026-09-16 (not yet committed). Sessions are stored per project as JSON files (`sessions.dir`, default `.mm/sessions`, relative to the project), switched on with `sessions.enabled: true`. Over ACP the agent now advertises `loadSession: true` and answers `session/load` by replaying the whole stored conversation — user and agent messages, and every tool call as an already-completed `tool_call` with its recorded input and output; nothing is executed again — then continues the session under the current system prompt.
47+
48+ Restart behaviour, decided after reading docker agent's source (tmp/docker-agent): a start is a new session; earlier ones come back through `/resume` (terminal) or the editor's Reload Agent (ACP). docker agent does not resume on its own either (`--session -1` / TUI `/sessions`); it chose the unstable `session/resume` without replay and a SQLite store in `~/.cagent/session.db`, mini-me the stable `session/load` with full replay and per-project files.
49+
50+ Still to do: confirm in Zed that Reload Agent rebuilds the thread (verified only with a scripted JSON-RPC client).
51+tasks:
52+ - id: 1
53+ title: "Config key `sessions` (enabled, dir) — off by default"
54+ state: closed
55+ priority: none
56+ author:
57+ name: k33g
58+ email: ph.charriere@gmail.com
59+ createdAt: 2026-09-16T05:30:00.000Z
60+ updatedAt: 2026-09-16T05:30:00.000Z
61+ closedAt: 2026-09-16T05:30:00.000Z
62+ - id: 2
63+ title: "internal/store: one JSON file per session under <project>/.mm/sessions/, rewritten whole after each turn, /compact and /new"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-09-16T05:30:00.000Z
70+ updatedAt: 2026-09-16T05:30:00.000Z
71+ closedAt: 2026-09-16T05:30:00.000Z
72+ - id: 3
73+ title: "ACP: loadSession capability when enabled; session/load replays the stored history (user/agent messages, tool calls as completed tool_call records) then registers the session"
74+ state: closed
75+ priority: none
76+ author:
77+ name: k33g
78+ email: ph.charriere@gmail.com
79+ createdAt: 2026-09-16T05:30:00.000Z
80+ updatedAt: 2026-09-16T05:30:00.000Z
81+ closedAt: 2026-09-16T05:30:00.000Z
82+ - id: 4
83+ title: "Terminal: new session at start, /sessions and /resume [id]; /new starts a new file"
84+ state: closed
85+ priority: none
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-09-16T05:30:00.000Z
90+ updatedAt: 2026-09-16T05:30:00.000Z
91+ closedAt: 2026-09-16T05:30:00.000Z
92+ - id: 5
93+ title: "Docs EN+FR (how-to keep-sessions, explanation persistent-sessions, reference rows)"
94+ state: closed
95+ priority: none
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-09-16T05:30:00.000Z
100+ updatedAt: 2026-09-16T05:30:00.000Z
101+ closedAt: 2026-09-16T05:30:00.000Z
102+ - id: 6
103+ title: Verify in a real Zed that Reload Agent rebuilds the thread
104+ state: open
105+ priority: medium
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-09-16T05:30:00.000Z
15110 comments: []
@@ -6,10 +6,105 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-15T23:34:42.762Z8 createdAt: 2026-09-15T23:34:42.762Z
9-updatedAt: 2026-09-15T23:34:54.131Z9+updatedAt: 2026-09-16T05:30:00.000Z
10 labels:10 labels:
11 - priority::medium11 - priority::medium
12 - kind::bug12 - kind::bug
13-body: ""13+body: |
14-tasks: []14+ ## Symptom
15+
16+ In Zed, with mini-me connected over ACP, the **Reload Agent** action shows:
17+
18+ > Loading or resuming sessions is not supported by this agent.
19+
20+ ## Cause
21+
22+ The message is Zed's, and it is accurate. "Reload Agent" restarts the `mm` process, then asks the new process to bring the current thread back with `session/load` (or `session/resume` when an agent declares that still-unstable capability).
23+
24+ In `internal/acp/acp.go`, `initialize` answers with empty agent capabilities, so `loadSession` is `false`, and both `LoadSession` and `ResumeSession` answer `method not found`. Zed therefore reports that the agent cannot reload and can only open a new thread.
25+
26+ ## Why it is not just a flag
27+
28+ mini-me keeps its ACP sessions in memory only. After a restart there is nothing left to load. The protocol also requires `session/load` to **replay the conversation to the editor** before answering: one `session/update` notification per user message, agent message and tool call, so the thread is rebuilt on screen. Replaying means re-sending the recorded messages and tool-call records (name, input, output, status) for display; the tools are **not** executed again.
29+
30+ ## What it would take
31+
32+ 1. Persist every ACP session on disk as the turns go: session id, `cwd`, message history (the Genkit message types serialise to JSON).
33+ 2. Implement `session/load`: read the file back, replay user and agent messages (and tool calls) as `session/update` notifications, restore the in-memory history, answer; advertise `loadSession: true` in `initialize`.
34+ 3. Tests for the serialisation and the replay order; ACP reference docs in EN and FR; `.memory/` update.
35+
36+ Medium-sized feature, comparable to `/new` plus persistence.
37+
38+ ## Open decisions
39+
40+ - Where to store sessions: once per machine (`~/.mm/sessions/`) or inside the project (`<cwd>/.mm/sessions/`, gitignored).
41+ - Replay tool calls with their output in the reloaded thread, or only the text messages. Full replay is more faithful and more work.
42+
43+
44+ ## Implementation note (bob, 2026-09-16)
45+
46+ Implemented 2026-09-16 (not yet committed). Sessions are stored per project as JSON files (`sessions.dir`, default `.mm/sessions`, relative to the project), switched on with `sessions.enabled: true`. Over ACP the agent now advertises `loadSession: true` and answers `session/load` by replaying the whole stored conversation — user and agent messages, and every tool call as an already-completed `tool_call` with its recorded input and output; nothing is executed again — then continues the session under the current system prompt.
47+
48+ Restart behaviour, decided after reading docker agent's source (tmp/docker-agent): a start is a new session; earlier ones come back through `/resume` (terminal) or the editor's Reload Agent (ACP). docker agent does not resume on its own either (`--session -1` / TUI `/sessions`); it chose the unstable `session/resume` without replay and a SQLite store in `~/.cagent/session.db`, mini-me the stable `session/load` with full replay and per-project files.
49+
50+ Still to do: confirm in Zed that Reload Agent rebuilds the thread (verified only with a scripted JSON-RPC client).
51+tasks:
52+ - id: 1
53+ title: "Config key `sessions` (enabled, dir) — off by default"
54+ state: closed
55+ priority: none
56+ author:
57+ name: k33g
58+ email: ph.charriere@gmail.com
59+ createdAt: 2026-09-16T05:30:00.000Z
60+ updatedAt: 2026-09-16T05:30:00.000Z
61+ closedAt: 2026-09-16T05:30:00.000Z
62+ - id: 2
63+ title: "internal/store: one JSON file per session under <project>/.mm/sessions/, rewritten whole after each turn, /compact and /new"
64+ state: closed
65+ priority: none
66+ author:
67+ name: k33g
68+ email: ph.charriere@gmail.com
69+ createdAt: 2026-09-16T05:30:00.000Z
70+ updatedAt: 2026-09-16T05:30:00.000Z
71+ closedAt: 2026-09-16T05:30:00.000Z
72+ - id: 3
73+ title: "ACP: loadSession capability when enabled; session/load replays the stored history (user/agent messages, tool calls as completed tool_call records) then registers the session"
74+ state: closed
75+ priority: none
76+ author:
77+ name: k33g
78+ email: ph.charriere@gmail.com
79+ createdAt: 2026-09-16T05:30:00.000Z
80+ updatedAt: 2026-09-16T05:30:00.000Z
81+ closedAt: 2026-09-16T05:30:00.000Z
82+ - id: 4
83+ title: "Terminal: new session at start, /sessions and /resume [id]; /new starts a new file"
84+ state: closed
85+ priority: none
86+ author:
87+ name: k33g
88+ email: ph.charriere@gmail.com
89+ createdAt: 2026-09-16T05:30:00.000Z
90+ updatedAt: 2026-09-16T05:30:00.000Z
91+ closedAt: 2026-09-16T05:30:00.000Z
92+ - id: 5
93+ title: "Docs EN+FR (how-to keep-sessions, explanation persistent-sessions, reference rows)"
94+ state: closed
95+ priority: none
96+ author:
97+ name: k33g
98+ email: ph.charriere@gmail.com
99+ createdAt: 2026-09-16T05:30:00.000Z
100+ updatedAt: 2026-09-16T05:30:00.000Z
101+ closedAt: 2026-09-16T05:30:00.000Z
102+ - id: 6
103+ title: Verify in a real Zed that Reload Agent rebuilds the thread
104+ state: open
105+ priority: medium
106+ author:
107+ name: k33g
108+ email: ph.charriere@gmail.com
109+ createdAt: 2026-09-16T05:30:00.000Z
15 comments: []110 comments: []
added .tickets/issues/0010-check-how-the-context-compression-is-working.yaml +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 10
3+title: Check how the context compression is working
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:09:48.463Z
9+updatedAt: 2026-09-16T06:00:00.000Z
10+labels: []
11+body: |
12+ ## Summary
13+
14+ Nothing in an agent loop shortens the history: every `bash` output (up to `maxOutput` characters) and every loaded skill stays until `/quit`. The `internal/compact` package (`compact.go`, `prompt.go`) fixes that with a **hybrid**: the OLD question turns are replaced by ONE summary written by the model itself, and the last `keepLastTurns` turns stay raw. It never calls the model directly (it hands a request to a `Summarizer`, `engine.Summarize` in production) and never prints (the front ends own the report line). Reviewed on 2026-09-16 against the code; nothing was changed.
15+
16+ ## When it runs
17+
18+ **Automatically — terminal only.** In `internal/agent/agent.go`, right before a question is appended to the history (and before the `before` index that the rollback and the ⚙ recap rely on is taken), `compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, cfg)` is called. If the window is still unknown and compression is on, `e.EnsureContextWindow` re-probes the server first (llama.cpp `/props` → `n_ctx`), before every question until the window is known. `Decide` triggers when `context.enabled` is true and either:
19+
20+ | Trigger | Rule | Reason string |
21+ |---------|------|---------------|
22+ | tokens | `window > 0` and `tokens × 100 ≥ window × threshold`, with `tokens = max(Estimate(history), measured)` | `"tokens"` |
23+ | messages | `maxMessages > 0` and `len(history) ≥ maxMessages` | `"messages"` |
24+
25+ `measured` is the server's own `usage.input_tokens` of the **last** model call, captured in the engine's `WrapModel` hook (`e.lastInput`). It sees the tools' JSON and the chat template, which the estimate cannot, but it lags one call behind and is forgotten (`ForgetInputTokens`) after a successful compaction because it described the history that was just replaced. `window` is the top-level `contextWindow` key, or what the provider probe learned; `0` = unknown, and then only `maxMessages` can fire (a start-up warning says so when `maxMessages` is `0` too).
26+
27+ **By hand — both front ends.** `/compact` skips the threshold, not the boundary: terminal `compactHistory(…, forced=true)`, ACP `compactSession` → `compactHistory` in `internal/acp/acp.go`, intercepted in `session/prompt` under `turnMu` (never while a generation holds the history).
28+
29+ **Not automatically over ACP.** `compact.Decide` is called in exactly one place, `internal/agent/agent.go`. The ACP `Prompt` path appends the question and generates without any check: a long editor session is never compacted unless the user types `/compact`.
30+
31+ ## What a compaction does
32+
33+ `Compact(ctx, msgs, cfg, summarize)`:
34+
35+ 1. `Split` separates the leading system message(s) from the **turns**: a turn starts at a user message and runs to the next one, so it holds the whole model ↔ tools exchange of that question. An earlier summary pair is a turn of its own (it starts with a user message).
36+ 2. `keep = max(keepLastTurns, 1)`. If there are no more turns than `keep`, or the only old turn is an earlier summary → `ErrNothingToCompact` (not a failure).
37+ 3. The old turns are flattened and sent as `Request(old, prompt, merge)`: a **note-taker system prompt** (replacing the agent's "you are a coding agent, run commands"), the old messages as they were, then the summary prompt as a user message. `merge` is true when the old turns start with an earlier summary: a line asks the model to fold it in rather than repeat it ("summarising a summary loses detail exponentially").
38+ 4. The built-in `DefaultPrompt` (overridable by `context.prompt`) asks for EXACTLY seven Markdown sections — Goal, Files touched, Decisions, Errors and fixes, Done, In progress / next steps, Useful commands — under 40 lines, concrete paths/commands/errors, **no quoting of file contents** (files may have changed since).
39+ 5. `SummaryPair` builds two messages tagged with metadata `compact: "summary"`: a **user** message `[Context summary — the N earlier messages of this session (Q question(s), C command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]` + the summary, and a **model** acknowledgement `Understood — I will re-check files before editing them.` (keeps the user/assistant alternation chat templates expect).
40+ 6. New history = system head + summary pair + kept turns verbatim. `Valid` re-checks what an OpenAI-compatible server checks (starts with system; every tool request in a model message; every tool response answers a pending request, paired by `Ref` or by name); a failure here is returned as an error and the caller keeps the old history.
41+
42+ Before / after with `keepLastTurns: 1` and three questions of one command each:
43+
44+ ```
45+ before (8 msgs after system): [sys] u1 m(call) t(result) m(text) u2 m(call) t(result) m(text) u3 m(call) t(result) m(text)
46+ after: [sys] u(summary notes*) m(ack*) u3 m(call) t(result) m(text)
47+ * metadata compact=summary
48+ report: 🗜️ compressed 8 messages → 1 summary + 4 kept (…k → …k tokens, …s)
49+ ```
50+
51+ The report line is `Result.Report()`, shared by both front ends (printed dim in the terminal when `showStats` is true, sent as an `agent_message_chunk` over ACP). After a successful compaction the engine's measured token count is forgotten; the loop detector is **kept** (the task continues), unlike `/new` which resets it.
52+
53+ ## The token estimate and its limits
54+
55+ `Estimate` counts characters — text parts, tool request name + JSON input, tool response name + output (a string output is taken as-is, not re-escaped) — divides by 3.5 (`(chars*2+6)/7`) and adds 4 tokens of framing per message. It ignores the system-side chat template, the tool definitions and any provider-side additions, so it under-counts real prompts; that is why the server's measured count is preferred when larger. On a server that reports no usage (the tests' fake engine, some proxies) the estimate is the only figure.
56+
57+ ## Failure and "nothing to compact"
58+
59+ | Case | Result |
60+ |------|--------|
61+ | `ErrNothingToCompact` | History untouched. Terminal: `🗜️ nothing to compact: N message(s), no turn older than the last K` only when forced (`/compact`); silent on the automatic path. ACP: always said (only reachable through `/compact`). |
62+ | summary error (server down, watchdog stall after `2 × watchdogTimeout` = 40 s by default, empty text) | History untouched, measured count kept. Terminal prints `[compact: failed, history kept: <engine.Explain>]`; ACP sends the same line as a chunk. The next question goes out uncompressed. |
63+ | `Valid` fails on the result | Same as an error: `compressed history is invalid: …`, old history kept. |
64+
65+ `engine.Summarize` clones the agent's sampling, removes `parallel_tool_calls` (a 400 without tools), sets `max_tokens = summaryMaxTokens`, streams only to feed the watchdog, and returns an error on an empty answer.
66+
67+ ## Configuration (`internal/config/config.go`)
68+
69+ | Key | Default | Validation | Note |
70+ |-----|---------|------------|------|
71+ | `contextWindow` (top level) | `0` | `≥ 0` | Shared by the banner and the trigger; `0` = ask the server (`/props` on llama.cpp; DMR tells nothing). |
72+ | `context.enabled` | `false` | — | Automatic trigger only; `/compact` always works. |
73+ | `context.threshold` | `75` | 1..100 | Percent of the window. |
74+ | `context.maxMessages` | `80` | `≥ 0` | Fallback on the message count; `0` disables. |
75+ | `context.keepLastTurns` | `3` | `≥ 1` | Question turns kept raw. |
76+ | `context.summaryMaxTokens` | `1200` | `> 0` | `max_tokens` of the summary request. |
77+ | `context.prompt` | `""` | — | Replaces `DefaultPrompt` when set (the merge line is still appended). |
78+ | `context.showStats` | `true` | — | Terminal report line after each compaction. |
79+
80+ Shipped files: `agent.dmr.yaml` → `contextWindow: 0`, `enabled: false`, otherwise defaults (compression is OFF on DMR, and the window is unknown there). `agent.llamacpp.yaml` → `contextWindow: 0`, `enabled: true`, `threshold: 75`, `maxMessages: 80`, `keepLastTurns: 3`, `summaryMaxTokens: 1200`, `showStats: true` (window read from `/props`).
81+
82+ ## Observations, gaps, follow-ups
83+
84+ - **No automatic compaction over ACP** (confirmed: `Decide` has a single call site, in the terminal loop). An editor session on llama.cpp with `enabled: true` still overflows unless the user types `/compact`. Suggested: call `Decide`/`compactHistory` in `acp.Prompt` before appending the question, same place and rule as the REPL, with the report sent as a chunk (or logged) — the `compactHistory(ctx, s, cfg, summarize, explain)` helper already exists there.
85+ - **Interaction with persistent sessions (bob's in-flight feature).** The stored history must be rewritten after every compaction (not appended): the summary pair replaces old turns, and a reload that replays the raw messages would otherwise show turns the model no longer has. On `session/load`, the summary pair will be replayed as a **user** message carrying the notes plus a one-line model acknowledgement — acceptable, but worth labelling in the replay if the editor shows it verbatim.
86+ - **Nothing-to-compact on the automatic path is silent**, so on a single very long turn the threshold can be crossed at every question with no visible sign; a dim line or a once-per-session note would help.
87+ - **The estimate ignores tool definitions and the chat template**; on a server that reports no usage the threshold fires later than intended. `threshold: 75` leaves margin, but a `maxOutput`-sized bash output (about 4–5k tokens) can still overshoot a small window.
88+ - **Keep policy is by turn count, not by token budget**: three huge turns can keep most of the window; cagent keeps a token-budgeted suffix instead (see below).
89+ - **No reactive compaction**: a 400 "context length exceeded" from the server is reported as an error and the turn is lost; cagent retries once after an overflow compaction.
90+
91+ ## Documentation check (`docs/en`, not edited)
92+
93+ - `how-to/manage-the-context-window.md`, variant "The server was started after the agent": says the window is re-probed "before the first question while it is still unknown". The code re-probes before **every** question while the window is unknown and compression is on (`agent.go`, the `EnsureContextWindow` call in the loop). Same wording in the FR page.
94+ - Neither `explanation/context-compression.md` nor `reference/acp.md` says that the automatic trigger is terminal-only; the explanation reads as if it applied to both front ends. Worth one sentence once the ACP gap is either fixed or accepted.
95+ - Everything else checked matches the code: defaults, the `max(estimate, measured)` rule, the merge of earlier summaries, the seven-section prompt, the failure behaviour, `/compact` semantics, the start-up warning.
96+
97+ ## Comparison with docker agent (cagent, `tmp/docker-agent`)
98+
99+ cagent (`pkg/compaction/compaction.go`, `pkg/session`, `pkg/runtime`) also summarises old messages and keeps a recent suffix verbatim, with the same 3.5 chars/token heuristic, but differs on four points: its default threshold is **90 %** of the context limit (`DefaultThreshold = 0.9`, overridable per agent and per model with `compaction_threshold`), with a single trigger on `input + output + added tokens` and no message-count fallback; the estimator is **calibrated** against the provider-reported usage of assistant turns; the kept suffix is chosen by a **token budget** snapped to a user/assistant boundary (`SplitIndexForKeep`), not by a turn count; and besides the proactive trigger it **reacts to a context-overflow error** by compacting and retrying the request once (`defaultMaxOverflowCompactions = 1`). It can also delegate the summary to a dedicated compaction model, runs before/after-compaction hooks, and records each compaction as an item in its SQLite session store so token accounting restarts from the summary.
100+tasks: []
101+comments: []
new file mode 100644
@@ -0,0 +1,101 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 10
3+title: Check how the context compression is working
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:09:48.463Z
9+updatedAt: 2026-09-16T06:00:00.000Z
10+labels: []
11+body: |
12+ ## Summary
13+
14+ Nothing in an agent loop shortens the history: every `bash` output (up to `maxOutput` characters) and every loaded skill stays until `/quit`. The `internal/compact` package (`compact.go`, `prompt.go`) fixes that with a **hybrid**: the OLD question turns are replaced by ONE summary written by the model itself, and the last `keepLastTurns` turns stay raw. It never calls the model directly (it hands a request to a `Summarizer`, `engine.Summarize` in production) and never prints (the front ends own the report line). Reviewed on 2026-09-16 against the code; nothing was changed.
15+
16+ ## When it runs
17+
18+ **Automatically — terminal only.** In `internal/agent/agent.go`, right before a question is appended to the history (and before the `before` index that the rollback and the ⚙ recap rely on is taken), `compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, cfg)` is called. If the window is still unknown and compression is on, `e.EnsureContextWindow` re-probes the server first (llama.cpp `/props` → `n_ctx`), before every question until the window is known. `Decide` triggers when `context.enabled` is true and either:
19+
20+ | Trigger | Rule | Reason string |
21+ |---------|------|---------------|
22+ | tokens | `window > 0` and `tokens × 100 ≥ window × threshold`, with `tokens = max(Estimate(history), measured)` | `"tokens"` |
23+ | messages | `maxMessages > 0` and `len(history) ≥ maxMessages` | `"messages"` |
24+
25+ `measured` is the server's own `usage.input_tokens` of the **last** model call, captured in the engine's `WrapModel` hook (`e.lastInput`). It sees the tools' JSON and the chat template, which the estimate cannot, but it lags one call behind and is forgotten (`ForgetInputTokens`) after a successful compaction because it described the history that was just replaced. `window` is the top-level `contextWindow` key, or what the provider probe learned; `0` = unknown, and then only `maxMessages` can fire (a start-up warning says so when `maxMessages` is `0` too).
26+
27+ **By hand — both front ends.** `/compact` skips the threshold, not the boundary: terminal `compactHistory(…, forced=true)`, ACP `compactSession` → `compactHistory` in `internal/acp/acp.go`, intercepted in `session/prompt` under `turnMu` (never while a generation holds the history).
28+
29+ **Not automatically over ACP.** `compact.Decide` is called in exactly one place, `internal/agent/agent.go`. The ACP `Prompt` path appends the question and generates without any check: a long editor session is never compacted unless the user types `/compact`.
30+
31+ ## What a compaction does
32+
33+ `Compact(ctx, msgs, cfg, summarize)`:
34+
35+ 1. `Split` separates the leading system message(s) from the **turns**: a turn starts at a user message and runs to the next one, so it holds the whole model ↔ tools exchange of that question. An earlier summary pair is a turn of its own (it starts with a user message).
36+ 2. `keep = max(keepLastTurns, 1)`. If there are no more turns than `keep`, or the only old turn is an earlier summary → `ErrNothingToCompact` (not a failure).
37+ 3. The old turns are flattened and sent as `Request(old, prompt, merge)`: a **note-taker system prompt** (replacing the agent's "you are a coding agent, run commands"), the old messages as they were, then the summary prompt as a user message. `merge` is true when the old turns start with an earlier summary: a line asks the model to fold it in rather than repeat it ("summarising a summary loses detail exponentially").
38+ 4. The built-in `DefaultPrompt` (overridable by `context.prompt`) asks for EXACTLY seven Markdown sections — Goal, Files touched, Decisions, Errors and fixes, Done, In progress / next steps, Useful commands — under 40 lines, concrete paths/commands/errors, **no quoting of file contents** (files may have changed since).
39+ 5. `SummaryPair` builds two messages tagged with metadata `compact: "summary"`: a **user** message `[Context summary — the N earlier messages of this session (Q question(s), C command(s)) were compressed into the notes below. Treat them as notes, not as observations: re-read a file or re-run a command before relying on its content.]` + the summary, and a **model** acknowledgement `Understood — I will re-check files before editing them.` (keeps the user/assistant alternation chat templates expect).
40+ 6. New history = system head + summary pair + kept turns verbatim. `Valid` re-checks what an OpenAI-compatible server checks (starts with system; every tool request in a model message; every tool response answers a pending request, paired by `Ref` or by name); a failure here is returned as an error and the caller keeps the old history.
41+
42+ Before / after with `keepLastTurns: 1` and three questions of one command each:
43+
44+ ```
45+ before (8 msgs after system): [sys] u1 m(call) t(result) m(text) u2 m(call) t(result) m(text) u3 m(call) t(result) m(text)
46+ after: [sys] u(summary notes*) m(ack*) u3 m(call) t(result) m(text)
47+ * metadata compact=summary
48+ report: 🗜️ compressed 8 messages → 1 summary + 4 kept (…k → …k tokens, …s)
49+ ```
50+
51+ The report line is `Result.Report()`, shared by both front ends (printed dim in the terminal when `showStats` is true, sent as an `agent_message_chunk` over ACP). After a successful compaction the engine's measured token count is forgotten; the loop detector is **kept** (the task continues), unlike `/new` which resets it.
52+
53+ ## The token estimate and its limits
54+
55+ `Estimate` counts characters — text parts, tool request name + JSON input, tool response name + output (a string output is taken as-is, not re-escaped) — divides by 3.5 (`(chars*2+6)/7`) and adds 4 tokens of framing per message. It ignores the system-side chat template, the tool definitions and any provider-side additions, so it under-counts real prompts; that is why the server's measured count is preferred when larger. On a server that reports no usage (the tests' fake engine, some proxies) the estimate is the only figure.
56+
57+ ## Failure and "nothing to compact"
58+
59+ | Case | Result |
60+ |------|--------|
61+ | `ErrNothingToCompact` | History untouched. Terminal: `🗜️ nothing to compact: N message(s), no turn older than the last K` only when forced (`/compact`); silent on the automatic path. ACP: always said (only reachable through `/compact`). |
62+ | summary error (server down, watchdog stall after `2 × watchdogTimeout` = 40 s by default, empty text) | History untouched, measured count kept. Terminal prints `[compact: failed, history kept: <engine.Explain>]`; ACP sends the same line as a chunk. The next question goes out uncompressed. |
63+ | `Valid` fails on the result | Same as an error: `compressed history is invalid: …`, old history kept. |
64+
65+ `engine.Summarize` clones the agent's sampling, removes `parallel_tool_calls` (a 400 without tools), sets `max_tokens = summaryMaxTokens`, streams only to feed the watchdog, and returns an error on an empty answer.
66+
67+ ## Configuration (`internal/config/config.go`)
68+
69+ | Key | Default | Validation | Note |
70+ |-----|---------|------------|------|
71+ | `contextWindow` (top level) | `0` | `≥ 0` | Shared by the banner and the trigger; `0` = ask the server (`/props` on llama.cpp; DMR tells nothing). |
72+ | `context.enabled` | `false` | — | Automatic trigger only; `/compact` always works. |
73+ | `context.threshold` | `75` | 1..100 | Percent of the window. |
74+ | `context.maxMessages` | `80` | `≥ 0` | Fallback on the message count; `0` disables. |
75+ | `context.keepLastTurns` | `3` | `≥ 1` | Question turns kept raw. |
76+ | `context.summaryMaxTokens` | `1200` | `> 0` | `max_tokens` of the summary request. |
77+ | `context.prompt` | `""` | — | Replaces `DefaultPrompt` when set (the merge line is still appended). |
78+ | `context.showStats` | `true` | — | Terminal report line after each compaction. |
79+
80+ Shipped files: `agent.dmr.yaml` → `contextWindow: 0`, `enabled: false`, otherwise defaults (compression is OFF on DMR, and the window is unknown there). `agent.llamacpp.yaml` → `contextWindow: 0`, `enabled: true`, `threshold: 75`, `maxMessages: 80`, `keepLastTurns: 3`, `summaryMaxTokens: 1200`, `showStats: true` (window read from `/props`).
81+
82+ ## Observations, gaps, follow-ups
83+
84+ - **No automatic compaction over ACP** (confirmed: `Decide` has a single call site, in the terminal loop). An editor session on llama.cpp with `enabled: true` still overflows unless the user types `/compact`. Suggested: call `Decide`/`compactHistory` in `acp.Prompt` before appending the question, same place and rule as the REPL, with the report sent as a chunk (or logged) — the `compactHistory(ctx, s, cfg, summarize, explain)` helper already exists there.
85+ - **Interaction with persistent sessions (bob's in-flight feature).** The stored history must be rewritten after every compaction (not appended): the summary pair replaces old turns, and a reload that replays the raw messages would otherwise show turns the model no longer has. On `session/load`, the summary pair will be replayed as a **user** message carrying the notes plus a one-line model acknowledgement — acceptable, but worth labelling in the replay if the editor shows it verbatim.
86+ - **Nothing-to-compact on the automatic path is silent**, so on a single very long turn the threshold can be crossed at every question with no visible sign; a dim line or a once-per-session note would help.
87+ - **The estimate ignores tool definitions and the chat template**; on a server that reports no usage the threshold fires later than intended. `threshold: 75` leaves margin, but a `maxOutput`-sized bash output (about 4–5k tokens) can still overshoot a small window.
88+ - **Keep policy is by turn count, not by token budget**: three huge turns can keep most of the window; cagent keeps a token-budgeted suffix instead (see below).
89+ - **No reactive compaction**: a 400 "context length exceeded" from the server is reported as an error and the turn is lost; cagent retries once after an overflow compaction.
90+
91+ ## Documentation check (`docs/en`, not edited)
92+
93+ - `how-to/manage-the-context-window.md`, variant "The server was started after the agent": says the window is re-probed "before the first question while it is still unknown". The code re-probes before **every** question while the window is unknown and compression is on (`agent.go`, the `EnsureContextWindow` call in the loop). Same wording in the FR page.
94+ - Neither `explanation/context-compression.md` nor `reference/acp.md` says that the automatic trigger is terminal-only; the explanation reads as if it applied to both front ends. Worth one sentence once the ACP gap is either fixed or accepted.
95+ - Everything else checked matches the code: defaults, the `max(estimate, measured)` rule, the merge of earlier summaries, the seven-section prompt, the failure behaviour, `/compact` semantics, the start-up warning.
96+
97+ ## Comparison with docker agent (cagent, `tmp/docker-agent`)
98+
99+ cagent (`pkg/compaction/compaction.go`, `pkg/session`, `pkg/runtime`) also summarises old messages and keeps a recent suffix verbatim, with the same 3.5 chars/token heuristic, but differs on four points: its default threshold is **90 %** of the context limit (`DefaultThreshold = 0.9`, overridable per agent and per model with `compaction_threshold`), with a single trigger on `input + output + added tokens` and no message-count fallback; the estimator is **calibrated** against the provider-reported usage of assistant turns; the kept suffix is chosen by a **token budget** snapped to a user/assistant boundary (`SplitIndexForKeep`), not by a turn count; and besides the proactive trigger it **reacts to a context-overflow error** by compacting and retrying the request once (`defaultMaxOverflowCompactions = 1`). It can also delegate the summary to a dedicated compaction model, runs before/after-compaction hooks, and records each compaction as an item in its SQLite session store so token accounting restarts from the summary.
100+tasks: []
101+comments: []
added .tickets/issues/0011-start-automatically-llama-cpp.yaml +15 -0
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 11
3+title: Start automatically llama.cpp
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:27:47.975Z
9+updatedAt: 2026-09-16T04:27:59.642Z
10+labels:
11+ - kind::feature
12+ - llama.cpp
13+body: ""
14+tasks: []
15+comments: []
new file mode 100644
@@ -0,0 +1,15 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 11
3+title: Start automatically llama.cpp
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:27:47.975Z
9+updatedAt: 2026-09-16T04:27:59.642Z
10+labels:
11+ - kind::feature
12+ - llama.cpp
13+body: ""
14+tasks: []
15+comments: []
added .tickets/issues/0012-new-tui-for-mini-me.yaml +311 -0
new file mode 100644
@@ -0,0 +1,311 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 12
3+title: New TUI for mini me
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:35:22.638Z
9+updatedAt: 2026-09-16T09:00:00.000Z
10+labels:
11+ - kind::feature
12+ - priority::medium
13+body: |
14+ ## What this ticket proposes
15+
16+ An **optional single-window mode** for mini-me — one full-screen terminal window with the conversation above and a box to type in below — modelled on the ACP agent window of the Turbo editors (turbo-core / turbo-go). The current line-mode REPL stays the default and stays intact: the window is a third front end beside the REPL and the ACP façade, selected with `mm -tui` (or `tui: true` in the YAML), and mini-me falls back to line mode when stdout is not a terminal (`mm | tee`, tutorials, tests).
17+
18+ Ticket 0003 recommended "full TUI: not now" for a Bubble Tea application. This ticket is the user asking to design that window anyway; the answer below keeps 0003's constraint — small dependency footprint, transcript-friendly — by borrowing Turbo's approach rather than cagent's.
19+
20+ ## 1. What turbo-core and turbo-go are (facts, read from the source on 2026-09-16)
21+
22+ Both repositories are **MIT** (`LICENSE`, "Copyright (c) 2026 turbo-editors"), shallow-cloned from Codeberg into `/tmp/turbo/`.
23+
24+ **turbo-core** (`codeberg.org/turbo-editors/turbo-core`, Go 1.26.1) is "the library every Turbo editor is built on": a Turbo Vision-style terminal IDE with a hole where the language goes. Sixteen packages, three third-party dependencies (`go.mod`): `github.com/gdamore/tcell/v2 v2.13.10` for the terminal, `BurntSushi/toml`, `golang.org/x/sys` for pty ioctls. No Bubble Tea, no lipgloss: the widgets, the JSON-RPC client, the VT emulator are hand-written.
25+
26+ **turbo-go** (`codeberg.org/turbo-editors/turbo-go`) is the Go editor built on it: `go.mod` requires `turbo-core v0.8.0` and `tcell/v2`; the repository is "the command, the profile that says this editor is for Go, and the Go scanner — about four hundred lines" (`README.md`). Its `acp-agent/agent.yaml` is a docker agent (cagent) configuration on llama.cpp used to test the agent window; `acp-agent/how-to-test.md` says how (`go work init . ../turbo-core`, `make build`).
27+
28+ ### 1.1 The widget model (`turbo-core/ui`, ~5 750 lines)
29+
30+ - `ui/README.md`: bounds are **absolute screen coordinates**; painters **clip and compose** (`p.Sub(rect)` returns a painter clipped to that rectangle whose drawing coordinates start at (0,0)).
31+ - The widget contract (`ui/view.go`): `Bounds() / SetBounds / Draw(p *Painter, th *theme.Theme) / HandleKey(*tcell.EventKey) bool / HandleMouse(*tcell.EventMouse) bool`. Handlers return whether they consumed the event. `Box` and `FocusBox` are the embeddable bases.
32+ - `Desktop` (windows back-to-front, `Tile`, `Cascade`, `ToggleMaximize`), `Window` (frame, title, `[x]`, `[■]`, drag/resize), `MenuBar`, `StatusBar` (clickable `Fn` hints, transient message, right-aligned extra), `Dialog` (modal, focus ring, Enter presses the default button), controls `Button InputLine CheckBox Label ListBox`, `DrawVScrollBar`.
33+ - Styling comes from `theme` (TOML → `tcell.Style`, eleven embedded themes, `th.Style(theme.KeyWindowBody)` etc.).
34+ - Event loop (`app/app.go:257-300`): `for !quitting { tick(); layout(); draw(); event := screen.PollEvent(); handle(event) }`. Work done off the main goroutine never draws: it records a fact and calls `wake()` = `screen.PostEvent(tcell.NewEventInterrupt(nil))` (`app/app.go:326`), whose failure is tolerated because `tick()` is **state-driven** (re-reads what is true now) — "a dropped wake-up costs a late turn, never a lost one".
35+
36+ ### 1.2 The ACP agent window (`turbo-core/acp`, ~6 500 lines incl. protocol client)
37+
38+ turbo-core is an ACP **client** (the editor side). `acp/README.md` lists the layers: `config.go` (`acp.toml`, agents a project/user lists), `process.go` (child process, stderr drained to a log), `protocol.go` (wire types, method names), `session.go` (handshake, prompts, cancel, permissions, `fs/*`), `transcript.go` (the conversation as a **value**: no terminal, no colours), `render.go` (entries → wrapped, styled lines), `view.go / view_draw.go / view_events.go` (the widget), `picker.go` (the `/` and `@` popup), `mention.go` (`@file` → content block **in place**, `resource` when the agent declares `promptCapabilities.embeddedContext`, `resource_link` otherwise).
39+
40+ The window itself (`acp/view.go`):
41+
42+ - Layout: the conversation above, a **rule** line, then a **3-row input box** (`InputHeight = 3`, "enough for a sentence that wrapped once"). `transcriptHeight() = H - InputHeight - 1`.
43+ - Input is `[]string` lines with `cursor` / `column`; multi-line editing in `view_events.go` (`insert`, `insertNewline`, `backspace` joining lines, `moveLeft/Right` across line ends, Home/End, ↑/↓ inside the box and, at its edges, into the conversation).
44+ - Keys (`view_events.go:handleWindowKey`): **Enter sends**, **Alt-Enter inserts a newline**, **Tab switches focus** between transcript and box, **Esc** drops the selection if any else **cancels the running turn** (`session.Cancel()`), **Ctrl-C / Ctrl-Ins copies** the selected transcript block (`OnCopy`, both the editor clipboard and OSC 52). Transcript keys: ↑/↓ move a caret, Shift-↑/↓ select, PgUp/PgDn page, Home/End; End re-enables **follow** mode. Mouse: wheel scrolls (3 lines), click sets focus / caret, drag selects.
45+ - The rule shows state and key hints (`view_draw.go:ruleLabel`): `" Enter sends · Alt-Enter new line · Tab switches "`, plus `"· / commands "` when the agent announced commands and `"· @ files "` when the editor supplied a file list; while a turn runs: `" ⠋ thinking — Esc stops it "`; `" starting… "`, `" stopped "`. The window **title** says `agent — thinking/starting/stopped` without a spinner (menus would flicker).
46+ - Draw is stateless with respect to the transcript: `lines()` re-wraps `session.Entries()` at the current width **every frame**; `follow` pins the scroll to the end while the agent writes and is released as soon as the user scrolls up (`scrollBy`, `clampScroll`).
47+ - The transcript model (`transcript.go`): `Entry{Kind: user|agent|thought|tool|plan|notice, Speaker, Text, ToolCallID, Status, Detail, Plan}`. A run of `agent_message_chunk`s is **coalesced into one entry** (`appendChunk`), a `tool_call_update` is **folded onto** the entry with the same id (`fold`), `usage_update` feeds `Session.Usage()` (used/size). Tool entries render as `‣ <tool> <detail> ✓ done|✗ failed|… running|… waiting` followed by the output in comment style (`render.go:toolLines`, `statusMark`); plans as `[x] [~] [ ]` lines; only a **fenced** block is drawn as code, coloured by the `syntax` scanners.
48+ - Permissions (`session.go:60-125`, `app/agent_permissions.go`): `session/request_permission` arrives on the reading goroutine, is **recorded** as a `*Permission` in a locked queue and the loop is woken; `askNextPermission()` opens a modal `ui.Dialog` on the next turn; `Answer(optionID)` / `Cancel()` reply **on a goroutine of their own** so a stalled agent cannot freeze the keystroke.
49+ - Slash commands and mentions (`picker.go`): `/` at the very start of the box lists `session.Commands()` (from `available_commands_update`), `@` anywhere lists project files (`View.Files`); 8-row popup over the conversation, Escape dismisses it until the text changes. A command is sent as **plain text** (`"/compact"`), "exactly as Zed does".
50+ - How the editor opens one (`app/agents.go:NewAgent`): `acp.Start(agent, projectRoot, acp.Options{OnUpdate: a.wake, OnPermission: a.recordPermission, ReadTextFile, WriteTextFile})`, then `view := acp.NewView(session)`, `window := ui.NewWindow(view.Title(), view)`, `desktop.Add(window)`.
51+
52+ ## 2. Target for mini-me: one window, one input zone
53+
54+ mini-me is the **agent**, not the editor, so the Turbo window maps almost one-to-one onto a front end that consumes mini-me's own events instead of a JSON-RPC stream: `ui.Sink` (`internal/ui/ui.go:62`: `Text`, `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd`, `WorkDir`) is the exact equivalent of turbo-core's `Transcript.Apply(update)`, and `engine.Generate`'s streaming callback is the `agent_message_chunk` source.
55+
56+ ```
57+ mm · mellum2-12b @ dmr · ctx 42% (27.3k/64k) · in 27.3k out 4.1k · session 20260916-043349-adb83d2c
58+ ┌──────────────────────────────────────────────────────────────────────────────┐
59+ │ you ▲│
60+ │ explain what @internal/acp/acp.go does with the session store ▓│
61+ │ ░│
62+ │ mm ░│
63+ │ The ACP front end keeps one session per `session/new`… ░│
64+ │ ░│
65+ │ ‣ bash ls internal/acp ✓ done 0.03s ░│
66+ │ acp.go acp_test.go persist.go replay.go sink.go ░│
67+ │ ░│
68+ │ ‣ edit_file internal/acp/acp.go … waiting for permission ░│
69+ │ ┃ Allow this edit? [A]llow [S]ession [R]eject ░│
70+ │ ▼│
71+ ├─ ⠋ thinking — Esc stops it ── Enter sends · Ctrl-J new line · / commands · @ files ─┤
72+ │ > and now add a test for /new │
73+ │ │
74+ │ │
75+ └──────────────────────────────────────────────────────────────────────────────┘
76+ F1 Help F2 Sessions Esc Cancel Ctrl-L Clear Ctrl-C Quit 3 msgs · 2 tools
77+ ```
78+
79+ | Zone | Content | Source in mini-me |
80+ |------|---------|-------------------|
81+ | Header line | model and provider, context % and tokens (`e.LastInputTokens()` vs `e.ContextWindow`), session id when `sessions.enabled` | `engine`, `config`, `internal/store` |
82+ | Transcript pane | coalesced user/agent entries, framed tool calls with detail, status, duration and output, file diffs (from `ToolResult`), **inline permission prompt** as an entry (see 2.1), compaction `🗜️` notices, `📎` attachments | `ui.Sink` implementation in `internal/tui` |
83+ | Rule | state (`thinking` + spinner / idle / error) and key hints, like Turbo's `ruleLabel` | `internal/tui` |
84+ | Input pane | 3 rows, multi-line editor, `> ` prompt, `@path` mentions (reuse `internal/mention`), `/` picker fed by **the same list as ACP's `availableCommands()`** plus the terminal-only ones (`/quit /abort /sessions /resume /help`) | `internal/mention`, shared command table |
85+ | Status bar | `Fn` hints, transient messages (warnings, `[compact: …]`), right-aligned counters | `ui.StatusBar`-like |
86+
87+ ### 2.1 Keybindings (what a terminal can actually detect)
88+
89+ | Key | Action | Note |
90+ |-----|--------|------|
91+ | Enter | send | empty box: nothing (Turbo's rule) |
92+ | **Ctrl-J** (and Alt-Enter where the terminal reports it) | newline in the box | Shift-Enter is **not** distinguishable from Enter in most terminals without the kitty keyboard protocol; tcell reports Alt-Enter on many but not all. Ctrl-J is universal (it *is* LF). Document both. |
93+ | Esc | cancel the running turn, else clear the picker / selection | same as Zed and Turbo |
94+ | Ctrl-C | quit when idle, cancel when a turn runs (current REPL semantics) | Turbo uses Ctrl-C for copy; mini-me keeps its existing meaning to stay consistent with line mode |
95+ | Tab | focus transcript ↔ input | |
96+ | ↑/↓ at box edges, PgUp/PgDn, Home/End | scroll / caret in transcript; End resumes follow | |
97+ | Ctrl-L | clear the screen (keep history) | |
98+ | F1 / `/help` | help overlay | closes ticket 0001 in this mode |
99+ | F2 / `/sessions` | session browser (phase 3) | needs 0008 (done) |
100+ | `a` / `s` / `r` while a permission entry is focused | Allow once / Allow for the session / Reject | mirrors the ACP `Allow` options |
101+ | Bracketed paste | inserted as one multi-line block, never sent | tcell `EnablePaste` |
102+
103+ ## 3. Implementation options
104+
105+ All three keep the same **seams** in mini-me: a new package `internal/tui` implementing `ui.Sink` (tools then need no change: they already emit through `ui.ActiveSink()` when one is installed — `internal/tools/tools.go:142,248`), consuming `engine.Generate`'s stream, and reusing `agent.Run`'s command semantics (`/new /compact /sessions /resume`, `@path` via `internal/mention`, persistent sessions via `internal/store`). The REPL's command handling should first be extracted from `agent.Run` (already the biggest complexity smell) into a shared `commands` helper so the two front ends cannot drift.
106+
107+ ### Option A — import turbo-core as a library (`ui`, `theme`, and the `acp` window's *shape*)
108+
109+ - **What**: `go get codeberg.org/turbo-editors/turbo-core` and use `ui.Painter/Window/Desktop/StatusBar/Dialog/ListBox` + `theme` for drawing; write `internal/tui` as a widget implementing `ui.Widget`, copying the structure of `acp/view*.go` (transcript above, rule, 3-row box, picker) but fed by `ui.Sink` events instead of a `Session`. The `acp` package itself is a **client** and cannot be reused as-is (mini-me is the agent); `Transcript`/`render.go` could be reused only if their `Entry` type were fed by hand — feasible, they are plain values with no protocol dependency.
110+ - **Dependencies**: turbo-core (+ tcell v2, toml, x/sys transitively). MIT: compatible.
111+ - **Pros**: proven Turbo Vision widgets, themes, tests, mouse, scrollbars, dialogs for free; the design goal ("like turbo-go's agent window") is met literally; one team's conventions (`README.md` per package, methodical docs) close to mini-me's own.
112+ - **Cons**: turbo-core is `v0.8.0`, pre-1.0, and "developed alongside the editors that use it" (`turbo-go/go.mod` comment) — API churn risk; it drags `buffer`, `lsp`, `terminal` etc. into the module graph even if unused (Go only compiles imported packages, but `go.sum`/vulnerability surface grows: osv already fails the gate); Turbo's desktop-of-windows model is more than one window needs.
113+ - **Effort**: window M, sink adapter M, picker S (Turbo's exists), permission dialog S (Turbo's `Dialog`), session browser S (`ListBox`). Total ≈ M.
114+
115+ ### Option B — tcell directly, hand-rolled single window (recommended)
116+
117+ - **What**: depend on `github.com/gdamore/tcell/v2` only (the library Turbo itself is built on). Write `internal/tui` with: a `Painter`-like clipped drawer (≈150 lines, Turbo's `ui/painter.go` is the model), a `Transcript` value (Turbo's `transcript.go` shape: coalesce chunks, fold tool updates), a `render` (wrap + fences, ≈250 lines), the `View` (transcript/rule/input, ≈600 lines across three files like Turbo), a picker (≈300), an inline permission entry (no modal dialog), one status line. Event loop copied from `app.Run`: `tick → draw → PollEvent → handle`, with `wake()` via `PostEvent(EventInterrupt)` from the engine/tool goroutines and **state-driven redraw**.
118+ - **Dependencies**: tcell v2 (+ its indirect `go-colorful`, `uniseg`, `x/term`, `x/text`). Alternate screen **only in this mode**; `spinner.Styled()`-style detection falls back to line mode when not a TTY.
119+ - **Pros**: smallest footprint that still gives a real window; no pre-1.0 API to track; every line is mini-me's and fits its "hand-written, bash-first" style; the ACP façade and the REPL are untouched.
120+ - **Cons**: re-implementing ~1 500 lines Turbo already has; no themes (a handful of `tcell.Style`s is enough); Markdown stays "fences only", as in Turbo.
121+ - **Effort**: painter S, transcript+render M, view+input M, picker S, permission entry S, status/header S, mode switch + fallback S, session browser S (after 0008), tests M (tcell `SimulationScreen` allows screen assertions like Turbo's `uitest_test.go`). Total ≈ M–L, delivered in phases.
122+
123+ ### Option C — Bubble Tea (cagent's stack)
124+
125+ - Already analysed in ticket 0003 §3.1: `bubbletea/v2 + lipgloss/v2 + glamour/v2 + chroma`, alternate screen, Elm-style `Update/View`. Richest rendering (incremental Markdown, syntax highlighting) and the most dependencies by far; two rendering paths to maintain; alternate screen semantics identical to A/B. Effort L. Kept here for comparison; not recommended for the same reasons as in 0003.
126+
127+ ### Comparison
128+
129+ | | A turbo-core | B tcell direct | C Bubble Tea |
130+ |---|---|---|---|
131+ | New direct deps | 1 (+3 transitive) | 1 (+4 transitive) | 4+ |
132+ | Widgets for free | yes (windows, dialogs, list, themes) | no | yes (many) |
133+ | API stability | pre-1.0, moving | tcell v2 stable since 2020 | v2 recent |
134+ | Fits "agent window" brief | literally | in spirit | no (different idiom) |
135+ | Streaming model | state-driven redraw + wake | same, hand-written | `tea.Msg` per chunk |
136+ | `mm \| tee` | mode-gated, line mode stays | same | same |
137+ | Effort | M | M–L | L |
138+
139+ ## 4. Recommendation and phased plan
140+
141+ **Recommend Option B**, with Option A as the escape hatch if phase 1 shows the hand-rolled painter/widgets costing more than expected (the two are compatible: both draw on tcell, and Turbo's file layout is the blueprint either way). Re-evaluate A when turbo-core reaches a tagged 1.0.
142+
143+ **Prerequisite (S)** — extract `agent.Run`'s command handling (`/quit /abort /compact /new /sessions /resume`, `@path` expansion) into a shared table used by the REPL, ACP (`availableCommands()`) and the TUI picker. Lowers the `agent.Run` complexity smell at the same time.
144+
145+ **Phase 1 — minimal window (M)**: `mm -tui` / `tui: true`; TTY detection with line-mode fallback; header, transcript (user/agent entries, coalesced streaming, follow mode), rule with spinner, 3-row input with Ctrl-J newline, Enter send, Esc cancel, Ctrl-C quit, wheel/PgUp/PgDn; `ui.Sink` implementation printing tool calls as one-line framed entries; `@path` echo. Tests on `tcell.SimulationScreen`.
146+
147+ **Phase 2 — tools and permissions (M)**: tool entries with detail, status, duration, output folded in, diffs coloured per line; **inline permission entry** (`a/s/r`) implementing `Sink.Allow` — the terminal gets the gatekeeper ACP already has (0003 §2.2), behind `permissions: ask | auto` so line mode keeps today's behaviour; `/` picker from the shared command table; `/help` overlay (0001) and `/about` (0002); tokens and context % in the header (0005; 0006 is the ACP twin).
148+
149+ **Phase 3 — sessions and reading comfort (M)**: F2/`/sessions` browser over `internal/store` (list, resume, new) — 0008's persistence makes it possible; transcript selection + copy (OSC 52 like Turbo, plus internal); fenced code blocks drawn distinctly (no highlighter); bracketed paste; optional light heading/bold rendering.
150+
151+ **Risks**
152+
153+ - Terminal compatibility: Alt-Enter/Shift-Enter not reported everywhere → Ctrl-J is the documented newline; test on macOS Terminal, iTerm2, Ghostty, Zed's terminal, tmux.
154+ - Windows: tcell supports it; pty-free design keeps it possible, but untested here (mini-me targets macOS/Linux).
155+ - Screen readers and copy/paste: the alternate screen hides the transcript from the terminal's scrollback → keep line mode as default and make `mm -tui` explicit; offer `/export` later.
156+ - Pipe detection: `mm -tui | tee` must silently run line mode (same rule as `spinner.Styled()`).
157+ - Concurrency: draw only on the loop goroutine; sink and engine callbacks record + `wake()` (Turbo's rule, and the same one mini-me's ACP `turnMu` already enforces for turns).
158+ - Quality gate: new code lives in `internal/tui` with tests; `agent.Run` must shrink, not grow.
159+
160+ ## 5. Related tickets
161+
162+ - 0003 Improve TUI — tiers 1–2 (multi-line input, `/help`, tokens in recap, permission prompt) are the line-mode counterparts; several become the same code behind the shared command table.
163+ - 0001 `/help`, 0002 `/about` — closed by phase 2 in both modes.
164+ - 0005 / 0006 token consumption — header counters (terminal) / ACP twin.
165+ - 0008 Reload in Zed — persistent sessions (`internal/store`) are what phase 3's session browser lists.
166+
167+ Sources: `/tmp/turbo/turbo-core` and `/tmp/turbo/turbo-go` (shallow clones of the Codeberg repositories, 2026-09-16), files cited inline.
168+ ## Progress (2026-09-16, agent bob) — Option B implemented, phases 1 and 2
169+
170+ The user chose **Option B**. Delivered in one pass, `go test -race ./...` green (14 packages), quality gate unchanged on pre-existing debt only (warnings 67 → 63, smells 19 → 19, none in the new packages):
171+
172+ - [x] Prerequisite: `internal/command` — the one slash-command table (`Quit Abort Compact New Sessions Resume Help`, per-front-end `Fronts` bits, `Parse`/`Is`/`For`/`Usage`); REPL dispatches on it, ACP `availableCommands()` reads its Editor slice, the window's picker lists its Window slice. `/help` added to the REPL and the window (closes ticket 0001). Shared helpers extracted on the way: `compact.Apply` (one report line for the three front ends), `session.AfterTurn` (the history-merge rule), `store.Keeper` (moved from `internal/agent/persist.go`, used by REPL and window). `agent.Run` complexity 121 → 110.
173+ - [x] Phase 1: `mm -tui` flag and `tui: true` key; `internal/tui` on `github.com/gdamore/tcell/v2 v2.13.10` with the Turbo file layout (`painter.go`, `wrap.go`, `transcript.go`, `render.go`, `view.go`, `draw.go`, `events.go`, `picker.go`, `sink.go`, `turn.go`, `tui.go`); header (model @ provider, ctx % or `~used (window unknown)`, session id), transcript with scroll bar and follow mode, rule with braille spinner and key hints, 3-row box (Enter sends, **Ctrl-J**/Alt-Enter newline, bracketed paste inserted whole), Esc cancels, Ctrl-C quits when idle / cancels when busy, Tab focus, PgUp/PgDn/↑↓/Home/End scroll, Ctrl-L clears the view, mouse wheel; `/` picker (8 rows, prefix filter, Tab completes, Enter sends a complete name, Esc dismisses until the text changes); `@path` via `internal/mention` with `📎` notices; same history rules as the REPL (auto-compaction before the question, full history kept after failure/cancel, `store.Keeper` when `sessions.enabled`); a question typed during a turn cancels it and runs after it settles; fallback to the line mode when stdin/stdout is not a terminal (`[tui: … — running the line mode]`), banner shown inside the window otherwise.
174+ - [x] Phase 2: tool entries `‣ tool detail ✓ done 0.03s` with folded output (`previewLines`) and `+`/`-` colouring for `write_file`/`edit_file`; **inline permission** for `bash` and the file tools behind `permissions: ask | auto` (default `auto`; `a` once, `s` for the session, `r`/Esc reject; a cancelled turn rejects; ACP and the line mode unchanged); tokens/context in the header (`e.LastInputTokens()` vs estimate); `/help` and F1.
175+ - [x] Tests on `tcell.NewSimulationScreen`: input editing, paste, picker, window keys, frame layout, follow/scroll, permission keys; runner tests with a canned model (turn, failure, cancel + pending, commands, compact, permissions); an end-to-end loop test; `command` table tests. Real binary: `-tui` with piped stdin falls back to the REPL; in a pseudo-terminal the window draws, `/help` renders, `/quit` exits 0 with the alternate screen released.
176+ - [x] Docs EN+FR: `how-to/use-the-window-mode.md`, `reference/cli.md` (`-tui`, window-mode table), `reference/slash-commands.md` (`/help`, window keys), `reference/configuration.md` (`tui`, `permissions`), `reference/tools.md`, `explanation/architecture.md` (three front ends, command table, draw-from-a-value), tutorial banner, both indexes, `docs/diagrams/packages.drawio` (`tui`, `command`, `tcell` nodes).
177+ - [ ] Phase 3 (not started): session browser (F2), transcript selection and copy, fenced code drawn distinctly, light Markdown.
178+ - [ ] Manual checks the user must do in a real terminal (macOS Terminal, iTerm2, Ghostty, Zed's terminal, tmux): Ctrl-J and Alt-Enter detection, Esc latency, resize, colours on light and dark themes.
179+
180+ ## Progress 2 (2026-09-16, agent bob) — copy text out of the window
181+
182+ User request: select the generated text in `-tui` and paste it elsewhere. Root cause: `EnableMouse` at start-up made the terminal hand every click to mm, killing its native click-and-drag selection.
183+
184+ - [x] Mouse capture is OFF at start-up; **F2** toggles it (wheel scrolling when on; `Shift`+drag then selects in most terminals). Status bar: `F2 mouse: off` / `mouse: on (Shift+drag selects)`. No config key: `tui` is a plain bool and turning it into a struct now was not worth it; the toggle is one key away.
185+ - [x] `/copy` (last answer, raw text), `/copy 2` (n-th from the end), `/copy all` (whole conversation as plain text), **Ctrl-Y** = `/copy`. New package `internal/clipboard`: native tool on PATH first (`pbcopy`, `wl-copy`, `xclip -selection clipboard`, `xsel --clipboard --input`), then OSC 52 through `tcell.Screen.SetClipboard`; the notice names the road (`📋 copied N chars (pbcopy)`), failures are `⚠️ no clipboard: …`. Native-first, not OSC-first as the brief suggested, because macOS Terminal.app ignores OSC 52 silently and "copied" must not lie.
186+ - [x] REPL: `/copy [n]` through the same package (OSC 52 to stdout only when it is a terminal); `/copy all` is refused there with a hint (the scrollback is selectable).
187+ - [x] Tests: clipboard chain (6), transcript `Answers`/`Plain`, `copyText`, runner `/copy` with a fake copier, F2 toggle + status label, Ctrl-Y hook, REPL `copyAnswer`. `go test -race ./...` 16 packages ok. Pty check on the Linux build: no `?1000h/?1002h/?1006h` at start-up, F2 emits them, F2 again emits `?1000l…`, `/copy all` reaches a fake `pbcopy`.
188+ - [ ] In-window selection (task 10): deferred. It only makes sense while the mouse is captured — the very state that defeats native selection — and needs a laid-out-line ↔ entry mapping plus a selection model (~300+ lines from Turbo's `view_events.go`/`transcript.go`); `/copy` covers the common case cheaply.
189+
190+ Side fix on the way: `skills.List` now skips a `README.md` at the skills root (the user's new `skills/README.md` was counted as a skill with no description and broke the shipped-skills test).
191+
192+ ## Progress 3 (2026-09-16, agent bob) — long pastes collapse into a token
193+
194+ User request: a paste too long for the box shows only a placeholder, but the agent gets the whole text. Root fact: tcell delivers a bracketed paste as a start mark, the text as ordinary keys, an end mark — so the keys are now buffered until the end mark (`paste.go`) instead of being inserted one by one.
195+
196+ - [x] Threshold: more than 3 lines (the box's height) OR more than 200 characters collapses; smaller pastes stay text (a pasted Tab is now a tab, not a focus change).
197+ - [x] Token `[Pasted #n · L lines · C chars]`: one private-use rune in the input, so ←/→/Backspace/Delete treat it as one character; deleting it drops the paste; pastes are numbered per message; drawn in its own style with the cursor column measured by label width.
198+ - [x] Enter hands the runner a `Prompt{Typed, Pastes}`: `Text()` (pastes expanded, verbatim) goes to the model; `Display()` (tokens) goes to the conversation; `Transcript.Entry.Full` keeps the expanded question so `/copy all` and `Plain()` carry it; the persisted session has the full text by construction.
199+ - [x] `@path` mentions are looked up in the typed text only (`mention.Expand(p.Typed)` then `p.Expand`); a pasted `/command` is text.
200+ - [x] Tests: `TestLongPasteCollapsesToOneToken`, `TestPasteTokenIsAtomic`, `TestShouldCollapse`, `TestPasteNeverSends` (short paste + tab), `TestRunnerTurnWithPaste` (full text to the model, collapsed display, mention rule, `/copy all`). `go test -race ./...` 16 packages ok. Pty check: a 50-line bracketed paste draws `[Pasted #1 · 50 lines …]` and none of the lines; Enter shows the collapsed question in the conversation.
201+ - [ ] Optional `/paste n` to peek at a collapsed block — not done (the pastes live in the View, commands run in the runner; cheap only with a new hook). Ask if wanted.
202+
203+ ## Implementation note (bob, 2026-09-16)
204+
205+ Decisions taken for the user: Option A (import turbo-core) not used — tcell direct, as recommended; `permissions` defaults to `auto` so nothing changes for the line mode; Ctrl-C keeps the REPL's meaning (quit when idle, cancel when busy) rather than Turbo's copy; a `/compact` typed while a turn runs cancels the turn first (queued like any input); the unstreamed answer of a one-block server reply is shown from the response when the sink saw no chunk; `hello-world/main.go` (user's untracked file) and `blog/fake-agent/main.go` are what the gate flags outside `internal/`.
206+tasks:
207+ - id: 1
208+ title: "Prerequisite: shared slash-command table internal/command used by REPL, ACP and the window picker"
209+ state: closed
210+ priority: none
211+ author:
212+ name: k33g
213+ email: ph.charriere@gmail.com
214+ createdAt: 2026-09-16T05:40:00.000Z
215+ updatedAt: 2026-09-16T05:40:00.000Z
216+ closedAt: 2026-09-16T05:40:00.000Z
217+ - id: 2
218+ title: "Phase 1: mm -tui window on tcell — header, transcript, rule, 3-row box, picker, @path, fallback to line mode"
219+ state: closed
220+ priority: none
221+ author:
222+ name: k33g
223+ email: ph.charriere@gmail.com
224+ createdAt: 2026-09-16T05:40:00.000Z
225+ updatedAt: 2026-09-16T05:40:00.000Z
226+ closedAt: 2026-09-16T05:40:00.000Z
227+ - id: 3
228+ title: "Phase 2: framed tool output with time and diff colours, inline permissions (permissions: ask|auto), tokens in header, /help and F1"
229+ state: closed
230+ priority: none
231+ author:
232+ name: k33g
233+ email: ph.charriere@gmail.com
234+ createdAt: 2026-09-16T05:40:00.000Z
235+ updatedAt: 2026-09-16T05:40:00.000Z
236+ closedAt: 2026-09-16T05:40:00.000Z
237+ - id: 4
238+ title: "Tests: simulation-screen view tests, runner tests, end-to-end loop test, command table tests"
239+ state: closed
240+ priority: none
241+ author:
242+ name: k33g
243+ email: ph.charriere@gmail.com
244+ createdAt: 2026-09-16T05:40:00.000Z
245+ updatedAt: 2026-09-16T05:40:00.000Z
246+ closedAt: 2026-09-16T05:40:00.000Z
247+ - id: 5
248+ title: "Docs EN+FR and package diagram updated"
249+ state: closed
250+ priority: none
251+ author:
252+ name: k33g
253+ email: ph.charriere@gmail.com
254+ createdAt: 2026-09-16T05:40:00.000Z
255+ updatedAt: 2026-09-16T05:40:00.000Z
256+ closedAt: 2026-09-16T05:40:00.000Z
257+ - id: 6
258+ title: "Phase 3: session browser, code fences drawn distinctly, light Markdown"
259+ state: open
260+ priority: none
261+ author:
262+ name: k33g
263+ email: ph.charriere@gmail.com
264+ createdAt: 2026-09-16T05:40:00.000Z
265+ - id: 7
266+ title: "Manual terminal checks (macOS Terminal, iTerm2, Ghostty, Zed terminal, tmux): Ctrl-J / Alt-Enter, Esc, resize, colours"
267+ state: open
268+ priority: none
269+ author:
270+ name: k33g
271+ email: ph.charriere@gmail.com
272+ createdAt: 2026-09-16T05:40:00.000Z
273+ - id: 8
274+ title: "Copy: mouse capture off by default (terminal selection works), F2 toggles it with the state in the status bar"
275+ state: closed
276+ priority: none
277+ author:
278+ name: k33g
279+ email: ph.charriere@gmail.com
280+ createdAt: 2026-09-16T08:30:00.000Z
281+ updatedAt: 2026-09-16T08:30:00.000Z
282+ closedAt: 2026-09-16T08:30:00.000Z
283+ - id: 9
284+ title: "Copy: /copy [n|all] and Ctrl-Y through internal/clipboard (pbcopy/wl-copy/xclip/xsel, then OSC 52); /copy [n] in the REPL too"
285+ state: closed
286+ priority: none
287+ author:
288+ name: k33g
289+ email: ph.charriere@gmail.com
290+ createdAt: 2026-09-16T08:30:00.000Z
291+ updatedAt: 2026-09-16T08:30:00.000Z
292+ closedAt: 2026-09-16T08:30:00.000Z
293+ - id: 10
294+ title: "In-window selection while the mouse is captured (drag to select, Ctrl-C/y copies) — deferred, see body"
295+ state: open
296+ priority: low
297+ author:
298+ name: k33g
299+ email: ph.charriere@gmail.com
300+ createdAt: 2026-09-16T08:30:00.000Z
301+ - id: 11
302+ title: "Long pastes collapse into a [Pasted #n · L lines · C chars] token; Enter sends the full text; @path inside a paste not looked up"
303+ state: closed
304+ priority: medium
305+ author:
306+ name: k33g
307+ email: ph.charriere@gmail.com
308+ createdAt: 2026-09-16T09:00:00.000Z
309+ updatedAt: 2026-09-16T09:00:00.000Z
310+ closedAt: 2026-09-16T09:00:00.000Z
311+comments: []
new file mode 100644
@@ -0,0 +1,311 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 12
3+title: New TUI for mini me
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T04:35:22.638Z
9+updatedAt: 2026-09-16T09:00:00.000Z
10+labels:
11+ - kind::feature
12+ - priority::medium
13+body: |
14+ ## What this ticket proposes
15+
16+ An **optional single-window mode** for mini-me — one full-screen terminal window with the conversation above and a box to type in below — modelled on the ACP agent window of the Turbo editors (turbo-core / turbo-go). The current line-mode REPL stays the default and stays intact: the window is a third front end beside the REPL and the ACP façade, selected with `mm -tui` (or `tui: true` in the YAML), and mini-me falls back to line mode when stdout is not a terminal (`mm | tee`, tutorials, tests).
17+
18+ Ticket 0003 recommended "full TUI: not now" for a Bubble Tea application. This ticket is the user asking to design that window anyway; the answer below keeps 0003's constraint — small dependency footprint, transcript-friendly — by borrowing Turbo's approach rather than cagent's.
19+
20+ ## 1. What turbo-core and turbo-go are (facts, read from the source on 2026-09-16)
21+
22+ Both repositories are **MIT** (`LICENSE`, "Copyright (c) 2026 turbo-editors"), shallow-cloned from Codeberg into `/tmp/turbo/`.
23+
24+ **turbo-core** (`codeberg.org/turbo-editors/turbo-core`, Go 1.26.1) is "the library every Turbo editor is built on": a Turbo Vision-style terminal IDE with a hole where the language goes. Sixteen packages, three third-party dependencies (`go.mod`): `github.com/gdamore/tcell/v2 v2.13.10` for the terminal, `BurntSushi/toml`, `golang.org/x/sys` for pty ioctls. No Bubble Tea, no lipgloss: the widgets, the JSON-RPC client, the VT emulator are hand-written.
25+
26+ **turbo-go** (`codeberg.org/turbo-editors/turbo-go`) is the Go editor built on it: `go.mod` requires `turbo-core v0.8.0` and `tcell/v2`; the repository is "the command, the profile that says this editor is for Go, and the Go scanner — about four hundred lines" (`README.md`). Its `acp-agent/agent.yaml` is a docker agent (cagent) configuration on llama.cpp used to test the agent window; `acp-agent/how-to-test.md` says how (`go work init . ../turbo-core`, `make build`).
27+
28+ ### 1.1 The widget model (`turbo-core/ui`, ~5 750 lines)
29+
30+ - `ui/README.md`: bounds are **absolute screen coordinates**; painters **clip and compose** (`p.Sub(rect)` returns a painter clipped to that rectangle whose drawing coordinates start at (0,0)).
31+ - The widget contract (`ui/view.go`): `Bounds() / SetBounds / Draw(p *Painter, th *theme.Theme) / HandleKey(*tcell.EventKey) bool / HandleMouse(*tcell.EventMouse) bool`. Handlers return whether they consumed the event. `Box` and `FocusBox` are the embeddable bases.
32+ - `Desktop` (windows back-to-front, `Tile`, `Cascade`, `ToggleMaximize`), `Window` (frame, title, `[x]`, `[■]`, drag/resize), `MenuBar`, `StatusBar` (clickable `Fn` hints, transient message, right-aligned extra), `Dialog` (modal, focus ring, Enter presses the default button), controls `Button InputLine CheckBox Label ListBox`, `DrawVScrollBar`.
33+ - Styling comes from `theme` (TOML → `tcell.Style`, eleven embedded themes, `th.Style(theme.KeyWindowBody)` etc.).
34+ - Event loop (`app/app.go:257-300`): `for !quitting { tick(); layout(); draw(); event := screen.PollEvent(); handle(event) }`. Work done off the main goroutine never draws: it records a fact and calls `wake()` = `screen.PostEvent(tcell.NewEventInterrupt(nil))` (`app/app.go:326`), whose failure is tolerated because `tick()` is **state-driven** (re-reads what is true now) — "a dropped wake-up costs a late turn, never a lost one".
35+
36+ ### 1.2 The ACP agent window (`turbo-core/acp`, ~6 500 lines incl. protocol client)
37+
38+ turbo-core is an ACP **client** (the editor side). `acp/README.md` lists the layers: `config.go` (`acp.toml`, agents a project/user lists), `process.go` (child process, stderr drained to a log), `protocol.go` (wire types, method names), `session.go` (handshake, prompts, cancel, permissions, `fs/*`), `transcript.go` (the conversation as a **value**: no terminal, no colours), `render.go` (entries → wrapped, styled lines), `view.go / view_draw.go / view_events.go` (the widget), `picker.go` (the `/` and `@` popup), `mention.go` (`@file` → content block **in place**, `resource` when the agent declares `promptCapabilities.embeddedContext`, `resource_link` otherwise).
39+
40+ The window itself (`acp/view.go`):
41+
42+ - Layout: the conversation above, a **rule** line, then a **3-row input box** (`InputHeight = 3`, "enough for a sentence that wrapped once"). `transcriptHeight() = H - InputHeight - 1`.
43+ - Input is `[]string` lines with `cursor` / `column`; multi-line editing in `view_events.go` (`insert`, `insertNewline`, `backspace` joining lines, `moveLeft/Right` across line ends, Home/End, ↑/↓ inside the box and, at its edges, into the conversation).
44+ - Keys (`view_events.go:handleWindowKey`): **Enter sends**, **Alt-Enter inserts a newline**, **Tab switches focus** between transcript and box, **Esc** drops the selection if any else **cancels the running turn** (`session.Cancel()`), **Ctrl-C / Ctrl-Ins copies** the selected transcript block (`OnCopy`, both the editor clipboard and OSC 52). Transcript keys: ↑/↓ move a caret, Shift-↑/↓ select, PgUp/PgDn page, Home/End; End re-enables **follow** mode. Mouse: wheel scrolls (3 lines), click sets focus / caret, drag selects.
45+ - The rule shows state and key hints (`view_draw.go:ruleLabel`): `" Enter sends · Alt-Enter new line · Tab switches "`, plus `"· / commands "` when the agent announced commands and `"· @ files "` when the editor supplied a file list; while a turn runs: `" ⠋ thinking — Esc stops it "`; `" starting… "`, `" stopped "`. The window **title** says `agent — thinking/starting/stopped` without a spinner (menus would flicker).
46+ - Draw is stateless with respect to the transcript: `lines()` re-wraps `session.Entries()` at the current width **every frame**; `follow` pins the scroll to the end while the agent writes and is released as soon as the user scrolls up (`scrollBy`, `clampScroll`).
47+ - The transcript model (`transcript.go`): `Entry{Kind: user|agent|thought|tool|plan|notice, Speaker, Text, ToolCallID, Status, Detail, Plan}`. A run of `agent_message_chunk`s is **coalesced into one entry** (`appendChunk`), a `tool_call_update` is **folded onto** the entry with the same id (`fold`), `usage_update` feeds `Session.Usage()` (used/size). Tool entries render as `‣ <tool> <detail> ✓ done|✗ failed|… running|… waiting` followed by the output in comment style (`render.go:toolLines`, `statusMark`); plans as `[x] [~] [ ]` lines; only a **fenced** block is drawn as code, coloured by the `syntax` scanners.
48+ - Permissions (`session.go:60-125`, `app/agent_permissions.go`): `session/request_permission` arrives on the reading goroutine, is **recorded** as a `*Permission` in a locked queue and the loop is woken; `askNextPermission()` opens a modal `ui.Dialog` on the next turn; `Answer(optionID)` / `Cancel()` reply **on a goroutine of their own** so a stalled agent cannot freeze the keystroke.
49+ - Slash commands and mentions (`picker.go`): `/` at the very start of the box lists `session.Commands()` (from `available_commands_update`), `@` anywhere lists project files (`View.Files`); 8-row popup over the conversation, Escape dismisses it until the text changes. A command is sent as **plain text** (`"/compact"`), "exactly as Zed does".
50+ - How the editor opens one (`app/agents.go:NewAgent`): `acp.Start(agent, projectRoot, acp.Options{OnUpdate: a.wake, OnPermission: a.recordPermission, ReadTextFile, WriteTextFile})`, then `view := acp.NewView(session)`, `window := ui.NewWindow(view.Title(), view)`, `desktop.Add(window)`.
51+
52+ ## 2. Target for mini-me: one window, one input zone
53+
54+ mini-me is the **agent**, not the editor, so the Turbo window maps almost one-to-one onto a front end that consumes mini-me's own events instead of a JSON-RPC stream: `ui.Sink` (`internal/ui/ui.go:62`: `Text`, `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd`, `WorkDir`) is the exact equivalent of turbo-core's `Transcript.Apply(update)`, and `engine.Generate`'s streaming callback is the `agent_message_chunk` source.
55+
56+ ```
57+ mm · mellum2-12b @ dmr · ctx 42% (27.3k/64k) · in 27.3k out 4.1k · session 20260916-043349-adb83d2c
58+ ┌──────────────────────────────────────────────────────────────────────────────┐
59+ │ you ▲│
60+ │ explain what @internal/acp/acp.go does with the session store ▓│
61+ │ ░│
62+ │ mm ░│
63+ │ The ACP front end keeps one session per `session/new`… ░│
64+ │ ░│
65+ │ ‣ bash ls internal/acp ✓ done 0.03s ░│
66+ │ acp.go acp_test.go persist.go replay.go sink.go ░│
67+ │ ░│
68+ │ ‣ edit_file internal/acp/acp.go … waiting for permission ░│
69+ │ ┃ Allow this edit? [A]llow [S]ession [R]eject ░│
70+ │ ▼│
71+ ├─ ⠋ thinking — Esc stops it ── Enter sends · Ctrl-J new line · / commands · @ files ─┤
72+ │ > and now add a test for /new │
73+ │ │
74+ │ │
75+ └──────────────────────────────────────────────────────────────────────────────┘
76+ F1 Help F2 Sessions Esc Cancel Ctrl-L Clear Ctrl-C Quit 3 msgs · 2 tools
77+ ```
78+
79+ | Zone | Content | Source in mini-me |
80+ |------|---------|-------------------|
81+ | Header line | model and provider, context % and tokens (`e.LastInputTokens()` vs `e.ContextWindow`), session id when `sessions.enabled` | `engine`, `config`, `internal/store` |
82+ | Transcript pane | coalesced user/agent entries, framed tool calls with detail, status, duration and output, file diffs (from `ToolResult`), **inline permission prompt** as an entry (see 2.1), compaction `🗜️` notices, `📎` attachments | `ui.Sink` implementation in `internal/tui` |
83+ | Rule | state (`thinking` + spinner / idle / error) and key hints, like Turbo's `ruleLabel` | `internal/tui` |
84+ | Input pane | 3 rows, multi-line editor, `> ` prompt, `@path` mentions (reuse `internal/mention`), `/` picker fed by **the same list as ACP's `availableCommands()`** plus the terminal-only ones (`/quit /abort /sessions /resume /help`) | `internal/mention`, shared command table |
85+ | Status bar | `Fn` hints, transient messages (warnings, `[compact: …]`), right-aligned counters | `ui.StatusBar`-like |
86+
87+ ### 2.1 Keybindings (what a terminal can actually detect)
88+
89+ | Key | Action | Note |
90+ |-----|--------|------|
91+ | Enter | send | empty box: nothing (Turbo's rule) |
92+ | **Ctrl-J** (and Alt-Enter where the terminal reports it) | newline in the box | Shift-Enter is **not** distinguishable from Enter in most terminals without the kitty keyboard protocol; tcell reports Alt-Enter on many but not all. Ctrl-J is universal (it *is* LF). Document both. |
93+ | Esc | cancel the running turn, else clear the picker / selection | same as Zed and Turbo |
94+ | Ctrl-C | quit when idle, cancel when a turn runs (current REPL semantics) | Turbo uses Ctrl-C for copy; mini-me keeps its existing meaning to stay consistent with line mode |
95+ | Tab | focus transcript ↔ input | |
96+ | ↑/↓ at box edges, PgUp/PgDn, Home/End | scroll / caret in transcript; End resumes follow | |
97+ | Ctrl-L | clear the screen (keep history) | |
98+ | F1 / `/help` | help overlay | closes ticket 0001 in this mode |
99+ | F2 / `/sessions` | session browser (phase 3) | needs 0008 (done) |
100+ | `a` / `s` / `r` while a permission entry is focused | Allow once / Allow for the session / Reject | mirrors the ACP `Allow` options |
101+ | Bracketed paste | inserted as one multi-line block, never sent | tcell `EnablePaste` |
102+
103+ ## 3. Implementation options
104+
105+ All three keep the same **seams** in mini-me: a new package `internal/tui` implementing `ui.Sink` (tools then need no change: they already emit through `ui.ActiveSink()` when one is installed — `internal/tools/tools.go:142,248`), consuming `engine.Generate`'s stream, and reusing `agent.Run`'s command semantics (`/new /compact /sessions /resume`, `@path` via `internal/mention`, persistent sessions via `internal/store`). The REPL's command handling should first be extracted from `agent.Run` (already the biggest complexity smell) into a shared `commands` helper so the two front ends cannot drift.
106+
107+ ### Option A — import turbo-core as a library (`ui`, `theme`, and the `acp` window's *shape*)
108+
109+ - **What**: `go get codeberg.org/turbo-editors/turbo-core` and use `ui.Painter/Window/Desktop/StatusBar/Dialog/ListBox` + `theme` for drawing; write `internal/tui` as a widget implementing `ui.Widget`, copying the structure of `acp/view*.go` (transcript above, rule, 3-row box, picker) but fed by `ui.Sink` events instead of a `Session`. The `acp` package itself is a **client** and cannot be reused as-is (mini-me is the agent); `Transcript`/`render.go` could be reused only if their `Entry` type were fed by hand — feasible, they are plain values with no protocol dependency.
110+ - **Dependencies**: turbo-core (+ tcell v2, toml, x/sys transitively). MIT: compatible.
111+ - **Pros**: proven Turbo Vision widgets, themes, tests, mouse, scrollbars, dialogs for free; the design goal ("like turbo-go's agent window") is met literally; one team's conventions (`README.md` per package, methodical docs) close to mini-me's own.
112+ - **Cons**: turbo-core is `v0.8.0`, pre-1.0, and "developed alongside the editors that use it" (`turbo-go/go.mod` comment) — API churn risk; it drags `buffer`, `lsp`, `terminal` etc. into the module graph even if unused (Go only compiles imported packages, but `go.sum`/vulnerability surface grows: osv already fails the gate); Turbo's desktop-of-windows model is more than one window needs.
113+ - **Effort**: window M, sink adapter M, picker S (Turbo's exists), permission dialog S (Turbo's `Dialog`), session browser S (`ListBox`). Total ≈ M.
114+
115+ ### Option B — tcell directly, hand-rolled single window (recommended)
116+
117+ - **What**: depend on `github.com/gdamore/tcell/v2` only (the library Turbo itself is built on). Write `internal/tui` with: a `Painter`-like clipped drawer (≈150 lines, Turbo's `ui/painter.go` is the model), a `Transcript` value (Turbo's `transcript.go` shape: coalesce chunks, fold tool updates), a `render` (wrap + fences, ≈250 lines), the `View` (transcript/rule/input, ≈600 lines across three files like Turbo), a picker (≈300), an inline permission entry (no modal dialog), one status line. Event loop copied from `app.Run`: `tick → draw → PollEvent → handle`, with `wake()` via `PostEvent(EventInterrupt)` from the engine/tool goroutines and **state-driven redraw**.
118+ - **Dependencies**: tcell v2 (+ its indirect `go-colorful`, `uniseg`, `x/term`, `x/text`). Alternate screen **only in this mode**; `spinner.Styled()`-style detection falls back to line mode when not a TTY.
119+ - **Pros**: smallest footprint that still gives a real window; no pre-1.0 API to track; every line is mini-me's and fits its "hand-written, bash-first" style; the ACP façade and the REPL are untouched.
120+ - **Cons**: re-implementing ~1 500 lines Turbo already has; no themes (a handful of `tcell.Style`s is enough); Markdown stays "fences only", as in Turbo.
121+ - **Effort**: painter S, transcript+render M, view+input M, picker S, permission entry S, status/header S, mode switch + fallback S, session browser S (after 0008), tests M (tcell `SimulationScreen` allows screen assertions like Turbo's `uitest_test.go`). Total ≈ M–L, delivered in phases.
122+
123+ ### Option C — Bubble Tea (cagent's stack)
124+
125+ - Already analysed in ticket 0003 §3.1: `bubbletea/v2 + lipgloss/v2 + glamour/v2 + chroma`, alternate screen, Elm-style `Update/View`. Richest rendering (incremental Markdown, syntax highlighting) and the most dependencies by far; two rendering paths to maintain; alternate screen semantics identical to A/B. Effort L. Kept here for comparison; not recommended for the same reasons as in 0003.
126+
127+ ### Comparison
128+
129+ | | A turbo-core | B tcell direct | C Bubble Tea |
130+ |---|---|---|---|
131+ | New direct deps | 1 (+3 transitive) | 1 (+4 transitive) | 4+ |
132+ | Widgets for free | yes (windows, dialogs, list, themes) | no | yes (many) |
133+ | API stability | pre-1.0, moving | tcell v2 stable since 2020 | v2 recent |
134+ | Fits "agent window" brief | literally | in spirit | no (different idiom) |
135+ | Streaming model | state-driven redraw + wake | same, hand-written | `tea.Msg` per chunk |
136+ | `mm \| tee` | mode-gated, line mode stays | same | same |
137+ | Effort | M | M–L | L |
138+
139+ ## 4. Recommendation and phased plan
140+
141+ **Recommend Option B**, with Option A as the escape hatch if phase 1 shows the hand-rolled painter/widgets costing more than expected (the two are compatible: both draw on tcell, and Turbo's file layout is the blueprint either way). Re-evaluate A when turbo-core reaches a tagged 1.0.
142+
143+ **Prerequisite (S)** — extract `agent.Run`'s command handling (`/quit /abort /compact /new /sessions /resume`, `@path` expansion) into a shared table used by the REPL, ACP (`availableCommands()`) and the TUI picker. Lowers the `agent.Run` complexity smell at the same time.
144+
145+ **Phase 1 — minimal window (M)**: `mm -tui` / `tui: true`; TTY detection with line-mode fallback; header, transcript (user/agent entries, coalesced streaming, follow mode), rule with spinner, 3-row input with Ctrl-J newline, Enter send, Esc cancel, Ctrl-C quit, wheel/PgUp/PgDn; `ui.Sink` implementation printing tool calls as one-line framed entries; `@path` echo. Tests on `tcell.SimulationScreen`.
146+
147+ **Phase 2 — tools and permissions (M)**: tool entries with detail, status, duration, output folded in, diffs coloured per line; **inline permission entry** (`a/s/r`) implementing `Sink.Allow` — the terminal gets the gatekeeper ACP already has (0003 §2.2), behind `permissions: ask | auto` so line mode keeps today's behaviour; `/` picker from the shared command table; `/help` overlay (0001) and `/about` (0002); tokens and context % in the header (0005; 0006 is the ACP twin).
148+
149+ **Phase 3 — sessions and reading comfort (M)**: F2/`/sessions` browser over `internal/store` (list, resume, new) — 0008's persistence makes it possible; transcript selection + copy (OSC 52 like Turbo, plus internal); fenced code blocks drawn distinctly (no highlighter); bracketed paste; optional light heading/bold rendering.
150+
151+ **Risks**
152+
153+ - Terminal compatibility: Alt-Enter/Shift-Enter not reported everywhere → Ctrl-J is the documented newline; test on macOS Terminal, iTerm2, Ghostty, Zed's terminal, tmux.
154+ - Windows: tcell supports it; pty-free design keeps it possible, but untested here (mini-me targets macOS/Linux).
155+ - Screen readers and copy/paste: the alternate screen hides the transcript from the terminal's scrollback → keep line mode as default and make `mm -tui` explicit; offer `/export` later.
156+ - Pipe detection: `mm -tui | tee` must silently run line mode (same rule as `spinner.Styled()`).
157+ - Concurrency: draw only on the loop goroutine; sink and engine callbacks record + `wake()` (Turbo's rule, and the same one mini-me's ACP `turnMu` already enforces for turns).
158+ - Quality gate: new code lives in `internal/tui` with tests; `agent.Run` must shrink, not grow.
159+
160+ ## 5. Related tickets
161+
162+ - 0003 Improve TUI — tiers 1–2 (multi-line input, `/help`, tokens in recap, permission prompt) are the line-mode counterparts; several become the same code behind the shared command table.
163+ - 0001 `/help`, 0002 `/about` — closed by phase 2 in both modes.
164+ - 0005 / 0006 token consumption — header counters (terminal) / ACP twin.
165+ - 0008 Reload in Zed — persistent sessions (`internal/store`) are what phase 3's session browser lists.
166+
167+ Sources: `/tmp/turbo/turbo-core` and `/tmp/turbo/turbo-go` (shallow clones of the Codeberg repositories, 2026-09-16), files cited inline.
168+ ## Progress (2026-09-16, agent bob) — Option B implemented, phases 1 and 2
169+
170+ The user chose **Option B**. Delivered in one pass, `go test -race ./...` green (14 packages), quality gate unchanged on pre-existing debt only (warnings 67 → 63, smells 19 → 19, none in the new packages):
171+
172+ - [x] Prerequisite: `internal/command` — the one slash-command table (`Quit Abort Compact New Sessions Resume Help`, per-front-end `Fronts` bits, `Parse`/`Is`/`For`/`Usage`); REPL dispatches on it, ACP `availableCommands()` reads its Editor slice, the window's picker lists its Window slice. `/help` added to the REPL and the window (closes ticket 0001). Shared helpers extracted on the way: `compact.Apply` (one report line for the three front ends), `session.AfterTurn` (the history-merge rule), `store.Keeper` (moved from `internal/agent/persist.go`, used by REPL and window). `agent.Run` complexity 121 → 110.
173+ - [x] Phase 1: `mm -tui` flag and `tui: true` key; `internal/tui` on `github.com/gdamore/tcell/v2 v2.13.10` with the Turbo file layout (`painter.go`, `wrap.go`, `transcript.go`, `render.go`, `view.go`, `draw.go`, `events.go`, `picker.go`, `sink.go`, `turn.go`, `tui.go`); header (model @ provider, ctx % or `~used (window unknown)`, session id), transcript with scroll bar and follow mode, rule with braille spinner and key hints, 3-row box (Enter sends, **Ctrl-J**/Alt-Enter newline, bracketed paste inserted whole), Esc cancels, Ctrl-C quits when idle / cancels when busy, Tab focus, PgUp/PgDn/↑↓/Home/End scroll, Ctrl-L clears the view, mouse wheel; `/` picker (8 rows, prefix filter, Tab completes, Enter sends a complete name, Esc dismisses until the text changes); `@path` via `internal/mention` with `📎` notices; same history rules as the REPL (auto-compaction before the question, full history kept after failure/cancel, `store.Keeper` when `sessions.enabled`); a question typed during a turn cancels it and runs after it settles; fallback to the line mode when stdin/stdout is not a terminal (`[tui: … — running the line mode]`), banner shown inside the window otherwise.
174+ - [x] Phase 2: tool entries `‣ tool detail ✓ done 0.03s` with folded output (`previewLines`) and `+`/`-` colouring for `write_file`/`edit_file`; **inline permission** for `bash` and the file tools behind `permissions: ask | auto` (default `auto`; `a` once, `s` for the session, `r`/Esc reject; a cancelled turn rejects; ACP and the line mode unchanged); tokens/context in the header (`e.LastInputTokens()` vs estimate); `/help` and F1.
175+ - [x] Tests on `tcell.NewSimulationScreen`: input editing, paste, picker, window keys, frame layout, follow/scroll, permission keys; runner tests with a canned model (turn, failure, cancel + pending, commands, compact, permissions); an end-to-end loop test; `command` table tests. Real binary: `-tui` with piped stdin falls back to the REPL; in a pseudo-terminal the window draws, `/help` renders, `/quit` exits 0 with the alternate screen released.
176+ - [x] Docs EN+FR: `how-to/use-the-window-mode.md`, `reference/cli.md` (`-tui`, window-mode table), `reference/slash-commands.md` (`/help`, window keys), `reference/configuration.md` (`tui`, `permissions`), `reference/tools.md`, `explanation/architecture.md` (three front ends, command table, draw-from-a-value), tutorial banner, both indexes, `docs/diagrams/packages.drawio` (`tui`, `command`, `tcell` nodes).
177+ - [ ] Phase 3 (not started): session browser (F2), transcript selection and copy, fenced code drawn distinctly, light Markdown.
178+ - [ ] Manual checks the user must do in a real terminal (macOS Terminal, iTerm2, Ghostty, Zed's terminal, tmux): Ctrl-J and Alt-Enter detection, Esc latency, resize, colours on light and dark themes.
179+
180+ ## Progress 2 (2026-09-16, agent bob) — copy text out of the window
181+
182+ User request: select the generated text in `-tui` and paste it elsewhere. Root cause: `EnableMouse` at start-up made the terminal hand every click to mm, killing its native click-and-drag selection.
183+
184+ - [x] Mouse capture is OFF at start-up; **F2** toggles it (wheel scrolling when on; `Shift`+drag then selects in most terminals). Status bar: `F2 mouse: off` / `mouse: on (Shift+drag selects)`. No config key: `tui` is a plain bool and turning it into a struct now was not worth it; the toggle is one key away.
185+ - [x] `/copy` (last answer, raw text), `/copy 2` (n-th from the end), `/copy all` (whole conversation as plain text), **Ctrl-Y** = `/copy`. New package `internal/clipboard`: native tool on PATH first (`pbcopy`, `wl-copy`, `xclip -selection clipboard`, `xsel --clipboard --input`), then OSC 52 through `tcell.Screen.SetClipboard`; the notice names the road (`📋 copied N chars (pbcopy)`), failures are `⚠️ no clipboard: …`. Native-first, not OSC-first as the brief suggested, because macOS Terminal.app ignores OSC 52 silently and "copied" must not lie.
186+ - [x] REPL: `/copy [n]` through the same package (OSC 52 to stdout only when it is a terminal); `/copy all` is refused there with a hint (the scrollback is selectable).
187+ - [x] Tests: clipboard chain (6), transcript `Answers`/`Plain`, `copyText`, runner `/copy` with a fake copier, F2 toggle + status label, Ctrl-Y hook, REPL `copyAnswer`. `go test -race ./...` 16 packages ok. Pty check on the Linux build: no `?1000h/?1002h/?1006h` at start-up, F2 emits them, F2 again emits `?1000l…`, `/copy all` reaches a fake `pbcopy`.
188+ - [ ] In-window selection (task 10): deferred. It only makes sense while the mouse is captured — the very state that defeats native selection — and needs a laid-out-line ↔ entry mapping plus a selection model (~300+ lines from Turbo's `view_events.go`/`transcript.go`); `/copy` covers the common case cheaply.
189+
190+ Side fix on the way: `skills.List` now skips a `README.md` at the skills root (the user's new `skills/README.md` was counted as a skill with no description and broke the shipped-skills test).
191+
192+ ## Progress 3 (2026-09-16, agent bob) — long pastes collapse into a token
193+
194+ User request: a paste too long for the box shows only a placeholder, but the agent gets the whole text. Root fact: tcell delivers a bracketed paste as a start mark, the text as ordinary keys, an end mark — so the keys are now buffered until the end mark (`paste.go`) instead of being inserted one by one.
195+
196+ - [x] Threshold: more than 3 lines (the box's height) OR more than 200 characters collapses; smaller pastes stay text (a pasted Tab is now a tab, not a focus change).
197+ - [x] Token `[Pasted #n · L lines · C chars]`: one private-use rune in the input, so ←/→/Backspace/Delete treat it as one character; deleting it drops the paste; pastes are numbered per message; drawn in its own style with the cursor column measured by label width.
198+ - [x] Enter hands the runner a `Prompt{Typed, Pastes}`: `Text()` (pastes expanded, verbatim) goes to the model; `Display()` (tokens) goes to the conversation; `Transcript.Entry.Full` keeps the expanded question so `/copy all` and `Plain()` carry it; the persisted session has the full text by construction.
199+ - [x] `@path` mentions are looked up in the typed text only (`mention.Expand(p.Typed)` then `p.Expand`); a pasted `/command` is text.
200+ - [x] Tests: `TestLongPasteCollapsesToOneToken`, `TestPasteTokenIsAtomic`, `TestShouldCollapse`, `TestPasteNeverSends` (short paste + tab), `TestRunnerTurnWithPaste` (full text to the model, collapsed display, mention rule, `/copy all`). `go test -race ./...` 16 packages ok. Pty check: a 50-line bracketed paste draws `[Pasted #1 · 50 lines …]` and none of the lines; Enter shows the collapsed question in the conversation.
201+ - [ ] Optional `/paste n` to peek at a collapsed block — not done (the pastes live in the View, commands run in the runner; cheap only with a new hook). Ask if wanted.
202+
203+ ## Implementation note (bob, 2026-09-16)
204+
205+ Decisions taken for the user: Option A (import turbo-core) not used — tcell direct, as recommended; `permissions` defaults to `auto` so nothing changes for the line mode; Ctrl-C keeps the REPL's meaning (quit when idle, cancel when busy) rather than Turbo's copy; a `/compact` typed while a turn runs cancels the turn first (queued like any input); the unstreamed answer of a one-block server reply is shown from the response when the sink saw no chunk; `hello-world/main.go` (user's untracked file) and `blog/fake-agent/main.go` are what the gate flags outside `internal/`.
206+tasks:
207+ - id: 1
208+ title: "Prerequisite: shared slash-command table internal/command used by REPL, ACP and the window picker"
209+ state: closed
210+ priority: none
211+ author:
212+ name: k33g
213+ email: ph.charriere@gmail.com
214+ createdAt: 2026-09-16T05:40:00.000Z
215+ updatedAt: 2026-09-16T05:40:00.000Z
216+ closedAt: 2026-09-16T05:40:00.000Z
217+ - id: 2
218+ title: "Phase 1: mm -tui window on tcell — header, transcript, rule, 3-row box, picker, @path, fallback to line mode"
219+ state: closed
220+ priority: none
221+ author:
222+ name: k33g
223+ email: ph.charriere@gmail.com
224+ createdAt: 2026-09-16T05:40:00.000Z
225+ updatedAt: 2026-09-16T05:40:00.000Z
226+ closedAt: 2026-09-16T05:40:00.000Z
227+ - id: 3
228+ title: "Phase 2: framed tool output with time and diff colours, inline permissions (permissions: ask|auto), tokens in header, /help and F1"
229+ state: closed
230+ priority: none
231+ author:
232+ name: k33g
233+ email: ph.charriere@gmail.com
234+ createdAt: 2026-09-16T05:40:00.000Z
235+ updatedAt: 2026-09-16T05:40:00.000Z
236+ closedAt: 2026-09-16T05:40:00.000Z
237+ - id: 4
238+ title: "Tests: simulation-screen view tests, runner tests, end-to-end loop test, command table tests"
239+ state: closed
240+ priority: none
241+ author:
242+ name: k33g
243+ email: ph.charriere@gmail.com
244+ createdAt: 2026-09-16T05:40:00.000Z
245+ updatedAt: 2026-09-16T05:40:00.000Z
246+ closedAt: 2026-09-16T05:40:00.000Z
247+ - id: 5
248+ title: "Docs EN+FR and package diagram updated"
249+ state: closed
250+ priority: none
251+ author:
252+ name: k33g
253+ email: ph.charriere@gmail.com
254+ createdAt: 2026-09-16T05:40:00.000Z
255+ updatedAt: 2026-09-16T05:40:00.000Z
256+ closedAt: 2026-09-16T05:40:00.000Z
257+ - id: 6
258+ title: "Phase 3: session browser, code fences drawn distinctly, light Markdown"
259+ state: open
260+ priority: none
261+ author:
262+ name: k33g
263+ email: ph.charriere@gmail.com
264+ createdAt: 2026-09-16T05:40:00.000Z
265+ - id: 7
266+ title: "Manual terminal checks (macOS Terminal, iTerm2, Ghostty, Zed terminal, tmux): Ctrl-J / Alt-Enter, Esc, resize, colours"
267+ state: open
268+ priority: none
269+ author:
270+ name: k33g
271+ email: ph.charriere@gmail.com
272+ createdAt: 2026-09-16T05:40:00.000Z
273+ - id: 8
274+ title: "Copy: mouse capture off by default (terminal selection works), F2 toggles it with the state in the status bar"
275+ state: closed
276+ priority: none
277+ author:
278+ name: k33g
279+ email: ph.charriere@gmail.com
280+ createdAt: 2026-09-16T08:30:00.000Z
281+ updatedAt: 2026-09-16T08:30:00.000Z
282+ closedAt: 2026-09-16T08:30:00.000Z
283+ - id: 9
284+ title: "Copy: /copy [n|all] and Ctrl-Y through internal/clipboard (pbcopy/wl-copy/xclip/xsel, then OSC 52); /copy [n] in the REPL too"
285+ state: closed
286+ priority: none
287+ author:
288+ name: k33g
289+ email: ph.charriere@gmail.com
290+ createdAt: 2026-09-16T08:30:00.000Z
291+ updatedAt: 2026-09-16T08:30:00.000Z
292+ closedAt: 2026-09-16T08:30:00.000Z
293+ - id: 10
294+ title: "In-window selection while the mouse is captured (drag to select, Ctrl-C/y copies) — deferred, see body"
295+ state: open
296+ priority: low
297+ author:
298+ name: k33g
299+ email: ph.charriere@gmail.com
300+ createdAt: 2026-09-16T08:30:00.000Z
301+ - id: 11
302+ title: "Long pastes collapse into a [Pasted #n · L lines · C chars] token; Enter sends the full text; @path inside a paste not looked up"
303+ state: closed
304+ priority: medium
305+ author:
306+ name: k33g
307+ email: ph.charriere@gmail.com
308+ createdAt: 2026-09-16T09:00:00.000Z
309+ updatedAt: 2026-09-16T09:00:00.000Z
310+ closedAt: 2026-09-16T09:00:00.000Z
311+comments: []
added .tickets/issues/0013-activate-deactivat-yolo-mode.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 13
3+title: Activate / Deactivat YOLO mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:47:28.194Z
9+updatedAt: 2026-09-16T05:47:28.194Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 13
3+title: Activate / Deactivat YOLO mode
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:47:28.194Z
9+updatedAt: 2026-09-16T05:47:28.194Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
added .tickets/issues/0014-check-all-the-shortcuts.yaml +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 14
3+title: Check all the shortcuts
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:51:16.287Z
9+updatedAt: 2026-09-16T05:51:50.352Z
10+labels: []
11+body: ""
12+tasks:
13+ - id: 1
14+ title: F1 seems not working (at least in the VSCode terminal)
15+ state: open
16+ priority: medium
17+ author:
18+ name: k33g
19+ email: ph.charriere@gmail.com
20+ createdAt: 2026-09-16T05:51:46.032Z
21+ updatedAt: 2026-09-16T05:51:50.347Z
22+comments: []
new file mode 100644
@@ -0,0 +1,22 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 14
3+title: Check all the shortcuts
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:51:16.287Z
9+updatedAt: 2026-09-16T05:51:50.352Z
10+labels: []
11+body: ""
12+tasks:
13+ - id: 1
14+ title: F1 seems not working (at least in the VSCode terminal)
15+ state: open
16+ priority: medium
17+ author:
18+ name: k33g
19+ email: ph.charriere@gmail.com
20+ createdAt: 2026-09-16T05:51:46.032Z
21+ updatedAt: 2026-09-16T05:51:50.347Z
22+comments: []
added .tickets/issues/0015-improve-skills-detection.yaml +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 15
3+title: Improve skills detection
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:52:51.530Z
9+updatedAt: 2026-09-16T05:52:51.530Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
new file mode 100644
@@ -0,0 +1,13 @@
1+# Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
2+id: 15
3+title: Improve skills detection
4+state: open
5+author:
6+ name: k33g
7+ email: ph.charriere@gmail.com
8+createdAt: 2026-09-16T05:52:51.530Z
9+updatedAt: 2026-09-16T05:52:51.530Z
10+labels: []
11+body: ""
12+tasks: []
13+comments: []
modified .tickets/labels.yaml +2 -0
@@ -36,3 +36,5 @@ labels:
3636 - name: kind::chore
3737 color: "#bfdadc"
3838 description: Maintenance, no visible change
39+ - name: llama.cpp
40+ color: "#fbca04"
@@ -36,3 +36,5 @@ labels:
36 - name: kind::chore36 - name: kind::chore
37 color: "#bfdadc"37 color: "#bfdadc"
38 description: Maintenance, no visible change38 description: Maintenance, no visible change
39+ - name: llama.cpp
40+ color: "#fbca04"
modified .vscode/settings.json +2 -1
@@ -58,5 +58,6 @@
5858 "gopls": {
5959 "ui.semanticTokens": true,
6060 "ui.completion.usePlaceholders": true
61- }
61+ },
62+ "issuespec.groupBy": "label"
6263 }
\ No newline at end of file
@@ -58,5 +58,6 @@
58 "gopls": {58 "gopls": {
59 "ui.semanticTokens": true,59 "ui.semanticTokens": true,
60 "ui.completion.usePlaceholders": true60 "ui.completion.usePlaceholders": true
61- }61+ },
62+ "issuespec.groupBy": "label"
62 }63 }
\ No newline at end of file\ No newline at end of file
modified agent.dmr.yaml +10 -0
@@ -68,6 +68,16 @@ editTools: true
6868 # is not declared at all.
6969 skillsDir: skills
7070
71+# Persistent sessions, per project. When enabled, every conversation is saved
72+# as one JSON file under `dir` (relative to the directory the agent is started
73+# from; the editor's project in ACP mode), rewritten after each turn. In the
74+# terminal, `/sessions` lists them and `/resume` continues the last one; in an
75+# editor, "Reload Agent" (ACP session/load) replays one. `/new` always starts
76+# a fresh session and file. Add `.mm/` to the project's .gitignore.
77+sessions:
78+ enabled: false
79+ dir: .mm/sessions
80+
7181 # How many lines of a command's output are echoed to the terminal. "Show me
7282 # that file" means show it: running `cat` is not displaying it. 0 disables it.
7383 previewLines: 20
@@ -68,6 +68,16 @@ editTools: true
68 # is not declared at all.68 # is not declared at all.
69 skillsDir: skills69 skillsDir: skills
70 70
71+# Persistent sessions, per project. When enabled, every conversation is saved
72+# as one JSON file under `dir` (relative to the directory the agent is started
73+# from; the editor's project in ACP mode), rewritten after each turn. In the
74+# terminal, `/sessions` lists them and `/resume` continues the last one; in an
75+# editor, "Reload Agent" (ACP session/load) replays one. `/new` always starts
76+# a fresh session and file. Add `.mm/` to the project's .gitignore.
77+sessions:
78+ enabled: false
79+ dir: .mm/sessions
80+
71 # How many lines of a command's output are echoed to the terminal. "Show me81 # How many lines of a command's output are echoed to the terminal. "Show me
72 # that file" means show it: running `cat` is not displaying it. 0 disables it.82 # that file" means show it: running `cat` is not displaying it. 0 disables it.
73 previewLines: 2083 previewLines: 20
modified agent.llamacpp.yaml +29 -0
@@ -54,6 +54,35 @@ contextWindow: 0
5454 maxOutput: 16000
5555 maxTurns: 40
5656 skillsDir: skills
57+
58+# Persistent sessions, per project — OFF by default.
59+#
60+# How to turn them on:
61+#
62+# 1. Set `enabled: true` below (keep `dir` unless you want another place).
63+# 2. Restart the agent from the project directory:
64+# mm agent.llamacpp.yaml
65+# or, from Zed, point AGENT_CONFIG at this file (absolute path) and use
66+# "Reload Agent" / open a new thread so the new process reads the key.
67+# 3. Check the banner: it now shows a `sessions:` line with the directory
68+# and the number of earlier sessions found there.
69+# 4. Add `.mm/` to the project's .gitignore (already done in this repository).
70+#
71+# What it does once on: every conversation is saved as one JSON file
72+# `<dir>/<id>.json`, rewritten after each turn, `/compact` and `/new`. `dir`
73+# is relative to the directory the agent is started from — the editor's
74+# project in ACP mode — so each project keeps its own sessions; an absolute
75+# path works too. A start always opens a NEW session: in the terminal,
76+# `/sessions` lists the stored ones and `/resume` continues the latest
77+# (`/resume <id>` a given one); in an editor, "Reload Agent" (ACP
78+# session/load) replays the whole thread, tool calls included, without
79+# running anything again. `/new` always starts a fresh session and file.
80+# Write failures are logged, never fatal; with `enabled: false` nothing is
81+# written and `/sessions` says so.
82+sessions:
83+ enabled: true
84+ dir: .mm/sessions
85+
5786 previewLines: 20
5887 displayCommands: true
5988
@@ -54,6 +54,35 @@ contextWindow: 0
54 maxOutput: 1600054 maxOutput: 16000
55 maxTurns: 4055 maxTurns: 40
56 skillsDir: skills56 skillsDir: skills
57+
58+# Persistent sessions, per project — OFF by default.
59+#
60+# How to turn them on:
61+#
62+# 1. Set `enabled: true` below (keep `dir` unless you want another place).
63+# 2. Restart the agent from the project directory:
64+# mm agent.llamacpp.yaml
65+# or, from Zed, point AGENT_CONFIG at this file (absolute path) and use
66+# "Reload Agent" / open a new thread so the new process reads the key.
67+# 3. Check the banner: it now shows a `sessions:` line with the directory
68+# and the number of earlier sessions found there.
69+# 4. Add `.mm/` to the project's .gitignore (already done in this repository).
70+#
71+# What it does once on: every conversation is saved as one JSON file
72+# `<dir>/<id>.json`, rewritten after each turn, `/compact` and `/new`. `dir`
73+# is relative to the directory the agent is started from — the editor's
74+# project in ACP mode — so each project keeps its own sessions; an absolute
75+# path works too. A start always opens a NEW session: in the terminal,
76+# `/sessions` lists the stored ones and `/resume` continues the latest
77+# (`/resume <id>` a given one); in an editor, "Reload Agent" (ACP
78+# session/load) replays the whole thread, tool calls included, without
79+# running anything again. `/new` always starts a fresh session and file.
80+# Write failures are logged, never fatal; with `enabled: false` nothing is
81+# written and `/sessions` says so.
82+sessions:
83+ enabled: true
84+ dir: .mm/sessions
85+
57 previewLines: 2086 previewLines: 20
58 displayCommands: true87 displayCommands: true
59 88
modified docs/diagrams/packages.drawio +1 -1
@@ -1 +1 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file
@@ -1 +1 @@
1-<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
1+<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="&lt;b&gt;main (mm)&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;wiring: config, engine, tools, then one front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="&lt;b&gt;internal/agent&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal REPL: /quit /abort /compact /new&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="&lt;b&gt;internal/acp&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;editor front end: Agent Client Protocol on stdio&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="&lt;b&gt;internal/session&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;what a conversation starts from; the /new reset&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="&lt;b&gt;internal/compact&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;history compression into a model-written summary&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="&lt;b&gt;internal/engine&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;LLM connection + generation, one Provider per server&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="&lt;b&gt;internal/tools&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;built-in tools: bash, read_skill, file tools&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="&lt;b&gt;internal/detector&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;repeated-action (loop) detection&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="&lt;b&gt;internal/config&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;settings with YAML overrides (agent.yaml)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="&lt;b&gt;internal/spinner&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal spinner and styling&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="&lt;b&gt;internal/ui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;human output destination + shared event sink&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="&lt;b&gt;internal/fileedit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;exact-replacement file editing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="&lt;b&gt;internal/skills&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;markdown skills catalogue (./skills)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="&lt;b&gt;github.com/firebase/genkit&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;ai.Message, tools, model calls&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="&lt;b&gt;github.com/coder/acp-go-sdk&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;JSON-RPC transport for ACP&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="&lt;b&gt;github.com/openai/openai-go&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;OpenAI-compatible HTTP client&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="&lt;b&gt;gopkg.in/yaml.v3&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;YAML parsing&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="&lt;b&gt;internal/mention&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the @path notation of a question&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="&lt;b&gt;internal/store&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;per-project session files (.mm/sessions/&lt;id&gt;.json)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="&lt;b&gt;internal/tui&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;window front end: one screen on tcell (mm -tui)&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="&lt;b&gt;internal/command&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;the one slash-command table, per front end&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="&lt;b&gt;github.com/gdamore/tcell/v2&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;terminal screen, keys, mouse&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="&lt;b&gt;internal/clipboard&lt;/b&gt;&lt;br/&gt;&lt;font style=&quot;font-size:10px&quot;&gt;system clipboard: native tool, then OSC 52&lt;/font&gt;" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="&lt;b&gt;mm — package dependencies&lt;/b&gt;&lt;br/&gt;Arrow: &quot;imports&quot;. Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.&lt;br/&gt;Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports &quot; &quot;}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>
\ No newline at end of file\ No newline at end of file
modified docs/en/README.md +6 -3
@@ -1,6 +1,6 @@
11 # mini-me — documentation (English)
22
3-`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
3+`mm` is a coding agent that runs in your terminal — line by line, or in a single window with `mm -tui` — or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
44
55 This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now.
66
@@ -16,11 +16,13 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
1616 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
1717 - [Start a new session without restarting](how-to/start-a-new-session.md)
1818 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
19+- [Keep sessions and resume them](how-to/keep-sessions.md)
20+- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)
1921
2022 ## Reference — look it up
2123
2224 - [Command line](reference/cli.md) — flags, environment variables, start-up banner
23-- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts
25+- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys
2426 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
2527 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
2628 - [Skill file format](reference/skill-format.md)
@@ -28,9 +30,10 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
2830
2931 ## Explanation — understand
3032
31-- [Architecture: one loop, two front ends](explanation/architecture.md)
33+- [Architecture: one loop, three front ends](explanation/architecture.md)
3234 - [Providers: one implementation per protocol](explanation/providers.md)
3335 - [Context compression](explanation/context-compression.md)
36+- [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
3437 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
3538
3639 ## Scope
@@ -1,6 +1,6 @@
1 # mini-me — documentation (English)1 # mini-me — documentation (English)
2 2
3-`mm` is a coding agent that runs in your terminal, or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.3+`mm` is a coding agent that runs in your terminal — line by line, or in a single window with `mm -tui` — or inside an editor through the Agent Client Protocol. It talks to a local model server (Docker Model Runner or llama.cpp's `llama-server`) and works with a handful of built-in tools: `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4 4
5 This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now.5 This documentation is organised along the four [Diátaxis](https://diataxis.fr) quadrants. Pick the one that matches what you need right now.
6 6
@@ -16,11 +16,13 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
16 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)16 - [Keep a long session inside the context window](how-to/manage-the-context-window.md)
17 - [Start a new session without restarting](how-to/start-a-new-session.md)17 - [Start a new session without restarting](how-to/start-a-new-session.md)
18 - [Point the model at a file with `@path`](how-to/attach-a-file.md)18 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
19+- [Keep sessions and resume them](how-to/keep-sessions.md)
20+- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md)
19 21
20 ## Reference — look it up22 ## Reference — look it up
21 23
22 - [Command line](reference/cli.md) — flags, environment variables, start-up banner24 - [Command line](reference/cli.md) — flags, environment variables, start-up banner
23-- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, keyboard shortcuts25+- [Slash commands](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, keyboard shortcuts, window keys
24 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule26 - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule
25 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`27 - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26 - [Skill file format](reference/skill-format.md)28 - [Skill file format](reference/skill-format.md)
@@ -28,9 +30,10 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr)
28 30
29 ## Explanation — understand31 ## Explanation — understand
30 32
31-- [Architecture: one loop, two front ends](explanation/architecture.md)33+- [Architecture: one loop, three front ends](explanation/architecture.md)
32 - [Providers: one implementation per protocol](explanation/providers.md)34 - [Providers: one implementation per protocol](explanation/providers.md)
33 - [Context compression](explanation/context-compression.md)35 - [Context compression](explanation/context-compression.md)
36+- [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
34 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)37 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35 38
36 ## Scope39 ## Scope
modified docs/en/explanation/architecture.md +13 -5
@@ -1,10 +1,10 @@
1-# Architecture: one loop, two front ends — explanation
1+# Architecture: one loop, three front ends — explanation
22
33 ## What is this about?
44
5-`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything.
5+`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal — line by line or in a window — and from an editor without duplicating anything.
66
7-The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`:
7+The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of three front ends. Each concern lives in its own package under `internal/`:
88
99 | Package | Responsibility |
1010 |---------|----------------|
@@ -13,13 +13,17 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
1313 | `tools` | The built-in tools the model can call. |
1414 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
1515 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
16+| `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
17+| `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
18+| `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
1619 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
1720 | `detector` | Detection of the same action repeated with the same result. |
1821 | `compact` | Compression of the history into a model-written summary. |
1922 | `spinner` | The "still working" indicator on a terminal. |
20-| `ui` | Where human-facing output goes, and the event sink the two front ends share. |
23+| `ui` | Where human-facing output goes, and the event sink the front ends share. |
2124 | `agent` | The terminal REPL. |
2225 | `acp` | The Agent Client Protocol façade. |
26+| `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
2327
2428 A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
2529
@@ -31,7 +35,11 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia
3135
3236 **Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.
3337
34-**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests.
38+**One loop, three front ends.** The terminal REPL, the window and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests. The window mode is the same trick a second time: `tui` installs its own `ui.Sink` for each turn, and the events become entries of a transcript drawn on the screen — so the tools needed no change to gain a third home, and the inline permission prompt of `permissions: ask` is the terminal's version of the editor's dialog.
39+
40+**One command table.** The slash commands live in `internal/command`, marked with the front ends that offer them. The REPL and the window read the same table; the ACP façade advertises only the Editor slice (`/new`, `/compact`) through `available_commands_update`, because the editor owns the process and cancels turns itself. A command added in one place is therefore offered — or deliberately not — everywhere at once.
41+
42+**The window draws from a value, on one goroutine.** The engine streams chunks and the tools emit events from their own goroutines; the window records them into a `Transcript` (a mutex-guarded value) and posts a wake-up event. Only the event loop draws, and it redraws from the transcript's current state every frame, so a dropped wake-up costs a late frame and never a lost one. This is the rule turbo-core's agent window follows, and the reason the window needs no widget library: a painter, a transcript, a layout function and a key map are enough.
3543
3644 **Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.
3745
@@ -1,10 +1,10 @@
1-# Architecture: one loop, two front ends — explanation1+# Architecture: one loop, three front ends — explanation
2 2
3 ## What is this about?3 ## What is this about?
4 4
5-`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal and from an editor without duplicating anything.5+`mm` is deliberately small: an agent loop, a handful of tools, and a configuration file. What makes it worth looking at is how the pieces are cut so that the same loop can be driven from a terminal — line by line or in a window — and from an editor without duplicating anything.
6 6
7-The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of two front ends. Each concern lives in its own package under `internal/`:7+The entry point, `main.go`, only does wiring: it loads the settings, opens the engine, builds the tool list, prints the banner, and hands everything to one of three front ends. Each concern lives in its own package under `internal/`:
8 8
9 | Package | Responsibility |9 | Package | Responsibility |
10 |---------|----------------|10 |---------|----------------|
@@ -13,13 +13,17 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e
13 | `tools` | The built-in tools the model can call. |13 | `tools` | The built-in tools the model can call. |
14 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |14 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
15 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |15 | `skills` | Discovery of markdown procedures and rendering of the `read_skill` catalogue. |
16+| `command` | The one table of slash commands: name, description, which front ends offer it, whether it takes an argument. The REPL dispatches on it, the ACP façade advertises its Editor slice, the window's `/` picker lists it. |
17+| `clipboard` | Puts text on the system clipboard: a native tool on `PATH` first (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 as the fallback, and it reports which. Used by `/copy` in the REPL and the window. |
18+| `store` | Per-project session files (`<dir>/<id>.json`, whole rewrite, atomic): create, save, load, list, latest. Used by both front ends when `sessions.enabled`. |
16 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |19 | `mention` | The `@path` notation of a question: existing paths become `[attached file: …]` lines, the same shape as an editor's `resource_link`. Used by both front ends. |
17 | `detector` | Detection of the same action repeated with the same result. |20 | `detector` | Detection of the same action repeated with the same result. |
18 | `compact` | Compression of the history into a model-written summary. |21 | `compact` | Compression of the history into a model-written summary. |
19 | `spinner` | The "still working" indicator on a terminal. |22 | `spinner` | The "still working" indicator on a terminal. |
20-| `ui` | Where human-facing output goes, and the event sink the two front ends share. |23+| `ui` | Where human-facing output goes, and the event sink the front ends share. |
21 | `agent` | The terminal REPL. |24 | `agent` | The terminal REPL. |
22 | `acp` | The Agent Client Protocol façade. |25 | `acp` | The Agent Client Protocol façade. |
26+| `tui` | The single-window front end (`mm -tui`): a `ui.Sink` drawing on tcell, one transcript value, an event loop woken by the engine's and tools' goroutines. |
23 27
24 A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).28 A draw.io diagram of these packages and their dependencies is kept at [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25 29
@@ -31,7 +35,11 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia
31 35
32 **Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.36 **Output is shown before the model sees it.** In the terminal, `bash` echoes the first lines of a command's output before returning it. "Show me that file" means show it; leaving it to the model to summarise was observed to leave the user with nothing.
33 37
34-**One loop, two front ends.** The terminal REPL and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests.38+**One loop, three front ends.** The terminal REPL, the window and the ACP façade receive exactly the same engine, system prompt and tool list. What differs is where events go. In the terminal, the tools print their own `🛠️` lines and nobody asks permission. In ACP mode, stdout belongs to JSON-RPC, so `main.go` redirects the human output to stderr, disables the spinner, and the façade installs a `ui.Sink` for each turn. The tools check for an active sink: with one, they emit `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` events instead of printing. The `ui` package knows nothing about the protocol; `acp` is the only place that translates those events into `session/update` notifications and permission requests. The window mode is the same trick a second time: `tui` installs its own `ui.Sink` for each turn, and the events become entries of a transcript drawn on the screen — so the tools needed no change to gain a third home, and the inline permission prompt of `permissions: ask` is the terminal's version of the editor's dialog.
39+
40+**One command table.** The slash commands live in `internal/command`, marked with the front ends that offer them. The REPL and the window read the same table; the ACP façade advertises only the Editor slice (`/new`, `/compact`) through `available_commands_update`, because the editor owns the process and cancels turns itself. A command added in one place is therefore offered — or deliberately not — everywhere at once.
41+
42+**The window draws from a value, on one goroutine.** The engine streams chunks and the tools emit events from their own goroutines; the window records them into a `Transcript` (a mutex-guarded value) and posts a wake-up event. Only the event loop draws, and it redraws from the transcript's current state every frame, so a dropped wake-up costs a late frame and never a lost one. This is the rule turbo-core's agent window follows, and the reason the window needs no widget library: a painter, a transcript, a layout function and a key map are enough.
35 43
36 **Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.44 **Permission lives in the sink, not in the tools.** A permission dialog is the main thing an editor adds and the terminal does not have. Putting the policy in the ACP sink keeps the tools identical in both modes; "allow always" is remembered per tool name for the session.
37 45
added docs/en/explanation/persistent-sessions.md +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+# Persistent sessions — explanation
2+
3+## What is this about?
4+
5+A session is one conversation: the system prompt, the questions, the answers and the tool calls in between. Until 2026-09-16 it lived in memory and died with the process; an editor that restarted `mm` could only open a new thread, which is what Zed's "Loading or resuming sessions is not supported by this agent" said. Persistent sessions (`sessions.enabled`) write each conversation to a file and let both front ends get it back.
6+
7+## Why files, per project
8+
9+Sessions live under the project, in `.mm/sessions/`, one JSON file each, rewritten whole after every turn. Three reasons. A conversation is about a project — its files, its commands — and belongs next to them, not in a home-wide database where projects mix. Files are inspectable: `jq` answers "what did the model read?" without a tool. And a whole-file rewrite is what compression needs: `/compact` replaces old turns by a summary, and the file must say the same as the memory, not the memory plus everything it used to hold.
10+
11+The id is the creation time plus a random tail (`20260916-101530-4f2a9c1e`), so a listing sorts by time and two front ends never collide.
12+
13+## Why a start is a new session
14+
15+When `mm` starts, it starts a new session and says how to get the old ones back, rather than resuming the latest on its own. This mirrors docker agent (cagent): its `run` command never resumes by itself — `--session -1` or the TUI's `/sessions` browser do — and it is the safer default. The last conversation may be about something else; resuming it silently would hand the model a stale context, and the user would only notice when the answers went wrong. An explicit `/resume` costs one line and keeps the surprise out.
16+
17+The terminal writes the file on the first question, not at start: a launch that asked nothing leaves no trace, and `/sessions` lists conversations, not launches.
18+
19+## Why `session/load` and a full replay
20+
21+The Agent Client Protocol offers two ways back into a session. `session/load` is in the stable spec: the agent replays the conversation as `session/update` notifications and the editor rebuilds the thread from them. `session/list` and `session/resume` are unstable extensions: the agent only re-registers the session, and the editor keeps whatever it was showing. docker agent chose the second (`loadSession: false`, `Resume` capability, SQLite store in `~/.cagent/session.db`). mini-me chose the first, because it is the stable one and because a replay puts the thread back even in an editor that kept nothing.
22+
23+"Replay" means re-sending records. Each tool call comes back as a single, already-completed `tool_call` carrying the input the model gave and the output the tool returned at the time. Nothing is executed again: reloading a thread that once ran `rm` runs nothing.
24+
25+A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.
26+
27+## `/new` in the two front ends
28+
29+In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
30+
31+## Rejected alternatives
32+
33+- **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.
34+- **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds.
35+- **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
36+- **`session/resume` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.
37+- **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
38+
39+## How it relates to the rest
40+
41+`internal/store` is the file layer, used by `internal/agent` (a `keeper` that saves after each turn and serves `/sessions` and `/resume`) and by `internal/acp` (a file per session, `session/load`, the replay in `replay.go`). `internal/session.WithSystem` swaps the stored prompt for the current one. Compression (`internal/compact`) is unaware of persistence: the front ends save after it runs.
new file mode 100644
@@ -0,0 +1,41 @@
1+# Persistent sessions — explanation
2+
3+## What is this about?
4+
5+A session is one conversation: the system prompt, the questions, the answers and the tool calls in between. Until 2026-09-16 it lived in memory and died with the process; an editor that restarted `mm` could only open a new thread, which is what Zed's "Loading or resuming sessions is not supported by this agent" said. Persistent sessions (`sessions.enabled`) write each conversation to a file and let both front ends get it back.
6+
7+## Why files, per project
8+
9+Sessions live under the project, in `.mm/sessions/`, one JSON file each, rewritten whole after every turn. Three reasons. A conversation is about a project — its files, its commands — and belongs next to them, not in a home-wide database where projects mix. Files are inspectable: `jq` answers "what did the model read?" without a tool. And a whole-file rewrite is what compression needs: `/compact` replaces old turns by a summary, and the file must say the same as the memory, not the memory plus everything it used to hold.
10+
11+The id is the creation time plus a random tail (`20260916-101530-4f2a9c1e`), so a listing sorts by time and two front ends never collide.
12+
13+## Why a start is a new session
14+
15+When `mm` starts, it starts a new session and says how to get the old ones back, rather than resuming the latest on its own. This mirrors docker agent (cagent): its `run` command never resumes by itself — `--session -1` or the TUI's `/sessions` browser do — and it is the safer default. The last conversation may be about something else; resuming it silently would hand the model a stale context, and the user would only notice when the answers went wrong. An explicit `/resume` costs one line and keeps the surprise out.
16+
17+The terminal writes the file on the first question, not at start: a launch that asked nothing leaves no trace, and `/sessions` lists conversations, not launches.
18+
19+## Why `session/load` and a full replay
20+
21+The Agent Client Protocol offers two ways back into a session. `session/load` is in the stable spec: the agent replays the conversation as `session/update` notifications and the editor rebuilds the thread from them. `session/list` and `session/resume` are unstable extensions: the agent only re-registers the session, and the editor keeps whatever it was showing. docker agent chose the second (`loadSession: false`, `Resume` capability, SQLite store in `~/.cagent/session.db`). mini-me chose the first, because it is the stable one and because a replay puts the thread back even in an editor that kept nothing.
22+
23+"Replay" means re-sending records. Each tool call comes back as a single, already-completed `tool_call` carrying the input the model gave and the output the tool returned at the time. Nothing is executed again: reloading a thread that once ran `rm` runs nothing.
24+
25+A reloaded or resumed session continues under the current system prompt, not the one stored with it: the prompt is the agent's, and a change to `agent.yaml` must reach old conversations too.
26+
27+## `/new` in the two front ends
28+
29+In the terminal `/new` simply leaves the old file alone and lets the next question open a new one. Over ACP the editor owns the session id and keeps using it, so the id's file must become the fresh session; the conversation so far is moved to a new id first. Either way nothing is lost on disk, and a later reload of the thread gives the current conversation, not the one `/new` closed.
30+
31+## Rejected alternatives
32+
33+- **Resume the latest session automatically at start.** Rejected for the stale-context reason above, and because docker agent does not do it either.
34+- **Append-only log per session.** Rejected: compression shrinks the history, and a log would keep saying what the memory no longer holds.
35+- **SQLite, like docker agent.** Rejected for a minimal agent: one more dependency, opaque files, and a per-user database where mini-me wants per-project files.
36+- **`session/resume` (unstable) instead of `session/load`.** Rejected: not in the stable spec, and it relies on the editor having kept the thread.
37+- **Replaying only text messages.** Rejected by the user: the tool calls are the part of a coding session worth seeing again.
38+
39+## How it relates to the rest
40+
41+`internal/store` is the file layer, used by `internal/agent` (a `keeper` that saves after each turn and serves `/sessions` and `/resume`) and by `internal/acp` (a file per session, `session/load`, the replay in `replay.go`). `internal/session.WithSystem` swaps the stored prompt for the current one. Compression (`internal/compact`) is unaware of persistence: the front ends save after it runs.
added docs/en/how-to/keep-sessions.md +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+# How to keep sessions and resume them
2+
3+This guide shows how to make `mm` save every conversation of a project and get one back later, in the terminal and from an editor. It assumes `mm` runs with a configuration file you can edit.
4+
5+## Steps
6+
7+1. Turn the feature on in the YAML file:
8+
9+ ```yaml
10+ sessions:
11+ enabled: true
12+ dir: .mm/sessions
13+ ```
14+
15+ `dir` is relative to the project: the directory you start `mm` from in the terminal, the project the editor opened in ACP mode. Add `.mm/` to the project's `.gitignore`: a session file holds every command output the model read.
16+
17+2. Start `mm` from the project directory. The banner now ends with a `sessions:` line:
18+
19+ ```text
20+ sessions: saved under /work/project/.mm/sessions · 2 earlier — /resume to continue the latest, /sessions to list
21+ ```
22+
23+ This run is a new session. Its file appears with the first question and is rewritten after every turn, after `/compact` and after `/new`.
24+
25+3. List the project's sessions when you need one back:
26+
27+ ```text
28+ > /sessions
29+ 3 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
30+ * 20260916-101530-4f2a9c1e 2026-09-16 10:15 2 turn(s) add a /new command
31+ 20260916-093012-77be01d3 2026-09-16 09:41 5 turn(s) fix the skills loader
32+ 20260915-181200-0a1b2c3d 2026-09-15 18:20 1 turn(s) list the files
33+ ```
34+
35+ The `*` marks the current session. Ids sort by creation time.
36+
37+4. Continue one:
38+
39+ ```text
40+ > /resume
41+ ↩️ resumed 20260916-093012-77be01d3 — 14 message(s), started with "fix the skills loader"
42+ ```
43+
44+ A bare `/resume` takes the most recently updated conversation other than the current one, skipping empty ones; `/resume <id>` names one. From there, the model sees the resumed history — under the current system prompt — and every new turn is saved to that session's file. Nothing is replayed on screen: the one line says what came back.
45+
46+## From an editor (ACP)
47+
48+1. Keep `sessions.enabled: true` in the file `AGENT_CONFIG` points at. Each thread the editor opens is a session, saved under `<project>/.mm/sessions/<id>.json` from its creation.
49+
50+2. Use the editor's **Reload Agent** (Zed) when the agent restarted or you changed its configuration. The editor sends `session/load` with the thread's id; `mm` reads the file back and replays the whole conversation — your questions, the answers, every tool call with the input it was given and the output it returned at the time. Nothing runs again. The thread then continues as before.
51+
52+3. `/new` in a thread keeps the thread (the editor's session id) but the conversation so far is kept on disk under a new id, and the thread's own file starts over empty. `/sessions` and `/resume` do not exist over ACP: the editor is the one listing threads.
53+
54+## Variants
55+
56+- **One store for every project.** Give `dir` an absolute path. Sessions of different projects then share one directory; each file records its `cwd`.
57+- **A project you cannot write to.** The banner or the `[acp]` log says `not saved` with the reason; the conversation goes on unpersisted.
58+- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did.
59+- **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload.
60+
61+## See also
62+
63+- [Configuration reference](../reference/configuration.md) — the `sessions` keys
64+- [Slash commands reference](../reference/slash-commands.md) — `/sessions`, `/resume`, `/new`
65+- [ACP reference](../reference/acp.md) — `session/load` and what it replays
66+- [Why sessions work this way](../explanation/persistent-sessions.md)
new file mode 100644
@@ -0,0 +1,66 @@
1+# How to keep sessions and resume them
2+
3+This guide shows how to make `mm` save every conversation of a project and get one back later, in the terminal and from an editor. It assumes `mm` runs with a configuration file you can edit.
4+
5+## Steps
6+
7+1. Turn the feature on in the YAML file:
8+
9+ ```yaml
10+ sessions:
11+ enabled: true
12+ dir: .mm/sessions
13+ ```
14+
15+ `dir` is relative to the project: the directory you start `mm` from in the terminal, the project the editor opened in ACP mode. Add `.mm/` to the project's `.gitignore`: a session file holds every command output the model read.
16+
17+2. Start `mm` from the project directory. The banner now ends with a `sessions:` line:
18+
19+ ```text
20+ sessions: saved under /work/project/.mm/sessions · 2 earlier — /resume to continue the latest, /sessions to list
21+ ```
22+
23+ This run is a new session. Its file appears with the first question and is rewritten after every turn, after `/compact` and after `/new`.
24+
25+3. List the project's sessions when you need one back:
26+
27+ ```text
28+ > /sessions
29+ 3 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
30+ * 20260916-101530-4f2a9c1e 2026-09-16 10:15 2 turn(s) add a /new command
31+ 20260916-093012-77be01d3 2026-09-16 09:41 5 turn(s) fix the skills loader
32+ 20260915-181200-0a1b2c3d 2026-09-15 18:20 1 turn(s) list the files
33+ ```
34+
35+ The `*` marks the current session. Ids sort by creation time.
36+
37+4. Continue one:
38+
39+ ```text
40+ > /resume
41+ ↩️ resumed 20260916-093012-77be01d3 — 14 message(s), started with "fix the skills loader"
42+ ```
43+
44+ A bare `/resume` takes the most recently updated conversation other than the current one, skipping empty ones; `/resume <id>` names one. From there, the model sees the resumed history — under the current system prompt — and every new turn is saved to that session's file. Nothing is replayed on screen: the one line says what came back.
45+
46+## From an editor (ACP)
47+
48+1. Keep `sessions.enabled: true` in the file `AGENT_CONFIG` points at. Each thread the editor opens is a session, saved under `<project>/.mm/sessions/<id>.json` from its creation.
49+
50+2. Use the editor's **Reload Agent** (Zed) when the agent restarted or you changed its configuration. The editor sends `session/load` with the thread's id; `mm` reads the file back and replays the whole conversation — your questions, the answers, every tool call with the input it was given and the output it returned at the time. Nothing runs again. The thread then continues as before.
51+
52+3. `/new` in a thread keeps the thread (the editor's session id) but the conversation so far is kept on disk under a new id, and the thread's own file starts over empty. `/sessions` and `/resume` do not exist over ACP: the editor is the one listing threads.
53+
54+## Variants
55+
56+- **One store for every project.** Give `dir` an absolute path. Sessions of different projects then share one directory; each file records its `cwd`.
57+- **A project you cannot write to.** The banner or the `[acp]` log says `not saved` with the reason; the conversation goes on unpersisted.
58+- **Compression.** A `/compact` or an automatic compression rewrites the file with the shortened history; a resumed or reloaded session starts from the summary, like the live one did.
59+- **Starting fresh on purpose.** Nothing to do: every start is a new session. The old ones only come back on `/resume` or on an editor reload.
60+
61+## See also
62+
63+- [Configuration reference](../reference/configuration.md) — the `sessions` keys
64+- [Slash commands reference](../reference/slash-commands.md) — `/sessions`, `/resume`, `/new`
65+- [ACP reference](../reference/acp.md) — `session/load` and what it replays
66+- [Why sessions work this way](../explanation/persistent-sessions.md)
modified docs/en/how-to/start-a-new-session.md +4 -0
@@ -25,6 +25,10 @@ This guide shows how to wipe the conversation and begin again from a clean histo
2525
2626 3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone.
2727
28+## With persistent sessions on
29+
30+With `sessions.enabled: true`, the terminal leaves the closed conversation's file where it is and opens a new file at your next question; `/sessions` lists both and `/resume <id>` brings the old one back. From an editor, the thread keeps its id, so the conversation so far is saved under a new id and the thread's own file restarts empty. See [Keep sessions and resume them](keep-sessions.md).
31+
2832 ## What `/new` resets, and what it keeps
2933
3034 | Reset | Kept |
@@ -25,6 +25,10 @@ This guide shows how to wipe the conversation and begin again from a clean histo
25 25
26 3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone.26 3. Keep working in the same thread. The session keeps its id, its working directory and the tools you already allowed "for this session"; only the history is gone.
27 27
28+## With persistent sessions on
29+
30+With `sessions.enabled: true`, the terminal leaves the closed conversation's file where it is and opens a new file at your next question; `/sessions` lists both and `/resume <id>` brings the old one back. From an editor, the thread keeps its id, so the conversation so far is saved under a new id and the thread's own file restarts empty. See [Keep sessions and resume them](keep-sessions.md).
31+
28 ## What `/new` resets, and what it keeps32 ## What `/new` resets, and what it keeps
29 33
30 | Reset | Kept |34 | Reset | Kept |
modified docs/en/how-to/use-from-an-editor.md +1 -0
@@ -31,6 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
3131 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
3232 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
3333 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34+- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread. See [Keep sessions and resume them](keep-sessions.md).
3435 - **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line.
3536
3637 ## See also
@@ -31,6 +31,7 @@ This guide shows how to host `mm` inside an editor that speaks the Agent Client
31 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.31 - **Cancel a running turn.** Use the editor's stop key (Escape in Zed). The agent answers the prompt with stop reason `cancelled` and keeps the commands that already ran in the history. There is no `/abort` over ACP.
32 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).32 - **Compress or restart the conversation.** Send `/compact` or `/new`; editors that list the agent's commands offer both in the slash-command menu. See the [slash commands reference](../reference/slash-commands.md#acp-mode).
33 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.33 - **Attach a file with `@`.** The editor's picker sends a `resource_link`, passed to the model as `[attached file: <path>]`; a path typed by hand as `@path` is understood the same way (see [`@path`](attach-a-file.md)). The model reads the file with its tools.
34+- **Reload the agent without losing the thread.** With `sessions.enabled: true` in the config file, the editor's Reload Agent (Zed) calls `session/load` and the whole conversation, tool calls included, is replayed into the thread. See [Keep sessions and resume them](keep-sessions.md).
34 - **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line.35 - **Run from a terminal to debug the protocol.** `mm -acp` reads JSON-RPC on stdin and writes it on stdout, one message per line.
35 36
36 ## See also37 ## See also
added docs/en/how-to/use-the-window-mode.md +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+# How to use the window mode (`mm -tui`)
2+
3+This guide shows how to run mini-me as a single full-screen window — the conversation above, a box to type in below — instead of the line-by-line REPL. It assumes you already run `mm` from a project directory.
4+
5+## Steps
6+
7+1. Start the window from the project directory:
8+
9+ ```bash
10+ mm -tui agent.dmr.yaml
11+ ```
12+
13+ Or set `tui: true` in the YAML and start `mm` as usual. The banner that the line mode prints is shown as the first line of the conversation.
14+
15+2. Type your question in the box at the bottom and press **Enter**. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below).
16+
17+3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.
18+
19+4. Press **Esc** to stop a running turn. Typing a new question and pressing Enter while a turn runs also stops it; the new question runs once the old one has settled.
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.
22+
23+6. Leave with `/quit`, or **Ctrl-C** when nothing is running.
24+
25+## Variants
26+
27+- **Ask before every `bash` and file tool.** Set `permissions: ask` in the YAML. The box is replaced by the question `? bash: rm -rf build` and three keys answer it: `a` allow once, `s` allow this tool for the session, `r` (or Esc) reject. A rejected tool tells the model `[command rejected by the user]`. The default, `auto`, runs tools as the line mode always has.
28+- **Point the model at a file.** `@path` works exactly as in the line mode: existing paths are attached and echoed as `📎` lines. See [Point the model at a file with `@path`](attach-a-file.md).
29+- **Keep and resume sessions.** With `sessions.enabled: true`, the header shows the session id being saved; `/sessions` and `/resume` work as in the line mode. See [Keep sessions and resume them](keep-sessions.md).
30+- **Scroll the conversation.** PgUp/PgDn page; **Tab** moves the focus to the conversation so ↑/↓, Home and End scroll it; End resumes following the end. **Ctrl-L** clears the displayed conversation without touching the history. The mouse wheel scrolls only after **F2** (see below).
31+- **Not a terminal.** `mm -tui | tee log.txt`, or any run where stdin or stdout is not a terminal, prints `[tui: stdin or stdout is not a terminal — running the line mode]` and runs the line mode. Nothing else changes.
32+
33+## Copy text out of the window
34+
35+The window leaves the mouse to the terminal: click and drag selects text exactly as in any terminal, and your terminal's copy shortcut copies it. The status bar says `F2 mouse: off`. Press **F2** to hand the mouse to the window instead — the wheel then scrolls the conversation and the status bar says `mouse: on (Shift+drag selects)`; in most terminals, holding Shift while dragging still selects. F2 again gives the mouse back.
36+
37+To copy an answer without selecting it:
38+
39+| Input | Copies |
40+|-------|--------|
41+| `/copy` or **Ctrl-Y** | The last answer, as raw text: no `🤖` prefix, no wrapping, no tool frames. |
42+| `/copy 2` | The answer before the last one (`3` the one before that, and so on). |
43+| `/copy all` | The whole conversation as plain text: questions prefixed with `> `, answers raw, tool calls as one heading line plus their output. |
44+
45+The confirmation names the road taken, `📋 copied 1 240 chars (pbcopy)`, because two exist and they are not equally reliable:
46+
47+1. A native tool found on `PATH`: `pbcopy` on macOS, `wl-copy` on Wayland, `xclip` or `xsel` on X11. This is the reliable path on a Mac.
48+2. Otherwise **OSC 52**, the escape sequence some terminals honour to set the clipboard. It works over SSH and in iTerm2 (allow it under *Preferences → General → Selection*), Ghostty, kitty and WezTerm; **macOS Terminal.app ignores it silently**. With no tool and no terminal, `/copy` says `⚠️ no clipboard: …`.
49+
50+The line mode has `/copy` and `/copy 2` too, through the same roads; `/copy all` is window-only, since the terminal's scrollback is already selectable there.
51+
52+## Paste long text
53+
54+A pasted block is never sent by itself: its line breaks become new lines in the box. When the block is longer than the box — **more than 3 lines, or more than 200 characters** — the box shows a single token instead of the text:
55+
56+```
57+> explain this stack trace [Pasted #1 · 42 lines · 1.2k chars] and the fix in [Pasted #2 · 5 lines · 310 chars]
58+```
59+
60+The token behaves as one character: ←/→ step over it, **Backspace** or **Delete** removes it whole together with the pasted text, and you keep typing around it. Several pastes in one message are numbered in the order you pasted them.
61+
62+On **Enter**, the model receives your sentence with each token replaced by the full pasted text, byte for byte, line breaks included. Nothing is shortened or summarised. The conversation, on the other hand, keeps showing the token, so a long paste does not push the exchange off the screen; the persisted session and `/copy all` carry the full question.
63+
64+Two details follow from "a paste is data you brought, not something you typed":
65+
66+- `@path` mentions **inside** a pasted block are not looked up; only the ones you typed yourself attach a file (see [`@path`](attach-a-file.md)).
67+- A `/command` inside a paste is text for the model, not a command.
68+
69+Pastes shorter than the threshold are inserted as text, exactly as before. Bracketed paste must be supported by the terminal for any of this to work: Terminal.app, iTerm2, Ghostty and kitty support it; inside **tmux**, a paste is passed through as bracketed only when the outer terminal and tmux both support it (tmux does since 1.8; `set -g` needs no option for pass-through, but a `paste-buffer` without `-p` sends the text as plain keys, which then arrive as typed characters and are inserted one by one).
70+
71+## See also
72+
73+- [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics
74+- [Slash commands](../reference/slash-commands.md) — the keys of the window
75+- [Configuration file](../reference/configuration.md) — `tui` and `permissions`
76+- [Architecture: one loop, three front ends](../explanation/architecture.md) — why the window is a third front end on the same sink
new file mode 100644
@@ -0,0 +1,76 @@
1+# How to use the window mode (`mm -tui`)
2+
3+This guide shows how to run mini-me as a single full-screen window — the conversation above, a box to type in below — instead of the line-by-line REPL. It assumes you already run `mm` from a project directory.
4+
5+## Steps
6+
7+1. Start the window from the project directory:
8+
9+ ```bash
10+ mm -tui agent.dmr.yaml
11+ ```
12+
13+ Or set `tui: true` in the YAML and start `mm` as usual. The banner that the line mode prints is shown as the first line of the conversation.
14+
15+2. Type your question in the box at the bottom and press **Enter**. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below).
16+
17+3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.
18+
19+4. Press **Esc** to stop a running turn. Typing a new question and pressing Enter while a turn runs also stops it; the new question runs once the old one has settled.
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.
22+
23+6. Leave with `/quit`, or **Ctrl-C** when nothing is running.
24+
25+## Variants
26+
27+- **Ask before every `bash` and file tool.** Set `permissions: ask` in the YAML. The box is replaced by the question `? bash: rm -rf build` and three keys answer it: `a` allow once, `s` allow this tool for the session, `r` (or Esc) reject. A rejected tool tells the model `[command rejected by the user]`. The default, `auto`, runs tools as the line mode always has.
28+- **Point the model at a file.** `@path` works exactly as in the line mode: existing paths are attached and echoed as `📎` lines. See [Point the model at a file with `@path`](attach-a-file.md).
29+- **Keep and resume sessions.** With `sessions.enabled: true`, the header shows the session id being saved; `/sessions` and `/resume` work as in the line mode. See [Keep sessions and resume them](keep-sessions.md).
30+- **Scroll the conversation.** PgUp/PgDn page; **Tab** moves the focus to the conversation so ↑/↓, Home and End scroll it; End resumes following the end. **Ctrl-L** clears the displayed conversation without touching the history. The mouse wheel scrolls only after **F2** (see below).
31+- **Not a terminal.** `mm -tui | tee log.txt`, or any run where stdin or stdout is not a terminal, prints `[tui: stdin or stdout is not a terminal — running the line mode]` and runs the line mode. Nothing else changes.
32+
33+## Copy text out of the window
34+
35+The window leaves the mouse to the terminal: click and drag selects text exactly as in any terminal, and your terminal's copy shortcut copies it. The status bar says `F2 mouse: off`. Press **F2** to hand the mouse to the window instead — the wheel then scrolls the conversation and the status bar says `mouse: on (Shift+drag selects)`; in most terminals, holding Shift while dragging still selects. F2 again gives the mouse back.
36+
37+To copy an answer without selecting it:
38+
39+| Input | Copies |
40+|-------|--------|
41+| `/copy` or **Ctrl-Y** | The last answer, as raw text: no `🤖` prefix, no wrapping, no tool frames. |
42+| `/copy 2` | The answer before the last one (`3` the one before that, and so on). |
43+| `/copy all` | The whole conversation as plain text: questions prefixed with `> `, answers raw, tool calls as one heading line plus their output. |
44+
45+The confirmation names the road taken, `📋 copied 1 240 chars (pbcopy)`, because two exist and they are not equally reliable:
46+
47+1. A native tool found on `PATH`: `pbcopy` on macOS, `wl-copy` on Wayland, `xclip` or `xsel` on X11. This is the reliable path on a Mac.
48+2. Otherwise **OSC 52**, the escape sequence some terminals honour to set the clipboard. It works over SSH and in iTerm2 (allow it under *Preferences → General → Selection*), Ghostty, kitty and WezTerm; **macOS Terminal.app ignores it silently**. With no tool and no terminal, `/copy` says `⚠️ no clipboard: …`.
49+
50+The line mode has `/copy` and `/copy 2` too, through the same roads; `/copy all` is window-only, since the terminal's scrollback is already selectable there.
51+
52+## Paste long text
53+
54+A pasted block is never sent by itself: its line breaks become new lines in the box. When the block is longer than the box — **more than 3 lines, or more than 200 characters** — the box shows a single token instead of the text:
55+
56+```
57+> explain this stack trace [Pasted #1 · 42 lines · 1.2k chars] and the fix in [Pasted #2 · 5 lines · 310 chars]
58+```
59+
60+The token behaves as one character: ←/→ step over it, **Backspace** or **Delete** removes it whole together with the pasted text, and you keep typing around it. Several pastes in one message are numbered in the order you pasted them.
61+
62+On **Enter**, the model receives your sentence with each token replaced by the full pasted text, byte for byte, line breaks included. Nothing is shortened or summarised. The conversation, on the other hand, keeps showing the token, so a long paste does not push the exchange off the screen; the persisted session and `/copy all` carry the full question.
63+
64+Two details follow from "a paste is data you brought, not something you typed":
65+
66+- `@path` mentions **inside** a pasted block are not looked up; only the ones you typed yourself attach a file (see [`@path`](attach-a-file.md)).
67+- A `/command` inside a paste is text for the model, not a command.
68+
69+Pastes shorter than the threshold are inserted as text, exactly as before. Bracketed paste must be supported by the terminal for any of this to work: Terminal.app, iTerm2, Ghostty and kitty support it; inside **tmux**, a paste is passed through as bracketed only when the outer terminal and tmux both support it (tmux does since 1.8; `set -g` needs no option for pass-through, but a `paste-buffer` without `-p` sends the text as plain keys, which then arrive as typed characters and are inserted one by one).
70+
71+## See also
72+
73+- [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics
74+- [Slash commands](../reference/slash-commands.md) — the keys of the window
75+- [Configuration file](../reference/configuration.md) — `tui` and `permissions`
76+- [Architecture: one loop, three front ends](../explanation/architecture.md) — why the window is a third front end on the same sink
modified docs/en/reference/acp.md +19 -4
@@ -16,7 +16,7 @@
1616 | Field | Value |
1717 |-------|-------|
1818 | `protocolVersion` | `1` |
19-| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. |
19+| `agentCapabilities` | `loadSession: true` when `sessions.enabled` is set, absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |
2020 | `agentInfo.name` | `bob` |
2121 | `agentInfo.title` | `Bob (bash-first agent)` |
2222 | `agentInfo.version` | `0.11.0` |
@@ -28,11 +28,26 @@
2828 |--------|-----------|
2929 | `initialize` | Logs the client name and protocol version on stderr; returns the values above. |
3030 | `authenticate` | Returns an empty response. |
31-| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). |
31+| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. |
3232 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
3333 | `session/cancel` | Cancels the turn in flight for that session. |
34+| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
3435 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
3536
37+## `session/load` replay
38+
39+Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent.
40+
41+| Stored element | Notification |
42+|----------------|--------------|
43+| User message | `user_message_chunk` with its text. |
44+| Compression summary (user-role message marked by `/compact`) | `agent_message_chunk` with `🗜️ ` followed by the summary. |
45+| Model text | `agent_message_chunk`. |
46+| Tool call with its recorded response | `tool_call` with `toolCallId` = the call's ref, `title` and `kind` as the live tool would give (`bash: <command>` execute, `read_file: <path>` read, `write_file`/`edit_file` edit, `read_skill: <name>` read), `status: completed`, `rawInput`, `rawOutput.output` and a `text` content holding the output. |
47+| Tool call without a recorded response | `tool_call` with `status: failed` and no content. |
48+
49+No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N message(s) replayed`.
50+
3651 ## `session/prompt`
3752
3853 | Aspect | Behaviour |
@@ -40,7 +55,7 @@
4055 | Unknown `sessionId` | `invalid params` error carrying the `sessionId`. |
4156 | Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. |
4257 | Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. |
43-| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
58+| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
4459 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
4560 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
4661 | Success | `stopReason: end_turn`. |
@@ -52,7 +67,7 @@
5267
5368 | Update | When |
5469 |--------|------|
55-| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. |
70+| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. |
5671 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
5772 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
5873 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
@@ -16,7 +16,7 @@
16 | Field | Value |16 | Field | Value |
17 |-------|-------|17 |-------|-------|
18 | `protocolVersion` | `1` |18 | `protocolVersion` | `1` |
19-| `agentCapabilities` | All absent (false): no `loadSession`, no image or audio prompts, no HTTP MCP. |19+| `agentCapabilities` | `loadSession: true` when `sessions.enabled` is set, absent (false) otherwise. Everything else absent (false): no image or audio prompts, no HTTP MCP. |
20 | `agentInfo.name` | `bob` |20 | `agentInfo.name` | `bob` |
21 | `agentInfo.title` | `Bob (bash-first agent)` |21 | `agentInfo.title` | `Bob (bash-first agent)` |
22 | `agentInfo.version` | `0.11.0` |22 | `agentInfo.version` | `0.11.0` |
@@ -28,11 +28,26 @@
28 |--------|-----------|28 |--------|-----------|
29 | `initialize` | Logs the client name and protocol version on stderr; returns the values above. |29 | `initialize` | Logs the client name and protocol version on stderr; returns the values above. |
30 | `authenticate` | Returns an empty response. |30 | `authenticate` | Returns an empty response. |
31-| `session/new` | Creates a session `bob-<pid>-<n>` with the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). |31+| `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. |
32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33 | `session/cancel` | Cancels the turn in flight for that session. |33 | `session/cancel` | Cancels the turn in flight for that session. |
34+| `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
34 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35 36
37+## `session/load` replay
38+
39+Sent before the response, one notification per element of the stored history, in order. The system prompt is not sent.
40+
41+| Stored element | Notification |
42+|----------------|--------------|
43+| User message | `user_message_chunk` with its text. |
44+| Compression summary (user-role message marked by `/compact`) | `agent_message_chunk` with `🗜️ ` followed by the summary. |
45+| Model text | `agent_message_chunk`. |
46+| Tool call with its recorded response | `tool_call` with `toolCallId` = the call's ref, `title` and `kind` as the live tool would give (`bash: <command>` execute, `read_file: <path>` read, `write_file`/`edit_file` edit, `read_skill: <name>` read), `status: completed`, `rawInput`, `rawOutput.output` and a `text` content holding the output. |
47+| Tool call without a recorded response | `tool_call` with `status: failed` and no content. |
48+
49+No tool runs during a replay. Logged as `[acp] session <id> loaded from <dir>: N message(s) replayed`.
50+
36 ## `session/prompt`51 ## `session/prompt`
37 52
38 | Aspect | Behaviour |53 | Aspect | Behaviour |
@@ -40,7 +55,7 @@
40 | Unknown `sessionId` | `invalid params` error carrying the `sessionId`. |55 | Unknown `sessionId` | `invalid params` error carrying the `sessionId`. |
41 | Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. |56 | Prompt content | `text` blocks concatenated; a `resource_link` becomes `\n[attached file: <path without file://>]`; other block types are ignored. Then every `@path` in the text that names an existing file or directory under the session's `cwd` (or an absolute / `~` path) appends `\n[attached file: <absolute path>]` or `\n[attached directory: …]`, once per path, and is logged as `[acp] session <id>: attached <path>`. |
42 | Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. |57 | Slash command | When the flattened text, trimmed, is exactly `/new` or `/compact`, no generation runs; the command answers with one `agent_message_chunk` and `stopReason: end_turn`. |
43-| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |58+| `/new` | The session's history is reset to the system prompt alone, the engine's last input-token count is forgotten, the chunk is `🆕 New session: N message(s) forgotten.`. The session id, `cwd` and allow-always grants are kept. With `sessions.enabled`, the conversation so far is saved under a new id first (logged `previous conversation kept as <id>`; nothing saved when it was empty) and the session's own file is rewritten with the fresh history. Logged as `[acp] session <id>: new session, N message(s) forgotten`. |
44 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |59 | `/compact` | The history is compressed with the `context` settings, forced (the threshold is ignored, `keepLastTurns` is honoured). The chunk is `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, or `🗜️ nothing to compact: N message(s), no turn older than the last K` (history unchanged), or `[compact: failed, history kept: <explanation>]` (history unchanged). The token count is forgotten only when the history changed. Waits for a running turn: turns are serialised. Logged as `[acp] session <id>: /compact — <line>`. |
45 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |60 | History | The full history returned by the engine is kept, even after a failure or a cancellation; a question with no answer at all is removed. |
46 | Success | `stopReason: end_turn`. |61 | Success | `stopReason: end_turn`. |
@@ -52,7 +67,7 @@
52 67
53 | Update | When |68 | Update | When |
54 |--------|------|69 |--------|------|
55-| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. |70+| `available_commands_update` | Once per session, right after the `session/new` response has been written (the editor must know the session id first, or it drops the update). Lists the slash commands `session/prompt` intercepts. Also sent at the end of a `session/load` replay. |
56 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |71 | `agent_message_chunk` (text) | Each streamed chunk of the model's answer, whitespace preserved; also the one-line confirmation of a slash command. |
57 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |72 | `tool_call` status `pending` | Before a tool runs; carries `title`, `kind`, `rawInput`, and `locations` with the absolute path when a file is involved. |
58 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |73 | `tool_call_update` status `in_progress` | Permission granted, tool executing. |
modified docs/en/reference/cli.md +18 -0
@@ -13,6 +13,7 @@ mm [-config <path>] [-acp] [<path>]
1313 | `-config <path>` | string | `""` | Path of the YAML configuration file. |
1414 | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. |
1515 | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. |
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. |
1617 | `-h` | — | — | Print the flag usage and exit. |
1718
1819 ## Configuration file resolution
@@ -46,6 +47,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
4647 | `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. |
4748 | `Agent ready. Commands: …` | Terminal mode only. |
4849 | `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. |
4951
5052 ### Example
5153
@@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
6365 | Spinner | Disabled. |
6466 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
6567
68+## Window mode specifics
69+
70+| Aspect | Behaviour |
71+|--------|-----------|
72+| Selection | `-tui` flag, or `tui: true` in the YAML; `-acp` wins over both. |
73+| Terminal check | Both stdin and stdout must be character devices. Otherwise `[tui: stdin or stdout is not a terminal — running the line mode]` is printed and the line mode runs, banner included. |
74+| 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. |
76+| 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`). |
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. |
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)]`. |
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. |
81+| Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
82+| Mouse | Not captured at start-up, so the terminal's click-and-drag selection works. `F2` captures it (wheel scrolling; `Shift`+drag selects) and releases it again. |
83+
6684 ## Exit codes
6785
6886 | Code | Cause |
@@ -13,6 +13,7 @@ mm [-config <path>] [-acp] [<path>]
13 | `-config <path>` | string | `""` | Path of the YAML configuration file. |13 | `-config <path>` | string | `""` | Path of the YAML configuration file. |
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 | `-h` | — | — | Print the flag usage and exit. |17 | `-h` | — | — | Print the flag usage and exit. |
17 18
18 ## Configuration file resolution19 ## Configuration file resolution
@@ -46,6 +47,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is
46 | `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. |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. |
47 | `Agent ready. Commands: …` | Terminal mode only. |48 | `Agent ready. Commands: …` | Terminal mode only. |
48 | `Shortcut: Ctrl+C …` | Terminal mode only. |49 | `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. |
49 51
50 ### Example52 ### Example
51 53
@@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
63 | Spinner | Disabled. |65 | Spinner | Disabled. |
64 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |66 | Relative `skillsDir` | Resolved relative to the directory of the configuration file, when a file was found; otherwise from the working directory, as in terminal mode. |
65 67
68+## Window mode specifics
69+
70+| Aspect | Behaviour |
71+|--------|-----------|
72+| Selection | `-tui` flag, or `tui: true` in the YAML; `-acp` wins over both. |
73+| Terminal check | Both stdin and stdout must be character devices. Otherwise `[tui: stdin or stdout is not a terminal — running the line mode]` is printed and the line mode runs, banner included. |
74+| 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. |
76+| 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`). |
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. |
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)]`. |
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. |
81+| Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). |
82+| Mouse | Not captured at start-up, so the terminal's click-and-drag selection works. `F2` captures it (wheel scrolling; `Shift`+drag selects) and releases it again. |
83+
66 ## Exit codes84 ## Exit codes
67 85
68 | Code | Cause |86 | Code | Cause |
modified docs/en/reference/configuration.md +12 -0
@@ -22,6 +22,9 @@
2222 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
2323 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
2424 | `context` | map | see below | Automatic history compression. |
25+| `sessions` | map | see below | Persistent, per-project sessions. |
26+| `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
27+| `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
2528
2629 ## `context` keys
2730
@@ -35,6 +38,13 @@
3538 | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |
3639 | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |
3740
41+## `sessions` keys
42+
43+| Key | Type | Default | Description |
44+|-----|------|---------|-------------|
45+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
46+| `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
47+
3848 ## Provider defaults
3949
4050 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
@@ -58,6 +68,8 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
5868 | `context.keepLastTurns must be >= 1` | zero or negative |
5969 | `context.summaryMaxTokens must be > 0` | zero or negative |
6070 | `context.maxMessages must be >= 0` | negative value |
71+| `sessions.dir must not be empty` | empty string |
72+| `permissions must be "auto" or "ask"` | any other value |
6173 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
6274
6375 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
@@ -22,6 +22,9 @@
22 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |22 | `sampling` | map | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Generation settings, OpenAI API key names, passed to the server. The shipped `agent.yaml` adds `parallel_tool_calls: false`. |
23 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |23 | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. |
24 | `context` | map | see below | Automatic history compression. |24 | `context` | map | see below | Automatic history compression. |
25+| `sessions` | map | see below | Persistent, per-project sessions. |
26+| `tui` | bool | `false` | Start in the single-window mode without the `-tui` flag. Falls back to the line mode when stdin or stdout is not a terminal; ignored with `-acp`. |
27+| `permissions` | string | `auto` | `ask`: in the window mode, `bash`, `write_file` and `edit_file` wait for the user's answer (`a`/`s`/`r`) before running. `auto`: tools run at once. The line mode and the ACP façade do not read it (ACP has its own dialog). Must be `auto` or `ask`. |
25 28
26 ## `context` keys29 ## `context` keys
27 30
@@ -35,6 +38,13 @@
35 | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |38 | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. |
36 | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |39 | `showStats` | bool | `true` | Print the one-line `🗜️` report after each compression. |
37 40
41+## `sessions` keys
42+
43+| Key | Type | Default | Description |
44+|-----|------|---------|-------------|
45+| `sessions.enabled` | bool | `false` | Save every conversation to a file, rewritten after each turn, `/compact` and `/new`. Enables `/sessions` and `/resume` in the terminal and `session/load` over ACP. |
46+| `sessions.dir` | string | `.mm/sessions` | Directory of the session files, one `<id>.json` each. Relative to the directory `mm` is started from (terminal) or to the session's `cwd` (ACP); an absolute path is used as-is. Must not be empty. |
47+
38 ## Provider defaults48 ## Provider defaults
39 49
40 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |50 | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required |
@@ -58,6 +68,8 @@ Reported as `[config error: <path>: <message>]`, exit code 1.
58 | `context.keepLastTurns must be >= 1` | zero or negative |68 | `context.keepLastTurns must be >= 1` | zero or negative |
59 | `context.summaryMaxTokens must be > 0` | zero or negative |69 | `context.summaryMaxTokens must be > 0` | zero or negative |
60 | `context.maxMessages must be >= 0` | negative value |70 | `context.maxMessages must be >= 0` | negative value |
71+| `sessions.dir must not be empty` | empty string |
72+| `permissions must be "auto" or "ask"` | any other value |
61 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |73 | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. |
62 74
63 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.75 A start-up warning, not an error, is printed when `context.enabled` is `true`, the window is unknown and `context.maxMessages` is `0`.
modified docs/en/reference/skill-format.md +1 -1
@@ -7,7 +7,7 @@
77 | Rule | Value |
88 |------|-------|
99 | Directory | `skillsDir` (default `skills`). |
10-| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. |
10+| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. A `README.md` at the root of the directory is documentation, not a skill: it is skipped. |
1111 | Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |
1212 | Both forms for one name | `read_skill` returns the flat file. |
1313 | Missing directory | Not an error: zero skills, `read_skill` not declared. |
@@ -7,7 +7,7 @@
7 | Rule | Value |7 | Rule | Value |
8 |------|-------|8 |------|-------|
9 | Directory | `skillsDir` (default `skills`). |9 | Directory | `skillsDir` (default `skills`). |
10-| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. |10+| Patterns | `<skillsDir>/<name>.md` (flat) and `<skillsDir>/<name>/SKILL.md` (one directory per skill), mixed freely. Other files in a skill's directory are ignored. A `README.md` at the root of the directory is documentation, not a skill: it is skipped. |
11 | Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |11 | Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. |
12 | Both forms for one name | `read_skill` returns the flat file. |12 | Both forms for one name | `read_skill` returns the flat file. |
13 | Missing directory | Not an error: zero skills, `read_skill` not declared. |13 | Missing directory | Not an error: zero skills, `read_skill` not declared. |
modified docs/en/reference/slash-commands.md +27 -1
@@ -10,8 +10,12 @@
1010 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
1111 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
1212 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13+| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
14+| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"`; resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
15+| `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
16+| `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
1317
14-An empty line at the prompt is ignored.
18+With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
1519
1620 ## `@path` in a question
1721
@@ -35,6 +39,28 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
3539 | `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
3640 | `Ctrl+D` (end of input) | Exits. | — |
3741
42+## Window mode keys
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:
45+
46+| Key | Action |
47+|-----|--------|
48+| `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. |
50+| `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. |
51+| `Ctrl-C` | Cancels the running turn; quits when idle. |
52+| `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 lines, and scroll the conversation from its first/last line. On the conversation: scroll one line. In the picker: move the choice. |
54+| `PgUp` `PgDn` | Scroll one page. |
55+| `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |
56+| `Ctrl-L` | Clears the displayed conversation; the history is kept. |
57+| `F1` | Same as `/help`. |
58+| `F2` | Toggles mouse capture. Off (default): the terminal's own click-and-drag selection works; the status bar says `F2 mouse: off`. On: the wheel scrolls, `Shift`+drag selects, the status bar says `mouse: on (Shift+drag selects)`. |
59+| `Ctrl-Y` | Same as `/copy`: the last answer to the clipboard. |
60+| `a` `s` `r` | Answer a pending permission: allow once, allow this tool for the session, reject. |
61+| 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. |
63+
3864 ## Messages printed at the end of a turn
3965
4066 | Message | Meaning |
@@ -10,8 +10,12 @@
10 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |10 | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. |
11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |11 | `/compact` | Compresses the history now, regardless of the threshold; prints the `🗜️` report, or `🗜️ nothing to compact` when no turn is older than `context.keepLastTurns`. | Sent to the model as a new message (see below). |
12 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |12 | `/new` | Clears the history back to the system prompt, resets the loop detector and forgets the server's last token count; prints `🆕 new session: N message(s) forgotten` (N counts every message except the system prompt). | Treated like any other text: cancels the generation, keeps its partial history, then runs as `/new` on that history. |
13+| `/sessions` | Lists the project's stored sessions, newest first: id, last update, question count, first question; `*` marks the current one. With `sessions.enabled: false`: `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Sent to the model as a new message. |
14+| `/resume [id]` | Continues a stored session: the most recently updated one other than the current, skipping empty ones, or the named id. Prints `↩️ resumed <id> — N message(s), started with "<first question>"`; resets the loop detector and the server's token count; later turns are saved to that session. Refused with a `⚠️` reason when sessions are off, when there is no earlier session, when the id is unknown or is the current session. | Sent to the model as a new message. |
15+| `/help` | Prints the commands of this front end, one per line with their description, aligned. | Sent to the model as a new message. |
16+| `/copy [n\|all]` | Copies the last answer (`n`: the n-th from the end) to the clipboard through `pbcopy`/`wl-copy`/`xclip`/`xsel` or, failing that, OSC 52 when stdout is a terminal, and prints `📋 copied N chars (<road>)` or `⚠️ …`. `/copy all` is window-mode only: here it says so. | Sent to the model as a new message. |
13 17
14-An empty line at the prompt is ignored.18+With `sessions.enabled`, `/new` leaves the current session's file as it is; the next question opens a new file. An empty line at the prompt is ignored.
15 19
16 ## `@path` in a question20 ## `@path` in a question
17 21
@@ -35,6 +39,28 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
35 | `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |39 | `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. |
36 | `Ctrl+D` (end of input) | Exits. | — |40 | `Ctrl+D` (end of input) | Exits. | — |
37 41
42+## Window mode keys
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:
45+
46+| Key | Action |
47+|-----|--------|
48+| `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. |
50+| `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. |
51+| `Ctrl-C` | Cancels the running turn; quits when idle. |
52+| `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 lines, and scroll the conversation from its first/last line. On the conversation: scroll one line. In the picker: move the choice. |
54+| `PgUp` `PgDn` | Scroll one page. |
55+| `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |
56+| `Ctrl-L` | Clears the displayed conversation; the history is kept. |
57+| `F1` | Same as `/help`. |
58+| `F2` | Toggles mouse capture. Off (default): the terminal's own click-and-drag selection works; the status bar says `F2 mouse: off`. On: the wheel scrolls, `Shift`+drag selects, the status bar says `mouse: on (Shift+drag selects)`. |
59+| `Ctrl-Y` | Same as `/copy`: the last answer to the clipboard. |
60+| `a` `s` `r` | Answer a pending permission: allow once, allow this tool for the session, reject. |
61+| 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. |
63+
38 ## Messages printed at the end of a turn64 ## Messages printed at the end of a turn
39 65
40 | Message | Meaning |66 | Message | Meaning |
modified docs/en/reference/tools.md +1 -0
@@ -19,6 +19,7 @@ Always declared.
1919 | Non-zero exit | Output ends with `[exit code: exit status N]`. |
2020 | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
2121 | ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |
22+| Window mode | One `‣ bash <command>` line with status, time and folded output. With `permissions: ask`, waits for `a`/`s`/`r`; a refusal returns the same text as in ACP. |
2223
2324 ## `read_skill`
2425
@@ -19,6 +19,7 @@ Always declared.
19 | Non-zero exit | Output ends with `[exit code: exit status N]`. |19 | 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)`. |20 | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. |
21 | ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |21 | ACP | Tool call of kind `execute`; permission requested before running; a refusal returns `[command rejected by the user]` with status `failed`. |
22+| Window mode | One `‣ bash <command>` line with status, time and folded output. With `permissions: ask`, waits for `a`/`s`/`r`; a refusal returns the same text as in ACP. |
22 23
23 ## `read_skill`24 ## `read_skill`
24 25
modified docs/en/tutorials/getting-started.md +1 -1
@@ -33,7 +33,7 @@ You should see a banner like this one (the model name is the one from `agent.yam
3333
3434 ```
3535 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.
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.
3737 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
3838
3939 >
@@ -33,7 +33,7 @@ You should see a banner like this one (the model name is the one from `agent.yam
33 33
34 ```34 ```
35 config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 035 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.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
39 >39 >
modified docs/fr/README.md +6 -3
@@ -1,6 +1,6 @@
11 # mini-me — documentation (français)
22
3-`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
3+`mm` est un agent de code qui tourne dans votre terminal — ligne à ligne, ou dans une fenêtre unique avec `mm -tui` — ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
44
55 Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment.
66
@@ -16,11 +16,13 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
1616 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
1717 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
1818 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
19+- [Conserver les sessions et les reprendre](how-to/keep-sessions.md)
20+- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)
1921
2022 ## Référence — consulter
2123
2224 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
23-- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier
25+- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre
2426 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
2527 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
2628 - [Format d'un fichier de skill](reference/skill-format.md)
@@ -28,9 +30,10 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
2830
2931 ## Explication — comprendre
3032
31-- [Architecture : une boucle, deux façades](explanation/architecture.md)
33+- [Architecture : une boucle, trois façades](explanation/architecture.md)
3234 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
3335 - [Compression du contexte](explanation/context-compression.md)
36+- [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
3437 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
3538
3639 ## Périmètre
@@ -1,6 +1,6 @@
1 # mini-me — documentation (français)1 # mini-me — documentation (français)
2 2
3-`mm` est un agent de code qui tourne dans votre terminal, ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.3+`mm` est un agent de code qui tourne dans votre terminal — ligne à ligne, ou dans une fenêtre unique avec `mm -tui` — ou dans un éditeur via l'Agent Client Protocol. Il dialogue avec un serveur de modèle local (Docker Model Runner ou `llama-server` de llama.cpp) et travaille avec une poignée d'outils intégrés : `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`.
4 4
5 Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment.5 Cette documentation est organisée selon les quatre quadrants [Diátaxis](https://diataxis.fr). Choisissez celui qui correspond à votre besoin du moment.
6 6
@@ -16,11 +16,13 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
16 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)16 - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md)
17 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)17 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
18 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)18 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
19+- [Conserver les sessions et les reprendre](how-to/keep-sessions.md)
20+- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md)
19 21
20 ## Référence — consulter22 ## Référence — consulter
21 23
22 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage24 - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage
23-- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, raccourcis clavier25+- [Commandes slash](reference/slash-commands.md) — `/quit`, `/abort`, `/compact`, `/new`, `/sessions`, `/resume`, `/help`, raccourcis clavier, touches de la fenêtre
24 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation26 - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation
25 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`27 - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file`
26 - [Format d'un fichier de skill](reference/skill-format.md)28 - [Format d'un fichier de skill](reference/skill-format.md)
@@ -28,9 +30,10 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https:
28 30
29 ## Explication — comprendre31 ## Explication — comprendre
30 32
31-- [Architecture : une boucle, deux façades](explanation/architecture.md)33+- [Architecture : une boucle, trois façades](explanation/architecture.md)
32 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)34 - [Fournisseurs : une implémentation par protocole](explanation/providers.md)
33 - [Compression du contexte](explanation/context-compression.md)35 - [Compression du contexte](explanation/context-compression.md)
36+- [Les sessions persistantes](explanation/persistent-sessions.md) — des fichiers par projet, pourquoi un lancement est une nouvelle session, `session/load` face au `session/resume` de docker agent
34 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)37 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
35 38
36 ## Périmètre39 ## Périmètre
modified docs/fr/explanation/architecture.md +13 -5
@@ -1,10 +1,10 @@
1-# Architecture : une boucle, deux façades — explication
1+# Architecture : une boucle, trois façades — explication
22
33 ## De quoi s'agit-il ?
44
5-`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer.
5+`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal — ligne à ligne ou dans une fenêtre — et depuis un éditeur sans rien dupliquer.
66
7-Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :
7+Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des trois façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :
88
99 | Paquet | Responsabilité |
1010 |--------|----------------|
@@ -13,13 +13,17 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
1313 | `tools` | Les outils intégrés que le modèle peut appeler. |
1414 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
1515 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
16+| `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
17+| `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
18+| `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. |
1619 | `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. |
1720 | `detector` | La détection d'une même action répétée avec le même résultat. |
1821 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |
1922 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
20-| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. |
23+| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
2124 | `agent` | Le REPL du terminal. |
2225 | `acp` | La façade Agent Client Protocol. |
26+| `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
2327
2428 Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
2529
@@ -31,7 +35,11 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans
3135
3236 **La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.
3337
34-**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission.
38+**Une boucle, trois façades.** Le REPL du terminal, la fenêtre et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission. Le mode fenêtre rejoue le même tour : `tui` installe son propre `ui.Sink` à chaque tour, et les événements deviennent des entrées d'une transcription dessinée à l'écran — les outils n'ont donc rien eu à changer pour gagner une troisième maison, et l'invite de permission en ligne de `permissions: ask` est la version terminal du dialogue de l'éditeur.
39+
40+**Une seule table de commandes.** Les commandes slash vivent dans `internal/command`, marquées des façades qui les offrent. Le REPL et la fenêtre lisent la même table ; la façade ACP n'annonce que la tranche Éditeur (`/new`, `/compact`) via `available_commands_update`, parce que l'éditeur possède le processus et annule lui-même les tours. Une commande ajoutée à un endroit est donc offerte — ou délibérément non — partout à la fois.
41+
42+**La fenêtre dessine depuis une valeur, sur une seule goroutine.** Le moteur diffuse des fragments et les outils émettent des événements depuis leurs propres goroutines ; la fenêtre les enregistre dans une `Transcript` (une valeur protégée par un mutex) et poste un événement de réveil. Seule la boucle d'événements dessine, et elle redessine depuis l'état courant de la transcription à chaque image : un réveil perdu coûte une image en retard, jamais une image perdue. C'est la règle que suit la fenêtre d'agent de turbo-core, et la raison pour laquelle la fenêtre n'a besoin d'aucune bibliothèque de widgets : un peintre, une transcription, une fonction de mise en page et une table de touches suffisent.
3543
3644 **La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.
3745
@@ -1,10 +1,10 @@
1-# Architecture : une boucle, deux façades — explication1+# Architecture : une boucle, trois façades — explication
2 2
3 ## De quoi s'agit-il ?3 ## De quoi s'agit-il ?
4 4
5-`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal et depuis un éditeur sans rien dupliquer.5+`mm` est volontairement petit : une boucle d'agent, une poignée d'outils et un fichier de configuration. Ce qui mérite le regard, c'est la façon dont les pièces sont découpées pour que la même boucle puisse être pilotée depuis un terminal — ligne à ligne ou dans une fenêtre — et depuis un éditeur sans rien dupliquer.
6 6
7-Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des deux façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :7+Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages, ouvre le moteur, construit la liste des outils, affiche la bannière et passe la main à l'une des trois façades. Chaque préoccupation vit dans son propre paquet sous `internal/` :
8 8
9 | Paquet | Responsabilité |9 | Paquet | Responsabilité |
10 |--------|----------------|10 |--------|----------------|
@@ -13,13 +13,17 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages
13 | `tools` | Les outils intégrés que le modèle peut appeler. |13 | `tools` | Les outils intégrés que le modèle peut appeler. |
14 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |14 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
15 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |15 | `skills` | La découverte des procédures markdown et le rendu du catalogue de `read_skill`. |
16+| `command` | L'unique table des commandes slash : nom, description, façades qui l'offrent, argument éventuel. Le REPL s'y branche, la façade ACP en annonce la tranche Éditeur, le sélecteur `/` de la fenêtre la liste. |
17+| `clipboard` | Met du texte dans le presse-papiers système : un outil natif du `PATH` d'abord (`pbcopy`, `wl-copy`, `xclip`, `xsel`), OSC 52 en repli, et il dit lequel a servi. Utilisé par `/copy` dans le REPL et la fenêtre. |
18+| `store` | Fichiers de session par projet (`<dir>/<id>.json`, réécriture complète, atomique) : création, sauvegarde, chargement, liste, plus récente. Utilisé par les deux façades quand `sessions.enabled`. |
16 | `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. |19 | `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. |
17 | `detector` | La détection d'une même action répétée avec le même résultat. |20 | `detector` | La détection d'une même action répétée avec le même résultat. |
18 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |21 | `compact` | La compression de l'historique en un résumé écrit par le modèle. |
19 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |22 | `spinner` | L'indicateur « toujours en cours » sur un terminal. |
20-| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les deux façades partagent. |23+| `ui` | Où va la sortie destinée à l'humain, et le puits d'événements que les façades partagent. |
21 | `agent` | Le REPL du terminal. |24 | `agent` | Le REPL du terminal. |
22 | `acp` | La façade Agent Client Protocol. |25 | `acp` | La façade Agent Client Protocol. |
26+| `tui` | La façade fenêtre unique (`mm -tui`) : un `ui.Sink` qui dessine sur tcell, une valeur de transcription, une boucle d'événements réveillée par les goroutines du moteur et des outils. |
23 27
24 Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).28 Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans [`docs/diagrams/packages.drawio`](../../diagrams/packages.drawio).
25 29
@@ -31,7 +35,11 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans
31 35
32 **La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.36 **La sortie est montrée avant que le modèle la voie.** Dans le terminal, `bash` affiche les premières lignes de la sortie d'une commande avant de la renvoyer. « Montre-moi ce fichier » veut dire le montrer ; laisser le modèle le résumer a été observé laissant l'utilisateur sans rien.
33 37
34-**Une boucle, deux façades.** Le REPL du terminal et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission.38+**Une boucle, trois façades.** Le REPL du terminal, la fenêtre et la façade ACP reçoivent exactement le même moteur, le même prompt système et la même liste d'outils. Ce qui diffère, c'est la destination des événements. Dans le terminal, les outils affichent eux-mêmes leurs lignes `🛠️` et personne ne demande de permission. En mode ACP, stdout appartient à JSON-RPC, donc `main.go` redirige la sortie humaine vers stderr, désactive le spinner, et la façade installe un `ui.Sink` à chaque tour. Les outils vérifient s'il y a un puits actif : s'il y en a un, ils émettent des événements `ToolStart`, `Allow`, `ToolRunning`, `ToolEnd` au lieu d'afficher. Le paquet `ui` ne sait rien du protocole ; `acp` est le seul endroit qui traduit ces événements en notifications `session/update` et en demandes de permission. Le mode fenêtre rejoue le même tour : `tui` installe son propre `ui.Sink` à chaque tour, et les événements deviennent des entrées d'une transcription dessinée à l'écran — les outils n'ont donc rien eu à changer pour gagner une troisième maison, et l'invite de permission en ligne de `permissions: ask` est la version terminal du dialogue de l'éditeur.
39+
40+**Une seule table de commandes.** Les commandes slash vivent dans `internal/command`, marquées des façades qui les offrent. Le REPL et la fenêtre lisent la même table ; la façade ACP n'annonce que la tranche Éditeur (`/new`, `/compact`) via `available_commands_update`, parce que l'éditeur possède le processus et annule lui-même les tours. Une commande ajoutée à un endroit est donc offerte — ou délibérément non — partout à la fois.
41+
42+**La fenêtre dessine depuis une valeur, sur une seule goroutine.** Le moteur diffuse des fragments et les outils émettent des événements depuis leurs propres goroutines ; la fenêtre les enregistre dans une `Transcript` (une valeur protégée par un mutex) et poste un événement de réveil. Seule la boucle d'événements dessine, et elle redessine depuis l'état courant de la transcription à chaque image : un réveil perdu coûte une image en retard, jamais une image perdue. C'est la règle que suit la fenêtre d'agent de turbo-core, et la raison pour laquelle la fenêtre n'a besoin d'aucune bibliothèque de widgets : un peintre, une transcription, une fonction de mise en page et une table de touches suffisent.
35 43
36 **La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.44 **La permission vit dans le puits, pas dans les outils.** Un dialogue de permission est la principale chose qu'un éditeur ajoute et que le terminal n'a pas. Placer la politique dans le puits ACP garde les outils identiques dans les deux modes ; « toujours autoriser » est mémorisé par nom d'outil pour la session.
37 45
added docs/fr/explanation/persistent-sessions.md +41 -0
new file mode 100644
@@ -0,0 +1,41 @@
1+# Les sessions persistantes — explication
2+
3+## De quoi s'agit-il ?
4+
5+Une session est une conversation : le prompt système, les questions, les réponses et les appels d'outils entre les deux. Jusqu'au 16 septembre 2026 elle vivait en mémoire et mourait avec le processus ; un éditeur qui relançait `mm` ne pouvait qu'ouvrir un nouveau fil, ce que Zed exprimait par « Loading or resuming sessions is not supported by this agent ». Les sessions persistantes (`sessions.enabled`) écrivent chaque conversation dans un fichier et permettent aux deux façades de la retrouver.
6+
7+## Pourquoi des fichiers, par projet
8+
9+Les sessions vivent sous le projet, dans `.mm/sessions/`, un fichier JSON chacune, réécrit en entier après chaque tour. Trois raisons. Une conversation porte sur un projet — ses fichiers, ses commandes — et a sa place à côté d'eux, pas dans une base de données globale où les projets se mélangent. Les fichiers se lisent : `jq` répond à « qu'a lu le modèle ? » sans outil. Et la réécriture complète est ce dont la compression a besoin : `/compact` remplace les vieux tours par un résumé, et le fichier doit dire la même chose que la mémoire, pas la mémoire plus tout ce qu'elle contenait avant.
10+
11+L'identifiant est l'heure de création suivie d'un suffixe aléatoire (`20260916-101530-4f2a9c1e`) : une liste se trie par date, et deux façades ne se marchent jamais dessus.
12+
13+## Pourquoi un lancement est une nouvelle session
14+
15+Quand `mm` démarre, il ouvre une nouvelle session et indique comment retrouver les anciennes, plutôt que de reprendre la dernière de lui-même. C'est le comportement de docker agent (cagent) : sa commande `run` ne reprend jamais seule — `--session -1` ou le navigateur `/sessions` de son interface le font — et c'est le défaut le plus sûr. La dernière conversation peut porter sur autre chose ; la reprendre en silence donnerait au modèle un contexte périmé, et l'utilisateur ne s'en apercevrait qu'aux réponses de travers. Un `/resume` explicite coûte une ligne et évite la surprise.
16+
17+Le terminal écrit le fichier à la première question, pas au lancement : un lancement sans question ne laisse aucune trace, et `/sessions` liste des conversations, pas des lancements.
18+
19+## Pourquoi `session/load` et un rejeu complet
20+
21+L'Agent Client Protocol offre deux retours vers une session. `session/load` fait partie de la spécification stable : l'agent rejoue la conversation sous forme de notifications `session/update` et l'éditeur reconstruit le fil à partir d'elles. `session/list` et `session/resume` sont des extensions instables : l'agent ne fait que réenregistrer la session, et l'éditeur garde ce qu'il affichait. docker agent a choisi la seconde voie (`loadSession: false`, capacité `Resume`, base SQLite dans `~/.cagent/session.db`). mini-me a choisi la première, parce qu'elle est stable et parce qu'un rejeu remet le fil en place même dans un éditeur qui n'avait rien gardé.
22+
23+« Rejouer » veut dire renvoyer des enregistrements. Chaque appel d'outil revient comme un seul `tool_call` déjà terminé, portant l'entrée donnée par le modèle et la sortie renvoyée par l'outil à l'époque. Rien n'est réexécuté : recharger un fil qui a un jour lancé `rm` ne lance rien.
24+
25+Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations.
26+
27+## `/new` dans les deux façades
28+
29+Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
30+
31+## Alternatives écartées
32+
33+- **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.
34+- **Un journal en ajout seul par session.** Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus.
35+- **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.
36+- **`session/resume` (instable) plutôt que `session/load`.** Écarté : hors de la spécification stable, et dépendant d'un éditeur qui aurait gardé le fil.
37+- **Ne rejouer que les messages texte.** Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.
38+
39+## Liens avec le reste
40+
41+`internal/store` est la couche fichiers, utilisée par `internal/agent` (un `keeper` qui enregistre après chaque tour et sert `/sessions` et `/resume`) et par `internal/acp` (un fichier par session, `session/load`, le rejeu dans `replay.go`). `internal/session.WithSystem` remplace le prompt enregistré par l'actuel. La compression (`internal/compact`) ignore la persistance : les façades enregistrent après son passage.
new file mode 100644
@@ -0,0 +1,41 @@
1+# Les sessions persistantes — explication
2+
3+## De quoi s'agit-il ?
4+
5+Une session est une conversation : le prompt système, les questions, les réponses et les appels d'outils entre les deux. Jusqu'au 16 septembre 2026 elle vivait en mémoire et mourait avec le processus ; un éditeur qui relançait `mm` ne pouvait qu'ouvrir un nouveau fil, ce que Zed exprimait par « Loading or resuming sessions is not supported by this agent ». Les sessions persistantes (`sessions.enabled`) écrivent chaque conversation dans un fichier et permettent aux deux façades de la retrouver.
6+
7+## Pourquoi des fichiers, par projet
8+
9+Les sessions vivent sous le projet, dans `.mm/sessions/`, un fichier JSON chacune, réécrit en entier après chaque tour. Trois raisons. Une conversation porte sur un projet — ses fichiers, ses commandes — et a sa place à côté d'eux, pas dans une base de données globale où les projets se mélangent. Les fichiers se lisent : `jq` répond à « qu'a lu le modèle ? » sans outil. Et la réécriture complète est ce dont la compression a besoin : `/compact` remplace les vieux tours par un résumé, et le fichier doit dire la même chose que la mémoire, pas la mémoire plus tout ce qu'elle contenait avant.
10+
11+L'identifiant est l'heure de création suivie d'un suffixe aléatoire (`20260916-101530-4f2a9c1e`) : une liste se trie par date, et deux façades ne se marchent jamais dessus.
12+
13+## Pourquoi un lancement est une nouvelle session
14+
15+Quand `mm` démarre, il ouvre une nouvelle session et indique comment retrouver les anciennes, plutôt que de reprendre la dernière de lui-même. C'est le comportement de docker agent (cagent) : sa commande `run` ne reprend jamais seule — `--session -1` ou le navigateur `/sessions` de son interface le font — et c'est le défaut le plus sûr. La dernière conversation peut porter sur autre chose ; la reprendre en silence donnerait au modèle un contexte périmé, et l'utilisateur ne s'en apercevrait qu'aux réponses de travers. Un `/resume` explicite coûte une ligne et évite la surprise.
16+
17+Le terminal écrit le fichier à la première question, pas au lancement : un lancement sans question ne laisse aucune trace, et `/sessions` liste des conversations, pas des lancements.
18+
19+## Pourquoi `session/load` et un rejeu complet
20+
21+L'Agent Client Protocol offre deux retours vers une session. `session/load` fait partie de la spécification stable : l'agent rejoue la conversation sous forme de notifications `session/update` et l'éditeur reconstruit le fil à partir d'elles. `session/list` et `session/resume` sont des extensions instables : l'agent ne fait que réenregistrer la session, et l'éditeur garde ce qu'il affichait. docker agent a choisi la seconde voie (`loadSession: false`, capacité `Resume`, base SQLite dans `~/.cagent/session.db`). mini-me a choisi la première, parce qu'elle est stable et parce qu'un rejeu remet le fil en place même dans un éditeur qui n'avait rien gardé.
22+
23+« Rejouer » veut dire renvoyer des enregistrements. Chaque appel d'outil revient comme un seul `tool_call` déjà terminé, portant l'entrée donnée par le modèle et la sortie renvoyée par l'outil à l'époque. Rien n'est réexécuté : recharger un fil qui a un jour lancé `rm` ne lance rien.
24+
25+Une session rechargée ou reprise continue sous le prompt système actuel, pas celui enregistré avec elle : le prompt appartient à l'agent, et un changement d'`agent.yaml` doit atteindre aussi les anciennes conversations.
26+
27+## `/new` dans les deux façades
28+
29+Dans le terminal, `/new` laisse simplement l'ancien fichier tel quel et la question suivante en ouvre un nouveau. En ACP, l'éditeur possède l'identifiant de session et continue de l'utiliser : le fichier de cet identifiant doit devenir la session neuve, et la conversation écoulée est d'abord déplacée sous un nouvel identifiant. Dans les deux cas rien n'est perdu sur disque, et un rechargement ultérieur du fil rend la conversation courante, pas celle que `/new` a fermée.
30+
31+## Alternatives écartées
32+
33+- **Reprendre automatiquement la dernière session au lancement.** Écartée pour la raison du contexte périmé, et parce que docker agent ne le fait pas non plus.
34+- **Un journal en ajout seul par session.** Écarté : la compression raccourcit l'historique, et un journal continuerait de dire ce que la mémoire ne contient plus.
35+- **SQLite, comme docker agent.** Écarté pour un agent minimal : une dépendance de plus, des fichiers opaques, et une base par utilisateur là où mini-me veut des fichiers par projet.
36+- **`session/resume` (instable) plutôt que `session/load`.** Écarté : hors de la spécification stable, et dépendant d'un éditeur qui aurait gardé le fil.
37+- **Ne rejouer que les messages texte.** Écarté par l'utilisateur : les appels d'outils sont la partie d'une session de code qui vaut d'être revue.
38+
39+## Liens avec le reste
40+
41+`internal/store` est la couche fichiers, utilisée par `internal/agent` (un `keeper` qui enregistre après chaque tour et sert `/sessions` et `/resume`) et par `internal/acp` (un fichier par session, `session/load`, le rejeu dans `replay.go`). `internal/session.WithSystem` remplace le prompt enregistré par l'actuel. La compression (`internal/compact`) ignore la persistance : les façades enregistrent après son passage.
added docs/fr/how-to/keep-sessions.md +66 -0
new file mode 100644
@@ -0,0 +1,66 @@
1+# Conserver les sessions et les reprendre
2+
3+Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un projet et en retrouver une plus tard, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne avec un fichier de configuration que vous pouvez modifier.
4+
5+## Étapes
6+
7+1. Activez la fonctionnalité dans le fichier YAML :
8+
9+ ```yaml
10+ sessions:
11+ enabled: true
12+ dir: .mm/sessions
13+ ```
14+
15+ `dir` est relatif au projet : le répertoire d'où vous lancez `mm` dans le terminal, le projet ouvert dans l'éditeur en mode ACP. Ajoutez `.mm/` au `.gitignore` du projet : un fichier de session contient toutes les sorties de commandes que le modèle a lues.
16+
17+2. Lancez `mm` depuis le répertoire du projet. La bannière se termine maintenant par une ligne `sessions:` :
18+
19+ ```text
20+ sessions: saved under /work/project/.mm/sessions · 2 earlier — /resume to continue the latest, /sessions to list
21+ ```
22+
23+ Ce lancement est une nouvelle session. Son fichier apparaît avec la première question et est réécrit après chaque tour, après `/compact` et après `/new`.
24+
25+3. Listez les sessions du projet quand vous en cherchez une :
26+
27+ ```text
28+ > /sessions
29+ 3 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
30+ * 20260916-101530-4f2a9c1e 2026-09-16 10:15 2 turn(s) add a /new command
31+ 20260916-093012-77be01d3 2026-09-16 09:41 5 turn(s) fix the skills loader
32+ 20260915-181200-0a1b2c3d 2026-09-15 18:20 1 turn(s) list the files
33+ ```
34+
35+ Le `*` marque la session courante. Les identifiants se trient par date de création.
36+
37+4. Reprenez-en une :
38+
39+ ```text
40+ > /resume
41+ ↩️ resumed 20260916-093012-77be01d3 — 14 message(s), started with "fix the skills loader"
42+ ```
43+
44+ Un `/resume` seul prend la conversation la plus récemment mise à jour autre que la courante, en ignorant les sessions vides ; `/resume <id>` en désigne une. À partir de là, le modèle voit l'historique repris — sous le prompt système actuel — et chaque nouveau tour est enregistré dans le fichier de cette session. Rien n'est rejoué à l'écran : la ligne dit ce qui est revenu.
45+
46+## Depuis un éditeur (ACP)
47+
48+1. Gardez `sessions.enabled: true` dans le fichier désigné par `AGENT_CONFIG`. Chaque fil ouvert par l'éditeur est une session, enregistrée sous `<projet>/.mm/sessions/<id>.json` dès sa création.
49+
50+2. Utilisez **Reload Agent** (Zed) quand l'agent a redémarré ou que vous avez changé sa configuration. L'éditeur envoie `session/load` avec l'identifiant du fil ; `mm` relit le fichier et rejoue toute la conversation — vos questions, les réponses, chaque appel d'outil avec l'entrée qu'il a reçue et la sortie qu'il a renvoyée à l'époque. Rien n'est réexécuté. Le fil continue ensuite comme avant.
51+
52+3. `/new` dans un fil garde le fil (l'identifiant de session de l'éditeur), mais la conversation écoulée est conservée sur disque sous un nouvel identifiant, et le fichier du fil repart vide. `/sessions` et `/resume` n'existent pas en ACP : c'est l'éditeur qui liste les fils.
53+
54+## Variantes
55+
56+- **Un seul dépôt pour tous les projets.** Donnez un chemin absolu à `dir`. Les sessions de différents projets partagent alors un répertoire ; chaque fichier enregistre son `cwd`.
57+- **Un projet en lecture seule.** La bannière ou le journal `[acp]` indique `not saved` avec la raison ; la conversation continue sans être enregistrée.
58+- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct.
59+- **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur.
60+
61+## Voir aussi
62+
63+- [Référence de la configuration](../reference/configuration.md) — les clés `sessions`
64+- [Référence des commandes slash](../reference/slash-commands.md) — `/sessions`, `/resume`, `/new`
65+- [Référence ACP](../reference/acp.md) — `session/load` et ce qu'il rejoue
66+- [Pourquoi les sessions fonctionnent ainsi](../explanation/persistent-sessions.md)
new file mode 100644
@@ -0,0 +1,66 @@
1+# Conserver les sessions et les reprendre
2+
3+Ce guide montre comment faire enregistrer par `mm` chaque conversation d'un projet et en retrouver une plus tard, dans le terminal et depuis un éditeur. Il suppose que `mm` tourne avec un fichier de configuration que vous pouvez modifier.
4+
5+## Étapes
6+
7+1. Activez la fonctionnalité dans le fichier YAML :
8+
9+ ```yaml
10+ sessions:
11+ enabled: true
12+ dir: .mm/sessions
13+ ```
14+
15+ `dir` est relatif au projet : le répertoire d'où vous lancez `mm` dans le terminal, le projet ouvert dans l'éditeur en mode ACP. Ajoutez `.mm/` au `.gitignore` du projet : un fichier de session contient toutes les sorties de commandes que le modèle a lues.
16+
17+2. Lancez `mm` depuis le répertoire du projet. La bannière se termine maintenant par une ligne `sessions:` :
18+
19+ ```text
20+ sessions: saved under /work/project/.mm/sessions · 2 earlier — /resume to continue the latest, /sessions to list
21+ ```
22+
23+ Ce lancement est une nouvelle session. Son fichier apparaît avec la première question et est réécrit après chaque tour, après `/compact` et après `/new`.
24+
25+3. Listez les sessions du projet quand vous en cherchez une :
26+
27+ ```text
28+ > /sessions
29+ 3 session(s) in /work/project/.mm/sessions — /resume <id> to continue one, /resume for the latest
30+ * 20260916-101530-4f2a9c1e 2026-09-16 10:15 2 turn(s) add a /new command
31+ 20260916-093012-77be01d3 2026-09-16 09:41 5 turn(s) fix the skills loader
32+ 20260915-181200-0a1b2c3d 2026-09-15 18:20 1 turn(s) list the files
33+ ```
34+
35+ Le `*` marque la session courante. Les identifiants se trient par date de création.
36+
37+4. Reprenez-en une :
38+
39+ ```text
40+ > /resume
41+ ↩️ resumed 20260916-093012-77be01d3 — 14 message(s), started with "fix the skills loader"
42+ ```
43+
44+ Un `/resume` seul prend la conversation la plus récemment mise à jour autre que la courante, en ignorant les sessions vides ; `/resume <id>` en désigne une. À partir de là, le modèle voit l'historique repris — sous le prompt système actuel — et chaque nouveau tour est enregistré dans le fichier de cette session. Rien n'est rejoué à l'écran : la ligne dit ce qui est revenu.
45+
46+## Depuis un éditeur (ACP)
47+
48+1. Gardez `sessions.enabled: true` dans le fichier désigné par `AGENT_CONFIG`. Chaque fil ouvert par l'éditeur est une session, enregistrée sous `<projet>/.mm/sessions/<id>.json` dès sa création.
49+
50+2. Utilisez **Reload Agent** (Zed) quand l'agent a redémarré ou que vous avez changé sa configuration. L'éditeur envoie `session/load` avec l'identifiant du fil ; `mm` relit le fichier et rejoue toute la conversation — vos questions, les réponses, chaque appel d'outil avec l'entrée qu'il a reçue et la sortie qu'il a renvoyée à l'époque. Rien n'est réexécuté. Le fil continue ensuite comme avant.
51+
52+3. `/new` dans un fil garde le fil (l'identifiant de session de l'éditeur), mais la conversation écoulée est conservée sur disque sous un nouvel identifiant, et le fichier du fil repart vide. `/sessions` et `/resume` n'existent pas en ACP : c'est l'éditeur qui liste les fils.
53+
54+## Variantes
55+
56+- **Un seul dépôt pour tous les projets.** Donnez un chemin absolu à `dir`. Les sessions de différents projets partagent alors un répertoire ; chaque fichier enregistre son `cwd`.
57+- **Un projet en lecture seule.** La bannière ou le journal `[acp]` indique `not saved` avec la raison ; la conversation continue sans être enregistrée.
58+- **Compression.** Un `/compact` ou une compression automatique réécrit le fichier avec l'historique raccourci ; une session reprise ou rechargée repart du résumé, comme la session en direct.
59+- **Repartir de zéro volontairement.** Rien à faire : chaque lancement est une nouvelle session. Les anciennes ne reviennent que sur `/resume` ou sur un rechargement depuis l'éditeur.
60+
61+## Voir aussi
62+
63+- [Référence de la configuration](../reference/configuration.md) — les clés `sessions`
64+- [Référence des commandes slash](../reference/slash-commands.md) — `/sessions`, `/resume`, `/new`
65+- [Référence ACP](../reference/acp.md) — `session/load` et ce qu'il rejoue
66+- [Pourquoi les sessions fonctionnent ainsi](../explanation/persistent-sessions.md)
modified docs/fr/how-to/start-a-new-session.md +4 -0
@@ -25,6 +25,10 @@ Ce guide montre comment effacer la conversation et repartir d'un historique vide
2525
2626 3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît.
2727
28+## Avec les sessions persistantes
29+
30+Avec `sessions.enabled: true`, le terminal laisse le fichier de la conversation fermée où il est et ouvre un nouveau fichier à votre prochaine question ; `/sessions` liste les deux et `/resume <id>` ramène l'ancienne. Depuis un éditeur, le fil garde son identifiant : la conversation écoulée est enregistrée sous un nouvel identifiant et le fichier du fil repart vide. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
31+
2832 ## Ce que `/new` réinitialise, et ce qu'il conserve
2933
3034 | Réinitialisé | Conservé |
@@ -25,6 +25,10 @@ Ce guide montre comment effacer la conversation et repartir d'un historique vide
25 25
26 3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît.26 3. Continuez à travailler dans le même fil. La session garde son identifiant, son répertoire de travail et les outils que vous avez déjà autorisés « pour cette session » ; seul l'historique disparaît.
27 27
28+## Avec les sessions persistantes
29+
30+Avec `sessions.enabled: true`, le terminal laisse le fichier de la conversation fermée où il est et ouvre un nouveau fichier à votre prochaine question ; `/sessions` liste les deux et `/resume <id>` ramène l'ancienne. Depuis un éditeur, le fil garde son identifiant : la conversation écoulée est enregistrée sous un nouvel identifiant et le fichier du fil repart vide. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
31+
28 ## Ce que `/new` réinitialise, et ce qu'il conserve32 ## Ce que `/new` réinitialise, et ce qu'il conserve
29 33
30 | Réinitialisé | Conservé |34 | Réinitialisé | Conservé |
modified docs/fr/how-to/use-from-an-editor.md +1 -0
@@ -31,6 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
3131 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
3232 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
3333 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34+- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
3435 - **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne.
3536
3637 ## Voir aussi
@@ -31,6 +31,7 @@ Ce guide montre comment héberger `mm` dans un éditeur qui parle l'Agent Client
31 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.31 - **Annuler un tour en cours.** Utilisez la touche d'arrêt de l'éditeur (Échap dans Zed). L'agent répond au prompt avec la raison d'arrêt `cancelled` et conserve dans l'historique les commandes déjà exécutées. Il n'y a pas de `/abort` en ACP.
32 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).32 - **Compresser ou recommencer la conversation.** Envoyez `/compact` ou `/new` ; les éditeurs qui listent les commandes de l'agent proposent les deux dans le menu des commandes slash. Voir la [référence des commandes slash](../reference/slash-commands.md#mode-acp).
33 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.33 - **Attacher un fichier avec `@`.** Le sélecteur de l'éditeur envoie un `resource_link`, transmis au modèle sous la forme `[attached file: <chemin>]` ; un chemin tapé à la main comme `@chemin` est compris de la même façon (voir [`@chemin`](attach-a-file.md)). Le modèle lit le fichier avec ses outils.
34+- **Recharger l'agent sans perdre le fil.** Avec `sessions.enabled: true` dans le fichier de configuration, le Reload Agent de l'éditeur (Zed) appelle `session/load` et toute la conversation, appels d'outils compris, est rejouée dans le fil. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
34 - **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne.35 - **Lancer depuis un terminal pour déboguer le protocole.** `mm -acp` lit du JSON-RPC sur stdin et l'écrit sur stdout, un message par ligne.
35 36
36 ## Voir aussi37 ## Voir aussi
added docs/fr/how-to/use-the-window-mode.md +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+# Utiliser le mode fenêtre (`mm -tui`)
2+
3+Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran — la conversation en haut, une zone de saisie en bas — au lieu du REPL ligne à ligne. Il suppose que vous lancez déjà `mm` depuis le répertoire d'un projet.
4+
5+## Étapes
6+
7+1. Lancez la fenêtre depuis le répertoire du projet :
8+
9+ ```bash
10+ mm -tui agent.dmr.yaml
11+ ```
12+
13+ Ou mettez `tui: true` dans le YAML et lancez `mm` comme d'habitude. La bannière que le mode ligne affiche apparaît comme première ligne de la conversation.
14+
15+2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas).
16+
17+3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.
18+
19+4. Appuyez sur **Échap** pour arrêter un tour en cours. Taper une nouvelle question et appuyer sur Entrée pendant un tour l'arrête aussi ; la nouvelle question part une fois l'ancien tour rangé.
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.
22+
23+6. Quittez avec `/quit`, ou **Ctrl-C** quand rien ne tourne.
24+
25+## Variantes
26+
27+- **Demander avant chaque `bash` et chaque outil de fichier.** Mettez `permissions: ask` dans le YAML. La zone de saisie est remplacée par la question `? bash: rm -rf build` et trois touches y répondent : `a` autoriser une fois, `s` autoriser cet outil pour la session, `r` (ou Échap) refuser. Un outil refusé dit au modèle `[command rejected by the user]`. La valeur par défaut, `auto`, exécute les outils comme le mode ligne l'a toujours fait.
28+- **Désigner un fichier au modèle.** `@chemin` fonctionne exactement comme dans le mode ligne : les chemins existants sont attachés et repris en lignes `📎`. Voir [Désigner un fichier au modèle avec `@chemin`](attach-a-file.md).
29+- **Conserver et reprendre des sessions.** Avec `sessions.enabled: true`, l'en-tête affiche l'identifiant de la session enregistrée ; `/sessions` et `/resume` fonctionnent comme dans le mode ligne. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
30+- **Faire défiler la conversation.** PgUp/PgDn paginent ; **Tab** donne le focus à la conversation pour que ↑/↓, Début et Fin la fassent défiler ; Fin reprend le suivi de la fin. **Ctrl-L** efface la conversation affichée sans toucher à l'historique. La molette ne fait défiler qu'après **F2** (voir plus bas).
31+- **Pas de terminal.** `mm -tui | tee log.txt`, ou tout lancement où stdin ou stdout n'est pas un terminal, affiche `[tui: stdin or stdout is not a terminal — running the line mode]` et lance le mode ligne. Rien d'autre ne change.
32+
33+## Copier du texte depuis la fenêtre
34+
35+La fenêtre laisse la souris au terminal : cliquer-glisser sélectionne du texte exactement comme dans n'importe quel terminal, et le raccourci de copie de votre terminal le copie. La barre d'état indique `F2 mouse: off`. Appuyez sur **F2** pour donner la souris à la fenêtre : la molette fait alors défiler la conversation et la barre d'état indique `mouse: on (Shift+drag selects)` ; dans la plupart des terminaux, glisser en maintenant Maj sélectionne encore. F2 à nouveau rend la souris.
36+
37+Pour copier une réponse sans la sélectionner :
38+
39+| Saisie | Copie |
40+|--------|-------|
41+| `/copy` ou **Ctrl-Y** | La dernière réponse, en texte brut : sans préfixe `🤖`, sans retour à la ligne forcé, sans cadre d'outil. |
42+| `/copy 2` | La réponse précédant la dernière (`3` celle d'avant, et ainsi de suite). |
43+| `/copy all` | Toute la conversation en texte brut : questions préfixées par `> `, réponses brutes, appels d'outils en une ligne de titre suivie de leur sortie. |
44+
45+La confirmation nomme le chemin emprunté, `📋 copied 1 240 chars (pbcopy)`, parce qu'il en existe deux et qu'ils ne sont pas aussi fiables l'un que l'autre :
46+
47+1. Un outil natif trouvé dans le `PATH` : `pbcopy` sur macOS, `wl-copy` sous Wayland, `xclip` ou `xsel` sous X11. C'est le chemin fiable sur un Mac.
48+2. Sinon **OSC 52**, la séquence d'échappement que certains terminaux honorent pour remplir le presse-papiers. Elle fonctionne à travers SSH et dans iTerm2 (à autoriser dans *Preferences → General → Selection*), Ghostty, kitty et WezTerm ; **Terminal.app de macOS l'ignore silencieusement**. Sans outil ni terminal, `/copy` répond `⚠️ no clipboard: …`.
49+
50+Le mode ligne a aussi `/copy` et `/copy 2`, par les mêmes chemins ; `/copy all` est réservé à la fenêtre, puisque l'historique du terminal y est déjà sélectionnable.
51+
52+## Coller un texte long
53+
54+Un bloc collé n'est jamais envoyé de lui-même : ses sauts de ligne deviennent des lignes dans la zone. Quand le bloc est plus long que la zone — **plus de 3 lignes, ou plus de 200 caractères** — la zone affiche un seul jeton à la place du texte :
55+
56+```
57+> explique cette trace [Pasted #1 · 42 lines · 1.2k chars] et le correctif dans [Pasted #2 · 5 lines · 310 chars]
58+```
59+
60+Le jeton se comporte comme un seul caractère : ←/→ le franchissent d'un coup, **Retour arrière** ou **Suppr** le retirent en entier avec le texte collé, et vous continuez à taper autour. Plusieurs collages dans un même message sont numérotés dans l'ordre où vous les avez collés.
61+
62+À **Entrée**, le modèle reçoit votre phrase où chaque jeton est remplacé par le texte collé complet, octet pour octet, sauts de ligne compris. Rien n'est raccourci ni résumé. La conversation, elle, continue d'afficher le jeton, si bien qu'un long collage ne chasse pas l'échange de l'écran ; la session persistée et `/copy all` portent la question complète.
63+
64+Deux détails découlent de « un collage est une donnée que vous apportez, pas quelque chose que vous avez tapé » :
65+
66+- Les mentions `@chemin` **à l'intérieur** d'un bloc collé ne sont pas résolues ; seules celles que vous avez tapées vous-même attachent un fichier (voir [`@chemin`](attach-a-file.md)).
67+- Une `/commande` dans un collage est du texte pour le modèle, pas une commande.
68+
69+Les collages plus courts que le seuil sont insérés comme du texte, exactement comme avant. Le terminal doit prendre en charge le collage encadré (bracketed paste) pour que tout cela fonctionne : Terminal.app, iTerm2, Ghostty et kitty le font ; dans **tmux**, un collage n'arrive encadré que si le terminal extérieur et tmux le prennent en charge tous deux (tmux le fait depuis la 1.8 ; aucune option `set -g` n'est nécessaire pour le passage, mais un `paste-buffer` sans `-p` envoie le texte comme des touches ordinaires, qui arrivent alors comme des caractères tapés et sont insérés un à un).
70+
71+## Voir aussi
72+
73+- [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre
74+- [Commandes slash](../reference/slash-commands.md) — les touches de la fenêtre
75+- [Fichier de configuration](../reference/configuration.md) — `tui` et `permissions`
76+- [Architecture : une boucle, trois façades](../explanation/architecture.md) — pourquoi la fenêtre est une troisième façade sur le même puits
new file mode 100644
@@ -0,0 +1,76 @@
1+# Utiliser le mode fenêtre (`mm -tui`)
2+
3+Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran — la conversation en haut, une zone de saisie en bas — au lieu du REPL ligne à ligne. Il suppose que vous lancez déjà `mm` depuis le répertoire d'un projet.
4+
5+## Étapes
6+
7+1. Lancez la fenêtre depuis le répertoire du projet :
8+
9+ ```bash
10+ mm -tui agent.dmr.yaml
11+ ```
12+
13+ Ou mettez `tui: true` dans le YAML et lancez `mm` comme d'habitude. La bannière que le mode ligne affiche apparaît comme première ligne de la conversation.
14+
15+2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas).
16+
17+3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.
18+
19+4. Appuyez sur **Échap** pour arrêter un tour en cours. Taper une nouvelle question et appuyer sur Entrée pendant un tour l'arrête aussi ; la nouvelle question part une fois l'ancien tour rangé.
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.
22+
23+6. Quittez avec `/quit`, ou **Ctrl-C** quand rien ne tourne.
24+
25+## Variantes
26+
27+- **Demander avant chaque `bash` et chaque outil de fichier.** Mettez `permissions: ask` dans le YAML. La zone de saisie est remplacée par la question `? bash: rm -rf build` et trois touches y répondent : `a` autoriser une fois, `s` autoriser cet outil pour la session, `r` (ou Échap) refuser. Un outil refusé dit au modèle `[command rejected by the user]`. La valeur par défaut, `auto`, exécute les outils comme le mode ligne l'a toujours fait.
28+- **Désigner un fichier au modèle.** `@chemin` fonctionne exactement comme dans le mode ligne : les chemins existants sont attachés et repris en lignes `📎`. Voir [Désigner un fichier au modèle avec `@chemin`](attach-a-file.md).
29+- **Conserver et reprendre des sessions.** Avec `sessions.enabled: true`, l'en-tête affiche l'identifiant de la session enregistrée ; `/sessions` et `/resume` fonctionnent comme dans le mode ligne. Voir [Conserver les sessions et les reprendre](keep-sessions.md).
30+- **Faire défiler la conversation.** PgUp/PgDn paginent ; **Tab** donne le focus à la conversation pour que ↑/↓, Début et Fin la fassent défiler ; Fin reprend le suivi de la fin. **Ctrl-L** efface la conversation affichée sans toucher à l'historique. La molette ne fait défiler qu'après **F2** (voir plus bas).
31+- **Pas de terminal.** `mm -tui | tee log.txt`, ou tout lancement où stdin ou stdout n'est pas un terminal, affiche `[tui: stdin or stdout is not a terminal — running the line mode]` et lance le mode ligne. Rien d'autre ne change.
32+
33+## Copier du texte depuis la fenêtre
34+
35+La fenêtre laisse la souris au terminal : cliquer-glisser sélectionne du texte exactement comme dans n'importe quel terminal, et le raccourci de copie de votre terminal le copie. La barre d'état indique `F2 mouse: off`. Appuyez sur **F2** pour donner la souris à la fenêtre : la molette fait alors défiler la conversation et la barre d'état indique `mouse: on (Shift+drag selects)` ; dans la plupart des terminaux, glisser en maintenant Maj sélectionne encore. F2 à nouveau rend la souris.
36+
37+Pour copier une réponse sans la sélectionner :
38+
39+| Saisie | Copie |
40+|--------|-------|
41+| `/copy` ou **Ctrl-Y** | La dernière réponse, en texte brut : sans préfixe `🤖`, sans retour à la ligne forcé, sans cadre d'outil. |
42+| `/copy 2` | La réponse précédant la dernière (`3` celle d'avant, et ainsi de suite). |
43+| `/copy all` | Toute la conversation en texte brut : questions préfixées par `> `, réponses brutes, appels d'outils en une ligne de titre suivie de leur sortie. |
44+
45+La confirmation nomme le chemin emprunté, `📋 copied 1 240 chars (pbcopy)`, parce qu'il en existe deux et qu'ils ne sont pas aussi fiables l'un que l'autre :
46+
47+1. Un outil natif trouvé dans le `PATH` : `pbcopy` sur macOS, `wl-copy` sous Wayland, `xclip` ou `xsel` sous X11. C'est le chemin fiable sur un Mac.
48+2. Sinon **OSC 52**, la séquence d'échappement que certains terminaux honorent pour remplir le presse-papiers. Elle fonctionne à travers SSH et dans iTerm2 (à autoriser dans *Preferences → General → Selection*), Ghostty, kitty et WezTerm ; **Terminal.app de macOS l'ignore silencieusement**. Sans outil ni terminal, `/copy` répond `⚠️ no clipboard: …`.
49+
50+Le mode ligne a aussi `/copy` et `/copy 2`, par les mêmes chemins ; `/copy all` est réservé à la fenêtre, puisque l'historique du terminal y est déjà sélectionnable.
51+
52+## Coller un texte long
53+
54+Un bloc collé n'est jamais envoyé de lui-même : ses sauts de ligne deviennent des lignes dans la zone. Quand le bloc est plus long que la zone — **plus de 3 lignes, ou plus de 200 caractères** — la zone affiche un seul jeton à la place du texte :
55+
56+```
57+> explique cette trace [Pasted #1 · 42 lines · 1.2k chars] et le correctif dans [Pasted #2 · 5 lines · 310 chars]
58+```
59+
60+Le jeton se comporte comme un seul caractère : ←/→ le franchissent d'un coup, **Retour arrière** ou **Suppr** le retirent en entier avec le texte collé, et vous continuez à taper autour. Plusieurs collages dans un même message sont numérotés dans l'ordre où vous les avez collés.
61+
62+À **Entrée**, le modèle reçoit votre phrase où chaque jeton est remplacé par le texte collé complet, octet pour octet, sauts de ligne compris. Rien n'est raccourci ni résumé. La conversation, elle, continue d'afficher le jeton, si bien qu'un long collage ne chasse pas l'échange de l'écran ; la session persistée et `/copy all` portent la question complète.
63+
64+Deux détails découlent de « un collage est une donnée que vous apportez, pas quelque chose que vous avez tapé » :
65+
66+- Les mentions `@chemin` **à l'intérieur** d'un bloc collé ne sont pas résolues ; seules celles que vous avez tapées vous-même attachent un fichier (voir [`@chemin`](attach-a-file.md)).
67+- Une `/commande` dans un collage est du texte pour le modèle, pas une commande.
68+
69+Les collages plus courts que le seuil sont insérés comme du texte, exactement comme avant. Le terminal doit prendre en charge le collage encadré (bracketed paste) pour que tout cela fonctionne : Terminal.app, iTerm2, Ghostty et kitty le font ; dans **tmux**, un collage n'arrive encadré que si le terminal extérieur et tmux le prennent en charge tous deux (tmux le fait depuis la 1.8 ; aucune option `set -g` n'est nécessaire pour le passage, mais un `paste-buffer` sans `-p` envoie le texte comme des touches ordinaires, qui arrivent alors comme des caractères tapés et sont insérés un à un).
70+
71+## Voir aussi
72+
73+- [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre
74+- [Commandes slash](../reference/slash-commands.md) — les touches de la fenêtre
75+- [Fichier de configuration](../reference/configuration.md) — `tui` et `permissions`
76+- [Architecture : une boucle, trois façades](../explanation/architecture.md) — pourquoi la fenêtre est une troisième façade sur le même puits
modified docs/fr/reference/acp.md +19 -4
@@ -16,7 +16,7 @@
1616 | Champ | Valeur |
1717 |-------|--------|
1818 | `protocolVersion` | `1` |
19-| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. |
19+| `agentCapabilities` | `loadSession: true` quand `sessions.enabled` est activé, absente (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |
2020 | `agentInfo.name` | `bob` |
2121 | `agentInfo.title` | `Bob (bash-first agent)` |
2222 | `agentInfo.version` | `0.11.0` |
@@ -28,11 +28,26 @@
2828 |---------|--------------|
2929 | `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. |
3030 | `authenticate` | Renvoie une réponse vide. |
31-| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). |
31+| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. |
3232 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
3333 | `session/cancel` | Annule le tour en cours pour cette session. |
34+| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
3435 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
3536
37+## Rejeu de `session/load`
38+
39+Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé.
40+
41+| Élément enregistré | Notification |
42+|--------------------|--------------|
43+| Message utilisateur | `user_message_chunk` avec son texte. |
44+| Résumé de compression (message de rôle utilisateur marqué par `/compact`) | `agent_message_chunk` avec `🗜️ ` suivi du résumé. |
45+| Texte du modèle | `agent_message_chunk`. |
46+| Appel d'outil avec sa réponse enregistrée | `tool_call` avec `toolCallId` = la référence de l'appel, `title` et `kind` tels que l'outil en direct les donnerait (`bash: <commande>` execute, `read_file: <chemin>` read, `write_file`/`edit_file` edit, `read_skill: <nom>` read), `status: completed`, `rawInput`, `rawOutput.output` et un contenu `text` portant la sortie. |
47+| Appel d'outil sans réponse enregistrée | `tool_call` avec `status: failed` et sans contenu. |
48+
49+Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id> loaded from <dir>: N message(s) replayed`.
50+
3651 ## `session/prompt`
3752
3853 | Aspect | Comportement |
@@ -40,7 +55,7 @@
4055 | `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. |
4156 | Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. |
4257 | Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. |
43-| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
58+| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
4459 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
4560 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
4661 | Succès | `stopReason: end_turn`. |
@@ -52,7 +67,7 @@
5267
5368 | Mise à jour | Quand |
5469 |-------------|-------|
55-| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. |
70+| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. |
5671 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |
5772 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
5873 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
@@ -16,7 +16,7 @@
16 | Champ | Valeur |16 | Champ | Valeur |
17 |-------|--------|17 |-------|--------|
18 | `protocolVersion` | `1` |18 | `protocolVersion` | `1` |
19-| `agentCapabilities` | Toutes absentes (false) : pas de `loadSession`, pas de prompts image ou audio, pas de MCP HTTP. |19+| `agentCapabilities` | `loadSession: true` quand `sessions.enabled` est activé, absente (false) sinon. Tout le reste absent (false) : pas de prompts image ou audio, pas de MCP HTTP. |
20 | `agentInfo.name` | `bob` |20 | `agentInfo.name` | `bob` |
21 | `agentInfo.title` | `Bob (bash-first agent)` |21 | `agentInfo.title` | `Bob (bash-first agent)` |
22 | `agentInfo.version` | `0.11.0` |22 | `agentInfo.version` | `0.11.0` |
@@ -28,11 +28,26 @@
28 |---------|--------------|28 |---------|--------------|
29 | `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. |29 | `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. |
30 | `authenticate` | Renvoie une réponse vide. |30 | `authenticate` | Renvoie une réponse vide. |
31-| `session/new` | Crée une session `bob-<pid>-<n>` avec le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). |31+| `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. |
32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33 | `session/cancel` | Annule le tour en cours pour cette session. |33 | `session/cancel` | Annule le tour en cours pour cette session. |
34+| `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
34 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |35 | `session/set_mode`, `session/set_config_option`, `session/list`, `session/resume`, `session/close`, `logout` | `method not found`. |
35 36
37+## Rejeu de `session/load`
38+
39+Envoyé avant la réponse, une notification par élément de l'historique enregistré, dans l'ordre. Le prompt système n'est pas envoyé.
40+
41+| Élément enregistré | Notification |
42+|--------------------|--------------|
43+| Message utilisateur | `user_message_chunk` avec son texte. |
44+| Résumé de compression (message de rôle utilisateur marqué par `/compact`) | `agent_message_chunk` avec `🗜️ ` suivi du résumé. |
45+| Texte du modèle | `agent_message_chunk`. |
46+| Appel d'outil avec sa réponse enregistrée | `tool_call` avec `toolCallId` = la référence de l'appel, `title` et `kind` tels que l'outil en direct les donnerait (`bash: <commande>` execute, `read_file: <chemin>` read, `write_file`/`edit_file` edit, `read_skill: <nom>` read), `status: completed`, `rawInput`, `rawOutput.output` et un contenu `text` portant la sortie. |
47+| Appel d'outil sans réponse enregistrée | `tool_call` avec `status: failed` et sans contenu. |
48+
49+Aucun outil ne s'exécute pendant un rejeu. Journalisé comme `[acp] session <id> loaded from <dir>: N message(s) replayed`.
50+
36 ## `session/prompt`51 ## `session/prompt`
37 52
38 | Aspect | Comportement |53 | Aspect | Comportement |
@@ -40,7 +55,7 @@
40 | `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. |55 | `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. |
41 | Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. |56 | Contenu du prompt | Blocs `text` concaténés ; un `resource_link` devient `\n[attached file: <chemin sans file://>]` ; les autres types de blocs sont ignorés. Ensuite, chaque `@chemin` du texte qui désigne un fichier ou un répertoire existant sous le `cwd` de la session (ou un chemin absolu / `~`) ajoute `\n[attached file: <chemin absolu>]` ou `\n[attached directory: …]`, une fois par chemin, et est journalisé comme `[acp] session <id>: attached <chemin>`. |
42 | Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. |57 | Commande slash | Quand le texte aplati, débarrassé de ses espaces, est exactement `/new` ou `/compact`, aucune génération n'a lieu ; la commande répond par un `agent_message_chunk` et `stopReason: end_turn`. |
43-| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |58+| `/new` | L'historique de la session revient au seul prompt système, le dernier compte de tokens d'entrée du moteur est oublié, le fragment est `🆕 New session: N message(s) forgotten.`. L'identifiant de session, le `cwd` et les autorisations « toujours autoriser » sont conservés. Avec `sessions.enabled`, la conversation écoulée est d'abord enregistrée sous un nouvel identifiant (journalisé `previous conversation kept as <id>` ; rien n'est enregistré si elle était vide) et le fichier de la session est réécrit avec l'historique neuf. Journalisé comme `[acp] session <id>: new session, N message(s) forgotten`. |
44 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |59 | `/compact` | L'historique est compressé avec les réglages `context`, en mode forcé (le seuil est ignoré, `keepLastTurns` est respecté). Le fragment est `🗜️ compressed N messages → 1 summary + N kept (… tokens, …s)`, ou `🗜️ nothing to compact: N message(s), no turn older than the last K` (historique inchangé), ou `[compact: failed, history kept: <explication>]` (historique inchangé). Le compte de tokens n'est oublié que si l'historique a changé. Attend la fin d'un tour en cours : les tours sont sérialisés. Journalisé comme `[acp] session <id>: /compact — <ligne>`. |
45 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |60 | Historique | L'historique complet renvoyé par le moteur est conservé, même après un échec ou une annulation ; une question restée sans aucune réponse est retirée. |
46 | Succès | `stopReason: end_turn`. |61 | Succès | `stopReason: end_turn`. |
@@ -52,7 +67,7 @@
52 67
53 | Mise à jour | Quand |68 | Mise à jour | Quand |
54 |-------------|-------|69 |-------------|-------|
55-| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. |70+| `available_commands_update` | Une fois par session, juste après l'écriture de la réponse à `session/new` (l'éditeur doit d'abord connaître l'identifiant de session, sinon il ignore la mise à jour). Liste les commandes slash que `session/prompt` intercepte. Envoyée aussi à la fin d'un rejeu de `session/load`. |
56 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |71 | `agent_message_chunk` (texte) | Chaque fragment diffusé de la réponse du modèle, espaces préservés ; aussi la ligne de confirmation d'une commande slash. |
57 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |72 | `tool_call` statut `pending` | Avant l'exécution d'un outil ; porte `title`, `kind`, `rawInput`, et `locations` avec le chemin absolu quand un fichier est concerné. |
58 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |73 | `tool_call_update` statut `in_progress` | Permission accordée, outil en cours d'exécution. |
modified docs/fr/reference/cli.md +18 -0
@@ -13,6 +13,7 @@ mm [-config <chemin>] [-acp] [<chemin>]
1313 | `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. |
1414 | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. |
1515 | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. |
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. |
1617 | `-h` | — | — | Affiche l'usage des options et quitte. |
1718
1819 ## Résolution du fichier de configuration
@@ -46,6 +47,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
4647 | `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. |
4748 | `Agent ready. Commands: …` | Mode terminal seulement. |
4849 | `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à. |
4951
5052 ### Exemple
5153
@@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
6365 | Spinner | Désactivé. |
6466 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
6567
68+## Spécificités du mode fenêtre
69+
70+| Aspect | Comportement |
71+|--------|--------------|
72+| Sélection | Option `-tui`, ou `tui: true` dans le YAML ; `-acp` l'emporte sur les deux. |
73+| Vérification du terminal | stdin et stdout doivent tous deux être des périphériques caractère. Sinon `[tui: stdin or stdout is not a terminal — running the line mode]` est affiché et le mode ligne s'exécute, bannière comprise. |
74+| É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. |
76+| 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`). |
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. |
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)]`. |
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. |
81+| Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
82+| Souris | Non capturée au démarrage, donc le cliquer-glisser du terminal sélectionne du texte. `F2` la capture (défilement à la molette ; `Maj`+glisser sélectionne) puis la rend. |
83+
6684 ## Codes de sortie
6785
6886 | Code | Cause |
@@ -13,6 +13,7 @@ mm [-config <chemin>] [-acp] [<chemin>]
13 | `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. |13 | `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. |
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 | `-h` | — | — | Affiche l'usage des options et quitte. |17 | `-h` | — | — | Affiche l'usage des options et quitte. |
17 18
18 ## Résolution du fichier de configuration19 ## Résolution du fichier de configuration
@@ -46,6 +47,7 @@ Affichée sur stdout en mode terminal, sur stderr en mode ACP. Grisée quand std
46 | `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. |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. |
47 | `Agent ready. Commands: …` | Mode terminal seulement. |48 | `Agent ready. Commands: …` | Mode terminal seulement. |
48 | `Shortcut: Ctrl+C …` | Mode terminal seulement. |49 | `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à. |
49 51
50 ### Exemple52 ### Exemple
51 53
@@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b-
63 | Spinner | Désactivé. |65 | Spinner | Désactivé. |
64 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |66 | `skillsDir` relatif | Résolu par rapport au répertoire du fichier de configuration, quand un fichier a été trouvé ; sinon depuis le répertoire de travail, comme en mode terminal. |
65 67
68+## Spécificités du mode fenêtre
69+
70+| Aspect | Comportement |
71+|--------|--------------|
72+| Sélection | Option `-tui`, ou `tui: true` dans le YAML ; `-acp` l'emporte sur les deux. |
73+| Vérification du terminal | stdin et stdout doivent tous deux être des périphériques caractère. Sinon `[tui: stdin or stdout is not a terminal — running the line mode]` est affiché et le mode ligne s'exécute, bannière comprise. |
74+| É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. |
76+| 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`). |
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. |
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)]`. |
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. |
81+| Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). |
82+| Souris | Non capturée au démarrage, donc le cliquer-glisser du terminal sélectionne du texte. `F2` la capture (défilement à la molette ; `Maj`+glisser sélectionne) puis la rend. |
83+
66 ## Codes de sortie84 ## Codes de sortie
67 85
68 | Code | Cause |86 | Code | Cause |
modified docs/fr/reference/configuration.md +12 -0
@@ -22,6 +22,9 @@
2222 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
2323 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
2424 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
25+| `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
26+| `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
27+| `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
2528
2629 ## Clés de `context`
2730
@@ -35,6 +38,13 @@
3538 | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |
3639 | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |
3740
41+## Clés de `sessions`
42+
43+| Clé | Type | Défaut | Description |
44+|-----|------|--------|-------------|
45+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
46+| `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
47+
3848 ## Valeurs par défaut des fournisseurs
3949
4050 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
@@ -58,6 +68,8 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
5868 | `context.keepLastTurns must be >= 1` | zéro ou négatif |
5969 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
6070 | `context.maxMessages must be >= 0` | valeur négative |
71+| `sessions.dir must not be empty` | chaîne vide |
72+| `permissions must be "auto" or "ask"` | toute autre valeur |
6173 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
6274
6375 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`.
@@ -22,6 +22,9 @@
22 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |22 | `sampling` | dictionnaire | `temperature: 0.0`, `top_p: 0.9`, `max_tokens: 4096` | Paramètres de génération, noms de l'API OpenAI, transmis au serveur. L'`agent.yaml` livré ajoute `parallel_tool_calls: false`. |
23 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |23 | `watchdogTimeout` | durée | `20s` | Silence toléré entre deux tokens diffusés avant de déclarer la connexion bloquée. Doublé pour les requêtes de résumé. |
24 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |24 | `context` | dictionnaire | voir ci-dessous | Compression automatique de l'historique. |
25+| `sessions` | dictionnaire | voir ci-dessous | Sessions persistantes, par projet. |
26+| `tui` | bool | `false` | Démarre dans le mode fenêtre unique sans l'option `-tui`. Retombe sur le mode ligne quand stdin ou stdout n'est pas un terminal ; ignoré avec `-acp`. |
27+| `permissions` | chaîne | `auto` | `ask` : en mode fenêtre, `bash`, `write_file` et `edit_file` attendent la réponse de l'utilisateur (`a`/`s`/`r`) avant de s'exécuter. `auto` : les outils s'exécutent aussitôt. Le mode ligne et la façade ACP ne la lisent pas (ACP a son propre dialogue). Doit valoir `auto` ou `ask`. |
25 28
26 ## Clés de `context`29 ## Clés de `context`
27 30
@@ -35,6 +38,13 @@
35 | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |38 | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. |
36 | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |39 | `showStats` | booléen | `true` | Affiche le rapport `🗜️` d'une ligne après chaque compression. |
37 40
41+## Clés de `sessions`
42+
43+| Clé | Type | Défaut | Description |
44+|-----|------|--------|-------------|
45+| `sessions.enabled` | bool | `false` | Enregistre chaque conversation dans un fichier, réécrit après chaque tour, `/compact` et `/new`. Active `/sessions` et `/resume` dans le terminal et `session/load` en ACP. |
46+| `sessions.dir` | chaîne | `.mm/sessions` | Répertoire des fichiers de session, un `<id>.json` chacun. Relatif au répertoire de lancement de `mm` (terminal) ou au `cwd` de la session (ACP) ; un chemin absolu est utilisé tel quel. Ne doit pas être vide. |
47+
38 ## Valeurs par défaut des fournisseurs48 ## Valeurs par défaut des fournisseurs
39 49
40 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |50 | Fournisseur | `baseUrl` | `fallback` | `apiKeyEnv` | Clé requise |
@@ -58,6 +68,8 @@ Signalées sous la forme `[config error: <chemin>: <message>]`, code de sortie 1
58 | `context.keepLastTurns must be >= 1` | zéro ou négatif |68 | `context.keepLastTurns must be >= 1` | zéro ou négatif |
59 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |69 | `context.summaryMaxTokens must be > 0` | zéro ou négatif |
60 | `context.maxMessages must be >= 0` | valeur négative |70 | `context.maxMessages must be >= 0` | valeur négative |
71+| `sessions.dir must not be empty` | chaîne vide |
72+| `permissions must be "auto" or "ask"` | toute autre valeur |
61 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |73 | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. |
62 74
63 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`.75 Un avertissement au démarrage, et non une erreur, est affiché quand `context.enabled` vaut `true`, que la fenêtre est inconnue et que `context.maxMessages` vaut `0`.
modified docs/fr/reference/skill-format.md +1 -1
@@ -7,7 +7,7 @@
77 | Règle | Valeur |
88 |-------|--------|
99 | Répertoire | `skillsDir` (défaut `skills`). |
10-| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. |
10+| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. Un `README.md` à la racine du répertoire est de la documentation, pas un skill : il est ignoré. |
1111 | Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. |
1212 | Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. |
1313 | Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. |
@@ -7,7 +7,7 @@
7 | Règle | Valeur |7 | Règle | Valeur |
8 |-------|--------|8 |-------|--------|
9 | Répertoire | `skillsDir` (défaut `skills`). |9 | Répertoire | `skillsDir` (défaut `skills`). |
10-| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. |10+| Motifs | `<skillsDir>/<nom>.md` (à plat) et `<skillsDir>/<nom>/SKILL.md` (un répertoire par skill), librement mélangés. Les autres fichiers du répertoire d'un skill sont ignorés. Un `README.md` à la racine du répertoire est de la documentation, pas un skill : il est ignoré. |
11 | Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. |11 | Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. |
12 | Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. |12 | Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. |
13 | Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. |13 | Répertoire absent | Pas une erreur : zéro skill, `read_skill` non déclaré. |
modified docs/fr/reference/slash-commands.md +27 -1
@@ -10,8 +10,12 @@
1010 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
1111 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
1212 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13+| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14+| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
15+| `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16+| `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
1317
14-Une ligne vide à l'invite est ignorée.
18+Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
1519
1620 ## `@chemin` dans une question
1721
@@ -35,6 +39,28 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
3539 | `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
3640 | `Ctrl+D` (fin d'entrée) | Quitte. | — |
3741
42+## Touches du mode fenêtre
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 :
45+
46+| Touche | Action |
47+|--------|--------|
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. |
49+| `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. |
52+| `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 ligne à l'autre, et font défiler la conversation depuis sa première/dernière ligne. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
54+| `PgUp` `PgDn` | Défilent d'une page. |
55+| `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |
56+| `Ctrl-L` | Efface la conversation affichée ; l'historique est conservé. |
57+| `F1` | Comme `/help`. |
58+| `F2` | Bascule la capture de la souris. Désactivée (défaut) : le cliquer-glisser du terminal sélectionne du texte ; la barre d'état indique `F2 mouse: off`. Activée : la molette fait défiler, `Maj`+glisser sélectionne, la barre d'état indique `mouse: on (Shift+drag selects)`. |
59+| `Ctrl-Y` | Comme `/copy` : la dernière réponse dans le presse-papiers. |
60+| `a` `s` `r` | Répondent à une permission en attente : autoriser une fois, autoriser cet outil pour la session, refuser. |
61+| 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. |
63+
3864 ## Messages affichés en fin de tour
3965
4066 | Message | Signification |
@@ -10,8 +10,12 @@
10 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |10 | `/abort` | Affiche `⚠️ No generation in progress.` | Annule la génération, affiche `🛑 Aborting...` ; les commandes déjà exécutées restent dans l'historique. |
11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |11 | `/compact` | Compresse l'historique immédiatement, sans tenir compte du seuil ; affiche le rapport `🗜️`, ou `🗜️ nothing to compact` quand aucun tour n'est plus ancien que `context.keepLastTurns`. | Envoyée au modèle comme nouveau message (voir ci-dessous). |
12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |12 | `/new` | Ramène l'historique au seul prompt système, réinitialise le détecteur de boucles et oublie le dernier compte de tokens du serveur ; affiche `🆕 new session: N message(s) forgotten` (N compte tous les messages sauf le prompt système). | Traitée comme n'importe quel texte : annule la génération, conserve son historique partiel, puis s'exécute comme `/new` sur cet historique. |
13+| `/sessions` | Liste les sessions enregistrées du projet, la plus récente d'abord : identifiant, dernière mise à jour, nombre de questions, première question ; `*` marque la session courante. Avec `sessions.enabled: false` : `⚠️ sessions are off (set sessions.enabled: true in the YAML)`. | Envoyée au modèle comme nouveau message. |
14+| `/resume [id]` | Reprend une session enregistrée : la plus récemment mise à jour autre que la courante, en ignorant les vides, ou l'identifiant donné. Affiche `↩️ resumed <id> — N message(s), started with "<première question>"` ; réinitialise le détecteur de boucles et le compte de tokens du serveur ; les tours suivants sont enregistrés dans cette session. Refusée avec une raison `⚠️` quand les sessions sont désactivées, qu'il n'y a pas de session antérieure, que l'identifiant est inconnu ou désigne la session courante. | Envoyée au modèle comme nouveau message. |
15+| `/help` | Affiche les commandes de cette façade, une par ligne avec leur description, alignées. | Envoyée au modèle comme nouveau message. |
16+| `/copy [n\|all]` | Copie la dernière réponse (`n` : la n-ième depuis la fin) dans le presse-papiers via `pbcopy`/`wl-copy`/`xclip`/`xsel` ou, à défaut, OSC 52 quand stdout est un terminal, et affiche `📋 copied N chars (<chemin>)` ou `⚠️ …`. `/copy all` est réservé au mode fenêtre : ici la commande le dit. | Envoyée au modèle comme nouveau message. |
13 17
14-Une ligne vide à l'invite est ignorée.18+Avec `sessions.enabled`, `/new` laisse le fichier de la session courante tel quel ; la question suivante ouvre un nouveau fichier. Une ligne vide à l'invite est ignorée.
15 19
16 ## `@chemin` dans une question20 ## `@chemin` dans une question
17 21
@@ -35,6 +39,28 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
35 | `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |39 | `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. |
36 | `Ctrl+D` (fin d'entrée) | Quitte. | — |40 | `Ctrl+D` (fin d'entrée) | Quitte. | — |
37 41
42+## Touches du mode fenêtre
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 :
45+
46+| Touche | Action |
47+|--------|--------|
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. |
49+| `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. |
52+| `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 ligne à l'autre, et font défiler la conversation depuis sa première/dernière ligne. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
54+| `PgUp` `PgDn` | Défilent d'une page. |
55+| `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |
56+| `Ctrl-L` | Efface la conversation affichée ; l'historique est conservé. |
57+| `F1` | Comme `/help`. |
58+| `F2` | Bascule la capture de la souris. Désactivée (défaut) : le cliquer-glisser du terminal sélectionne du texte ; la barre d'état indique `F2 mouse: off`. Activée : la molette fait défiler, `Maj`+glisser sélectionne, la barre d'état indique `mouse: on (Shift+drag selects)`. |
59+| `Ctrl-Y` | Comme `/copy` : la dernière réponse dans le presse-papiers. |
60+| `a` `s` `r` | Répondent à une permission en attente : autoriser une fois, autoriser cet outil pour la session, refuser. |
61+| 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. |
63+
38 ## Messages affichés en fin de tour64 ## Messages affichés en fin de tour
39 65
40 | Message | Signification |66 | Message | Signification |
modified docs/fr/reference/tools.md +1 -0
@@ -19,6 +19,7 @@ Toujours déclaré.
1919 | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |
2020 | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
2121 | ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. |
22+| Mode fenêtre | Une ligne `‣ bash <commande>` avec statut, durée et sortie repliée. Avec `permissions: ask`, attend `a`/`s`/`r` ; un refus renvoie le même texte qu'en ACP. |
2223
2324 ## `read_skill`
2425
@@ -19,6 +19,7 @@ Toujours déclaré.
19 | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. |19 | 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)`. |20 | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. |
21 | ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. |21 | ACP | Appel d'outil de genre `execute` ; permission demandée avant exécution ; un refus renvoie `[command rejected by the user]` avec le statut `failed`. |
22+| Mode fenêtre | Une ligne `‣ bash <commande>` avec statut, durée et sortie repliée. Avec `permissions: ask`, attend `a`/`s`/`r` ; un refus renvoie le même texte qu'en ACP. |
22 23
23 ## `read_skill`24 ## `read_skill`
24 25
modified docs/fr/tutorials/getting-started.md +1 -1
@@ -33,7 +33,7 @@ Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`age
3333
3434 ```
3535 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.
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.
3737 Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.
3838
3939 >
@@ -33,7 +33,7 @@ Vous devez voir une bannière comme celle-ci (le nom du modèle est celui d'`age
33 33
34 ```34 ```
35 config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 035 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.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
39 >39 >
modified go.mod +10 -1
@@ -4,10 +4,19 @@ go 1.25.0
44
55 require (
66 github.com/firebase/genkit/go v1.10.0
7+ github.com/gdamore/tcell/v2 v2.13.10
78 github.com/openai/openai-go v1.8.2
89 gopkg.in/yaml.v3 v3.0.1
910 )
1011
12+require (
13+ github.com/gdamore/encoding v1.0.1 // indirect
14+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
15+ github.com/rivo/uniseg v0.4.7 // indirect
16+ golang.org/x/term v0.37.0 // indirect
17+ golang.org/x/text v0.31.0 // indirect
18+)
19+
1120 require (
1221 github.com/bahlo/generic-list-go v0.2.0 // indirect
1322 github.com/buger/jsonparser v1.1.1 // indirect
@@ -35,5 +44,5 @@ require (
3544 go.opentelemetry.io/otel/metric v1.36.0 // indirect
3645 go.opentelemetry.io/otel/sdk v1.36.0 // indirect
3746 go.opentelemetry.io/otel/trace v1.36.0 // indirect
38- golang.org/x/sys v0.34.0 // indirect
47+ golang.org/x/sys v0.38.0 // indirect
3948 )
@@ -4,10 +4,19 @@ go 1.25.0
4 4
5 require (5 require (
6 github.com/firebase/genkit/go v1.10.06 github.com/firebase/genkit/go v1.10.0
7+ github.com/gdamore/tcell/v2 v2.13.10
7 github.com/openai/openai-go v1.8.28 github.com/openai/openai-go v1.8.2
8 gopkg.in/yaml.v3 v3.0.19 gopkg.in/yaml.v3 v3.0.1
9 )10 )
10 11
12+require (
13+ github.com/gdamore/encoding v1.0.1 // indirect
14+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
15+ github.com/rivo/uniseg v0.4.7 // indirect
16+ golang.org/x/term v0.37.0 // indirect
17+ golang.org/x/text v0.31.0 // indirect
18+)
19+
11 require (20 require (
12 github.com/bahlo/generic-list-go v0.2.0 // indirect21 github.com/bahlo/generic-list-go v0.2.0 // indirect
13 github.com/buger/jsonparser v1.1.1 // indirect22 github.com/buger/jsonparser v1.1.1 // indirect
@@ -35,5 +44,5 @@ require (
35 go.opentelemetry.io/otel/metric v1.36.0 // indirect44 go.opentelemetry.io/otel/metric v1.36.0 // indirect
36 go.opentelemetry.io/otel/sdk v1.36.0 // indirect45 go.opentelemetry.io/otel/sdk v1.36.0 // indirect
37 go.opentelemetry.io/otel/trace v1.36.0 // indirect46 go.opentelemetry.io/otel/trace v1.36.0 // indirect
38- golang.org/x/sys v0.34.0 // indirect47+ golang.org/x/sys v0.38.0 // indirect
39 )48 )
modified go.sum +45 -2
@@ -11,6 +11,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1111 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1212 github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs=
1313 github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E=
14+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
15+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
16+github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
17+github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
1418 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
1519 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
1620 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -30,6 +34,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3034 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3135 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
3236 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
37+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
38+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
3339 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
3440 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
3541 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
@@ -38,6 +44,8 @@ github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP
3844 github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
3945 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4046 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
47+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
48+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
4149 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
4250 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
4351 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -65,6 +73,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
6573 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
6674 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
6775 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
76+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
6877 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
6978 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
7079 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
@@ -77,8 +86,42 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr
7786 go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
7887 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
7988 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
80-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
81-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
89+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
90+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
91+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
92+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
93+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
94+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
95+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
96+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
97+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
98+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
99+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
100+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
101+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
102+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
103+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
107+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
108+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
109+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
110+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
111+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
112+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
113+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
114+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
116+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
117+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
118+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
119+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
120+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
121+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
122+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
123+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
124+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
82125 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
83126 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
84127 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -11,6 +11,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
11 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=11 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
12 github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs=12 github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs=
13 github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E=13 github.com/firebase/genkit/go v1.10.0/go.mod h1:AzmlJrm+2PjSrLnBHwY0uTbRC/GsazMa0JYpBrVf18E=
14+github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
15+github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
16+github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA=
17+github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
14 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=18 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
15 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=19 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
16 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=20 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -30,6 +34,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
30 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=34 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
31 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=35 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
32 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=36 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
37+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
38+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
33 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=39 github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
34 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=40 github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
35 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=41 github.com/mbleigh/raymond v0.0.0-20250414171441-6b3a58ab9e0a h1:v2cBA3xWKv2cIOVhnzX/gNgkNXqiHfUgJtA3r61Hf7A=
@@ -38,6 +44,8 @@ github.com/openai/openai-go v1.8.2 h1:UqSkJ1vCOPUpz9Ka5tS0324EJFEuOvMc+lA/EarJWP
38 github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=44 github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
39 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=45 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
40 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=46 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
47+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
48+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
41 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=49 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
42 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=50 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
43 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=51 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -65,6 +73,7 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17
65 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=73 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
66 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=74 github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
67 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=75 github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
76+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
68 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=77 go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
69 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=78 go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
70 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=79 go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
@@ -77,8 +86,42 @@ go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKr
77 go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=86 go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
78 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=87 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
79 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=88 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
80-golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=89+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
81-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=90+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
91+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
92+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
93+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
94+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
95+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
96+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
97+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
98+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
99+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
100+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
101+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
102+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
103+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
107+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
108+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
109+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
110+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
111+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
112+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
113+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
114+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
115+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
116+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
117+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
118+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
119+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
120+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
121+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
122+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
123+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
124+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
82 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=125 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
83 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=126 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
84 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=127 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
modified internal/acp/acp.go +66 -52
@@ -22,7 +22,6 @@ package acp
2222 import (
2323 "context"
2424 "encoding/json"
25- "errors"
2625 "fmt"
2726 "io"
2827 "log/slog"
@@ -30,11 +29,13 @@ import (
3029 "strings"
3130 "sync"
3231
32+ "mm/internal/command"
3333 "mm/internal/compact"
3434 "mm/internal/config"
3535 "mm/internal/engine"
3636 "mm/internal/mention"
3737 history "mm/internal/session"
38+ "mm/internal/store"
3839 "mm/internal/ui"
3940
4041 sdk "github.com/coder/acp-go-sdk"
@@ -52,6 +53,11 @@ type session struct {
5253 cwd string
5354 messages []*ai.Message
5455 cancel context.CancelFunc // cancels the turn in flight, if any
56+ // record is the session's file when persistence is on (config
57+ // `sessions.enabled`), nil otherwise. Its id is the ACP session id, so
58+ // an editor's session/load names the file directly.
59+ record *store.Session
60+ st *store.Store
5561 // allowAlways remembers the tools the user granted for the whole session
5662 // ("allow always" in the permission dialog).
5763 allowAlways map[string]bool
@@ -64,9 +70,13 @@ type front struct {
6470 conn *sdk.AgentSideConnection
6571 out *responseWriter // what the SDK writes to; see responseWriter
6672
67- mu sync.Mutex // guards sessions and nextID
73+ // sessionsDir is where sessions are persisted, or "" when persistence is
74+ // off. Relative to each session's cwd — the project the editor opened —
75+ // so one running agent keeps one store per project.
76+ sessionsDir string
77+
78+ mu sync.Mutex // guards sessions
6879 sessions map[sdk.SessionId]*session
69- nextID int
7080
7181 // turnMu serialises the turns. Two reasons, both structural: the active
7282 // ui.Sink is process-global, and engine.Generate was never designed for two
@@ -93,13 +103,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
93103 // we read the client's messages.
94104 func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {
95105 f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}
106+ if config.Cfg.Sessions.Enabled {
107+ f.sessionsDir = config.Cfg.Sessions.Dir
108+ }
96109 f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
97110 conn := sdk.NewAgentSideConnection(f, f.out, r)
98111 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
99112 f.conn = conn
113+ lastFront = f
100114 return f
101115 }
102116
117+// lastFront is the front most recently built by newFront. The pipe-driven
118+// tests need to look inside the front they talk to, and newFront's callers
119+// only ever build one at a time.
120+var lastFront *front
121+
103122 // responseWriter is the io.Writer handed to the SDK: the real output plus one
104123 // hook per session, run once the session/new response carrying that session
105124 // id has been written. It exists for available_commands_update. The spec says
@@ -167,9 +186,11 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init
167186 clientName(p.ClientInfo), p.ProtocolVersion)
168187 return sdk.InitializeResponse{
169188 ProtocolVersion: sdk.ProtocolVersionNumber, // 1
170- // Everything absent is false: no loadSession, no image/audio prompts,
171- // no MCP over http. Honest capabilities are what lets a client adapt.
172- AgentCapabilities: sdk.AgentCapabilities{},
189+ // Everything absent is false: no image/audio prompts, no MCP over
190+ // http. loadSession is true only when sessions are persisted — an
191+ // editor's "reload" needs a file to reload. Honest capabilities are
192+ // what lets a client adapt.
193+ AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != ""},
173194 AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},
174195 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
175196 }, nil
@@ -189,14 +210,18 @@ func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.Auth
189210 // --- session/new --------------------------------------------------------------
190211
191212 func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
192- f.mu.Lock()
193- f.nextID++
194- sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID))
195- f.sessions[sid] = &session{
213+ // The id is the store's: time-sortable and unique across restarts, which
214+ // matters as soon as it names a file. Used unchanged when nothing is
215+ // persisted, so both modes log the same kind of id.
216+ sid := sdk.SessionId(store.NewID())
217+ s := &session{
196218 cwd: p.Cwd, // absolute, per the spec; where bash runs for this session
197219 messages: history.Fresh(f.system),
198220 allowAlways: map[string]bool{},
199221 }
222+ f.persistNew(sid, s)
223+ f.mu.Lock()
224+ f.sessions[sid] = s
200225 f.mu.Unlock()
201226 // mcpServers is where the client offers extra tools. bob's whole point is
202227 // the opposite — one built-in tool — so they are acknowledged and ignored.
@@ -216,29 +241,18 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
216241 return sdk.NewSessionResponse{SessionId: sid}, nil
217242 }
218243
219-// compactCommand is the REPL's /compact, spelled here because ACP has no other
220-// place for it; /new is shared through internal/session. /quit and /abort are
221-// deliberately absent: the editor owns the process and ends it by closing
222-// stdin, and it cancels a turn with its own stop key through session/cancel.
223-const compactCommand = "/compact"
224-
225-// isCommand says whether input, trimmed, is exactly the given command — the
226-// same rule as session.IsNewCommand: "/compact " is the command, "/compact
227-// now" is a question for the model.
228-func isCommand(input, command string) bool {
229- return strings.TrimSpace(input) == command
230-}
231-
232244 // availableCommands lists the slash commands this front end handles itself,
233245 // in the shape ACP advertises them: bare names, the client adds the slash.
234-// Only what session/prompt actually intercepts belongs here — announcing
235-// /quit or /abort, which the REPL has and this front end does not, would
236-// promise the editor something the next prompt would hand to the model.
246+// The list is the shared table's Editor slice (internal/command): only what
247+// slashCommand intercepts — announcing /quit or /abort, which the REPL has
248+// and this front end does not, would promise the editor something the next
249+// prompt would hand to the model.
237250 func availableCommands() []sdk.AvailableCommand {
238- return []sdk.AvailableCommand{
239- {Name: history.NewCommandName, Description: "Clear the history and start a new session"},
240- {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"},
251+ var out []sdk.AvailableCommand
252+ for _, c := range command.For(command.Editor) {
253+ out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})
241254 }
255+ return out
242256 }
243257
244258 // --- session/prompt -----------------------------------------------------------
@@ -259,11 +273,8 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
259273 // A slash command is answered here, without a turn: it is the REPL's "only
260274 // between two generations" rule, met for free — turnMu guarantees no
261275 // generation is running on this session's history while we replace it.
262- switch {
263- case history.IsNewCommand(input):
264- return f.startNewSession(ctx, p.SessionId, s), nil
265- case isCommand(input, compactCommand):
266- return f.compactSession(ctx, p.SessionId, s), nil
276+ if resp, ok := f.slashCommand(ctx, p.SessionId, s, input); ok {
277+ return resp, nil
267278 }
268279
269280 // One cancellable context per turn: session/cancel fires s.cancel, the
@@ -296,20 +307,13 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
296307 s.messages = append(s.messages, ai.NewUserTextMessage(input))
297308 before := len(s.messages)
298309
299- resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools)
310+ resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
300311
301312 // Same rule as the REPL, for the same reason: keep the FULL history even
302313 // when the turn failed or was cancelled — the commands that already ran are
303314 // part of the conversation, and forgetting them makes the model re-run or
304315 // invent them on the next turn.
305- switch {
306- case len(history) > before:
307- s.messages = history
308- case resp != nil && resp.Message != nil:
309- s.messages = append(s.messages, resp.Message)
310- case err != nil:
311- s.messages = s.messages[:before-1] // question without an answer: forget it
312- }
316+ s.messages = history.AfterTurn(s.messages, before, resp, full, err)
313317
314318 // The ⚙ recap of the REPL, one line on stderr: the editor already renders
315319 // every tool call, but the trail is what lets `dev: open acp logs` say at a
@@ -317,6 +321,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
317321 turn := s.messages[min(before, len(s.messages)):]
318322 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
319323 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
324+ f.persist(p.SessionId, s)
320325
321326 switch {
322327 case turnCtx.Err() != nil:
@@ -336,6 +341,18 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
336341 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
337342 }
338343
344+// slashCommand runs input when it is one of the commands this front end
345+// handles itself, and says whether it was. Called under turnMu.
346+func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, input string) (sdk.PromptResponse, bool) {
347+ switch {
348+ case command.Is(input, command.New):
349+ return f.startNewSession(ctx, sid, s), true
350+ case command.Is(input, command.Compact):
351+ return f.compactSession(ctx, sid, s), true
352+ }
353+ return sdk.PromptResponse{}, false
354+}
355+
339356 // startNewSession is the ACP side of /new: the session keeps its id, its cwd
340357 // and the "allow always" grants — the dialog promised them "for this session",
341358 // and the session, as the editor sees it, is the same one — but its history
@@ -344,8 +361,10 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
344361 // REPL prints travels as an agent message so the editor shows it in the
345362 // thread, and the turn ends right there: the model is not consulted.
346363 func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
364+ f.archive(sid, s)
347365 forgotten := resetSession(s, f.system)
348366 f.e.ForgetInputTokens()
367+ f.persist(sid, s)
349368 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
350369 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
351370 SessionId: sid,
@@ -366,6 +385,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
366385 }, f.e.Explain)
367386 if compressed {
368387 f.e.ForgetInputTokens() // the server's measure described the history just replaced
388+ f.persist(sid, s)
369389 }
370390 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
371391 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
@@ -382,15 +402,9 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
382402 // injected, so the three outcomes are testable without an engine or a
383403 // connection.
384404 func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
385- res, err := compact.Compact(ctx, s.messages, cfg, summarize)
386- switch {
387- case errors.Is(err, compact.ErrNothingToCompact):
388- return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false
389- case err != nil:
390- return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false
391- }
392- s.messages = res.Messages
393- return "🗜️ " + res.Report(), true
405+ var outcome compact.Outcome
406+ s.messages, line, outcome = compact.Apply(ctx, s.messages, cfg, summarize, explain)
407+ return line, outcome == compact.Compressed
394408 }
395409
396410 // resetSession replaces the session's history with a fresh one and returns how
@@ -22,7 +22,6 @@ package acp
22 import (22 import (
23 "context"23 "context"
24 "encoding/json"24 "encoding/json"
25- "errors"
26 "fmt"25 "fmt"
27 "io"26 "io"
28 "log/slog"27 "log/slog"
@@ -30,11 +29,13 @@ import (
30 "strings"29 "strings"
31 "sync"30 "sync"
32 31
32+ "mm/internal/command"
33 "mm/internal/compact"33 "mm/internal/compact"
34 "mm/internal/config"34 "mm/internal/config"
35 "mm/internal/engine"35 "mm/internal/engine"
36 "mm/internal/mention"36 "mm/internal/mention"
37 history "mm/internal/session"37 history "mm/internal/session"
38+ "mm/internal/store"
38 "mm/internal/ui"39 "mm/internal/ui"
39 40
40 sdk "github.com/coder/acp-go-sdk"41 sdk "github.com/coder/acp-go-sdk"
@@ -52,6 +53,11 @@ type session struct {
52 cwd string53 cwd string
53 messages []*ai.Message54 messages []*ai.Message
54 cancel context.CancelFunc // cancels the turn in flight, if any55 cancel context.CancelFunc // cancels the turn in flight, if any
56+ // record is the session's file when persistence is on (config
57+ // `sessions.enabled`), nil otherwise. Its id is the ACP session id, so
58+ // an editor's session/load names the file directly.
59+ record *store.Session
60+ st *store.Store
55 // allowAlways remembers the tools the user granted for the whole session61 // allowAlways remembers the tools the user granted for the whole session
56 // ("allow always" in the permission dialog).62 // ("allow always" in the permission dialog).
57 allowAlways map[string]bool63 allowAlways map[string]bool
@@ -64,9 +70,13 @@ type front struct {
64 conn *sdk.AgentSideConnection70 conn *sdk.AgentSideConnection
65 out *responseWriter // what the SDK writes to; see responseWriter71 out *responseWriter // what the SDK writes to; see responseWriter
66 72
67- mu sync.Mutex // guards sessions and nextID73+ // sessionsDir is where sessions are persisted, or "" when persistence is
74+ // off. Relative to each session's cwd — the project the editor opened —
75+ // so one running agent keeps one store per project.
76+ sessionsDir string
77+
78+ mu sync.Mutex // guards sessions
68 sessions map[sdk.SessionId]*session79 sessions map[sdk.SessionId]*session
69- nextID int
70 80
71 // turnMu serialises the turns. Two reasons, both structural: the active81 // turnMu serialises the turns. Two reasons, both structural: the active
72 // ui.Sink is process-global, and engine.Generate was never designed for two82 // ui.Sink is process-global, and engine.Generate was never designed for two
@@ -93,13 +103,22 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
93 // we read the client's messages.103 // we read the client's messages.
94 func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {104 func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front {
95 f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}105 f := &front{e: e, system: system, tools: tools, sessions: map[sdk.SessionId]*session{}}
106+ if config.Cfg.Sessions.Enabled {
107+ f.sessionsDir = config.Cfg.Sessions.Dir
108+ }
96 f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}109 f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}}
97 conn := sdk.NewAgentSideConnection(f, f.out, r)110 conn := sdk.NewAgentSideConnection(f, f.out, r)
98 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))111 conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil)))
99 f.conn = conn112 f.conn = conn
113+ lastFront = f
100 return f114 return f
101 }115 }
102 116
117+// lastFront is the front most recently built by newFront. The pipe-driven
118+// tests need to look inside the front they talk to, and newFront's callers
119+// only ever build one at a time.
120+var lastFront *front
121+
103 // responseWriter is the io.Writer handed to the SDK: the real output plus one122 // responseWriter is the io.Writer handed to the SDK: the real output plus one
104 // hook per session, run once the session/new response carrying that session123 // hook per session, run once the session/new response carrying that session
105 // id has been written. It exists for available_commands_update. The spec says124 // id has been written. It exists for available_commands_update. The spec says
@@ -167,9 +186,11 @@ func (f *front) Initialize(_ context.Context, p sdk.InitializeRequest) (sdk.Init
167 clientName(p.ClientInfo), p.ProtocolVersion)186 clientName(p.ClientInfo), p.ProtocolVersion)
168 return sdk.InitializeResponse{187 return sdk.InitializeResponse{
169 ProtocolVersion: sdk.ProtocolVersionNumber, // 1188 ProtocolVersion: sdk.ProtocolVersionNumber, // 1
170- // Everything absent is false: no loadSession, no image/audio prompts,189+ // Everything absent is false: no image/audio prompts, no MCP over
171- // no MCP over http. Honest capabilities are what lets a client adapt.190+ // http. loadSession is true only when sessions are persisted — an
172- AgentCapabilities: sdk.AgentCapabilities{},191+ // editor's "reload" needs a file to reload. Honest capabilities are
192+ // what lets a client adapt.
193+ AgentCapabilities: sdk.AgentCapabilities{LoadSession: f.sessionsDir != ""},
173 AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},194 AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version},
174 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into195 AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into
175 }, nil196 }, nil
@@ -189,14 +210,18 @@ func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.Auth
189 // --- session/new --------------------------------------------------------------210 // --- session/new --------------------------------------------------------------
190 211
191 func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {212 func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
192- f.mu.Lock()213+ // The id is the store's: time-sortable and unique across restarts, which
193- f.nextID++214+ // matters as soon as it names a file. Used unchanged when nothing is
194- sid := sdk.SessionId(fmt.Sprintf("bob-%d-%d", os.Getpid(), f.nextID))215+ // persisted, so both modes log the same kind of id.
195- f.sessions[sid] = &session{216+ sid := sdk.SessionId(store.NewID())
217+ s := &session{
196 cwd: p.Cwd, // absolute, per the spec; where bash runs for this session218 cwd: p.Cwd, // absolute, per the spec; where bash runs for this session
197 messages: history.Fresh(f.system),219 messages: history.Fresh(f.system),
198 allowAlways: map[string]bool{},220 allowAlways: map[string]bool{},
199 }221 }
222+ f.persistNew(sid, s)
223+ f.mu.Lock()
224+ f.sessions[sid] = s
200 f.mu.Unlock()225 f.mu.Unlock()
201 // mcpServers is where the client offers extra tools. bob's whole point is226 // mcpServers is where the client offers extra tools. bob's whole point is
202 // the opposite — one built-in tool — so they are acknowledged and ignored.227 // the opposite — one built-in tool — so they are acknowledged and ignored.
@@ -216,29 +241,18 @@ func (f *front) NewSession(ctx context.Context, p sdk.NewSessionRequest) (sdk.Ne
216 return sdk.NewSessionResponse{SessionId: sid}, nil241 return sdk.NewSessionResponse{SessionId: sid}, nil
217 }242 }
218 243
219-// compactCommand is the REPL's /compact, spelled here because ACP has no other
220-// place for it; /new is shared through internal/session. /quit and /abort are
221-// deliberately absent: the editor owns the process and ends it by closing
222-// stdin, and it cancels a turn with its own stop key through session/cancel.
223-const compactCommand = "/compact"
224-
225-// isCommand says whether input, trimmed, is exactly the given command — the
226-// same rule as session.IsNewCommand: "/compact " is the command, "/compact
227-// now" is a question for the model.
228-func isCommand(input, command string) bool {
229- return strings.TrimSpace(input) == command
230-}
231-
232 // availableCommands lists the slash commands this front end handles itself,244 // availableCommands lists the slash commands this front end handles itself,
233 // in the shape ACP advertises them: bare names, the client adds the slash.245 // in the shape ACP advertises them: bare names, the client adds the slash.
234-// Only what session/prompt actually intercepts belongs here — announcing246+// The list is the shared table's Editor slice (internal/command): only what
235-// /quit or /abort, which the REPL has and this front end does not, would247+// slashCommand intercepts — announcing /quit or /abort, which the REPL has
236-// promise the editor something the next prompt would hand to the model.248+// and this front end does not, would promise the editor something the next
249+// prompt would hand to the model.
237 func availableCommands() []sdk.AvailableCommand {250 func availableCommands() []sdk.AvailableCommand {
238- return []sdk.AvailableCommand{251+ var out []sdk.AvailableCommand
239- {Name: history.NewCommandName, Description: "Clear the history and start a new session"},252+ for _, c := range command.For(command.Editor) {
240- {Name: strings.TrimPrefix(compactCommand, "/"), Description: "Compress the history now, keeping the last turns"},253+ out = append(out, sdk.AvailableCommand{Name: c.Name, Description: c.Description})
241 }254 }
255+ return out
242 }256 }
243 257
244 // --- session/prompt -----------------------------------------------------------258 // --- session/prompt -----------------------------------------------------------
@@ -259,11 +273,8 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
259 // A slash command is answered here, without a turn: it is the REPL's "only273 // A slash command is answered here, without a turn: it is the REPL's "only
260 // between two generations" rule, met for free — turnMu guarantees no274 // between two generations" rule, met for free — turnMu guarantees no
261 // generation is running on this session's history while we replace it.275 // generation is running on this session's history while we replace it.
262- switch {276+ if resp, ok := f.slashCommand(ctx, p.SessionId, s, input); ok {
263- case history.IsNewCommand(input):277+ return resp, nil
264- return f.startNewSession(ctx, p.SessionId, s), nil
265- case isCommand(input, compactCommand):
266- return f.compactSession(ctx, p.SessionId, s), nil
267 }278 }
268 279
269 // One cancellable context per turn: session/cancel fires s.cancel, the280 // One cancellable context per turn: session/cancel fires s.cancel, the
@@ -296,20 +307,13 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
296 s.messages = append(s.messages, ai.NewUserTextMessage(input))307 s.messages = append(s.messages, ai.NewUserTextMessage(input))
297 before := len(s.messages)308 before := len(s.messages)
298 309
299- resp, history, err := f.e.Generate(turnCtx, s.messages, f.tools)310+ resp, full, err := f.e.Generate(turnCtx, s.messages, f.tools)
300 311
301 // Same rule as the REPL, for the same reason: keep the FULL history even312 // Same rule as the REPL, for the same reason: keep the FULL history even
302 // when the turn failed or was cancelled — the commands that already ran are313 // when the turn failed or was cancelled — the commands that already ran are
303 // part of the conversation, and forgetting them makes the model re-run or314 // part of the conversation, and forgetting them makes the model re-run or
304 // invent them on the next turn.315 // invent them on the next turn.
305- switch {316+ s.messages = history.AfterTurn(s.messages, before, resp, full, err)
306- case len(history) > before:
307- s.messages = history
308- case resp != nil && resp.Message != nil:
309- s.messages = append(s.messages, resp.Message)
310- case err != nil:
311- s.messages = s.messages[:before-1] // question without an answer: forget it
312- }
313 317
314 // The ⚙ recap of the REPL, one line on stderr: the editor already renders318 // The ⚙ recap of the REPL, one line on stderr: the editor already renders
315 // every tool call, but the trail is what lets `dev: open acp logs` say at a319 // every tool call, but the trail is what lets `dev: open acp logs` say at a
@@ -317,6 +321,7 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
317 turn := s.messages[min(before, len(s.messages)):]321 turn := s.messages[min(before, len(s.messages)):]
318 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",322 fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n",
319 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))323 len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn))
324+ f.persist(p.SessionId, s)
320 325
321 switch {326 switch {
322 case turnCtx.Err() != nil:327 case turnCtx.Err() != nil:
@@ -336,6 +341,18 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
336 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil341 return sdk.PromptResponse{StopReason: sdk.StopReasonEndTurn}, nil
337 }342 }
338 343
344+// slashCommand runs input when it is one of the commands this front end
345+// handles itself, and says whether it was. Called under turnMu.
346+func (f *front) slashCommand(ctx context.Context, sid sdk.SessionId, s *session, input string) (sdk.PromptResponse, bool) {
347+ switch {
348+ case command.Is(input, command.New):
349+ return f.startNewSession(ctx, sid, s), true
350+ case command.Is(input, command.Compact):
351+ return f.compactSession(ctx, sid, s), true
352+ }
353+ return sdk.PromptResponse{}, false
354+}
355+
339 // startNewSession is the ACP side of /new: the session keeps its id, its cwd356 // startNewSession is the ACP side of /new: the session keeps its id, its cwd
340 // and the "allow always" grants — the dialog promised them "for this session",357 // and the "allow always" grants — the dialog promised them "for this session",
341 // and the session, as the editor sees it, is the same one — but its history358 // and the session, as the editor sees it, is the same one — but its history
@@ -344,8 +361,10 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
344 // REPL prints travels as an agent message so the editor shows it in the361 // REPL prints travels as an agent message so the editor shows it in the
345 // thread, and the turn ends right there: the model is not consulted.362 // thread, and the turn ends right there: the model is not consulted.
346 func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {363 func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse {
364+ f.archive(sid, s)
347 forgotten := resetSession(s, f.system)365 forgotten := resetSession(s, f.system)
348 f.e.ForgetInputTokens()366 f.e.ForgetInputTokens()
367+ f.persist(sid, s)
349 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)368 fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten)
350 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{369 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
351 SessionId: sid,370 SessionId: sid,
@@ -366,6 +385,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
366 }, f.e.Explain)385 }, f.e.Explain)
367 if compressed {386 if compressed {
368 f.e.ForgetInputTokens() // the server's measure described the history just replaced387 f.e.ForgetInputTokens() // the server's measure described the history just replaced
388+ f.persist(sid, s)
369 }389 }
370 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)390 fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line)
371 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{391 _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
@@ -382,15 +402,9 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio
382 // injected, so the three outcomes are testable without an engine or a402 // injected, so the three outcomes are testable without an engine or a
383 // connection.403 // connection.
384 func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {404 func compactHistory(ctx context.Context, s *session, cfg config.ContextConfig, summarize compact.Summarizer, explain func(error) string) (line string, compressed bool) {
385- res, err := compact.Compact(ctx, s.messages, cfg, summarize)405+ var outcome compact.Outcome
386- switch {406+ s.messages, line, outcome = compact.Apply(ctx, s.messages, cfg, summarize, explain)
387- case errors.Is(err, compact.ErrNothingToCompact):407+ return line, outcome == compact.Compressed
388- return fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(s.messages), cfg.KeepLastTurns), false
389- case err != nil:
390- return fmt.Sprintf("[compact: failed, history kept: %s]", explain(err)), false
391- }
392- s.messages = res.Messages
393- return "🗜️ " + res.Report(), true
394 }408 }
395 409
396 // resetSession replaces the session's history with a fresh one and returns how410 // resetSession replaces the session's history with a fresh one and returns how
modified internal/acp/acp_test.go +3 -18
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "mm/internal/command"
1314 "mm/internal/config"
1415 history "mm/internal/session"
1516
@@ -54,7 +55,7 @@ func TestToolKindDefaultsToExecute(t *testing.T) {
5455 // command that works but is not listed is one the user never finds). Names
5556 // are bare: ACP clients add the slash.
5657 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
57- want := []string{history.NewCommandName, "compact"}
58+ want := []string{"compact", history.NewCommandName} // the shared table's display order
5859 cmds := availableCommands()
5960 if len(cmds) != len(want) {
6061 t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)
@@ -71,28 +72,12 @@ func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
7172 }
7273 // Each advertised name must be recognised when it comes back as "/name".
7374 typed := "/" + c.Name
74- if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) {
75+ if !command.Is(typed, command.New) && !command.Is(typed, command.Compact) {
7576 t.Errorf("%q is advertised but session/prompt would hand it to the model", typed)
7677 }
7778 }
7879 }
7980
80-// Same rule as /new: the command is the whole line, spaces aside.
81-func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) {
82- cases := map[string]bool{
83- "/compact": true,
84- " /compact\n": true,
85- "/compact now": false,
86- "/compacts": false,
87- "please /compact": false,
88- }
89- for in, want := range cases {
90- if got := isCommand(in, compactCommand); got != want {
91- t.Errorf("isCommand(%q) = %v, want %v", in, got, want)
92- }
93- }
94-}
95-
9681 // resetSession is what /new does to a session: the history goes back to the
9782 // system prompt alone, the count says how much was dropped, and everything
9883 // that is NOT the history — cwd, the "allow always" grants — survives, because
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 "time"11 "time"
12 12
13+ "mm/internal/command"
13 "mm/internal/config"14 "mm/internal/config"
14 history "mm/internal/session"15 history "mm/internal/session"
15 16
@@ -54,7 +55,7 @@ func TestToolKindDefaultsToExecute(t *testing.T) {
54 // command that works but is not listed is one the user never finds). Names55 // command that works but is not listed is one the user never finds). Names
55 // are bare: ACP clients add the slash.56 // are bare: ACP clients add the slash.
56 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {57 func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
57- want := []string{history.NewCommandName, "compact"}58+ want := []string{"compact", history.NewCommandName} // the shared table's display order
58 cmds := availableCommands()59 cmds := availableCommands()
59 if len(cmds) != len(want) {60 if len(cmds) != len(want) {
60 t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)61 t.Fatalf("availableCommands: %d command(s), want %d: %+v", len(cmds), len(want), cmds)
@@ -71,28 +72,12 @@ func TestAvailableCommandsMatchWhatPromptIntercepts(t *testing.T) {
71 }72 }
72 // Each advertised name must be recognised when it comes back as "/name".73 // Each advertised name must be recognised when it comes back as "/name".
73 typed := "/" + c.Name74 typed := "/" + c.Name
74- if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) {75+ if !command.Is(typed, command.New) && !command.Is(typed, command.Compact) {
75 t.Errorf("%q is advertised but session/prompt would hand it to the model", typed)76 t.Errorf("%q is advertised but session/prompt would hand it to the model", typed)
76 }77 }
77 }78 }
78 }79 }
79 80
80-// Same rule as /new: the command is the whole line, spaces aside.
81-func TestIsCommandTrimsAndRequiresExactMatch(t *testing.T) {
82- cases := map[string]bool{
83- "/compact": true,
84- " /compact\n": true,
85- "/compact now": false,
86- "/compacts": false,
87- "please /compact": false,
88- }
89- for in, want := range cases {
90- if got := isCommand(in, compactCommand); got != want {
91- t.Errorf("isCommand(%q) = %v, want %v", in, got, want)
92- }
93- }
94-}
95-
96 // resetSession is what /new does to a session: the history goes back to the81 // resetSession is what /new does to a session: the history goes back to the
97 // system prompt alone, the count says how much was dropped, and everything82 // system prompt alone, the count says how much was dropped, and everything
98 // that is NOT the history — cwd, the "allow always" grants — survives, because83 // that is NOT the history — cwd, the "allow always" grants — survives, because
added internal/acp/persist.go +112 -0
new file mode 100644
@@ -0,0 +1,112 @@
1+package acp
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "time"
9+
10+ history "mm/internal/session"
11+ "mm/internal/store"
12+
13+ sdk "github.com/coder/acp-go-sdk"
14+)
15+
16+// Persistence over ACP. When sessions are on (config `sessions.enabled`),
17+// each ACP session owns one file named after its id in the project's store
18+// (`sessions.dir` under the session's cwd), rewritten whole after every turn
19+// and every command that changes the history. A write failure is logged and
20+// the session goes on unpersisted: a read-only project must not break the
21+// editor. session/load is what an editor's "reload agent" calls: it reads
22+// that file back and replays it.
23+
24+var _ sdk.AgentLoader = (*front)(nil)
25+
26+// LoadSession implements session/load: the editor restarted the agent and
27+// wants its thread back. The stored history is replayed to the editor as
28+// session/update notifications before the response — the spec's contract,
29+// and the editor accepts them because it asked for this id — then becomes
30+// the session's history again. Nothing is executed again (see
31+// replayUpdates). Without persistence the method does not exist, as
32+// initialize said.
33+func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
34+ if f.sessionsDir == "" {
35+ return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad)
36+ }
37+ st := f.storeFor(p.Cwd)
38+ rec, err := st.Load(string(p.SessionId))
39+ if err != nil {
40+ fmt.Fprintf(os.Stderr, "[acp] session/load %s in %s: %v\n", p.SessionId, st.Dir(), err)
41+ return sdk.LoadSessionResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId, "error": err.Error()})
42+ }
43+ // The stored history continues under TODAY's system prompt: the file may
44+ // predate a change to agent.yaml, and the prompt is the agent's, not the
45+ // conversation's.
46+ rec.Messages = history.WithSystem(rec.Messages, f.system)
47+ s := &session{cwd: p.Cwd, messages: rec.Messages, allowAlways: map[string]bool{}, record: rec, st: st}
48+ f.mu.Lock()
49+ f.sessions[p.SessionId] = s
50+ f.mu.Unlock()
51+
52+ for _, u := range replayUpdates(rec.Messages) {
53+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u})
54+ }
55+ // The commands list travels with the replay: the responseWriter hook
56+ // cannot help here, a session/load response carries no session id.
57+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
58+ SessionId: p.SessionId,
59+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
60+ })
61+ fmt.Fprintf(os.Stderr, "[acp] session %s loaded from %s: %d message(s) replayed\n", p.SessionId, st.Dir(), len(rec.Messages)-1)
62+ return sdk.LoadSessionResponse{}, nil
63+}
64+
65+// storeFor is the project's store: sessionsDir under cwd, or as-is when
66+// absolute.
67+func (f *front) storeFor(cwd string) *store.Store {
68+ dir := f.sessionsDir
69+ if !filepath.IsAbs(dir) {
70+ dir = filepath.Join(cwd, dir)
71+ }
72+ return store.Open(dir)
73+}
74+
75+// persistNew gives a fresh session its file, under the ACP id.
76+func (f *front) persistNew(sid sdk.SessionId, s *session) {
77+ if f.sessionsDir == "" {
78+ return
79+ }
80+ s.st = f.storeFor(s.cwd)
81+ now := time.Now().UTC()
82+ s.record = &store.Session{ID: string(sid), Cwd: s.cwd, CreatedAt: now, UpdatedAt: now}
83+ f.persist(sid, s)
84+}
85+
86+// persist rewrites the session's file with its current history.
87+func (f *front) persist(sid sdk.SessionId, s *session) {
88+ if s.record == nil {
89+ return
90+ }
91+ s.record.Messages = s.messages
92+ if err := s.st.Save(s.record); err != nil {
93+ fmt.Fprintf(os.Stderr, "[acp] session %s: not saved: %v\n", sid, err)
94+ }
95+}
96+
97+// archive is /new's side of persistence. The editor keeps its session id, so
98+// that id's file must become the new, empty session — but the old
99+// conversation stays on disk under a fresh id, like a terminal session left
100+// behind by /new. Nothing to keep when the history is just the prompt.
101+func (f *front) archive(sid sdk.SessionId, s *session) {
102+ if s.record == nil || history.Forgotten(s.messages) == 0 {
103+ return
104+ }
105+ old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages}
106+ if err := s.st.Save(old); err != nil {
107+ fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation not archived: %v\n", sid, err)
108+ return
109+ }
110+ s.record.CreatedAt = time.Now().UTC()
111+ fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation kept as %s\n", sid, old.ID)
112+}
new file mode 100644
@@ -0,0 +1,112 @@
1+package acp
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "time"
9+
10+ history "mm/internal/session"
11+ "mm/internal/store"
12+
13+ sdk "github.com/coder/acp-go-sdk"
14+)
15+
16+// Persistence over ACP. When sessions are on (config `sessions.enabled`),
17+// each ACP session owns one file named after its id in the project's store
18+// (`sessions.dir` under the session's cwd), rewritten whole after every turn
19+// and every command that changes the history. A write failure is logged and
20+// the session goes on unpersisted: a read-only project must not break the
21+// editor. session/load is what an editor's "reload agent" calls: it reads
22+// that file back and replays it.
23+
24+var _ sdk.AgentLoader = (*front)(nil)
25+
26+// LoadSession implements session/load: the editor restarted the agent and
27+// wants its thread back. The stored history is replayed to the editor as
28+// session/update notifications before the response — the spec's contract,
29+// and the editor accepts them because it asked for this id — then becomes
30+// the session's history again. Nothing is executed again (see
31+// replayUpdates). Without persistence the method does not exist, as
32+// initialize said.
33+func (f *front) LoadSession(ctx context.Context, p sdk.LoadSessionRequest) (sdk.LoadSessionResponse, error) {
34+ if f.sessionsDir == "" {
35+ return sdk.LoadSessionResponse{}, sdk.NewMethodNotFound(sdk.AgentMethodSessionLoad)
36+ }
37+ st := f.storeFor(p.Cwd)
38+ rec, err := st.Load(string(p.SessionId))
39+ if err != nil {
40+ fmt.Fprintf(os.Stderr, "[acp] session/load %s in %s: %v\n", p.SessionId, st.Dir(), err)
41+ return sdk.LoadSessionResponse{}, sdk.NewInvalidParams(map[string]any{"sessionId": p.SessionId, "error": err.Error()})
42+ }
43+ // The stored history continues under TODAY's system prompt: the file may
44+ // predate a change to agent.yaml, and the prompt is the agent's, not the
45+ // conversation's.
46+ rec.Messages = history.WithSystem(rec.Messages, f.system)
47+ s := &session{cwd: p.Cwd, messages: rec.Messages, allowAlways: map[string]bool{}, record: rec, st: st}
48+ f.mu.Lock()
49+ f.sessions[p.SessionId] = s
50+ f.mu.Unlock()
51+
52+ for _, u := range replayUpdates(rec.Messages) {
53+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{SessionId: p.SessionId, Update: u})
54+ }
55+ // The commands list travels with the replay: the responseWriter hook
56+ // cannot help here, a session/load response carries no session id.
57+ _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{
58+ SessionId: p.SessionId,
59+ Update: sdk.SessionUpdate{AvailableCommandsUpdate: &sdk.SessionAvailableCommandsUpdate{AvailableCommands: availableCommands()}},
60+ })
61+ fmt.Fprintf(os.Stderr, "[acp] session %s loaded from %s: %d message(s) replayed\n", p.SessionId, st.Dir(), len(rec.Messages)-1)
62+ return sdk.LoadSessionResponse{}, nil
63+}
64+
65+// storeFor is the project's store: sessionsDir under cwd, or as-is when
66+// absolute.
67+func (f *front) storeFor(cwd string) *store.Store {
68+ dir := f.sessionsDir
69+ if !filepath.IsAbs(dir) {
70+ dir = filepath.Join(cwd, dir)
71+ }
72+ return store.Open(dir)
73+}
74+
75+// persistNew gives a fresh session its file, under the ACP id.
76+func (f *front) persistNew(sid sdk.SessionId, s *session) {
77+ if f.sessionsDir == "" {
78+ return
79+ }
80+ s.st = f.storeFor(s.cwd)
81+ now := time.Now().UTC()
82+ s.record = &store.Session{ID: string(sid), Cwd: s.cwd, CreatedAt: now, UpdatedAt: now}
83+ f.persist(sid, s)
84+}
85+
86+// persist rewrites the session's file with its current history.
87+func (f *front) persist(sid sdk.SessionId, s *session) {
88+ if s.record == nil {
89+ return
90+ }
91+ s.record.Messages = s.messages
92+ if err := s.st.Save(s.record); err != nil {
93+ fmt.Fprintf(os.Stderr, "[acp] session %s: not saved: %v\n", sid, err)
94+ }
95+}
96+
97+// archive is /new's side of persistence. The editor keeps its session id, so
98+// that id's file must become the new, empty session — but the old
99+// conversation stays on disk under a fresh id, like a terminal session left
100+// behind by /new. Nothing to keep when the history is just the prompt.
101+func (f *front) archive(sid sdk.SessionId, s *session) {
102+ if s.record == nil || history.Forgotten(s.messages) == 0 {
103+ return
104+ }
105+ old := &store.Session{ID: store.NewID(), Cwd: s.cwd, CreatedAt: s.record.CreatedAt, Messages: s.messages}
106+ if err := s.st.Save(old); err != nil {
107+ fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation not archived: %v\n", sid, err)
108+ return
109+ }
110+ s.record.CreatedAt = time.Now().UTC()
111+ fmt.Fprintf(os.Stderr, "[acp] session %s: previous conversation kept as %s\n", sid, old.ID)
112+}
added internal/acp/persist_test.go +241 -0
new file mode 100644
@@ -0,0 +1,241 @@
1+package acp
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "mm/internal/config"
15+ "mm/internal/store"
16+
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// withSessions turns persistence on for one test and restores the defaults.
21+func withSessions(t *testing.T) {
22+ t.Helper()
23+ saved := config.Cfg.Sessions
24+ config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}
25+ t.Cleanup(func() { config.Cfg.Sessions = saved })
26+}
27+
28+// wire drives a front over pipes like an editor would and hands back the raw
29+// lines the agent writes, in order.
30+type wire struct {
31+ t *testing.T
32+ in *io.PipeWriter
33+ lines chan string
34+}
35+
36+func newWire(t *testing.T, system string) *wire {
37+ t.Helper()
38+ clientToAgentR, clientToAgentW := io.Pipe()
39+ agentToClientR, agentToClientW := io.Pipe()
40+ t.Cleanup(func() { _ = clientToAgentW.Close(); _ = agentToClientR.Close() })
41+ newFront(nil, system, nil, agentToClientW, clientToAgentR)
42+ lines := make(chan string, 64)
43+ go func() {
44+ sc := bufio.NewScanner(agentToClientR)
45+ sc.Buffer(make([]byte, 1<<20), 1<<20)
46+ for sc.Scan() {
47+ lines <- sc.Text()
48+ }
49+ close(lines)
50+ }()
51+ return &wire{t: t, in: clientToAgentW, lines: lines}
52+}
53+
54+func (w *wire) send(id int, method string, params string) {
55+ go func() {
56+ _, _ = io.WriteString(w.in, `{"jsonrpc":"2.0","id":`+itoa(id)+`,"method":"`+method+`","params":`+params+"}\n")
57+ }()
58+}
59+
60+// until reads lines up to and including the response carrying id, returning
61+// the notifications seen before it and the response itself.
62+func (w *wire) until(id int) (notifications []map[string]any, response map[string]any) {
63+ w.t.Helper()
64+ for {
65+ select {
66+ case l, ok := <-w.lines:
67+ if !ok {
68+ w.t.Fatal("agent closed its output")
69+ }
70+ var m map[string]any
71+ if err := json.Unmarshal([]byte(l), &m); err != nil {
72+ w.t.Fatalf("not JSON: %s", l)
73+ }
74+ if rid, ok := m["id"].(float64); ok && int(rid) == id && m["method"] == nil {
75+ return notifications, m
76+ }
77+ if m["method"] == "session/update" {
78+ notifications = append(notifications, m["params"].(map[string]any)["update"].(map[string]any))
79+ }
80+ case <-time.After(5 * time.Second):
81+ w.t.Fatalf("timed out waiting for the response to request %d", id)
82+ }
83+ }
84+}
85+
86+func itoa(i int) string { return strconv.Itoa(i) }
87+
88+func storedHistory() []*ai.Message {
89+ return []*ai.Message{
90+ ai.NewSystemTextMessage("old prompt"),
91+ ai.NewUserTextMessage("list the files"),
92+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_1", Name: "bash", Input: map[string]any{"command": "ls"}})),
93+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "call_1", Name: "bash", Output: "main.go\n"})),
94+ ai.NewModelMessage(ai.NewTextPart("One file: main.go.")),
95+ }
96+}
97+
98+// An editor's "reload agent" is a session/load for the id it had: the stored
99+// conversation comes back as notifications — question, finished tool call,
100+// answer — then the commands list, then the response; and the session is
101+// live again, under the CURRENT system prompt, with the same file.
102+func TestLoadSessionReplaysStoredHistory(t *testing.T) {
103+ withSessions(t)
104+ cwd := t.TempDir()
105+ st := store.Open(filepath.Join(cwd, ".mm", "sessions"))
106+ if err := st.Save(&store.Session{ID: "20260916-100000-aaaaaaaa", Cwd: cwd, Messages: storedHistory()}); err != nil {
107+ t.Fatal(err)
108+ }
109+
110+ w := newWire(t, "new prompt")
111+ w.send(1, "session/load", `{"sessionId":"20260916-100000-aaaaaaaa","cwd":"`+cwd+`","mcpServers":[]}`)
112+ notes, resp := w.until(1)
113+ if resp["error"] != nil {
114+ t.Fatalf("session/load failed: %v", resp["error"])
115+ }
116+
117+ kinds := make([]string, 0, len(notes))
118+ for _, n := range notes {
119+ kinds = append(kinds, n["sessionUpdate"].(string))
120+ }
121+ want := "user_message_chunk tool_call agent_message_chunk available_commands_update"
122+ if got := strings.Join(kinds, " "); got != want {
123+ t.Fatalf("replayed:\n got %s\nwant %s", got, want)
124+ }
125+ if notes[0]["content"].(map[string]any)["text"] != "list the files" {
126+ t.Errorf("user chunk = %v", notes[0]["content"])
127+ }
128+ call := notes[1]
129+ if call["toolCallId"] != "call_1" || call["status"] != "completed" || call["title"] != "bash: ls" {
130+ t.Errorf("tool_call = %v", call)
131+ }
132+ if got := call["content"].([]any)[0].(map[string]any)["content"].(map[string]any)["text"]; got != "main.go\n" {
133+ t.Errorf("tool_call content = %v", got)
134+ }
135+
136+ // The session is live: known to Prompt (no "invalid params"), and its
137+ // history runs under the new prompt. Prompt would call the nil engine, so
138+ // look at the front's state through a second load instead of a prompt.
139+ f := frontOf(t, w)
140+ s := f.sessions["20260916-100000-aaaaaaaa"]
141+ if s == nil {
142+ t.Fatal("loaded session not registered")
143+ }
144+ if s.messages[0].Text() != "new prompt" || len(s.messages) != len(storedHistory()) {
145+ t.Errorf("history after load: %d messages, prompt %q", len(s.messages), s.messages[0].Text())
146+ }
147+ if s.record == nil || s.record.ID != "20260916-100000-aaaaaaaa" {
148+ t.Errorf("loaded session has no record to keep saving to: %+v", s.record)
149+ }
150+}
151+
152+// frontOf recovers the front behind a wire: the test double registers it in
153+// lastFront when built.
154+func frontOf(t *testing.T, _ *wire) *front {
155+ t.Helper()
156+ if lastFront == nil {
157+ t.Fatal("no front built")
158+ }
159+ return lastFront
160+}
161+
162+// Unknown id, or persistence off: two different refusals, both explicit.
163+func TestLoadSessionRefusals(t *testing.T) {
164+ w := newWire(t, "sys")
165+ w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
166+ _, resp := w.until(1)
167+ if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
168+ t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"])
169+ }
170+
171+ withSessions(t)
172+ w = newWire(t, "sys")
173+ w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
174+ _, resp = w.until(2)
175+ if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 {
176+ t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"])
177+ }
178+}
179+
180+// With persistence on, initialize says so and session/new writes the
181+// session's file at once — one file, named after the id, holding the prompt.
182+func TestNewSessionIsPersistedAndAdvertised(t *testing.T) {
183+ withSessions(t)
184+ cwd := t.TempDir()
185+ w := newWire(t, "sys")
186+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
187+ _, init := w.until(1)
188+ if caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any); caps["loadSession"] != true {
189+ t.Errorf("agentCapabilities = %v, want loadSession true", caps)
190+ }
191+ w.send(2, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`)
192+ _, resp := w.until(2)
193+ sid := resp["result"].(map[string]any)["sessionId"].(string)
194+ file := filepath.Join(cwd, ".mm", "sessions", sid+".json")
195+ data, err := os.ReadFile(file)
196+ if err != nil {
197+ t.Fatalf("session file not written: %v", err)
198+ }
199+ var rec store.Session
200+ if err := json.Unmarshal(data, &rec); err != nil || rec.ID != sid || len(rec.Messages) != 1 || rec.Cwd != cwd {
201+ t.Errorf("stored %+v (%v), want id %s, cwd %s, 1 message", rec, err, sid, cwd)
202+ }
203+}
204+
205+// /new over ACP: the editor keeps its id, so the id's file becomes the fresh
206+// session and the old conversation is kept under a new id. An empty
207+// conversation leaves nothing behind.
208+func TestArchiveKeepsTheOldConversationUnderANewID(t *testing.T) {
209+ withSessions(t)
210+ cwd := t.TempDir()
211+ f := &front{sessionsDir: ".mm/sessions", system: "sys"}
212+ s := &session{cwd: cwd, messages: storedHistory(), allowAlways: map[string]bool{}}
213+ f.persistNew("id-1", s)
214+
215+ f.archive("id-1", s)
216+ resetSession(s, "sys")
217+ f.persist("id-1", s)
218+
219+ list := s.st.List()
220+ if len(list) != 2 {
221+ t.Fatalf("%d stored sessions after /new, want 2: %+v", len(list), list)
222+ }
223+ current, _ := s.st.Load("id-1")
224+ if len(current.Messages) != 1 {
225+ t.Errorf("current file holds %d messages, want the prompt alone", len(current.Messages))
226+ }
227+ var archived *store.Summary
228+ for i := range list {
229+ if list[i].ID != "id-1" {
230+ archived = &list[i]
231+ }
232+ }
233+ if archived == nil || archived.FirstQuestion != "list the files" {
234+ t.Errorf("archived conversation not found: %+v", list)
235+ }
236+
237+ f.archive("id-1", s) // nothing to keep now
238+ if got := len(s.st.List()); got != 2 {
239+ t.Errorf("archiving an empty conversation created a file: %d sessions", got)
240+ }
241+}
new file mode 100644
@@ -0,0 +1,241 @@
1+package acp
2+
3+import (
4+ "bufio"
5+ "encoding/json"
6+ "io"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+ "testing"
12+ "time"
13+
14+ "mm/internal/config"
15+ "mm/internal/store"
16+
17+ "github.com/firebase/genkit/go/ai"
18+)
19+
20+// withSessions turns persistence on for one test and restores the defaults.
21+func withSessions(t *testing.T) {
22+ t.Helper()
23+ saved := config.Cfg.Sessions
24+ config.Cfg.Sessions = config.SessionsConfig{Enabled: true, Dir: ".mm/sessions"}
25+ t.Cleanup(func() { config.Cfg.Sessions = saved })
26+}
27+
28+// wire drives a front over pipes like an editor would and hands back the raw
29+// lines the agent writes, in order.
30+type wire struct {
31+ t *testing.T
32+ in *io.PipeWriter
33+ lines chan string
34+}
35+
36+func newWire(t *testing.T, system string) *wire {
37+ t.Helper()
38+ clientToAgentR, clientToAgentW := io.Pipe()
39+ agentToClientR, agentToClientW := io.Pipe()
40+ t.Cleanup(func() { _ = clientToAgentW.Close(); _ = agentToClientR.Close() })
41+ newFront(nil, system, nil, agentToClientW, clientToAgentR)
42+ lines := make(chan string, 64)
43+ go func() {
44+ sc := bufio.NewScanner(agentToClientR)
45+ sc.Buffer(make([]byte, 1<<20), 1<<20)
46+ for sc.Scan() {
47+ lines <- sc.Text()
48+ }
49+ close(lines)
50+ }()
51+ return &wire{t: t, in: clientToAgentW, lines: lines}
52+}
53+
54+func (w *wire) send(id int, method string, params string) {
55+ go func() {
56+ _, _ = io.WriteString(w.in, `{"jsonrpc":"2.0","id":`+itoa(id)+`,"method":"`+method+`","params":`+params+"}\n")
57+ }()
58+}
59+
60+// until reads lines up to and including the response carrying id, returning
61+// the notifications seen before it and the response itself.
62+func (w *wire) until(id int) (notifications []map[string]any, response map[string]any) {
63+ w.t.Helper()
64+ for {
65+ select {
66+ case l, ok := <-w.lines:
67+ if !ok {
68+ w.t.Fatal("agent closed its output")
69+ }
70+ var m map[string]any
71+ if err := json.Unmarshal([]byte(l), &m); err != nil {
72+ w.t.Fatalf("not JSON: %s", l)
73+ }
74+ if rid, ok := m["id"].(float64); ok && int(rid) == id && m["method"] == nil {
75+ return notifications, m
76+ }
77+ if m["method"] == "session/update" {
78+ notifications = append(notifications, m["params"].(map[string]any)["update"].(map[string]any))
79+ }
80+ case <-time.After(5 * time.Second):
81+ w.t.Fatalf("timed out waiting for the response to request %d", id)
82+ }
83+ }
84+}
85+
86+func itoa(i int) string { return strconv.Itoa(i) }
87+
88+func storedHistory() []*ai.Message {
89+ return []*ai.Message{
90+ ai.NewSystemTextMessage("old prompt"),
91+ ai.NewUserTextMessage("list the files"),
92+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_1", Name: "bash", Input: map[string]any{"command": "ls"}})),
93+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "call_1", Name: "bash", Output: "main.go\n"})),
94+ ai.NewModelMessage(ai.NewTextPart("One file: main.go.")),
95+ }
96+}
97+
98+// An editor's "reload agent" is a session/load for the id it had: the stored
99+// conversation comes back as notifications — question, finished tool call,
100+// answer — then the commands list, then the response; and the session is
101+// live again, under the CURRENT system prompt, with the same file.
102+func TestLoadSessionReplaysStoredHistory(t *testing.T) {
103+ withSessions(t)
104+ cwd := t.TempDir()
105+ st := store.Open(filepath.Join(cwd, ".mm", "sessions"))
106+ if err := st.Save(&store.Session{ID: "20260916-100000-aaaaaaaa", Cwd: cwd, Messages: storedHistory()}); err != nil {
107+ t.Fatal(err)
108+ }
109+
110+ w := newWire(t, "new prompt")
111+ w.send(1, "session/load", `{"sessionId":"20260916-100000-aaaaaaaa","cwd":"`+cwd+`","mcpServers":[]}`)
112+ notes, resp := w.until(1)
113+ if resp["error"] != nil {
114+ t.Fatalf("session/load failed: %v", resp["error"])
115+ }
116+
117+ kinds := make([]string, 0, len(notes))
118+ for _, n := range notes {
119+ kinds = append(kinds, n["sessionUpdate"].(string))
120+ }
121+ want := "user_message_chunk tool_call agent_message_chunk available_commands_update"
122+ if got := strings.Join(kinds, " "); got != want {
123+ t.Fatalf("replayed:\n got %s\nwant %s", got, want)
124+ }
125+ if notes[0]["content"].(map[string]any)["text"] != "list the files" {
126+ t.Errorf("user chunk = %v", notes[0]["content"])
127+ }
128+ call := notes[1]
129+ if call["toolCallId"] != "call_1" || call["status"] != "completed" || call["title"] != "bash: ls" {
130+ t.Errorf("tool_call = %v", call)
131+ }
132+ if got := call["content"].([]any)[0].(map[string]any)["content"].(map[string]any)["text"]; got != "main.go\n" {
133+ t.Errorf("tool_call content = %v", got)
134+ }
135+
136+ // The session is live: known to Prompt (no "invalid params"), and its
137+ // history runs under the new prompt. Prompt would call the nil engine, so
138+ // look at the front's state through a second load instead of a prompt.
139+ f := frontOf(t, w)
140+ s := f.sessions["20260916-100000-aaaaaaaa"]
141+ if s == nil {
142+ t.Fatal("loaded session not registered")
143+ }
144+ if s.messages[0].Text() != "new prompt" || len(s.messages) != len(storedHistory()) {
145+ t.Errorf("history after load: %d messages, prompt %q", len(s.messages), s.messages[0].Text())
146+ }
147+ if s.record == nil || s.record.ID != "20260916-100000-aaaaaaaa" {
148+ t.Errorf("loaded session has no record to keep saving to: %+v", s.record)
149+ }
150+}
151+
152+// frontOf recovers the front behind a wire: the test double registers it in
153+// lastFront when built.
154+func frontOf(t *testing.T, _ *wire) *front {
155+ t.Helper()
156+ if lastFront == nil {
157+ t.Fatal("no front built")
158+ }
159+ return lastFront
160+}
161+
162+// Unknown id, or persistence off: two different refusals, both explicit.
163+func TestLoadSessionRefusals(t *testing.T) {
164+ w := newWire(t, "sys")
165+ w.send(1, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
166+ _, resp := w.until(1)
167+ if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32601 {
168+ t.Errorf("persistence off: %v, want method not found (-32601)", resp["error"])
169+ }
170+
171+ withSessions(t)
172+ w = newWire(t, "sys")
173+ w.send(2, "session/load", `{"sessionId":"nope","cwd":"`+t.TempDir()+`","mcpServers":[]}`)
174+ _, resp = w.until(2)
175+ if e, _ := resp["error"].(map[string]any); e == nil || e["code"].(float64) != -32602 {
176+ t.Errorf("unknown id: %v, want invalid params (-32602)", resp["error"])
177+ }
178+}
179+
180+// With persistence on, initialize says so and session/new writes the
181+// session's file at once — one file, named after the id, holding the prompt.
182+func TestNewSessionIsPersistedAndAdvertised(t *testing.T) {
183+ withSessions(t)
184+ cwd := t.TempDir()
185+ w := newWire(t, "sys")
186+ w.send(1, "initialize", `{"protocolVersion":1,"clientCapabilities":{}}`)
187+ _, init := w.until(1)
188+ if caps := init["result"].(map[string]any)["agentCapabilities"].(map[string]any); caps["loadSession"] != true {
189+ t.Errorf("agentCapabilities = %v, want loadSession true", caps)
190+ }
191+ w.send(2, "session/new", `{"cwd":"`+cwd+`","mcpServers":[]}`)
192+ _, resp := w.until(2)
193+ sid := resp["result"].(map[string]any)["sessionId"].(string)
194+ file := filepath.Join(cwd, ".mm", "sessions", sid+".json")
195+ data, err := os.ReadFile(file)
196+ if err != nil {
197+ t.Fatalf("session file not written: %v", err)
198+ }
199+ var rec store.Session
200+ if err := json.Unmarshal(data, &rec); err != nil || rec.ID != sid || len(rec.Messages) != 1 || rec.Cwd != cwd {
201+ t.Errorf("stored %+v (%v), want id %s, cwd %s, 1 message", rec, err, sid, cwd)
202+ }
203+}
204+
205+// /new over ACP: the editor keeps its id, so the id's file becomes the fresh
206+// session and the old conversation is kept under a new id. An empty
207+// conversation leaves nothing behind.
208+func TestArchiveKeepsTheOldConversationUnderANewID(t *testing.T) {
209+ withSessions(t)
210+ cwd := t.TempDir()
211+ f := &front{sessionsDir: ".mm/sessions", system: "sys"}
212+ s := &session{cwd: cwd, messages: storedHistory(), allowAlways: map[string]bool{}}
213+ f.persistNew("id-1", s)
214+
215+ f.archive("id-1", s)
216+ resetSession(s, "sys")
217+ f.persist("id-1", s)
218+
219+ list := s.st.List()
220+ if len(list) != 2 {
221+ t.Fatalf("%d stored sessions after /new, want 2: %+v", len(list), list)
222+ }
223+ current, _ := s.st.Load("id-1")
224+ if len(current.Messages) != 1 {
225+ t.Errorf("current file holds %d messages, want the prompt alone", len(current.Messages))
226+ }
227+ var archived *store.Summary
228+ for i := range list {
229+ if list[i].ID != "id-1" {
230+ archived = &list[i]
231+ }
232+ }
233+ if archived == nil || archived.FirstQuestion != "list the files" {
234+ t.Errorf("archived conversation not found: %+v", list)
235+ }
236+
237+ f.archive("id-1", s) // nothing to keep now
238+ if got := len(s.st.List()); got != 2 {
239+ t.Errorf("archiving an empty conversation created a file: %d sessions", got)
240+ }
241+}
added internal/acp/replay.go +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+package acp
2+
3+import (
4+ "fmt"
5+
6+ "mm/internal/compact"
7+
8+ sdk "github.com/coder/acp-go-sdk"
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// replayUpdates turns a stored history into the session/update notifications
13+// that rebuild the thread in the editor: one user_message_chunk per question,
14+// one agent_message_chunk per answer text, one tool_call per recorded call —
15+// already `completed`, carrying the input the model gave and the output the
16+// tool returned at the time. Nothing runs again: this is a reading of the
17+// record, in the shapes the sink sends live (sink.go), so the reloaded thread
18+// looks like the original one.
19+//
20+// The system prompt is not shown (the editor never saw it live either). A
21+// compression summary — a user-role message the model was asked to continue
22+// from — is shown as an agent message: to the reader it is the agent's
23+// recollection, not a question they typed.
24+func replayUpdates(msgs []*ai.Message) []sdk.SessionUpdate {
25+ responses := toolResponses(msgs)
26+ var out []sdk.SessionUpdate
27+ for i, m := range msgs {
28+ if m == nil {
29+ continue
30+ }
31+ switch m.Role {
32+ case ai.RoleUser:
33+ if compact.IsSummary(m) {
34+ out = append(out, sdk.UpdateAgentMessageText("🗜️ "+m.Text()))
35+ } else if text := m.Text(); text != "" {
36+ out = append(out, sdk.UpdateUserMessageText(text))
37+ }
38+ case ai.RoleModel:
39+ out = append(out, replayModelMessage(m, i, responses)...)
40+ }
41+ }
42+ return out
43+}
44+
45+// replayModelMessage renders one model message: its text, then each tool
46+// call it made, paired with the recorded response when there is one.
47+func replayModelMessage(m *ai.Message, index int, responses map[string]*ai.ToolResponse) []sdk.SessionUpdate {
48+ var out []sdk.SessionUpdate
49+ for j, part := range m.Content {
50+ switch {
51+ case part.IsText() && part.Text != "":
52+ out = append(out, sdk.UpdateAgentMessageText(part.Text))
53+ case part.IsToolRequest() && part.ToolRequest != nil:
54+ out = append(out, replayToolCall(part.ToolRequest, responses[part.ToolRequest.Ref], fmt.Sprintf("replay_%d_%d", index, j)))
55+ }
56+ }
57+ return out
58+}
59+
60+// replayToolCall is one recorded call as a single, already-finished tool_call.
61+// A call whose response was never recorded (the turn was cancelled between
62+// the two) is shown as failed, with no content — what the reader saw then.
63+func replayToolCall(req *ai.ToolRequest, resp *ai.ToolResponse, fallbackID string) sdk.SessionUpdate {
64+ id := req.Ref
65+ if id == "" {
66+ id = fallbackID
67+ }
68+ title, kind := toolPresentation(req.Name, req.Input)
69+ opts := []sdk.ToolCallStartOpt{
70+ sdk.WithStartKind(kind),
71+ sdk.WithStartRawInput(req.Input),
72+ }
73+ if resp == nil {
74+ opts = append(opts, sdk.WithStartStatus(sdk.ToolCallStatusFailed))
75+ return sdk.StartToolCall(sdk.ToolCallId(id), title, opts...)
76+ }
77+ output := fmt.Sprint(resp.Output)
78+ opts = append(opts,
79+ sdk.WithStartStatus(sdk.ToolCallStatusCompleted),
80+ sdk.WithStartRawOutput(map[string]any{"output": output}),
81+ )
82+ if output != "" {
83+ opts = append(opts, sdk.WithStartContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock(output))}))
84+ }
85+ return sdk.StartToolCall(sdk.ToolCallId(id), title, opts...)
86+}
87+
88+// toolPresentation gives a recorded call the title and kind the live tool
89+// would have given it (tools.go: "bash: <command>", "read_skill: <name>"…),
90+// from the tool's name and the one string argument each tool takes.
91+func toolPresentation(name string, input any) (string, sdk.ToolKind) {
92+ title := name
93+ if args, ok := input.(map[string]any); ok {
94+ for _, key := range []string{"command", "path", "name"} {
95+ if v, ok := args[key].(string); ok && v != "" {
96+ title = name + ": " + v
97+ break
98+ }
99+ }
100+ }
101+ switch name {
102+ case "read_file", "read_skill":
103+ return title, sdk.ToolKindRead
104+ case "write_file", "edit_file":
105+ return title, sdk.ToolKindEdit
106+ }
107+ return title, sdk.ToolKindExecute
108+}
109+
110+// toolResponses indexes the tool messages' responses by the call they answer.
111+func toolResponses(msgs []*ai.Message) map[string]*ai.ToolResponse {
112+ out := map[string]*ai.ToolResponse{}
113+ for _, m := range msgs {
114+ if m == nil || m.Role != ai.RoleTool {
115+ continue
116+ }
117+ for _, part := range m.Content {
118+ if part.IsToolResponse() && part.ToolResponse != nil && part.ToolResponse.Ref != "" {
119+ out[part.ToolResponse.Ref] = part.ToolResponse
120+ }
121+ }
122+ }
123+ return out
124+}
new file mode 100644
@@ -0,0 +1,124 @@
1+package acp
2+
3+import (
4+ "fmt"
5+
6+ "mm/internal/compact"
7+
8+ sdk "github.com/coder/acp-go-sdk"
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// replayUpdates turns a stored history into the session/update notifications
13+// that rebuild the thread in the editor: one user_message_chunk per question,
14+// one agent_message_chunk per answer text, one tool_call per recorded call —
15+// already `completed`, carrying the input the model gave and the output the
16+// tool returned at the time. Nothing runs again: this is a reading of the
17+// record, in the shapes the sink sends live (sink.go), so the reloaded thread
18+// looks like the original one.
19+//
20+// The system prompt is not shown (the editor never saw it live either). A
21+// compression summary — a user-role message the model was asked to continue
22+// from — is shown as an agent message: to the reader it is the agent's
23+// recollection, not a question they typed.
24+func replayUpdates(msgs []*ai.Message) []sdk.SessionUpdate {
25+ responses := toolResponses(msgs)
26+ var out []sdk.SessionUpdate
27+ for i, m := range msgs {
28+ if m == nil {
29+ continue
30+ }
31+ switch m.Role {
32+ case ai.RoleUser:
33+ if compact.IsSummary(m) {
34+ out = append(out, sdk.UpdateAgentMessageText("🗜️ "+m.Text()))
35+ } else if text := m.Text(); text != "" {
36+ out = append(out, sdk.UpdateUserMessageText(text))
37+ }
38+ case ai.RoleModel:
39+ out = append(out, replayModelMessage(m, i, responses)...)
40+ }
41+ }
42+ return out
43+}
44+
45+// replayModelMessage renders one model message: its text, then each tool
46+// call it made, paired with the recorded response when there is one.
47+func replayModelMessage(m *ai.Message, index int, responses map[string]*ai.ToolResponse) []sdk.SessionUpdate {
48+ var out []sdk.SessionUpdate
49+ for j, part := range m.Content {
50+ switch {
51+ case part.IsText() && part.Text != "":
52+ out = append(out, sdk.UpdateAgentMessageText(part.Text))
53+ case part.IsToolRequest() && part.ToolRequest != nil:
54+ out = append(out, replayToolCall(part.ToolRequest, responses[part.ToolRequest.Ref], fmt.Sprintf("replay_%d_%d", index, j)))
55+ }
56+ }
57+ return out
58+}
59+
60+// replayToolCall is one recorded call as a single, already-finished tool_call.
61+// A call whose response was never recorded (the turn was cancelled between
62+// the two) is shown as failed, with no content — what the reader saw then.
63+func replayToolCall(req *ai.ToolRequest, resp *ai.ToolResponse, fallbackID string) sdk.SessionUpdate {
64+ id := req.Ref
65+ if id == "" {
66+ id = fallbackID
67+ }
68+ title, kind := toolPresentation(req.Name, req.Input)
69+ opts := []sdk.ToolCallStartOpt{
70+ sdk.WithStartKind(kind),
71+ sdk.WithStartRawInput(req.Input),
72+ }
73+ if resp == nil {
74+ opts = append(opts, sdk.WithStartStatus(sdk.ToolCallStatusFailed))
75+ return sdk.StartToolCall(sdk.ToolCallId(id), title, opts...)
76+ }
77+ output := fmt.Sprint(resp.Output)
78+ opts = append(opts,
79+ sdk.WithStartStatus(sdk.ToolCallStatusCompleted),
80+ sdk.WithStartRawOutput(map[string]any{"output": output}),
81+ )
82+ if output != "" {
83+ opts = append(opts, sdk.WithStartContent([]sdk.ToolCallContent{sdk.ToolContent(sdk.TextBlock(output))}))
84+ }
85+ return sdk.StartToolCall(sdk.ToolCallId(id), title, opts...)
86+}
87+
88+// toolPresentation gives a recorded call the title and kind the live tool
89+// would have given it (tools.go: "bash: <command>", "read_skill: <name>"…),
90+// from the tool's name and the one string argument each tool takes.
91+func toolPresentation(name string, input any) (string, sdk.ToolKind) {
92+ title := name
93+ if args, ok := input.(map[string]any); ok {
94+ for _, key := range []string{"command", "path", "name"} {
95+ if v, ok := args[key].(string); ok && v != "" {
96+ title = name + ": " + v
97+ break
98+ }
99+ }
100+ }
101+ switch name {
102+ case "read_file", "read_skill":
103+ return title, sdk.ToolKindRead
104+ case "write_file", "edit_file":
105+ return title, sdk.ToolKindEdit
106+ }
107+ return title, sdk.ToolKindExecute
108+}
109+
110+// toolResponses indexes the tool messages' responses by the call they answer.
111+func toolResponses(msgs []*ai.Message) map[string]*ai.ToolResponse {
112+ out := map[string]*ai.ToolResponse{}
113+ for _, m := range msgs {
114+ if m == nil || m.Role != ai.RoleTool {
115+ continue
116+ }
117+ for _, part := range m.Content {
118+ if part.IsToolResponse() && part.ToolResponse != nil && part.ToolResponse.Ref != "" {
119+ out[part.ToolResponse.Ref] = part.ToolResponse
120+ }
121+ }
122+ }
123+ return out
124+}
added internal/acp/replay_test.go +109 -0
new file mode 100644
@@ -0,0 +1,109 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ sdk "github.com/coder/acp-go-sdk"
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// A stored history replays as the thread the editor showed live: the user's
11+// question, the tool call — completed, with its input and output — and the
12+// answer, in order; the system prompt is never shown; a tool call whose
13+// response was not recorded is shown failed.
14+func TestReplayUpdatesRebuildsTheThread(t *testing.T) {
15+ msgs := []*ai.Message{
16+ ai.NewSystemTextMessage("You are Bob."),
17+ ai.NewUserTextMessage("list the files"),
18+ ai.NewModelMessage(
19+ ai.NewTextPart("Let me look."),
20+ ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_1", Name: "bash", Input: map[string]any{"command": "ls"}}),
21+ ),
22+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "call_1", Name: "bash", Output: "main.go\n"})),
23+ ai.NewModelMessage(ai.NewTextPart("One file: main.go.")),
24+ ai.NewUserTextMessage("edit it"),
25+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_2", Name: "edit_file", Input: map[string]any{"path": "main.go"}})),
26+ }
27+ got := replayUpdates(msgs)
28+
29+ want := []string{"user:list the files", "agent:Let me look.", "tool:call_1", "agent:One file: main.go.", "user:edit it", "tool:call_2"}
30+ if len(got) != len(want) {
31+ t.Fatalf("%d updates, want %d: %s", len(got), len(want), describe(got))
32+ }
33+ for i, w := range want {
34+ if d := describeOne(got[i]); d != w {
35+ t.Errorf("update %d = %s, want %s", i, d, w)
36+ }
37+ }
38+
39+ call1 := got[2].ToolCall
40+ if call1.Title != "bash: ls" || call1.Kind != sdk.ToolKindExecute || call1.Status != sdk.ToolCallStatusCompleted {
41+ t.Errorf("call_1 = %+v, want title \"bash: ls\", execute, completed", call1)
42+ }
43+ if len(call1.Content) != 1 || call1.Content[0].Content == nil || call1.Content[0].Content.Content.Text.Text != "main.go\n" {
44+ t.Errorf("call_1 content = %+v, want the recorded output as text", call1.Content)
45+ }
46+ if out, _ := call1.RawOutput.(map[string]any); out["output"] != "main.go\n" {
47+ t.Errorf("call_1 rawOutput = %v", call1.RawOutput)
48+ }
49+ if in, _ := call1.RawInput.(map[string]any); in["command"] != "ls" {
50+ t.Errorf("call_1 rawInput = %v", call1.RawInput)
51+ }
52+ call2 := got[5].ToolCall
53+ if call2.Title != "edit_file: main.go" || call2.Kind != sdk.ToolKindEdit || call2.Status != sdk.ToolCallStatusFailed || len(call2.Content) != 0 {
54+ t.Errorf("call_2 (no response recorded) = %+v, want edit, failed, no content", call2)
55+ }
56+}
57+
58+// A compression summary is a user-role message in the history, but the reader
59+// never typed it: it replays as the agent's words, marked as a compression.
60+func TestReplayShowsSummaryAsAgentMessage(t *testing.T) {
61+ summary := ai.NewUserTextMessage("Earlier: the user listed files.")
62+ summary.Metadata = map[string]any{"compact": "summary"}
63+ got := replayUpdates([]*ai.Message{ai.NewSystemTextMessage("sys"), summary, ai.NewModelMessage(ai.NewTextPart("Understood."))})
64+ if len(got) != 2 || got[0].AgentMessageChunk == nil || got[0].AgentMessageChunk.Content.Text.Text != "🗜️ Earlier: the user listed files." {
65+ t.Errorf("summary replay = %s", describe(got))
66+ }
67+}
68+
69+// Every tool gets the title and kind the live event would carry.
70+func TestToolPresentation(t *testing.T) {
71+ cases := []struct {
72+ name string
73+ input any
74+ title string
75+ kind sdk.ToolKind
76+ }{
77+ {"bash", map[string]any{"command": "go test"}, "bash: go test", sdk.ToolKindExecute},
78+ {"read_file", map[string]any{"path": "a.go"}, "read_file: a.go", sdk.ToolKindRead},
79+ {"write_file", map[string]any{"path": "b.go", "content": "x"}, "write_file: b.go", sdk.ToolKindEdit},
80+ {"read_skill", map[string]any{"name": "greetings"}, "read_skill: greetings", sdk.ToolKindRead},
81+ {"mystery", "not a map", "mystery", sdk.ToolKindExecute},
82+ }
83+ for _, c := range cases {
84+ title, kind := toolPresentation(c.name, c.input)
85+ if title != c.title || kind != c.kind {
86+ t.Errorf("toolPresentation(%s) = %q, %q; want %q, %q", c.name, title, kind, c.title, c.kind)
87+ }
88+ }
89+}
90+
91+func describe(us []sdk.SessionUpdate) string {
92+ s := ""
93+ for _, u := range us {
94+ s += describeOne(u) + " | "
95+ }
96+ return s
97+}
98+
99+func describeOne(u sdk.SessionUpdate) string {
100+ switch {
101+ case u.UserMessageChunk != nil:
102+ return "user:" + u.UserMessageChunk.Content.Text.Text
103+ case u.AgentMessageChunk != nil:
104+ return "agent:" + u.AgentMessageChunk.Content.Text.Text
105+ case u.ToolCall != nil:
106+ return "tool:" + string(u.ToolCall.ToolCallId)
107+ }
108+ return "other"
109+}
new file mode 100644
@@ -0,0 +1,109 @@
1+package acp
2+
3+import (
4+ "testing"
5+
6+ sdk "github.com/coder/acp-go-sdk"
7+ "github.com/firebase/genkit/go/ai"
8+)
9+
10+// A stored history replays as the thread the editor showed live: the user's
11+// question, the tool call — completed, with its input and output — and the
12+// answer, in order; the system prompt is never shown; a tool call whose
13+// response was not recorded is shown failed.
14+func TestReplayUpdatesRebuildsTheThread(t *testing.T) {
15+ msgs := []*ai.Message{
16+ ai.NewSystemTextMessage("You are Bob."),
17+ ai.NewUserTextMessage("list the files"),
18+ ai.NewModelMessage(
19+ ai.NewTextPart("Let me look."),
20+ ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_1", Name: "bash", Input: map[string]any{"command": "ls"}}),
21+ ),
22+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: "call_1", Name: "bash", Output: "main.go\n"})),
23+ ai.NewModelMessage(ai.NewTextPart("One file: main.go.")),
24+ ai.NewUserTextMessage("edit it"),
25+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: "call_2", Name: "edit_file", Input: map[string]any{"path": "main.go"}})),
26+ }
27+ got := replayUpdates(msgs)
28+
29+ want := []string{"user:list the files", "agent:Let me look.", "tool:call_1", "agent:One file: main.go.", "user:edit it", "tool:call_2"}
30+ if len(got) != len(want) {
31+ t.Fatalf("%d updates, want %d: %s", len(got), len(want), describe(got))
32+ }
33+ for i, w := range want {
34+ if d := describeOne(got[i]); d != w {
35+ t.Errorf("update %d = %s, want %s", i, d, w)
36+ }
37+ }
38+
39+ call1 := got[2].ToolCall
40+ if call1.Title != "bash: ls" || call1.Kind != sdk.ToolKindExecute || call1.Status != sdk.ToolCallStatusCompleted {
41+ t.Errorf("call_1 = %+v, want title \"bash: ls\", execute, completed", call1)
42+ }
43+ if len(call1.Content) != 1 || call1.Content[0].Content == nil || call1.Content[0].Content.Content.Text.Text != "main.go\n" {
44+ t.Errorf("call_1 content = %+v, want the recorded output as text", call1.Content)
45+ }
46+ if out, _ := call1.RawOutput.(map[string]any); out["output"] != "main.go\n" {
47+ t.Errorf("call_1 rawOutput = %v", call1.RawOutput)
48+ }
49+ if in, _ := call1.RawInput.(map[string]any); in["command"] != "ls" {
50+ t.Errorf("call_1 rawInput = %v", call1.RawInput)
51+ }
52+ call2 := got[5].ToolCall
53+ if call2.Title != "edit_file: main.go" || call2.Kind != sdk.ToolKindEdit || call2.Status != sdk.ToolCallStatusFailed || len(call2.Content) != 0 {
54+ t.Errorf("call_2 (no response recorded) = %+v, want edit, failed, no content", call2)
55+ }
56+}
57+
58+// A compression summary is a user-role message in the history, but the reader
59+// never typed it: it replays as the agent's words, marked as a compression.
60+func TestReplayShowsSummaryAsAgentMessage(t *testing.T) {
61+ summary := ai.NewUserTextMessage("Earlier: the user listed files.")
62+ summary.Metadata = map[string]any{"compact": "summary"}
63+ got := replayUpdates([]*ai.Message{ai.NewSystemTextMessage("sys"), summary, ai.NewModelMessage(ai.NewTextPart("Understood."))})
64+ if len(got) != 2 || got[0].AgentMessageChunk == nil || got[0].AgentMessageChunk.Content.Text.Text != "🗜️ Earlier: the user listed files." {
65+ t.Errorf("summary replay = %s", describe(got))
66+ }
67+}
68+
69+// Every tool gets the title and kind the live event would carry.
70+func TestToolPresentation(t *testing.T) {
71+ cases := []struct {
72+ name string
73+ input any
74+ title string
75+ kind sdk.ToolKind
76+ }{
77+ {"bash", map[string]any{"command": "go test"}, "bash: go test", sdk.ToolKindExecute},
78+ {"read_file", map[string]any{"path": "a.go"}, "read_file: a.go", sdk.ToolKindRead},
79+ {"write_file", map[string]any{"path": "b.go", "content": "x"}, "write_file: b.go", sdk.ToolKindEdit},
80+ {"read_skill", map[string]any{"name": "greetings"}, "read_skill: greetings", sdk.ToolKindRead},
81+ {"mystery", "not a map", "mystery", sdk.ToolKindExecute},
82+ }
83+ for _, c := range cases {
84+ title, kind := toolPresentation(c.name, c.input)
85+ if title != c.title || kind != c.kind {
86+ t.Errorf("toolPresentation(%s) = %q, %q; want %q, %q", c.name, title, kind, c.title, c.kind)
87+ }
88+ }
89+}
90+
91+func describe(us []sdk.SessionUpdate) string {
92+ s := ""
93+ for _, u := range us {
94+ s += describeOne(u) + " | "
95+ }
96+ return s
97+}
98+
99+func describeOne(u sdk.SessionUpdate) string {
100+ switch {
101+ case u.UserMessageChunk != nil:
102+ return "user:" + u.UserMessageChunk.Content.Text.Text
103+ case u.AgentMessageChunk != nil:
104+ return "agent:" + u.AgentMessageChunk.Content.Text.Text
105+ case u.ToolCall != nil:
106+ return "tool:" + string(u.ToolCall.ToolCallId)
107+ }
108+ return "other"
109+}
modified internal/agent/agent.go +107 -56
@@ -4,13 +4,14 @@ package agent
44 import (
55 "bufio"
66 "context"
7- "errors"
87 "fmt"
98 "io"
109 "os"
1110 "os/signal"
1211 "strings"
1312
13+ "mm/internal/clipboard"
14+ "mm/internal/command"
1415 "mm/internal/compact"
1516 "mm/internal/config"
1617 "mm/internal/detector"
@@ -18,6 +19,7 @@ import (
1819 "mm/internal/mention"
1920 "mm/internal/session"
2021 "mm/internal/spinner"
22+ "mm/internal/store"
2123
2224 "github.com/firebase/genkit/go/ai"
2325 )
@@ -39,10 +41,16 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
3941 ctx, cancelAll := context.WithCancel(ctx)
4042 defer cancelAll()
4143
42- // @path mentions are relative to where mm was started — the same anchor
43- // as a relative skillsDir and as the bash tool's own working directory.
44+ // @path mentions and the sessions directory are relative to where mm was
45+ // started — the same anchor as a relative skillsDir and as the bash
46+ // tool's own working directory.
4447 cwd, _ := os.Getwd()
4548
49+ // Persistence, when on (config `sessions`): this run is a new session,
50+ // saved from its first turn; earlier ones are a /resume away. See
51+ // store.Keeper.
52+ keep := store.NewKeeper(cwd)
53+
4654 // sigCh captures Ctrl+C (SIGINT).
4755 sigCh := make(chan os.Signal, 1)
4856 signal.Notify(sigCh, os.Interrupt)
@@ -81,8 +89,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
8189
8290 startInputReader()
8391
84- fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`)
92+ fmt.Println(`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.`)
8593 fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`)
94+ if hint := keep.Hint(); hint != "" {
95+ dimln(hint)
96+ }
8697
8798 generating := false
8899
@@ -107,32 +118,28 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
107118 }
108119 }
109120
110- // 2. Basic Command Handling.
111- if input == "/quit" {
121+ // 2. Basic Command Handling — the names come from the shared table
122+ // (internal/command), the same one the editor and the window read.
123+ if command.Is(input, command.Quit) {
112124 break
113125 }
114126 if input == "" {
115127 continue
116128 }
117- if input == "/abort" {
129+ if command.Is(input, command.Abort) {
118130 spinner.Stop()
119131 fmt.Println("⚠️ No generation in progress.")
120132 continue
121133 }
122- // Manual compression. Only reachable here, between two generations:
123- // Genkit holds its own copy of the conversation while it runs, and a
124- // tool_call/tool_result pair in flight must not move under it.
125- if input == "/compact" {
126- messages = compactHistory(ctx, e, messages, true)
127- continue
128- }
129- // New session. Same place and same reason as /compact: the history
130- // only moves between two generations. Typed DURING a generation, the
131- // command goes through the "new command received" branch below, which
132- // cancels the turn and re-injects it here — so the reset always applies
133- // to the finished history, never to one Genkit still holds.
134- if session.IsNewCommand(input) {
135- messages = startNewSession(e, d, system, messages)
134+ // The commands that move the history — /compact, /new, /sessions,
135+ // /resume — are only reachable here, between two generations: Genkit
136+ // holds its own copy of the conversation while it runs, and a
137+ // tool_call/tool_result pair in flight must not move under it. Typed
138+ // DURING a generation, such a command goes through the "new command
139+ // received" branch below, which cancels the turn and re-injects it
140+ // here — so it always applies to the finished history.
141+ if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled {
142+ messages = next
136143 continue
137144 }
138145
@@ -160,6 +167,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
160167 }
161168 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
162169 messages = compactHistory(ctx, e, messages, false)
170+ save(keep, messages)
163171 }
164172 // "@path" in the question names a file for the model to look at. The
165173 // text stays as typed; each existing path is appended as an
@@ -198,14 +206,8 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
198206 // overrun or a silence caught by the watchdog, all the work of
199207 // the turn is in `history`, and Genkit itself returns nothing.
200208 // (resp.History() will not do: empty on the streamed path.)
201- switch {
202- case len(res.history) > before:
203- messages = res.history
204- case res.resp != nil && res.resp.Message != nil:
205- messages = append(messages, res.resp.Message)
206- case res.err != nil:
207- messages = messages[:before-1] // question sans réponse : on l'oublie
208- }
209+ messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
210+ save(keep, messages)
209211
210212 if res.err != nil {
211213 if genCtx.Err() == context.Canceled {
@@ -269,11 +271,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
269271
270272 case nextInput := <-inputCh:
271273 spinner.Stop() // le spinner tourne : on lui reprend la ligne
272- switch nextInput {
273- case "/abort":
274+ switch {
275+ case command.Is(nextInput, command.Abort):
274276 cancelGen()
275277 fmt.Println("🛑 Aborting...")
276- case "/quit":
278+ case command.Is(nextInput, command.Quit):
277279 cancelGen()
278280 fmt.Println("Goodbye.")
279281 return
@@ -288,15 +290,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
288290 // Same principle as above: the commands already run stay in
289291 // the history, otherwise the next question starts again
290292 // without knowing what was done.
291- switch {
292- case len(res.history) > before:
293- messages = res.history
294- case res.err != nil:
295- messages = messages[:before-1]
296- if genCtx.Err() != context.Canceled {
297- fmt.Println("[error: " + e.Explain(res.err) + "]")
298- }
293+ messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
294+ if res.err != nil && genCtx.Err() != context.Canceled {
295+ fmt.Println("[error: " + e.Explain(res.err) + "]")
299296 }
297+ save(keep, messages)
300298 generationRunning = false
301299 generating = false
302300
@@ -370,30 +368,34 @@ func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, f
370368 // while it runs: without a label the agent looks hung, exactly the case
371369 // the spinner exists for.
372370 spinner.Start("Compressing")
373- res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
371+ out, line, outcome := compact.Apply(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
374372 return e.Summarize(ctx, request, cfg.SummaryMaxTokens)
375- })
373+ }, e.Explain)
376374 spinner.Stop()
377375
378- switch {
379- case errors.Is(err, compact.ErrNothingToCompact):
376+ switch outcome {
377+ case compact.Nothing:
380378 if forced {
381- dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns))
379+ dimln(line)
380+ }
381+ case compact.Failed:
382+ fmt.Println(line)
383+ case compact.Compressed:
384+ // The server's last measure described the history we just replaced.
385+ e.ForgetInputTokens()
386+ if cfg.ShowStats {
387+ dimln(line)
382388 }
383- return msgs
384- case err != nil:
385- // One line, in the provider's words, like every other error here.
386- fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err))
387- return msgs
388389 }
390+ return out
391+}
389392
390- // The server's last measure described the history we just replaced.
391- e.ForgetInputTokens()
392-
393- if cfg.ShowStats {
394- dimln("🗜️ " + res.Report())
393+// save rewrites the session file and reports a failure in one line: a
394+// read-only project still gets an agent.
395+func save(keep *store.Keeper, msgs []*ai.Message) {
396+ if err := keep.Save(msgs); err != nil {
397+ fmt.Println("[sessions: not saved: " + err.Error() + "]")
395398 }
396- return res.Messages
397399 }
398400
399401 // startNewSession forgets the conversation and returns the history of a fresh
@@ -423,3 +425,52 @@ func dimln(line string) {
423425 fmt.Println(line)
424426 }
425427 }
428+
429+// loop is what the REPL's commands act on: the engine, the loop detector,
430+// the session keeper and the system prompt Run was given.
431+type loop struct {
432+ ctx context.Context
433+ e *engine.Engine
434+ d *detector.LoopDetector
435+ keep *store.Keeper
436+ system string
437+}
438+
439+// historyCommand runs the slash commands that replace the history — /compact,
440+// /new, and the stored-session pair /sessions and /resume — plus /help, and
441+// reports whether input was one of them. Run only calls it between two
442+// generations. A resumed session is a new task for the loop detector too,
443+// and the server's token count described another history.
444+func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Message, bool) {
445+ cmd, arg, ok := command.Parse(input)
446+ if !ok {
447+ return messages, false
448+ }
449+ switch cmd.Name {
450+ case command.Compact:
451+ messages = compactHistory(l.ctx, l.e, messages, true)
452+ save(l.keep, messages)
453+ case command.New:
454+ messages = startNewSession(l.e, l.d, l.system, messages)
455+ l.keep.Renew()
456+ case command.Sessions:
457+ fmt.Println(l.keep.Listing())
458+ case command.Help:
459+ fmt.Println(command.Usage(command.Terminal))
460+ case command.Copy:
461+ fmt.Println(copyAnswer(messages, arg, clipboard.New(terminalOSC52())))
462+ case command.Resume:
463+ resumed, line, err := l.keep.Resume(arg, l.system)
464+ if err != nil {
465+ fmt.Println("⚠️ " + err.Error())
466+ break
467+ }
468+ messages = resumed
469+ l.d.Reset()
470+ l.e.ForgetInputTokens()
471+ fmt.Println(line)
472+ default:
473+ return messages, false
474+ }
475+ return messages, true
476+}
@@ -4,13 +4,14 @@ package agent
4 import (4 import (
5 "bufio"5 "bufio"
6 "context"6 "context"
7- "errors"
8 "fmt"7 "fmt"
9 "io"8 "io"
10 "os"9 "os"
11 "os/signal"10 "os/signal"
12 "strings"11 "strings"
13 12
13+ "mm/internal/clipboard"
14+ "mm/internal/command"
14 "mm/internal/compact"15 "mm/internal/compact"
15 "mm/internal/config"16 "mm/internal/config"
16 "mm/internal/detector"17 "mm/internal/detector"
@@ -18,6 +19,7 @@ import (
18 "mm/internal/mention"19 "mm/internal/mention"
19 "mm/internal/session"20 "mm/internal/session"
20 "mm/internal/spinner"21 "mm/internal/spinner"
22+ "mm/internal/store"
21 23
22 "github.com/firebase/genkit/go/ai"24 "github.com/firebase/genkit/go/ai"
23 )25 )
@@ -39,10 +41,16 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
39 ctx, cancelAll := context.WithCancel(ctx)41 ctx, cancelAll := context.WithCancel(ctx)
40 defer cancelAll()42 defer cancelAll()
41 43
42- // @path mentions are relative to where mm was started — the same anchor44+ // @path mentions and the sessions directory are relative to where mm was
43- // as a relative skillsDir and as the bash tool's own working directory.45+ // started — the same anchor as a relative skillsDir and as the bash
46+ // tool's own working directory.
44 cwd, _ := os.Getwd()47 cwd, _ := os.Getwd()
45 48
49+ // Persistence, when on (config `sessions`): this run is a new session,
50+ // saved from its first turn; earlier ones are a /resume away. See
51+ // store.Keeper.
52+ keep := store.NewKeeper(cwd)
53+
46 // sigCh captures Ctrl+C (SIGINT).54 // sigCh captures Ctrl+C (SIGINT).
47 sigCh := make(chan os.Signal, 1)55 sigCh := make(chan os.Signal, 1)
48 signal.Notify(sigCh, os.Interrupt)56 signal.Notify(sigCh, os.Interrupt)
@@ -81,8 +89,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
81 89
82 startInputReader()90 startInputReader()
83 91
84- fmt.Println(`Agent ready. Commands: "/quit" to exit, "/abort" to stop current generation, "/compact" to compress the history, "/new" to start a new session.`)92+ fmt.Println(`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.`)
85 fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`)93 fmt.Println(`Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit.`)
94+ if hint := keep.Hint(); hint != "" {
95+ dimln(hint)
96+ }
86 97
87 generating := false98 generating := false
88 99
@@ -107,32 +118,28 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
107 }118 }
108 }119 }
109 120
110- // 2. Basic Command Handling.121+ // 2. Basic Command Handling — the names come from the shared table
111- if input == "/quit" {122+ // (internal/command), the same one the editor and the window read.
123+ if command.Is(input, command.Quit) {
112 break124 break
113 }125 }
114 if input == "" {126 if input == "" {
115 continue127 continue
116 }128 }
117- if input == "/abort" {129+ if command.Is(input, command.Abort) {
118 spinner.Stop()130 spinner.Stop()
119 fmt.Println("⚠️ No generation in progress.")131 fmt.Println("⚠️ No generation in progress.")
120 continue132 continue
121 }133 }
122- // Manual compression. Only reachable here, between two generations:134+ // The commands that move the history — /compact, /new, /sessions,
123- // Genkit holds its own copy of the conversation while it runs, and a135+ // /resume — are only reachable here, between two generations: Genkit
124- // tool_call/tool_result pair in flight must not move under it.136+ // holds its own copy of the conversation while it runs, and a
125- if input == "/compact" {137+ // tool_call/tool_result pair in flight must not move under it. Typed
126- messages = compactHistory(ctx, e, messages, true)138+ // DURING a generation, such a command goes through the "new command
127- continue139+ // received" branch below, which cancels the turn and re-injects it
128- }140+ // here — so it always applies to the finished history.
129- // New session. Same place and same reason as /compact: the history141+ if next, handled := (&loop{ctx: ctx, e: e, d: d, keep: keep, system: system}).historyCommand(input, messages); handled {
130- // only moves between two generations. Typed DURING a generation, the142+ messages = next
131- // command goes through the "new command received" branch below, which
132- // cancels the turn and re-injects it here — so the reset always applies
133- // to the finished history, never to one Genkit still holds.
134- if session.IsNewCommand(input) {
135- messages = startNewSession(e, d, system, messages)
136 continue143 continue
137 }144 }
138 145
@@ -160,6 +167,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
160 }167 }
161 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {168 if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact {
162 messages = compactHistory(ctx, e, messages, false)169 messages = compactHistory(ctx, e, messages, false)
170+ save(keep, messages)
163 }171 }
164 // "@path" in the question names a file for the model to look at. The172 // "@path" in the question names a file for the model to look at. The
165 // text stays as typed; each existing path is appended as an173 // text stays as typed; each existing path is appended as an
@@ -198,14 +206,8 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
198 // overrun or a silence caught by the watchdog, all the work of206 // overrun or a silence caught by the watchdog, all the work of
199 // the turn is in `history`, and Genkit itself returns nothing.207 // the turn is in `history`, and Genkit itself returns nothing.
200 // (resp.History() will not do: empty on the streamed path.)208 // (resp.History() will not do: empty on the streamed path.)
201- switch {209+ messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
202- case len(res.history) > before:210+ save(keep, messages)
203- messages = res.history
204- case res.resp != nil && res.resp.Message != nil:
205- messages = append(messages, res.resp.Message)
206- case res.err != nil:
207- messages = messages[:before-1] // question sans réponse : on l'oublie
208- }
209 211
210 if res.err != nil {212 if res.err != nil {
211 if genCtx.Err() == context.Canceled {213 if genCtx.Err() == context.Canceled {
@@ -269,11 +271,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
269 271
270 case nextInput := <-inputCh:272 case nextInput := <-inputCh:
271 spinner.Stop() // le spinner tourne : on lui reprend la ligne273 spinner.Stop() // le spinner tourne : on lui reprend la ligne
272- switch nextInput {274+ switch {
273- case "/abort":275+ case command.Is(nextInput, command.Abort):
274 cancelGen()276 cancelGen()
275 fmt.Println("🛑 Aborting...")277 fmt.Println("🛑 Aborting...")
276- case "/quit":278+ case command.Is(nextInput, command.Quit):
277 cancelGen()279 cancelGen()
278 fmt.Println("Goodbye.")280 fmt.Println("Goodbye.")
279 return281 return
@@ -288,15 +290,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
288 // Same principle as above: the commands already run stay in290 // Same principle as above: the commands already run stay in
289 // the history, otherwise the next question starts again291 // the history, otherwise the next question starts again
290 // without knowing what was done.292 // without knowing what was done.
291- switch {293+ messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
292- case len(res.history) > before:294+ if res.err != nil && genCtx.Err() != context.Canceled {
293- messages = res.history295+ fmt.Println("[error: " + e.Explain(res.err) + "]")
294- case res.err != nil:
295- messages = messages[:before-1]
296- if genCtx.Err() != context.Canceled {
297- fmt.Println("[error: " + e.Explain(res.err) + "]")
298- }
299 }296 }
297+ save(keep, messages)
300 generationRunning = false298 generationRunning = false
301 generating = false299 generating = false
302 300
@@ -370,30 +368,34 @@ func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, f
370 // while it runs: without a label the agent looks hung, exactly the case368 // while it runs: without a label the agent looks hung, exactly the case
371 // the spinner exists for.369 // the spinner exists for.
372 spinner.Start("Compressing")370 spinner.Start("Compressing")
373- res, err := compact.Compact(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {371+ out, line, outcome := compact.Apply(ctx, msgs, cfg, func(ctx context.Context, request []*ai.Message) (string, error) {
374 return e.Summarize(ctx, request, cfg.SummaryMaxTokens)372 return e.Summarize(ctx, request, cfg.SummaryMaxTokens)
375- })373+ }, e.Explain)
376 spinner.Stop()374 spinner.Stop()
377 375
378- switch {376+ switch outcome {
379- case errors.Is(err, compact.ErrNothingToCompact):377+ case compact.Nothing:
380 if forced {378 if forced {
381- dimln(fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns))379+ dimln(line)
380+ }
381+ case compact.Failed:
382+ fmt.Println(line)
383+ case compact.Compressed:
384+ // The server's last measure described the history we just replaced.
385+ e.ForgetInputTokens()
386+ if cfg.ShowStats {
387+ dimln(line)
382 }388 }
383- return msgs
384- case err != nil:
385- // One line, in the provider's words, like every other error here.
386- fmt.Printf("[compact: failed, history kept: %s]\n", e.Explain(err))
387- return msgs
388 }389 }
390+ return out
391+}
389 392
390- // The server's last measure described the history we just replaced.393+// save rewrites the session file and reports a failure in one line: a
391- e.ForgetInputTokens()394+// read-only project still gets an agent.
392-395+func save(keep *store.Keeper, msgs []*ai.Message) {
393- if cfg.ShowStats {396+ if err := keep.Save(msgs); err != nil {
394- dimln("🗜️ " + res.Report())397+ fmt.Println("[sessions: not saved: " + err.Error() + "]")
395 }398 }
396- return res.Messages
397 }399 }
398 400
399 // startNewSession forgets the conversation and returns the history of a fresh401 // startNewSession forgets the conversation and returns the history of a fresh
@@ -423,3 +425,52 @@ func dimln(line string) {
423 fmt.Println(line)425 fmt.Println(line)
424 }426 }
425 }427 }
428+
429+// loop is what the REPL's commands act on: the engine, the loop detector,
430+// the session keeper and the system prompt Run was given.
431+type loop struct {
432+ ctx context.Context
433+ e *engine.Engine
434+ d *detector.LoopDetector
435+ keep *store.Keeper
436+ system string
437+}
438+
439+// historyCommand runs the slash commands that replace the history — /compact,
440+// /new, and the stored-session pair /sessions and /resume — plus /help, and
441+// reports whether input was one of them. Run only calls it between two
442+// generations. A resumed session is a new task for the loop detector too,
443+// and the server's token count described another history.
444+func (l *loop) historyCommand(input string, messages []*ai.Message) ([]*ai.Message, bool) {
445+ cmd, arg, ok := command.Parse(input)
446+ if !ok {
447+ return messages, false
448+ }
449+ switch cmd.Name {
450+ case command.Compact:
451+ messages = compactHistory(l.ctx, l.e, messages, true)
452+ save(l.keep, messages)
453+ case command.New:
454+ messages = startNewSession(l.e, l.d, l.system, messages)
455+ l.keep.Renew()
456+ case command.Sessions:
457+ fmt.Println(l.keep.Listing())
458+ case command.Help:
459+ fmt.Println(command.Usage(command.Terminal))
460+ case command.Copy:
461+ fmt.Println(copyAnswer(messages, arg, clipboard.New(terminalOSC52())))
462+ case command.Resume:
463+ resumed, line, err := l.keep.Resume(arg, l.system)
464+ if err != nil {
465+ fmt.Println("⚠️ " + err.Error())
466+ break
467+ }
468+ messages = resumed
469+ l.d.Reset()
470+ l.e.ForgetInputTokens()
471+ fmt.Println(line)
472+ default:
473+ return messages, false
474+ }
475+ return messages, true
476+}
added internal/agent/copy.go +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strconv"
7+ "strings"
8+
9+ "mm/internal/clipboard"
10+
11+ "github.com/firebase/genkit/go/ai"
12+)
13+
14+// copyAnswer is the REPL's /copy [n]: the model's last text answer (or the
15+// n-th from the end) onto the clipboard, and one line saying how it went.
16+// "all" is the window mode's: the REPL's transcript is the terminal's
17+// scrollback, which the terminal itself can select.
18+func copyAnswer(messages []*ai.Message, arg string, clip copier) string {
19+ if arg == "all" {
20+ return "⚠️ /copy all is a window-mode command; here, select the text in the terminal"
21+ }
22+ n := 1
23+ if arg != "" {
24+ parsed, err := strconv.Atoi(arg)
25+ if err != nil || parsed < 1 {
26+ return fmt.Sprintf("⚠️ /copy takes a number (1 = the last answer), not %q", arg)
27+ }
28+ n = parsed
29+ }
30+ answers := modelAnswers(messages)
31+ if n > len(answers) {
32+ return fmt.Sprintf("⚠️ nothing to copy: %d answer(s) in the history, /copy %d asks for more", len(answers), n)
33+ }
34+ text := answers[len(answers)-n]
35+ how, err := clip.Copy(text)
36+ if err != nil {
37+ return "⚠️ no clipboard: " + err.Error()
38+ }
39+ return fmt.Sprintf("📋 copied %d chars (%s)", len([]rune(text)), how)
40+}
41+
42+// copier is what /copy writes to: a clipboard.Copier, or a fake in tests.
43+type copier interface {
44+ Copy(text string) (how string, err error)
45+}
46+
47+// modelAnswers is the text of every model message that spoke to the user —
48+// tool-call-only messages have none — oldest first.
49+func modelAnswers(messages []*ai.Message) []string {
50+ var out []string
51+ for _, m := range messages {
52+ if m.Role != ai.RoleModel {
53+ continue
54+ }
55+ if text := strings.TrimSpace(m.Text()); text != "" {
56+ out = append(out, text)
57+ }
58+ }
59+ return out
60+}
61+
62+// terminalOSC52 is the REPL's OSC 52 sink: the sequence goes to stdout when
63+// it is a terminal, nowhere when stdout is a pipe (it would corrupt a log).
64+func terminalOSC52() func([]byte) {
65+ info, err := os.Stdout.Stat()
66+ if err != nil || info.Mode()&os.ModeCharDevice == 0 {
67+ return nil
68+ }
69+ return func(b []byte) { fmt.Print(clipboard.OSC52(string(b))) }
70+}
new file mode 100644
@@ -0,0 +1,70 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "os"
6+ "strconv"
7+ "strings"
8+
9+ "mm/internal/clipboard"
10+
11+ "github.com/firebase/genkit/go/ai"
12+)
13+
14+// copyAnswer is the REPL's /copy [n]: the model's last text answer (or the
15+// n-th from the end) onto the clipboard, and one line saying how it went.
16+// "all" is the window mode's: the REPL's transcript is the terminal's
17+// scrollback, which the terminal itself can select.
18+func copyAnswer(messages []*ai.Message, arg string, clip copier) string {
19+ if arg == "all" {
20+ return "⚠️ /copy all is a window-mode command; here, select the text in the terminal"
21+ }
22+ n := 1
23+ if arg != "" {
24+ parsed, err := strconv.Atoi(arg)
25+ if err != nil || parsed < 1 {
26+ return fmt.Sprintf("⚠️ /copy takes a number (1 = the last answer), not %q", arg)
27+ }
28+ n = parsed
29+ }
30+ answers := modelAnswers(messages)
31+ if n > len(answers) {
32+ return fmt.Sprintf("⚠️ nothing to copy: %d answer(s) in the history, /copy %d asks for more", len(answers), n)
33+ }
34+ text := answers[len(answers)-n]
35+ how, err := clip.Copy(text)
36+ if err != nil {
37+ return "⚠️ no clipboard: " + err.Error()
38+ }
39+ return fmt.Sprintf("📋 copied %d chars (%s)", len([]rune(text)), how)
40+}
41+
42+// copier is what /copy writes to: a clipboard.Copier, or a fake in tests.
43+type copier interface {
44+ Copy(text string) (how string, err error)
45+}
46+
47+// modelAnswers is the text of every model message that spoke to the user —
48+// tool-call-only messages have none — oldest first.
49+func modelAnswers(messages []*ai.Message) []string {
50+ var out []string
51+ for _, m := range messages {
52+ if m.Role != ai.RoleModel {
53+ continue
54+ }
55+ if text := strings.TrimSpace(m.Text()); text != "" {
56+ out = append(out, text)
57+ }
58+ }
59+ return out
60+}
61+
62+// terminalOSC52 is the REPL's OSC 52 sink: the sequence goes to stdout when
63+// it is a terminal, nowhere when stdout is a pipe (it would corrupt a log).
64+func terminalOSC52() func([]byte) {
65+ info, err := os.Stdout.Stat()
66+ if err != nil || info.Mode()&os.ModeCharDevice == 0 {
67+ return nil
68+ }
69+ return func(b []byte) { fmt.Print(clipboard.OSC52(string(b))) }
70+}
added internal/agent/copy_test.go +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+package agent
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "github.com/firebase/genkit/go/ai"
9+)
10+
11+type fakeCopier struct {
12+ got string
13+ fail error
14+}
15+
16+func (f *fakeCopier) Copy(text string) (string, error) {
17+ f.got = text
18+ return "pbcopy", f.fail
19+}
20+
21+func history() []*ai.Message {
22+ return []*ai.Message{
23+ ai.NewSystemTextMessage("sys"),
24+ ai.NewUserTextMessage("list"),
25+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})),
26+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "a.go"})),
27+ ai.NewModelMessage(ai.NewTextPart("One file: a.go")),
28+ ai.NewUserTextMessage("thanks"),
29+ ai.NewModelMessage(ai.NewTextPart("You are welcome.")),
30+ }
31+}
32+
33+// /copy takes the model's last spoken answer — a tool-call-only message is
34+// not an answer — or the n-th from the end, and says how it was copied.
35+func TestCopyAnswerPicksSpokenAnswers(t *testing.T) {
36+ if got := modelAnswers(history()); len(got) != 2 || got[0] != "One file: a.go" || got[1] != "You are welcome." {
37+ t.Fatalf("modelAnswers = %q", got)
38+ }
39+ clip := &fakeCopier{}
40+ if line := copyAnswer(history(), "", clip); clip.got != "You are welcome." || !strings.Contains(line, "📋 copied 16 chars (pbcopy)") {
41+ t.Errorf("/copy: clipboard %q, line %q", clip.got, line)
42+ }
43+ if copyAnswer(history(), "2", clip); clip.got != "One file: a.go" {
44+ t.Errorf("/copy 2: clipboard %q", clip.got)
45+ }
46+}
47+
48+// The failure cases each say what went wrong instead of copying nothing
49+// silently.
50+func TestCopyAnswerErrors(t *testing.T) {
51+ clip := &fakeCopier{}
52+ cases := map[string]string{
53+ "3": "nothing to copy: 2 answer(s)",
54+ "x": "/copy takes a number",
55+ "all": "window-mode command",
56+ }
57+ for arg, want := range cases {
58+ if line := copyAnswer(history(), arg, clip); !strings.HasPrefix(line, "⚠️") || !strings.Contains(line, want) {
59+ t.Errorf("copyAnswer(%q) = %q, want %q", arg, line, want)
60+ }
61+ }
62+ if line := copyAnswer(history(), "", &fakeCopier{fail: errors.New("exit 1")}); line != "⚠️ no clipboard: exit 1" {
63+ t.Errorf("failing tool: %q", line)
64+ }
65+}
new file mode 100644
@@ -0,0 +1,65 @@
1+package agent
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+
8+ "github.com/firebase/genkit/go/ai"
9+)
10+
11+type fakeCopier struct {
12+ got string
13+ fail error
14+}
15+
16+func (f *fakeCopier) Copy(text string) (string, error) {
17+ f.got = text
18+ return "pbcopy", f.fail
19+}
20+
21+func history() []*ai.Message {
22+ return []*ai.Message{
23+ ai.NewSystemTextMessage("sys"),
24+ ai.NewUserTextMessage("list"),
25+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Name: "bash", Input: map[string]any{"command": "ls"}})),
26+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Name: "bash", Output: "a.go"})),
27+ ai.NewModelMessage(ai.NewTextPart("One file: a.go")),
28+ ai.NewUserTextMessage("thanks"),
29+ ai.NewModelMessage(ai.NewTextPart("You are welcome.")),
30+ }
31+}
32+
33+// /copy takes the model's last spoken answer — a tool-call-only message is
34+// not an answer — or the n-th from the end, and says how it was copied.
35+func TestCopyAnswerPicksSpokenAnswers(t *testing.T) {
36+ if got := modelAnswers(history()); len(got) != 2 || got[0] != "One file: a.go" || got[1] != "You are welcome." {
37+ t.Fatalf("modelAnswers = %q", got)
38+ }
39+ clip := &fakeCopier{}
40+ if line := copyAnswer(history(), "", clip); clip.got != "You are welcome." || !strings.Contains(line, "📋 copied 16 chars (pbcopy)") {
41+ t.Errorf("/copy: clipboard %q, line %q", clip.got, line)
42+ }
43+ if copyAnswer(history(), "2", clip); clip.got != "One file: a.go" {
44+ t.Errorf("/copy 2: clipboard %q", clip.got)
45+ }
46+}
47+
48+// The failure cases each say what went wrong instead of copying nothing
49+// silently.
50+func TestCopyAnswerErrors(t *testing.T) {
51+ clip := &fakeCopier{}
52+ cases := map[string]string{
53+ "3": "nothing to copy: 2 answer(s)",
54+ "x": "/copy takes a number",
55+ "all": "window-mode command",
56+ }
57+ for arg, want := range cases {
58+ if line := copyAnswer(history(), arg, clip); !strings.HasPrefix(line, "⚠️") || !strings.Contains(line, want) {
59+ t.Errorf("copyAnswer(%q) = %q, want %q", arg, line, want)
60+ }
61+ }
62+ if line := copyAnswer(history(), "", &fakeCopier{fail: errors.New("exit 1")}); line != "⚠️ no clipboard: exit 1" {
63+ t.Errorf("failing tool: %q", line)
64+ }
65+}
added internal/clipboard/clipboard.go +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+// Package clipboard puts text on the system clipboard from a terminal
2+// program, the way that actually works on the machine at hand. Two roads,
3+// tried in this order:
4+//
5+// 1. a native tool on PATH — pbcopy (macOS), wl-copy (Wayland), xclip or
6+// xsel (X11). Reliable: it talks to the OS, whatever the terminal is.
7+// 2. OSC 52, the escape sequence terminals may honour to set the clipboard.
8+// It works over SSH and in iTerm2 (once allowed), Ghostty, kitty, WezTerm;
9+// macOS Terminal.app ignores it silently. It is the fallback, not the
10+// first choice, because "copied" must not be a lie.
11+//
12+// The result names the road taken, so the user learns what to trust:
13+//
14+// c := clipboard.New(screen.SetClipboard) // nil when no terminal sink
15+// how, err := c.Copy("hello") // how == "pbcopy", err == nil
16+package clipboard
17+
18+import (
19+ "encoding/base64"
20+ "errors"
21+ "fmt"
22+ "os/exec"
23+ "strings"
24+)
25+
26+// ErrNoClipboard is returned when neither a native tool nor an OSC 52 sink
27+// is available.
28+var ErrNoClipboard = errors.New("no clipboard tool on PATH (pbcopy, wl-copy, xclip, xsel) and no terminal to send OSC 52 to")
29+
30+// tools are the native clipboard writers, in order of preference. The
31+// first one found on PATH wins.
32+var tools = []struct {
33+ name string
34+ args []string
35+}{
36+ {"pbcopy", nil},
37+ {"wl-copy", nil},
38+ {"xclip", []string{"-selection", "clipboard"}},
39+ {"xsel", []string{"--clipboard", "--input"}},
40+}
41+
42+// Copier writes text to the clipboard. Its two function fields are the seams
43+// tests replace; New fills them with the real thing.
44+type Copier struct {
45+ // osc52 sends the raw OSC 52 payload to the terminal; nil when there is
46+ // no terminal to send it to (a pipe, the ACP façade).
47+ osc52 func([]byte)
48+ // lookPath and run are exec.LookPath and "run this tool with this stdin".
49+ lookPath func(string) (string, error)
50+ run func(path string, args []string, input string) error
51+}
52+
53+// New returns a Copier that uses the real PATH and the given OSC 52 sink.
54+// Pass tcell's screen.SetClipboard, a function writing to the terminal, or
55+// nil when the program has no terminal of its own.
56+func New(osc52 func([]byte)) *Copier {
57+ return &Copier{osc52: osc52, lookPath: exec.LookPath, run: runTool}
58+}
59+
60+// Copy puts text on the clipboard and reports how: the tool's name, or
61+// "OSC 52". Both roads failing is ErrNoClipboard; a tool that was found but
62+// failed is reported as that failure.
63+func (c *Copier) Copy(text string) (how string, err error) {
64+ for _, t := range tools {
65+ path, err := c.lookPath(t.name)
66+ if err != nil {
67+ continue
68+ }
69+ if err := c.run(path, t.args, text); err != nil {
70+ return t.name, fmt.Errorf("%s: %w", t.name, err)
71+ }
72+ return t.name, nil
73+ }
74+ if c.osc52 != nil {
75+ c.osc52([]byte(text))
76+ return "OSC 52", nil
77+ }
78+ return "", ErrNoClipboard
79+}
80+
81+// OSC52 encodes text as the sequence a terminal reads to set its clipboard,
82+// for programs that write to the terminal themselves rather than through
83+// tcell: ESC ] 52 ; c ; <base64> ESC \.
84+func OSC52(text string) string {
85+ return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x1b\\"
86+}
87+
88+// runTool feeds input to the tool on stdin and waits for it.
89+func runTool(path string, args []string, input string) error {
90+ cmd := exec.Command(path, args...)
91+ cmd.Stdin = strings.NewReader(input)
92+ return cmd.Run()
93+}
new file mode 100644
@@ -0,0 +1,93 @@
1+// Package clipboard puts text on the system clipboard from a terminal
2+// program, the way that actually works on the machine at hand. Two roads,
3+// tried in this order:
4+//
5+// 1. a native tool on PATH — pbcopy (macOS), wl-copy (Wayland), xclip or
6+// xsel (X11). Reliable: it talks to the OS, whatever the terminal is.
7+// 2. OSC 52, the escape sequence terminals may honour to set the clipboard.
8+// It works over SSH and in iTerm2 (once allowed), Ghostty, kitty, WezTerm;
9+// macOS Terminal.app ignores it silently. It is the fallback, not the
10+// first choice, because "copied" must not be a lie.
11+//
12+// The result names the road taken, so the user learns what to trust:
13+//
14+// c := clipboard.New(screen.SetClipboard) // nil when no terminal sink
15+// how, err := c.Copy("hello") // how == "pbcopy", err == nil
16+package clipboard
17+
18+import (
19+ "encoding/base64"
20+ "errors"
21+ "fmt"
22+ "os/exec"
23+ "strings"
24+)
25+
26+// ErrNoClipboard is returned when neither a native tool nor an OSC 52 sink
27+// is available.
28+var ErrNoClipboard = errors.New("no clipboard tool on PATH (pbcopy, wl-copy, xclip, xsel) and no terminal to send OSC 52 to")
29+
30+// tools are the native clipboard writers, in order of preference. The
31+// first one found on PATH wins.
32+var tools = []struct {
33+ name string
34+ args []string
35+}{
36+ {"pbcopy", nil},
37+ {"wl-copy", nil},
38+ {"xclip", []string{"-selection", "clipboard"}},
39+ {"xsel", []string{"--clipboard", "--input"}},
40+}
41+
42+// Copier writes text to the clipboard. Its two function fields are the seams
43+// tests replace; New fills them with the real thing.
44+type Copier struct {
45+ // osc52 sends the raw OSC 52 payload to the terminal; nil when there is
46+ // no terminal to send it to (a pipe, the ACP façade).
47+ osc52 func([]byte)
48+ // lookPath and run are exec.LookPath and "run this tool with this stdin".
49+ lookPath func(string) (string, error)
50+ run func(path string, args []string, input string) error
51+}
52+
53+// New returns a Copier that uses the real PATH and the given OSC 52 sink.
54+// Pass tcell's screen.SetClipboard, a function writing to the terminal, or
55+// nil when the program has no terminal of its own.
56+func New(osc52 func([]byte)) *Copier {
57+ return &Copier{osc52: osc52, lookPath: exec.LookPath, run: runTool}
58+}
59+
60+// Copy puts text on the clipboard and reports how: the tool's name, or
61+// "OSC 52". Both roads failing is ErrNoClipboard; a tool that was found but
62+// failed is reported as that failure.
63+func (c *Copier) Copy(text string) (how string, err error) {
64+ for _, t := range tools {
65+ path, err := c.lookPath(t.name)
66+ if err != nil {
67+ continue
68+ }
69+ if err := c.run(path, t.args, text); err != nil {
70+ return t.name, fmt.Errorf("%s: %w", t.name, err)
71+ }
72+ return t.name, nil
73+ }
74+ if c.osc52 != nil {
75+ c.osc52([]byte(text))
76+ return "OSC 52", nil
77+ }
78+ return "", ErrNoClipboard
79+}
80+
81+// OSC52 encodes text as the sequence a terminal reads to set its clipboard,
82+// for programs that write to the terminal themselves rather than through
83+// tcell: ESC ] 52 ; c ; <base64> ESC \.
84+func OSC52(text string) string {
85+ return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\x1b\\"
86+}
87+
88+// runTool feeds input to the tool on stdin and waits for it.
89+func runTool(path string, args []string, input string) error {
90+ cmd := exec.Command(path, args...)
91+ cmd.Stdin = strings.NewReader(input)
92+ return cmd.Run()
93+}
added internal/clipboard/clipboard_test.go +106 -0
new file mode 100644
@@ -0,0 +1,106 @@
1+package clipboard
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+// fake is a PATH with the given tools, recording what each was fed.
10+type fake struct {
11+ present map[string]bool
12+ fed map[string]string
13+ fail map[string]error
14+ osc []byte
15+}
16+
17+func (f *fake) copier(withTerminal bool) *Copier {
18+ c := &Copier{
19+ lookPath: func(name string) (string, error) {
20+ if f.present[name] {
21+ return "/usr/bin/" + name, nil
22+ }
23+ return "", errors.New("not found")
24+ },
25+ run: func(path string, args []string, input string) error {
26+ name := strings.TrimPrefix(path, "/usr/bin/")
27+ if err := f.fail[name]; err != nil {
28+ return err
29+ }
30+ if f.fed == nil {
31+ f.fed = map[string]string{}
32+ }
33+ f.fed[name] = strings.Join(args, " ") + "|" + input
34+ return nil
35+ },
36+ }
37+ if withTerminal {
38+ c.osc52 = func(b []byte) { f.osc = b }
39+ }
40+ return c
41+}
42+
43+// The first tool found on PATH is used, in the documented order, and the
44+// text reaches it on stdin with the tool's own arguments.
45+func TestCopyPrefersNativeToolsInOrder(t *testing.T) {
46+ f := &fake{present: map[string]bool{"xclip": true, "xsel": true}}
47+ how, err := f.copier(true).Copy("hello")
48+ if err != nil || how != "xclip" {
49+ t.Fatalf("Copy = %q, %v; want xclip", how, err)
50+ }
51+ if got := f.fed["xclip"]; got != "-selection clipboard|hello" {
52+ t.Errorf("xclip was fed %q", got)
53+ }
54+ if f.osc != nil {
55+ t.Error("OSC 52 was sent although a tool worked")
56+ }
57+}
58+
59+// pbcopy beats everything else when present.
60+func TestCopyPbcopyFirst(t *testing.T) {
61+ f := &fake{present: map[string]bool{"pbcopy": true, "wl-copy": true}}
62+ how, _ := f.copier(false).Copy("x")
63+ if how != "pbcopy" {
64+ t.Errorf("how = %q, want pbcopy", how)
65+ }
66+}
67+
68+// Without a tool, OSC 52 is the fallback — and it says so.
69+func TestCopyFallsBackToOSC52(t *testing.T) {
70+ f := &fake{}
71+ how, err := f.copier(true).Copy("via terminal")
72+ if err != nil || how != "OSC 52" {
73+ t.Fatalf("Copy = %q, %v; want OSC 52", how, err)
74+ }
75+ if string(f.osc) != "via terminal" {
76+ t.Errorf("OSC 52 payload %q", f.osc)
77+ }
78+}
79+
80+// No tool and no terminal is an error the caller can show.
81+func TestCopyWithoutAnyRoadIsAnError(t *testing.T) {
82+ _, err := (&fake{}).copier(false).Copy("x")
83+ if !errors.Is(err, ErrNoClipboard) {
84+ t.Errorf("err = %v, want ErrNoClipboard", err)
85+ }
86+}
87+
88+// A tool that is present but fails is reported as its own failure, not
89+// silently replaced by the next road: the user must know pbcopy broke.
90+func TestCopyReportsAFailingTool(t *testing.T) {
91+ f := &fake{present: map[string]bool{"pbcopy": true}, fail: map[string]error{"pbcopy": errors.New("exit 1")}}
92+ how, err := f.copier(true).Copy("x")
93+ if how != "pbcopy" || err == nil || !strings.Contains(err.Error(), "pbcopy: exit 1") {
94+ t.Errorf("Copy = %q, %v", how, err)
95+ }
96+ if f.osc != nil {
97+ t.Error("OSC 52 was tried after a tool failure")
98+ }
99+}
100+
101+// The raw sequence is what a terminal expects: base64 between the markers.
102+func TestOSC52Encodes(t *testing.T) {
103+ if got := OSC52("hi"); got != "\x1b]52;c;aGk=\x1b\\" {
104+ t.Errorf("OSC52 = %q", got)
105+ }
106+}
new file mode 100644
@@ -0,0 +1,106 @@
1+package clipboard
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+)
8+
9+// fake is a PATH with the given tools, recording what each was fed.
10+type fake struct {
11+ present map[string]bool
12+ fed map[string]string
13+ fail map[string]error
14+ osc []byte
15+}
16+
17+func (f *fake) copier(withTerminal bool) *Copier {
18+ c := &Copier{
19+ lookPath: func(name string) (string, error) {
20+ if f.present[name] {
21+ return "/usr/bin/" + name, nil
22+ }
23+ return "", errors.New("not found")
24+ },
25+ run: func(path string, args []string, input string) error {
26+ name := strings.TrimPrefix(path, "/usr/bin/")
27+ if err := f.fail[name]; err != nil {
28+ return err
29+ }
30+ if f.fed == nil {
31+ f.fed = map[string]string{}
32+ }
33+ f.fed[name] = strings.Join(args, " ") + "|" + input
34+ return nil
35+ },
36+ }
37+ if withTerminal {
38+ c.osc52 = func(b []byte) { f.osc = b }
39+ }
40+ return c
41+}
42+
43+// The first tool found on PATH is used, in the documented order, and the
44+// text reaches it on stdin with the tool's own arguments.
45+func TestCopyPrefersNativeToolsInOrder(t *testing.T) {
46+ f := &fake{present: map[string]bool{"xclip": true, "xsel": true}}
47+ how, err := f.copier(true).Copy("hello")
48+ if err != nil || how != "xclip" {
49+ t.Fatalf("Copy = %q, %v; want xclip", how, err)
50+ }
51+ if got := f.fed["xclip"]; got != "-selection clipboard|hello" {
52+ t.Errorf("xclip was fed %q", got)
53+ }
54+ if f.osc != nil {
55+ t.Error("OSC 52 was sent although a tool worked")
56+ }
57+}
58+
59+// pbcopy beats everything else when present.
60+func TestCopyPbcopyFirst(t *testing.T) {
61+ f := &fake{present: map[string]bool{"pbcopy": true, "wl-copy": true}}
62+ how, _ := f.copier(false).Copy("x")
63+ if how != "pbcopy" {
64+ t.Errorf("how = %q, want pbcopy", how)
65+ }
66+}
67+
68+// Without a tool, OSC 52 is the fallback — and it says so.
69+func TestCopyFallsBackToOSC52(t *testing.T) {
70+ f := &fake{}
71+ how, err := f.copier(true).Copy("via terminal")
72+ if err != nil || how != "OSC 52" {
73+ t.Fatalf("Copy = %q, %v; want OSC 52", how, err)
74+ }
75+ if string(f.osc) != "via terminal" {
76+ t.Errorf("OSC 52 payload %q", f.osc)
77+ }
78+}
79+
80+// No tool and no terminal is an error the caller can show.
81+func TestCopyWithoutAnyRoadIsAnError(t *testing.T) {
82+ _, err := (&fake{}).copier(false).Copy("x")
83+ if !errors.Is(err, ErrNoClipboard) {
84+ t.Errorf("err = %v, want ErrNoClipboard", err)
85+ }
86+}
87+
88+// A tool that is present but fails is reported as its own failure, not
89+// silently replaced by the next road: the user must know pbcopy broke.
90+func TestCopyReportsAFailingTool(t *testing.T) {
91+ f := &fake{present: map[string]bool{"pbcopy": true}, fail: map[string]error{"pbcopy": errors.New("exit 1")}}
92+ how, err := f.copier(true).Copy("x")
93+ if how != "pbcopy" || err == nil || !strings.Contains(err.Error(), "pbcopy: exit 1") {
94+ t.Errorf("Copy = %q, %v", how, err)
95+ }
96+ if f.osc != nil {
97+ t.Error("OSC 52 was tried after a tool failure")
98+ }
99+}
100+
101+// The raw sequence is what a terminal expects: base64 between the markers.
102+func TestOSC52Encodes(t *testing.T) {
103+ if got := OSC52("hi"); got != "\x1b]52;c;aGk=\x1b\\" {
104+ t.Errorf("OSC52 = %q", got)
105+ }
106+}
added internal/command/command.go +149 -0
new file mode 100644
@@ -0,0 +1,149 @@
1+// Package command is the one table of slash commands, read by every front
2+// end: the terminal REPL dispatches on it, the ACP façade advertises the part
3+// of it an editor may send, and the window mode's "/" picker lists it. One
4+// table, so the three cannot drift — a command that works but is not listed
5+// is one the user never finds, and one listed but not handled reaches the
6+// model as a question.
7+//
8+// cmd, arg, ok := command.Parse("/resume 20260916-101530-4f2a")
9+// // cmd.Name == command.Resume, arg == "20260916-101530-4f2a", ok == true
10+// for _, c := range command.For(command.Editor) { fmt.Println(c.Slash()) }
11+package command
12+
13+import (
14+ "strings"
15+
16+ "mm/internal/session"
17+)
18+
19+// Front is a front end, as a bit so a command can name several.
20+type Front uint8
21+
22+// The three front ends.
23+const (
24+ // Terminal is the line-mode REPL (internal/agent).
25+ Terminal Front = 1 << iota
26+ // Editor is the ACP façade (internal/acp): the editor owns the process
27+ // and cancels turns itself, so it gets neither /quit nor /abort.
28+ Editor
29+ // Window is the single-window mode (internal/tui).
30+ Window
31+)
32+
33+// The command names, without the slash.
34+const (
35+ Quit = "quit"
36+ Abort = "abort"
37+ Compact = "compact"
38+ New = session.NewCommandName
39+ Sessions = "sessions"
40+ Resume = "resume"
41+ Help = "help"
42+ Copy = "copy"
43+)
44+
45+// Command is one slash command: what it is called, what it does, who offers
46+// it, and whether it takes an argument.
47+type Command struct {
48+ Name string
49+ Description string
50+ Fronts Front
51+ // TakesArg says "/name something" is still this command ("/resume <id>");
52+ // without it, "/compact now" is a question for the model, as it always was.
53+ TakesArg bool
54+}
55+
56+// Slash is the command as typed.
57+func (c Command) Slash() string { return "/" + c.Name }
58+
59+// Offered reports whether front f lists this command.
60+func (c Command) Offered(f Front) bool { return c.Fronts&f != 0 }
61+
62+// table is the single source of truth. Order is the display order.
63+var table = []Command{
64+ {Name: Quit, Description: "Exit the agent", Fronts: Terminal | Window},
65+ {Name: Abort, Description: "Stop the generation in progress", Fronts: Terminal | Window},
66+ {Name: Compact, Description: "Compress the history now, keeping the last turns", Fronts: Terminal | Editor | Window},
67+ {Name: New, Description: "Clear the history and start a new session", Fronts: Terminal | Editor | Window},
68+ {Name: Sessions, Description: "List the stored sessions of this project", Fronts: Terminal | Window},
69+ {Name: Resume, Description: "Continue a stored session: the latest, or the given id", Fronts: Terminal | Window, TakesArg: true},
70+ {Name: Help, Description: "List the commands", Fronts: Terminal | Window},
71+ {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
72+}
73+
74+// All returns the whole table, in display order.
75+func All() []Command { return append([]Command(nil), table...) }
76+
77+// For returns the commands front f offers, in display order.
78+func For(f Front) []Command {
79+ var out []Command
80+ for _, c := range table {
81+ if c.Offered(f) {
82+ out = append(out, c)
83+ }
84+ }
85+ return out
86+}
87+
88+// Parse recognises a slash command in what was typed. The input is trimmed
89+// first: an editor sends the line as typed, spaces included. A command that
90+// takes no argument must be the whole line; one that does may be followed by
91+// its argument, returned trimmed (empty when absent).
92+//
93+// Parse("/new") // New, "", true
94+// Parse(" /compact\n") // Compact, "", true
95+// Parse("/compact now") // _, _, false — a question for the model
96+// Parse("/resume") // Resume, "", true
97+// Parse("/resume abc") // Resume, "abc", true
98+func Parse(input string) (cmd Command, arg string, ok bool) {
99+ input = strings.TrimSpace(input)
100+ if !strings.HasPrefix(input, "/") {
101+ return Command{}, "", false
102+ }
103+ name, rest, _ := strings.Cut(input[1:], " ")
104+ for _, c := range table {
105+ if c.Name != name {
106+ continue
107+ }
108+ rest = strings.TrimSpace(rest)
109+ if rest != "" && !c.TakesArg {
110+ return Command{}, "", false
111+ }
112+ return c, rest, true
113+ }
114+ return Command{}, "", false
115+}
116+
117+// Is reports whether input is exactly the command named, with no argument
118+// — the test the front ends make most often.
119+func Is(input, name string) bool {
120+ cmd, arg, ok := Parse(input)
121+ return ok && cmd.Name == name && arg == ""
122+}
123+
124+// Usage renders the commands front f offers, one per line, aligned: what
125+// /help prints.
126+func Usage(f Front) string {
127+ cmds := For(f)
128+ width := 0
129+ for _, c := range cmds {
130+ width = max(width, len(usage(c)))
131+ }
132+ var b strings.Builder
133+ for _, c := range cmds {
134+ b.WriteString(usage(c))
135+ b.WriteString(strings.Repeat(" ", width-len(usage(c))))
136+ b.WriteString(" ")
137+ b.WriteString(c.Description)
138+ b.WriteByte('\n')
139+ }
140+ return strings.TrimRight(b.String(), "\n")
141+}
142+
143+// usage is how a command is typed, with its argument when it takes one.
144+func usage(c Command) string {
145+ if c.TakesArg {
146+ return c.Slash() + " [arg]"
147+ }
148+ return c.Slash()
149+}
new file mode 100644
@@ -0,0 +1,149 @@
1+// Package command is the one table of slash commands, read by every front
2+// end: the terminal REPL dispatches on it, the ACP façade advertises the part
3+// of it an editor may send, and the window mode's "/" picker lists it. One
4+// table, so the three cannot drift — a command that works but is not listed
5+// is one the user never finds, and one listed but not handled reaches the
6+// model as a question.
7+//
8+// cmd, arg, ok := command.Parse("/resume 20260916-101530-4f2a")
9+// // cmd.Name == command.Resume, arg == "20260916-101530-4f2a", ok == true
10+// for _, c := range command.For(command.Editor) { fmt.Println(c.Slash()) }
11+package command
12+
13+import (
14+ "strings"
15+
16+ "mm/internal/session"
17+)
18+
19+// Front is a front end, as a bit so a command can name several.
20+type Front uint8
21+
22+// The three front ends.
23+const (
24+ // Terminal is the line-mode REPL (internal/agent).
25+ Terminal Front = 1 << iota
26+ // Editor is the ACP façade (internal/acp): the editor owns the process
27+ // and cancels turns itself, so it gets neither /quit nor /abort.
28+ Editor
29+ // Window is the single-window mode (internal/tui).
30+ Window
31+)
32+
33+// The command names, without the slash.
34+const (
35+ Quit = "quit"
36+ Abort = "abort"
37+ Compact = "compact"
38+ New = session.NewCommandName
39+ Sessions = "sessions"
40+ Resume = "resume"
41+ Help = "help"
42+ Copy = "copy"
43+)
44+
45+// Command is one slash command: what it is called, what it does, who offers
46+// it, and whether it takes an argument.
47+type Command struct {
48+ Name string
49+ Description string
50+ Fronts Front
51+ // TakesArg says "/name something" is still this command ("/resume <id>");
52+ // without it, "/compact now" is a question for the model, as it always was.
53+ TakesArg bool
54+}
55+
56+// Slash is the command as typed.
57+func (c Command) Slash() string { return "/" + c.Name }
58+
59+// Offered reports whether front f lists this command.
60+func (c Command) Offered(f Front) bool { return c.Fronts&f != 0 }
61+
62+// table is the single source of truth. Order is the display order.
63+var table = []Command{
64+ {Name: Quit, Description: "Exit the agent", Fronts: Terminal | Window},
65+ {Name: Abort, Description: "Stop the generation in progress", Fronts: Terminal | Window},
66+ {Name: Compact, Description: "Compress the history now, keeping the last turns", Fronts: Terminal | Editor | Window},
67+ {Name: New, Description: "Clear the history and start a new session", Fronts: Terminal | Editor | Window},
68+ {Name: Sessions, Description: "List the stored sessions of this project", Fronts: Terminal | Window},
69+ {Name: Resume, Description: "Continue a stored session: the latest, or the given id", Fronts: Terminal | Window, TakesArg: true},
70+ {Name: Help, Description: "List the commands", Fronts: Terminal | Window},
71+ {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true},
72+}
73+
74+// All returns the whole table, in display order.
75+func All() []Command { return append([]Command(nil), table...) }
76+
77+// For returns the commands front f offers, in display order.
78+func For(f Front) []Command {
79+ var out []Command
80+ for _, c := range table {
81+ if c.Offered(f) {
82+ out = append(out, c)
83+ }
84+ }
85+ return out
86+}
87+
88+// Parse recognises a slash command in what was typed. The input is trimmed
89+// first: an editor sends the line as typed, spaces included. A command that
90+// takes no argument must be the whole line; one that does may be followed by
91+// its argument, returned trimmed (empty when absent).
92+//
93+// Parse("/new") // New, "", true
94+// Parse(" /compact\n") // Compact, "", true
95+// Parse("/compact now") // _, _, false — a question for the model
96+// Parse("/resume") // Resume, "", true
97+// Parse("/resume abc") // Resume, "abc", true
98+func Parse(input string) (cmd Command, arg string, ok bool) {
99+ input = strings.TrimSpace(input)
100+ if !strings.HasPrefix(input, "/") {
101+ return Command{}, "", false
102+ }
103+ name, rest, _ := strings.Cut(input[1:], " ")
104+ for _, c := range table {
105+ if c.Name != name {
106+ continue
107+ }
108+ rest = strings.TrimSpace(rest)
109+ if rest != "" && !c.TakesArg {
110+ return Command{}, "", false
111+ }
112+ return c, rest, true
113+ }
114+ return Command{}, "", false
115+}
116+
117+// Is reports whether input is exactly the command named, with no argument
118+// — the test the front ends make most often.
119+func Is(input, name string) bool {
120+ cmd, arg, ok := Parse(input)
121+ return ok && cmd.Name == name && arg == ""
122+}
123+
124+// Usage renders the commands front f offers, one per line, aligned: what
125+// /help prints.
126+func Usage(f Front) string {
127+ cmds := For(f)
128+ width := 0
129+ for _, c := range cmds {
130+ width = max(width, len(usage(c)))
131+ }
132+ var b strings.Builder
133+ for _, c := range cmds {
134+ b.WriteString(usage(c))
135+ b.WriteString(strings.Repeat(" ", width-len(usage(c))))
136+ b.WriteString(" ")
137+ b.WriteString(c.Description)
138+ b.WriteByte('\n')
139+ }
140+ return strings.TrimRight(b.String(), "\n")
141+}
142+
143+// usage is how a command is typed, with its argument when it takes one.
144+func usage(c Command) string {
145+ if c.TakesArg {
146+ return c.Slash() + " [arg]"
147+ }
148+ return c.Slash()
149+}
added internal/command/command_test.go +123 -0
new file mode 100644
@@ -0,0 +1,123 @@
1+package command
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// The editor gets exactly what the ACP façade intercepts — no /quit (it owns
9+// the process), no /abort (its stop key cancels), no session browsing (it
10+// lists its own threads) — and the two terminal modes get everything.
11+func TestForFiltersByFront(t *testing.T) {
12+ names := func(cmds []Command) string {
13+ var out []string
14+ for _, c := range cmds {
15+ out = append(out, c.Name)
16+ }
17+ return strings.Join(out, " ")
18+ }
19+ if got := names(For(Editor)); got != "compact new" {
20+ t.Errorf("Editor commands = %q, want %q", got, "compact new")
21+ }
22+ want := "quit abort compact new sessions resume help copy"
23+ if got := names(For(Terminal)); got != want {
24+ t.Errorf("Terminal commands = %q, want %q", got, want)
25+ }
26+ if got := names(For(Window)); got != want {
27+ t.Errorf("Window commands = %q, want %q", got, want)
28+ }
29+ if len(All()) != 8 {
30+ t.Errorf("All() has %d commands, want 8", len(All()))
31+ }
32+}
33+
34+// Parse trims, needs the slash, refuses an argument on a command that takes
35+// none (so "/compact now" still goes to the model), and returns the argument
36+// of one that does.
37+func TestParse(t *testing.T) {
38+ cases := []struct {
39+ in string
40+ name string
41+ arg string
42+ ok bool
43+ }{
44+ {"/new", New, "", true},
45+ {" /compact\n", Compact, "", true},
46+ {"/compact now", "", "", false},
47+ {"/resume", Resume, "", true},
48+ {"/resume abc ", Resume, "abc", true},
49+ {"/help", Help, "", true},
50+ {"/nope", "", "", false},
51+ {"new", "", "", false},
52+ {"", "", "", false},
53+ {"/", "", "", false},
54+ }
55+ for _, c := range cases {
56+ cmd, arg, ok := Parse(c.in)
57+ if ok != c.ok || cmd.Name != c.name || arg != c.arg {
58+ t.Errorf("Parse(%q) = (%q, %q, %v), want (%q, %q, %v)", c.in, cmd.Name, arg, ok, c.name, c.arg, c.ok)
59+ }
60+ }
61+}
62+
63+// Is is the exact-match test the front ends make: the command alone.
64+func TestIs(t *testing.T) {
65+ if !Is("/quit", Quit) || !Is(" /abort ", Abort) {
66+ t.Error("Is rejects an exact command")
67+ }
68+ if Is("/resume abc", Resume) {
69+ t.Error("Is accepted a command with an argument")
70+ }
71+ if Is("/quit", Abort) {
72+ t.Error("Is matched the wrong command")
73+ }
74+}
75+
76+// Every command has a description (a picker shows bare names otherwise) and a
77+// name that Parse recognises when typed back with its slash.
78+func TestTableIsSelfConsistent(t *testing.T) {
79+ seen := map[string]bool{}
80+ for _, c := range All() {
81+ if c.Description == "" {
82+ t.Errorf("%s has no description", c.Slash())
83+ }
84+ if seen[c.Name] {
85+ t.Errorf("%s listed twice", c.Slash())
86+ }
87+ seen[c.Name] = true
88+ if got, _, ok := Parse(c.Slash()); !ok || got.Name != c.Name {
89+ t.Errorf("Parse(%q) does not find the command", c.Slash())
90+ }
91+ if c.Fronts == 0 {
92+ t.Errorf("%s is offered nowhere", c.Slash())
93+ }
94+ }
95+}
96+
97+// Usage lists one command per line, aligned, with the argument placeholder on
98+// the commands that take one and nothing the front does not offer.
99+func TestUsageListsAlignedLines(t *testing.T) {
100+ text := Usage(Editor)
101+ lines := strings.Split(text, "\n")
102+ if len(lines) != 2 || !strings.HasPrefix(lines[0], "/compact") || !strings.HasPrefix(lines[1], "/new") {
103+ t.Fatalf("Usage(Editor):\n%s", text)
104+ }
105+ full := Usage(Terminal)
106+ if !strings.Contains(full, "/resume [arg]") || !strings.Contains(full, "/help") {
107+ t.Errorf("Usage(Terminal) misses usage lines:\n%s", full)
108+ }
109+ // Aligned: every description starts at the same column.
110+ col := -1
111+ for _, l := range strings.Split(full, "\n") {
112+ at := strings.Index(l, " ")
113+ for at < len(l) && l[at] == ' ' {
114+ at++
115+ }
116+ if col == -1 {
117+ col = at
118+ } else if at != col {
119+ t.Errorf("descriptions not aligned:\n%s", full)
120+ break
121+ }
122+ }
123+}
new file mode 100644
@@ -0,0 +1,123 @@
1+package command
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// The editor gets exactly what the ACP façade intercepts — no /quit (it owns
9+// the process), no /abort (its stop key cancels), no session browsing (it
10+// lists its own threads) — and the two terminal modes get everything.
11+func TestForFiltersByFront(t *testing.T) {
12+ names := func(cmds []Command) string {
13+ var out []string
14+ for _, c := range cmds {
15+ out = append(out, c.Name)
16+ }
17+ return strings.Join(out, " ")
18+ }
19+ if got := names(For(Editor)); got != "compact new" {
20+ t.Errorf("Editor commands = %q, want %q", got, "compact new")
21+ }
22+ want := "quit abort compact new sessions resume help copy"
23+ if got := names(For(Terminal)); got != want {
24+ t.Errorf("Terminal commands = %q, want %q", got, want)
25+ }
26+ if got := names(For(Window)); got != want {
27+ t.Errorf("Window commands = %q, want %q", got, want)
28+ }
29+ if len(All()) != 8 {
30+ t.Errorf("All() has %d commands, want 8", len(All()))
31+ }
32+}
33+
34+// Parse trims, needs the slash, refuses an argument on a command that takes
35+// none (so "/compact now" still goes to the model), and returns the argument
36+// of one that does.
37+func TestParse(t *testing.T) {
38+ cases := []struct {
39+ in string
40+ name string
41+ arg string
42+ ok bool
43+ }{
44+ {"/new", New, "", true},
45+ {" /compact\n", Compact, "", true},
46+ {"/compact now", "", "", false},
47+ {"/resume", Resume, "", true},
48+ {"/resume abc ", Resume, "abc", true},
49+ {"/help", Help, "", true},
50+ {"/nope", "", "", false},
51+ {"new", "", "", false},
52+ {"", "", "", false},
53+ {"/", "", "", false},
54+ }
55+ for _, c := range cases {
56+ cmd, arg, ok := Parse(c.in)
57+ if ok != c.ok || cmd.Name != c.name || arg != c.arg {
58+ t.Errorf("Parse(%q) = (%q, %q, %v), want (%q, %q, %v)", c.in, cmd.Name, arg, ok, c.name, c.arg, c.ok)
59+ }
60+ }
61+}
62+
63+// Is is the exact-match test the front ends make: the command alone.
64+func TestIs(t *testing.T) {
65+ if !Is("/quit", Quit) || !Is(" /abort ", Abort) {
66+ t.Error("Is rejects an exact command")
67+ }
68+ if Is("/resume abc", Resume) {
69+ t.Error("Is accepted a command with an argument")
70+ }
71+ if Is("/quit", Abort) {
72+ t.Error("Is matched the wrong command")
73+ }
74+}
75+
76+// Every command has a description (a picker shows bare names otherwise) and a
77+// name that Parse recognises when typed back with its slash.
78+func TestTableIsSelfConsistent(t *testing.T) {
79+ seen := map[string]bool{}
80+ for _, c := range All() {
81+ if c.Description == "" {
82+ t.Errorf("%s has no description", c.Slash())
83+ }
84+ if seen[c.Name] {
85+ t.Errorf("%s listed twice", c.Slash())
86+ }
87+ seen[c.Name] = true
88+ if got, _, ok := Parse(c.Slash()); !ok || got.Name != c.Name {
89+ t.Errorf("Parse(%q) does not find the command", c.Slash())
90+ }
91+ if c.Fronts == 0 {
92+ t.Errorf("%s is offered nowhere", c.Slash())
93+ }
94+ }
95+}
96+
97+// Usage lists one command per line, aligned, with the argument placeholder on
98+// the commands that take one and nothing the front does not offer.
99+func TestUsageListsAlignedLines(t *testing.T) {
100+ text := Usage(Editor)
101+ lines := strings.Split(text, "\n")
102+ if len(lines) != 2 || !strings.HasPrefix(lines[0], "/compact") || !strings.HasPrefix(lines[1], "/new") {
103+ t.Fatalf("Usage(Editor):\n%s", text)
104+ }
105+ full := Usage(Terminal)
106+ if !strings.Contains(full, "/resume [arg]") || !strings.Contains(full, "/help") {
107+ t.Errorf("Usage(Terminal) misses usage lines:\n%s", full)
108+ }
109+ // Aligned: every description starts at the same column.
110+ col := -1
111+ for _, l := range strings.Split(full, "\n") {
112+ at := strings.Index(l, " ")
113+ for at < len(l) && l[at] == ' ' {
114+ at++
115+ }
116+ if col == -1 {
117+ col = at
118+ } else if at != col {
119+ t.Errorf("descriptions not aligned:\n%s", full)
120+ break
121+ }
122+ }
123+}
added internal/compact/apply.go +46 -0
new file mode 100644
@@ -0,0 +1,46 @@
1+package compact
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "mm/internal/config"
9+
10+ "github.com/firebase/genkit/go/ai"
11+)
12+
13+// Outcome says how Apply ended.
14+type Outcome int
15+
16+// The three ways a compaction can end.
17+const (
18+ // Compressed: old turns were replaced by a summary; the history changed.
19+ Compressed Outcome = iota
20+ // Nothing: no turn older than the kept ones; the history is unchanged.
21+ Nothing
22+ // Failed: the summariser failed; the history is unchanged.
23+ Failed
24+)
25+
26+// Apply compresses msgs and returns the history to keep, the one-line report
27+// the front ends show, and how it ended. The words are the same for the
28+// terminal, the editor and the window, so the three agree on what happened;
29+// what to do with the line (dim it, send it, put it in a transcript) and
30+// whether to forget the server's token count are the caller's business.
31+// explain turns the summariser's error into one line (engine.Explain).
32+//
33+// msgs, line, outcome := compact.Apply(ctx, msgs, cfg, summarize, e.Explain)
34+// if outcome == compact.Compressed {
35+// e.ForgetInputTokens()
36+// }
37+func Apply(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer, explain func(error) string) ([]*ai.Message, string, Outcome) {
38+ res, err := Compact(ctx, msgs, cfg, summarize)
39+ switch {
40+ case errors.Is(err, ErrNothingToCompact):
41+ return msgs, fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns), Nothing
42+ case err != nil:
43+ return msgs, "[compact: failed, history kept: " + explain(err) + "]", Failed
44+ }
45+ return res.Messages, "🗜️ " + res.Report(), Compressed
46+}
new file mode 100644
@@ -0,0 +1,46 @@
1+package compact
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+
8+ "mm/internal/config"
9+
10+ "github.com/firebase/genkit/go/ai"
11+)
12+
13+// Outcome says how Apply ended.
14+type Outcome int
15+
16+// The three ways a compaction can end.
17+const (
18+ // Compressed: old turns were replaced by a summary; the history changed.
19+ Compressed Outcome = iota
20+ // Nothing: no turn older than the kept ones; the history is unchanged.
21+ Nothing
22+ // Failed: the summariser failed; the history is unchanged.
23+ Failed
24+)
25+
26+// Apply compresses msgs and returns the history to keep, the one-line report
27+// the front ends show, and how it ended. The words are the same for the
28+// terminal, the editor and the window, so the three agree on what happened;
29+// what to do with the line (dim it, send it, put it in a transcript) and
30+// whether to forget the server's token count are the caller's business.
31+// explain turns the summariser's error into one line (engine.Explain).
32+//
33+// msgs, line, outcome := compact.Apply(ctx, msgs, cfg, summarize, e.Explain)
34+// if outcome == compact.Compressed {
35+// e.ForgetInputTokens()
36+// }
37+func Apply(ctx context.Context, msgs []*ai.Message, cfg config.ContextConfig, summarize Summarizer, explain func(error) string) ([]*ai.Message, string, Outcome) {
38+ res, err := Compact(ctx, msgs, cfg, summarize)
39+ switch {
40+ case errors.Is(err, ErrNothingToCompact):
41+ return msgs, fmt.Sprintf("🗜️ nothing to compact: %d message(s), no turn older than the last %d", len(msgs), cfg.KeepLastTurns), Nothing
42+ case err != nil:
43+ return msgs, "[compact: failed, history kept: " + explain(err) + "]", Failed
44+ }
45+ return res.Messages, "🗜️ " + res.Report(), Compressed
46+}
added internal/compact/apply_test.go +44 -0
new file mode 100644
@@ -0,0 +1,44 @@
1+package compact
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// The three outcomes, with the words every front end shows. Only Compressed
13+// returns a different history.
14+func TestApplyOutcomes(t *testing.T) {
15+ explain := func(err error) string { return "explained: " + err.Error() }
16+ summary, _ := fixed("the user ran a and b")
17+
18+ msgs := history(turn("a", 1, "x"), turn("b", 1, "y"), turn("c", 1, "z"))
19+ out, line, outcome := Apply(context.Background(), msgs, cfg(1), summary, explain)
20+ if outcome != Compressed || len(out) >= len(msgs) {
21+ t.Fatalf("outcome %v, %d → %d messages", outcome, len(msgs), len(out))
22+ }
23+ if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
24+ t.Errorf("report %q", line)
25+ }
26+
27+ one := history(turn("a", 1, "x"))
28+ out, line, outcome = Apply(context.Background(), one, cfg(1), summary, explain)
29+ if outcome != Nothing || len(out) != len(one) {
30+ t.Errorf("outcome %v, %d → %d messages", outcome, len(one), len(out))
31+ }
32+ if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
33+ t.Errorf("line %q", line)
34+ }
35+
36+ failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
37+ out, line, outcome = Apply(context.Background(), msgs, cfg(1), failing, explain)
38+ if outcome != Failed || len(out) != len(msgs) {
39+ t.Errorf("outcome %v, %d → %d messages", outcome, len(msgs), len(out))
40+ }
41+ if line != "[compact: failed, history kept: explained: server down]" {
42+ t.Errorf("line %q", line)
43+ }
44+}
new file mode 100644
@@ -0,0 +1,44 @@
1+package compact
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+
9+ "github.com/firebase/genkit/go/ai"
10+)
11+
12+// The three outcomes, with the words every front end shows. Only Compressed
13+// returns a different history.
14+func TestApplyOutcomes(t *testing.T) {
15+ explain := func(err error) string { return "explained: " + err.Error() }
16+ summary, _ := fixed("the user ran a and b")
17+
18+ msgs := history(turn("a", 1, "x"), turn("b", 1, "y"), turn("c", 1, "z"))
19+ out, line, outcome := Apply(context.Background(), msgs, cfg(1), summary, explain)
20+ if outcome != Compressed || len(out) >= len(msgs) {
21+ t.Fatalf("outcome %v, %d → %d messages", outcome, len(msgs), len(out))
22+ }
23+ if !strings.HasPrefix(line, "🗜️ compressed 8 messages → 1 summary + 4 kept") {
24+ t.Errorf("report %q", line)
25+ }
26+
27+ one := history(turn("a", 1, "x"))
28+ out, line, outcome = Apply(context.Background(), one, cfg(1), summary, explain)
29+ if outcome != Nothing || len(out) != len(one) {
30+ t.Errorf("outcome %v, %d → %d messages", outcome, len(one), len(out))
31+ }
32+ if line != "🗜️ nothing to compact: 5 message(s), no turn older than the last 1" {
33+ t.Errorf("line %q", line)
34+ }
35+
36+ failing := func(context.Context, []*ai.Message) (string, error) { return "", errors.New("server down") }
37+ out, line, outcome = Apply(context.Background(), msgs, cfg(1), failing, explain)
38+ if outcome != Failed || len(out) != len(msgs) {
39+ t.Errorf("outcome %v, %d → %d messages", outcome, len(msgs), len(out))
40+ }
41+ if line != "[compact: failed, history kept: explained: server down]" {
42+ t.Errorf("line %q", line)
43+ }
44+}
modified internal/compact/compact.go +5 -3
@@ -50,11 +50,13 @@ type Result struct {
5050 // compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)
5151 func (r Result) Report() string {
5252 return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",
53- r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed))
53+ r.Compressed, r.Kept, Kilo(r.Before), Kilo(r.After), seconds(r.Elapsed))
5454 }
5555
56-// kilo prints a token count the way the banner does: 18.2k, not 18234.
57-func kilo(n int) string {
56+// Kilo prints a token count the way the banner does: 18.2k, not 18234.
57+//
58+// compact.Kilo(18234) // "18.2k"
59+func Kilo(n int) string {
5860 if n < 1000 {
5961 return fmt.Sprintf("%d", n)
6062 }
@@ -50,11 +50,13 @@ type Result struct {
50 // compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)50 // compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s)
51 func (r Result) Report() string {51 func (r Result) Report() string {
52 return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",52 return fmt.Sprintf("compressed %d messages → 1 summary + %d kept (%s → %s tokens, %s)",
53- r.Compressed, r.Kept, kilo(r.Before), kilo(r.After), seconds(r.Elapsed))53+ r.Compressed, r.Kept, Kilo(r.Before), Kilo(r.After), seconds(r.Elapsed))
54 }54 }
55 55
56-// kilo prints a token count the way the banner does: 18.2k, not 18234.56+// Kilo prints a token count the way the banner does: 18.2k, not 18234.
57-func kilo(n int) string {57+//
58+// compact.Kilo(18234) // "18.2k"
59+func Kilo(n int) string {
58 if n < 1000 {60 if n < 1000 {
59 return fmt.Sprintf("%d", n)61 return fmt.Sprintf("%d", n)
60 }62 }
modified internal/config/config.go +43 -0
@@ -90,6 +90,40 @@ type Config struct {
9090 // measures against is the top-level ContextWindow above — or, when that is
9191 // 0, what the provider's probe learned from the server.
9292 Context ContextConfig `yaml:"context"`
93+
94+ // Sessions: whether conversations are saved per project, and where
95+ // (internal/store). Off unless `sessions.enabled: true`. When on, every
96+ // turn rewrites the session's file, the terminal can /resume one, and an
97+ // editor can reload one over ACP (session/load).
98+ Sessions SessionsConfig `yaml:"sessions"`
99+
100+ // TUI: start in the single-window mode (`mm -tui`) without the flag.
101+ // Falls back to the line mode when stdout is not a terminal.
102+ TUI bool `yaml:"tui"`
103+
104+ // Permissions: whether the window mode asks before `bash` and the file
105+ // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as
106+ // the terminal always has. Only the window mode reads it today; the
107+ // editor has its own dialog and the line mode has no gatekeeper.
108+ Permissions string `yaml:"permissions"`
109+}
110+
111+// The two values of Permissions.
112+const (
113+ PermissionsAuto = "auto"
114+ PermissionsAsk = "ask"
115+)
116+
117+// SessionsConfig is the `sessions:` block of the YAML.
118+type SessionsConfig struct {
119+ // Enabled turns the persistence on. Without it nothing is written and
120+ // ACP does not advertise session/load.
121+ Enabled bool `yaml:"enabled"`
122+
123+ // Dir: where the session files go, relative to the project — the
124+ // directory mm is started from in the terminal, the session's cwd in ACP
125+ // mode. An absolute path is used as-is (one store for every project).
126+ Dir string `yaml:"dir"`
93127 }
94128
95129 // ContextConfig drives the compression of the history (see
@@ -179,6 +213,11 @@ which ones you left running.`,
179213 "max_tokens": 4096,
180214 },
181215 WatchdogTimeout: 20 * time.Second,
216+ Sessions: SessionsConfig{
217+ Enabled: false,
218+ Dir: ".mm/sessions",
219+ },
220+ Permissions: PermissionsAuto,
182221 Context: ContextConfig{
183222 Enabled: false,
184223 // 75 % leaves a quarter of the window for the next question, the tool
@@ -273,6 +312,10 @@ func (c Config) validate() error {
273312 return errors.New("context.summaryMaxTokens must be > 0")
274313 case c.Context.MaxMessages < 0:
275314 return errors.New("context.maxMessages must be >= 0")
315+ case c.Sessions.Dir == "":
316+ return errors.New("sessions.dir must not be empty")
317+ case c.Permissions != PermissionsAuto && c.Permissions != PermissionsAsk:
318+ return errors.New(`permissions must be "auto" or "ask"`)
276319 }
277320 // `enabled: true` with no window and no maxMessages would never trigger —
278321 // but the window may still come from the server's probe at start-up, so
@@ -90,6 +90,40 @@ type Config struct {
90 // measures against is the top-level ContextWindow above — or, when that is90 // measures against is the top-level ContextWindow above — or, when that is
91 // 0, what the provider's probe learned from the server.91 // 0, what the provider's probe learned from the server.
92 Context ContextConfig `yaml:"context"`92 Context ContextConfig `yaml:"context"`
93+
94+ // Sessions: whether conversations are saved per project, and where
95+ // (internal/store). Off unless `sessions.enabled: true`. When on, every
96+ // turn rewrites the session's file, the terminal can /resume one, and an
97+ // editor can reload one over ACP (session/load).
98+ Sessions SessionsConfig `yaml:"sessions"`
99+
100+ // TUI: start in the single-window mode (`mm -tui`) without the flag.
101+ // Falls back to the line mode when stdout is not a terminal.
102+ TUI bool `yaml:"tui"`
103+
104+ // Permissions: whether the window mode asks before `bash` and the file
105+ // tools run — "ask" shows a/s/r in the box, "auto" (default) runs them as
106+ // the terminal always has. Only the window mode reads it today; the
107+ // editor has its own dialog and the line mode has no gatekeeper.
108+ Permissions string `yaml:"permissions"`
109+}
110+
111+// The two values of Permissions.
112+const (
113+ PermissionsAuto = "auto"
114+ PermissionsAsk = "ask"
115+)
116+
117+// SessionsConfig is the `sessions:` block of the YAML.
118+type SessionsConfig struct {
119+ // Enabled turns the persistence on. Without it nothing is written and
120+ // ACP does not advertise session/load.
121+ Enabled bool `yaml:"enabled"`
122+
123+ // Dir: where the session files go, relative to the project — the
124+ // directory mm is started from in the terminal, the session's cwd in ACP
125+ // mode. An absolute path is used as-is (one store for every project).
126+ Dir string `yaml:"dir"`
93 }127 }
94 128
95 // ContextConfig drives the compression of the history (see129 // ContextConfig drives the compression of the history (see
@@ -179,6 +213,11 @@ which ones you left running.`,
179 "max_tokens": 4096,213 "max_tokens": 4096,
180 },214 },
181 WatchdogTimeout: 20 * time.Second,215 WatchdogTimeout: 20 * time.Second,
216+ Sessions: SessionsConfig{
217+ Enabled: false,
218+ Dir: ".mm/sessions",
219+ },
220+ Permissions: PermissionsAuto,
182 Context: ContextConfig{221 Context: ContextConfig{
183 Enabled: false,222 Enabled: false,
184 // 75 % leaves a quarter of the window for the next question, the tool223 // 75 % leaves a quarter of the window for the next question, the tool
@@ -273,6 +312,10 @@ func (c Config) validate() error {
273 return errors.New("context.summaryMaxTokens must be > 0")312 return errors.New("context.summaryMaxTokens must be > 0")
274 case c.Context.MaxMessages < 0:313 case c.Context.MaxMessages < 0:
275 return errors.New("context.maxMessages must be >= 0")314 return errors.New("context.maxMessages must be >= 0")
315+ case c.Sessions.Dir == "":
316+ return errors.New("sessions.dir must not be empty")
317+ case c.Permissions != PermissionsAuto && c.Permissions != PermissionsAsk:
318+ return errors.New(`permissions must be "auto" or "ask"`)
276 }319 }
277 // `enabled: true` with no window and no maxMessages would never trigger —320 // `enabled: true` with no window and no maxMessages would never trigger —
278 // but the window may still come from the server's probe at start-up, so321 // but the window may still come from the server's probe at start-up, so
added internal/config/config_test.go +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// The sessions block is off by default and points at .mm/sessions, so a YAML
11+// that never mentions it changes nothing; a YAML that turns it on is read;
12+// an empty dir is refused rather than silently writing next to the binary.
13+func TestSessionsConfig(t *testing.T) {
14+ defaults := Cfg
15+ t.Cleanup(func() { Cfg = defaults })
16+
17+ if Cfg.Sessions.Enabled || Cfg.Sessions.Dir != ".mm/sessions" {
18+ t.Fatalf("defaults: %+v, want disabled at .mm/sessions", Cfg.Sessions)
19+ }
20+
21+ dir := t.TempDir()
22+ on := filepath.Join(dir, "on.yaml")
23+ if err := os.WriteFile(on, []byte("sessions:\n enabled: true\n dir: history/sessions\n"), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ if _, err := Load(on); err != nil {
27+ t.Fatal(err)
28+ }
29+ if !Cfg.Sessions.Enabled || Cfg.Sessions.Dir != "history/sessions" {
30+ t.Errorf("after on.yaml: %+v", Cfg.Sessions)
31+ }
32+
33+ Cfg = defaults
34+ bad := filepath.Join(dir, "bad.yaml")
35+ if err := os.WriteFile(bad, []byte("sessions:\n enabled: true\n dir: \"\"\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ if _, err := Load(bad); err == nil || !strings.Contains(err.Error(), "sessions.dir") {
39+ t.Errorf("Load(bad.yaml) = %v, want a sessions.dir validation error", err)
40+ }
41+}
42+
43+// The window mode is off by default and permissions are "auto", so nothing
44+// changes for existing YAML files; `tui: true` and `permissions: ask` are
45+// read; any other permissions value is refused with its name.
46+func TestTUIAndPermissionsConfig(t *testing.T) {
47+ defaults := Cfg
48+ t.Cleanup(func() { Cfg = defaults })
49+
50+ if Cfg.TUI || Cfg.Permissions != PermissionsAuto {
51+ t.Fatalf("defaults: tui %v permissions %q", Cfg.TUI, Cfg.Permissions)
52+ }
53+
54+ dir := t.TempDir()
55+ on := filepath.Join(dir, "on.yaml")
56+ if err := os.WriteFile(on, []byte("tui: true\npermissions: ask\n"), 0o644); err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := Load(on); err != nil {
60+ t.Fatal(err)
61+ }
62+ if !Cfg.TUI || Cfg.Permissions != PermissionsAsk {
63+ t.Errorf("after on.yaml: tui %v permissions %q", Cfg.TUI, Cfg.Permissions)
64+ }
65+
66+ Cfg = defaults
67+ bad := filepath.Join(dir, "bad.yaml")
68+ if err := os.WriteFile(bad, []byte("permissions: maybe\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ if _, err := Load(bad); err == nil || !strings.Contains(err.Error(), "permissions") {
72+ t.Errorf("Load(bad.yaml) = %v, want a permissions validation error", err)
73+ }
74+}
new file mode 100644
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+)
9+
10+// The sessions block is off by default and points at .mm/sessions, so a YAML
11+// that never mentions it changes nothing; a YAML that turns it on is read;
12+// an empty dir is refused rather than silently writing next to the binary.
13+func TestSessionsConfig(t *testing.T) {
14+ defaults := Cfg
15+ t.Cleanup(func() { Cfg = defaults })
16+
17+ if Cfg.Sessions.Enabled || Cfg.Sessions.Dir != ".mm/sessions" {
18+ t.Fatalf("defaults: %+v, want disabled at .mm/sessions", Cfg.Sessions)
19+ }
20+
21+ dir := t.TempDir()
22+ on := filepath.Join(dir, "on.yaml")
23+ if err := os.WriteFile(on, []byte("sessions:\n enabled: true\n dir: history/sessions\n"), 0o644); err != nil {
24+ t.Fatal(err)
25+ }
26+ if _, err := Load(on); err != nil {
27+ t.Fatal(err)
28+ }
29+ if !Cfg.Sessions.Enabled || Cfg.Sessions.Dir != "history/sessions" {
30+ t.Errorf("after on.yaml: %+v", Cfg.Sessions)
31+ }
32+
33+ Cfg = defaults
34+ bad := filepath.Join(dir, "bad.yaml")
35+ if err := os.WriteFile(bad, []byte("sessions:\n enabled: true\n dir: \"\"\n"), 0o644); err != nil {
36+ t.Fatal(err)
37+ }
38+ if _, err := Load(bad); err == nil || !strings.Contains(err.Error(), "sessions.dir") {
39+ t.Errorf("Load(bad.yaml) = %v, want a sessions.dir validation error", err)
40+ }
41+}
42+
43+// The window mode is off by default and permissions are "auto", so nothing
44+// changes for existing YAML files; `tui: true` and `permissions: ask` are
45+// read; any other permissions value is refused with its name.
46+func TestTUIAndPermissionsConfig(t *testing.T) {
47+ defaults := Cfg
48+ t.Cleanup(func() { Cfg = defaults })
49+
50+ if Cfg.TUI || Cfg.Permissions != PermissionsAuto {
51+ t.Fatalf("defaults: tui %v permissions %q", Cfg.TUI, Cfg.Permissions)
52+ }
53+
54+ dir := t.TempDir()
55+ on := filepath.Join(dir, "on.yaml")
56+ if err := os.WriteFile(on, []byte("tui: true\npermissions: ask\n"), 0o644); err != nil {
57+ t.Fatal(err)
58+ }
59+ if _, err := Load(on); err != nil {
60+ t.Fatal(err)
61+ }
62+ if !Cfg.TUI || Cfg.Permissions != PermissionsAsk {
63+ t.Errorf("after on.yaml: tui %v permissions %q", Cfg.TUI, Cfg.Permissions)
64+ }
65+
66+ Cfg = defaults
67+ bad := filepath.Join(dir, "bad.yaml")
68+ if err := os.WriteFile(bad, []byte("permissions: maybe\n"), 0o644); err != nil {
69+ t.Fatal(err)
70+ }
71+ if _, err := Load(bad); err == nil || !strings.Contains(err.Error(), "permissions") {
72+ t.Errorf("Load(bad.yaml) = %v, want a permissions validation error", err)
73+ }
74+}
modified internal/session/session.go +38 -0
@@ -59,3 +59,41 @@ func Forgotten(msgs []*ai.Message) int {
5959 }
6060 return n
6161 }
62+
63+// WithSystem returns msgs with the system prompt set to system: replaced when
64+// the history starts with one, prepended when it does not. Used when a stored
65+// session is resumed, so an old conversation continues under the agent's
66+// current prompt.
67+//
68+// messages = session.WithSystem(stored.Messages, config.Cfg.System)
69+func WithSystem(msgs []*ai.Message, system string) []*ai.Message {
70+ fresh := Fresh(system)
71+ if len(msgs) > 0 && msgs[0] != nil && msgs[0].Role == ai.RoleSystem {
72+ return append(fresh, msgs[1:]...)
73+ }
74+ return append(fresh, msgs...)
75+}
76+
77+// AfterTurn merges what a generation returned into the history, the same way
78+// in every front end: the FULL history Genkit was given when tool turns ran
79+// (even after a failure or a cancellation — the commands that ran are part
80+// of the conversation, and forgetting them makes the model re-run or invent
81+// them); the answer alone when there was no tool turn; and, when the turn
82+// failed before producing anything, the history without the unanswered
83+// question. before is len(messages) right after the question was appended.
84+//
85+// messages = append(messages, ai.NewUserTextMessage(input))
86+// before := len(messages)
87+// resp, history, err := e.Generate(ctx, messages, tools)
88+// messages = session.AfterTurn(messages, before, resp, history, err)
89+func AfterTurn(messages []*ai.Message, before int, resp *ai.ModelResponse, history []*ai.Message, err error) []*ai.Message {
90+ switch {
91+ case len(history) > before:
92+ return history
93+ case resp != nil && resp.Message != nil:
94+ return append(messages, resp.Message)
95+ case err != nil && before > 0 && before <= len(messages):
96+ return messages[:before-1]
97+ }
98+ return messages
99+}
@@ -59,3 +59,41 @@ func Forgotten(msgs []*ai.Message) int {
59 }59 }
60 return n60 return n
61 }61 }
62+
63+// WithSystem returns msgs with the system prompt set to system: replaced when
64+// the history starts with one, prepended when it does not. Used when a stored
65+// session is resumed, so an old conversation continues under the agent's
66+// current prompt.
67+//
68+// messages = session.WithSystem(stored.Messages, config.Cfg.System)
69+func WithSystem(msgs []*ai.Message, system string) []*ai.Message {
70+ fresh := Fresh(system)
71+ if len(msgs) > 0 && msgs[0] != nil && msgs[0].Role == ai.RoleSystem {
72+ return append(fresh, msgs[1:]...)
73+ }
74+ return append(fresh, msgs...)
75+}
76+
77+// AfterTurn merges what a generation returned into the history, the same way
78+// in every front end: the FULL history Genkit was given when tool turns ran
79+// (even after a failure or a cancellation — the commands that ran are part
80+// of the conversation, and forgetting them makes the model re-run or invent
81+// them); the answer alone when there was no tool turn; and, when the turn
82+// failed before producing anything, the history without the unanswered
83+// question. before is len(messages) right after the question was appended.
84+//
85+// messages = append(messages, ai.NewUserTextMessage(input))
86+// before := len(messages)
87+// resp, history, err := e.Generate(ctx, messages, tools)
88+// messages = session.AfterTurn(messages, before, resp, history, err)
89+func AfterTurn(messages []*ai.Message, before int, resp *ai.ModelResponse, history []*ai.Message, err error) []*ai.Message {
90+ switch {
91+ case len(history) > before:
92+ return history
93+ case resp != nil && resp.Message != nil:
94+ return append(messages, resp.Message)
95+ case err != nil && before > 0 && before <= len(messages):
96+ return messages[:before-1]
97+ }
98+ return messages
99+}
modified internal/session/session_test.go +44 -0
@@ -1,6 +1,7 @@
11 package session
22
33 import (
4+ "errors"
45 "fmt"
56 "testing"
67
@@ -87,3 +88,46 @@ func ExampleFresh() {
8788 fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs))
8889 // Output: 1 system 0
8990 }
91+
92+// A resumed history runs under the agent's current prompt: an old system
93+// message is replaced, a history without one gets one, and the conversation
94+// itself is untouched.
95+func TestWithSystemReplacesOrPrependsThePrompt(t *testing.T) {
96+ old := []*ai.Message{ai.NewSystemTextMessage("old prompt"), ai.NewUserTextMessage("hi"), ai.NewModelMessage(ai.NewTextPart("hello"))}
97+ got := WithSystem(old, "new prompt")
98+ if len(got) != 3 || got[0].Role != ai.RoleSystem || got[0].Text() != "new prompt" || got[1].Text() != "hi" || got[2].Text() != "hello" {
99+ t.Errorf("WithSystem(old) = %d messages, first %q", len(got), got[0].Text())
100+ }
101+ if old[0].Text() != "old prompt" {
102+ t.Error("WithSystem mutated its input")
103+ }
104+ bare := []*ai.Message{ai.NewUserTextMessage("hi")}
105+ if got := WithSystem(bare, "p"); len(got) != 2 || got[0].Role != ai.RoleSystem || got[1].Text() != "hi" {
106+ t.Errorf("WithSystem(bare) = %v", got)
107+ }
108+ if got := WithSystem(nil, "p"); len(got) != 1 || got[0].Text() != "p" {
109+ t.Errorf("WithSystem(nil) = %v", got)
110+ }
111+}
112+
113+// AfterTurn keeps the tool turns whatever happened, appends a lone answer,
114+// drops an unanswered question, and leaves an inconclusive result alone.
115+func TestAfterTurn(t *testing.T) {
116+ msgs := append(Fresh("sys"), ai.NewUserTextMessage("q"))
117+ before := len(msgs)
118+ full := append(append([]*ai.Message{}, msgs...), ai.NewModelMessage(ai.NewTextPart("call")), ai.NewMessage(ai.RoleTool, nil, ai.NewTextPart("out")))
119+
120+ if got := AfterTurn(msgs, before, nil, full, errors.New("cancelled")); len(got) != 4 {
121+ t.Errorf("tool turns dropped after a failure: %d messages", len(got))
122+ }
123+ resp := &ai.ModelResponse{Message: ai.NewModelMessage(ai.NewTextPart("answer"))}
124+ if got := AfterTurn(msgs, before, resp, msgs, nil); len(got) != 3 || got[2].Text() != "answer" {
125+ t.Errorf("lone answer not appended: %d messages", len(got))
126+ }
127+ if got := AfterTurn(msgs, before, nil, nil, errors.New("boom")); len(got) != 1 {
128+ t.Errorf("unanswered question kept: %d messages", len(got))
129+ }
130+ if got := AfterTurn(msgs, before, nil, nil, nil); len(got) != 2 {
131+ t.Errorf("inconclusive result changed the history: %d messages", len(got))
132+ }
133+}
@@ -1,6 +1,7 @@
1 package session1 package session
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "testing"6 "testing"
6 7
@@ -87,3 +88,46 @@ func ExampleFresh() {
87 fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs))88 fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs))
88 // Output: 1 system 089 // Output: 1 system 0
89 }90 }
91+
92+// A resumed history runs under the agent's current prompt: an old system
93+// message is replaced, a history without one gets one, and the conversation
94+// itself is untouched.
95+func TestWithSystemReplacesOrPrependsThePrompt(t *testing.T) {
96+ old := []*ai.Message{ai.NewSystemTextMessage("old prompt"), ai.NewUserTextMessage("hi"), ai.NewModelMessage(ai.NewTextPart("hello"))}
97+ got := WithSystem(old, "new prompt")
98+ if len(got) != 3 || got[0].Role != ai.RoleSystem || got[0].Text() != "new prompt" || got[1].Text() != "hi" || got[2].Text() != "hello" {
99+ t.Errorf("WithSystem(old) = %d messages, first %q", len(got), got[0].Text())
100+ }
101+ if old[0].Text() != "old prompt" {
102+ t.Error("WithSystem mutated its input")
103+ }
104+ bare := []*ai.Message{ai.NewUserTextMessage("hi")}
105+ if got := WithSystem(bare, "p"); len(got) != 2 || got[0].Role != ai.RoleSystem || got[1].Text() != "hi" {
106+ t.Errorf("WithSystem(bare) = %v", got)
107+ }
108+ if got := WithSystem(nil, "p"); len(got) != 1 || got[0].Text() != "p" {
109+ t.Errorf("WithSystem(nil) = %v", got)
110+ }
111+}
112+
113+// AfterTurn keeps the tool turns whatever happened, appends a lone answer,
114+// drops an unanswered question, and leaves an inconclusive result alone.
115+func TestAfterTurn(t *testing.T) {
116+ msgs := append(Fresh("sys"), ai.NewUserTextMessage("q"))
117+ before := len(msgs)
118+ full := append(append([]*ai.Message{}, msgs...), ai.NewModelMessage(ai.NewTextPart("call")), ai.NewMessage(ai.RoleTool, nil, ai.NewTextPart("out")))
119+
120+ if got := AfterTurn(msgs, before, nil, full, errors.New("cancelled")); len(got) != 4 {
121+ t.Errorf("tool turns dropped after a failure: %d messages", len(got))
122+ }
123+ resp := &ai.ModelResponse{Message: ai.NewModelMessage(ai.NewTextPart("answer"))}
124+ if got := AfterTurn(msgs, before, resp, msgs, nil); len(got) != 3 || got[2].Text() != "answer" {
125+ t.Errorf("lone answer not appended: %d messages", len(got))
126+ }
127+ if got := AfterTurn(msgs, before, nil, nil, errors.New("boom")); len(got) != 1 {
128+ t.Errorf("unanswered question kept: %d messages", len(got))
129+ }
130+ if got := AfterTurn(msgs, before, nil, nil, nil); len(got) != 2 {
131+ t.Errorf("inconclusive result changed the history: %d messages", len(got))
132+ }
133+}
modified internal/skills/skills.go +9 -0
@@ -87,6 +87,9 @@ func List(dir string) []Skill {
8787 }
8888 var out []Skill
8989 for _, p := range append(flat, nested...) {
90+ if isReadme(p) {
91+ continue // a README in the skills directory documents them, it is not one
92+ }
9093 content, err := os.ReadFile(p)
9194 if err != nil {
9295 continue
@@ -97,6 +100,12 @@ func List(dir string) []Skill {
97100 return out
98101 }
99102
103+// isReadme reports whether path is a README, whatever its case: the one
104+// flat markdown file that describes the directory instead of being a skill.
105+func isReadme(path string) bool {
106+ return strings.EqualFold(filepath.Base(path), "README.md")
107+}
108+
100109 // Read returns the full markdown of one skill, whichever layout it uses: the
101110 // flat `<name>.md` is tried first, then `<name>/SKILL.md`.
102111 func Read(dir, name string) (string, error) {
@@ -87,6 +87,9 @@ func List(dir string) []Skill {
87 }87 }
88 var out []Skill88 var out []Skill
89 for _, p := range append(flat, nested...) {89 for _, p := range append(flat, nested...) {
90+ if isReadme(p) {
91+ continue // a README in the skills directory documents them, it is not one
92+ }
90 content, err := os.ReadFile(p)93 content, err := os.ReadFile(p)
91 if err != nil {94 if err != nil {
92 continue95 continue
@@ -97,6 +100,12 @@ func List(dir string) []Skill {
97 return out100 return out
98 }101 }
99 102
103+// isReadme reports whether path is a README, whatever its case: the one
104+// flat markdown file that describes the directory instead of being a skill.
105+func isReadme(path string) bool {
106+ return strings.EqualFold(filepath.Base(path), "README.md")
107+}
108+
100 // Read returns the full markdown of one skill, whichever layout it uses: the109 // Read returns the full markdown of one skill, whichever layout it uses: the
101 // flat `<name>.md` is tried first, then `<name>/SKILL.md`.110 // flat `<name>.md` is tried first, then `<name>/SKILL.md`.
102 func Read(dir, name string) (string, error) {111 func Read(dir, name string) (string, error) {
modified internal/skills/skills_test.go +17 -0
@@ -240,3 +240,20 @@ func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {
240240 t.Error("Read(../escape) escaped the directory")
241241 }
242242 }
243+
244+// A README.md at the root of the skills directory is documentation, not a
245+// skill: it must not show up in the catalogue (nor as a nameless entry the
246+// shipped-skills test would flag).
247+func TestListSkipsReadme(t *testing.T) {
248+ dir := t.TempDir()
249+ write(t, dir, "README.md", "# The skills\n")
250+ write(t, dir, "readme.md", "# lower-case too\n")
251+ write(t, dir, "real.md", "---\nname: real\ndescription: a skill\n---\n")
252+ list := List(dir)
253+ if len(list) != 1 || list[0].Name != "real" {
254+ t.Errorf("List = %+v, want only the real skill", list)
255+ }
256+ if _, err := Read(dir, "README"); err == nil {
257+ t.Log("Read(README) still opens the file: acceptable, only the catalogue hides it")
258+ }
259+}
@@ -240,3 +240,20 @@ func TestReadAcceptsFlatAndNestedLayouts(t *testing.T) {
240 t.Error("Read(../escape) escaped the directory")240 t.Error("Read(../escape) escaped the directory")
241 }241 }
242 }242 }
243+
244+// A README.md at the root of the skills directory is documentation, not a
245+// skill: it must not show up in the catalogue (nor as a nameless entry the
246+// shipped-skills test would flag).
247+func TestListSkipsReadme(t *testing.T) {
248+ dir := t.TempDir()
249+ write(t, dir, "README.md", "# The skills\n")
250+ write(t, dir, "readme.md", "# lower-case too\n")
251+ write(t, dir, "real.md", "---\nname: real\ndescription: a skill\n---\n")
252+ list := List(dir)
253+ if len(list) != 1 || list[0].Name != "real" {
254+ t.Errorf("List = %+v, want only the real skill", list)
255+ }
256+ if _, err := Read(dir, "README"); err == nil {
257+ t.Log("Read(README) still opens the file: acceptable, only the catalogue hides it")
258+ }
259+}
added internal/store/keeper.go +189 -0
new file mode 100644
@@ -0,0 +1,189 @@
1+package store
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+
9+ "mm/internal/config"
10+ "mm/internal/session"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// ErrDisabled is what a nil Keeper answers to the commands that need one:
16+// persistence is off in the YAML.
17+var ErrDisabled = errors.New("sessions are off (set sessions.enabled: true in the YAML)")
18+
19+// Keeper is a front end's side of persistent sessions (config `sessions`):
20+// it owns the current session's file and rewrites it after every turn and
21+// every command that changes the history. A nil Keeper — persistence off —
22+// accepts every call and does nothing, so a front end never tests the
23+// setting itself. Shared by the terminal REPL and the window mode; the ACP
24+// façade has its own, keyed on the editor's session ids.
25+//
26+// Restart behaviour, decided 2026-09-16 after reading docker agent's code:
27+// a new run starts a NEW session and says how to get the old ones back
28+// (/sessions, /resume). docker agent does the same — `cagent run` never
29+// resumes on its own — and it is the safer default: the last conversation
30+// may be about something else, and resuming it silently would hand the
31+// model a stale context.
32+//
33+// keep := store.NewKeeper(cwd) // nil when sessions are off
34+// if err := keep.Save(messages); err != nil { … }
35+// messages, line, err := keep.Resume("", system) // the latest other session
36+type Keeper struct {
37+ st *Store
38+ rec *Session
39+ cwd string
40+}
41+
42+// NewKeeper opens the project's store when sessions are on, nil otherwise.
43+// The directory is `sessions.dir` under cwd — the directory the front end
44+// runs in — or as-is when absolute.
45+func NewKeeper(cwd string) *Keeper {
46+ if !config.Cfg.Sessions.Enabled {
47+ return nil
48+ }
49+ dir := config.Cfg.Sessions.Dir
50+ if !filepath.IsAbs(dir) {
51+ dir = filepath.Join(cwd, dir)
52+ }
53+ return &Keeper{st: Open(dir), cwd: cwd}
54+}
55+
56+// Save rewrites the current session's file with msgs — and creates it on the
57+// first save: a run that never asks anything leaves no file behind, so
58+// /sessions lists conversations, not launches. A nil Keeper saves nothing
59+// and returns nil.
60+func (k *Keeper) Save(msgs []*ai.Message) error {
61+ if k == nil {
62+ return nil
63+ }
64+ if k.rec == nil {
65+ rec, err := k.st.New(k.cwd, msgs)
66+ k.rec = rec
67+ return err
68+ }
69+ k.rec.Messages = msgs
70+ return k.st.Save(k.rec)
71+}
72+
73+// Renew is /new: the old file stays as it is; the next Save opens a new one.
74+func (k *Keeper) Renew() {
75+ if k != nil {
76+ k.rec = nil
77+ }
78+}
79+
80+// CurrentID is the id of the session being saved, "" before the first save
81+// and on a nil Keeper.
82+func (k *Keeper) CurrentID() string {
83+ if k == nil || k.rec == nil {
84+ return ""
85+ }
86+ return k.rec.ID
87+}
88+
89+// Dir is where the files go, "" on a nil Keeper.
90+func (k *Keeper) Dir() string {
91+ if k == nil {
92+ return ""
93+ }
94+ return k.st.Dir()
95+}
96+
97+// Resume loads a stored session — the latest other one when id is "" — and
98+// makes it the current one, under the agent's current system prompt. The
99+// one-line report says what came back so a wrong /resume is visible at once.
100+func (k *Keeper) Resume(id, system string) ([]*ai.Message, string, error) {
101+ rec, err := k.locate(id)
102+ if err != nil {
103+ return nil, "", err
104+ }
105+ rec.Messages = session.WithSystem(rec.Messages, system)
106+ k.rec = rec
107+ if err := k.Save(rec.Messages); err != nil {
108+ return nil, "", err
109+ }
110+ return rec.Messages, fmt.Sprintf("↩️ resumed %s — %s", rec.ID, describe(rec)), nil
111+}
112+
113+// locate finds the session a /resume names — the latest other one for "" —
114+// and refuses the current one, since resuming it would change nothing.
115+func (k *Keeper) locate(id string) (*Session, error) {
116+ if k == nil {
117+ return nil, ErrDisabled
118+ }
119+ if id == "" {
120+ if id = k.latestOther(); id == "" {
121+ return nil, errors.New("no earlier session to resume in " + k.st.Dir())
122+ }
123+ }
124+ rec, err := k.st.Load(id)
125+ if err == nil && k.rec != nil && rec.ID == k.rec.ID {
126+ return nil, fmt.Errorf("session %s is the current one", rec.ID)
127+ }
128+ return rec, err
129+}
130+
131+// latestOther is the most recently updated stored session that is not the
132+// current one and holds a conversation — what a bare /resume means — or ""
133+// when there is none. An empty session (an editor thread nobody wrote in)
134+// is not worth resuming over a real one.
135+func (k *Keeper) latestOther() string {
136+ for _, s := range k.st.List() {
137+ if (k.rec == nil || s.ID != k.rec.ID) && s.Turns > 0 {
138+ return s.ID
139+ }
140+ }
141+ return ""
142+}
143+
144+// Listing renders /sessions: newest first, the current one marked.
145+func (k *Keeper) Listing() string {
146+ if k == nil {
147+ return "⚠️ " + ErrDisabled.Error()
148+ }
149+ list := k.st.List()
150+ if len(list) == 0 {
151+ return "no stored session in " + k.st.Dir()
152+ }
153+ var b strings.Builder
154+ fmt.Fprintf(&b, "%d session(s) in %s — /resume <id> to continue one, /resume for the latest\n", len(list), k.st.Dir())
155+ for _, s := range list {
156+ mark := " "
157+ if k.rec != nil && s.ID == k.rec.ID {
158+ mark = "*"
159+ }
160+ fmt.Fprintf(&b, "%s %s %s %d turn(s) %s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion)
161+ }
162+ return strings.TrimRight(b.String(), "\n")
163+}
164+
165+// Hint is the start-up line: where sessions go, and how to get an earlier
166+// one back when there is one. "" on a nil Keeper.
167+func (k *Keeper) Hint() string {
168+ if k == nil {
169+ return ""
170+ }
171+ earlier := len(k.st.List())
172+ line := "sessions: saved under " + k.st.Dir()
173+ if earlier > 0 {
174+ line += fmt.Sprintf(" · %d earlier — /resume to continue the latest, /sessions to list", earlier)
175+ }
176+ return line
177+}
178+
179+func describe(rec *Session) string {
180+ turns := session.Forgotten(rec.Messages)
181+ first := ""
182+ for _, m := range rec.Messages {
183+ if m != nil && m.Role == ai.RoleUser {
184+ first = strings.SplitN(strings.TrimSpace(m.Text()), "\n", 2)[0]
185+ break
186+ }
187+ }
188+ return fmt.Sprintf("%d message(s), started with %q", turns, first)
189+}
new file mode 100644
@@ -0,0 +1,189 @@
1+package store
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "path/filepath"
7+ "strings"
8+
9+ "mm/internal/config"
10+ "mm/internal/session"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+// ErrDisabled is what a nil Keeper answers to the commands that need one:
16+// persistence is off in the YAML.
17+var ErrDisabled = errors.New("sessions are off (set sessions.enabled: true in the YAML)")
18+
19+// Keeper is a front end's side of persistent sessions (config `sessions`):
20+// it owns the current session's file and rewrites it after every turn and
21+// every command that changes the history. A nil Keeper — persistence off —
22+// accepts every call and does nothing, so a front end never tests the
23+// setting itself. Shared by the terminal REPL and the window mode; the ACP
24+// façade has its own, keyed on the editor's session ids.
25+//
26+// Restart behaviour, decided 2026-09-16 after reading docker agent's code:
27+// a new run starts a NEW session and says how to get the old ones back
28+// (/sessions, /resume). docker agent does the same — `cagent run` never
29+// resumes on its own — and it is the safer default: the last conversation
30+// may be about something else, and resuming it silently would hand the
31+// model a stale context.
32+//
33+// keep := store.NewKeeper(cwd) // nil when sessions are off
34+// if err := keep.Save(messages); err != nil { … }
35+// messages, line, err := keep.Resume("", system) // the latest other session
36+type Keeper struct {
37+ st *Store
38+ rec *Session
39+ cwd string
40+}
41+
42+// NewKeeper opens the project's store when sessions are on, nil otherwise.
43+// The directory is `sessions.dir` under cwd — the directory the front end
44+// runs in — or as-is when absolute.
45+func NewKeeper(cwd string) *Keeper {
46+ if !config.Cfg.Sessions.Enabled {
47+ return nil
48+ }
49+ dir := config.Cfg.Sessions.Dir
50+ if !filepath.IsAbs(dir) {
51+ dir = filepath.Join(cwd, dir)
52+ }
53+ return &Keeper{st: Open(dir), cwd: cwd}
54+}
55+
56+// Save rewrites the current session's file with msgs — and creates it on the
57+// first save: a run that never asks anything leaves no file behind, so
58+// /sessions lists conversations, not launches. A nil Keeper saves nothing
59+// and returns nil.
60+func (k *Keeper) Save(msgs []*ai.Message) error {
61+ if k == nil {
62+ return nil
63+ }
64+ if k.rec == nil {
65+ rec, err := k.st.New(k.cwd, msgs)
66+ k.rec = rec
67+ return err
68+ }
69+ k.rec.Messages = msgs
70+ return k.st.Save(k.rec)
71+}
72+
73+// Renew is /new: the old file stays as it is; the next Save opens a new one.
74+func (k *Keeper) Renew() {
75+ if k != nil {
76+ k.rec = nil
77+ }
78+}
79+
80+// CurrentID is the id of the session being saved, "" before the first save
81+// and on a nil Keeper.
82+func (k *Keeper) CurrentID() string {
83+ if k == nil || k.rec == nil {
84+ return ""
85+ }
86+ return k.rec.ID
87+}
88+
89+// Dir is where the files go, "" on a nil Keeper.
90+func (k *Keeper) Dir() string {
91+ if k == nil {
92+ return ""
93+ }
94+ return k.st.Dir()
95+}
96+
97+// Resume loads a stored session — the latest other one when id is "" — and
98+// makes it the current one, under the agent's current system prompt. The
99+// one-line report says what came back so a wrong /resume is visible at once.
100+func (k *Keeper) Resume(id, system string) ([]*ai.Message, string, error) {
101+ rec, err := k.locate(id)
102+ if err != nil {
103+ return nil, "", err
104+ }
105+ rec.Messages = session.WithSystem(rec.Messages, system)
106+ k.rec = rec
107+ if err := k.Save(rec.Messages); err != nil {
108+ return nil, "", err
109+ }
110+ return rec.Messages, fmt.Sprintf("↩️ resumed %s — %s", rec.ID, describe(rec)), nil
111+}
112+
113+// locate finds the session a /resume names — the latest other one for "" —
114+// and refuses the current one, since resuming it would change nothing.
115+func (k *Keeper) locate(id string) (*Session, error) {
116+ if k == nil {
117+ return nil, ErrDisabled
118+ }
119+ if id == "" {
120+ if id = k.latestOther(); id == "" {
121+ return nil, errors.New("no earlier session to resume in " + k.st.Dir())
122+ }
123+ }
124+ rec, err := k.st.Load(id)
125+ if err == nil && k.rec != nil && rec.ID == k.rec.ID {
126+ return nil, fmt.Errorf("session %s is the current one", rec.ID)
127+ }
128+ return rec, err
129+}
130+
131+// latestOther is the most recently updated stored session that is not the
132+// current one and holds a conversation — what a bare /resume means — or ""
133+// when there is none. An empty session (an editor thread nobody wrote in)
134+// is not worth resuming over a real one.
135+func (k *Keeper) latestOther() string {
136+ for _, s := range k.st.List() {
137+ if (k.rec == nil || s.ID != k.rec.ID) && s.Turns > 0 {
138+ return s.ID
139+ }
140+ }
141+ return ""
142+}
143+
144+// Listing renders /sessions: newest first, the current one marked.
145+func (k *Keeper) Listing() string {
146+ if k == nil {
147+ return "⚠️ " + ErrDisabled.Error()
148+ }
149+ list := k.st.List()
150+ if len(list) == 0 {
151+ return "no stored session in " + k.st.Dir()
152+ }
153+ var b strings.Builder
154+ fmt.Fprintf(&b, "%d session(s) in %s — /resume <id> to continue one, /resume for the latest\n", len(list), k.st.Dir())
155+ for _, s := range list {
156+ mark := " "
157+ if k.rec != nil && s.ID == k.rec.ID {
158+ mark = "*"
159+ }
160+ fmt.Fprintf(&b, "%s %s %s %d turn(s) %s\n", mark, s.ID, s.UpdatedAt.Local().Format("2006-01-02 15:04"), s.Turns, s.FirstQuestion)
161+ }
162+ return strings.TrimRight(b.String(), "\n")
163+}
164+
165+// Hint is the start-up line: where sessions go, and how to get an earlier
166+// one back when there is one. "" on a nil Keeper.
167+func (k *Keeper) Hint() string {
168+ if k == nil {
169+ return ""
170+ }
171+ earlier := len(k.st.List())
172+ line := "sessions: saved under " + k.st.Dir()
173+ if earlier > 0 {
174+ line += fmt.Sprintf(" · %d earlier — /resume to continue the latest, /sessions to list", earlier)
175+ }
176+ return line
177+}
178+
179+func describe(rec *Session) string {
180+ turns := session.Forgotten(rec.Messages)
181+ first := ""
182+ for _, m := range rec.Messages {
183+ if m != nil && m.Role == ai.RoleUser {
184+ first = strings.SplitN(strings.TrimSpace(m.Text()), "\n", 2)[0]
185+ break
186+ }
187+ }
188+ return fmt.Sprintf("%d message(s), started with %q", turns, first)
189+}
added internal/store/keeper_test.go +140 -0
new file mode 100644
@@ -0,0 +1,140 @@
1+package store
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+ "mm/internal/session"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+func withSessions(t *testing.T, enabled bool) {
16+ t.Helper()
17+ saved := config.Cfg.Sessions
18+ config.Cfg.Sessions = config.SessionsConfig{Enabled: enabled, Dir: ".mm/sessions"}
19+ t.Cleanup(func() { config.Cfg.Sessions = saved })
20+}
21+
22+func talk(msgs []*ai.Message, q string) []*ai.Message {
23+ return append(msgs, ai.NewUserTextMessage(q), ai.NewModelMessage(ai.NewTextPart("answer to "+q)))
24+}
25+
26+// Persistence off: no Keeper, and every call is a harmless no-op except the
27+// two commands, which say why they cannot work.
28+func TestNilKeeperIsInert(t *testing.T) {
29+ withSessions(t, false)
30+ k := NewKeeper(t.TempDir())
31+ if k != nil {
32+ t.Fatal("keeper built while sessions are off")
33+ }
34+ if err := k.Save(nil); err != nil {
35+ t.Errorf("Save on nil keeper: %v", err)
36+ }
37+ k.Renew()
38+ if _, _, err := k.Resume("", "sys"); err == nil || !strings.Contains(err.Error(), "sessions are off") {
39+ t.Errorf("resume on nil keeper: %v", err)
40+ }
41+ if got := k.Listing(); !strings.Contains(got, "sessions are off") {
42+ t.Errorf("listing on nil keeper: %q", got)
43+ }
44+ if k.Hint() != "" {
45+ t.Errorf("hint on nil keeper: %q", k.Hint())
46+ }
47+}
48+
49+// A run writes its file on the first save, not before, and rewrites it on
50+// each save; /new leaves the old file alone and the next save opens a new
51+// one; the hint counts the stored sessions.
52+func TestKeeperSaveRenew(t *testing.T) {
53+ withSessions(t, true)
54+ cwd := t.TempDir()
55+ k := NewKeeper(cwd)
56+ dir := filepath.Join(cwd, ".mm", "sessions")
57+ if k.rec != nil || k.st.Dir() != dir {
58+ t.Fatalf("keeper %+v, want no record yet, store under %s", k, dir)
59+ }
60+ if hint := k.Hint(); hint != "sessions: saved under "+dir {
61+ t.Errorf("hint with no stored session: %q", hint)
62+ }
63+ if _, err := os.Stat(dir); err == nil {
64+ t.Error("the sessions directory was created before anything was said")
65+ }
66+ msgs := talk(session.Fresh("sys"), "one")
67+ _ = k.Save(msgs)
68+ if k.rec == nil {
69+ t.Fatal("first save did not create the record")
70+ }
71+ first := k.rec.ID
72+
73+ k.Renew()
74+ if k.rec != nil {
75+ t.Fatal("renew kept a record")
76+ }
77+ _ = k.Save(talk(session.Fresh("sys"), "two"))
78+ if k.rec.ID == first {
79+ t.Fatal("save after renew reused the old session id")
80+ }
81+ entries, _ := os.ReadDir(dir)
82+ if len(entries) != 2 {
83+ t.Fatalf("%d files after /new, want 2", len(entries))
84+ }
85+ old, err := k.st.Load(first)
86+ if err != nil || len(old.Messages) != 3 {
87+ t.Errorf("old session %v, %v; want 3 messages kept", old, err)
88+ }
89+ if hint := k.Hint(); !strings.Contains(hint, "2 earlier") || !strings.Contains(hint, "/resume") {
90+ t.Errorf("hint after /new: %q", hint)
91+ }
92+}
93+
94+// /resume brings the latest OTHER (or a named) session back under the
95+// current prompt and makes it the one being saved; resuming the current
96+// session, an unknown id, or nothing when there is nothing, is refused with
97+// a reason.
98+func TestKeeperResumeAndListing(t *testing.T) {
99+ withSessions(t, true)
100+ cwd := t.TempDir()
101+ k := NewKeeper(cwd)
102+ if _, _, err := k.Resume("", "sys"); err == nil || !strings.Contains(err.Error(), "no earlier session") {
103+ t.Errorf("resume with nothing stored: %v", err)
104+ }
105+ _ = k.Save(talk(session.Fresh("old prompt"), "first question"))
106+ first := k.rec.ID
107+
108+ k.Renew()
109+ _ = k.Save(talk(session.Fresh("old prompt"), "second question"))
110+ current := k.rec.ID
111+ if _, _, err := k.Resume(current, "sys"); err == nil || !strings.Contains(err.Error(), "current") {
112+ t.Errorf("resuming the current session: %v", err)
113+ }
114+ if _, _, err := k.Resume("nope", "sys"); err == nil {
115+ t.Error("resuming an unknown id succeeded")
116+ }
117+
118+ // A bare /resume skips the current session and empty ones, and takes the
119+ // latest other conversation.
120+ if err := k.st.Save(&Session{ID: "99999999-999999-empty000", Cwd: cwd, Messages: session.Fresh("p")}); err != nil {
121+ t.Fatal(err)
122+ }
123+ msgs, line, err := k.Resume("", "new prompt")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if len(msgs) != 3 || msgs[0].Text() != "new prompt" || msgs[1].Text() != "first question" {
128+ t.Errorf("resumed messages: %d, prompt %q", len(msgs), msgs[0].Text())
129+ }
130+ if !strings.Contains(line, "↩️ resumed "+first) || !strings.Contains(line, `"first question"`) {
131+ t.Errorf("resume report: %q", line)
132+ }
133+ if k.rec.ID != first {
134+ t.Errorf("keeper now saves to %s, want %s", k.rec.ID, first)
135+ }
136+ got := k.Listing()
137+ if !strings.Contains(got, "3 session(s)") || !strings.Contains(got, "* "+first) || !strings.Contains(got, " "+current) {
138+ t.Errorf("listing:\n%s", got)
139+ }
140+}
new file mode 100644
@@ -0,0 +1,140 @@
1+package store
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/config"
10+ "mm/internal/session"
11+
12+ "github.com/firebase/genkit/go/ai"
13+)
14+
15+func withSessions(t *testing.T, enabled bool) {
16+ t.Helper()
17+ saved := config.Cfg.Sessions
18+ config.Cfg.Sessions = config.SessionsConfig{Enabled: enabled, Dir: ".mm/sessions"}
19+ t.Cleanup(func() { config.Cfg.Sessions = saved })
20+}
21+
22+func talk(msgs []*ai.Message, q string) []*ai.Message {
23+ return append(msgs, ai.NewUserTextMessage(q), ai.NewModelMessage(ai.NewTextPart("answer to "+q)))
24+}
25+
26+// Persistence off: no Keeper, and every call is a harmless no-op except the
27+// two commands, which say why they cannot work.
28+func TestNilKeeperIsInert(t *testing.T) {
29+ withSessions(t, false)
30+ k := NewKeeper(t.TempDir())
31+ if k != nil {
32+ t.Fatal("keeper built while sessions are off")
33+ }
34+ if err := k.Save(nil); err != nil {
35+ t.Errorf("Save on nil keeper: %v", err)
36+ }
37+ k.Renew()
38+ if _, _, err := k.Resume("", "sys"); err == nil || !strings.Contains(err.Error(), "sessions are off") {
39+ t.Errorf("resume on nil keeper: %v", err)
40+ }
41+ if got := k.Listing(); !strings.Contains(got, "sessions are off") {
42+ t.Errorf("listing on nil keeper: %q", got)
43+ }
44+ if k.Hint() != "" {
45+ t.Errorf("hint on nil keeper: %q", k.Hint())
46+ }
47+}
48+
49+// A run writes its file on the first save, not before, and rewrites it on
50+// each save; /new leaves the old file alone and the next save opens a new
51+// one; the hint counts the stored sessions.
52+func TestKeeperSaveRenew(t *testing.T) {
53+ withSessions(t, true)
54+ cwd := t.TempDir()
55+ k := NewKeeper(cwd)
56+ dir := filepath.Join(cwd, ".mm", "sessions")
57+ if k.rec != nil || k.st.Dir() != dir {
58+ t.Fatalf("keeper %+v, want no record yet, store under %s", k, dir)
59+ }
60+ if hint := k.Hint(); hint != "sessions: saved under "+dir {
61+ t.Errorf("hint with no stored session: %q", hint)
62+ }
63+ if _, err := os.Stat(dir); err == nil {
64+ t.Error("the sessions directory was created before anything was said")
65+ }
66+ msgs := talk(session.Fresh("sys"), "one")
67+ _ = k.Save(msgs)
68+ if k.rec == nil {
69+ t.Fatal("first save did not create the record")
70+ }
71+ first := k.rec.ID
72+
73+ k.Renew()
74+ if k.rec != nil {
75+ t.Fatal("renew kept a record")
76+ }
77+ _ = k.Save(talk(session.Fresh("sys"), "two"))
78+ if k.rec.ID == first {
79+ t.Fatal("save after renew reused the old session id")
80+ }
81+ entries, _ := os.ReadDir(dir)
82+ if len(entries) != 2 {
83+ t.Fatalf("%d files after /new, want 2", len(entries))
84+ }
85+ old, err := k.st.Load(first)
86+ if err != nil || len(old.Messages) != 3 {
87+ t.Errorf("old session %v, %v; want 3 messages kept", old, err)
88+ }
89+ if hint := k.Hint(); !strings.Contains(hint, "2 earlier") || !strings.Contains(hint, "/resume") {
90+ t.Errorf("hint after /new: %q", hint)
91+ }
92+}
93+
94+// /resume brings the latest OTHER (or a named) session back under the
95+// current prompt and makes it the one being saved; resuming the current
96+// session, an unknown id, or nothing when there is nothing, is refused with
97+// a reason.
98+func TestKeeperResumeAndListing(t *testing.T) {
99+ withSessions(t, true)
100+ cwd := t.TempDir()
101+ k := NewKeeper(cwd)
102+ if _, _, err := k.Resume("", "sys"); err == nil || !strings.Contains(err.Error(), "no earlier session") {
103+ t.Errorf("resume with nothing stored: %v", err)
104+ }
105+ _ = k.Save(talk(session.Fresh("old prompt"), "first question"))
106+ first := k.rec.ID
107+
108+ k.Renew()
109+ _ = k.Save(talk(session.Fresh("old prompt"), "second question"))
110+ current := k.rec.ID
111+ if _, _, err := k.Resume(current, "sys"); err == nil || !strings.Contains(err.Error(), "current") {
112+ t.Errorf("resuming the current session: %v", err)
113+ }
114+ if _, _, err := k.Resume("nope", "sys"); err == nil {
115+ t.Error("resuming an unknown id succeeded")
116+ }
117+
118+ // A bare /resume skips the current session and empty ones, and takes the
119+ // latest other conversation.
120+ if err := k.st.Save(&Session{ID: "99999999-999999-empty000", Cwd: cwd, Messages: session.Fresh("p")}); err != nil {
121+ t.Fatal(err)
122+ }
123+ msgs, line, err := k.Resume("", "new prompt")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ if len(msgs) != 3 || msgs[0].Text() != "new prompt" || msgs[1].Text() != "first question" {
128+ t.Errorf("resumed messages: %d, prompt %q", len(msgs), msgs[0].Text())
129+ }
130+ if !strings.Contains(line, "↩️ resumed "+first) || !strings.Contains(line, `"first question"`) {
131+ t.Errorf("resume report: %q", line)
132+ }
133+ if k.rec.ID != first {
134+ t.Errorf("keeper now saves to %s, want %s", k.rec.ID, first)
135+ }
136+ got := k.Listing()
137+ if !strings.Contains(got, "3 session(s)") || !strings.Contains(got, "* "+first) || !strings.Contains(got, " "+current) {
138+ t.Errorf("listing:\n%s", got)
139+ }
140+}
added internal/store/store.go +219 -0
new file mode 100644
@@ -0,0 +1,219 @@
1+// Package store persists sessions per project: one JSON file per session
2+// under <project>/.mm/sessions/ (the directory is a setting). A file holds
3+// the whole history as it stands — after a compression the file shrinks with
4+// it — so a session can be picked up where it stopped, by the terminal
5+// (/resume) or by an editor (ACP session/load, which replays it).
6+//
7+// The store is deliberately plain: files, not a database. A project's
8+// sessions sit next to its code, travel with a backup of the project, and
9+// can be read with `jq` when something looks wrong.
10+//
11+// st := store.Open(filepath.Join(project, ".mm", "sessions"))
12+// sess := st.New(cwd, messages) // a fresh session, saved at once
13+// sess.Messages = history // …after each turn:
14+// _ = st.Save(sess)
15+// last, _ := st.Latest() // the most recently updated session
16+// one, _ := st.Load("20260916-101530-4f2a")
17+// for _, s := range st.List() { fmt.Println(s.ID, s.FirstQuestion) }
18+package store
19+
20+import (
21+ "crypto/rand"
22+ "encoding/hex"
23+ "encoding/json"
24+ "errors"
25+ "fmt"
26+ "os"
27+ "path/filepath"
28+ "sort"
29+ "strings"
30+ "time"
31+
32+ "github.com/firebase/genkit/go/ai"
33+)
34+
35+// Session is what one file holds. Messages is the full history, system
36+// prompt first, exactly as the front end keeps it in memory.
37+type Session struct {
38+ ID string `json:"id"`
39+ Cwd string `json:"cwd"`
40+ CreatedAt time.Time `json:"createdAt"`
41+ UpdatedAt time.Time `json:"updatedAt"`
42+ Messages []*ai.Message `json:"messages"`
43+}
44+
45+// Summary is what a listing shows for one session, without loading it all.
46+type Summary struct {
47+ ID string
48+ UpdatedAt time.Time
49+ Turns int // user questions in the history
50+ FirstQuestion string // the first user message, one line, trimmed
51+}
52+
53+// ErrNotFound is returned by Load when no file holds that id.
54+var ErrNotFound = errors.New("session not found")
55+
56+// Store is one sessions directory. The zero value is unusable: use Open.
57+type Store struct {
58+ dir string
59+}
60+
61+// Open returns a store on dir. The directory is created on the first Save,
62+// not here: opening a store on a project must not litter it.
63+func Open(dir string) *Store {
64+ return &Store{dir: dir}
65+}
66+
67+// Dir is the sessions directory, as given to Open.
68+func (s *Store) Dir() string { return s.dir }
69+
70+// NewID returns a fresh session id: the UTC time to the second, then four
71+// random bytes, so ids sort by creation time in a listing and stay unique
72+// across restarts and across the two front ends.
73+//
74+// store.NewID() // "20260916-101530-4f2a9c1e"
75+func NewID() string {
76+ var b [4]byte
77+ _, _ = rand.Read(b[:])
78+ return time.Now().UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b[:])
79+}
80+
81+// New creates a session on the given history and saves it. The id is
82+// NewID(); the caller keeps the returned session and saves it again after
83+// each turn.
84+func (s *Store) New(cwd string, messages []*ai.Message) (*Session, error) {
85+ now := time.Now().UTC()
86+ sess := &Session{ID: NewID(), Cwd: cwd, CreatedAt: now, UpdatedAt: now, Messages: messages}
87+ return sess, s.Save(sess)
88+}
89+
90+// Save writes the session's file, whole, atomically (temp file + rename): a
91+// crash mid-write leaves the previous version, never a truncated one.
92+// UpdatedAt is set to now.
93+func (s *Store) Save(sess *Session) error {
94+ if err := validID(sess.ID); err != nil {
95+ return err
96+ }
97+ if err := os.MkdirAll(s.dir, 0o755); err != nil {
98+ return fmt.Errorf("sessions dir: %w", err)
99+ }
100+ sess.UpdatedAt = time.Now().UTC()
101+ data, err := json.MarshalIndent(sess, "", " ")
102+ if err != nil {
103+ return fmt.Errorf("encode session %s: %w", sess.ID, err)
104+ }
105+ if err := writeAtomic(s.path(sess.ID), data); err != nil {
106+ return fmt.Errorf("write session %s: %w", sess.ID, err)
107+ }
108+ return nil
109+}
110+
111+// writeAtomic writes data to path through a temp file and a rename, so a
112+// reader never sees a half-written file.
113+func writeAtomic(path string, data []byte) error {
114+ tmp := path + ".tmp"
115+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
116+ return err
117+ }
118+ if err := os.Rename(tmp, path); err != nil {
119+ _ = os.Remove(tmp)
120+ return err
121+ }
122+ return nil
123+}
124+
125+// Load reads one session by id. ErrNotFound when there is no such file; any
126+// other error means the file exists but cannot be read or decoded.
127+func (s *Store) Load(id string) (*Session, error) {
128+ if err := validID(id); err != nil {
129+ return nil, err
130+ }
131+ data, err := os.ReadFile(s.path(id))
132+ if errors.Is(err, os.ErrNotExist) {
133+ return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
134+ }
135+ if err != nil {
136+ return nil, err
137+ }
138+ var sess Session
139+ if err := json.Unmarshal(data, &sess); err != nil {
140+ return nil, fmt.Errorf("decode session %s: %w", id, err)
141+ }
142+ return &sess, nil
143+}
144+
145+// List returns the stored sessions, most recently updated first. A missing
146+// directory is an empty list; a file that cannot be decoded is skipped — one
147+// corrupt session must not hide the others.
148+func (s *Store) List() []Summary {
149+ entries, err := os.ReadDir(s.dir)
150+ if err != nil {
151+ return nil
152+ }
153+ var out []Summary
154+ for _, e := range entries {
155+ name := e.Name()
156+ if e.IsDir() || !strings.HasSuffix(name, ".json") {
157+ continue
158+ }
159+ sess, err := s.Load(strings.TrimSuffix(name, ".json"))
160+ if err != nil {
161+ continue
162+ }
163+ out = append(out, summarize(sess))
164+ }
165+ sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
166+ return out
167+}
168+
169+// Latest loads the most recently updated session. ErrNotFound when the
170+// project has none.
171+func (s *Store) Latest() (*Session, error) {
172+ list := s.List()
173+ if len(list) == 0 {
174+ return nil, ErrNotFound
175+ }
176+ return s.Load(list[0].ID)
177+}
178+
179+func (s *Store) path(id string) string {
180+ return filepath.Join(s.dir, id+".json")
181+}
182+
183+// validID keeps ids to plain file names: no separators, no "..", nothing
184+// that could make Load read outside the directory.
185+func validID(id string) error {
186+ if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
187+ return fmt.Errorf("invalid session id %q", id)
188+ }
189+ return nil
190+}
191+
192+func summarize(sess *Session) Summary {
193+ sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt}
194+ for _, m := range sess.Messages {
195+ if m == nil || m.Role != ai.RoleUser {
196+ continue
197+ }
198+ sum.Turns++
199+ if sum.FirstQuestion == "" {
200+ sum.FirstQuestion = firstLine(m.Text())
201+ }
202+ }
203+ return sum
204+}
205+
206+// firstLine is the first non-empty line of text, trimmed, at most 60 runes.
207+func firstLine(text string) string {
208+ for _, line := range strings.Split(text, "\n") {
209+ line = strings.TrimSpace(line)
210+ if line == "" {
211+ continue
212+ }
213+ if r := []rune(line); len(r) > 60 {
214+ return string(r[:57]) + "..."
215+ }
216+ return line
217+ }
218+ return ""
219+}
new file mode 100644
@@ -0,0 +1,219 @@
1+// Package store persists sessions per project: one JSON file per session
2+// under <project>/.mm/sessions/ (the directory is a setting). A file holds
3+// the whole history as it stands — after a compression the file shrinks with
4+// it — so a session can be picked up where it stopped, by the terminal
5+// (/resume) or by an editor (ACP session/load, which replays it).
6+//
7+// The store is deliberately plain: files, not a database. A project's
8+// sessions sit next to its code, travel with a backup of the project, and
9+// can be read with `jq` when something looks wrong.
10+//
11+// st := store.Open(filepath.Join(project, ".mm", "sessions"))
12+// sess := st.New(cwd, messages) // a fresh session, saved at once
13+// sess.Messages = history // …after each turn:
14+// _ = st.Save(sess)
15+// last, _ := st.Latest() // the most recently updated session
16+// one, _ := st.Load("20260916-101530-4f2a")
17+// for _, s := range st.List() { fmt.Println(s.ID, s.FirstQuestion) }
18+package store
19+
20+import (
21+ "crypto/rand"
22+ "encoding/hex"
23+ "encoding/json"
24+ "errors"
25+ "fmt"
26+ "os"
27+ "path/filepath"
28+ "sort"
29+ "strings"
30+ "time"
31+
32+ "github.com/firebase/genkit/go/ai"
33+)
34+
35+// Session is what one file holds. Messages is the full history, system
36+// prompt first, exactly as the front end keeps it in memory.
37+type Session struct {
38+ ID string `json:"id"`
39+ Cwd string `json:"cwd"`
40+ CreatedAt time.Time `json:"createdAt"`
41+ UpdatedAt time.Time `json:"updatedAt"`
42+ Messages []*ai.Message `json:"messages"`
43+}
44+
45+// Summary is what a listing shows for one session, without loading it all.
46+type Summary struct {
47+ ID string
48+ UpdatedAt time.Time
49+ Turns int // user questions in the history
50+ FirstQuestion string // the first user message, one line, trimmed
51+}
52+
53+// ErrNotFound is returned by Load when no file holds that id.
54+var ErrNotFound = errors.New("session not found")
55+
56+// Store is one sessions directory. The zero value is unusable: use Open.
57+type Store struct {
58+ dir string
59+}
60+
61+// Open returns a store on dir. The directory is created on the first Save,
62+// not here: opening a store on a project must not litter it.
63+func Open(dir string) *Store {
64+ return &Store{dir: dir}
65+}
66+
67+// Dir is the sessions directory, as given to Open.
68+func (s *Store) Dir() string { return s.dir }
69+
70+// NewID returns a fresh session id: the UTC time to the second, then four
71+// random bytes, so ids sort by creation time in a listing and stay unique
72+// across restarts and across the two front ends.
73+//
74+// store.NewID() // "20260916-101530-4f2a9c1e"
75+func NewID() string {
76+ var b [4]byte
77+ _, _ = rand.Read(b[:])
78+ return time.Now().UTC().Format("20060102-150405") + "-" + hex.EncodeToString(b[:])
79+}
80+
81+// New creates a session on the given history and saves it. The id is
82+// NewID(); the caller keeps the returned session and saves it again after
83+// each turn.
84+func (s *Store) New(cwd string, messages []*ai.Message) (*Session, error) {
85+ now := time.Now().UTC()
86+ sess := &Session{ID: NewID(), Cwd: cwd, CreatedAt: now, UpdatedAt: now, Messages: messages}
87+ return sess, s.Save(sess)
88+}
89+
90+// Save writes the session's file, whole, atomically (temp file + rename): a
91+// crash mid-write leaves the previous version, never a truncated one.
92+// UpdatedAt is set to now.
93+func (s *Store) Save(sess *Session) error {
94+ if err := validID(sess.ID); err != nil {
95+ return err
96+ }
97+ if err := os.MkdirAll(s.dir, 0o755); err != nil {
98+ return fmt.Errorf("sessions dir: %w", err)
99+ }
100+ sess.UpdatedAt = time.Now().UTC()
101+ data, err := json.MarshalIndent(sess, "", " ")
102+ if err != nil {
103+ return fmt.Errorf("encode session %s: %w", sess.ID, err)
104+ }
105+ if err := writeAtomic(s.path(sess.ID), data); err != nil {
106+ return fmt.Errorf("write session %s: %w", sess.ID, err)
107+ }
108+ return nil
109+}
110+
111+// writeAtomic writes data to path through a temp file and a rename, so a
112+// reader never sees a half-written file.
113+func writeAtomic(path string, data []byte) error {
114+ tmp := path + ".tmp"
115+ if err := os.WriteFile(tmp, data, 0o644); err != nil {
116+ return err
117+ }
118+ if err := os.Rename(tmp, path); err != nil {
119+ _ = os.Remove(tmp)
120+ return err
121+ }
122+ return nil
123+}
124+
125+// Load reads one session by id. ErrNotFound when there is no such file; any
126+// other error means the file exists but cannot be read or decoded.
127+func (s *Store) Load(id string) (*Session, error) {
128+ if err := validID(id); err != nil {
129+ return nil, err
130+ }
131+ data, err := os.ReadFile(s.path(id))
132+ if errors.Is(err, os.ErrNotExist) {
133+ return nil, fmt.Errorf("%w: %s", ErrNotFound, id)
134+ }
135+ if err != nil {
136+ return nil, err
137+ }
138+ var sess Session
139+ if err := json.Unmarshal(data, &sess); err != nil {
140+ return nil, fmt.Errorf("decode session %s: %w", id, err)
141+ }
142+ return &sess, nil
143+}
144+
145+// List returns the stored sessions, most recently updated first. A missing
146+// directory is an empty list; a file that cannot be decoded is skipped — one
147+// corrupt session must not hide the others.
148+func (s *Store) List() []Summary {
149+ entries, err := os.ReadDir(s.dir)
150+ if err != nil {
151+ return nil
152+ }
153+ var out []Summary
154+ for _, e := range entries {
155+ name := e.Name()
156+ if e.IsDir() || !strings.HasSuffix(name, ".json") {
157+ continue
158+ }
159+ sess, err := s.Load(strings.TrimSuffix(name, ".json"))
160+ if err != nil {
161+ continue
162+ }
163+ out = append(out, summarize(sess))
164+ }
165+ sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) })
166+ return out
167+}
168+
169+// Latest loads the most recently updated session. ErrNotFound when the
170+// project has none.
171+func (s *Store) Latest() (*Session, error) {
172+ list := s.List()
173+ if len(list) == 0 {
174+ return nil, ErrNotFound
175+ }
176+ return s.Load(list[0].ID)
177+}
178+
179+func (s *Store) path(id string) string {
180+ return filepath.Join(s.dir, id+".json")
181+}
182+
183+// validID keeps ids to plain file names: no separators, no "..", nothing
184+// that could make Load read outside the directory.
185+func validID(id string) error {
186+ if id == "" || id == "." || id == ".." || strings.ContainsAny(id, `/\`) {
187+ return fmt.Errorf("invalid session id %q", id)
188+ }
189+ return nil
190+}
191+
192+func summarize(sess *Session) Summary {
193+ sum := Summary{ID: sess.ID, UpdatedAt: sess.UpdatedAt}
194+ for _, m := range sess.Messages {
195+ if m == nil || m.Role != ai.RoleUser {
196+ continue
197+ }
198+ sum.Turns++
199+ if sum.FirstQuestion == "" {
200+ sum.FirstQuestion = firstLine(m.Text())
201+ }
202+ }
203+ return sum
204+}
205+
206+// firstLine is the first non-empty line of text, trimmed, at most 60 runes.
207+func firstLine(text string) string {
208+ for _, line := range strings.Split(text, "\n") {
209+ line = strings.TrimSpace(line)
210+ if line == "" {
211+ continue
212+ }
213+ if r := []rune(line); len(r) > 60 {
214+ return string(r[:57]) + "..."
215+ }
216+ return line
217+ }
218+ return ""
219+}
added internal/store/store_test.go +146 -0
new file mode 100644
@@ -0,0 +1,146 @@
1+package store
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "testing"
9+ "time"
10+
11+ "github.com/firebase/genkit/go/ai"
12+)
13+
14+func history(questions ...string) []*ai.Message {
15+ msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")}
16+ for _, q := range questions {
17+ msgs = append(msgs,
18+ ai.NewUserTextMessage(q),
19+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})),
20+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q + "\n"})),
21+ ai.NewModelMessage(ai.NewTextPart("done "+q)),
22+ )
23+ }
24+ return msgs
25+}
26+
27+// Ids sort by time and never collide: two ids taken in the same second differ
28+// by their random tail, and the shape is what the docs promise.
29+func TestNewIDShapeAndUniqueness(t *testing.T) {
30+ a, b := NewID(), NewID()
31+ shape := regexp.MustCompile(`^\d{8}-\d{6}-[0-9a-f]{8}$`)
32+ if !shape.MatchString(a) {
33+ t.Errorf("id %q does not match %s", a, shape)
34+ }
35+ if a == b {
36+ t.Errorf("two ids in a row are equal: %q", a)
37+ }
38+}
39+
40+// The whole history goes to disk and comes back equal, tool calls included —
41+// that is what lets a session resume and lets ACP replay it.
42+func TestNewSaveLoadRoundTrip(t *testing.T) {
43+ st := Open(filepath.Join(t.TempDir(), ".mm", "sessions"))
44+ msgs := history("a", "b")
45+ sess, err := st.New("/work/project", msgs)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, err := os.Stat(filepath.Join(st.Dir(), sess.ID+".json")); err != nil {
50+ t.Fatalf("session file not written: %v", err)
51+ }
52+
53+ got, err := st.Load(sess.ID)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if got.ID != sess.ID || got.Cwd != "/work/project" || len(got.Messages) != len(msgs) {
58+ t.Fatalf("loaded %+v, want id %s, cwd /work/project, %d messages", got, sess.ID, len(msgs))
59+ }
60+ req := got.Messages[2].Content[0].ToolRequest
61+ if req == nil || req.Name != "bash" || req.Input.(map[string]any)["command"] != "echo a" {
62+ t.Errorf("tool request did not survive the round trip: %+v", got.Messages[2].Content[0])
63+ }
64+ if resp := got.Messages[3].Content[0].ToolResponse; resp == nil || resp.Output != "a\n" {
65+ t.Errorf("tool response did not survive the round trip: %+v", got.Messages[3].Content[0])
66+ }
67+ if got.Messages[0].Role != ai.RoleSystem || got.Messages[1].Text() != "a" {
68+ t.Errorf("roles or text lost: %v / %q", got.Messages[0].Role, got.Messages[1].Text())
69+ }
70+}
71+
72+// Save rewrites the whole file: after a compression the file must hold the
73+// shorter history, not the old one plus the new. No .tmp file is left behind.
74+func TestSaveReplacesTheFileWhole(t *testing.T) {
75+ st := Open(t.TempDir())
76+ sess, err := st.New("/w", history("a", "b", "c"))
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ sess.Messages = history("summary")
81+ if err := st.Save(sess); err != nil {
82+ t.Fatal(err)
83+ }
84+ got, _ := st.Load(sess.ID)
85+ if len(got.Messages) != len(history("summary")) {
86+ t.Errorf("file holds %d messages after a shrinking save, want %d", len(got.Messages), len(history("summary")))
87+ }
88+ entries, _ := os.ReadDir(st.Dir())
89+ if len(entries) != 1 {
90+ t.Errorf("%d entries in the store dir, want 1 (no .tmp left behind)", len(entries))
91+ }
92+}
93+
94+// Load says "not found" for an unknown id in a way callers can test for, and
95+// refuses ids that would leave the directory.
96+func TestLoadNotFoundAndBadIDs(t *testing.T) {
97+ st := Open(t.TempDir())
98+ if _, err := st.Load("nope"); !errors.Is(err, ErrNotFound) {
99+ t.Errorf("Load(nope) = %v, want ErrNotFound", err)
100+ }
101+ for _, id := range []string{"", "..", "../x", `a\b`, "a/b"} {
102+ if _, err := st.Load(id); err == nil || errors.Is(err, ErrNotFound) {
103+ t.Errorf("Load(%q) = %v, want an invalid-id error", id, err)
104+ }
105+ if err := st.Save(&Session{ID: id}); err == nil {
106+ t.Errorf("Save(%q) accepted an id that is not a plain file name", id)
107+ }
108+ }
109+}
110+
111+// List is newest first, counts the questions, shows the first one shortened,
112+// skips what it cannot decode, and is empty — not an error — on a missing
113+// directory.
114+func TestListAndLatest(t *testing.T) {
115+ st := Open(filepath.Join(t.TempDir(), "none"))
116+ if l := st.List(); len(l) != 0 {
117+ t.Errorf("List on a missing dir = %v, want empty", l)
118+ }
119+ if _, err := st.Latest(); !errors.Is(err, ErrNotFound) {
120+ t.Errorf("Latest on a missing dir = %v, want ErrNotFound", err)
121+ }
122+
123+ st = Open(t.TempDir())
124+ old, _ := st.New("/w", history("first question that is long enough to be cut somewhere around sixty runes"))
125+ old.UpdatedAt = time.Now().Add(-time.Hour)
126+ data, _ := os.ReadFile(filepath.Join(st.Dir(), old.ID+".json"))
127+ _ = os.WriteFile(filepath.Join(st.Dir(), old.ID+".json"), regexp.MustCompile(`"updatedAt": "[^"]*"`).ReplaceAll(data, []byte(`"updatedAt": "2020-01-01T00:00:00Z"`)), 0o644)
128+ recent, _ := st.New("/w", history("x", "y"))
129+ _ = os.WriteFile(filepath.Join(st.Dir(), "corrupt.json"), []byte("{not json"), 0o644)
130+ _ = os.WriteFile(filepath.Join(st.Dir(), "notes.txt"), []byte("ignored"), 0o644)
131+
132+ list := st.List()
133+ if len(list) != 2 || list[0].ID != recent.ID || list[1].ID != old.ID {
134+ t.Fatalf("List = %+v, want [%s %s]", list, recent.ID, old.ID)
135+ }
136+ if list[0].Turns != 2 || list[0].FirstQuestion != "x" {
137+ t.Errorf("recent summary %+v", list[0])
138+ }
139+ if got := list[1].FirstQuestion; len([]rune(got)) != 60 || got[len(got)-3:] != "..." {
140+ t.Errorf("long first question not shortened to 60 runes: %q", got)
141+ }
142+ latest, err := st.Latest()
143+ if err != nil || latest.ID != recent.ID {
144+ t.Errorf("Latest = %v, %v; want %s", latest, err, recent.ID)
145+ }
146+}
new file mode 100644
@@ -0,0 +1,146 @@
1+package store
2+
3+import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "testing"
9+ "time"
10+
11+ "github.com/firebase/genkit/go/ai"
12+)
13+
14+func history(questions ...string) []*ai.Message {
15+ msgs := []*ai.Message{ai.NewSystemTextMessage("You are Bob.")}
16+ for _, q := range questions {
17+ msgs = append(msgs,
18+ ai.NewUserTextMessage(q),
19+ ai.NewModelMessage(ai.NewToolRequestPart(&ai.ToolRequest{Ref: q, Name: "bash", Input: map[string]any{"command": "echo " + q}})),
20+ ai.NewMessage(ai.RoleTool, nil, ai.NewToolResponsePart(&ai.ToolResponse{Ref: q, Name: "bash", Output: q + "\n"})),
21+ ai.NewModelMessage(ai.NewTextPart("done "+q)),
22+ )
23+ }
24+ return msgs
25+}
26+
27+// Ids sort by time and never collide: two ids taken in the same second differ
28+// by their random tail, and the shape is what the docs promise.
29+func TestNewIDShapeAndUniqueness(t *testing.T) {
30+ a, b := NewID(), NewID()
31+ shape := regexp.MustCompile(`^\d{8}-\d{6}-[0-9a-f]{8}$`)
32+ if !shape.MatchString(a) {
33+ t.Errorf("id %q does not match %s", a, shape)
34+ }
35+ if a == b {
36+ t.Errorf("two ids in a row are equal: %q", a)
37+ }
38+}
39+
40+// The whole history goes to disk and comes back equal, tool calls included —
41+// that is what lets a session resume and lets ACP replay it.
42+func TestNewSaveLoadRoundTrip(t *testing.T) {
43+ st := Open(filepath.Join(t.TempDir(), ".mm", "sessions"))
44+ msgs := history("a", "b")
45+ sess, err := st.New("/work/project", msgs)
46+ if err != nil {
47+ t.Fatal(err)
48+ }
49+ if _, err := os.Stat(filepath.Join(st.Dir(), sess.ID+".json")); err != nil {
50+ t.Fatalf("session file not written: %v", err)
51+ }
52+
53+ got, err := st.Load(sess.ID)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ if got.ID != sess.ID || got.Cwd != "/work/project" || len(got.Messages) != len(msgs) {
58+ t.Fatalf("loaded %+v, want id %s, cwd /work/project, %d messages", got, sess.ID, len(msgs))
59+ }
60+ req := got.Messages[2].Content[0].ToolRequest
61+ if req == nil || req.Name != "bash" || req.Input.(map[string]any)["command"] != "echo a" {
62+ t.Errorf("tool request did not survive the round trip: %+v", got.Messages[2].Content[0])
63+ }
64+ if resp := got.Messages[3].Content[0].ToolResponse; resp == nil || resp.Output != "a\n" {
65+ t.Errorf("tool response did not survive the round trip: %+v", got.Messages[3].Content[0])
66+ }
67+ if got.Messages[0].Role != ai.RoleSystem || got.Messages[1].Text() != "a" {
68+ t.Errorf("roles or text lost: %v / %q", got.Messages[0].Role, got.Messages[1].Text())
69+ }
70+}
71+
72+// Save rewrites the whole file: after a compression the file must hold the
73+// shorter history, not the old one plus the new. No .tmp file is left behind.
74+func TestSaveReplacesTheFileWhole(t *testing.T) {
75+ st := Open(t.TempDir())
76+ sess, err := st.New("/w", history("a", "b", "c"))
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ sess.Messages = history("summary")
81+ if err := st.Save(sess); err != nil {
82+ t.Fatal(err)
83+ }
84+ got, _ := st.Load(sess.ID)
85+ if len(got.Messages) != len(history("summary")) {
86+ t.Errorf("file holds %d messages after a shrinking save, want %d", len(got.Messages), len(history("summary")))
87+ }
88+ entries, _ := os.ReadDir(st.Dir())
89+ if len(entries) != 1 {
90+ t.Errorf("%d entries in the store dir, want 1 (no .tmp left behind)", len(entries))
91+ }
92+}
93+
94+// Load says "not found" for an unknown id in a way callers can test for, and
95+// refuses ids that would leave the directory.
96+func TestLoadNotFoundAndBadIDs(t *testing.T) {
97+ st := Open(t.TempDir())
98+ if _, err := st.Load("nope"); !errors.Is(err, ErrNotFound) {
99+ t.Errorf("Load(nope) = %v, want ErrNotFound", err)
100+ }
101+ for _, id := range []string{"", "..", "../x", `a\b`, "a/b"} {
102+ if _, err := st.Load(id); err == nil || errors.Is(err, ErrNotFound) {
103+ t.Errorf("Load(%q) = %v, want an invalid-id error", id, err)
104+ }
105+ if err := st.Save(&Session{ID: id}); err == nil {
106+ t.Errorf("Save(%q) accepted an id that is not a plain file name", id)
107+ }
108+ }
109+}
110+
111+// List is newest first, counts the questions, shows the first one shortened,
112+// skips what it cannot decode, and is empty — not an error — on a missing
113+// directory.
114+func TestListAndLatest(t *testing.T) {
115+ st := Open(filepath.Join(t.TempDir(), "none"))
116+ if l := st.List(); len(l) != 0 {
117+ t.Errorf("List on a missing dir = %v, want empty", l)
118+ }
119+ if _, err := st.Latest(); !errors.Is(err, ErrNotFound) {
120+ t.Errorf("Latest on a missing dir = %v, want ErrNotFound", err)
121+ }
122+
123+ st = Open(t.TempDir())
124+ old, _ := st.New("/w", history("first question that is long enough to be cut somewhere around sixty runes"))
125+ old.UpdatedAt = time.Now().Add(-time.Hour)
126+ data, _ := os.ReadFile(filepath.Join(st.Dir(), old.ID+".json"))
127+ _ = os.WriteFile(filepath.Join(st.Dir(), old.ID+".json"), regexp.MustCompile(`"updatedAt": "[^"]*"`).ReplaceAll(data, []byte(`"updatedAt": "2020-01-01T00:00:00Z"`)), 0o644)
128+ recent, _ := st.New("/w", history("x", "y"))
129+ _ = os.WriteFile(filepath.Join(st.Dir(), "corrupt.json"), []byte("{not json"), 0o644)
130+ _ = os.WriteFile(filepath.Join(st.Dir(), "notes.txt"), []byte("ignored"), 0o644)
131+
132+ list := st.List()
133+ if len(list) != 2 || list[0].ID != recent.ID || list[1].ID != old.ID {
134+ t.Fatalf("List = %+v, want [%s %s]", list, recent.ID, old.ID)
135+ }
136+ if list[0].Turns != 2 || list[0].FirstQuestion != "x" {
137+ t.Errorf("recent summary %+v", list[0])
138+ }
139+ if got := list[1].FirstQuestion; len([]rune(got)) != 60 || got[len(got)-3:] != "..." {
140+ t.Errorf("long first question not shortened to 60 runes: %q", got)
141+ }
142+ latest, err := st.Latest()
143+ if err != nil || latest.ID != recent.ID {
144+ t.Errorf("Latest = %v, %v; want %s", latest, err, recent.ID)
145+ }
146+}
added internal/tui/copy.go +58 -0
new file mode 100644
@@ -0,0 +1,58 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "mm/internal/clipboard"
8+)
9+
10+// /copy — the last answer, or the whole conversation, onto the clipboard.
11+// Kept in its own file, like Turbo's one-concern-per-file layout: the runner
12+// stays about turns.
13+
14+// copy is /copy [n|all]: the last answer, the n-th from the end, or the whole
15+// conversation as plain text, onto the clipboard. The notice names the road
16+// taken (pbcopy, OSC 52…) so the user knows what to trust.
17+func (r *runner) copy(arg string) {
18+ text, err := copyText(r.t, arg)
19+ if err != nil {
20+ r.t.Error("⚠️ " + err.Error())
21+ return
22+ }
23+ if r.clip == nil {
24+ r.t.Error("⚠️ no clipboard: " + clipboard.ErrNoClipboard.Error())
25+ return
26+ }
27+ how, err := r.clip.Copy(text)
28+ if err != nil {
29+ r.t.Error("⚠️ no clipboard: " + err.Error())
30+ return
31+ }
32+ r.t.Notice(fmt.Sprintf("📋 copied %d chars (%s)", len([]rune(text)), how))
33+}
34+
35+// copyText picks what /copy sends: "" or "1" the last answer, "n" the n-th
36+// from the end, "all" the whole conversation. Kept apart from the clipboard
37+// so the choice is testable on a Transcript alone.
38+func copyText(t *Transcript, arg string) (string, error) {
39+ if arg == "all" {
40+ if t.Len() == 0 {
41+ return "", fmt.Errorf("nothing to copy: the conversation is empty")
42+ }
43+ return t.Plain(), nil
44+ }
45+ n := 1
46+ if arg != "" {
47+ parsed, err := strconv.Atoi(arg)
48+ if err != nil || parsed < 1 {
49+ return "", fmt.Errorf("/copy takes a number (1 = the last answer) or \"all\", not %q", arg)
50+ }
51+ n = parsed
52+ }
53+ answers := t.Answers()
54+ if n > len(answers) {
55+ return "", fmt.Errorf("nothing to copy: %d answer(s) in the conversation, /copy %d asks for more", len(answers), n)
56+ }
57+ return answers[len(answers)-n], nil
58+}
new file mode 100644
@@ -0,0 +1,58 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strconv"
6+
7+ "mm/internal/clipboard"
8+)
9+
10+// /copy — the last answer, or the whole conversation, onto the clipboard.
11+// Kept in its own file, like Turbo's one-concern-per-file layout: the runner
12+// stays about turns.
13+
14+// copy is /copy [n|all]: the last answer, the n-th from the end, or the whole
15+// conversation as plain text, onto the clipboard. The notice names the road
16+// taken (pbcopy, OSC 52…) so the user knows what to trust.
17+func (r *runner) copy(arg string) {
18+ text, err := copyText(r.t, arg)
19+ if err != nil {
20+ r.t.Error("⚠️ " + err.Error())
21+ return
22+ }
23+ if r.clip == nil {
24+ r.t.Error("⚠️ no clipboard: " + clipboard.ErrNoClipboard.Error())
25+ return
26+ }
27+ how, err := r.clip.Copy(text)
28+ if err != nil {
29+ r.t.Error("⚠️ no clipboard: " + err.Error())
30+ return
31+ }
32+ r.t.Notice(fmt.Sprintf("📋 copied %d chars (%s)", len([]rune(text)), how))
33+}
34+
35+// copyText picks what /copy sends: "" or "1" the last answer, "n" the n-th
36+// from the end, "all" the whole conversation. Kept apart from the clipboard
37+// so the choice is testable on a Transcript alone.
38+func copyText(t *Transcript, arg string) (string, error) {
39+ if arg == "all" {
40+ if t.Len() == 0 {
41+ return "", fmt.Errorf("nothing to copy: the conversation is empty")
42+ }
43+ return t.Plain(), nil
44+ }
45+ n := 1
46+ if arg != "" {
47+ parsed, err := strconv.Atoi(arg)
48+ if err != nil || parsed < 1 {
49+ return "", fmt.Errorf("/copy takes a number (1 = the last answer) or \"all\", not %q", arg)
50+ }
51+ n = parsed
52+ }
53+ answers := t.Answers()
54+ if n > len(answers) {
55+ return "", fmt.Errorf("nothing to copy: %d answer(s) in the conversation, /copy %d asks for more", len(answers), n)
56+ }
57+ return answers[len(answers)-n], nil
58+}
added internal/tui/doc.go +18 -0
new file mode 100644
@@ -0,0 +1,18 @@
1+// Package tui is mini-me's third front end: one full-screen terminal window
2+// with the conversation above and a box to type in below, selected with
3+// `mm -tui`. It is modelled on the ACP agent window of the Turbo editors
4+// (codeberg.org/turbo-editors/turbo-core, package acp) and drawn directly on
5+// github.com/gdamore/tcell/v2 — no widget library, a handful of styles.
6+//
7+// The window is a ui.Sink like the ACP façade: the tools already emit their
8+// events through ui.ActiveSink(), so nothing in internal/tools changes. The
9+// engine's streamed chunks, the tool events and the results of a turn are
10+// recorded into a Transcript from whatever goroutine they arrive on, and the
11+// screen is redrawn from that value on the event loop's goroutine — the one
12+// place that draws. Anything that happens elsewhere records a fact and calls
13+// wake(), which posts an interrupt event; the redraw is state-driven, so a
14+// dropped wake-up costs a late frame, never a lost one (Turbo's rule).
15+//
16+// When stdin or stdout is not a terminal (`mm -tui | tee`), Run returns
17+// ErrNotTerminal and main falls back to the line-mode REPL.
18+package tui
new file mode 100644
@@ -0,0 +1,18 @@
1+// Package tui is mini-me's third front end: one full-screen terminal window
2+// with the conversation above and a box to type in below, selected with
3+// `mm -tui`. It is modelled on the ACP agent window of the Turbo editors
4+// (codeberg.org/turbo-editors/turbo-core, package acp) and drawn directly on
5+// github.com/gdamore/tcell/v2 — no widget library, a handful of styles.
6+//
7+// The window is a ui.Sink like the ACP façade: the tools already emit their
8+// events through ui.ActiveSink(), so nothing in internal/tools changes. The
9+// engine's streamed chunks, the tool events and the results of a turn are
10+// recorded into a Transcript from whatever goroutine they arrive on, and the
11+// screen is redrawn from that value on the event loop's goroutine — the one
12+// place that draws. Anything that happens elsewhere records a fact and calls
13+// wake(), which posts an interrupt event; the redraw is state-driven, so a
14+// dropped wake-up costs a late frame, never a lost one (Turbo's rule).
15+//
16+// When stdin or stdout is not a terminal (`mm -tui | tee`), Run returns
17+// ErrNotTerminal and main falls back to the line-mode REPL.
18+package tui
added internal/tui/draw.go +212 -0
new file mode 100644
@@ -0,0 +1,212 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// draw paints one frame: header, conversation, rule, input box (or the
11+// permission question), status bar, picker. Called on the loop goroutine
12+// only, from the transcript's current value.
13+func (v *View) draw() {
14+ w, h := v.size()
15+ if w < 1 || h < 1 {
16+ return
17+ }
18+ v.screen.Clear()
19+ p := painter{screen: v.screen, area: rect{w: w, h: h}}
20+ v.drawHeader(p.sub(rect{x: 0, y: 0, w: w, h: 1}))
21+ th := v.transcriptHeight()
22+ v.drawTranscript(p.sub(rect{x: 0, y: 1, w: w, h: th}))
23+ v.drawRule(p.sub(rect{x: 0, y: 1 + th, w: w, h: 1}))
24+ box := p.sub(rect{x: 0, y: 2 + th, w: w, h: inputHeight})
25+ if v.ask != nil {
26+ v.drawPermission(box)
27+ } else {
28+ v.drawInput(box)
29+ }
30+ v.drawStatus(p.sub(rect{x: 0, y: h - 1, w: w, h: 1}))
31+ v.drawPicker(p.sub(rect{x: 0, y: 1, w: w, h: th}))
32+ v.screen.Show()
33+}
34+
35+func (v *View) drawHeader(p painter) {
36+ p.fill(' ', stBar)
37+ p.textLimited(1, 0, max(p.width()-2, 0), v.header, stBar)
38+}
39+
40+// drawTranscript paints the conversation, scrolled to wherever it is, with a
41+// scroll bar in the last column.
42+func (v *View) drawTranscript(p painter) {
43+ height := p.height()
44+ if height < 1 {
45+ return
46+ }
47+ lines := v.lines()
48+ v.laidOut = len(lines)
49+ v.clampScroll(height, len(lines))
50+ width := max(p.width()-2, 1)
51+ for row := 0; row < height; row++ {
52+ at := v.scroll + row
53+ if at >= len(lines) {
54+ break
55+ }
56+ p.textLimited(1, row, width, lines[at].Text, styleOf(lines[at].Style))
57+ }
58+ v.drawScrollBar(p, height, len(lines))
59+}
60+
61+// drawScrollBar shows where the visible rows sit in the conversation.
62+func (v *View) drawScrollBar(p painter, height, total int) {
63+ x := p.width() - 1
64+ if total <= height {
65+ return
66+ }
67+ for y := 0; y < height; y++ {
68+ p.set(x, y, '░', stScroll)
69+ }
70+ thumb := max(height*height/total, 1)
71+ top := v.scroll * (height - thumb) / max(total-height, 1)
72+ for y := top; y < top+thumb && y < height; y++ {
73+ p.set(x, y, '█', stThumb)
74+ }
75+}
76+
77+// drawRule separates the conversation from the box and says what the window
78+// is doing, with the keys that matter right now.
79+func (v *View) drawRule(p painter) {
80+ style := stRule
81+ if v.busy != "" {
82+ style = stRuleBusy
83+ }
84+ p.hline(0, 0, p.width(), '─', style)
85+ p.textLimited(2, 0, max(p.width()-4, 0), v.ruleLabel(), style)
86+}
87+
88+// ruleLabel is one sentence per state.
89+func (v *View) ruleLabel() string {
90+ switch {
91+ case v.ask != nil:
92+ return " a allow once · s allow for this session · r / Esc reject "
93+ case v.busy != "":
94+ return fmt.Sprintf(" %c %s — Esc stops it · Enter sends the next question ", v.spinner(), v.busy)
95+ case !v.onInput:
96+ return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
97+ default:
98+ return " Enter sends · Ctrl-J new line · / commands · @ files · Tab scroll "
99+ }
100+}
101+
102+// drawInput paints what has been typed and the cursor. A paste token is one
103+// rune in the input but a whole label on the screen, so lines are drawn
104+// rune by rune and the cursor column is measured the same way.
105+func (v *View) drawInput(p painter) {
106+ height := p.height()
107+ if height < 1 {
108+ return
109+ }
110+ p.text(0, 0, ">", stPrompt)
111+ from := max(v.cursor-height+1, 0)
112+ for row := 0; row < height; row++ {
113+ at := from + row
114+ if at >= len(v.input) {
115+ break
116+ }
117+ v.drawInputLine(p, row, v.input[at])
118+ }
119+ if v.onInput {
120+ p.showCursor(2+v.cursorX(), v.cursor-from)
121+ } else {
122+ v.screen.HideCursor()
123+ }
124+}
125+
126+// drawInputLine paints one line of the box from column 2: plain runes as
127+// they are, paste tokens as their label in the token style, clipped with an
128+// ellipsis at the right edge.
129+func (v *View) drawInputLine(p painter, row int, line string) {
130+ x, limit := 2, p.width()
131+ put := func(text string, style tcell.Style) {
132+ for _, r := range text {
133+ if x >= limit {
134+ return
135+ }
136+ if x == limit-1 && len(text) > 1 {
137+ p.set(x, row, '…', style)
138+ x++
139+ return
140+ }
141+ p.set(x, row, r, style)
142+ x++
143+ }
144+ }
145+ for _, r := range line {
146+ if label, ok := v.pastes.labelOf(r); ok {
147+ put(label, stPaste)
148+ continue
149+ }
150+ put(string(r), stBody)
151+ }
152+}
153+
154+// cursorX is the screen column of the cursor within its line: one cell per
155+// rune, a label's width per paste token.
156+func (v *View) cursorX() int {
157+ x := 0
158+ for i, r := range v.runes() {
159+ if i >= v.column {
160+ break
161+ }
162+ if label, ok := v.pastes.labelOf(r); ok {
163+ x += len([]rune(label))
164+ continue
165+ }
166+ x++
167+ }
168+ return x
169+}
170+
171+// drawPermission replaces the box with the question a tool is waiting on.
172+func (v *View) drawPermission(p painter) {
173+ v.screen.HideCursor()
174+ p.textLimited(0, 0, p.width(), "? "+v.ask.title, stAsk)
175+ p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject", stBody)
176+}
177+
178+// drawStatus is the bottom bar: key hints left, counters right.
179+func (v *View) drawStatus(p painter) {
180+ p.fill(' ', stBar)
181+ left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
182+ p.textLimited(0, 0, max(p.width()-len([]rune(v.status))-2, 0), left, stBar)
183+ if v.status != "" {
184+ p.text(max(p.width()-len([]rune(v.status))-1, 0), 0, v.status, stBar)
185+ }
186+}
187+
188+// drawPicker paints the popup over the bottom of the conversation: one row
189+// per choice, the highlighted one in reverse.
190+func (v *View) drawPicker(p painter) {
191+ cs := v.choices()
192+ if len(cs) == 0 {
193+ return
194+ }
195+ rows := min(len(cs), pickerRows, p.height())
196+ top := p.height() - rows
197+ width := 0
198+ for _, c := range cs {
199+ width = max(width, len([]rune(c.Slash()))+2+len([]rune(c.Description)))
200+ }
201+ width = min(width+4, p.width())
202+ for i := 0; i < rows; i++ {
203+ c := cs[i]
204+ style := stBody
205+ if i == v.pick.selected {
206+ style = stPick
207+ }
208+ text := " " + c.Slash() + strings.Repeat(" ", max(10-len([]rune(c.Slash())), 1)) + c.Description
209+ p.hline(0, top+i, width, ' ', style)
210+ p.textLimited(0, top+i, width, text, style)
211+ }
212+}
new file mode 100644
@@ -0,0 +1,212 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// draw paints one frame: header, conversation, rule, input box (or the
11+// permission question), status bar, picker. Called on the loop goroutine
12+// only, from the transcript's current value.
13+func (v *View) draw() {
14+ w, h := v.size()
15+ if w < 1 || h < 1 {
16+ return
17+ }
18+ v.screen.Clear()
19+ p := painter{screen: v.screen, area: rect{w: w, h: h}}
20+ v.drawHeader(p.sub(rect{x: 0, y: 0, w: w, h: 1}))
21+ th := v.transcriptHeight()
22+ v.drawTranscript(p.sub(rect{x: 0, y: 1, w: w, h: th}))
23+ v.drawRule(p.sub(rect{x: 0, y: 1 + th, w: w, h: 1}))
24+ box := p.sub(rect{x: 0, y: 2 + th, w: w, h: inputHeight})
25+ if v.ask != nil {
26+ v.drawPermission(box)
27+ } else {
28+ v.drawInput(box)
29+ }
30+ v.drawStatus(p.sub(rect{x: 0, y: h - 1, w: w, h: 1}))
31+ v.drawPicker(p.sub(rect{x: 0, y: 1, w: w, h: th}))
32+ v.screen.Show()
33+}
34+
35+func (v *View) drawHeader(p painter) {
36+ p.fill(' ', stBar)
37+ p.textLimited(1, 0, max(p.width()-2, 0), v.header, stBar)
38+}
39+
40+// drawTranscript paints the conversation, scrolled to wherever it is, with a
41+// scroll bar in the last column.
42+func (v *View) drawTranscript(p painter) {
43+ height := p.height()
44+ if height < 1 {
45+ return
46+ }
47+ lines := v.lines()
48+ v.laidOut = len(lines)
49+ v.clampScroll(height, len(lines))
50+ width := max(p.width()-2, 1)
51+ for row := 0; row < height; row++ {
52+ at := v.scroll + row
53+ if at >= len(lines) {
54+ break
55+ }
56+ p.textLimited(1, row, width, lines[at].Text, styleOf(lines[at].Style))
57+ }
58+ v.drawScrollBar(p, height, len(lines))
59+}
60+
61+// drawScrollBar shows where the visible rows sit in the conversation.
62+func (v *View) drawScrollBar(p painter, height, total int) {
63+ x := p.width() - 1
64+ if total <= height {
65+ return
66+ }
67+ for y := 0; y < height; y++ {
68+ p.set(x, y, '░', stScroll)
69+ }
70+ thumb := max(height*height/total, 1)
71+ top := v.scroll * (height - thumb) / max(total-height, 1)
72+ for y := top; y < top+thumb && y < height; y++ {
73+ p.set(x, y, '█', stThumb)
74+ }
75+}
76+
77+// drawRule separates the conversation from the box and says what the window
78+// is doing, with the keys that matter right now.
79+func (v *View) drawRule(p painter) {
80+ style := stRule
81+ if v.busy != "" {
82+ style = stRuleBusy
83+ }
84+ p.hline(0, 0, p.width(), '─', style)
85+ p.textLimited(2, 0, max(p.width()-4, 0), v.ruleLabel(), style)
86+}
87+
88+// ruleLabel is one sentence per state.
89+func (v *View) ruleLabel() string {
90+ switch {
91+ case v.ask != nil:
92+ return " a allow once · s allow for this session · r / Esc reject "
93+ case v.busy != "":
94+ return fmt.Sprintf(" %c %s — Esc stops it · Enter sends the next question ", v.spinner(), v.busy)
95+ case !v.onInput:
96+ return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
97+ default:
98+ return " Enter sends · Ctrl-J new line · / commands · @ files · Tab scroll "
99+ }
100+}
101+
102+// drawInput paints what has been typed and the cursor. A paste token is one
103+// rune in the input but a whole label on the screen, so lines are drawn
104+// rune by rune and the cursor column is measured the same way.
105+func (v *View) drawInput(p painter) {
106+ height := p.height()
107+ if height < 1 {
108+ return
109+ }
110+ p.text(0, 0, ">", stPrompt)
111+ from := max(v.cursor-height+1, 0)
112+ for row := 0; row < height; row++ {
113+ at := from + row
114+ if at >= len(v.input) {
115+ break
116+ }
117+ v.drawInputLine(p, row, v.input[at])
118+ }
119+ if v.onInput {
120+ p.showCursor(2+v.cursorX(), v.cursor-from)
121+ } else {
122+ v.screen.HideCursor()
123+ }
124+}
125+
126+// drawInputLine paints one line of the box from column 2: plain runes as
127+// they are, paste tokens as their label in the token style, clipped with an
128+// ellipsis at the right edge.
129+func (v *View) drawInputLine(p painter, row int, line string) {
130+ x, limit := 2, p.width()
131+ put := func(text string, style tcell.Style) {
132+ for _, r := range text {
133+ if x >= limit {
134+ return
135+ }
136+ if x == limit-1 && len(text) > 1 {
137+ p.set(x, row, '…', style)
138+ x++
139+ return
140+ }
141+ p.set(x, row, r, style)
142+ x++
143+ }
144+ }
145+ for _, r := range line {
146+ if label, ok := v.pastes.labelOf(r); ok {
147+ put(label, stPaste)
148+ continue
149+ }
150+ put(string(r), stBody)
151+ }
152+}
153+
154+// cursorX is the screen column of the cursor within its line: one cell per
155+// rune, a label's width per paste token.
156+func (v *View) cursorX() int {
157+ x := 0
158+ for i, r := range v.runes() {
159+ if i >= v.column {
160+ break
161+ }
162+ if label, ok := v.pastes.labelOf(r); ok {
163+ x += len([]rune(label))
164+ continue
165+ }
166+ x++
167+ }
168+ return x
169+}
170+
171+// drawPermission replaces the box with the question a tool is waiting on.
172+func (v *View) drawPermission(p painter) {
173+ v.screen.HideCursor()
174+ p.textLimited(0, 0, p.width(), "? "+v.ask.title, stAsk)
175+ p.textLimited(2, 1, max(p.width()-2, 0), "[a] allow once [s] allow "+v.ask.tool+" for this session [r] reject", stBody)
176+}
177+
178+// drawStatus is the bottom bar: key hints left, counters right.
179+func (v *View) drawStatus(p painter) {
180+ p.fill(' ', stBar)
181+ left := " F1 help · Ctrl-Y copy · " + v.mouseLabel() + " · Ctrl-C quit"
182+ p.textLimited(0, 0, max(p.width()-len([]rune(v.status))-2, 0), left, stBar)
183+ if v.status != "" {
184+ p.text(max(p.width()-len([]rune(v.status))-1, 0), 0, v.status, stBar)
185+ }
186+}
187+
188+// drawPicker paints the popup over the bottom of the conversation: one row
189+// per choice, the highlighted one in reverse.
190+func (v *View) drawPicker(p painter) {
191+ cs := v.choices()
192+ if len(cs) == 0 {
193+ return
194+ }
195+ rows := min(len(cs), pickerRows, p.height())
196+ top := p.height() - rows
197+ width := 0
198+ for _, c := range cs {
199+ width = max(width, len([]rune(c.Slash()))+2+len([]rune(c.Description)))
200+ }
201+ width = min(width+4, p.width())
202+ for i := 0; i < rows; i++ {
203+ c := cs[i]
204+ style := stBody
205+ if i == v.pick.selected {
206+ style = stPick
207+ }
208+ text := " " + c.Slash() + strings.Repeat(" ", max(10-len([]rune(c.Slash())), 1)) + c.Description
209+ p.hline(0, top+i, width, ' ', style)
210+ p.textLimited(0, top+i, width, text, style)
211+ }
212+}
added internal/tui/events.go +290 -0
new file mode 100644
@@ -0,0 +1,290 @@
1+package tui
2+
3+import (
4+ "github.com/gdamore/tcell/v2"
5+)
6+
7+// handle routes one event. Everything here runs on the loop goroutine.
8+func (v *View) handle(ev tcell.Event) {
9+ switch e := ev.(type) {
10+ case *tcell.EventKey:
11+ v.handleKey(e)
12+ case *tcell.EventPaste:
13+ v.pasting = e.Start()
14+ if !v.pasting {
15+ v.finishPaste()
16+ }
17+ case *tcell.EventMouse:
18+ v.handleMouse(e)
19+ case *tcell.EventResize:
20+ v.screen.Sync()
21+ }
22+}
23+
24+// handleKey is the whole keymap. Order matters: a pending permission takes
25+// every key, then the picker, then the keys that mean the same thing
26+// whichever pane has the focus, then the pane's own.
27+func (v *View) handleKey(ev *tcell.EventKey) {
28+ before := v.Input()
29+ defer func() {
30+ if v.Input() != before && v.pick.dismissedFor != v.Input() {
31+ v.pick.dismiss("") // Escape's "not now" lasts until the text moves on
32+ }
33+ }()
34+
35+ if v.pasting {
36+ v.bufferPasteKey(ev)
37+ return
38+ }
39+ if v.ask != nil {
40+ v.answerPermission(ev)
41+ return
42+ }
43+ if v.handlePickerKey(ev) {
44+ return
45+ }
46+ if v.handleWindowKey(ev) {
47+ return
48+ }
49+ if v.onInput && v.handleInputKey(ev) {
50+ return
51+ }
52+ v.handleScrollKey(ev)
53+}
54+
55+// answerPermission maps a key onto the pending permission: a once, s for
56+// the session, r or Escape reject. Anything else is ignored — the box is
57+// not editable while the question stands.
58+func (v *View) answerPermission(ev *tcell.EventKey) {
59+ var answer permissionAnswer
60+ switch {
61+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 'a' || ev.Rune() == 'A'):
62+ answer = permAllowOnce
63+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 's' || ev.Rune() == 'S'):
64+ answer = permAllowSession
65+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 'r' || ev.Rune() == 'R'), ev.Key() == tcell.KeyEscape:
66+ answer = permReject
67+ default:
68+ return
69+ }
70+ v.ask = nil
71+ if v.on.answer != nil {
72+ v.on.answer(answer)
73+ }
74+}
75+
76+// handlePickerKey drives the popup while it is open.
77+func (v *View) handlePickerKey(ev *tcell.EventKey) bool {
78+ if !v.pickerOpen() {
79+ return false
80+ }
81+ switch ev.Key() {
82+ case tcell.KeyUp:
83+ v.movePick(-1)
84+ case tcell.KeyDown:
85+ v.movePick(1)
86+ case tcell.KeyTab:
87+ v.complete()
88+ case tcell.KeyEnter:
89+ if v.typedIsComplete() {
90+ v.send()
91+ } else {
92+ v.complete()
93+ }
94+ case tcell.KeyEscape:
95+ v.pick.dismiss(v.input[0])
96+ default:
97+ return false
98+ }
99+ return true
100+}
101+
102+// handleWindowKey deals with the keys that mean the same thing whichever pane
103+// has the focus.
104+func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
105+ switch {
106+ case ev.Key() == tcell.KeyCtrlC:
107+ // The REPL's meaning, kept: cancel a running turn, quit when idle.
108+ if v.busy == "" || !v.cancelTurn() {
109+ v.on.quit()
110+ }
111+ case ev.Key() == tcell.KeyEscape:
112+ v.cancelTurn()
113+ case ev.Key() == tcell.KeyCtrlL:
114+ v.clearScreen()
115+ case ev.Key() == tcell.KeyF1:
116+ if v.on.help != nil {
117+ v.on.help()
118+ }
119+ case ev.Key() == tcell.KeyF2:
120+ v.toggleMouse()
121+ case ev.Key() == tcell.KeyCtrlY:
122+ if v.on.copy != nil {
123+ v.on.copy()
124+ }
125+ case ev.Key() == tcell.KeyTab:
126+ v.onInput = !v.onInput
127+ case ev.Key() == tcell.KeyCtrlJ, ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
128+ v.insertNewline()
129+ case ev.Key() == tcell.KeyEnter:
130+ v.send()
131+ case ev.Key() == tcell.KeyPgUp:
132+ v.scrollBy(-max(v.transcriptHeight()-1, 1))
133+ case ev.Key() == tcell.KeyPgDn:
134+ v.scrollBy(max(v.transcriptHeight()-1, 1))
135+ default:
136+ return false
137+ }
138+ return true
139+}
140+
141+// cancelTurn stops the turn in progress and reports whether there was one.
142+func (v *View) cancelTurn() bool {
143+ return v.on.cancel != nil && v.on.cancel()
144+}
145+
146+// handleInputKey edits what is being typed.
147+func (v *View) handleInputKey(ev *tcell.EventKey) bool {
148+ switch ev.Key() {
149+ case tcell.KeyRune:
150+ v.insert(ev.Rune())
151+ case tcell.KeyBackspace, tcell.KeyBackspace2:
152+ v.backspace()
153+ case tcell.KeyDelete:
154+ v.deleteRune()
155+ case tcell.KeyLeft:
156+ v.moveLeft()
157+ case tcell.KeyRight:
158+ v.moveRight()
159+ case tcell.KeyHome:
160+ v.column = 0
161+ case tcell.KeyEnd:
162+ v.column = len(v.runes())
163+ case tcell.KeyUp:
164+ if v.cursor == 0 {
165+ return false // scroll the conversation instead
166+ }
167+ v.cursor--
168+ v.clampColumn()
169+ case tcell.KeyDown:
170+ if v.cursor >= len(v.input)-1 {
171+ return false
172+ }
173+ v.cursor++
174+ v.clampColumn()
175+ default:
176+ return false
177+ }
178+ return true
179+}
180+
181+// handleScrollKey moves through the conversation: arrows one line, Home to
182+// the top, End to the bottom (which resumes following).
183+func (v *View) handleScrollKey(ev *tcell.EventKey) {
184+ switch ev.Key() {
185+ case tcell.KeyUp:
186+ v.scrollBy(-1)
187+ case tcell.KeyDown:
188+ v.scrollBy(1)
189+ case tcell.KeyHome:
190+ v.scroll, v.follow = 0, false
191+ case tcell.KeyEnd:
192+ v.follow = true
193+ }
194+}
195+
196+// handleMouse scrolls the conversation with the wheel.
197+func (v *View) handleMouse(ev *tcell.EventMouse) {
198+ switch ev.Buttons() {
199+ case tcell.WheelUp:
200+ v.scrollBy(-3)
201+ case tcell.WheelDown:
202+ v.scrollBy(3)
203+ }
204+}
205+
206+// --- editing the box -----------------------------------------------------------
207+
208+func (v *View) insert(r rune) {
209+ runes := v.runes()
210+ v.column = min(v.column, len(runes))
211+ updated := append([]rune{}, runes[:v.column]...)
212+ updated = append(updated, r)
213+ updated = append(updated, runes[v.column:]...)
214+ v.input[v.cursor] = string(updated)
215+ v.column++
216+}
217+
218+// insertNewline breaks the line the cursor is on: a second paragraph without
219+// sending the first.
220+func (v *View) insertNewline() {
221+ runes := v.runes()
222+ v.column = min(v.column, len(runes))
223+ before, after := string(runes[:v.column]), string(runes[v.column:])
224+ v.input[v.cursor] = before
225+ v.input = append(v.input, "")
226+ copy(v.input[v.cursor+2:], v.input[v.cursor+1:])
227+ v.input[v.cursor+1] = after
228+ v.cursor++
229+ v.column = 0
230+}
231+
232+// backspace removes the rune before the cursor, joining lines at a line
233+// start. A paste token goes whole, its text with it.
234+func (v *View) backspace() {
235+ if v.column > 0 {
236+ runes := v.runes()
237+ v.pastes.drop(runes[v.column-1])
238+ v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))
239+ v.column--
240+ return
241+ }
242+ if v.cursor == 0 {
243+ return
244+ }
245+ above := []rune(v.input[v.cursor-1])
246+ v.column = len(above)
247+ v.input[v.cursor-1] = string(above) + v.input[v.cursor]
248+ v.input = append(v.input[:v.cursor], v.input[v.cursor+1:]...)
249+ v.cursor--
250+}
251+
252+// deleteRune removes the rune under the cursor — a whole paste token when
253+// that is what stands there.
254+func (v *View) deleteRune() {
255+ runes := v.runes()
256+ if v.column >= len(runes) {
257+ return
258+ }
259+ v.pastes.drop(runes[v.column])
260+ v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
261+}
262+
263+func (v *View) moveLeft() {
264+ switch {
265+ case v.column > 0:
266+ v.column--
267+ case v.cursor > 0:
268+ v.cursor--
269+ v.column = len(v.runes())
270+ }
271+}
272+
273+func (v *View) moveRight() {
274+ switch {
275+ case v.column < len(v.runes()):
276+ v.column++
277+ case v.cursor < len(v.input)-1:
278+ v.cursor++
279+ v.column = 0
280+ }
281+}
282+
283+func (v *View) runes() []rune {
284+ if v.cursor >= len(v.input) {
285+ return nil
286+ }
287+ return []rune(v.input[v.cursor])
288+}
289+
290+func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) }
new file mode 100644
@@ -0,0 +1,290 @@
1+package tui
2+
3+import (
4+ "github.com/gdamore/tcell/v2"
5+)
6+
7+// handle routes one event. Everything here runs on the loop goroutine.
8+func (v *View) handle(ev tcell.Event) {
9+ switch e := ev.(type) {
10+ case *tcell.EventKey:
11+ v.handleKey(e)
12+ case *tcell.EventPaste:
13+ v.pasting = e.Start()
14+ if !v.pasting {
15+ v.finishPaste()
16+ }
17+ case *tcell.EventMouse:
18+ v.handleMouse(e)
19+ case *tcell.EventResize:
20+ v.screen.Sync()
21+ }
22+}
23+
24+// handleKey is the whole keymap. Order matters: a pending permission takes
25+// every key, then the picker, then the keys that mean the same thing
26+// whichever pane has the focus, then the pane's own.
27+func (v *View) handleKey(ev *tcell.EventKey) {
28+ before := v.Input()
29+ defer func() {
30+ if v.Input() != before && v.pick.dismissedFor != v.Input() {
31+ v.pick.dismiss("") // Escape's "not now" lasts until the text moves on
32+ }
33+ }()
34+
35+ if v.pasting {
36+ v.bufferPasteKey(ev)
37+ return
38+ }
39+ if v.ask != nil {
40+ v.answerPermission(ev)
41+ return
42+ }
43+ if v.handlePickerKey(ev) {
44+ return
45+ }
46+ if v.handleWindowKey(ev) {
47+ return
48+ }
49+ if v.onInput && v.handleInputKey(ev) {
50+ return
51+ }
52+ v.handleScrollKey(ev)
53+}
54+
55+// answerPermission maps a key onto the pending permission: a once, s for
56+// the session, r or Escape reject. Anything else is ignored — the box is
57+// not editable while the question stands.
58+func (v *View) answerPermission(ev *tcell.EventKey) {
59+ var answer permissionAnswer
60+ switch {
61+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 'a' || ev.Rune() == 'A'):
62+ answer = permAllowOnce
63+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 's' || ev.Rune() == 'S'):
64+ answer = permAllowSession
65+ case ev.Key() == tcell.KeyRune && (ev.Rune() == 'r' || ev.Rune() == 'R'), ev.Key() == tcell.KeyEscape:
66+ answer = permReject
67+ default:
68+ return
69+ }
70+ v.ask = nil
71+ if v.on.answer != nil {
72+ v.on.answer(answer)
73+ }
74+}
75+
76+// handlePickerKey drives the popup while it is open.
77+func (v *View) handlePickerKey(ev *tcell.EventKey) bool {
78+ if !v.pickerOpen() {
79+ return false
80+ }
81+ switch ev.Key() {
82+ case tcell.KeyUp:
83+ v.movePick(-1)
84+ case tcell.KeyDown:
85+ v.movePick(1)
86+ case tcell.KeyTab:
87+ v.complete()
88+ case tcell.KeyEnter:
89+ if v.typedIsComplete() {
90+ v.send()
91+ } else {
92+ v.complete()
93+ }
94+ case tcell.KeyEscape:
95+ v.pick.dismiss(v.input[0])
96+ default:
97+ return false
98+ }
99+ return true
100+}
101+
102+// handleWindowKey deals with the keys that mean the same thing whichever pane
103+// has the focus.
104+func (v *View) handleWindowKey(ev *tcell.EventKey) bool {
105+ switch {
106+ case ev.Key() == tcell.KeyCtrlC:
107+ // The REPL's meaning, kept: cancel a running turn, quit when idle.
108+ if v.busy == "" || !v.cancelTurn() {
109+ v.on.quit()
110+ }
111+ case ev.Key() == tcell.KeyEscape:
112+ v.cancelTurn()
113+ case ev.Key() == tcell.KeyCtrlL:
114+ v.clearScreen()
115+ case ev.Key() == tcell.KeyF1:
116+ if v.on.help != nil {
117+ v.on.help()
118+ }
119+ case ev.Key() == tcell.KeyF2:
120+ v.toggleMouse()
121+ case ev.Key() == tcell.KeyCtrlY:
122+ if v.on.copy != nil {
123+ v.on.copy()
124+ }
125+ case ev.Key() == tcell.KeyTab:
126+ v.onInput = !v.onInput
127+ case ev.Key() == tcell.KeyCtrlJ, ev.Key() == tcell.KeyEnter && ev.Modifiers()&tcell.ModAlt != 0:
128+ v.insertNewline()
129+ case ev.Key() == tcell.KeyEnter:
130+ v.send()
131+ case ev.Key() == tcell.KeyPgUp:
132+ v.scrollBy(-max(v.transcriptHeight()-1, 1))
133+ case ev.Key() == tcell.KeyPgDn:
134+ v.scrollBy(max(v.transcriptHeight()-1, 1))
135+ default:
136+ return false
137+ }
138+ return true
139+}
140+
141+// cancelTurn stops the turn in progress and reports whether there was one.
142+func (v *View) cancelTurn() bool {
143+ return v.on.cancel != nil && v.on.cancel()
144+}
145+
146+// handleInputKey edits what is being typed.
147+func (v *View) handleInputKey(ev *tcell.EventKey) bool {
148+ switch ev.Key() {
149+ case tcell.KeyRune:
150+ v.insert(ev.Rune())
151+ case tcell.KeyBackspace, tcell.KeyBackspace2:
152+ v.backspace()
153+ case tcell.KeyDelete:
154+ v.deleteRune()
155+ case tcell.KeyLeft:
156+ v.moveLeft()
157+ case tcell.KeyRight:
158+ v.moveRight()
159+ case tcell.KeyHome:
160+ v.column = 0
161+ case tcell.KeyEnd:
162+ v.column = len(v.runes())
163+ case tcell.KeyUp:
164+ if v.cursor == 0 {
165+ return false // scroll the conversation instead
166+ }
167+ v.cursor--
168+ v.clampColumn()
169+ case tcell.KeyDown:
170+ if v.cursor >= len(v.input)-1 {
171+ return false
172+ }
173+ v.cursor++
174+ v.clampColumn()
175+ default:
176+ return false
177+ }
178+ return true
179+}
180+
181+// handleScrollKey moves through the conversation: arrows one line, Home to
182+// the top, End to the bottom (which resumes following).
183+func (v *View) handleScrollKey(ev *tcell.EventKey) {
184+ switch ev.Key() {
185+ case tcell.KeyUp:
186+ v.scrollBy(-1)
187+ case tcell.KeyDown:
188+ v.scrollBy(1)
189+ case tcell.KeyHome:
190+ v.scroll, v.follow = 0, false
191+ case tcell.KeyEnd:
192+ v.follow = true
193+ }
194+}
195+
196+// handleMouse scrolls the conversation with the wheel.
197+func (v *View) handleMouse(ev *tcell.EventMouse) {
198+ switch ev.Buttons() {
199+ case tcell.WheelUp:
200+ v.scrollBy(-3)
201+ case tcell.WheelDown:
202+ v.scrollBy(3)
203+ }
204+}
205+
206+// --- editing the box -----------------------------------------------------------
207+
208+func (v *View) insert(r rune) {
209+ runes := v.runes()
210+ v.column = min(v.column, len(runes))
211+ updated := append([]rune{}, runes[:v.column]...)
212+ updated = append(updated, r)
213+ updated = append(updated, runes[v.column:]...)
214+ v.input[v.cursor] = string(updated)
215+ v.column++
216+}
217+
218+// insertNewline breaks the line the cursor is on: a second paragraph without
219+// sending the first.
220+func (v *View) insertNewline() {
221+ runes := v.runes()
222+ v.column = min(v.column, len(runes))
223+ before, after := string(runes[:v.column]), string(runes[v.column:])
224+ v.input[v.cursor] = before
225+ v.input = append(v.input, "")
226+ copy(v.input[v.cursor+2:], v.input[v.cursor+1:])
227+ v.input[v.cursor+1] = after
228+ v.cursor++
229+ v.column = 0
230+}
231+
232+// backspace removes the rune before the cursor, joining lines at a line
233+// start. A paste token goes whole, its text with it.
234+func (v *View) backspace() {
235+ if v.column > 0 {
236+ runes := v.runes()
237+ v.pastes.drop(runes[v.column-1])
238+ v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column-1]...), runes[v.column:]...))
239+ v.column--
240+ return
241+ }
242+ if v.cursor == 0 {
243+ return
244+ }
245+ above := []rune(v.input[v.cursor-1])
246+ v.column = len(above)
247+ v.input[v.cursor-1] = string(above) + v.input[v.cursor]
248+ v.input = append(v.input[:v.cursor], v.input[v.cursor+1:]...)
249+ v.cursor--
250+}
251+
252+// deleteRune removes the rune under the cursor — a whole paste token when
253+// that is what stands there.
254+func (v *View) deleteRune() {
255+ runes := v.runes()
256+ if v.column >= len(runes) {
257+ return
258+ }
259+ v.pastes.drop(runes[v.column])
260+ v.input[v.cursor] = string(append(append([]rune{}, runes[:v.column]...), runes[v.column+1:]...))
261+}
262+
263+func (v *View) moveLeft() {
264+ switch {
265+ case v.column > 0:
266+ v.column--
267+ case v.cursor > 0:
268+ v.cursor--
269+ v.column = len(v.runes())
270+ }
271+}
272+
273+func (v *View) moveRight() {
274+ switch {
275+ case v.column < len(v.runes()):
276+ v.column++
277+ case v.cursor < len(v.input)-1:
278+ v.cursor++
279+ v.column = 0
280+ }
281+}
282+
283+func (v *View) runes() []rune {
284+ if v.cursor >= len(v.input) {
285+ return nil
286+ }
287+ return []rune(v.input[v.cursor])
288+}
289+
290+func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) }
added internal/tui/painter.go +81 -0
new file mode 100644
@@ -0,0 +1,81 @@
1+package tui
2+
3+import "github.com/gdamore/tcell/v2"
4+
5+// rect is a region of the screen in absolute cells.
6+type rect struct{ x, y, w, h int }
7+
8+func (r rect) contains(x, y int) bool {
9+ return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
10+}
11+
12+// painter draws into one rect of the screen in LOCAL coordinates and clips
13+// to it, so a pane cannot paint over its neighbours. The model is Turbo's
14+// ui.Painter, reduced to what one window needs.
15+type painter struct {
16+ screen tcell.Screen
17+ area rect
18+}
19+
20+// sub returns a painter for a rect given in this painter's local coordinates.
21+func (p painter) sub(r rect) painter {
22+ abs := rect{x: p.area.x + r.x, y: p.area.y + r.y, w: r.w, h: r.h}
23+ // Clip to the parent.
24+ right := min(abs.x+abs.w, p.area.x+p.area.w)
25+ bottom := min(abs.y+abs.h, p.area.y+p.area.h)
26+ abs.w, abs.h = max(right-abs.x, 0), max(bottom-abs.y, 0)
27+ return painter{screen: p.screen, area: abs}
28+}
29+
30+func (p painter) width() int { return p.area.w }
31+func (p painter) height() int { return p.area.h }
32+
33+// set paints one cell, if it is inside the area.
34+func (p painter) set(x, y int, r rune, style tcell.Style) {
35+ ax, ay := p.area.x+x, p.area.y+y
36+ if !p.area.contains(ax, ay) {
37+ return
38+ }
39+ p.screen.SetContent(ax, ay, r, nil, style)
40+}
41+
42+// text writes a string from (x, y), clipped, and returns the next column.
43+func (p painter) text(x, y int, s string, style tcell.Style) int {
44+ for _, r := range s {
45+ p.set(x, y, r, style)
46+ x++
47+ }
48+ return x
49+}
50+
51+// textLimited writes at most width runes, marking a cut with an ellipsis.
52+func (p painter) textLimited(x, y, width int, s string, style tcell.Style) {
53+ p.text(x, y, clip(s, width), style)
54+}
55+
56+// fill paints the whole area with one rune and style.
57+func (p painter) fill(r rune, style tcell.Style) {
58+ for y := 0; y < p.area.h; y++ {
59+ for x := 0; x < p.area.w; x++ {
60+ p.set(x, y, r, style)
61+ }
62+ }
63+}
64+
65+// hline draws a horizontal line of width cells.
66+func (p painter) hline(x, y, width int, r rune, style tcell.Style) {
67+ for i := 0; i < width; i++ {
68+ p.set(x+i, y, r, style)
69+ }
70+}
71+
72+// showCursor places the terminal cursor at a local position, or hides it
73+// when the position is outside the area.
74+func (p painter) showCursor(x, y int) {
75+ ax, ay := p.area.x+x, p.area.y+y
76+ if !p.area.contains(ax, ay) {
77+ p.screen.HideCursor()
78+ return
79+ }
80+ p.screen.ShowCursor(ax, ay)
81+}
new file mode 100644
@@ -0,0 +1,81 @@
1+package tui
2+
3+import "github.com/gdamore/tcell/v2"
4+
5+// rect is a region of the screen in absolute cells.
6+type rect struct{ x, y, w, h int }
7+
8+func (r rect) contains(x, y int) bool {
9+ return x >= r.x && y >= r.y && x < r.x+r.w && y < r.y+r.h
10+}
11+
12+// painter draws into one rect of the screen in LOCAL coordinates and clips
13+// to it, so a pane cannot paint over its neighbours. The model is Turbo's
14+// ui.Painter, reduced to what one window needs.
15+type painter struct {
16+ screen tcell.Screen
17+ area rect
18+}
19+
20+// sub returns a painter for a rect given in this painter's local coordinates.
21+func (p painter) sub(r rect) painter {
22+ abs := rect{x: p.area.x + r.x, y: p.area.y + r.y, w: r.w, h: r.h}
23+ // Clip to the parent.
24+ right := min(abs.x+abs.w, p.area.x+p.area.w)
25+ bottom := min(abs.y+abs.h, p.area.y+p.area.h)
26+ abs.w, abs.h = max(right-abs.x, 0), max(bottom-abs.y, 0)
27+ return painter{screen: p.screen, area: abs}
28+}
29+
30+func (p painter) width() int { return p.area.w }
31+func (p painter) height() int { return p.area.h }
32+
33+// set paints one cell, if it is inside the area.
34+func (p painter) set(x, y int, r rune, style tcell.Style) {
35+ ax, ay := p.area.x+x, p.area.y+y
36+ if !p.area.contains(ax, ay) {
37+ return
38+ }
39+ p.screen.SetContent(ax, ay, r, nil, style)
40+}
41+
42+// text writes a string from (x, y), clipped, and returns the next column.
43+func (p painter) text(x, y int, s string, style tcell.Style) int {
44+ for _, r := range s {
45+ p.set(x, y, r, style)
46+ x++
47+ }
48+ return x
49+}
50+
51+// textLimited writes at most width runes, marking a cut with an ellipsis.
52+func (p painter) textLimited(x, y, width int, s string, style tcell.Style) {
53+ p.text(x, y, clip(s, width), style)
54+}
55+
56+// fill paints the whole area with one rune and style.
57+func (p painter) fill(r rune, style tcell.Style) {
58+ for y := 0; y < p.area.h; y++ {
59+ for x := 0; x < p.area.w; x++ {
60+ p.set(x, y, r, style)
61+ }
62+ }
63+}
64+
65+// hline draws a horizontal line of width cells.
66+func (p painter) hline(x, y, width int, r rune, style tcell.Style) {
67+ for i := 0; i < width; i++ {
68+ p.set(x+i, y, r, style)
69+ }
70+}
71+
72+// showCursor places the terminal cursor at a local position, or hides it
73+// when the position is outside the area.
74+func (p painter) showCursor(x, y int) {
75+ ax, ay := p.area.x+x, p.area.y+y
76+ if !p.area.contains(ax, ay) {
77+ p.screen.HideCursor()
78+ return
79+ }
80+ p.screen.ShowCursor(ax, ay)
81+}
added internal/tui/paste.go +204 -0
new file mode 100644
@@ -0,0 +1,204 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/compact"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// Long pastes. A block pasted into a three-row box is unreadable there and
13+// hides what was typed around it, so past a threshold the box shows one token
14+// — `[Pasted #1 · 42 lines · 1.2k chars]` — and keeps the text aside. The
15+// token is a single rune in the input, so the cursor, Backspace and Delete
16+// treat it as one character for free. On Enter the token is replaced by the
17+// whole text, byte for byte: the model reads what was pasted, the screen
18+// shows what was typed.
19+
20+// A paste is collapsed when it has MORE lines than the box has rows, or more
21+// characters than fit a sentence. Smaller pastes are inserted as text.
22+const (
23+ pasteCollapseLines = inputHeight
24+ pasteCollapseChars = 200
25+)
26+
27+// pasteBase is the first placeholder rune: Supplementary Private Use
28+// Area-A, which no keyboard produces and no font shows as text.
29+const pasteBase rune = 0xF0000
30+
31+// paste is one collapsed block: its number in this message and its text.
32+type paste struct {
33+ id int
34+ text string
35+}
36+
37+// placeholder is the rune standing for this paste in the input.
38+func (p paste) placeholder() rune { return pasteBase + rune(p.id) }
39+
40+// lines is how many lines the pasted text spans.
41+func (p paste) lines() int { return strings.Count(p.text, "\n") + 1 }
42+
43+// label is what the box and the conversation show in the token's place.
44+func (p paste) label() string {
45+ return fmt.Sprintf("[Pasted #%d · %d lines · %s chars]", p.id, p.lines(), compact.Kilo(len([]rune(p.text))))
46+}
47+
48+// isPlaceholder reports whether r stands for a paste.
49+func isPlaceholder(r rune) bool { return r >= pasteBase && r < pasteBase+0x10000 }
50+
51+// shouldCollapse says whether a paste is too long for the box.
52+func shouldCollapse(text string) bool {
53+ return strings.Count(text, "\n")+1 > pasteCollapseLines || len([]rune(text)) > pasteCollapseChars
54+}
55+
56+// pastes is the box's collapsed blocks, by placeholder, plus the paste in
57+// progress: bracketed paste arrives as a start mark, the keys, an end mark,
58+// and the keys are held here until the end mark says how long it all was.
59+type pastes struct {
60+ blocks map[rune]paste
61+ next int
62+ buf []rune
63+}
64+
65+// add keeps a block and returns its placeholder. Numbers count up within one
66+// message and start again once it is sent.
67+func (ps *pastes) add(text string) rune {
68+ if ps.blocks == nil {
69+ ps.blocks = map[rune]paste{}
70+ }
71+ ps.next++
72+ p := paste{id: ps.next, text: text}
73+ ps.blocks[p.placeholder()] = p
74+ return p.placeholder()
75+}
76+
77+// drop forgets the block a deleted placeholder stood for.
78+func (ps *pastes) drop(r rune) { delete(ps.blocks, r) }
79+
80+// prune forgets every block whose placeholder is no longer in text — the box
81+// was replaced wholesale (a picker completion, a send).
82+func (ps *pastes) prune(text string) {
83+ for r := range ps.blocks {
84+ if !strings.ContainsRune(text, r) {
85+ delete(ps.blocks, r)
86+ }
87+ }
88+ if len(ps.blocks) == 0 {
89+ ps.next = 0
90+ }
91+}
92+
93+// labelOf is the token text for a placeholder, if it is one.
94+func (ps *pastes) labelOf(r rune) (string, bool) {
95+ p, ok := ps.blocks[r]
96+ if !ok {
97+ return "", false
98+ }
99+ return p.label(), true
100+}
101+
102+// Prompt is what Enter hands the front end: the text as typed, placeholders
103+// included, and the pastes behind them. Text is what the model reads,
104+// Display what the conversation shows.
105+//
106+// p := promptOf("explain this") // no paste: both are the same
107+// p.Text() // "explain this"
108+// p.Display() // "explain this"
109+type Prompt struct {
110+ Typed string
111+ Pastes map[rune]paste
112+}
113+
114+// promptOf is a Prompt with no paste: a slash command, a plain question, a
115+// test's input.
116+func promptOf(text string) Prompt { return Prompt{Typed: text} }
117+
118+// snapshot is the Prompt for the current box: the blocks are copied so the
119+// box can be cleared at once.
120+func (ps *pastes) snapshot(typed string) Prompt {
121+ p := Prompt{Typed: typed}
122+ if len(ps.blocks) > 0 {
123+ p.Pastes = make(map[rune]paste, len(ps.blocks))
124+ for r, b := range ps.blocks {
125+ p.Pastes[r] = b
126+ }
127+ }
128+ return p
129+}
130+
131+// Text is the typed text with every placeholder replaced by its paste,
132+// verbatim — newlines and all.
133+func (p Prompt) Text() string {
134+ return p.Expand(p.Typed)
135+}
136+
137+// Display is the typed text with every placeholder replaced by its label:
138+// what the conversation shows for the question.
139+func (p Prompt) Display() string {
140+ return p.replace(p.Typed, func(b paste) string { return b.label() })
141+}
142+
143+// Expand replaces the placeholders in any text derived from Typed — the
144+// output of mention.Expand, say — by their pastes.
145+func (p Prompt) Expand(text string) string {
146+ return p.replace(text, func(b paste) string { return b.text })
147+}
148+
149+// HasPastes says whether the prompt carries a collapsed block.
150+func (p Prompt) HasPastes() bool { return len(p.Pastes) > 0 }
151+
152+func (p Prompt) replace(text string, with func(paste) string) string {
153+ if len(p.Pastes) == 0 {
154+ return text
155+ }
156+ var b strings.Builder
157+ for _, r := range text {
158+ if block, ok := p.Pastes[r]; ok {
159+ b.WriteString(with(block))
160+ continue
161+ }
162+ b.WriteRune(r)
163+ }
164+ return b.String()
165+}
166+
167+// --- the view's side: buffering a paste, inserting it ---------------------------------
168+
169+// bufferPasteKey holds one key of a paste in progress. The terminal sends
170+// the pasted text as ordinary keys between the two paste marks; they are
171+// kept aside until the end mark, when the whole block is known and can be
172+// inserted as text or collapsed into one token (paste.go). A pasted tab
173+// stays a tab: it must not move the focus.
174+func (v *View) bufferPasteKey(ev *tcell.EventKey) {
175+ switch ev.Key() {
176+ case tcell.KeyRune:
177+ v.pastes.buf = append(v.pastes.buf, ev.Rune())
178+ case tcell.KeyEnter:
179+ v.pastes.buf = append(v.pastes.buf, '\n')
180+ case tcell.KeyTab:
181+ v.pastes.buf = append(v.pastes.buf, '\t')
182+ }
183+}
184+
185+// finishPaste inserts the block a paste delivered: as text when it fits the
186+// box, as one token when it does not. Either way nothing is sent.
187+func (v *View) finishPaste() {
188+ text := string(v.pastes.buf)
189+ v.pastes.buf = nil
190+ if text == "" {
191+ return
192+ }
193+ if shouldCollapse(text) {
194+ v.insert(v.pastes.add(text))
195+ return
196+ }
197+ for _, r := range text {
198+ if r == '\n' {
199+ v.insertNewline()
200+ continue
201+ }
202+ v.insert(r)
203+ }
204+}
new file mode 100644
@@ -0,0 +1,204 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "mm/internal/compact"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// Long pastes. A block pasted into a three-row box is unreadable there and
13+// hides what was typed around it, so past a threshold the box shows one token
14+// — `[Pasted #1 · 42 lines · 1.2k chars]` — and keeps the text aside. The
15+// token is a single rune in the input, so the cursor, Backspace and Delete
16+// treat it as one character for free. On Enter the token is replaced by the
17+// whole text, byte for byte: the model reads what was pasted, the screen
18+// shows what was typed.
19+
20+// A paste is collapsed when it has MORE lines than the box has rows, or more
21+// characters than fit a sentence. Smaller pastes are inserted as text.
22+const (
23+ pasteCollapseLines = inputHeight
24+ pasteCollapseChars = 200
25+)
26+
27+// pasteBase is the first placeholder rune: Supplementary Private Use
28+// Area-A, which no keyboard produces and no font shows as text.
29+const pasteBase rune = 0xF0000
30+
31+// paste is one collapsed block: its number in this message and its text.
32+type paste struct {
33+ id int
34+ text string
35+}
36+
37+// placeholder is the rune standing for this paste in the input.
38+func (p paste) placeholder() rune { return pasteBase + rune(p.id) }
39+
40+// lines is how many lines the pasted text spans.
41+func (p paste) lines() int { return strings.Count(p.text, "\n") + 1 }
42+
43+// label is what the box and the conversation show in the token's place.
44+func (p paste) label() string {
45+ return fmt.Sprintf("[Pasted #%d · %d lines · %s chars]", p.id, p.lines(), compact.Kilo(len([]rune(p.text))))
46+}
47+
48+// isPlaceholder reports whether r stands for a paste.
49+func isPlaceholder(r rune) bool { return r >= pasteBase && r < pasteBase+0x10000 }
50+
51+// shouldCollapse says whether a paste is too long for the box.
52+func shouldCollapse(text string) bool {
53+ return strings.Count(text, "\n")+1 > pasteCollapseLines || len([]rune(text)) > pasteCollapseChars
54+}
55+
56+// pastes is the box's collapsed blocks, by placeholder, plus the paste in
57+// progress: bracketed paste arrives as a start mark, the keys, an end mark,
58+// and the keys are held here until the end mark says how long it all was.
59+type pastes struct {
60+ blocks map[rune]paste
61+ next int
62+ buf []rune
63+}
64+
65+// add keeps a block and returns its placeholder. Numbers count up within one
66+// message and start again once it is sent.
67+func (ps *pastes) add(text string) rune {
68+ if ps.blocks == nil {
69+ ps.blocks = map[rune]paste{}
70+ }
71+ ps.next++
72+ p := paste{id: ps.next, text: text}
73+ ps.blocks[p.placeholder()] = p
74+ return p.placeholder()
75+}
76+
77+// drop forgets the block a deleted placeholder stood for.
78+func (ps *pastes) drop(r rune) { delete(ps.blocks, r) }
79+
80+// prune forgets every block whose placeholder is no longer in text — the box
81+// was replaced wholesale (a picker completion, a send).
82+func (ps *pastes) prune(text string) {
83+ for r := range ps.blocks {
84+ if !strings.ContainsRune(text, r) {
85+ delete(ps.blocks, r)
86+ }
87+ }
88+ if len(ps.blocks) == 0 {
89+ ps.next = 0
90+ }
91+}
92+
93+// labelOf is the token text for a placeholder, if it is one.
94+func (ps *pastes) labelOf(r rune) (string, bool) {
95+ p, ok := ps.blocks[r]
96+ if !ok {
97+ return "", false
98+ }
99+ return p.label(), true
100+}
101+
102+// Prompt is what Enter hands the front end: the text as typed, placeholders
103+// included, and the pastes behind them. Text is what the model reads,
104+// Display what the conversation shows.
105+//
106+// p := promptOf("explain this") // no paste: both are the same
107+// p.Text() // "explain this"
108+// p.Display() // "explain this"
109+type Prompt struct {
110+ Typed string
111+ Pastes map[rune]paste
112+}
113+
114+// promptOf is a Prompt with no paste: a slash command, a plain question, a
115+// test's input.
116+func promptOf(text string) Prompt { return Prompt{Typed: text} }
117+
118+// snapshot is the Prompt for the current box: the blocks are copied so the
119+// box can be cleared at once.
120+func (ps *pastes) snapshot(typed string) Prompt {
121+ p := Prompt{Typed: typed}
122+ if len(ps.blocks) > 0 {
123+ p.Pastes = make(map[rune]paste, len(ps.blocks))
124+ for r, b := range ps.blocks {
125+ p.Pastes[r] = b
126+ }
127+ }
128+ return p
129+}
130+
131+// Text is the typed text with every placeholder replaced by its paste,
132+// verbatim — newlines and all.
133+func (p Prompt) Text() string {
134+ return p.Expand(p.Typed)
135+}
136+
137+// Display is the typed text with every placeholder replaced by its label:
138+// what the conversation shows for the question.
139+func (p Prompt) Display() string {
140+ return p.replace(p.Typed, func(b paste) string { return b.label() })
141+}
142+
143+// Expand replaces the placeholders in any text derived from Typed — the
144+// output of mention.Expand, say — by their pastes.
145+func (p Prompt) Expand(text string) string {
146+ return p.replace(text, func(b paste) string { return b.text })
147+}
148+
149+// HasPastes says whether the prompt carries a collapsed block.
150+func (p Prompt) HasPastes() bool { return len(p.Pastes) > 0 }
151+
152+func (p Prompt) replace(text string, with func(paste) string) string {
153+ if len(p.Pastes) == 0 {
154+ return text
155+ }
156+ var b strings.Builder
157+ for _, r := range text {
158+ if block, ok := p.Pastes[r]; ok {
159+ b.WriteString(with(block))
160+ continue
161+ }
162+ b.WriteRune(r)
163+ }
164+ return b.String()
165+}
166+
167+// --- the view's side: buffering a paste, inserting it ---------------------------------
168+
169+// bufferPasteKey holds one key of a paste in progress. The terminal sends
170+// the pasted text as ordinary keys between the two paste marks; they are
171+// kept aside until the end mark, when the whole block is known and can be
172+// inserted as text or collapsed into one token (paste.go). A pasted tab
173+// stays a tab: it must not move the focus.
174+func (v *View) bufferPasteKey(ev *tcell.EventKey) {
175+ switch ev.Key() {
176+ case tcell.KeyRune:
177+ v.pastes.buf = append(v.pastes.buf, ev.Rune())
178+ case tcell.KeyEnter:
179+ v.pastes.buf = append(v.pastes.buf, '\n')
180+ case tcell.KeyTab:
181+ v.pastes.buf = append(v.pastes.buf, '\t')
182+ }
183+}
184+
185+// finishPaste inserts the block a paste delivered: as text when it fits the
186+// box, as one token when it does not. Either way nothing is sent.
187+func (v *View) finishPaste() {
188+ text := string(v.pastes.buf)
189+ v.pastes.buf = nil
190+ if text == "" {
191+ return
192+ }
193+ if shouldCollapse(text) {
194+ v.insert(v.pastes.add(text))
195+ return
196+ }
197+ for _, r := range text {
198+ if r == '\n' {
199+ v.insertNewline()
200+ continue
201+ }
202+ v.insert(r)
203+ }
204+}
added internal/tui/picker.go +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+package tui
2+
3+import (
4+ "strings"
5+
6+ "mm/internal/command"
7+)
8+
9+// pickerRows is how many choices the popup shows at most.
10+const pickerRows = 8
11+
12+// picker is the popup that opens over the conversation while a "/" command
13+// is being typed at the very start of the box: it lists the window's
14+// commands, narrowed by what has been typed, and completes the chosen one.
15+// Escape dismisses it until the text changes.
16+type picker struct {
17+ selected int
18+ dismissedFor string // the input text Escape was pressed on; "" = not dismissed
19+ dismissed bool
20+}
21+
22+// choices returns the commands the picker offers for what is typed: the
23+// window's commands whose name starts with the word after the slash. Nothing
24+// when the box holds more than one line, does not start with "/", already
25+// has a space (the command is complete), or was dismissed on this text.
26+func (v *View) choices() []command.Command {
27+ if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") {
28+ return nil
29+ }
30+ if v.pick.dismissed && v.pick.dismissedFor == v.input[0] {
31+ return nil
32+ }
33+ word := strings.TrimPrefix(v.input[0], "/")
34+ var out []command.Command
35+ for _, c := range command.For(command.Window) {
36+ if strings.HasPrefix(c.Name, word) {
37+ out = append(out, c)
38+ }
39+ }
40+ return out
41+}
42+
43+// pickerOpen reports whether the popup is showing.
44+func (v *View) pickerOpen() bool { return len(v.choices()) > 0 }
45+
46+// dismiss closes the picker for the given text (Escape), or resets it.
47+func (p *picker) dismiss(text string) {
48+ p.dismissed = text != ""
49+ p.dismissedFor = text
50+ p.selected = 0
51+}
52+
53+// movePick moves the selection, wrapping around.
54+func (v *View) movePick(by int) {
55+ n := len(v.choices())
56+ if n == 0 {
57+ return
58+ }
59+ v.pick.selected = ((v.pick.selected+by)%n + n) % n
60+}
61+
62+// selectedChoice is the highlighted command, if the popup is open.
63+func (v *View) selectedChoice() (command.Command, bool) {
64+ cs := v.choices()
65+ if len(cs) == 0 {
66+ return command.Command{}, false
67+ }
68+ return cs[min(v.pick.selected, len(cs)-1)], true
69+}
70+
71+// complete puts the highlighted command in the box, with a space after it
72+// when it takes an argument, and closes the popup. Enter on a command that is
73+// already complete sends it instead: see events.go.
74+func (v *View) complete() {
75+ c, ok := v.selectedChoice()
76+ if !ok {
77+ return
78+ }
79+ text := c.Slash()
80+ if c.TakesArg {
81+ text += " "
82+ }
83+ v.setInput(text)
84+ v.pick.dismiss(text)
85+}
86+
87+// typedIsComplete reports whether the box holds exactly the highlighted
88+// command's name: Enter then means "send", not "complete".
89+func (v *View) typedIsComplete() bool {
90+ c, ok := v.selectedChoice()
91+ return ok && v.input[0] == c.Slash()
92+}
new file mode 100644
@@ -0,0 +1,92 @@
1+package tui
2+
3+import (
4+ "strings"
5+
6+ "mm/internal/command"
7+)
8+
9+// pickerRows is how many choices the popup shows at most.
10+const pickerRows = 8
11+
12+// picker is the popup that opens over the conversation while a "/" command
13+// is being typed at the very start of the box: it lists the window's
14+// commands, narrowed by what has been typed, and completes the chosen one.
15+// Escape dismisses it until the text changes.
16+type picker struct {
17+ selected int
18+ dismissedFor string // the input text Escape was pressed on; "" = not dismissed
19+ dismissed bool
20+}
21+
22+// choices returns the commands the picker offers for what is typed: the
23+// window's commands whose name starts with the word after the slash. Nothing
24+// when the box holds more than one line, does not start with "/", already
25+// has a space (the command is complete), or was dismissed on this text.
26+func (v *View) choices() []command.Command {
27+ if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") {
28+ return nil
29+ }
30+ if v.pick.dismissed && v.pick.dismissedFor == v.input[0] {
31+ return nil
32+ }
33+ word := strings.TrimPrefix(v.input[0], "/")
34+ var out []command.Command
35+ for _, c := range command.For(command.Window) {
36+ if strings.HasPrefix(c.Name, word) {
37+ out = append(out, c)
38+ }
39+ }
40+ return out
41+}
42+
43+// pickerOpen reports whether the popup is showing.
44+func (v *View) pickerOpen() bool { return len(v.choices()) > 0 }
45+
46+// dismiss closes the picker for the given text (Escape), or resets it.
47+func (p *picker) dismiss(text string) {
48+ p.dismissed = text != ""
49+ p.dismissedFor = text
50+ p.selected = 0
51+}
52+
53+// movePick moves the selection, wrapping around.
54+func (v *View) movePick(by int) {
55+ n := len(v.choices())
56+ if n == 0 {
57+ return
58+ }
59+ v.pick.selected = ((v.pick.selected+by)%n + n) % n
60+}
61+
62+// selectedChoice is the highlighted command, if the popup is open.
63+func (v *View) selectedChoice() (command.Command, bool) {
64+ cs := v.choices()
65+ if len(cs) == 0 {
66+ return command.Command{}, false
67+ }
68+ return cs[min(v.pick.selected, len(cs)-1)], true
69+}
70+
71+// complete puts the highlighted command in the box, with a space after it
72+// when it takes an argument, and closes the popup. Enter on a command that is
73+// already complete sends it instead: see events.go.
74+func (v *View) complete() {
75+ c, ok := v.selectedChoice()
76+ if !ok {
77+ return
78+ }
79+ text := c.Slash()
80+ if c.TakesArg {
81+ text += " "
82+ }
83+ v.setInput(text)
84+ v.pick.dismiss(text)
85+}
86+
87+// typedIsComplete reports whether the box holds exactly the highlighted
88+// command's name: Enter then means "send", not "complete".
89+func (v *View) typedIsComplete() bool {
90+ c, ok := v.selectedChoice()
91+ return ok && v.input[0] == c.Slash()
92+}
added internal/tui/render.go +190 -0
new file mode 100644
@@ -0,0 +1,190 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+)
8+
9+// styleKind names how a laid-out line is coloured; the screen maps it to a
10+// tcell.Style. Names, not styles, so layout is testable by comparing values.
11+type styleKind int
12+
13+const (
14+ styleBody styleKind = iota
15+ styleLabel
16+ styleTool
17+ styleToolFailed
18+ styleOutput
19+ styleNotice
20+ styleError
21+ styleCode
22+ styleDiffAdd
23+ styleDiffDel
24+)
25+
26+// Line is one drawn line of the conversation: its text, wrapped to the width
27+// asked for, and how to colour it.
28+type Line struct {
29+ Text string
30+ Style styleKind
31+}
32+
33+// indent is the margin content lines are drawn at, under their label.
34+const indent = 2
35+
36+// pad is that margin as text.
37+var pad = strings.Repeat(" ", indent)
38+
39+// layout lays a whole conversation out at a width, ready to draw: prose is
40+// wrapped, fenced code is not (editors clip code, they do not reflow it), and
41+// a tool's output is folded to previewLines lines. agentName labels the
42+// answers; previewLines <= 0 shows every line of output.
43+func layout(entries []Entry, agentName string, width, previewLines int) []Line {
44+ var out []Line
45+ for i, e := range entries {
46+ if i > 0 {
47+ out = append(out, Line{})
48+ }
49+ out = append(out, linesOf(e, agentName, width, previewLines)...)
50+ }
51+ return out
52+}
53+
54+// linesOf lays one entry out. One switch so a reader can see every kind is
55+// covered.
56+func linesOf(e Entry, agentName string, width, previewLines int) []Line {
57+ switch e.Kind {
58+ case KindUser:
59+ return append(label("you"), prose(e.Text, width, styleBody)...)
60+ case KindAgent:
61+ return append(label(agentName), body(e.Text, width)...)
62+ case KindTool:
63+ return toolLines(e, width, previewLines)
64+ case KindError:
65+ return prose(e.Text, width, styleError)
66+ default:
67+ return prose(e.Text, width, styleNotice)
68+ }
69+}
70+
71+// label is the one line naming who is speaking.
72+func label(who string) []Line {
73+ return []Line{{Text: "‣ " + who, Style: styleLabel}}
74+}
75+
76+// prose wraps plain text at one style, indented.
77+func prose(text string, width int, style styleKind) []Line {
78+ var out []Line
79+ for _, line := range wrap(text, max(width-indent, 1)) {
80+ out = append(out, Line{Text: pad + line, Style: style})
81+ }
82+ return out
83+}
84+
85+// body lays out an answer: prose wrapped, fenced blocks drawn as code.
86+func body(text string, width int) []Line {
87+ var out []Line
88+ inFence := false
89+ var para []string
90+ flush := func() {
91+ if len(para) > 0 {
92+ out = append(out, prose(strings.Join(para, "\n"), width, styleBody)...)
93+ para = nil
94+ }
95+ }
96+ for _, line := range strings.Split(text, "\n") {
97+ if strings.HasPrefix(strings.TrimSpace(line), "```") {
98+ flush()
99+ inFence = !inFence
100+ out = append(out, Line{Text: pad + strings.TrimSpace(line), Style: styleCode})
101+ continue
102+ }
103+ if inFence {
104+ out = append(out, Line{Text: pad + line, Style: styleCode})
105+ continue
106+ }
107+ para = append(para, line)
108+ }
109+ flush()
110+ return out
111+}
112+
113+// toolLines draw a tool call: what it was, how it went, and what came back,
114+// folded to previewLines lines.
115+func toolLines(e Entry, width, previewLines int) []Line {
116+ style := styleTool
117+ if e.Failed {
118+ style = styleToolFailed
119+ }
120+ heading := "‣ " + e.Tool
121+ if d := e.Detail(); d != "" {
122+ heading += " " + oneLine(d)
123+ }
124+ heading += " " + statusMark(e.Status)
125+ if e.Elapsed > 0 && (e.Status == StatusDone || e.Status == StatusFailed) {
126+ heading += " " + elapsed(e.Elapsed)
127+ }
128+ out := []Line{{Text: clip(heading, width), Style: style}}
129+ if e.Text == "" {
130+ return out
131+ }
132+ lines := strings.Split(e.Text, "\n")
133+ hidden := 0
134+ if previewLines > 0 && len(lines) > previewLines {
135+ hidden = len(lines) - previewLines
136+ lines = lines[:previewLines]
137+ }
138+ for _, l := range lines {
139+ out = append(out, Line{Text: pad + clip(l, max(width-indent, 1)), Style: outputStyle(e, l)})
140+ }
141+ if hidden > 0 {
142+ out = append(out, Line{Text: pad + fmt.Sprintf("… %d more line(s)", hidden), Style: styleNotice})
143+ }
144+ return out
145+}
146+
147+// outputStyle colours a line of tool output: the +/- lines of a file diff,
148+// the rest in comment style.
149+func outputStyle(e Entry, line string) styleKind {
150+ if e.Diff {
151+ switch {
152+ case strings.HasPrefix(line, "+"):
153+ return styleDiffAdd
154+ case strings.HasPrefix(line, "-"):
155+ return styleDiffDel
156+ }
157+ }
158+ return styleOutput
159+}
160+
161+// statusMark is the short sign of how a tool call is going — a word beside
162+// the symbol, because ✓ and ✗ are one cell apart at a glance.
163+func statusMark(status string) string {
164+ switch status {
165+ case StatusDone:
166+ return "✓ done"
167+ case StatusFailed:
168+ return "✗ failed"
169+ case StatusRunning:
170+ return "… running"
171+ case StatusWaiting:
172+ return "… waiting for permission"
173+ default:
174+ return "… pending"
175+ }
176+}
177+
178+// elapsed prints a duration the way the spinner does: tenths under ten
179+// seconds, whole seconds above.
180+func elapsed(d time.Duration) string {
181+ if d < 10*time.Second {
182+ return fmt.Sprintf("%.2fs", d.Seconds())
183+ }
184+ return fmt.Sprintf("%ds", int(d.Seconds()))
185+}
186+
187+// oneLine folds a multi-line detail (a heredoc) onto one line.
188+func oneLine(s string) string {
189+ return strings.Join(strings.Fields(s), " ")
190+}
new file mode 100644
@@ -0,0 +1,190 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "time"
7+)
8+
9+// styleKind names how a laid-out line is coloured; the screen maps it to a
10+// tcell.Style. Names, not styles, so layout is testable by comparing values.
11+type styleKind int
12+
13+const (
14+ styleBody styleKind = iota
15+ styleLabel
16+ styleTool
17+ styleToolFailed
18+ styleOutput
19+ styleNotice
20+ styleError
21+ styleCode
22+ styleDiffAdd
23+ styleDiffDel
24+)
25+
26+// Line is one drawn line of the conversation: its text, wrapped to the width
27+// asked for, and how to colour it.
28+type Line struct {
29+ Text string
30+ Style styleKind
31+}
32+
33+// indent is the margin content lines are drawn at, under their label.
34+const indent = 2
35+
36+// pad is that margin as text.
37+var pad = strings.Repeat(" ", indent)
38+
39+// layout lays a whole conversation out at a width, ready to draw: prose is
40+// wrapped, fenced code is not (editors clip code, they do not reflow it), and
41+// a tool's output is folded to previewLines lines. agentName labels the
42+// answers; previewLines <= 0 shows every line of output.
43+func layout(entries []Entry, agentName string, width, previewLines int) []Line {
44+ var out []Line
45+ for i, e := range entries {
46+ if i > 0 {
47+ out = append(out, Line{})
48+ }
49+ out = append(out, linesOf(e, agentName, width, previewLines)...)
50+ }
51+ return out
52+}
53+
54+// linesOf lays one entry out. One switch so a reader can see every kind is
55+// covered.
56+func linesOf(e Entry, agentName string, width, previewLines int) []Line {
57+ switch e.Kind {
58+ case KindUser:
59+ return append(label("you"), prose(e.Text, width, styleBody)...)
60+ case KindAgent:
61+ return append(label(agentName), body(e.Text, width)...)
62+ case KindTool:
63+ return toolLines(e, width, previewLines)
64+ case KindError:
65+ return prose(e.Text, width, styleError)
66+ default:
67+ return prose(e.Text, width, styleNotice)
68+ }
69+}
70+
71+// label is the one line naming who is speaking.
72+func label(who string) []Line {
73+ return []Line{{Text: "‣ " + who, Style: styleLabel}}
74+}
75+
76+// prose wraps plain text at one style, indented.
77+func prose(text string, width int, style styleKind) []Line {
78+ var out []Line
79+ for _, line := range wrap(text, max(width-indent, 1)) {
80+ out = append(out, Line{Text: pad + line, Style: style})
81+ }
82+ return out
83+}
84+
85+// body lays out an answer: prose wrapped, fenced blocks drawn as code.
86+func body(text string, width int) []Line {
87+ var out []Line
88+ inFence := false
89+ var para []string
90+ flush := func() {
91+ if len(para) > 0 {
92+ out = append(out, prose(strings.Join(para, "\n"), width, styleBody)...)
93+ para = nil
94+ }
95+ }
96+ for _, line := range strings.Split(text, "\n") {
97+ if strings.HasPrefix(strings.TrimSpace(line), "```") {
98+ flush()
99+ inFence = !inFence
100+ out = append(out, Line{Text: pad + strings.TrimSpace(line), Style: styleCode})
101+ continue
102+ }
103+ if inFence {
104+ out = append(out, Line{Text: pad + line, Style: styleCode})
105+ continue
106+ }
107+ para = append(para, line)
108+ }
109+ flush()
110+ return out
111+}
112+
113+// toolLines draw a tool call: what it was, how it went, and what came back,
114+// folded to previewLines lines.
115+func toolLines(e Entry, width, previewLines int) []Line {
116+ style := styleTool
117+ if e.Failed {
118+ style = styleToolFailed
119+ }
120+ heading := "‣ " + e.Tool
121+ if d := e.Detail(); d != "" {
122+ heading += " " + oneLine(d)
123+ }
124+ heading += " " + statusMark(e.Status)
125+ if e.Elapsed > 0 && (e.Status == StatusDone || e.Status == StatusFailed) {
126+ heading += " " + elapsed(e.Elapsed)
127+ }
128+ out := []Line{{Text: clip(heading, width), Style: style}}
129+ if e.Text == "" {
130+ return out
131+ }
132+ lines := strings.Split(e.Text, "\n")
133+ hidden := 0
134+ if previewLines > 0 && len(lines) > previewLines {
135+ hidden = len(lines) - previewLines
136+ lines = lines[:previewLines]
137+ }
138+ for _, l := range lines {
139+ out = append(out, Line{Text: pad + clip(l, max(width-indent, 1)), Style: outputStyle(e, l)})
140+ }
141+ if hidden > 0 {
142+ out = append(out, Line{Text: pad + fmt.Sprintf("… %d more line(s)", hidden), Style: styleNotice})
143+ }
144+ return out
145+}
146+
147+// outputStyle colours a line of tool output: the +/- lines of a file diff,
148+// the rest in comment style.
149+func outputStyle(e Entry, line string) styleKind {
150+ if e.Diff {
151+ switch {
152+ case strings.HasPrefix(line, "+"):
153+ return styleDiffAdd
154+ case strings.HasPrefix(line, "-"):
155+ return styleDiffDel
156+ }
157+ }
158+ return styleOutput
159+}
160+
161+// statusMark is the short sign of how a tool call is going — a word beside
162+// the symbol, because ✓ and ✗ are one cell apart at a glance.
163+func statusMark(status string) string {
164+ switch status {
165+ case StatusDone:
166+ return "✓ done"
167+ case StatusFailed:
168+ return "✗ failed"
169+ case StatusRunning:
170+ return "… running"
171+ case StatusWaiting:
172+ return "… waiting for permission"
173+ default:
174+ return "… pending"
175+ }
176+}
177+
178+// elapsed prints a duration the way the spinner does: tenths under ten
179+// seconds, whole seconds above.
180+func elapsed(d time.Duration) string {
181+ if d < 10*time.Second {
182+ return fmt.Sprintf("%.2fs", d.Seconds())
183+ }
184+ return fmt.Sprintf("%ds", int(d.Seconds()))
185+}
186+
187+// oneLine folds a multi-line detail (a heredoc) onto one line.
188+func oneLine(s string) string {
189+ return strings.Join(strings.Fields(s), " ")
190+}
added internal/tui/sink.go +61 -0
new file mode 100644
@@ -0,0 +1,61 @@
1+package tui
2+
3+import (
4+ "context"
5+
6+ "mm/internal/config"
7+ "mm/internal/ui"
8+)
9+
10+// sink is the window's ui.Sink: the tools' events become transcript entries
11+// and a wake-up, from whatever goroutine they arrive on. Allow is the one
12+// event with an answer: with `permissions: ask` the tool waits for the user
13+// (a/s/r in the box); with `auto`, today's terminal behaviour, it runs.
14+type sink struct{ r *runner }
15+
16+var _ ui.Sink = (*sink)(nil)
17+
18+func (k *sink) Text(text string) {
19+ k.r.streamed.Store(true)
20+ k.r.t.Chunk(text)
21+ k.r.wake()
22+}
23+
24+func (k *sink) ToolStart(ev ui.ToolEvent) {
25+ k.r.t.ToolStart(ev)
26+ k.r.wake()
27+}
28+
29+// Allow gates bash and the file tools. read_skill never asks (it reads what
30+// the agent ships — the tools skip Allow for it). "Allow for this session"
31+// is remembered per tool, as the ACP façade does.
32+func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool {
33+ if config.Cfg.Permissions != config.PermissionsAsk || k.r.allowAlways[ev.Tool] {
34+ return true
35+ }
36+ k.r.t.SetStatus(ev.ID, StatusWaiting)
37+ k.r.wake()
38+ switch k.r.request(ctx, ev) {
39+ case permAllowOnce:
40+ return true
41+ case permAllowSession:
42+ k.r.allowAlways[ev.Tool] = true
43+ return true
44+ default:
45+ return false
46+ }
47+}
48+
49+func (k *sink) ToolRunning(id string) {
50+ k.r.t.SetStatus(id, StatusRunning)
51+ k.r.wake()
52+}
53+
54+func (k *sink) ToolEnd(id string, res ui.ToolResult) {
55+ k.r.t.ToolEnd(id, res)
56+ k.r.wake()
57+}
58+
59+// WorkDir is where mm was started: bash runs there and relative paths
60+// resolve against it, as in the REPL.
61+func (k *sink) WorkDir() string { return k.r.opts.Cwd }
new file mode 100644
@@ -0,0 +1,61 @@
1+package tui
2+
3+import (
4+ "context"
5+
6+ "mm/internal/config"
7+ "mm/internal/ui"
8+)
9+
10+// sink is the window's ui.Sink: the tools' events become transcript entries
11+// and a wake-up, from whatever goroutine they arrive on. Allow is the one
12+// event with an answer: with `permissions: ask` the tool waits for the user
13+// (a/s/r in the box); with `auto`, today's terminal behaviour, it runs.
14+type sink struct{ r *runner }
15+
16+var _ ui.Sink = (*sink)(nil)
17+
18+func (k *sink) Text(text string) {
19+ k.r.streamed.Store(true)
20+ k.r.t.Chunk(text)
21+ k.r.wake()
22+}
23+
24+func (k *sink) ToolStart(ev ui.ToolEvent) {
25+ k.r.t.ToolStart(ev)
26+ k.r.wake()
27+}
28+
29+// Allow gates bash and the file tools. read_skill never asks (it reads what
30+// the agent ships — the tools skip Allow for it). "Allow for this session"
31+// is remembered per tool, as the ACP façade does.
32+func (k *sink) Allow(ctx context.Context, ev ui.ToolEvent) bool {
33+ if config.Cfg.Permissions != config.PermissionsAsk || k.r.allowAlways[ev.Tool] {
34+ return true
35+ }
36+ k.r.t.SetStatus(ev.ID, StatusWaiting)
37+ k.r.wake()
38+ switch k.r.request(ctx, ev) {
39+ case permAllowOnce:
40+ return true
41+ case permAllowSession:
42+ k.r.allowAlways[ev.Tool] = true
43+ return true
44+ default:
45+ return false
46+ }
47+}
48+
49+func (k *sink) ToolRunning(id string) {
50+ k.r.t.SetStatus(id, StatusRunning)
51+ k.r.wake()
52+}
53+
54+func (k *sink) ToolEnd(id string, res ui.ToolResult) {
55+ k.r.t.ToolEnd(id, res)
56+ k.r.wake()
57+}
58+
59+// WorkDir is where mm was started: bash runs there and relative paths
60+// resolve against it, as in the REPL.
61+func (k *sink) WorkDir() string { return k.r.opts.Cwd }
added internal/tui/style.go +53 -0
new file mode 100644
@@ -0,0 +1,53 @@
1+package tui
2+
3+import "github.com/gdamore/tcell/v2"
4+
5+// The window's palette: a handful of styles on the terminal's own colours,
6+// so it reads on a light or a dark theme alike. No theme file — the point of
7+// option B was to keep the footprint small.
8+var (
9+ stBody = tcell.StyleDefault
10+ stLabel = tcell.StyleDefault.Bold(true)
11+ stTool = tcell.StyleDefault.Foreground(tcell.ColorTeal)
12+ stToolFail = tcell.StyleDefault.Foreground(tcell.ColorRed)
13+ stOutput = tcell.StyleDefault.Dim(true)
14+ stNotice = tcell.StyleDefault.Dim(true)
15+ stError = tcell.StyleDefault.Foreground(tcell.ColorRed)
16+ stCode = tcell.StyleDefault.Foreground(tcell.ColorOlive)
17+ stDiffAdd = tcell.StyleDefault.Foreground(tcell.ColorGreen)
18+ stDiffDel = tcell.StyleDefault.Foreground(tcell.ColorRed)
19+ stBar = tcell.StyleDefault.Reverse(true)
20+ stRule = tcell.StyleDefault.Dim(true)
21+ stRuleBusy = tcell.StyleDefault.Foreground(tcell.ColorTeal)
22+ stPrompt = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal)
23+ stPick = tcell.StyleDefault.Reverse(true)
24+ stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow)
25+ stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)
26+ stScroll = tcell.StyleDefault.Dim(true)
27+ stThumb = tcell.StyleDefault.Reverse(true)
28+)
29+
30+// styles maps a laid-out line's kind onto a screen style; styleBody is the
31+// zero value and the fallback.
32+var styles = map[styleKind]tcell.Style{
33+ styleLabel: stLabel,
34+ styleTool: stTool,
35+ styleToolFailed: stToolFail,
36+ styleOutput: stOutput,
37+ styleNotice: stNotice,
38+ styleError: stError,
39+ styleCode: stCode,
40+ styleDiffAdd: stDiffAdd,
41+ styleDiffDel: stDiffDel,
42+}
43+
44+// styleOf returns the screen style of a laid-out line's kind.
45+func styleOf(k styleKind) tcell.Style {
46+ if st, ok := styles[k]; ok {
47+ return st
48+ }
49+ return stBody
50+}
51+
52+// spinnerFrames are the braille frames the rule animates while a turn runs.
53+var spinnerFrames = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
new file mode 100644
@@ -0,0 +1,53 @@
1+package tui
2+
3+import "github.com/gdamore/tcell/v2"
4+
5+// The window's palette: a handful of styles on the terminal's own colours,
6+// so it reads on a light or a dark theme alike. No theme file — the point of
7+// option B was to keep the footprint small.
8+var (
9+ stBody = tcell.StyleDefault
10+ stLabel = tcell.StyleDefault.Bold(true)
11+ stTool = tcell.StyleDefault.Foreground(tcell.ColorTeal)
12+ stToolFail = tcell.StyleDefault.Foreground(tcell.ColorRed)
13+ stOutput = tcell.StyleDefault.Dim(true)
14+ stNotice = tcell.StyleDefault.Dim(true)
15+ stError = tcell.StyleDefault.Foreground(tcell.ColorRed)
16+ stCode = tcell.StyleDefault.Foreground(tcell.ColorOlive)
17+ stDiffAdd = tcell.StyleDefault.Foreground(tcell.ColorGreen)
18+ stDiffDel = tcell.StyleDefault.Foreground(tcell.ColorRed)
19+ stBar = tcell.StyleDefault.Reverse(true)
20+ stRule = tcell.StyleDefault.Dim(true)
21+ stRuleBusy = tcell.StyleDefault.Foreground(tcell.ColorTeal)
22+ stPrompt = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorTeal)
23+ stPick = tcell.StyleDefault.Reverse(true)
24+ stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow)
25+ stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)
26+ stScroll = tcell.StyleDefault.Dim(true)
27+ stThumb = tcell.StyleDefault.Reverse(true)
28+)
29+
30+// styles maps a laid-out line's kind onto a screen style; styleBody is the
31+// zero value and the fallback.
32+var styles = map[styleKind]tcell.Style{
33+ styleLabel: stLabel,
34+ styleTool: stTool,
35+ styleToolFailed: stToolFail,
36+ styleOutput: stOutput,
37+ styleNotice: stNotice,
38+ styleError: stError,
39+ styleCode: stCode,
40+ styleDiffAdd: stDiffAdd,
41+ styleDiffDel: stDiffDel,
42+}
43+
44+// styleOf returns the screen style of a laid-out line's kind.
45+func styleOf(k styleKind) tcell.Style {
46+ if st, ok := styles[k]; ok {
47+ return st
48+ }
49+ return stBody
50+}
51+
52+// spinnerFrames are the braille frames the rule animates while a turn runs.
53+var spinnerFrames = []rune("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
added internal/tui/transcript.go +263 -0
new file mode 100644
@@ -0,0 +1,263 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "sync"
6+ "time"
7+
8+ "mm/internal/ui"
9+)
10+
11+// Kind says what one entry of the conversation is.
12+type Kind int
13+
14+// The kinds of entry a conversation holds.
15+const (
16+ // KindUser is a question, as typed.
17+ KindUser Kind = iota
18+ // KindAgent is the model's answer, streamed chunk by chunk into ONE entry.
19+ KindAgent
20+ // KindTool is a tool call: its title, status, output and timing.
21+ KindTool
22+ // KindNotice is the window speaking for itself: a compaction report, a
23+ // resumed session, the ⚙ recap of a turn.
24+ KindNotice
25+ // KindError is a failed turn or command, in one line.
26+ KindError
27+)
28+
29+// Tool statuses, as the transcript shows them.
30+const (
31+ StatusPending = "pending"
32+ StatusWaiting = "waiting" // for the user's permission
33+ StatusRunning = "running"
34+ StatusDone = "done"
35+ StatusFailed = "failed"
36+)
37+
38+// Entry is one thing in the conversation. A run of streamed chunks is one
39+// entry, not one per chunk: text arrives a token at a time, and an entry per
40+// chunk could neither be wrapped nor told apart from a fenced code block.
41+type Entry struct {
42+ Kind Kind
43+ Text string // the question, the answer, the notice; a tool's OUTPUT
44+ // Full is the question as the model read it when Text is its collapsed
45+ // form — a long paste shown as `[Pasted #1 · …]`. Empty when they agree.
46+ Full string
47+
48+ // Tool call fields, on KindTool only.
49+ ID string // ui.ToolEvent.ID, so an update finds its entry
50+ Tool string // "bash", "edit_file", …
51+ Title string // the tool's one-line title: "bash: ls -1"
52+ Status string
53+ Failed bool
54+ Started time.Time
55+ Elapsed time.Duration
56+ Diff bool // the output is a file diff (edit tools): +/- lines get colour
57+}
58+
59+// Detail is what the tool was asked, without the tool's own name prefix:
60+// "ls -1" for "bash: ls -1".
61+func (e Entry) Detail() string {
62+ return strings.TrimSpace(strings.TrimPrefix(e.Title, e.Tool+":"))
63+}
64+
65+// Transcript is the conversation as a value, safe to feed from any goroutine
66+// and read from the drawing one. It knows nothing about the screen.
67+//
68+// t := &Transcript{}
69+// t.AddUser("list the files")
70+// t.Chunk("Here "); t.Chunk("they are:") // one agent entry
71+// t.ToolStart(ev); t.ToolRunning(ev.ID); t.ToolEnd(ev.ID, res)
72+// for _, e := range t.Entries() { … }
73+type Transcript struct {
74+ mu sync.Mutex
75+ entries []Entry
76+ open bool // the last entry is an agent run still receiving chunks
77+ tools int // tool calls recorded, for the status bar
78+ now func() time.Time
79+}
80+
81+// Entries returns a copy of the conversation, oldest first.
82+func (t *Transcript) Entries() []Entry {
83+ t.mu.Lock()
84+ defer t.mu.Unlock()
85+ return append([]Entry(nil), t.entries...)
86+}
87+
88+// Len is how many entries the conversation holds.
89+func (t *Transcript) Len() int {
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ return len(t.entries)
93+}
94+
95+// Tools is how many tool calls were recorded since the transcript was
96+// created or cleared.
97+func (t *Transcript) Tools() int {
98+ t.mu.Lock()
99+ defer t.mu.Unlock()
100+ return t.tools
101+}
102+
103+// AddUser appends a question and closes any agent run in progress.
104+func (t *Transcript) AddUser(text string) { t.add(Entry{Kind: KindUser, Text: text}) }
105+
106+// AddUserFull appends a question shown collapsed (display) but read in full
107+// by the model (full): Plain gives the full form, the screen the short one.
108+func (t *Transcript) AddUserFull(display, full string) {
109+ e := Entry{Kind: KindUser, Text: display}
110+ if full != display {
111+ e.Full = full
112+ }
113+ t.add(e)
114+}
115+
116+// Notice appends a line the window says for itself.
117+func (t *Transcript) Notice(text string) { t.add(Entry{Kind: KindNotice, Text: text}) }
118+
119+// Error appends a one-line failure.
120+func (t *Transcript) Error(text string) { t.add(Entry{Kind: KindError, Text: text}) }
121+
122+// Chunk appends streamed text to the open agent entry, opening one when the
123+// previous entry is not an agent run — a tool call in between starts a new
124+// paragraph of the answer, which is how it reads.
125+func (t *Transcript) Chunk(text string) {
126+ if text == "" {
127+ return
128+ }
129+ t.mu.Lock()
130+ defer t.mu.Unlock()
131+ if t.open && len(t.entries) > 0 && t.entries[len(t.entries)-1].Kind == KindAgent {
132+ t.entries[len(t.entries)-1].Text += text
133+ return
134+ }
135+ t.entries = append(t.entries, Entry{Kind: KindAgent, Text: text})
136+ t.open = true
137+}
138+
139+// Close ends the agent run in progress: the next Chunk starts a new entry.
140+// Called when a turn ends.
141+func (t *Transcript) Close() {
142+ t.mu.Lock()
143+ t.open = false
144+ t.mu.Unlock()
145+}
146+
147+// ToolStart records a call before it runs.
148+func (t *Transcript) ToolStart(ev ui.ToolEvent) {
149+ t.mu.Lock()
150+ defer t.mu.Unlock()
151+ t.open = false
152+ t.tools++
153+ t.entries = append(t.entries, Entry{
154+ Kind: KindTool, ID: ev.ID, Tool: ev.Tool, Title: ev.Title,
155+ Status: StatusPending, Started: t.clock(),
156+ Diff: ev.Kind == "edit",
157+ })
158+}
159+
160+// SetStatus changes a call's status: waiting (for permission), running.
161+func (t *Transcript) SetStatus(id, status string) {
162+ t.mu.Lock()
163+ defer t.mu.Unlock()
164+ if at := t.indexOf(id); at >= 0 {
165+ t.entries[at].Status = status
166+ }
167+}
168+
169+// ToolEnd folds the outcome onto the call's entry: status, output, timing.
170+func (t *Transcript) ToolEnd(id string, res ui.ToolResult) {
171+ t.mu.Lock()
172+ defer t.mu.Unlock()
173+ at := t.indexOf(id)
174+ if at < 0 {
175+ return
176+ }
177+ e := &t.entries[at]
178+ e.Text = strings.TrimRight(res.Output, "\n")
179+ e.Failed = res.Failed
180+ e.Status = StatusDone
181+ if res.Failed {
182+ e.Status = StatusFailed
183+ }
184+ e.Elapsed = t.clock().Sub(e.Started)
185+}
186+
187+// Answers returns the agent's answers, oldest first, as raw text: no
188+// prefix, no wrapping, no tool frames — what /copy puts on the clipboard.
189+func (t *Transcript) Answers() []string {
190+ t.mu.Lock()
191+ defer t.mu.Unlock()
192+ var out []string
193+ for _, e := range t.entries {
194+ if e.Kind == KindAgent {
195+ out = append(out, e.Text)
196+ }
197+ }
198+ return out
199+}
200+
201+// Plain renders the whole conversation as text a person can paste
202+// somewhere: questions prefixed with "> " (in full, pastes expanded),
203+// answers raw, tool calls as one heading line plus their output, notices and
204+// errors as they read. Entries are separated by a blank line.
205+func (t *Transcript) Plain() string {
206+ var parts []string
207+ for _, e := range t.Entries() {
208+ parts = append(parts, e.plain())
209+ }
210+ return strings.Join(parts, "\n\n")
211+}
212+
213+// plain is one entry as Plain shows it.
214+func (e Entry) plain() string {
215+ switch e.Kind {
216+ case KindUser:
217+ if e.Full != "" {
218+ return "> " + e.Full
219+ }
220+ return "> " + e.Text
221+ case KindTool:
222+ head := "‣ " + e.Title + " (" + e.Status + ")"
223+ if e.Text == "" {
224+ return head
225+ }
226+ return head + "\n" + e.Text
227+ default:
228+ return e.Text
229+ }
230+}
231+
232+// Clear forgets the displayed conversation (Ctrl-L). The history the model
233+// sees is untouched: this is the screen, not the session.
234+func (t *Transcript) Clear() {
235+ t.mu.Lock()
236+ t.entries, t.open, t.tools = nil, false, 0
237+ t.mu.Unlock()
238+}
239+
240+func (t *Transcript) add(e Entry) {
241+ t.mu.Lock()
242+ t.open = false
243+ t.entries = append(t.entries, e)
244+ t.mu.Unlock()
245+}
246+
247+// indexOf finds a tool entry by id, searching from the end: the call being
248+// updated is almost always the last one.
249+func (t *Transcript) indexOf(id string) int {
250+ for i := len(t.entries) - 1; i >= 0; i-- {
251+ if t.entries[i].Kind == KindTool && t.entries[i].ID == id {
252+ return i
253+ }
254+ }
255+ return -1
256+}
257+
258+func (t *Transcript) clock() time.Time {
259+ if t.now != nil {
260+ return t.now()
261+ }
262+ return time.Now()
263+}
new file mode 100644
@@ -0,0 +1,263 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "sync"
6+ "time"
7+
8+ "mm/internal/ui"
9+)
10+
11+// Kind says what one entry of the conversation is.
12+type Kind int
13+
14+// The kinds of entry a conversation holds.
15+const (
16+ // KindUser is a question, as typed.
17+ KindUser Kind = iota
18+ // KindAgent is the model's answer, streamed chunk by chunk into ONE entry.
19+ KindAgent
20+ // KindTool is a tool call: its title, status, output and timing.
21+ KindTool
22+ // KindNotice is the window speaking for itself: a compaction report, a
23+ // resumed session, the ⚙ recap of a turn.
24+ KindNotice
25+ // KindError is a failed turn or command, in one line.
26+ KindError
27+)
28+
29+// Tool statuses, as the transcript shows them.
30+const (
31+ StatusPending = "pending"
32+ StatusWaiting = "waiting" // for the user's permission
33+ StatusRunning = "running"
34+ StatusDone = "done"
35+ StatusFailed = "failed"
36+)
37+
38+// Entry is one thing in the conversation. A run of streamed chunks is one
39+// entry, not one per chunk: text arrives a token at a time, and an entry per
40+// chunk could neither be wrapped nor told apart from a fenced code block.
41+type Entry struct {
42+ Kind Kind
43+ Text string // the question, the answer, the notice; a tool's OUTPUT
44+ // Full is the question as the model read it when Text is its collapsed
45+ // form — a long paste shown as `[Pasted #1 · …]`. Empty when they agree.
46+ Full string
47+
48+ // Tool call fields, on KindTool only.
49+ ID string // ui.ToolEvent.ID, so an update finds its entry
50+ Tool string // "bash", "edit_file", …
51+ Title string // the tool's one-line title: "bash: ls -1"
52+ Status string
53+ Failed bool
54+ Started time.Time
55+ Elapsed time.Duration
56+ Diff bool // the output is a file diff (edit tools): +/- lines get colour
57+}
58+
59+// Detail is what the tool was asked, without the tool's own name prefix:
60+// "ls -1" for "bash: ls -1".
61+func (e Entry) Detail() string {
62+ return strings.TrimSpace(strings.TrimPrefix(e.Title, e.Tool+":"))
63+}
64+
65+// Transcript is the conversation as a value, safe to feed from any goroutine
66+// and read from the drawing one. It knows nothing about the screen.
67+//
68+// t := &Transcript{}
69+// t.AddUser("list the files")
70+// t.Chunk("Here "); t.Chunk("they are:") // one agent entry
71+// t.ToolStart(ev); t.ToolRunning(ev.ID); t.ToolEnd(ev.ID, res)
72+// for _, e := range t.Entries() { … }
73+type Transcript struct {
74+ mu sync.Mutex
75+ entries []Entry
76+ open bool // the last entry is an agent run still receiving chunks
77+ tools int // tool calls recorded, for the status bar
78+ now func() time.Time
79+}
80+
81+// Entries returns a copy of the conversation, oldest first.
82+func (t *Transcript) Entries() []Entry {
83+ t.mu.Lock()
84+ defer t.mu.Unlock()
85+ return append([]Entry(nil), t.entries...)
86+}
87+
88+// Len is how many entries the conversation holds.
89+func (t *Transcript) Len() int {
90+ t.mu.Lock()
91+ defer t.mu.Unlock()
92+ return len(t.entries)
93+}
94+
95+// Tools is how many tool calls were recorded since the transcript was
96+// created or cleared.
97+func (t *Transcript) Tools() int {
98+ t.mu.Lock()
99+ defer t.mu.Unlock()
100+ return t.tools
101+}
102+
103+// AddUser appends a question and closes any agent run in progress.
104+func (t *Transcript) AddUser(text string) { t.add(Entry{Kind: KindUser, Text: text}) }
105+
106+// AddUserFull appends a question shown collapsed (display) but read in full
107+// by the model (full): Plain gives the full form, the screen the short one.
108+func (t *Transcript) AddUserFull(display, full string) {
109+ e := Entry{Kind: KindUser, Text: display}
110+ if full != display {
111+ e.Full = full
112+ }
113+ t.add(e)
114+}
115+
116+// Notice appends a line the window says for itself.
117+func (t *Transcript) Notice(text string) { t.add(Entry{Kind: KindNotice, Text: text}) }
118+
119+// Error appends a one-line failure.
120+func (t *Transcript) Error(text string) { t.add(Entry{Kind: KindError, Text: text}) }
121+
122+// Chunk appends streamed text to the open agent entry, opening one when the
123+// previous entry is not an agent run — a tool call in between starts a new
124+// paragraph of the answer, which is how it reads.
125+func (t *Transcript) Chunk(text string) {
126+ if text == "" {
127+ return
128+ }
129+ t.mu.Lock()
130+ defer t.mu.Unlock()
131+ if t.open && len(t.entries) > 0 && t.entries[len(t.entries)-1].Kind == KindAgent {
132+ t.entries[len(t.entries)-1].Text += text
133+ return
134+ }
135+ t.entries = append(t.entries, Entry{Kind: KindAgent, Text: text})
136+ t.open = true
137+}
138+
139+// Close ends the agent run in progress: the next Chunk starts a new entry.
140+// Called when a turn ends.
141+func (t *Transcript) Close() {
142+ t.mu.Lock()
143+ t.open = false
144+ t.mu.Unlock()
145+}
146+
147+// ToolStart records a call before it runs.
148+func (t *Transcript) ToolStart(ev ui.ToolEvent) {
149+ t.mu.Lock()
150+ defer t.mu.Unlock()
151+ t.open = false
152+ t.tools++
153+ t.entries = append(t.entries, Entry{
154+ Kind: KindTool, ID: ev.ID, Tool: ev.Tool, Title: ev.Title,
155+ Status: StatusPending, Started: t.clock(),
156+ Diff: ev.Kind == "edit",
157+ })
158+}
159+
160+// SetStatus changes a call's status: waiting (for permission), running.
161+func (t *Transcript) SetStatus(id, status string) {
162+ t.mu.Lock()
163+ defer t.mu.Unlock()
164+ if at := t.indexOf(id); at >= 0 {
165+ t.entries[at].Status = status
166+ }
167+}
168+
169+// ToolEnd folds the outcome onto the call's entry: status, output, timing.
170+func (t *Transcript) ToolEnd(id string, res ui.ToolResult) {
171+ t.mu.Lock()
172+ defer t.mu.Unlock()
173+ at := t.indexOf(id)
174+ if at < 0 {
175+ return
176+ }
177+ e := &t.entries[at]
178+ e.Text = strings.TrimRight(res.Output, "\n")
179+ e.Failed = res.Failed
180+ e.Status = StatusDone
181+ if res.Failed {
182+ e.Status = StatusFailed
183+ }
184+ e.Elapsed = t.clock().Sub(e.Started)
185+}
186+
187+// Answers returns the agent's answers, oldest first, as raw text: no
188+// prefix, no wrapping, no tool frames — what /copy puts on the clipboard.
189+func (t *Transcript) Answers() []string {
190+ t.mu.Lock()
191+ defer t.mu.Unlock()
192+ var out []string
193+ for _, e := range t.entries {
194+ if e.Kind == KindAgent {
195+ out = append(out, e.Text)
196+ }
197+ }
198+ return out
199+}
200+
201+// Plain renders the whole conversation as text a person can paste
202+// somewhere: questions prefixed with "> " (in full, pastes expanded),
203+// answers raw, tool calls as one heading line plus their output, notices and
204+// errors as they read. Entries are separated by a blank line.
205+func (t *Transcript) Plain() string {
206+ var parts []string
207+ for _, e := range t.Entries() {
208+ parts = append(parts, e.plain())
209+ }
210+ return strings.Join(parts, "\n\n")
211+}
212+
213+// plain is one entry as Plain shows it.
214+func (e Entry) plain() string {
215+ switch e.Kind {
216+ case KindUser:
217+ if e.Full != "" {
218+ return "> " + e.Full
219+ }
220+ return "> " + e.Text
221+ case KindTool:
222+ head := "‣ " + e.Title + " (" + e.Status + ")"
223+ if e.Text == "" {
224+ return head
225+ }
226+ return head + "\n" + e.Text
227+ default:
228+ return e.Text
229+ }
230+}
231+
232+// Clear forgets the displayed conversation (Ctrl-L). The history the model
233+// sees is untouched: this is the screen, not the session.
234+func (t *Transcript) Clear() {
235+ t.mu.Lock()
236+ t.entries, t.open, t.tools = nil, false, 0
237+ t.mu.Unlock()
238+}
239+
240+func (t *Transcript) add(e Entry) {
241+ t.mu.Lock()
242+ t.open = false
243+ t.entries = append(t.entries, e)
244+ t.mu.Unlock()
245+}
246+
247+// indexOf finds a tool entry by id, searching from the end: the call being
248+// updated is almost always the last one.
249+func (t *Transcript) indexOf(id string) int {
250+ for i := len(t.entries) - 1; i >= 0; i-- {
251+ if t.entries[i].Kind == KindTool && t.entries[i].ID == id {
252+ return i
253+ }
254+ }
255+ return -1
256+}
257+
258+func (t *Transcript) clock() time.Time {
259+ if t.now != nil {
260+ return t.now()
261+ }
262+ return time.Now()
263+}
added internal/tui/transcript_test.go +172 -0
new file mode 100644
@@ -0,0 +1,172 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "mm/internal/ui"
9+)
10+
11+// Chunks coalesce into one agent entry; a question, a tool call or Close
12+// starts a new one.
13+func TestTranscriptCoalescesChunks(t *testing.T) {
14+ tr := &Transcript{}
15+ tr.AddUser("hi")
16+ tr.Chunk("Hel")
17+ tr.Chunk("lo")
18+ tr.Chunk("") // ignored
19+ if got := tr.Entries(); len(got) != 2 || got[1].Kind != KindAgent || got[1].Text != "Hello" {
20+ t.Fatalf("entries %+v", got)
21+ }
22+ tr.Close()
23+ tr.Chunk("again")
24+ tr.ToolStart(ui.ToolEvent{ID: "call_1", Tool: "bash", Title: "bash: ls"})
25+ tr.Chunk("after")
26+ got := tr.Entries()
27+ if len(got) != 5 || got[2].Text != "again" || got[3].Kind != KindTool || got[4].Text != "after" {
28+ t.Errorf("entries after close/tool: %+v", got)
29+ }
30+ if tr.Len() != 5 || tr.Tools() != 1 {
31+ t.Errorf("Len %d Tools %d", tr.Len(), tr.Tools())
32+ }
33+}
34+
35+// A tool call is one entry that its status changes and its end fold onto,
36+// with the output, the failure flag and the time it took.
37+func TestTranscriptFoldsToolUpdates(t *testing.T) {
38+ clock := time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC)
39+ tr := &Transcript{now: func() time.Time { return clock }}
40+ tr.ToolStart(ui.ToolEvent{ID: "call_7", Tool: "bash", Title: "bash: ls -1", Kind: "execute"})
41+ tr.SetStatus("call_7", StatusWaiting)
42+ if e := tr.Entries()[0]; e.Status != StatusWaiting || e.Detail() != "ls -1" || e.Diff {
43+ t.Errorf("after SetStatus: %+v", e)
44+ }
45+ tr.SetStatus("call_7", StatusRunning)
46+ clock = clock.Add(1500 * time.Millisecond)
47+ tr.ToolEnd("call_7", ui.ToolResult{Output: "a\nb\n", Failed: true})
48+ tr.ToolEnd("nope", ui.ToolResult{Output: "ignored"})
49+ got := tr.Entries()
50+ if len(got) != 1 {
51+ t.Fatalf("%d entries, want 1", len(got))
52+ }
53+ e := got[0]
54+ if e.Status != StatusFailed || !e.Failed || e.Text != "a\nb" || e.Elapsed != 1500*time.Millisecond {
55+ t.Errorf("folded entry %+v", e)
56+ }
57+ tr.ToolStart(ui.ToolEvent{ID: "call_8", Tool: "edit_file", Title: "edit_file: x.go", Kind: "edit"})
58+ if !tr.Entries()[1].Diff {
59+ t.Error("an edit tool's output is not flagged as a diff")
60+ }
61+}
62+
63+// Clear empties the screen's conversation and its counters, nothing else.
64+func TestTranscriptClear(t *testing.T) {
65+ tr := &Transcript{}
66+ tr.AddUser("q")
67+ tr.ToolStart(ui.ToolEvent{ID: "c", Tool: "bash"})
68+ tr.Clear()
69+ if tr.Len() != 0 || tr.Tools() != 0 {
70+ t.Errorf("after Clear: %d entries, %d tools", tr.Len(), tr.Tools())
71+ }
72+ tr.Chunk("fresh")
73+ if got := tr.Entries(); len(got) != 1 || got[0].Text != "fresh" {
74+ t.Errorf("after Clear + Chunk: %+v", got)
75+ }
76+}
77+
78+// wrap breaks at spaces, keeps the author's newlines, cuts words longer than
79+// the width, and keeps a blank line as a blank line.
80+func TestWrap(t *testing.T) {
81+ got := wrap("the quick brown fox\n\njumps", 9)
82+ want := []string{"the quick", "brown fox", "", "jumps"}
83+ if strings.Join(got, "|") != strings.Join(want, "|") {
84+ t.Errorf("wrap = %q, want %q", got, want)
85+ }
86+ if got := wrap("abcdefghij", 4); strings.Join(got, "|") != "abcd|efgh|ij" {
87+ t.Errorf("long word: %q", got)
88+ }
89+ if got := wrap("x", 0); got != nil {
90+ t.Errorf("width 0: %q", got)
91+ }
92+ if clip("hello", 3) != "he…" || clip("hi", 5) != "hi" || clip("hi", 1) != "…" || clip("hi", 0) != "" {
93+ t.Error("clip")
94+ }
95+}
96+
97+// layout: labels, wrapped prose, fenced code left unwrapped, tool heading
98+// with status and timing, output folded to previewLines, diff colouring.
99+func TestLayout(t *testing.T) {
100+ entries := []Entry{
101+ {Kind: KindUser, Text: "one two three four"},
102+ {Kind: KindAgent, Text: "Sure.\n```go\nfunc very_long_identifier_that_exceeds() {}\n```\ndone"},
103+ {Kind: KindTool, Tool: "bash", Title: "bash: ls", Status: StatusDone, Elapsed: 30 * time.Millisecond, Text: "a\nb\nc\nd"},
104+ {Kind: KindTool, Tool: "edit_file", Title: "edit_file: x.go", Status: StatusFailed, Failed: true, Diff: true, Text: "-old\n+new"},
105+ {Kind: KindNotice, Text: "🗜️ compressed"},
106+ {Kind: KindError, Text: "[error: boom]"},
107+ }
108+ // Narrow for the wrapping rules, wider for the rest, so the expectations
109+ // stay readable.
110+ lines := append(layout(entries[:2], "mm", 14, 2), layout(entries[2:], "mm", 40, 2)...)
111+ texts := make([]string, 0, len(lines))
112+ for _, l := range lines {
113+ texts = append(texts, l.Text)
114+ }
115+ joined := strings.Join(texts, "\n")
116+ for _, want := range []string{
117+ "‣ you\n one two\n three four",
118+ "‣ mm\n Sure.\n ```go\n func very_long_identifier_that_exceeds() {}\n ```\n done",
119+ "‣ bash ls ✓ done 0.03s",
120+ " a\n b\n … 2 more line(s)",
121+ "‣ edit_file x.go ✗ failed",
122+ " -old\n +new",
123+ " 🗜️ compressed",
124+ " [error: boom]",
125+ } {
126+ if !strings.Contains(joined, want) {
127+ t.Errorf("layout misses %q in:\n%s", want, joined)
128+ }
129+ }
130+ styles := map[string]styleKind{}
131+ for _, l := range lines {
132+ styles[l.Text] = l.Style
133+ }
134+ if styles["‣ you"] != styleLabel || styles[" -old"] != styleDiffDel || styles[" +new"] != styleDiffAdd ||
135+ styles[" [error: boom]"] != styleError || styles[" 🗜️ compressed"] != styleNotice ||
136+ styles[" func very_long_identifier_that_exceeds() {}"] != styleCode {
137+ t.Errorf("styles %v", styles)
138+ }
139+ // Full width: the heading shows status and time.
140+ wide := layout(entries[2:3], "mm", 80, 0)
141+ if wide[0].Text != "‣ bash ls ✓ done 0.03s" || wide[0].Style != styleTool || len(wide) != 5 {
142+ t.Errorf("wide tool heading %+v (%d lines)", wide[0], len(wide))
143+ }
144+ if failed := layout(entries[3:4], "mm", 80, 0); failed[0].Style != styleToolFailed || !strings.Contains(failed[0].Text, "✗ failed") {
145+ t.Errorf("failed tool heading %+v", failed[0])
146+ }
147+}
148+
149+// Answers is the raw text of the agent's entries — streamed chunks joined,
150+// no prefix, no tool frames — oldest first; Plain is the whole conversation
151+// as pasteable text.
152+func TestAnswersAndPlainAreRawText(t *testing.T) {
153+ tr := &Transcript{now: func() time.Time { return time.Unix(0, 0) }}
154+ tr.AddUser("list the files")
155+ tr.ToolStart(ui.ToolEvent{ID: "1", Tool: "bash", Title: "bash: ls"})
156+ tr.ToolEnd("1", ui.ToolResult{Output: "a.go\nb.go\n"})
157+ tr.Chunk("Two ")
158+ tr.Chunk("files.")
159+ tr.Close()
160+ tr.Notice("⚙ 1 command(s)")
161+ tr.AddUser("thanks")
162+ tr.Chunk("You are welcome.")
163+ tr.Close()
164+
165+ if got := tr.Answers(); len(got) != 2 || got[0] != "Two files." || got[1] != "You are welcome." {
166+ t.Errorf("Answers = %q", got)
167+ }
168+ want := "> list the files\n\n‣ bash: ls (done)\na.go\nb.go\n\nTwo files.\n\n⚙ 1 command(s)\n\n> thanks\n\nYou are welcome."
169+ if got := tr.Plain(); got != want {
170+ t.Errorf("Plain:\n got %q\nwant %q", got, want)
171+ }
172+}
new file mode 100644
@@ -0,0 +1,172 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "mm/internal/ui"
9+)
10+
11+// Chunks coalesce into one agent entry; a question, a tool call or Close
12+// starts a new one.
13+func TestTranscriptCoalescesChunks(t *testing.T) {
14+ tr := &Transcript{}
15+ tr.AddUser("hi")
16+ tr.Chunk("Hel")
17+ tr.Chunk("lo")
18+ tr.Chunk("") // ignored
19+ if got := tr.Entries(); len(got) != 2 || got[1].Kind != KindAgent || got[1].Text != "Hello" {
20+ t.Fatalf("entries %+v", got)
21+ }
22+ tr.Close()
23+ tr.Chunk("again")
24+ tr.ToolStart(ui.ToolEvent{ID: "call_1", Tool: "bash", Title: "bash: ls"})
25+ tr.Chunk("after")
26+ got := tr.Entries()
27+ if len(got) != 5 || got[2].Text != "again" || got[3].Kind != KindTool || got[4].Text != "after" {
28+ t.Errorf("entries after close/tool: %+v", got)
29+ }
30+ if tr.Len() != 5 || tr.Tools() != 1 {
31+ t.Errorf("Len %d Tools %d", tr.Len(), tr.Tools())
32+ }
33+}
34+
35+// A tool call is one entry that its status changes and its end fold onto,
36+// with the output, the failure flag and the time it took.
37+func TestTranscriptFoldsToolUpdates(t *testing.T) {
38+ clock := time.Date(2026, 9, 16, 10, 0, 0, 0, time.UTC)
39+ tr := &Transcript{now: func() time.Time { return clock }}
40+ tr.ToolStart(ui.ToolEvent{ID: "call_7", Tool: "bash", Title: "bash: ls -1", Kind: "execute"})
41+ tr.SetStatus("call_7", StatusWaiting)
42+ if e := tr.Entries()[0]; e.Status != StatusWaiting || e.Detail() != "ls -1" || e.Diff {
43+ t.Errorf("after SetStatus: %+v", e)
44+ }
45+ tr.SetStatus("call_7", StatusRunning)
46+ clock = clock.Add(1500 * time.Millisecond)
47+ tr.ToolEnd("call_7", ui.ToolResult{Output: "a\nb\n", Failed: true})
48+ tr.ToolEnd("nope", ui.ToolResult{Output: "ignored"})
49+ got := tr.Entries()
50+ if len(got) != 1 {
51+ t.Fatalf("%d entries, want 1", len(got))
52+ }
53+ e := got[0]
54+ if e.Status != StatusFailed || !e.Failed || e.Text != "a\nb" || e.Elapsed != 1500*time.Millisecond {
55+ t.Errorf("folded entry %+v", e)
56+ }
57+ tr.ToolStart(ui.ToolEvent{ID: "call_8", Tool: "edit_file", Title: "edit_file: x.go", Kind: "edit"})
58+ if !tr.Entries()[1].Diff {
59+ t.Error("an edit tool's output is not flagged as a diff")
60+ }
61+}
62+
63+// Clear empties the screen's conversation and its counters, nothing else.
64+func TestTranscriptClear(t *testing.T) {
65+ tr := &Transcript{}
66+ tr.AddUser("q")
67+ tr.ToolStart(ui.ToolEvent{ID: "c", Tool: "bash"})
68+ tr.Clear()
69+ if tr.Len() != 0 || tr.Tools() != 0 {
70+ t.Errorf("after Clear: %d entries, %d tools", tr.Len(), tr.Tools())
71+ }
72+ tr.Chunk("fresh")
73+ if got := tr.Entries(); len(got) != 1 || got[0].Text != "fresh" {
74+ t.Errorf("after Clear + Chunk: %+v", got)
75+ }
76+}
77+
78+// wrap breaks at spaces, keeps the author's newlines, cuts words longer than
79+// the width, and keeps a blank line as a blank line.
80+func TestWrap(t *testing.T) {
81+ got := wrap("the quick brown fox\n\njumps", 9)
82+ want := []string{"the quick", "brown fox", "", "jumps"}
83+ if strings.Join(got, "|") != strings.Join(want, "|") {
84+ t.Errorf("wrap = %q, want %q", got, want)
85+ }
86+ if got := wrap("abcdefghij", 4); strings.Join(got, "|") != "abcd|efgh|ij" {
87+ t.Errorf("long word: %q", got)
88+ }
89+ if got := wrap("x", 0); got != nil {
90+ t.Errorf("width 0: %q", got)
91+ }
92+ if clip("hello", 3) != "he…" || clip("hi", 5) != "hi" || clip("hi", 1) != "…" || clip("hi", 0) != "" {
93+ t.Error("clip")
94+ }
95+}
96+
97+// layout: labels, wrapped prose, fenced code left unwrapped, tool heading
98+// with status and timing, output folded to previewLines, diff colouring.
99+func TestLayout(t *testing.T) {
100+ entries := []Entry{
101+ {Kind: KindUser, Text: "one two three four"},
102+ {Kind: KindAgent, Text: "Sure.\n```go\nfunc very_long_identifier_that_exceeds() {}\n```\ndone"},
103+ {Kind: KindTool, Tool: "bash", Title: "bash: ls", Status: StatusDone, Elapsed: 30 * time.Millisecond, Text: "a\nb\nc\nd"},
104+ {Kind: KindTool, Tool: "edit_file", Title: "edit_file: x.go", Status: StatusFailed, Failed: true, Diff: true, Text: "-old\n+new"},
105+ {Kind: KindNotice, Text: "🗜️ compressed"},
106+ {Kind: KindError, Text: "[error: boom]"},
107+ }
108+ // Narrow for the wrapping rules, wider for the rest, so the expectations
109+ // stay readable.
110+ lines := append(layout(entries[:2], "mm", 14, 2), layout(entries[2:], "mm", 40, 2)...)
111+ texts := make([]string, 0, len(lines))
112+ for _, l := range lines {
113+ texts = append(texts, l.Text)
114+ }
115+ joined := strings.Join(texts, "\n")
116+ for _, want := range []string{
117+ "‣ you\n one two\n three four",
118+ "‣ mm\n Sure.\n ```go\n func very_long_identifier_that_exceeds() {}\n ```\n done",
119+ "‣ bash ls ✓ done 0.03s",
120+ " a\n b\n … 2 more line(s)",
121+ "‣ edit_file x.go ✗ failed",
122+ " -old\n +new",
123+ " 🗜️ compressed",
124+ " [error: boom]",
125+ } {
126+ if !strings.Contains(joined, want) {
127+ t.Errorf("layout misses %q in:\n%s", want, joined)
128+ }
129+ }
130+ styles := map[string]styleKind{}
131+ for _, l := range lines {
132+ styles[l.Text] = l.Style
133+ }
134+ if styles["‣ you"] != styleLabel || styles[" -old"] != styleDiffDel || styles[" +new"] != styleDiffAdd ||
135+ styles[" [error: boom]"] != styleError || styles[" 🗜️ compressed"] != styleNotice ||
136+ styles[" func very_long_identifier_that_exceeds() {}"] != styleCode {
137+ t.Errorf("styles %v", styles)
138+ }
139+ // Full width: the heading shows status and time.
140+ wide := layout(entries[2:3], "mm", 80, 0)
141+ if wide[0].Text != "‣ bash ls ✓ done 0.03s" || wide[0].Style != styleTool || len(wide) != 5 {
142+ t.Errorf("wide tool heading %+v (%d lines)", wide[0], len(wide))
143+ }
144+ if failed := layout(entries[3:4], "mm", 80, 0); failed[0].Style != styleToolFailed || !strings.Contains(failed[0].Text, "✗ failed") {
145+ t.Errorf("failed tool heading %+v", failed[0])
146+ }
147+}
148+
149+// Answers is the raw text of the agent's entries — streamed chunks joined,
150+// no prefix, no tool frames — oldest first; Plain is the whole conversation
151+// as pasteable text.
152+func TestAnswersAndPlainAreRawText(t *testing.T) {
153+ tr := &Transcript{now: func() time.Time { return time.Unix(0, 0) }}
154+ tr.AddUser("list the files")
155+ tr.ToolStart(ui.ToolEvent{ID: "1", Tool: "bash", Title: "bash: ls"})
156+ tr.ToolEnd("1", ui.ToolResult{Output: "a.go\nb.go\n"})
157+ tr.Chunk("Two ")
158+ tr.Chunk("files.")
159+ tr.Close()
160+ tr.Notice("⚙ 1 command(s)")
161+ tr.AddUser("thanks")
162+ tr.Chunk("You are welcome.")
163+ tr.Close()
164+
165+ if got := tr.Answers(); len(got) != 2 || got[0] != "Two files." || got[1] != "You are welcome." {
166+ t.Errorf("Answers = %q", got)
167+ }
168+ want := "> list the files\n\n‣ bash: ls (done)\na.go\nb.go\n\nTwo files.\n\n⚙ 1 command(s)\n\n> thanks\n\nYou are welcome."
169+ if got := tr.Plain(); got != want {
170+ t.Errorf("Plain:\n got %q\nwant %q", got, want)
171+ }
172+}
added internal/tui/tui.go +149 -0
new file mode 100644
@@ -0,0 +1,149 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "time"
8+
9+ "mm/internal/clipboard"
10+ "mm/internal/config"
11+
12+ "github.com/gdamore/tcell/v2"
13+)
14+
15+// ErrNotTerminal is what Run returns when stdin or stdout is not a terminal:
16+// the caller falls back to the line-mode REPL, so `mm -tui | tee` still works.
17+var ErrNotTerminal = errors.New("stdin or stdout is not a terminal")
18+
19+// Terminal reports whether both stdin and stdout are terminals — the
20+// condition for the window to be drawn at all.
21+func Terminal() bool {
22+ return isTerminal(os.Stdin) && isTerminal(os.Stdout)
23+}
24+
25+func isTerminal(f *os.File) bool {
26+ info, err := f.Stat()
27+ return err == nil && info.Mode()&os.ModeCharDevice != 0
28+}
29+
30+// Run opens the window on the real terminal and blocks until the user quits
31+// (/quit, Ctrl-C when idle) or ctx is cancelled. It returns ErrNotTerminal
32+// without drawing anything when there is no terminal to draw on.
33+//
34+// if err := tui.Run(ctx, opts); errors.Is(err, tui.ErrNotTerminal) {
35+// agent.Run(…) // line mode instead
36+// }
37+func Run(ctx context.Context, opts Options) error {
38+ if !Terminal() {
39+ return ErrNotTerminal
40+ }
41+ screen, err := tcell.NewScreen()
42+ if err != nil {
43+ return err
44+ }
45+ if err := screen.Init(); err != nil {
46+ return err
47+ }
48+ defer screen.Fini()
49+ return run(ctx, screen, opts)
50+}
51+
52+// app is one window: the screen, the view drawing on it, the runner owning
53+// the session, and the transcript they share.
54+type app struct {
55+ screen tcell.Screen
56+ t *Transcript
57+ r *runner
58+ v *View
59+}
60+
61+// newApp wires the three parts on a screen the caller has initialised.
62+func newApp(screen tcell.Screen, opts Options) *app {
63+ wake := func() { _ = screen.PostEvent(tcell.NewEventInterrupt(nil)) }
64+ t := &Transcript{}
65+ r := newRunner(opts, t, wake)
66+ r.clip = clipboard.New(screen.SetClipboard)
67+ v := newView(screen, t, opts.AgentName, hooks{
68+ send: r.submit,
69+ cancel: r.cancelTurn,
70+ quit: r.quitNow,
71+ help: r.help,
72+ answer: r.answer,
73+ copy: func() { r.copy("") },
74+ })
75+ v.previewLines = config.Cfg.PreviewLines
76+ if opts.Banner != "" {
77+ t.Notice(opts.Banner)
78+ }
79+ if hint := r.keep.Hint(); hint != "" {
80+ t.Notice(hint)
81+ }
82+ return &app{screen: screen, t: t, r: r, v: v}
83+}
84+
85+// run is the event loop on any screen — the real one, or a simulation in
86+// tests. Every frame: take finished jobs, refresh the chrome, draw, wait for
87+// an event, handle it. Nothing outside this goroutine draws.
88+func run(ctx context.Context, screen tcell.Screen, opts Options) error {
89+ return newApp(screen, opts).loop(ctx)
90+}
91+
92+// loop runs until /quit, Ctrl-C when idle, or ctx is cancelled.
93+func (a *app) loop(ctx context.Context) error {
94+ // Bracketed paste, yes; mouse capture, no: with the mouse captured the
95+ // terminal hands every click to us and its own click-and-drag selection
96+ // — how people copy text out of a terminal — stops working. Wheel
97+ // scrolling is one F2 away (events.go), and says so in the status bar.
98+ a.screen.EnablePaste()
99+ wake := func() { _ = a.screen.PostEvent(tcell.NewEventInterrupt(nil)) }
100+
101+ stop := make(chan struct{})
102+ defer close(stop)
103+ go animate(a.r, wake, stop)
104+ go func() {
105+ select {
106+ case <-ctx.Done():
107+ a.r.quitNow()
108+ case <-stop:
109+ }
110+ }()
111+
112+ for !a.r.quit.Load() {
113+ a.frame()
114+ ev := a.screen.PollEvent()
115+ if ev == nil {
116+ break // the screen was finalised from under us
117+ }
118+ if _, ok := ev.(*tcell.EventInterrupt); ok {
119+ continue
120+ }
121+ a.v.handle(ev)
122+ }
123+ a.r.cancelTurn()
124+ return nil
125+}
126+
127+// frame is one turn of the loop's state-driven work, then a redraw.
128+func (a *app) frame() {
129+ 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.draw()
132+}
133+
134+// animate wakes the loop while a job runs, so the spinner turns and streamed
135+// text shows up even between events.
136+func animate(r *runner, wake func(), stop <-chan struct{}) {
137+ tick := time.NewTicker(100 * time.Millisecond)
138+ defer tick.Stop()
139+ for {
140+ select {
141+ case <-stop:
142+ return
143+ case <-tick.C:
144+ if r.isBusy() {
145+ wake()
146+ }
147+ }
148+ }
149+}
new file mode 100644
@@ -0,0 +1,149 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "time"
8+
9+ "mm/internal/clipboard"
10+ "mm/internal/config"
11+
12+ "github.com/gdamore/tcell/v2"
13+)
14+
15+// ErrNotTerminal is what Run returns when stdin or stdout is not a terminal:
16+// the caller falls back to the line-mode REPL, so `mm -tui | tee` still works.
17+var ErrNotTerminal = errors.New("stdin or stdout is not a terminal")
18+
19+// Terminal reports whether both stdin and stdout are terminals — the
20+// condition for the window to be drawn at all.
21+func Terminal() bool {
22+ return isTerminal(os.Stdin) && isTerminal(os.Stdout)
23+}
24+
25+func isTerminal(f *os.File) bool {
26+ info, err := f.Stat()
27+ return err == nil && info.Mode()&os.ModeCharDevice != 0
28+}
29+
30+// Run opens the window on the real terminal and blocks until the user quits
31+// (/quit, Ctrl-C when idle) or ctx is cancelled. It returns ErrNotTerminal
32+// without drawing anything when there is no terminal to draw on.
33+//
34+// if err := tui.Run(ctx, opts); errors.Is(err, tui.ErrNotTerminal) {
35+// agent.Run(…) // line mode instead
36+// }
37+func Run(ctx context.Context, opts Options) error {
38+ if !Terminal() {
39+ return ErrNotTerminal
40+ }
41+ screen, err := tcell.NewScreen()
42+ if err != nil {
43+ return err
44+ }
45+ if err := screen.Init(); err != nil {
46+ return err
47+ }
48+ defer screen.Fini()
49+ return run(ctx, screen, opts)
50+}
51+
52+// app is one window: the screen, the view drawing on it, the runner owning
53+// the session, and the transcript they share.
54+type app struct {
55+ screen tcell.Screen
56+ t *Transcript
57+ r *runner
58+ v *View
59+}
60+
61+// newApp wires the three parts on a screen the caller has initialised.
62+func newApp(screen tcell.Screen, opts Options) *app {
63+ wake := func() { _ = screen.PostEvent(tcell.NewEventInterrupt(nil)) }
64+ t := &Transcript{}
65+ r := newRunner(opts, t, wake)
66+ r.clip = clipboard.New(screen.SetClipboard)
67+ v := newView(screen, t, opts.AgentName, hooks{
68+ send: r.submit,
69+ cancel: r.cancelTurn,
70+ quit: r.quitNow,
71+ help: r.help,
72+ answer: r.answer,
73+ copy: func() { r.copy("") },
74+ })
75+ v.previewLines = config.Cfg.PreviewLines
76+ if opts.Banner != "" {
77+ t.Notice(opts.Banner)
78+ }
79+ if hint := r.keep.Hint(); hint != "" {
80+ t.Notice(hint)
81+ }
82+ return &app{screen: screen, t: t, r: r, v: v}
83+}
84+
85+// run is the event loop on any screen — the real one, or a simulation in
86+// tests. Every frame: take finished jobs, refresh the chrome, draw, wait for
87+// an event, handle it. Nothing outside this goroutine draws.
88+func run(ctx context.Context, screen tcell.Screen, opts Options) error {
89+ return newApp(screen, opts).loop(ctx)
90+}
91+
92+// loop runs until /quit, Ctrl-C when idle, or ctx is cancelled.
93+func (a *app) loop(ctx context.Context) error {
94+ // Bracketed paste, yes; mouse capture, no: with the mouse captured the
95+ // terminal hands every click to us and its own click-and-drag selection
96+ // — how people copy text out of a terminal — stops working. Wheel
97+ // scrolling is one F2 away (events.go), and says so in the status bar.
98+ a.screen.EnablePaste()
99+ wake := func() { _ = a.screen.PostEvent(tcell.NewEventInterrupt(nil)) }
100+
101+ stop := make(chan struct{})
102+ defer close(stop)
103+ go animate(a.r, wake, stop)
104+ go func() {
105+ select {
106+ case <-ctx.Done():
107+ a.r.quitNow()
108+ case <-stop:
109+ }
110+ }()
111+
112+ for !a.r.quit.Load() {
113+ a.frame()
114+ ev := a.screen.PollEvent()
115+ if ev == nil {
116+ break // the screen was finalised from under us
117+ }
118+ if _, ok := ev.(*tcell.EventInterrupt); ok {
119+ continue
120+ }
121+ a.v.handle(ev)
122+ }
123+ a.r.cancelTurn()
124+ return nil
125+}
126+
127+// frame is one turn of the loop's state-driven work, then a redraw.
128+func (a *app) frame() {
129+ 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.draw()
132+}
133+
134+// animate wakes the loop while a job runs, so the spinner turns and streamed
135+// text shows up even between events.
136+func animate(r *runner, wake func(), stop <-chan struct{}) {
137+ tick := time.NewTicker(100 * time.Millisecond)
138+ defer tick.Stop()
139+ for {
140+ select {
141+ case <-stop:
142+ return
143+ case <-tick.C:
144+ if r.isBusy() {
145+ wake()
146+ }
147+ }
148+ }
149+}
added internal/tui/tui_test.go +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+
13+ "github.com/gdamore/tcell/v2"
14+)
15+
16+// Under `go test` stdin is not a terminal: Run must refuse without drawing,
17+// with the error main falls back on.
18+func TestRunRefusesWithoutTerminal(t *testing.T) {
19+ if Terminal() {
20+ t.Skip("running on a real terminal")
21+ }
22+ if err := Run(context.Background(), Options{}); !errors.Is(err, ErrNotTerminal) {
23+ t.Errorf("Run without a terminal: %v", err)
24+ }
25+}
26+
27+// The whole loop on a simulation screen: the banner and a question show up,
28+// the answer reaches the window, and /quit ends the loop. The screen is read
29+// only after the loop has returned — the simulation screen is not safe to
30+// read while another goroutine draws on it.
31+func TestRunLoopEndToEnd(t *testing.T) {
32+ saved := config.Cfg
33+ t.Cleanup(func() { config.Cfg = saved })
34+ config.Cfg.Sessions.Enabled = false
35+
36+ screen := tcell.NewSimulationScreen("UTF-8")
37+ if err := screen.Init(); err != nil {
38+ t.Fatal(err)
39+ }
40+ screen.SetSize(60, 16)
41+ m := &fakeModel{answer: "forty-two"}
42+ a := newApp(screen, Options{
43+ Model: model{m}, Window: func() int { return 0 }, Detector: detector.NewLoopDetector(10, 3),
44+ System: "sys", Cwd: t.TempDir(), AgentName: "mm", ModelName: "model", Provider: "dmr", Banner: "config: test",
45+ })
46+ done := make(chan error, 1)
47+ go func() { done <- a.loop(context.Background()) }()
48+ typeKeys := func(s string) {
49+ for _, r := range s {
50+ screen.InjectKey(tcell.KeyRune, r, tcell.ModNone)
51+ }
52+ }
53+ typeKeys("six times seven?")
54+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
55+
56+ answered := func() bool {
57+ for _, e := range a.t.Entries() {
58+ if e.Kind == KindAgent && e.Text == "forty-two" {
59+ return true
60+ }
61+ }
62+ return false
63+ }
64+ deadline := time.Now().Add(5 * time.Second)
65+ for (!answered() || a.r.isBusy()) && time.Now().Before(deadline) {
66+ time.Sleep(10 * time.Millisecond)
67+ }
68+ if !answered() {
69+ t.Fatal("the answer never reached the transcript")
70+ }
71+ // One more frame is drawn before /quit is handled, so the last frame
72+ // holds the whole conversation.
73+ typeKeys("/quit")
74+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
75+ select {
76+ case err := <-done:
77+ if err != nil {
78+ t.Errorf("loop: %v", err)
79+ }
80+ case <-time.After(5 * time.Second):
81+ t.Fatal("/quit did not end the loop")
82+ }
83+ cells, w, h := screen.GetContents()
84+ var b strings.Builder
85+ for y := 0; y < h; y++ {
86+ for x := 0; x < w; x++ {
87+ if c := cells[y*w+x]; len(c.Runes) > 0 {
88+ b.WriteString(string(c.Runes))
89+ } else {
90+ b.WriteByte(' ')
91+ }
92+ }
93+ b.WriteByte('\n')
94+ }
95+ for _, want := range []string{"config: test", "six times seven?", "forty-two", "mm · model @ dmr", "> /quit"} {
96+ if !strings.Contains(b.String(), want) {
97+ t.Errorf("last frame misses %q:\n%s", want, b.String())
98+ }
99+ }
100+ screen.Fini()
101+}
new file mode 100644
@@ -0,0 +1,101 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "mm/internal/config"
11+ "mm/internal/detector"
12+
13+ "github.com/gdamore/tcell/v2"
14+)
15+
16+// Under `go test` stdin is not a terminal: Run must refuse without drawing,
17+// with the error main falls back on.
18+func TestRunRefusesWithoutTerminal(t *testing.T) {
19+ if Terminal() {
20+ t.Skip("running on a real terminal")
21+ }
22+ if err := Run(context.Background(), Options{}); !errors.Is(err, ErrNotTerminal) {
23+ t.Errorf("Run without a terminal: %v", err)
24+ }
25+}
26+
27+// The whole loop on a simulation screen: the banner and a question show up,
28+// the answer reaches the window, and /quit ends the loop. The screen is read
29+// only after the loop has returned — the simulation screen is not safe to
30+// read while another goroutine draws on it.
31+func TestRunLoopEndToEnd(t *testing.T) {
32+ saved := config.Cfg
33+ t.Cleanup(func() { config.Cfg = saved })
34+ config.Cfg.Sessions.Enabled = false
35+
36+ screen := tcell.NewSimulationScreen("UTF-8")
37+ if err := screen.Init(); err != nil {
38+ t.Fatal(err)
39+ }
40+ screen.SetSize(60, 16)
41+ m := &fakeModel{answer: "forty-two"}
42+ a := newApp(screen, Options{
43+ Model: model{m}, Window: func() int { return 0 }, Detector: detector.NewLoopDetector(10, 3),
44+ System: "sys", Cwd: t.TempDir(), AgentName: "mm", ModelName: "model", Provider: "dmr", Banner: "config: test",
45+ })
46+ done := make(chan error, 1)
47+ go func() { done <- a.loop(context.Background()) }()
48+ typeKeys := func(s string) {
49+ for _, r := range s {
50+ screen.InjectKey(tcell.KeyRune, r, tcell.ModNone)
51+ }
52+ }
53+ typeKeys("six times seven?")
54+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
55+
56+ answered := func() bool {
57+ for _, e := range a.t.Entries() {
58+ if e.Kind == KindAgent && e.Text == "forty-two" {
59+ return true
60+ }
61+ }
62+ return false
63+ }
64+ deadline := time.Now().Add(5 * time.Second)
65+ for (!answered() || a.r.isBusy()) && time.Now().Before(deadline) {
66+ time.Sleep(10 * time.Millisecond)
67+ }
68+ if !answered() {
69+ t.Fatal("the answer never reached the transcript")
70+ }
71+ // One more frame is drawn before /quit is handled, so the last frame
72+ // holds the whole conversation.
73+ typeKeys("/quit")
74+ screen.InjectKey(tcell.KeyEnter, 0, tcell.ModNone)
75+ select {
76+ case err := <-done:
77+ if err != nil {
78+ t.Errorf("loop: %v", err)
79+ }
80+ case <-time.After(5 * time.Second):
81+ t.Fatal("/quit did not end the loop")
82+ }
83+ cells, w, h := screen.GetContents()
84+ var b strings.Builder
85+ for y := 0; y < h; y++ {
86+ for x := 0; x < w; x++ {
87+ if c := cells[y*w+x]; len(c.Runes) > 0 {
88+ b.WriteString(string(c.Runes))
89+ } else {
90+ b.WriteByte(' ')
91+ }
92+ }
93+ b.WriteByte('\n')
94+ }
95+ for _, want := range []string{"config: test", "six times seven?", "forty-two", "mm · model @ dmr", "> /quit"} {
96+ if !strings.Contains(b.String(), want) {
97+ t.Errorf("last frame misses %q:\n%s", want, b.String())
98+ }
99+ }
100+ screen.Fini()
101+}
added internal/tui/turn.go +441 -0
new file mode 100644
@@ -0,0 +1,441 @@
1+package tui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "sync/atomic"
9+
10+ "mm/internal/command"
11+ "mm/internal/compact"
12+ "mm/internal/config"
13+ "mm/internal/detector"
14+ "mm/internal/engine"
15+ "mm/internal/mention"
16+ "mm/internal/session"
17+ "mm/internal/store"
18+ "mm/internal/ui"
19+
20+ "github.com/firebase/genkit/go/ai"
21+)
22+
23+// Model is the slice of engine.Engine the window uses, as an interface so
24+// the runner is testable with a canned model.
25+type Model interface {
26+ Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error)
27+ Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error)
28+ Explain(err error) string
29+ LastInputTokens() int
30+ ForgetInputTokens()
31+}
32+
33+// Options is what Run needs from main: the same wiring as the two other
34+// front ends, plus what the header shows.
35+type Options struct {
36+ Model Model
37+ Window func() int // the context window the engine knows now (0 = unknown)
38+ Ensure func(ctx context.Context) (int, string) // ask the server again while unknown; may be nil
39+ Tools []ai.ToolRef
40+ Detector *detector.LoopDetector
41+ System string
42+ Cwd string
43+ // Header fields.
44+ AgentName string // "mm"
45+ ModelName string
46+ Provider string
47+ // Banner is the start-up text main printed (or would have); shown as
48+ // the first notice, since stdout is the window now.
49+ Banner string
50+}
51+
52+// Copier is what /copy writes to: clipboard.Copier, or a fake in tests.
53+type Copier interface {
54+ Copy(text string) (how string, err error)
55+}
56+
57+// outcome is what a background job — a turn or a compaction — reports back
58+// to the loop goroutine.
59+type outcome struct {
60+ kind string // "turn" or "compact"
61+ messages []*ai.Message
62+ before int
63+ resp *ai.ModelResponse
64+ history []*ai.Message
65+ err error
66+ // cancelled says the turn's context was cut — by Esc, Ctrl-C or a new
67+ // question — which is not a failure to report as one.
68+ cancelled bool
69+ line string
70+ changed bool
71+}
72+
73+// runner owns the session — history, engine, keeper, detector — and runs
74+// turns on a goroutine; the loop goroutine calls settle to take the results.
75+// It is the window's counterpart of agent.Run, with the same rules: the full
76+// history is kept after a failure or a cancellation, compaction runs before a
77+// question is appended, and the history only moves between two turns.
78+type runner struct {
79+ opts Options
80+ t *Transcript
81+ wake func()
82+ keep *store.Keeper
83+ clip Copier // set by newApp; nil means /copy reports that it cannot
84+
85+ messages []*ai.Message
86+ busy atomic.Value // string: "", "thinking", "compressing"
87+ quit atomic.Bool
88+ cancel context.CancelFunc
89+ results chan outcome
90+ pending Prompt // input to run once the cancelled turn has settled
91+
92+ askMu sync.Mutex
93+ ask *permission
94+ allowAlways map[string]bool
95+
96+ // streamed says the sink saw at least one chunk this turn. An answer
97+ // that did not stream (a test model, or a server answering in one block
98+ // without the retry path) is shown from the response instead.
99+ streamed atomic.Bool
100+}
101+
102+func newRunner(opts Options, t *Transcript, wake func()) *runner {
103+ r := &runner{
104+ opts: opts,
105+ t: t,
106+ wake: wake,
107+ keep: store.NewKeeper(opts.Cwd),
108+ messages: session.Fresh(opts.System),
109+ results: make(chan outcome, 1),
110+ allowAlways: map[string]bool{},
111+ }
112+ r.busy.Store("")
113+ return r
114+}
115+
116+// busyLabel is what the rule shows: "", "thinking" or "compressing".
117+func (r *runner) busyLabel() string { return r.busy.Load().(string) }
118+
119+// isBusy reports whether a job is running.
120+func (r *runner) isBusy() bool { return r.busyLabel() != "" }
121+
122+// submit is what Enter does with the box: a slash command or a question.
123+// During a turn it cancels the turn and keeps the text for when the turn has
124+// settled — the REPL's "new command received" rule.
125+func (r *runner) submit(p Prompt) {
126+ if r.isBusy() {
127+ r.pending = p
128+ r.cancelTurn()
129+ return
130+ }
131+ r.dispatch(p)
132+}
133+
134+// dispatch runs a slash command, or starts a turn. A command is read from
135+// the typed text: a paste token is never part of a command name.
136+func (r *runner) dispatch(p Prompt) {
137+ cmd, arg, ok := command.Parse(p.Typed)
138+ if !ok {
139+ r.startTurn(p)
140+ return
141+ }
142+ switch cmd.Name {
143+ case command.Quit:
144+ r.quitNow()
145+ case command.Abort:
146+ r.t.Notice("⚠️ No generation in progress.")
147+ case command.Compact:
148+ r.startCompact()
149+ case command.New:
150+ r.newSession()
151+ case command.Sessions:
152+ r.t.Notice(r.keep.Listing())
153+ case command.Resume:
154+ r.resume(arg)
155+ case command.Help:
156+ r.help()
157+ case command.Copy:
158+ r.copy(arg)
159+ }
160+}
161+
162+// help lists the window's commands and keys.
163+func (r *runner) help() {
164+ 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")
165+}
166+
167+// quitNow ends the loop on its next turn.
168+func (r *runner) quitNow() {
169+ r.quit.Store(true)
170+ r.wake()
171+}
172+
173+// cancelTurn stops the job in progress and reports whether there was one.
174+func (r *runner) cancelTurn() bool {
175+ if !r.isBusy() || r.cancel == nil {
176+ return false
177+ }
178+ r.cancel()
179+ return true
180+}
181+
182+// newSession is /new: the same three resets as the REPL's startNewSession.
183+func (r *runner) newSession() {
184+ forgotten := session.Forgotten(r.messages)
185+ r.messages = session.Fresh(r.opts.System)
186+ r.opts.Model.ForgetInputTokens()
187+ r.opts.Detector.Reset()
188+ r.keep.Renew()
189+ r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
190+}
191+
192+// resume is /resume [id]: a stored session becomes the current one.
193+func (r *runner) resume(id string) {
194+ msgs, line, err := r.keep.Resume(id, r.opts.System)
195+ if err != nil {
196+ r.t.Error("⚠️ " + err.Error())
197+ return
198+ }
199+ r.messages = msgs
200+ r.opts.Detector.Reset()
201+ r.opts.Model.ForgetInputTokens()
202+ r.t.Notice(line)
203+}
204+
205+// startTurn appends the question and generates on a goroutine. The
206+// automatic compaction runs there too, before the question is appended, so
207+// the window stays responsive while the summary is written.
208+//
209+// The conversation shows the question as typed, pastes collapsed; the model
210+// reads it with the pastes expanded. @path mentions are looked up in the
211+// typed text only: a pasted block is data the user brought, not a request
212+// to attach whatever paths it happens to name.
213+func (r *runner) startTurn(p Prompt) {
214+ r.t.AddUserFull(p.Display(), p.Text())
215+ expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
216+ input := p.Expand(expanded)
217+ for _, a := range attached {
218+ r.t.Notice("📎 " + a.Path)
219+ }
220+ ctx, cancel := context.WithCancel(context.Background())
221+ r.cancel = cancel
222+ r.busy.Store("thinking")
223+ r.streamed.Store(false)
224+ msgs := append([]*ai.Message(nil), r.messages...)
225+ go func() {
226+ msgs = r.compactIfDue(ctx, msgs)
227+ msgs = append(msgs, ai.NewUserTextMessage(input))
228+ before := len(msgs)
229+ ui.SetActive(&sink{r: r})
230+ defer ui.SetActive(nil)
231+ resp, history, err := r.opts.Model.Generate(ctx, msgs, r.opts.Tools)
232+ if ctx.Err() != nil && err == nil {
233+ err = ctx.Err()
234+ }
235+ r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil}
236+ r.wake()
237+ }()
238+}
239+
240+// compactIfDue is the automatic compaction, the REPL's rule verbatim: the
241+// server's own count of the last context beats the estimate, the window is
242+// re-probed while unknown, and a failure keeps the history.
243+func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Message {
244+ cfg := config.Cfg.Context
245+ if !cfg.Enabled {
246+ return msgs
247+ }
248+ if r.opts.Window() == 0 && r.opts.Ensure != nil {
249+ if w, src := r.opts.Ensure(ctx); w > 0 {
250+ r.t.Notice(fmt.Sprintf("ctx: %d (%s)", w, src))
251+ }
252+ }
253+ dec := compact.Decide(msgs, r.opts.Model.LastInputTokens(), r.opts.Window(), cfg)
254+ if !dec.Compact {
255+ return msgs
256+ }
257+ out, line, outcome := compact.Apply(ctx, msgs, cfg, r.summarize, r.opts.Model.Explain)
258+ switch outcome {
259+ case compact.Compressed:
260+ r.opts.Model.ForgetInputTokens()
261+ if cfg.ShowStats {
262+ r.t.Notice(line)
263+ }
264+ case compact.Failed:
265+ r.t.Error(line)
266+ }
267+ return out
268+}
269+
270+func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, error) {
271+ return r.opts.Model.Summarize(ctx, request, config.Cfg.Context.SummaryMaxTokens)
272+}
273+
274+// startCompact is /compact: forced, on a goroutine, reported as a notice.
275+func (r *runner) startCompact() {
276+ ctx, cancel := context.WithCancel(context.Background())
277+ r.cancel = cancel
278+ r.busy.Store("compressing")
279+ msgs := append([]*ai.Message(nil), r.messages...)
280+ go func() {
281+ out, line, oc := compact.Apply(ctx, msgs, config.Cfg.Context, r.summarize, r.opts.Model.Explain)
282+ r.results <- outcome{kind: "compact", messages: out, line: line, changed: oc == compact.Compressed, err: errIf(oc == compact.Failed)}
283+ r.wake()
284+ }()
285+}
286+
287+func errIf(failed bool) error {
288+ if failed {
289+ return fmt.Errorf("compaction failed")
290+ }
291+ return nil
292+}
293+
294+// settle takes a finished job, if any, and applies it to the session. Called
295+// by the loop before every frame — the one goroutine that touches messages.
296+func (r *runner) settle() {
297+ select {
298+ case res := <-r.results:
299+ r.apply(res)
300+ default:
301+ return
302+ }
303+ r.busy.Store("")
304+ r.cancel = nil
305+ if p := r.pending; p.Typed != "" {
306+ r.pending = Prompt{}
307+ r.dispatch(p)
308+ }
309+}
310+
311+// apply folds one outcome into the session and the transcript.
312+func (r *runner) apply(res outcome) {
313+ if res.kind == "compact" {
314+ r.messages = res.messages
315+ switch {
316+ case res.err != nil:
317+ r.t.Error(res.line)
318+ case res.changed:
319+ r.opts.Model.ForgetInputTokens()
320+ r.save()
321+ r.t.Notice(res.line)
322+ default:
323+ r.t.Notice(res.line)
324+ }
325+ return
326+ }
327+ r.messages = session.AfterTurn(res.messages, res.before, res.resp, res.history, res.err)
328+ r.save()
329+ if !r.streamed.Load() && res.resp != nil && res.resp.Message != nil {
330+ r.t.Chunk(res.resp.Message.Text())
331+ }
332+ r.t.Close()
333+ switch {
334+ case res.err != nil && res.cancelled:
335+ r.t.Notice("🛑 Generation aborted.")
336+ case res.err != nil:
337+ r.t.Error("[error: " + r.opts.Model.Explain(res.err) + "]")
338+ default:
339+ r.recordCalls(res.resp)
340+ }
341+ r.t.Notice(recap(r.messages[min(res.before, len(r.messages)):]))
342+}
343+
344+// recordCalls feeds the loop detector with the answer's tool requests, as the
345+// REPL does after a successful turn.
346+func (r *runner) recordCalls(resp *ai.ModelResponse) {
347+ if resp == nil || resp.Message == nil {
348+ return
349+ }
350+ for _, part := range resp.Message.Content {
351+ if part.IsToolRequest() && part.ToolRequest != nil {
352+ r.opts.Detector.Record(detector.Action{
353+ ToolName: part.ToolRequest.Name,
354+ Input: fmt.Sprintf("%v", part.ToolRequest.Input),
355+ Output: "[tool_call]",
356+ })
357+ }
358+ }
359+}
360+
361+// recap is the ⚙ line of a turn: how much the model worked.
362+func recap(turn []*ai.Message) string {
363+ line := fmt.Sprintf("⚙ %d command(s)", len(engine.CommandList(turn)))
364+ if ops := engine.FileOps(turn); len(ops) > 0 {
365+ line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
366+ }
367+ if k := engine.Skills(turn); k > 0 {
368+ line += fmt.Sprintf(" · 📖 %d skill(s)", k)
369+ }
370+ return line
371+}
372+
373+// save rewrites the session file and reports a failure as a notice.
374+func (r *runner) save() {
375+ if err := r.keep.Save(r.messages); err != nil {
376+ r.t.Error("[sessions: not saved: " + err.Error() + "]")
377+ }
378+}
379+
380+// header is the top line: who is answering, how full the context is, which
381+// session is being saved.
382+func (r *runner) header() string {
383+ var b strings.Builder
384+ fmt.Fprintf(&b, "%s · %s @ %s", r.opts.AgentName, r.opts.ModelName, r.opts.Provider)
385+ used := max(r.opts.Model.LastInputTokens(), compact.Estimate(r.messages))
386+ if w := r.opts.Window(); w > 0 {
387+ fmt.Fprintf(&b, " · ctx %d%% (%s/%s)", used*100/w, compact.Kilo(used), compact.Kilo(w))
388+ } else {
389+ fmt.Fprintf(&b, " · ctx ~%s (window unknown)", compact.Kilo(used))
390+ }
391+ if id := r.keep.CurrentID(); id != "" {
392+ fmt.Fprintf(&b, " · session %s", id)
393+ }
394+ return b.String()
395+}
396+
397+// status is the right-hand side of the bottom bar.
398+func (r *runner) status() string {
399+ return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools())
400+}
401+
402+// --- permissions ------------------------------------------------------------------
403+
404+// currentAsk is the permission waiting for an answer, nil when none.
405+func (r *runner) currentAsk() *permission {
406+ r.askMu.Lock()
407+ defer r.askMu.Unlock()
408+ return r.ask
409+}
410+
411+// answer settles the pending permission from the loop goroutine.
412+func (r *runner) answer(a permissionAnswer) {
413+ r.askMu.Lock()
414+ defer r.askMu.Unlock()
415+ if r.ask == nil {
416+ return
417+ }
418+ r.ask.reply <- a
419+ r.ask = nil
420+}
421+
422+// request posts a permission for the view to show and waits for the answer,
423+// or for the turn to end. Called on the tool's goroutine.
424+func (r *runner) request(ctx context.Context, ev ui.ToolEvent) permissionAnswer {
425+ p := &permission{tool: ev.Tool, title: ev.Title, reply: make(chan permissionAnswer, 1)}
426+ r.askMu.Lock()
427+ r.ask = p
428+ r.askMu.Unlock()
429+ r.wake()
430+ select {
431+ case a := <-p.reply:
432+ return a
433+ case <-ctx.Done():
434+ r.askMu.Lock()
435+ if r.ask == p {
436+ r.ask = nil
437+ }
438+ r.askMu.Unlock()
439+ return permReject
440+ }
441+}
new file mode 100644
@@ -0,0 +1,441 @@
1+package tui
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "sync/atomic"
9+
10+ "mm/internal/command"
11+ "mm/internal/compact"
12+ "mm/internal/config"
13+ "mm/internal/detector"
14+ "mm/internal/engine"
15+ "mm/internal/mention"
16+ "mm/internal/session"
17+ "mm/internal/store"
18+ "mm/internal/ui"
19+
20+ "github.com/firebase/genkit/go/ai"
21+)
22+
23+// Model is the slice of engine.Engine the window uses, as an interface so
24+// the runner is testable with a canned model.
25+type Model interface {
26+ Generate(ctx context.Context, messages []*ai.Message, tools []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error)
27+ Summarize(ctx context.Context, messages []*ai.Message, maxTokens int) (string, error)
28+ Explain(err error) string
29+ LastInputTokens() int
30+ ForgetInputTokens()
31+}
32+
33+// Options is what Run needs from main: the same wiring as the two other
34+// front ends, plus what the header shows.
35+type Options struct {
36+ Model Model
37+ Window func() int // the context window the engine knows now (0 = unknown)
38+ Ensure func(ctx context.Context) (int, string) // ask the server again while unknown; may be nil
39+ Tools []ai.ToolRef
40+ Detector *detector.LoopDetector
41+ System string
42+ Cwd string
43+ // Header fields.
44+ AgentName string // "mm"
45+ ModelName string
46+ Provider string
47+ // Banner is the start-up text main printed (or would have); shown as
48+ // the first notice, since stdout is the window now.
49+ Banner string
50+}
51+
52+// Copier is what /copy writes to: clipboard.Copier, or a fake in tests.
53+type Copier interface {
54+ Copy(text string) (how string, err error)
55+}
56+
57+// outcome is what a background job — a turn or a compaction — reports back
58+// to the loop goroutine.
59+type outcome struct {
60+ kind string // "turn" or "compact"
61+ messages []*ai.Message
62+ before int
63+ resp *ai.ModelResponse
64+ history []*ai.Message
65+ err error
66+ // cancelled says the turn's context was cut — by Esc, Ctrl-C or a new
67+ // question — which is not a failure to report as one.
68+ cancelled bool
69+ line string
70+ changed bool
71+}
72+
73+// runner owns the session — history, engine, keeper, detector — and runs
74+// turns on a goroutine; the loop goroutine calls settle to take the results.
75+// It is the window's counterpart of agent.Run, with the same rules: the full
76+// history is kept after a failure or a cancellation, compaction runs before a
77+// question is appended, and the history only moves between two turns.
78+type runner struct {
79+ opts Options
80+ t *Transcript
81+ wake func()
82+ keep *store.Keeper
83+ clip Copier // set by newApp; nil means /copy reports that it cannot
84+
85+ messages []*ai.Message
86+ busy atomic.Value // string: "", "thinking", "compressing"
87+ quit atomic.Bool
88+ cancel context.CancelFunc
89+ results chan outcome
90+ pending Prompt // input to run once the cancelled turn has settled
91+
92+ askMu sync.Mutex
93+ ask *permission
94+ allowAlways map[string]bool
95+
96+ // streamed says the sink saw at least one chunk this turn. An answer
97+ // that did not stream (a test model, or a server answering in one block
98+ // without the retry path) is shown from the response instead.
99+ streamed atomic.Bool
100+}
101+
102+func newRunner(opts Options, t *Transcript, wake func()) *runner {
103+ r := &runner{
104+ opts: opts,
105+ t: t,
106+ wake: wake,
107+ keep: store.NewKeeper(opts.Cwd),
108+ messages: session.Fresh(opts.System),
109+ results: make(chan outcome, 1),
110+ allowAlways: map[string]bool{},
111+ }
112+ r.busy.Store("")
113+ return r
114+}
115+
116+// busyLabel is what the rule shows: "", "thinking" or "compressing".
117+func (r *runner) busyLabel() string { return r.busy.Load().(string) }
118+
119+// isBusy reports whether a job is running.
120+func (r *runner) isBusy() bool { return r.busyLabel() != "" }
121+
122+// submit is what Enter does with the box: a slash command or a question.
123+// During a turn it cancels the turn and keeps the text for when the turn has
124+// settled — the REPL's "new command received" rule.
125+func (r *runner) submit(p Prompt) {
126+ if r.isBusy() {
127+ r.pending = p
128+ r.cancelTurn()
129+ return
130+ }
131+ r.dispatch(p)
132+}
133+
134+// dispatch runs a slash command, or starts a turn. A command is read from
135+// the typed text: a paste token is never part of a command name.
136+func (r *runner) dispatch(p Prompt) {
137+ cmd, arg, ok := command.Parse(p.Typed)
138+ if !ok {
139+ r.startTurn(p)
140+ return
141+ }
142+ switch cmd.Name {
143+ case command.Quit:
144+ r.quitNow()
145+ case command.Abort:
146+ r.t.Notice("⚠️ No generation in progress.")
147+ case command.Compact:
148+ r.startCompact()
149+ case command.New:
150+ r.newSession()
151+ case command.Sessions:
152+ r.t.Notice(r.keep.Listing())
153+ case command.Resume:
154+ r.resume(arg)
155+ case command.Help:
156+ r.help()
157+ case command.Copy:
158+ r.copy(arg)
159+ }
160+}
161+
162+// help lists the window's commands and keys.
163+func (r *runner) help() {
164+ 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")
165+}
166+
167+// quitNow ends the loop on its next turn.
168+func (r *runner) quitNow() {
169+ r.quit.Store(true)
170+ r.wake()
171+}
172+
173+// cancelTurn stops the job in progress and reports whether there was one.
174+func (r *runner) cancelTurn() bool {
175+ if !r.isBusy() || r.cancel == nil {
176+ return false
177+ }
178+ r.cancel()
179+ return true
180+}
181+
182+// newSession is /new: the same three resets as the REPL's startNewSession.
183+func (r *runner) newSession() {
184+ forgotten := session.Forgotten(r.messages)
185+ r.messages = session.Fresh(r.opts.System)
186+ r.opts.Model.ForgetInputTokens()
187+ r.opts.Detector.Reset()
188+ r.keep.Renew()
189+ r.t.Notice(fmt.Sprintf("🆕 new session: %d message(s) forgotten", forgotten))
190+}
191+
192+// resume is /resume [id]: a stored session becomes the current one.
193+func (r *runner) resume(id string) {
194+ msgs, line, err := r.keep.Resume(id, r.opts.System)
195+ if err != nil {
196+ r.t.Error("⚠️ " + err.Error())
197+ return
198+ }
199+ r.messages = msgs
200+ r.opts.Detector.Reset()
201+ r.opts.Model.ForgetInputTokens()
202+ r.t.Notice(line)
203+}
204+
205+// startTurn appends the question and generates on a goroutine. The
206+// automatic compaction runs there too, before the question is appended, so
207+// the window stays responsive while the summary is written.
208+//
209+// The conversation shows the question as typed, pastes collapsed; the model
210+// reads it with the pastes expanded. @path mentions are looked up in the
211+// typed text only: a pasted block is data the user brought, not a request
212+// to attach whatever paths it happens to name.
213+func (r *runner) startTurn(p Prompt) {
214+ r.t.AddUserFull(p.Display(), p.Text())
215+ expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
216+ input := p.Expand(expanded)
217+ for _, a := range attached {
218+ r.t.Notice("📎 " + a.Path)
219+ }
220+ ctx, cancel := context.WithCancel(context.Background())
221+ r.cancel = cancel
222+ r.busy.Store("thinking")
223+ r.streamed.Store(false)
224+ msgs := append([]*ai.Message(nil), r.messages...)
225+ go func() {
226+ msgs = r.compactIfDue(ctx, msgs)
227+ msgs = append(msgs, ai.NewUserTextMessage(input))
228+ before := len(msgs)
229+ ui.SetActive(&sink{r: r})
230+ defer ui.SetActive(nil)
231+ resp, history, err := r.opts.Model.Generate(ctx, msgs, r.opts.Tools)
232+ if ctx.Err() != nil && err == nil {
233+ err = ctx.Err()
234+ }
235+ r.results <- outcome{kind: "turn", messages: msgs, before: before, resp: resp, history: history, err: err, cancelled: ctx.Err() != nil}
236+ r.wake()
237+ }()
238+}
239+
240+// compactIfDue is the automatic compaction, the REPL's rule verbatim: the
241+// server's own count of the last context beats the estimate, the window is
242+// re-probed while unknown, and a failure keeps the history.
243+func (r *runner) compactIfDue(ctx context.Context, msgs []*ai.Message) []*ai.Message {
244+ cfg := config.Cfg.Context
245+ if !cfg.Enabled {
246+ return msgs
247+ }
248+ if r.opts.Window() == 0 && r.opts.Ensure != nil {
249+ if w, src := r.opts.Ensure(ctx); w > 0 {
250+ r.t.Notice(fmt.Sprintf("ctx: %d (%s)", w, src))
251+ }
252+ }
253+ dec := compact.Decide(msgs, r.opts.Model.LastInputTokens(), r.opts.Window(), cfg)
254+ if !dec.Compact {
255+ return msgs
256+ }
257+ out, line, outcome := compact.Apply(ctx, msgs, cfg, r.summarize, r.opts.Model.Explain)
258+ switch outcome {
259+ case compact.Compressed:
260+ r.opts.Model.ForgetInputTokens()
261+ if cfg.ShowStats {
262+ r.t.Notice(line)
263+ }
264+ case compact.Failed:
265+ r.t.Error(line)
266+ }
267+ return out
268+}
269+
270+func (r *runner) summarize(ctx context.Context, request []*ai.Message) (string, error) {
271+ return r.opts.Model.Summarize(ctx, request, config.Cfg.Context.SummaryMaxTokens)
272+}
273+
274+// startCompact is /compact: forced, on a goroutine, reported as a notice.
275+func (r *runner) startCompact() {
276+ ctx, cancel := context.WithCancel(context.Background())
277+ r.cancel = cancel
278+ r.busy.Store("compressing")
279+ msgs := append([]*ai.Message(nil), r.messages...)
280+ go func() {
281+ out, line, oc := compact.Apply(ctx, msgs, config.Cfg.Context, r.summarize, r.opts.Model.Explain)
282+ r.results <- outcome{kind: "compact", messages: out, line: line, changed: oc == compact.Compressed, err: errIf(oc == compact.Failed)}
283+ r.wake()
284+ }()
285+}
286+
287+func errIf(failed bool) error {
288+ if failed {
289+ return fmt.Errorf("compaction failed")
290+ }
291+ return nil
292+}
293+
294+// settle takes a finished job, if any, and applies it to the session. Called
295+// by the loop before every frame — the one goroutine that touches messages.
296+func (r *runner) settle() {
297+ select {
298+ case res := <-r.results:
299+ r.apply(res)
300+ default:
301+ return
302+ }
303+ r.busy.Store("")
304+ r.cancel = nil
305+ if p := r.pending; p.Typed != "" {
306+ r.pending = Prompt{}
307+ r.dispatch(p)
308+ }
309+}
310+
311+// apply folds one outcome into the session and the transcript.
312+func (r *runner) apply(res outcome) {
313+ if res.kind == "compact" {
314+ r.messages = res.messages
315+ switch {
316+ case res.err != nil:
317+ r.t.Error(res.line)
318+ case res.changed:
319+ r.opts.Model.ForgetInputTokens()
320+ r.save()
321+ r.t.Notice(res.line)
322+ default:
323+ r.t.Notice(res.line)
324+ }
325+ return
326+ }
327+ r.messages = session.AfterTurn(res.messages, res.before, res.resp, res.history, res.err)
328+ r.save()
329+ if !r.streamed.Load() && res.resp != nil && res.resp.Message != nil {
330+ r.t.Chunk(res.resp.Message.Text())
331+ }
332+ r.t.Close()
333+ switch {
334+ case res.err != nil && res.cancelled:
335+ r.t.Notice("🛑 Generation aborted.")
336+ case res.err != nil:
337+ r.t.Error("[error: " + r.opts.Model.Explain(res.err) + "]")
338+ default:
339+ r.recordCalls(res.resp)
340+ }
341+ r.t.Notice(recap(r.messages[min(res.before, len(r.messages)):]))
342+}
343+
344+// recordCalls feeds the loop detector with the answer's tool requests, as the
345+// REPL does after a successful turn.
346+func (r *runner) recordCalls(resp *ai.ModelResponse) {
347+ if resp == nil || resp.Message == nil {
348+ return
349+ }
350+ for _, part := range resp.Message.Content {
351+ if part.IsToolRequest() && part.ToolRequest != nil {
352+ r.opts.Detector.Record(detector.Action{
353+ ToolName: part.ToolRequest.Name,
354+ Input: fmt.Sprintf("%v", part.ToolRequest.Input),
355+ Output: "[tool_call]",
356+ })
357+ }
358+ }
359+}
360+
361+// recap is the ⚙ line of a turn: how much the model worked.
362+func recap(turn []*ai.Message) string {
363+ line := fmt.Sprintf("⚙ %d command(s)", len(engine.CommandList(turn)))
364+ if ops := engine.FileOps(turn); len(ops) > 0 {
365+ line += fmt.Sprintf(" · 📝 %d file op(s)", len(ops))
366+ }
367+ if k := engine.Skills(turn); k > 0 {
368+ line += fmt.Sprintf(" · 📖 %d skill(s)", k)
369+ }
370+ return line
371+}
372+
373+// save rewrites the session file and reports a failure as a notice.
374+func (r *runner) save() {
375+ if err := r.keep.Save(r.messages); err != nil {
376+ r.t.Error("[sessions: not saved: " + err.Error() + "]")
377+ }
378+}
379+
380+// header is the top line: who is answering, how full the context is, which
381+// session is being saved.
382+func (r *runner) header() string {
383+ var b strings.Builder
384+ fmt.Fprintf(&b, "%s · %s @ %s", r.opts.AgentName, r.opts.ModelName, r.opts.Provider)
385+ used := max(r.opts.Model.LastInputTokens(), compact.Estimate(r.messages))
386+ if w := r.opts.Window(); w > 0 {
387+ fmt.Fprintf(&b, " · ctx %d%% (%s/%s)", used*100/w, compact.Kilo(used), compact.Kilo(w))
388+ } else {
389+ fmt.Fprintf(&b, " · ctx ~%s (window unknown)", compact.Kilo(used))
390+ }
391+ if id := r.keep.CurrentID(); id != "" {
392+ fmt.Fprintf(&b, " · session %s", id)
393+ }
394+ return b.String()
395+}
396+
397+// status is the right-hand side of the bottom bar.
398+func (r *runner) status() string {
399+ return fmt.Sprintf("%d msgs · %d tools", session.Forgotten(r.messages), r.t.Tools())
400+}
401+
402+// --- permissions ------------------------------------------------------------------
403+
404+// currentAsk is the permission waiting for an answer, nil when none.
405+func (r *runner) currentAsk() *permission {
406+ r.askMu.Lock()
407+ defer r.askMu.Unlock()
408+ return r.ask
409+}
410+
411+// answer settles the pending permission from the loop goroutine.
412+func (r *runner) answer(a permissionAnswer) {
413+ r.askMu.Lock()
414+ defer r.askMu.Unlock()
415+ if r.ask == nil {
416+ return
417+ }
418+ r.ask.reply <- a
419+ r.ask = nil
420+}
421+
422+// request posts a permission for the view to show and waits for the answer,
423+// or for the turn to end. Called on the tool's goroutine.
424+func (r *runner) request(ctx context.Context, ev ui.ToolEvent) permissionAnswer {
425+ p := &permission{tool: ev.Tool, title: ev.Title, reply: make(chan permissionAnswer, 1)}
426+ r.askMu.Lock()
427+ r.ask = p
428+ r.askMu.Unlock()
429+ r.wake()
430+ select {
431+ case a := <-p.reply:
432+ return a
433+ case <-ctx.Done():
434+ r.askMu.Lock()
435+ if r.ask == p {
436+ r.ask = nil
437+ }
438+ r.askMu.Unlock()
439+ return permReject
440+ }
441+}
added internal/tui/turn_test.go +396 -0
new file mode 100644
@@ -0,0 +1,396 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/config"
14+ "mm/internal/detector"
15+ "mm/internal/session"
16+ "mm/internal/ui"
17+
18+ "github.com/firebase/genkit/go/ai"
19+)
20+
21+// fakeModel answers with a canned text; its first call can block until
22+// cancelled, which is how a turn in flight is simulated.
23+type fakeModel struct {
24+ answer string
25+ blockFirst bool
26+ fail error
27+ summary string
28+ tokens int
29+ calls atomic.Int32
30+}
31+
32+func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Message, error) {
33+ if m.calls.Add(1) == 1 && m.blockFirst {
34+ <-ctx.Done()
35+ return nil, nil, ctx.Err()
36+ }
37+ if m.fail != nil {
38+ return nil, nil, m.fail
39+ }
40+ return &ai.ModelResponse{Message: ai.NewModelMessage(ai.NewTextPart(m.answer))}, nil, nil
41+}
42+
43+type model struct{ *fakeModel }
44+
45+func (m model) Generate(ctx context.Context, _ []*ai.Message, _ []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
46+ return m.generate(ctx)
47+}
48+func (m model) Summarize(context.Context, []*ai.Message, int) (string, error) {
49+ if m.summary == "" {
50+ return "", errors.New("no summariser")
51+ }
52+ return m.summary, nil
53+}
54+func (m model) Explain(err error) string { return "explained: " + err.Error() }
55+func (m model) LastInputTokens() int { return m.tokens }
56+func (m model) ForgetInputTokens() { m.tokens = 0 }
57+
58+func newTestRunner(t *testing.T, m *fakeModel) (*runner, *Transcript) {
59+ t.Helper()
60+ saved := config.Cfg
61+ t.Cleanup(func() { config.Cfg = saved })
62+ config.Cfg.Sessions.Enabled = false
63+ tr := &Transcript{}
64+ woken := make(chan struct{}, 64)
65+ r := newRunner(Options{
66+ Model: model{m}, Window: func() int { return 1000 }, Detector: detector.NewLoopDetector(10, 3),
67+ System: "sys", Cwd: t.TempDir(), AgentName: "mm", ModelName: "m", Provider: "p",
68+ }, tr, func() {
69+ select {
70+ case woken <- struct{}{}:
71+ default:
72+ }
73+ })
74+ return r, tr
75+}
76+
77+// waitIdle runs settle until the job is taken, or fails the test.
78+func waitIdle(t *testing.T, r *runner) {
79+ t.Helper()
80+ deadline := time.Now().Add(5 * time.Second)
81+ for time.Now().Before(deadline) {
82+ r.settle()
83+ if !r.isBusy() {
84+ return
85+ }
86+ time.Sleep(5 * time.Millisecond)
87+ }
88+ t.Fatal("the runner never settled")
89+}
90+
91+func notices(tr *Transcript) string {
92+ var b strings.Builder
93+ for _, e := range tr.Entries() {
94+ if e.Kind == KindNotice || e.Kind == KindError {
95+ b.WriteString(e.Text + "\n")
96+ }
97+ }
98+ return b.String()
99+}
100+
101+// A question becomes a user entry at once, a turn on a goroutine, and after
102+// settle the answer is in the history, the file saved, the recap shown.
103+func TestRunnerTurn(t *testing.T) {
104+ r, tr := newTestRunner(t, &fakeModel{answer: "42"})
105+ r.submit(promptOf("what is 6 times 7?"))
106+ if !r.isBusy() || r.busyLabel() != "thinking" {
107+ t.Fatalf("busy %q after submit", r.busyLabel())
108+ }
109+ if got := tr.Entries(); len(got) != 1 || got[0].Kind != KindUser {
110+ t.Fatalf("entries right after submit: %+v", got)
111+ }
112+ waitIdle(t, r)
113+ if len(r.messages) != 3 || r.messages[2].Text() != "42" {
114+ t.Errorf("history after the turn: %d messages", len(r.messages))
115+ }
116+ if !strings.Contains(notices(tr), "⚙ 0 command(s)") {
117+ t.Errorf("no recap:\n%s", notices(tr))
118+ }
119+ if !strings.Contains(r.header(), "mm · m @ p · ctx") || !strings.Contains(r.status(), "2 msgs") {
120+ t.Errorf("header %q status %q", r.header(), r.status())
121+ }
122+}
123+
124+// A failed turn drops the unanswered question and says why, in one line.
125+func TestRunnerFailure(t *testing.T) {
126+ r, tr := newTestRunner(t, &fakeModel{fail: errors.New("boom")})
127+ r.submit(promptOf("q"))
128+ waitIdle(t, r)
129+ if len(r.messages) != 1 {
130+ t.Errorf("unanswered question kept: %d messages", len(r.messages))
131+ }
132+ if !strings.Contains(notices(tr), "[error: explained: boom]") {
133+ t.Errorf("notices:\n%s", notices(tr))
134+ }
135+}
136+
137+// Enter during a turn cancels it and runs the new text once the cancelled
138+// turn has settled; the cancelled turn is reported as aborted, not failed.
139+func TestRunnerCancelAndPending(t *testing.T) {
140+ m := &fakeModel{blockFirst: true, answer: "done"}
141+ r, tr := newTestRunner(t, m)
142+ r.submit(promptOf("first"))
143+ r.submit(promptOf("second"))
144+ if r.pending.Typed != "second" {
145+ t.Fatalf("pending %q", r.pending.Typed)
146+ }
147+ waitIdle(t, r) // settles "first" (aborted) and dispatches "second"
148+ if !r.isBusy() && r.pending.Typed != "" {
149+ t.Fatalf("second not dispatched: busy %v pending %q", r.isBusy(), r.pending.Typed)
150+ }
151+ waitIdle(t, r)
152+ if !strings.Contains(notices(tr), "🛑 Generation aborted.") {
153+ t.Errorf("notices:\n%s", notices(tr))
154+ }
155+ if m.calls.Load() != 2 || len(r.messages) != 3 || r.messages[1].Text() != "second" {
156+ t.Errorf("calls %d, history %d", m.calls.Load(), len(r.messages))
157+ }
158+ // The unstreamed answer was still shown.
159+ if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" {
160+ t.Errorf("answer not shown: %+v", got)
161+ }
162+ if r.cancelTurn() {
163+ t.Error("cancelTurn reported a turn while idle")
164+ }
165+}
166+
167+// The slash commands: /help lists the table, /new resets, /abort when idle
168+// says so, /sessions with persistence off says so, /quit ends the loop.
169+func TestRunnerCommands(t *testing.T) {
170+ r, tr := newTestRunner(t, &fakeModel{answer: "ok"})
171+ r.submit(promptOf("q"))
172+ waitIdle(t, r)
173+ r.submit(promptOf("/help"))
174+ if n := notices(tr); !strings.Contains(n, "/resume [arg]") || !strings.Contains(n, "Ctrl-J new line") {
175+ t.Errorf("help:\n%s", n)
176+ }
177+ r.submit(promptOf("/abort"))
178+ r.submit(promptOf("/sessions"))
179+ r.submit(promptOf("/resume"))
180+ r.submit(promptOf("/new"))
181+ n := notices(tr)
182+ for _, want := range []string{"⚠️ No generation in progress.", "sessions are off", "🆕 new session: 2 message(s) forgotten"} {
183+ if !strings.Contains(n, want) {
184+ t.Errorf("notices miss %q:\n%s", want, n)
185+ }
186+ }
187+ if len(r.messages) != 1 {
188+ t.Errorf("/new left %d messages", len(r.messages))
189+ }
190+ r.submit(promptOf("/quit"))
191+ if !r.quit.Load() {
192+ t.Error("/quit did not end the loop")
193+ }
194+}
195+
196+// /compact runs on a goroutine and reports in the REPL's words.
197+func TestRunnerCompact(t *testing.T) {
198+ r, tr := newTestRunner(t, &fakeModel{summary: "they talked"})
199+ config.Cfg.Context.KeepLastTurns = 1
200+ for _, q := range []string{"a", "b", "c"} {
201+ r.messages = append(r.messages, ai.NewUserTextMessage(q), ai.NewModelMessage(ai.NewTextPart("ans "+q)))
202+ }
203+ r.submit(promptOf("/compact"))
204+ if r.busyLabel() != "compressing" {
205+ t.Fatalf("busy %q", r.busyLabel())
206+ }
207+ waitIdle(t, r)
208+ if !strings.Contains(notices(tr), "🗜️ compressed") || session.Forgotten(r.messages) >= 6 {
209+ t.Errorf("notices:\n%s\nmessages %d", notices(tr), len(r.messages))
210+ }
211+ r.submit(promptOf("/compact"))
212+ waitIdle(t, r)
213+ if !strings.Contains(notices(tr), "nothing to compact") {
214+ t.Errorf("second compact:\n%s", notices(tr))
215+ }
216+}
217+
218+// With permissions: ask, Allow posts a question, waits for the answer, and
219+// remembers "for this session" per tool; a cancelled turn rejects.
220+func TestSinkAllowAsk(t *testing.T) {
221+ r, tr := newTestRunner(t, &fakeModel{})
222+ config.Cfg.Permissions = config.PermissionsAsk
223+ k := &sink{r: r}
224+ ev := ui.ToolEvent{ID: "call_1", Tool: "bash", Title: "bash: ls"}
225+ tr.ToolStart(ev)
226+
227+ got := make(chan bool, 1)
228+ go func() { got <- k.Allow(context.Background(), ev) }()
229+ deadline := time.Now().Add(2 * time.Second)
230+ for r.currentAsk() == nil && time.Now().Before(deadline) {
231+ time.Sleep(time.Millisecond)
232+ }
233+ ask := r.currentAsk()
234+ if ask == nil || ask.tool != "bash" || tr.Entries()[0].Status != StatusWaiting {
235+ t.Fatalf("no question posted: %+v, status %q", ask, tr.Entries()[0].Status)
236+ }
237+ r.answer(permAllowSession)
238+ if !<-got || !r.allowAlways["bash"] || r.currentAsk() != nil {
239+ t.Error("allow-for-session was not honoured")
240+ }
241+ if !k.Allow(context.Background(), ev) {
242+ t.Error("second call asked again")
243+ }
244+
245+ // Reject, on another tool.
246+ ev2 := ui.ToolEvent{ID: "call_2", Tool: "edit_file", Title: "edit_file: x"}
247+ go func() { got <- k.Allow(context.Background(), ev2) }()
248+ for r.currentAsk() == nil {
249+ time.Sleep(time.Millisecond)
250+ }
251+ r.answer(permReject)
252+ if <-got {
253+ t.Error("reject allowed the tool")
254+ }
255+
256+ // A cancelled turn abandons the question.
257+ ctx, cancel := context.WithCancel(context.Background())
258+ go func() { got <- k.Allow(ctx, ev2) }()
259+ for r.currentAsk() == nil {
260+ time.Sleep(time.Millisecond)
261+ }
262+ cancel()
263+ if <-got || r.currentAsk() != nil {
264+ t.Error("a cancelled turn did not reject and clear the question")
265+ }
266+
267+ // auto: never asks.
268+ config.Cfg.Permissions = config.PermissionsAuto
269+ if !k.Allow(context.Background(), ui.ToolEvent{ID: "c", Tool: "write_file"}) || r.currentAsk() != nil {
270+ t.Error("auto asked")
271+ }
272+ if k.WorkDir() != r.opts.Cwd {
273+ t.Error("WorkDir is not the start directory")
274+ }
275+}
276+
277+// fakeCopier records what /copy sent and answers with a fixed road.
278+type fakeCopier struct {
279+ got string
280+ fail error
281+}
282+
283+func (f *fakeCopier) Copy(text string) (string, error) {
284+ f.got = text
285+ if f.fail != nil {
286+ return "pbcopy", f.fail
287+ }
288+ return "pbcopy", nil
289+}
290+
291+// copyText: "" and "1" are the last answer, "2" the one before, "all" the
292+// conversation; anything else, or asking for more answers than there are,
293+// is an error that names the problem.
294+func TestCopyTextPicksTheRightAnswer(t *testing.T) {
295+ tr := &Transcript{}
296+ if _, err := copyText(tr, ""); err == nil || !strings.Contains(err.Error(), "0 answer(s)") {
297+ t.Errorf("empty transcript: err = %v", err)
298+ }
299+ if _, err := copyText(tr, "all"); err == nil {
300+ t.Error("empty transcript, all: no error")
301+ }
302+ tr.AddUser("q1")
303+ tr.Chunk("first")
304+ tr.Close()
305+ tr.AddUser("q2")
306+ tr.Chunk("second")
307+ tr.Close()
308+ cases := map[string]string{"": "second", "1": "second", "2": "first", "all": "> q1\n\nfirst\n\n> q2\n\nsecond"}
309+ for arg, want := range cases {
310+ got, err := copyText(tr, arg)
311+ if err != nil || got != want {
312+ t.Errorf("copyText(%q) = %q, %v; want %q", arg, got, err, want)
313+ }
314+ }
315+ for _, bad := range []string{"3", "0", "x", "-1"} {
316+ if _, err := copyText(tr, bad); err == nil {
317+ t.Errorf("copyText(%q): no error", bad)
318+ }
319+ }
320+}
321+
322+// /copy puts the raw last answer on the clipboard and says how; a failure
323+// or a missing clipboard is an error notice, never a silent nothing.
324+func TestRunnerCopy(t *testing.T) {
325+ r, tr := newTestRunner(t, &fakeModel{answer: "forty-two"})
326+ r.dispatch(promptOf("/copy"))
327+ if got := notices(tr); !strings.Contains(got, "⚠️") || !strings.Contains(got, "0 answer(s)") {
328+ t.Fatalf("copy on an empty conversation:\n%s", got)
329+ }
330+ r.dispatch(promptOf("what is the answer?"))
331+ waitIdle(t, r)
332+
333+ r.clip = nil
334+ r.dispatch(promptOf("/copy"))
335+ if got := notices(tr); !strings.Contains(got, "no clipboard") {
336+ t.Errorf("no copier: %s", got)
337+ }
338+
339+ clip := &fakeCopier{}
340+ r.clip = clip
341+ r.dispatch(promptOf("/copy"))
342+ if clip.got != "forty-two" {
343+ t.Errorf("clipboard got %q, want the raw answer", clip.got)
344+ }
345+ if got := notices(tr); !strings.Contains(got, "📋 copied 9 chars (pbcopy)") {
346+ t.Errorf("no confirmation with the road taken:\n%s", got)
347+ }
348+
349+ r.clip = &fakeCopier{fail: errors.New("exit 1")}
350+ r.dispatch(promptOf("/copy all"))
351+ if got := notices(tr); !strings.Contains(got, "⚠️ no clipboard: exit 1") {
352+ t.Errorf("failure not reported:\n%s", got)
353+ }
354+}
355+
356+// A long paste reaches the model in full, shows collapsed in the conversation,
357+// and its @paths are not looked up — only the ones the user typed are.
358+func TestRunnerTurnWithPaste(t *testing.T) {
359+ r, tr := newTestRunner(t, &fakeModel{answer: "ok"})
360+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "notes.md"), []byte("x"), 0o644); err != nil {
361+ t.Fatal(err)
362+ }
363+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "inner.go"), []byte("x"), 0o644); err != nil {
364+ t.Fatal(err)
365+ }
366+ var box pastes
367+ block := strings.Repeat("line with @inner.go\n", 10) // 10 lines: collapsed
368+ token := box.add(block)
369+ typed := "explain " + string(token) + " against @notes.md"
370+ p := box.snapshot(typed)
371+
372+ r.submit(p)
373+ waitIdle(t, r)
374+
375+ question := r.messages[1].Text()
376+ wantStart := "explain " + block + " against @notes.md"
377+ if !strings.HasPrefix(question, wantStart) {
378+ t.Errorf("the model did not get the paste in full:\n%q", question)
379+ }
380+ if strings.Count(question, "[attached file:") != 1 || !strings.Contains(question, "notes.md]") {
381+ t.Errorf("mentions: want only the typed @notes.md attached, got:\n%q", question)
382+ }
383+ shown := tr.Entries()[0]
384+ if shown.Kind != KindUser || shown.Text != "explain [Pasted #1 · 11 lines · 200 chars] against @notes.md" {
385+ t.Errorf("conversation shows %q", shown.Text)
386+ }
387+ if shown.Full != wantStart {
388+ t.Errorf("Full = %q", shown.Full)
389+ }
390+ if plain := tr.Plain(); !strings.Contains(plain, "> "+wantStart) {
391+ t.Errorf("/copy all does not carry the full question:\n%s", plain)
392+ }
393+ if n := notices(tr); strings.Count(n, "📎") != 1 || !strings.Contains(n, "notes.md") {
394+ t.Errorf("attachments echoed:\n%s", n)
395+ }
396+}
new file mode 100644
@@ -0,0 +1,396 @@
1+package tui
2+
3+import (
4+ "context"
5+ "errors"
6+ "os"
7+ "path/filepath"
8+ "strings"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "mm/internal/config"
14+ "mm/internal/detector"
15+ "mm/internal/session"
16+ "mm/internal/ui"
17+
18+ "github.com/firebase/genkit/go/ai"
19+)
20+
21+// fakeModel answers with a canned text; its first call can block until
22+// cancelled, which is how a turn in flight is simulated.
23+type fakeModel struct {
24+ answer string
25+ blockFirst bool
26+ fail error
27+ summary string
28+ tokens int
29+ calls atomic.Int32
30+}
31+
32+func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Message, error) {
33+ if m.calls.Add(1) == 1 && m.blockFirst {
34+ <-ctx.Done()
35+ return nil, nil, ctx.Err()
36+ }
37+ if m.fail != nil {
38+ return nil, nil, m.fail
39+ }
40+ return &ai.ModelResponse{Message: ai.NewModelMessage(ai.NewTextPart(m.answer))}, nil, nil
41+}
42+
43+type model struct{ *fakeModel }
44+
45+func (m model) Generate(ctx context.Context, _ []*ai.Message, _ []ai.ToolRef) (*ai.ModelResponse, []*ai.Message, error) {
46+ return m.generate(ctx)
47+}
48+func (m model) Summarize(context.Context, []*ai.Message, int) (string, error) {
49+ if m.summary == "" {
50+ return "", errors.New("no summariser")
51+ }
52+ return m.summary, nil
53+}
54+func (m model) Explain(err error) string { return "explained: " + err.Error() }
55+func (m model) LastInputTokens() int { return m.tokens }
56+func (m model) ForgetInputTokens() { m.tokens = 0 }
57+
58+func newTestRunner(t *testing.T, m *fakeModel) (*runner, *Transcript) {
59+ t.Helper()
60+ saved := config.Cfg
61+ t.Cleanup(func() { config.Cfg = saved })
62+ config.Cfg.Sessions.Enabled = false
63+ tr := &Transcript{}
64+ woken := make(chan struct{}, 64)
65+ r := newRunner(Options{
66+ Model: model{m}, Window: func() int { return 1000 }, Detector: detector.NewLoopDetector(10, 3),
67+ System: "sys", Cwd: t.TempDir(), AgentName: "mm", ModelName: "m", Provider: "p",
68+ }, tr, func() {
69+ select {
70+ case woken <- struct{}{}:
71+ default:
72+ }
73+ })
74+ return r, tr
75+}
76+
77+// waitIdle runs settle until the job is taken, or fails the test.
78+func waitIdle(t *testing.T, r *runner) {
79+ t.Helper()
80+ deadline := time.Now().Add(5 * time.Second)
81+ for time.Now().Before(deadline) {
82+ r.settle()
83+ if !r.isBusy() {
84+ return
85+ }
86+ time.Sleep(5 * time.Millisecond)
87+ }
88+ t.Fatal("the runner never settled")
89+}
90+
91+func notices(tr *Transcript) string {
92+ var b strings.Builder
93+ for _, e := range tr.Entries() {
94+ if e.Kind == KindNotice || e.Kind == KindError {
95+ b.WriteString(e.Text + "\n")
96+ }
97+ }
98+ return b.String()
99+}
100+
101+// A question becomes a user entry at once, a turn on a goroutine, and after
102+// settle the answer is in the history, the file saved, the recap shown.
103+func TestRunnerTurn(t *testing.T) {
104+ r, tr := newTestRunner(t, &fakeModel{answer: "42"})
105+ r.submit(promptOf("what is 6 times 7?"))
106+ if !r.isBusy() || r.busyLabel() != "thinking" {
107+ t.Fatalf("busy %q after submit", r.busyLabel())
108+ }
109+ if got := tr.Entries(); len(got) != 1 || got[0].Kind != KindUser {
110+ t.Fatalf("entries right after submit: %+v", got)
111+ }
112+ waitIdle(t, r)
113+ if len(r.messages) != 3 || r.messages[2].Text() != "42" {
114+ t.Errorf("history after the turn: %d messages", len(r.messages))
115+ }
116+ if !strings.Contains(notices(tr), "⚙ 0 command(s)") {
117+ t.Errorf("no recap:\n%s", notices(tr))
118+ }
119+ if !strings.Contains(r.header(), "mm · m @ p · ctx") || !strings.Contains(r.status(), "2 msgs") {
120+ t.Errorf("header %q status %q", r.header(), r.status())
121+ }
122+}
123+
124+// A failed turn drops the unanswered question and says why, in one line.
125+func TestRunnerFailure(t *testing.T) {
126+ r, tr := newTestRunner(t, &fakeModel{fail: errors.New("boom")})
127+ r.submit(promptOf("q"))
128+ waitIdle(t, r)
129+ if len(r.messages) != 1 {
130+ t.Errorf("unanswered question kept: %d messages", len(r.messages))
131+ }
132+ if !strings.Contains(notices(tr), "[error: explained: boom]") {
133+ t.Errorf("notices:\n%s", notices(tr))
134+ }
135+}
136+
137+// Enter during a turn cancels it and runs the new text once the cancelled
138+// turn has settled; the cancelled turn is reported as aborted, not failed.
139+func TestRunnerCancelAndPending(t *testing.T) {
140+ m := &fakeModel{blockFirst: true, answer: "done"}
141+ r, tr := newTestRunner(t, m)
142+ r.submit(promptOf("first"))
143+ r.submit(promptOf("second"))
144+ if r.pending.Typed != "second" {
145+ t.Fatalf("pending %q", r.pending.Typed)
146+ }
147+ waitIdle(t, r) // settles "first" (aborted) and dispatches "second"
148+ if !r.isBusy() && r.pending.Typed != "" {
149+ t.Fatalf("second not dispatched: busy %v pending %q", r.isBusy(), r.pending.Typed)
150+ }
151+ waitIdle(t, r)
152+ if !strings.Contains(notices(tr), "🛑 Generation aborted.") {
153+ t.Errorf("notices:\n%s", notices(tr))
154+ }
155+ if m.calls.Load() != 2 || len(r.messages) != 3 || r.messages[1].Text() != "second" {
156+ t.Errorf("calls %d, history %d", m.calls.Load(), len(r.messages))
157+ }
158+ // The unstreamed answer was still shown.
159+ if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" {
160+ t.Errorf("answer not shown: %+v", got)
161+ }
162+ if r.cancelTurn() {
163+ t.Error("cancelTurn reported a turn while idle")
164+ }
165+}
166+
167+// The slash commands: /help lists the table, /new resets, /abort when idle
168+// says so, /sessions with persistence off says so, /quit ends the loop.
169+func TestRunnerCommands(t *testing.T) {
170+ r, tr := newTestRunner(t, &fakeModel{answer: "ok"})
171+ r.submit(promptOf("q"))
172+ waitIdle(t, r)
173+ r.submit(promptOf("/help"))
174+ if n := notices(tr); !strings.Contains(n, "/resume [arg]") || !strings.Contains(n, "Ctrl-J new line") {
175+ t.Errorf("help:\n%s", n)
176+ }
177+ r.submit(promptOf("/abort"))
178+ r.submit(promptOf("/sessions"))
179+ r.submit(promptOf("/resume"))
180+ r.submit(promptOf("/new"))
181+ n := notices(tr)
182+ for _, want := range []string{"⚠️ No generation in progress.", "sessions are off", "🆕 new session: 2 message(s) forgotten"} {
183+ if !strings.Contains(n, want) {
184+ t.Errorf("notices miss %q:\n%s", want, n)
185+ }
186+ }
187+ if len(r.messages) != 1 {
188+ t.Errorf("/new left %d messages", len(r.messages))
189+ }
190+ r.submit(promptOf("/quit"))
191+ if !r.quit.Load() {
192+ t.Error("/quit did not end the loop")
193+ }
194+}
195+
196+// /compact runs on a goroutine and reports in the REPL's words.
197+func TestRunnerCompact(t *testing.T) {
198+ r, tr := newTestRunner(t, &fakeModel{summary: "they talked"})
199+ config.Cfg.Context.KeepLastTurns = 1
200+ for _, q := range []string{"a", "b", "c"} {
201+ r.messages = append(r.messages, ai.NewUserTextMessage(q), ai.NewModelMessage(ai.NewTextPart("ans "+q)))
202+ }
203+ r.submit(promptOf("/compact"))
204+ if r.busyLabel() != "compressing" {
205+ t.Fatalf("busy %q", r.busyLabel())
206+ }
207+ waitIdle(t, r)
208+ if !strings.Contains(notices(tr), "🗜️ compressed") || session.Forgotten(r.messages) >= 6 {
209+ t.Errorf("notices:\n%s\nmessages %d", notices(tr), len(r.messages))
210+ }
211+ r.submit(promptOf("/compact"))
212+ waitIdle(t, r)
213+ if !strings.Contains(notices(tr), "nothing to compact") {
214+ t.Errorf("second compact:\n%s", notices(tr))
215+ }
216+}
217+
218+// With permissions: ask, Allow posts a question, waits for the answer, and
219+// remembers "for this session" per tool; a cancelled turn rejects.
220+func TestSinkAllowAsk(t *testing.T) {
221+ r, tr := newTestRunner(t, &fakeModel{})
222+ config.Cfg.Permissions = config.PermissionsAsk
223+ k := &sink{r: r}
224+ ev := ui.ToolEvent{ID: "call_1", Tool: "bash", Title: "bash: ls"}
225+ tr.ToolStart(ev)
226+
227+ got := make(chan bool, 1)
228+ go func() { got <- k.Allow(context.Background(), ev) }()
229+ deadline := time.Now().Add(2 * time.Second)
230+ for r.currentAsk() == nil && time.Now().Before(deadline) {
231+ time.Sleep(time.Millisecond)
232+ }
233+ ask := r.currentAsk()
234+ if ask == nil || ask.tool != "bash" || tr.Entries()[0].Status != StatusWaiting {
235+ t.Fatalf("no question posted: %+v, status %q", ask, tr.Entries()[0].Status)
236+ }
237+ r.answer(permAllowSession)
238+ if !<-got || !r.allowAlways["bash"] || r.currentAsk() != nil {
239+ t.Error("allow-for-session was not honoured")
240+ }
241+ if !k.Allow(context.Background(), ev) {
242+ t.Error("second call asked again")
243+ }
244+
245+ // Reject, on another tool.
246+ ev2 := ui.ToolEvent{ID: "call_2", Tool: "edit_file", Title: "edit_file: x"}
247+ go func() { got <- k.Allow(context.Background(), ev2) }()
248+ for r.currentAsk() == nil {
249+ time.Sleep(time.Millisecond)
250+ }
251+ r.answer(permReject)
252+ if <-got {
253+ t.Error("reject allowed the tool")
254+ }
255+
256+ // A cancelled turn abandons the question.
257+ ctx, cancel := context.WithCancel(context.Background())
258+ go func() { got <- k.Allow(ctx, ev2) }()
259+ for r.currentAsk() == nil {
260+ time.Sleep(time.Millisecond)
261+ }
262+ cancel()
263+ if <-got || r.currentAsk() != nil {
264+ t.Error("a cancelled turn did not reject and clear the question")
265+ }
266+
267+ // auto: never asks.
268+ config.Cfg.Permissions = config.PermissionsAuto
269+ if !k.Allow(context.Background(), ui.ToolEvent{ID: "c", Tool: "write_file"}) || r.currentAsk() != nil {
270+ t.Error("auto asked")
271+ }
272+ if k.WorkDir() != r.opts.Cwd {
273+ t.Error("WorkDir is not the start directory")
274+ }
275+}
276+
277+// fakeCopier records what /copy sent and answers with a fixed road.
278+type fakeCopier struct {
279+ got string
280+ fail error
281+}
282+
283+func (f *fakeCopier) Copy(text string) (string, error) {
284+ f.got = text
285+ if f.fail != nil {
286+ return "pbcopy", f.fail
287+ }
288+ return "pbcopy", nil
289+}
290+
291+// copyText: "" and "1" are the last answer, "2" the one before, "all" the
292+// conversation; anything else, or asking for more answers than there are,
293+// is an error that names the problem.
294+func TestCopyTextPicksTheRightAnswer(t *testing.T) {
295+ tr := &Transcript{}
296+ if _, err := copyText(tr, ""); err == nil || !strings.Contains(err.Error(), "0 answer(s)") {
297+ t.Errorf("empty transcript: err = %v", err)
298+ }
299+ if _, err := copyText(tr, "all"); err == nil {
300+ t.Error("empty transcript, all: no error")
301+ }
302+ tr.AddUser("q1")
303+ tr.Chunk("first")
304+ tr.Close()
305+ tr.AddUser("q2")
306+ tr.Chunk("second")
307+ tr.Close()
308+ cases := map[string]string{"": "second", "1": "second", "2": "first", "all": "> q1\n\nfirst\n\n> q2\n\nsecond"}
309+ for arg, want := range cases {
310+ got, err := copyText(tr, arg)
311+ if err != nil || got != want {
312+ t.Errorf("copyText(%q) = %q, %v; want %q", arg, got, err, want)
313+ }
314+ }
315+ for _, bad := range []string{"3", "0", "x", "-1"} {
316+ if _, err := copyText(tr, bad); err == nil {
317+ t.Errorf("copyText(%q): no error", bad)
318+ }
319+ }
320+}
321+
322+// /copy puts the raw last answer on the clipboard and says how; a failure
323+// or a missing clipboard is an error notice, never a silent nothing.
324+func TestRunnerCopy(t *testing.T) {
325+ r, tr := newTestRunner(t, &fakeModel{answer: "forty-two"})
326+ r.dispatch(promptOf("/copy"))
327+ if got := notices(tr); !strings.Contains(got, "⚠️") || !strings.Contains(got, "0 answer(s)") {
328+ t.Fatalf("copy on an empty conversation:\n%s", got)
329+ }
330+ r.dispatch(promptOf("what is the answer?"))
331+ waitIdle(t, r)
332+
333+ r.clip = nil
334+ r.dispatch(promptOf("/copy"))
335+ if got := notices(tr); !strings.Contains(got, "no clipboard") {
336+ t.Errorf("no copier: %s", got)
337+ }
338+
339+ clip := &fakeCopier{}
340+ r.clip = clip
341+ r.dispatch(promptOf("/copy"))
342+ if clip.got != "forty-two" {
343+ t.Errorf("clipboard got %q, want the raw answer", clip.got)
344+ }
345+ if got := notices(tr); !strings.Contains(got, "📋 copied 9 chars (pbcopy)") {
346+ t.Errorf("no confirmation with the road taken:\n%s", got)
347+ }
348+
349+ r.clip = &fakeCopier{fail: errors.New("exit 1")}
350+ r.dispatch(promptOf("/copy all"))
351+ if got := notices(tr); !strings.Contains(got, "⚠️ no clipboard: exit 1") {
352+ t.Errorf("failure not reported:\n%s", got)
353+ }
354+}
355+
356+// A long paste reaches the model in full, shows collapsed in the conversation,
357+// and its @paths are not looked up — only the ones the user typed are.
358+func TestRunnerTurnWithPaste(t *testing.T) {
359+ r, tr := newTestRunner(t, &fakeModel{answer: "ok"})
360+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "notes.md"), []byte("x"), 0o644); err != nil {
361+ t.Fatal(err)
362+ }
363+ if err := os.WriteFile(filepath.Join(r.opts.Cwd, "inner.go"), []byte("x"), 0o644); err != nil {
364+ t.Fatal(err)
365+ }
366+ var box pastes
367+ block := strings.Repeat("line with @inner.go\n", 10) // 10 lines: collapsed
368+ token := box.add(block)
369+ typed := "explain " + string(token) + " against @notes.md"
370+ p := box.snapshot(typed)
371+
372+ r.submit(p)
373+ waitIdle(t, r)
374+
375+ question := r.messages[1].Text()
376+ wantStart := "explain " + block + " against @notes.md"
377+ if !strings.HasPrefix(question, wantStart) {
378+ t.Errorf("the model did not get the paste in full:\n%q", question)
379+ }
380+ if strings.Count(question, "[attached file:") != 1 || !strings.Contains(question, "notes.md]") {
381+ t.Errorf("mentions: want only the typed @notes.md attached, got:\n%q", question)
382+ }
383+ shown := tr.Entries()[0]
384+ if shown.Kind != KindUser || shown.Text != "explain [Pasted #1 · 11 lines · 200 chars] against @notes.md" {
385+ t.Errorf("conversation shows %q", shown.Text)
386+ }
387+ if shown.Full != wantStart {
388+ t.Errorf("Full = %q", shown.Full)
389+ }
390+ if plain := tr.Plain(); !strings.Contains(plain, "> "+wantStart) {
391+ t.Errorf("/copy all does not carry the full question:\n%s", plain)
392+ }
393+ if n := notices(tr); strings.Count(n, "📎") != 1 || !strings.Contains(n, "notes.md") {
394+ t.Errorf("attachments echoed:\n%s", n)
395+ }
396+}
added internal/tui/view.go +210 -0
new file mode 100644
@@ -0,0 +1,210 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// inputHeight is how many rows the box you type into takes, not counting the
11+// rule above it: enough for a sentence that wrapped once (Turbo's choice).
12+const inputHeight = 3
13+
14+// chromeRows is what the window keeps for itself: header, rule, input box,
15+// status bar. The conversation gets the rest.
16+const chromeRows = 1 + 1 + inputHeight + 1
17+
18+// permission is a tool call waiting for the user's yes or no. It is shown in
19+// place of the input box; a/s/r answer it through hooks.answer (events.go).
20+type permission struct {
21+ tool string
22+ title string
23+ reply chan permissionAnswer // the runner's, buffered; the view never writes it
24+}
25+
26+// permissionAnswer is what the user chose.
27+type permissionAnswer int
28+
29+const (
30+ permReject permissionAnswer = iota
31+ permAllowOnce
32+ permAllowSession
33+)
34+
35+// hooks is what the window asks the front end to do. The view owns the
36+// screen and what is typed; the session — history, engine, commands — is the
37+// runner's, behind these callbacks, so the view is testable with a
38+// simulation screen and no engine.
39+type hooks struct {
40+ // send hands a finished input over: a question or a slash command, with
41+ // the pastes collapsed in the box behind their placeholders.
42+ send func(p Prompt)
43+ // cancel stops the turn in progress and reports whether there was one.
44+ cancel func() bool
45+ // quit ends the window.
46+ quit func()
47+ // help is F1.
48+ help func()
49+ // answer settles the pending permission.
50+ answer func(permissionAnswer)
51+ // copy is Ctrl-Y: the last answer to the clipboard.
52+ copy func()
53+}
54+
55+// View is the window: header, conversation, rule, input box, status bar,
56+// and the popup picker. It draws from the Transcript's current value every
57+// frame and keeps no copy of it.
58+type View struct {
59+ screen tcell.Screen
60+ t *Transcript
61+ on hooks
62+
63+ agentName string
64+ previewLines int
65+
66+ // 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+
72+ // 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.
74+ input []string
75+ cursor int // which line
76+ column int // where in it, in runes
77+ pasting bool
78+ pastes pastes
79+
80+ // scroll is how many laid-out lines are hidden above the conversation;
81+ // follow pins the view to the end while the agent writes; laidOut is the
82+ // count as last drawn, so a scroll agrees with what is on the screen.
83+ scroll int
84+ follow bool
85+ laidOut int
86+ onInput bool // focus: input box (true) or conversation (false)
87+ // mouse says whether the terminal hands mouse events to the window (F2).
88+ // Off by default so the terminal's own text selection keeps working.
89+ mouse bool
90+
91+ pick picker
92+ now func() time.Time
93+}
94+
95+// newView returns a window drawing on screen, reading t.
96+func newView(screen tcell.Screen, t *Transcript, agentName string, on hooks) *View {
97+ return &View{
98+ screen: screen,
99+ t: t,
100+ on: on,
101+ agentName: agentName,
102+ input: []string{""},
103+ follow: true,
104+ onInput: true,
105+ now: time.Now,
106+ }
107+}
108+
109+// Input returns what has been typed and not yet sent, paste placeholders
110+// included.
111+func (v *View) Input() string { return strings.Join(v.input, "\n") }
112+
113+// Display returns the box as the screen shows it: paste tokens as labels.
114+func (v *View) Display() string { return v.pastes.snapshot(v.Input()).Display() }
115+
116+// setInput replaces what has been typed, cursor at the end. Pastes whose
117+// placeholder is not in the new text are forgotten with it.
118+func (v *View) setInput(text string) {
119+ v.input = strings.Split(text, "\n")
120+ v.cursor = len(v.input) - 1
121+ v.column = len([]rune(v.input[v.cursor]))
122+ v.pastes.prune(text)
123+}
124+
125+// send hands the input over and clears the box. An empty box sends nothing:
126+// Enter on a blank line is somebody thinking, not a turn worth spending.
127+func (v *View) send() {
128+ text := strings.TrimSpace(v.Input())
129+ if text == "" {
130+ return
131+ }
132+ prompt := v.pastes.snapshot(text)
133+ v.setInput("")
134+ v.follow = true
135+ v.pick.dismiss("")
136+ if v.on.send != nil {
137+ v.on.send(prompt)
138+ }
139+}
140+
141+// size is the screen's, as columns and rows.
142+func (v *View) size() (int, int) { return v.screen.Size() }
143+
144+// transcriptHeight is how many rows the conversation gets.
145+func (v *View) transcriptHeight() int {
146+ _, h := v.size()
147+ return max(h-chromeRows, 0)
148+}
149+
150+// transcriptWidth is how many columns a laid-out line may take: the margin
151+// and the scroll bar are kept out of it.
152+func (v *View) transcriptWidth() int {
153+ w, _ := v.size()
154+ return max(w-3, 1)
155+}
156+
157+// lines lays the conversation out at the current width.
158+func (v *View) lines() []Line {
159+ return layout(v.t.Entries(), v.agentName, v.transcriptWidth(), v.previewLines)
160+}
161+
162+// scrollBy moves through the conversation and stops following the end as
163+// soon as somebody scrolls up.
164+func (v *View) scrollBy(by int) {
165+ height := max(v.transcriptHeight(), 1)
166+ v.scroll = max(v.scroll+by, 0)
167+ v.follow = v.scroll >= max(v.laidOut-height, 0)
168+}
169+
170+// clampScroll keeps the view inside the conversation and pins it to the end
171+// while following. It runs at draw time, the one moment the laid-out height
172+// is known.
173+func (v *View) clampScroll(height, total int) {
174+ last := max(total-height, 0)
175+ if v.follow {
176+ v.scroll = last
177+ return
178+ }
179+ v.scroll = min(max(v.scroll, 0), last)
180+}
181+
182+// toggleMouse is F2: capture the mouse for wheel scrolling, or give it back
183+// to the terminal so click-and-drag selects text again.
184+func (v *View) toggleMouse() {
185+ v.mouse = !v.mouse
186+ if v.mouse {
187+ v.screen.EnableMouse(tcell.MouseButtonEvents)
188+ } else {
189+ v.screen.DisableMouse()
190+ }
191+}
192+
193+// mouseLabel is the status bar's word on the mouse.
194+func (v *View) mouseLabel() string {
195+ if v.mouse {
196+ return "F2 mouse: on (Shift+drag selects)"
197+ }
198+ return "F2 mouse: off"
199+}
200+
201+// clearScreen is Ctrl-L: the displayed conversation goes, the session stays.
202+func (v *View) clearScreen() {
203+ v.t.Clear()
204+ v.scroll, v.follow = 0, true
205+}
206+
207+// spinner is the frame to draw now.
208+func (v *View) spinner() rune {
209+ return spinnerFrames[(v.now().UnixMilli()/100)%int64(len(spinnerFrames))]
210+}
new file mode 100644
@@ -0,0 +1,210 @@
1+package tui
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/gdamore/tcell/v2"
8+)
9+
10+// inputHeight is how many rows the box you type into takes, not counting the
11+// rule above it: enough for a sentence that wrapped once (Turbo's choice).
12+const inputHeight = 3
13+
14+// chromeRows is what the window keeps for itself: header, rule, input box,
15+// status bar. The conversation gets the rest.
16+const chromeRows = 1 + 1 + inputHeight + 1
17+
18+// permission is a tool call waiting for the user's yes or no. It is shown in
19+// place of the input box; a/s/r answer it through hooks.answer (events.go).
20+type permission struct {
21+ tool string
22+ title string
23+ reply chan permissionAnswer // the runner's, buffered; the view never writes it
24+}
25+
26+// permissionAnswer is what the user chose.
27+type permissionAnswer int
28+
29+const (
30+ permReject permissionAnswer = iota
31+ permAllowOnce
32+ permAllowSession
33+)
34+
35+// hooks is what the window asks the front end to do. The view owns the
36+// screen and what is typed; the session — history, engine, commands — is the
37+// runner's, behind these callbacks, so the view is testable with a
38+// simulation screen and no engine.
39+type hooks struct {
40+ // send hands a finished input over: a question or a slash command, with
41+ // the pastes collapsed in the box behind their placeholders.
42+ send func(p Prompt)
43+ // cancel stops the turn in progress and reports whether there was one.
44+ cancel func() bool
45+ // quit ends the window.
46+ quit func()
47+ // help is F1.
48+ help func()
49+ // answer settles the pending permission.
50+ answer func(permissionAnswer)
51+ // copy is Ctrl-Y: the last answer to the clipboard.
52+ copy func()
53+}
54+
55+// View is the window: header, conversation, rule, input box, status bar,
56+// and the popup picker. It draws from the Transcript's current value every
57+// frame and keeps no copy of it.
58+type View struct {
59+ screen tcell.Screen
60+ t *Transcript
61+ on hooks
62+
63+ agentName string
64+ previewLines int
65+
66+ // 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+
72+ // 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.
74+ input []string
75+ cursor int // which line
76+ column int // where in it, in runes
77+ pasting bool
78+ pastes pastes
79+
80+ // scroll is how many laid-out lines are hidden above the conversation;
81+ // follow pins the view to the end while the agent writes; laidOut is the
82+ // count as last drawn, so a scroll agrees with what is on the screen.
83+ scroll int
84+ follow bool
85+ laidOut int
86+ onInput bool // focus: input box (true) or conversation (false)
87+ // mouse says whether the terminal hands mouse events to the window (F2).
88+ // Off by default so the terminal's own text selection keeps working.
89+ mouse bool
90+
91+ pick picker
92+ now func() time.Time
93+}
94+
95+// newView returns a window drawing on screen, reading t.
96+func newView(screen tcell.Screen, t *Transcript, agentName string, on hooks) *View {
97+ return &View{
98+ screen: screen,
99+ t: t,
100+ on: on,
101+ agentName: agentName,
102+ input: []string{""},
103+ follow: true,
104+ onInput: true,
105+ now: time.Now,
106+ }
107+}
108+
109+// Input returns what has been typed and not yet sent, paste placeholders
110+// included.
111+func (v *View) Input() string { return strings.Join(v.input, "\n") }
112+
113+// Display returns the box as the screen shows it: paste tokens as labels.
114+func (v *View) Display() string { return v.pastes.snapshot(v.Input()).Display() }
115+
116+// setInput replaces what has been typed, cursor at the end. Pastes whose
117+// placeholder is not in the new text are forgotten with it.
118+func (v *View) setInput(text string) {
119+ v.input = strings.Split(text, "\n")
120+ v.cursor = len(v.input) - 1
121+ v.column = len([]rune(v.input[v.cursor]))
122+ v.pastes.prune(text)
123+}
124+
125+// send hands the input over and clears the box. An empty box sends nothing:
126+// Enter on a blank line is somebody thinking, not a turn worth spending.
127+func (v *View) send() {
128+ text := strings.TrimSpace(v.Input())
129+ if text == "" {
130+ return
131+ }
132+ prompt := v.pastes.snapshot(text)
133+ v.setInput("")
134+ v.follow = true
135+ v.pick.dismiss("")
136+ if v.on.send != nil {
137+ v.on.send(prompt)
138+ }
139+}
140+
141+// size is the screen's, as columns and rows.
142+func (v *View) size() (int, int) { return v.screen.Size() }
143+
144+// transcriptHeight is how many rows the conversation gets.
145+func (v *View) transcriptHeight() int {
146+ _, h := v.size()
147+ return max(h-chromeRows, 0)
148+}
149+
150+// transcriptWidth is how many columns a laid-out line may take: the margin
151+// and the scroll bar are kept out of it.
152+func (v *View) transcriptWidth() int {
153+ w, _ := v.size()
154+ return max(w-3, 1)
155+}
156+
157+// lines lays the conversation out at the current width.
158+func (v *View) lines() []Line {
159+ return layout(v.t.Entries(), v.agentName, v.transcriptWidth(), v.previewLines)
160+}
161+
162+// scrollBy moves through the conversation and stops following the end as
163+// soon as somebody scrolls up.
164+func (v *View) scrollBy(by int) {
165+ height := max(v.transcriptHeight(), 1)
166+ v.scroll = max(v.scroll+by, 0)
167+ v.follow = v.scroll >= max(v.laidOut-height, 0)
168+}
169+
170+// clampScroll keeps the view inside the conversation and pins it to the end
171+// while following. It runs at draw time, the one moment the laid-out height
172+// is known.
173+func (v *View) clampScroll(height, total int) {
174+ last := max(total-height, 0)
175+ if v.follow {
176+ v.scroll = last
177+ return
178+ }
179+ v.scroll = min(max(v.scroll, 0), last)
180+}
181+
182+// toggleMouse is F2: capture the mouse for wheel scrolling, or give it back
183+// to the terminal so click-and-drag selects text again.
184+func (v *View) toggleMouse() {
185+ v.mouse = !v.mouse
186+ if v.mouse {
187+ v.screen.EnableMouse(tcell.MouseButtonEvents)
188+ } else {
189+ v.screen.DisableMouse()
190+ }
191+}
192+
193+// mouseLabel is the status bar's word on the mouse.
194+func (v *View) mouseLabel() string {
195+ if v.mouse {
196+ return "F2 mouse: on (Shift+drag selects)"
197+ }
198+ return "F2 mouse: off"
199+}
200+
201+// clearScreen is Ctrl-L: the displayed conversation goes, the session stays.
202+func (v *View) clearScreen() {
203+ v.t.Clear()
204+ v.scroll, v.follow = 0, true
205+}
206+
207+// spinner is the frame to draw now.
208+func (v *View) spinner() rune {
209+ return spinnerFrames[(v.now().UnixMilli()/100)%int64(len(spinnerFrames))]
210+}
added internal/tui/view_test.go +467 -0
new file mode 100644
@@ -0,0 +1,467 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// harness is a window on a simulation screen with recording hooks.
13+type harness struct {
14+ screen tcell.SimulationScreen
15+ v *View
16+ sent []string // what Enter handed over, pastes expanded
17+ prompts []Prompt
18+ cancelled int
19+ quit int
20+ helped int
21+ copied int
22+ running bool
23+ answers []permissionAnswer
24+}
25+
26+func newHarness(t *testing.T, w, h int) *harness {
27+ t.Helper()
28+ screen := tcell.NewSimulationScreen("UTF-8")
29+ if err := screen.Init(); err != nil {
30+ t.Fatal(err)
31+ }
32+ screen.SetSize(w, h)
33+ t.Cleanup(screen.Fini)
34+ hs := &harness{screen: screen}
35+ hs.v = newView(screen, &Transcript{}, "mm", hooks{
36+ send: func(p Prompt) { hs.sent = append(hs.sent, p.Text()); hs.prompts = append(hs.prompts, p) },
37+ cancel: func() bool { hs.cancelled++; return hs.running },
38+ quit: func() { hs.quit++ },
39+ help: func() { hs.helped++ },
40+ copy: func() { hs.copied++ },
41+ answer: func(a permissionAnswer) { hs.answers = append(hs.answers, a) },
42+ })
43+ hs.v.now = func() time.Time { return time.Unix(0, 0) }
44+ return hs
45+}
46+
47+func (hs *harness) key(k tcell.Key, mod tcell.ModMask) { hs.v.handle(tcell.NewEventKey(k, 0, mod)) }
48+func (hs *harness) typeText(s string) {
49+ for _, r := range s {
50+ hs.v.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
51+ }
52+}
53+
54+// rows returns the screen as trimmed text lines.
55+func (hs *harness) rows() []string {
56+ hs.v.draw()
57+ cells, w, h := hs.screen.GetContents()
58+ out := make([]string, h)
59+ for y := 0; y < h; y++ {
60+ var b strings.Builder
61+ for x := 0; x < w; x++ {
62+ c := cells[y*w+x]
63+ if len(c.Runes) == 0 {
64+ b.WriteRune(' ')
65+ continue
66+ }
67+ b.WriteString(string(c.Runes))
68+ }
69+ out[y] = strings.TrimRight(b.String(), " ")
70+ }
71+ return out
72+}
73+
74+func (hs *harness) screenText() string { return strings.Join(hs.rows(), "\n") }
75+
76+// Typing then Enter sends the text and empties the box; Enter on an empty
77+// box sends nothing; Ctrl-J (and Alt-Enter) insert a newline so the second
78+// line goes with the first.
79+func TestEnterSendsAndCtrlJBreaksLines(t *testing.T) {
80+ hs := newHarness(t, 60, 12)
81+ hs.key(tcell.KeyEnter, tcell.ModNone)
82+ if len(hs.sent) != 0 {
83+ t.Fatalf("an empty box was sent: %q", hs.sent)
84+ }
85+ hs.typeText("first")
86+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
87+ hs.typeText("second")
88+ hs.key(tcell.KeyEnter, tcell.ModAlt)
89+ hs.typeText("third")
90+ if got := hs.v.Input(); got != "first\nsecond\nthird" {
91+ t.Fatalf("input %q", got)
92+ }
93+ hs.key(tcell.KeyEnter, tcell.ModNone)
94+ if len(hs.sent) != 1 || hs.sent[0] != "first\nsecond\nthird" || hs.v.Input() != "" {
95+ t.Errorf("sent %q, box %q", hs.sent, hs.v.Input())
96+ }
97+}
98+
99+// Editing: backspace joins lines, arrows cross line ends, Home/End, Delete.
100+func TestInputEditing(t *testing.T) {
101+ hs := newHarness(t, 60, 12)
102+ hs.typeText("ab")
103+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
104+ hs.typeText("cd")
105+ hs.key(tcell.KeyHome, tcell.ModNone)
106+ hs.key(tcell.KeyBackspace2, tcell.ModNone) // joins "ab" + "cd"
107+ if hs.v.Input() != "abcd" || hs.v.cursor != 0 || hs.v.column != 2 {
108+ t.Fatalf("after join: %q cursor %d col %d", hs.v.Input(), hs.v.cursor, hs.v.column)
109+ }
110+ hs.key(tcell.KeyLeft, tcell.ModNone)
111+ hs.key(tcell.KeyDelete, tcell.ModNone)
112+ if hs.v.Input() != "acd" {
113+ t.Errorf("after delete: %q", hs.v.Input())
114+ }
115+ hs.key(tcell.KeyEnd, tcell.ModNone)
116+ hs.typeText("!")
117+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
118+ hs.key(tcell.KeyLeft, tcell.ModNone) // from the start of line 2 onto the end of line 1
119+ if hs.v.cursor != 0 || hs.v.column != 4 {
120+ t.Errorf("Left across a line end: cursor %d col %d", hs.v.cursor, hs.v.column)
121+ }
122+ hs.key(tcell.KeyRight, tcell.ModNone)
123+ if hs.v.cursor != 1 || hs.v.column != 0 {
124+ t.Errorf("Right across a line end: cursor %d col %d", hs.v.cursor, hs.v.column)
125+ }
126+}
127+
128+// paste feeds the harness a bracketed paste the way tcell delivers one: a
129+// start mark, the text as keys (Enter for a newline, Tab for a tab), an end
130+// mark.
131+func (hs *harness) paste(text string) {
132+ hs.v.handle(tcell.NewEventPaste(true))
133+ for _, r := range text {
134+ switch r {
135+ case '\n':
136+ hs.key(tcell.KeyEnter, tcell.ModNone)
137+ case '\t':
138+ hs.key(tcell.KeyTab, tcell.ModNone)
139+ default:
140+ hs.v.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
141+ }
142+ }
143+ hs.v.handle(tcell.NewEventPaste(false))
144+}
145+
146+// A short pasted block is inserted whole, as text: the Enters inside it
147+// become newlines, a Tab stays a tab, and nothing is sent.
148+func TestPasteNeverSends(t *testing.T) {
149+ hs := newHarness(t, 60, 12)
150+ hs.paste("one\n\ttwo")
151+ if len(hs.sent) != 0 || hs.v.Input() != "one\n\ttwo" || !hs.v.onInput {
152+ t.Errorf("sent %q, box %q, focus on box %v", hs.sent, hs.v.Input(), hs.v.onInput)
153+ }
154+ hs.key(tcell.KeyEnter, tcell.ModNone)
155+ if len(hs.sent) != 1 {
156+ t.Errorf("Enter after the paste did not send: %q", hs.sent)
157+ }
158+}
159+
160+// The picker: "/" opens it with every window command, typing narrows it,
161+// Down moves, Tab completes (with a space for a command taking an argument),
162+// Enter on a complete name sends, Escape dismisses until the text changes.
163+func TestPicker(t *testing.T) {
164+ hs := newHarness(t, 70, 20)
165+ hs.typeText("/")
166+ if got := len(hs.v.choices()); got != 8 {
167+ t.Fatalf("%d choices on '/', want 8", got)
168+ }
169+ if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {
170+ t.Errorf("picker not drawn:\n%s", text)
171+ }
172+ hs.typeText("re")
173+ cs := hs.v.choices()
174+ if len(cs) != 1 || cs[0].Name != "resume" {
175+ t.Fatalf("choices for /re: %+v", cs)
176+ }
177+ hs.key(tcell.KeyTab, tcell.ModNone)
178+ if hs.v.Input() != "/resume " || hs.v.pickerOpen() {
179+ t.Errorf("after Tab: box %q, picker open %v", hs.v.Input(), hs.v.pickerOpen())
180+ }
181+ hs.typeText("abc")
182+ hs.key(tcell.KeyEnter, tcell.ModNone)
183+ if len(hs.sent) != 1 || hs.sent[0] != "/resume abc" {
184+ t.Errorf("sent %q", hs.sent)
185+ }
186+
187+ // Enter on a complete name sends rather than completing.
188+ hs.typeText("/new")
189+ if !hs.v.pickerOpen() || !hs.v.typedIsComplete() {
190+ t.Fatalf("picker open %v, complete %v", hs.v.pickerOpen(), hs.v.typedIsComplete())
191+ }
192+ hs.key(tcell.KeyEnter, tcell.ModNone)
193+ if len(hs.sent) != 2 || hs.sent[1] != "/new" {
194+ t.Errorf("sent %q", hs.sent)
195+ }
196+
197+ // Down wraps; Escape dismisses for this text only.
198+ hs.typeText("/")
199+ hs.key(tcell.KeyDown, tcell.ModNone)
200+ if c, _ := hs.v.selectedChoice(); c.Name != "abort" {
201+ t.Errorf("Down selected %q", c.Name)
202+ }
203+ hs.key(tcell.KeyEscape, tcell.ModNone)
204+ if hs.v.pickerOpen() || hs.cancelled != 0 {
205+ t.Errorf("Escape did not just dismiss the picker (open %v, cancelled %d)", hs.v.pickerOpen(), hs.cancelled)
206+ }
207+ hs.typeText("q")
208+ if !hs.v.pickerOpen() {
209+ t.Error("picker stayed dismissed after the text changed")
210+ }
211+}
212+
213+// Ctrl-C quits when idle and cancels when a turn runs; Escape cancels; F1
214+// asks for help; Ctrl-L clears the conversation; Tab moves the focus.
215+func TestWindowKeys(t *testing.T) {
216+ hs := newHarness(t, 60, 12)
217+ hs.key(tcell.KeyCtrlC, tcell.ModNone)
218+ if hs.quit != 1 {
219+ t.Errorf("Ctrl-C idle: quit %d", hs.quit)
220+ }
221+ hs.running, hs.v.busy = true, "thinking"
222+ hs.key(tcell.KeyCtrlC, tcell.ModNone)
223+ hs.key(tcell.KeyEscape, tcell.ModNone)
224+ if hs.cancelled != 2 || hs.quit != 1 {
225+ t.Errorf("busy: cancelled %d quit %d", hs.cancelled, hs.quit)
226+ }
227+ hs.key(tcell.KeyF1, tcell.ModNone)
228+ if hs.helped != 1 {
229+ t.Error("F1 did not ask for help")
230+ }
231+ hs.v.t.AddUser("hello")
232+ hs.key(tcell.KeyCtrlL, tcell.ModNone)
233+ if hs.v.t.Len() != 0 {
234+ t.Error("Ctrl-L kept the conversation")
235+ }
236+ hs.key(tcell.KeyTab, tcell.ModNone)
237+ if hs.v.onInput {
238+ t.Error("Tab kept the focus on the box")
239+ }
240+ hs.running, hs.v.busy = false, ""
241+ if !strings.Contains(hs.screenText(), "Tab back to the box") {
242+ t.Errorf("rule does not say how to return:\n%s", hs.screenText())
243+ }
244+}
245+
246+// The frame: header and status bars in place, the conversation labelled and
247+// wrapped, the rule showing the spinner while busy, the box with its prompt.
248+func TestDrawFrame(t *testing.T) {
249+ hs := newHarness(t, 40, 14) // conversation: rows 1–8, rule 9, box 10–12, status 13
250+ hs.v.header = "mm · model @ dmr"
251+ hs.v.status = "2 msgs · 0 tools"
252+ hs.v.t.AddUser("hello there")
253+ hs.v.t.Chunk("Hi! ")
254+ hs.v.t.Chunk("How can I help?")
255+ hs.typeText("nex")
256+ rows := hs.rows()
257+ if !strings.HasPrefix(rows[0], " mm · model @ dmr") {
258+ t.Errorf("header row %q", rows[0])
259+ }
260+ if rows[1] != " ‣ you" || rows[2] != " hello there" || rows[3] != "" || rows[4] != " ‣ mm" || rows[5] != " Hi! How can I help?" {
261+ t.Errorf("conversation rows %q", rows[1:6])
262+ }
263+ if !strings.HasPrefix(rows[9], "── Enter sends") {
264+ t.Errorf("rule row %q", rows[9])
265+ }
266+ if rows[10] != "> nex" {
267+ t.Errorf("input row %q", rows[10])
268+ }
269+ if last := rows[13]; !strings.Contains(last, "F1 help") || !strings.HasSuffix(last, "2 msgs · 0 tools") {
270+ t.Errorf("status row %q", last)
271+ }
272+ hs.v.busy = "thinking"
273+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it") {
274+ t.Errorf("busy rule %q", rule)
275+ }
276+}
277+
278+// Following: the view sticks to the end while text arrives, lets go when
279+// scrolled up, and End pins it back; PgUp/PgDn page.
280+func TestScrollFollow(t *testing.T) {
281+ hs := newHarness(t, 40, 10) // transcript height 4
282+ for i := 0; i < 20; i++ {
283+ hs.v.t.Notice("line")
284+ }
285+ hs.rows()
286+ if !hs.v.follow || hs.v.scroll == 0 {
287+ t.Fatalf("not following the end: follow %v scroll %d", hs.v.follow, hs.v.scroll)
288+ }
289+ hs.key(tcell.KeyPgUp, tcell.ModNone)
290+ hs.rows()
291+ if hs.v.follow {
292+ t.Error("PgUp kept following")
293+ }
294+ at := hs.v.scroll
295+ hs.v.t.Notice("more")
296+ hs.rows()
297+ if hs.v.scroll != at {
298+ t.Errorf("new text moved a reader who scrolled up: %d → %d", at, hs.v.scroll)
299+ }
300+ hs.key(tcell.KeyEnd, tcell.ModNone) // on the box, End moves the column; Tab first
301+ hs.key(tcell.KeyTab, tcell.ModNone)
302+ hs.key(tcell.KeyEnd, tcell.ModNone)
303+ hs.rows()
304+ if !hs.v.follow {
305+ t.Error("End did not resume following")
306+ }
307+ hs.v.handle(tcell.NewEventMouse(0, 0, tcell.WheelUp, tcell.ModNone))
308+ if hs.v.follow {
309+ t.Error("wheel up kept following")
310+ }
311+}
312+
313+// A pending permission replaces the box, takes every key, and a/s/r/Esc
314+// answer it.
315+func TestPermissionKeys(t *testing.T) {
316+ hs := newHarness(t, 70, 12)
317+ for _, c := range []struct {
318+ key tcell.Key
319+ r rune
320+ want permissionAnswer
321+ }{{tcell.KeyRune, 'a', permAllowOnce}, {tcell.KeyRune, 's', permAllowSession}, {tcell.KeyRune, 'r', permReject}, {tcell.KeyEscape, 0, permReject}} {
322+ hs.answers = nil
323+ hs.v.ask = &permission{tool: "bash", title: "bash: rm -rf build"}
324+ if text := hs.screenText(); !strings.Contains(text, "? bash: rm -rf build") || !strings.Contains(text, "[s] allow bash for this session") {
325+ t.Fatalf("permission not drawn:\n%s", text)
326+ }
327+ hs.typeText("x") // ignored while the question stands
328+ if hs.v.Input() != "" {
329+ t.Errorf("typing reached the box during a permission: %q", hs.v.Input())
330+ }
331+ hs.v.handle(tcell.NewEventKey(c.key, c.r, tcell.ModNone))
332+ if len(hs.answers) != 1 || hs.answers[0] != c.want {
333+ t.Errorf("key %q answered %v, want %v", c.r, hs.answers, c.want)
334+ }
335+ if hs.v.ask != nil {
336+ t.Error("the question stayed after the answer")
337+ }
338+ }
339+}
340+
341+// The mouse is the terminal's until F2 says otherwise: with it off, click
342+// and drag select text as in any terminal; F2 captures it (wheel scrolling)
343+// and the status bar says so, F2 again gives it back.
344+func TestF2TogglesMouseCaptureAndStatusSaysSo(t *testing.T) {
345+ hs := newHarness(t, 80, 12)
346+ if hs.v.mouse {
347+ t.Fatal("mouse captured at start-up: the terminal's own selection would be dead")
348+ }
349+ if text := hs.screenText(); !strings.Contains(text, "F2 mouse: off") {
350+ t.Errorf("status bar does not say the mouse is off:\n%s", text)
351+ }
352+ hs.key(tcell.KeyF2, tcell.ModNone)
353+ if !hs.v.mouse {
354+ t.Fatal("F2 did not capture the mouse")
355+ }
356+ if text := hs.screenText(); !strings.Contains(text, "mouse: on (Shift+drag selects)") {
357+ t.Errorf("status bar does not say the mouse is on:\n%s", text)
358+ }
359+ hs.key(tcell.KeyF2, tcell.ModNone)
360+ if hs.v.mouse {
361+ t.Fatal("second F2 did not release the mouse")
362+ }
363+}
364+
365+// Ctrl-Y asks the front end to copy the last answer, from either pane.
366+func TestCtrlYCopies(t *testing.T) {
367+ hs := newHarness(t, 80, 12)
368+ hs.key(tcell.KeyCtrlY, tcell.ModNone)
369+ hs.key(tcell.KeyTab, tcell.ModNone) // focus on the conversation
370+ hs.key(tcell.KeyCtrlY, tcell.ModNone)
371+ if hs.copied != 2 {
372+ t.Errorf("copy hook called %d time(s), want 2", hs.copied)
373+ }
374+}
375+
376+// A paste longer than the box collapses into one token: the box shows the
377+// label and not the lines, the input holds one placeholder rune, and Enter
378+// hands the model the whole text while the display keeps the label.
379+func TestLongPasteCollapsesToOneToken(t *testing.T) {
380+ hs := newHarness(t, 80, 12)
381+ var lines []string
382+ for i := 1; i <= 50; i++ {
383+ lines = append(lines, fmt.Sprintf("line %02d of the block", i))
384+ }
385+ block := strings.Join(lines, "\n")
386+ hs.typeText("look: ")
387+ hs.paste(block)
388+ hs.typeText(" please")
389+
390+ runes := []rune(hs.v.input[0])
391+ if len(hs.v.input) != 1 || len(runes) != len("look: please")+1 || !isPlaceholder(runes[6]) {
392+ t.Fatalf("input %q (%d lines)", hs.v.Input(), len(hs.v.input))
393+ }
394+ row := hs.rows()[8] // header 0, conversation 1–6, rule 7, box 8–10, status 11
395+ if !strings.Contains(row, "> look: [Pasted #1 · 50 lines · 1.0k chars] please") {
396+ t.Errorf("box row %q", row)
397+ }
398+ if strings.Contains(hs.screenText(), "line 01 of the block") {
399+ t.Error("the pasted lines were drawn")
400+ }
401+ if x := hs.v.cursorX(); x != len([]rune("look: [Pasted #1 · 50 lines · 1.0k chars] please")) {
402+ t.Errorf("cursor at column %d", x)
403+ }
404+
405+ hs.key(tcell.KeyEnter, tcell.ModNone)
406+ if len(hs.prompts) != 1 {
407+ t.Fatalf("sent %d prompt(s)", len(hs.prompts))
408+ }
409+ p := hs.prompts[0]
410+ if p.Text() != "look: "+block+" please" {
411+ t.Errorf("Text() = %q", p.Text())
412+ }
413+ if p.Display() != "look: [Pasted #1 · 50 lines · 1.0k chars] please" {
414+ t.Errorf("Display() = %q", p.Display())
415+ }
416+ if hs.v.Input() != "" || len(hs.v.pastes.blocks) != 0 || hs.v.pastes.next != 0 {
417+ t.Error("the box or its pastes survived the send")
418+ }
419+}
420+
421+// The token is one character: Left crosses it in one step, Backspace removes
422+// it whole (and the paste with it), Delete too; two pastes are numbered.
423+func TestPasteTokenIsAtomic(t *testing.T) {
424+ hs := newHarness(t, 80, 12)
425+ long := strings.Repeat("x", pasteCollapseChars+1) // 1 line, too many chars
426+ hs.paste(long)
427+ hs.typeText("a")
428+ hs.paste(strings.Repeat("y\n", 5)) // 6 lines
429+ if got := hs.v.Display(); got != "[Pasted #1 · 1 lines · 201 chars]a[Pasted #2 · 6 lines · 10 chars]" {
430+ t.Fatalf("display %q", got)
431+ }
432+ hs.key(tcell.KeyLeft, tcell.ModNone) // over token #2
433+ hs.key(tcell.KeyLeft, tcell.ModNone) // over "a"
434+ if hs.v.column != 1 {
435+ t.Fatalf("column %d after two Lefts, want 1 (one step per token)", hs.v.column)
436+ }
437+ hs.key(tcell.KeyBackspace2, tcell.ModNone) // removes token #1 whole
438+ if got := hs.v.Display(); got != "a[Pasted #2 · 6 lines · 10 chars]" || len(hs.v.pastes.blocks) != 1 {
439+ t.Errorf("after Backspace: %q, %d paste(s) kept", got, len(hs.v.pastes.blocks))
440+ }
441+ hs.key(tcell.KeyRight, tcell.ModNone) // past "a"
442+ hs.key(tcell.KeyDelete, tcell.ModNone) // removes token #2 whole
443+ if got := hs.v.Display(); got != "a" || len(hs.v.pastes.blocks) != 0 {
444+ t.Errorf("after Delete: %q, %d paste(s) kept", got, len(hs.v.pastes.blocks))
445+ }
446+ hs.key(tcell.KeyEnter, tcell.ModNone)
447+ if len(hs.sent) != 1 || hs.sent[0] != "a" || hs.prompts[0].HasPastes() {
448+ t.Errorf("sent %q, has pastes %v", hs.sent, hs.prompts[0].HasPastes())
449+ }
450+}
451+
452+// The threshold: 3 lines of 200 characters are still text; a 4th line, or a
453+// 201st character, collapses.
454+func TestShouldCollapse(t *testing.T) {
455+ cases := map[string]bool{
456+ "short": false,
457+ "a\nb\nc": false,
458+ "a\nb\nc\nd": true,
459+ strings.Repeat("x", pasteCollapseChars): false,
460+ strings.Repeat("x", pasteCollapseChars+1): true,
461+ }
462+ for in, want := range cases {
463+ if got := shouldCollapse(in); got != want {
464+ t.Errorf("shouldCollapse(%d chars, %d lines) = %v, want %v", len(in), strings.Count(in, "\n")+1, got, want)
465+ }
466+ }
467+}
new file mode 100644
@@ -0,0 +1,467 @@
1+package tui
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/gdamore/tcell/v2"
10+)
11+
12+// harness is a window on a simulation screen with recording hooks.
13+type harness struct {
14+ screen tcell.SimulationScreen
15+ v *View
16+ sent []string // what Enter handed over, pastes expanded
17+ prompts []Prompt
18+ cancelled int
19+ quit int
20+ helped int
21+ copied int
22+ running bool
23+ answers []permissionAnswer
24+}
25+
26+func newHarness(t *testing.T, w, h int) *harness {
27+ t.Helper()
28+ screen := tcell.NewSimulationScreen("UTF-8")
29+ if err := screen.Init(); err != nil {
30+ t.Fatal(err)
31+ }
32+ screen.SetSize(w, h)
33+ t.Cleanup(screen.Fini)
34+ hs := &harness{screen: screen}
35+ hs.v = newView(screen, &Transcript{}, "mm", hooks{
36+ send: func(p Prompt) { hs.sent = append(hs.sent, p.Text()); hs.prompts = append(hs.prompts, p) },
37+ cancel: func() bool { hs.cancelled++; return hs.running },
38+ quit: func() { hs.quit++ },
39+ help: func() { hs.helped++ },
40+ copy: func() { hs.copied++ },
41+ answer: func(a permissionAnswer) { hs.answers = append(hs.answers, a) },
42+ })
43+ hs.v.now = func() time.Time { return time.Unix(0, 0) }
44+ return hs
45+}
46+
47+func (hs *harness) key(k tcell.Key, mod tcell.ModMask) { hs.v.handle(tcell.NewEventKey(k, 0, mod)) }
48+func (hs *harness) typeText(s string) {
49+ for _, r := range s {
50+ hs.v.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
51+ }
52+}
53+
54+// rows returns the screen as trimmed text lines.
55+func (hs *harness) rows() []string {
56+ hs.v.draw()
57+ cells, w, h := hs.screen.GetContents()
58+ out := make([]string, h)
59+ for y := 0; y < h; y++ {
60+ var b strings.Builder
61+ for x := 0; x < w; x++ {
62+ c := cells[y*w+x]
63+ if len(c.Runes) == 0 {
64+ b.WriteRune(' ')
65+ continue
66+ }
67+ b.WriteString(string(c.Runes))
68+ }
69+ out[y] = strings.TrimRight(b.String(), " ")
70+ }
71+ return out
72+}
73+
74+func (hs *harness) screenText() string { return strings.Join(hs.rows(), "\n") }
75+
76+// Typing then Enter sends the text and empties the box; Enter on an empty
77+// box sends nothing; Ctrl-J (and Alt-Enter) insert a newline so the second
78+// line goes with the first.
79+func TestEnterSendsAndCtrlJBreaksLines(t *testing.T) {
80+ hs := newHarness(t, 60, 12)
81+ hs.key(tcell.KeyEnter, tcell.ModNone)
82+ if len(hs.sent) != 0 {
83+ t.Fatalf("an empty box was sent: %q", hs.sent)
84+ }
85+ hs.typeText("first")
86+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
87+ hs.typeText("second")
88+ hs.key(tcell.KeyEnter, tcell.ModAlt)
89+ hs.typeText("third")
90+ if got := hs.v.Input(); got != "first\nsecond\nthird" {
91+ t.Fatalf("input %q", got)
92+ }
93+ hs.key(tcell.KeyEnter, tcell.ModNone)
94+ if len(hs.sent) != 1 || hs.sent[0] != "first\nsecond\nthird" || hs.v.Input() != "" {
95+ t.Errorf("sent %q, box %q", hs.sent, hs.v.Input())
96+ }
97+}
98+
99+// Editing: backspace joins lines, arrows cross line ends, Home/End, Delete.
100+func TestInputEditing(t *testing.T) {
101+ hs := newHarness(t, 60, 12)
102+ hs.typeText("ab")
103+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
104+ hs.typeText("cd")
105+ hs.key(tcell.KeyHome, tcell.ModNone)
106+ hs.key(tcell.KeyBackspace2, tcell.ModNone) // joins "ab" + "cd"
107+ if hs.v.Input() != "abcd" || hs.v.cursor != 0 || hs.v.column != 2 {
108+ t.Fatalf("after join: %q cursor %d col %d", hs.v.Input(), hs.v.cursor, hs.v.column)
109+ }
110+ hs.key(tcell.KeyLeft, tcell.ModNone)
111+ hs.key(tcell.KeyDelete, tcell.ModNone)
112+ if hs.v.Input() != "acd" {
113+ t.Errorf("after delete: %q", hs.v.Input())
114+ }
115+ hs.key(tcell.KeyEnd, tcell.ModNone)
116+ hs.typeText("!")
117+ hs.key(tcell.KeyCtrlJ, tcell.ModNone)
118+ hs.key(tcell.KeyLeft, tcell.ModNone) // from the start of line 2 onto the end of line 1
119+ if hs.v.cursor != 0 || hs.v.column != 4 {
120+ t.Errorf("Left across a line end: cursor %d col %d", hs.v.cursor, hs.v.column)
121+ }
122+ hs.key(tcell.KeyRight, tcell.ModNone)
123+ if hs.v.cursor != 1 || hs.v.column != 0 {
124+ t.Errorf("Right across a line end: cursor %d col %d", hs.v.cursor, hs.v.column)
125+ }
126+}
127+
128+// paste feeds the harness a bracketed paste the way tcell delivers one: a
129+// start mark, the text as keys (Enter for a newline, Tab for a tab), an end
130+// mark.
131+func (hs *harness) paste(text string) {
132+ hs.v.handle(tcell.NewEventPaste(true))
133+ for _, r := range text {
134+ switch r {
135+ case '\n':
136+ hs.key(tcell.KeyEnter, tcell.ModNone)
137+ case '\t':
138+ hs.key(tcell.KeyTab, tcell.ModNone)
139+ default:
140+ hs.v.handle(tcell.NewEventKey(tcell.KeyRune, r, tcell.ModNone))
141+ }
142+ }
143+ hs.v.handle(tcell.NewEventPaste(false))
144+}
145+
146+// A short pasted block is inserted whole, as text: the Enters inside it
147+// become newlines, a Tab stays a tab, and nothing is sent.
148+func TestPasteNeverSends(t *testing.T) {
149+ hs := newHarness(t, 60, 12)
150+ hs.paste("one\n\ttwo")
151+ if len(hs.sent) != 0 || hs.v.Input() != "one\n\ttwo" || !hs.v.onInput {
152+ t.Errorf("sent %q, box %q, focus on box %v", hs.sent, hs.v.Input(), hs.v.onInput)
153+ }
154+ hs.key(tcell.KeyEnter, tcell.ModNone)
155+ if len(hs.sent) != 1 {
156+ t.Errorf("Enter after the paste did not send: %q", hs.sent)
157+ }
158+}
159+
160+// The picker: "/" opens it with every window command, typing narrows it,
161+// Down moves, Tab completes (with a space for a command taking an argument),
162+// Enter on a complete name sends, Escape dismisses until the text changes.
163+func TestPicker(t *testing.T) {
164+ hs := newHarness(t, 70, 20)
165+ hs.typeText("/")
166+ if got := len(hs.v.choices()); got != 8 {
167+ t.Fatalf("%d choices on '/', want 8", got)
168+ }
169+ if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {
170+ t.Errorf("picker not drawn:\n%s", text)
171+ }
172+ hs.typeText("re")
173+ cs := hs.v.choices()
174+ if len(cs) != 1 || cs[0].Name != "resume" {
175+ t.Fatalf("choices for /re: %+v", cs)
176+ }
177+ hs.key(tcell.KeyTab, tcell.ModNone)
178+ if hs.v.Input() != "/resume " || hs.v.pickerOpen() {
179+ t.Errorf("after Tab: box %q, picker open %v", hs.v.Input(), hs.v.pickerOpen())
180+ }
181+ hs.typeText("abc")
182+ hs.key(tcell.KeyEnter, tcell.ModNone)
183+ if len(hs.sent) != 1 || hs.sent[0] != "/resume abc" {
184+ t.Errorf("sent %q", hs.sent)
185+ }
186+
187+ // Enter on a complete name sends rather than completing.
188+ hs.typeText("/new")
189+ if !hs.v.pickerOpen() || !hs.v.typedIsComplete() {
190+ t.Fatalf("picker open %v, complete %v", hs.v.pickerOpen(), hs.v.typedIsComplete())
191+ }
192+ hs.key(tcell.KeyEnter, tcell.ModNone)
193+ if len(hs.sent) != 2 || hs.sent[1] != "/new" {
194+ t.Errorf("sent %q", hs.sent)
195+ }
196+
197+ // Down wraps; Escape dismisses for this text only.
198+ hs.typeText("/")
199+ hs.key(tcell.KeyDown, tcell.ModNone)
200+ if c, _ := hs.v.selectedChoice(); c.Name != "abort" {
201+ t.Errorf("Down selected %q", c.Name)
202+ }
203+ hs.key(tcell.KeyEscape, tcell.ModNone)
204+ if hs.v.pickerOpen() || hs.cancelled != 0 {
205+ t.Errorf("Escape did not just dismiss the picker (open %v, cancelled %d)", hs.v.pickerOpen(), hs.cancelled)
206+ }
207+ hs.typeText("q")
208+ if !hs.v.pickerOpen() {
209+ t.Error("picker stayed dismissed after the text changed")
210+ }
211+}
212+
213+// Ctrl-C quits when idle and cancels when a turn runs; Escape cancels; F1
214+// asks for help; Ctrl-L clears the conversation; Tab moves the focus.
215+func TestWindowKeys(t *testing.T) {
216+ hs := newHarness(t, 60, 12)
217+ hs.key(tcell.KeyCtrlC, tcell.ModNone)
218+ if hs.quit != 1 {
219+ t.Errorf("Ctrl-C idle: quit %d", hs.quit)
220+ }
221+ hs.running, hs.v.busy = true, "thinking"
222+ hs.key(tcell.KeyCtrlC, tcell.ModNone)
223+ hs.key(tcell.KeyEscape, tcell.ModNone)
224+ if hs.cancelled != 2 || hs.quit != 1 {
225+ t.Errorf("busy: cancelled %d quit %d", hs.cancelled, hs.quit)
226+ }
227+ hs.key(tcell.KeyF1, tcell.ModNone)
228+ if hs.helped != 1 {
229+ t.Error("F1 did not ask for help")
230+ }
231+ hs.v.t.AddUser("hello")
232+ hs.key(tcell.KeyCtrlL, tcell.ModNone)
233+ if hs.v.t.Len() != 0 {
234+ t.Error("Ctrl-L kept the conversation")
235+ }
236+ hs.key(tcell.KeyTab, tcell.ModNone)
237+ if hs.v.onInput {
238+ t.Error("Tab kept the focus on the box")
239+ }
240+ hs.running, hs.v.busy = false, ""
241+ if !strings.Contains(hs.screenText(), "Tab back to the box") {
242+ t.Errorf("rule does not say how to return:\n%s", hs.screenText())
243+ }
244+}
245+
246+// The frame: header and status bars in place, the conversation labelled and
247+// wrapped, the rule showing the spinner while busy, the box with its prompt.
248+func TestDrawFrame(t *testing.T) {
249+ hs := newHarness(t, 40, 14) // conversation: rows 1–8, rule 9, box 10–12, status 13
250+ hs.v.header = "mm · model @ dmr"
251+ hs.v.status = "2 msgs · 0 tools"
252+ hs.v.t.AddUser("hello there")
253+ hs.v.t.Chunk("Hi! ")
254+ hs.v.t.Chunk("How can I help?")
255+ hs.typeText("nex")
256+ rows := hs.rows()
257+ if !strings.HasPrefix(rows[0], " mm · model @ dmr") {
258+ t.Errorf("header row %q", rows[0])
259+ }
260+ if rows[1] != " ‣ you" || rows[2] != " hello there" || rows[3] != "" || rows[4] != " ‣ mm" || rows[5] != " Hi! How can I help?" {
261+ t.Errorf("conversation rows %q", rows[1:6])
262+ }
263+ if !strings.HasPrefix(rows[9], "── Enter sends") {
264+ t.Errorf("rule row %q", rows[9])
265+ }
266+ if rows[10] != "> nex" {
267+ t.Errorf("input row %q", rows[10])
268+ }
269+ if last := rows[13]; !strings.Contains(last, "F1 help") || !strings.HasSuffix(last, "2 msgs · 0 tools") {
270+ t.Errorf("status row %q", last)
271+ }
272+ hs.v.busy = "thinking"
273+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it") {
274+ t.Errorf("busy rule %q", rule)
275+ }
276+}
277+
278+// Following: the view sticks to the end while text arrives, lets go when
279+// scrolled up, and End pins it back; PgUp/PgDn page.
280+func TestScrollFollow(t *testing.T) {
281+ hs := newHarness(t, 40, 10) // transcript height 4
282+ for i := 0; i < 20; i++ {
283+ hs.v.t.Notice("line")
284+ }
285+ hs.rows()
286+ if !hs.v.follow || hs.v.scroll == 0 {
287+ t.Fatalf("not following the end: follow %v scroll %d", hs.v.follow, hs.v.scroll)
288+ }
289+ hs.key(tcell.KeyPgUp, tcell.ModNone)
290+ hs.rows()
291+ if hs.v.follow {
292+ t.Error("PgUp kept following")
293+ }
294+ at := hs.v.scroll
295+ hs.v.t.Notice("more")
296+ hs.rows()
297+ if hs.v.scroll != at {
298+ t.Errorf("new text moved a reader who scrolled up: %d → %d", at, hs.v.scroll)
299+ }
300+ hs.key(tcell.KeyEnd, tcell.ModNone) // on the box, End moves the column; Tab first
301+ hs.key(tcell.KeyTab, tcell.ModNone)
302+ hs.key(tcell.KeyEnd, tcell.ModNone)
303+ hs.rows()
304+ if !hs.v.follow {
305+ t.Error("End did not resume following")
306+ }
307+ hs.v.handle(tcell.NewEventMouse(0, 0, tcell.WheelUp, tcell.ModNone))
308+ if hs.v.follow {
309+ t.Error("wheel up kept following")
310+ }
311+}
312+
313+// A pending permission replaces the box, takes every key, and a/s/r/Esc
314+// answer it.
315+func TestPermissionKeys(t *testing.T) {
316+ hs := newHarness(t, 70, 12)
317+ for _, c := range []struct {
318+ key tcell.Key
319+ r rune
320+ want permissionAnswer
321+ }{{tcell.KeyRune, 'a', permAllowOnce}, {tcell.KeyRune, 's', permAllowSession}, {tcell.KeyRune, 'r', permReject}, {tcell.KeyEscape, 0, permReject}} {
322+ hs.answers = nil
323+ hs.v.ask = &permission{tool: "bash", title: "bash: rm -rf build"}
324+ if text := hs.screenText(); !strings.Contains(text, "? bash: rm -rf build") || !strings.Contains(text, "[s] allow bash for this session") {
325+ t.Fatalf("permission not drawn:\n%s", text)
326+ }
327+ hs.typeText("x") // ignored while the question stands
328+ if hs.v.Input() != "" {
329+ t.Errorf("typing reached the box during a permission: %q", hs.v.Input())
330+ }
331+ hs.v.handle(tcell.NewEventKey(c.key, c.r, tcell.ModNone))
332+ if len(hs.answers) != 1 || hs.answers[0] != c.want {
333+ t.Errorf("key %q answered %v, want %v", c.r, hs.answers, c.want)
334+ }
335+ if hs.v.ask != nil {
336+ t.Error("the question stayed after the answer")
337+ }
338+ }
339+}
340+
341+// The mouse is the terminal's until F2 says otherwise: with it off, click
342+// and drag select text as in any terminal; F2 captures it (wheel scrolling)
343+// and the status bar says so, F2 again gives it back.
344+func TestF2TogglesMouseCaptureAndStatusSaysSo(t *testing.T) {
345+ hs := newHarness(t, 80, 12)
346+ if hs.v.mouse {
347+ t.Fatal("mouse captured at start-up: the terminal's own selection would be dead")
348+ }
349+ if text := hs.screenText(); !strings.Contains(text, "F2 mouse: off") {
350+ t.Errorf("status bar does not say the mouse is off:\n%s", text)
351+ }
352+ hs.key(tcell.KeyF2, tcell.ModNone)
353+ if !hs.v.mouse {
354+ t.Fatal("F2 did not capture the mouse")
355+ }
356+ if text := hs.screenText(); !strings.Contains(text, "mouse: on (Shift+drag selects)") {
357+ t.Errorf("status bar does not say the mouse is on:\n%s", text)
358+ }
359+ hs.key(tcell.KeyF2, tcell.ModNone)
360+ if hs.v.mouse {
361+ t.Fatal("second F2 did not release the mouse")
362+ }
363+}
364+
365+// Ctrl-Y asks the front end to copy the last answer, from either pane.
366+func TestCtrlYCopies(t *testing.T) {
367+ hs := newHarness(t, 80, 12)
368+ hs.key(tcell.KeyCtrlY, tcell.ModNone)
369+ hs.key(tcell.KeyTab, tcell.ModNone) // focus on the conversation
370+ hs.key(tcell.KeyCtrlY, tcell.ModNone)
371+ if hs.copied != 2 {
372+ t.Errorf("copy hook called %d time(s), want 2", hs.copied)
373+ }
374+}
375+
376+// A paste longer than the box collapses into one token: the box shows the
377+// label and not the lines, the input holds one placeholder rune, and Enter
378+// hands the model the whole text while the display keeps the label.
379+func TestLongPasteCollapsesToOneToken(t *testing.T) {
380+ hs := newHarness(t, 80, 12)
381+ var lines []string
382+ for i := 1; i <= 50; i++ {
383+ lines = append(lines, fmt.Sprintf("line %02d of the block", i))
384+ }
385+ block := strings.Join(lines, "\n")
386+ hs.typeText("look: ")
387+ hs.paste(block)
388+ hs.typeText(" please")
389+
390+ runes := []rune(hs.v.input[0])
391+ if len(hs.v.input) != 1 || len(runes) != len("look: please")+1 || !isPlaceholder(runes[6]) {
392+ t.Fatalf("input %q (%d lines)", hs.v.Input(), len(hs.v.input))
393+ }
394+ row := hs.rows()[8] // header 0, conversation 1–6, rule 7, box 8–10, status 11
395+ if !strings.Contains(row, "> look: [Pasted #1 · 50 lines · 1.0k chars] please") {
396+ t.Errorf("box row %q", row)
397+ }
398+ if strings.Contains(hs.screenText(), "line 01 of the block") {
399+ t.Error("the pasted lines were drawn")
400+ }
401+ if x := hs.v.cursorX(); x != len([]rune("look: [Pasted #1 · 50 lines · 1.0k chars] please")) {
402+ t.Errorf("cursor at column %d", x)
403+ }
404+
405+ hs.key(tcell.KeyEnter, tcell.ModNone)
406+ if len(hs.prompts) != 1 {
407+ t.Fatalf("sent %d prompt(s)", len(hs.prompts))
408+ }
409+ p := hs.prompts[0]
410+ if p.Text() != "look: "+block+" please" {
411+ t.Errorf("Text() = %q", p.Text())
412+ }
413+ if p.Display() != "look: [Pasted #1 · 50 lines · 1.0k chars] please" {
414+ t.Errorf("Display() = %q", p.Display())
415+ }
416+ if hs.v.Input() != "" || len(hs.v.pastes.blocks) != 0 || hs.v.pastes.next != 0 {
417+ t.Error("the box or its pastes survived the send")
418+ }
419+}
420+
421+// The token is one character: Left crosses it in one step, Backspace removes
422+// it whole (and the paste with it), Delete too; two pastes are numbered.
423+func TestPasteTokenIsAtomic(t *testing.T) {
424+ hs := newHarness(t, 80, 12)
425+ long := strings.Repeat("x", pasteCollapseChars+1) // 1 line, too many chars
426+ hs.paste(long)
427+ hs.typeText("a")
428+ hs.paste(strings.Repeat("y\n", 5)) // 6 lines
429+ if got := hs.v.Display(); got != "[Pasted #1 · 1 lines · 201 chars]a[Pasted #2 · 6 lines · 10 chars]" {
430+ t.Fatalf("display %q", got)
431+ }
432+ hs.key(tcell.KeyLeft, tcell.ModNone) // over token #2
433+ hs.key(tcell.KeyLeft, tcell.ModNone) // over "a"
434+ if hs.v.column != 1 {
435+ t.Fatalf("column %d after two Lefts, want 1 (one step per token)", hs.v.column)
436+ }
437+ hs.key(tcell.KeyBackspace2, tcell.ModNone) // removes token #1 whole
438+ if got := hs.v.Display(); got != "a[Pasted #2 · 6 lines · 10 chars]" || len(hs.v.pastes.blocks) != 1 {
439+ t.Errorf("after Backspace: %q, %d paste(s) kept", got, len(hs.v.pastes.blocks))
440+ }
441+ hs.key(tcell.KeyRight, tcell.ModNone) // past "a"
442+ hs.key(tcell.KeyDelete, tcell.ModNone) // removes token #2 whole
443+ if got := hs.v.Display(); got != "a" || len(hs.v.pastes.blocks) != 0 {
444+ t.Errorf("after Delete: %q, %d paste(s) kept", got, len(hs.v.pastes.blocks))
445+ }
446+ hs.key(tcell.KeyEnter, tcell.ModNone)
447+ if len(hs.sent) != 1 || hs.sent[0] != "a" || hs.prompts[0].HasPastes() {
448+ t.Errorf("sent %q, has pastes %v", hs.sent, hs.prompts[0].HasPastes())
449+ }
450+}
451+
452+// The threshold: 3 lines of 200 characters are still text; a 4th line, or a
453+// 201st character, collapses.
454+func TestShouldCollapse(t *testing.T) {
455+ cases := map[string]bool{
456+ "short": false,
457+ "a\nb\nc": false,
458+ "a\nb\nc\nd": true,
459+ strings.Repeat("x", pasteCollapseChars): false,
460+ strings.Repeat("x", pasteCollapseChars+1): true,
461+ }
462+ for in, want := range cases {
463+ if got := shouldCollapse(in); got != want {
464+ t.Errorf("shouldCollapse(%d chars, %d lines) = %v, want %v", len(in), strings.Count(in, "\n")+1, got, want)
465+ }
466+ }
467+}
added internal/tui/wrap.go +65 -0
new file mode 100644
@@ -0,0 +1,65 @@
1+package tui
2+
3+import "strings"
4+
5+// wrap breaks text into lines no wider than width runes, at spaces where it
6+// can and mid-word when a word is longer than the line. Existing newlines are
7+// kept: they are the author's own breaks. width < 1 yields nothing.
8+func wrap(text string, width int) []string {
9+ if width < 1 {
10+ return nil
11+ }
12+ var out []string
13+ for _, line := range strings.Split(text, "\n") {
14+ out = append(out, wrapLine(line, width)...)
15+ }
16+ return out
17+}
18+
19+// wrapLine wraps one line, which always yields at least one — a blank line is
20+// a blank line, not nothing.
21+func wrapLine(line string, width int) []string {
22+ runes := []rune(line)
23+ if len(runes) <= width {
24+ return []string{line}
25+ }
26+ var out []string
27+ for len(runes) > width {
28+ at := breakPoint(runes, width)
29+ out = append(out, strings.TrimRight(string(runes[:at]), " "))
30+ runes = runes[at:]
31+ for len(runes) > 0 && runes[0] == ' ' {
32+ runes = runes[1:]
33+ }
34+ }
35+ if len(runes) > 0 {
36+ out = append(out, string(runes))
37+ }
38+ return out
39+}
40+
41+// breakPoint returns where to break a run of runes: the last space that fits,
42+// or the hard edge — what stops a long path from looping for ever.
43+func breakPoint(runes []rune, width int) int {
44+ for at := width; at > 0; at-- {
45+ if runes[at] == ' ' {
46+ return at
47+ }
48+ }
49+ return width
50+}
51+
52+// clip cuts text to width runes, marking the cut with an ellipsis.
53+func clip(text string, width int) string {
54+ runes := []rune(text)
55+ if width <= 0 {
56+ return ""
57+ }
58+ if len(runes) <= width {
59+ return text
60+ }
61+ if width == 1 {
62+ return "…"
63+ }
64+ return string(runes[:width-1]) + "…"
65+}
new file mode 100644
@@ -0,0 +1,65 @@
1+package tui
2+
3+import "strings"
4+
5+// wrap breaks text into lines no wider than width runes, at spaces where it
6+// can and mid-word when a word is longer than the line. Existing newlines are
7+// kept: they are the author's own breaks. width < 1 yields nothing.
8+func wrap(text string, width int) []string {
9+ if width < 1 {
10+ return nil
11+ }
12+ var out []string
13+ for _, line := range strings.Split(text, "\n") {
14+ out = append(out, wrapLine(line, width)...)
15+ }
16+ return out
17+}
18+
19+// wrapLine wraps one line, which always yields at least one — a blank line is
20+// a blank line, not nothing.
21+func wrapLine(line string, width int) []string {
22+ runes := []rune(line)
23+ if len(runes) <= width {
24+ return []string{line}
25+ }
26+ var out []string
27+ for len(runes) > width {
28+ at := breakPoint(runes, width)
29+ out = append(out, strings.TrimRight(string(runes[:at]), " "))
30+ runes = runes[at:]
31+ for len(runes) > 0 && runes[0] == ' ' {
32+ runes = runes[1:]
33+ }
34+ }
35+ if len(runes) > 0 {
36+ out = append(out, string(runes))
37+ }
38+ return out
39+}
40+
41+// breakPoint returns where to break a run of runes: the last space that fits,
42+// or the hard edge — what stops a long path from looping for ever.
43+func breakPoint(runes []rune, width int) int {
44+ for at := width; at > 0; at-- {
45+ if runes[at] == ' ' {
46+ return at
47+ }
48+ }
49+ return width
50+}
51+
52+// clip cuts text to width runes, marking the cut with an ellipsis.
53+func clip(text string, width int) string {
54+ runes := []rune(text)
55+ if width <= 0 {
56+ return ""
57+ }
58+ if len(runes) <= width {
59+ return text
60+ }
61+ if width == 1 {
62+ return "…"
63+ }
64+ return string(runes[:width-1]) + "…"
65+}
modified main.go +101 -32
@@ -26,7 +26,9 @@
2626 package main
2727
2828 import (
29+ "bytes"
2930 "context"
31+ "errors"
3032 "flag"
3133 "fmt"
3234 "os"
@@ -41,6 +43,7 @@ import (
4143 "mm/internal/skills"
4244 "mm/internal/spinner"
4345 "mm/internal/tools"
46+ "mm/internal/tui"
4447 "mm/internal/ui"
4548
4649 "github.com/firebase/genkit/go/ai"
@@ -60,12 +63,13 @@ func main() {
6063 // saying so is better than ignoring it.
6164 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
6265 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
66+ 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")
6367 flag.Parse()
6468 if *configPath == "" && flag.NArg() > 0 {
6569 *configPath = flag.Arg(0)
6670 }
6771 if flag.NArg() > 1 {
68- fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")
72+ say("[usage: at most one config file, got %d arguments]", flag.NArg())
6973 os.Exit(1)
7074 }
7175
@@ -82,7 +86,7 @@ func main() {
8286 // Settings: built-in defaults, overridden by the YAML file if there is one.
8387 path, err := config.Load(*configPath)
8488 if err != nil {
85- fmt.Fprintln(ui.Out, "[config error:", err, "]")
89+ say("[config error: %v ]", err)
8690 os.Exit(1)
8791 }
8892
@@ -95,47 +99,95 @@ func main() {
9599 // is made absolute here, so the banner can say exactly where it looked.
96100 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
97101
102+ window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode)
103+
98104 e, err := engine.New(ctx)
99105 if err != nil {
100- fmt.Fprintln(ui.Out, "[engine error:", err, "]")
106+ say("[engine error: %v ]", err)
101107 os.Exit(1)
102108 }
103109
104110 // Initialize the global loop detector.
105111 loopDetector := detector.NewLoopDetector(10, 3)
106112
107- agentTools := []ai.ToolRef{
108- tools.Bash(e.G, loopDetector),
113+ agentTools, toolNames, skillCount := assembleTools(e, loopDetector)
114+
115+ if path == "" {
116+ path = "built-in defaults"
109117 }
118+ printBanner(ctx, e, path, toolNames, skillCount)
110119
111- // `read_skill` only exists when there is something to read. Its description
112- // carries the catalogue, so declaring it with an empty directory would
113- // advertise a tool that can do nothing.
114- skillCount := len(skills.List(config.Cfg.SkillsDir))
120+ // Three front ends, one wiring: everything above — config, engine, tools,
121+ // detector — is strictly identical whichever loop runs below.
122+ if *acpMode {
123+ acp.Run(ctx, e, config.Cfg.System, agentTools)
124+ return
125+ }
126+ if window {
127+ runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()))
128+ return
129+ }
130+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
131+}
132+
133+// runWindow starts the single-window front end, falling back to the line
134+// mode — banner first — if the terminal cannot be opened after all.
135+func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) {
136+ cwd, _ := os.Getwd()
137+ err := tui.Run(ctx, tui.Options{
138+ Model: e,
139+ Window: func() int { return e.ContextWindow },
140+ Ensure: e.EnsureContextWindow,
141+ Tools: agentTools,
142+ Detector: d,
143+ System: config.Cfg.System,
144+ Cwd: cwd,
145+ AgentName: "mm",
146+ ModelName: config.Cfg.Model,
147+ Provider: e.Backend.Provider,
148+ Banner: banner,
149+ })
150+ if err == nil {
151+ return
152+ }
153+ ui.Out = os.Stdout
154+ if !errors.Is(err, tui.ErrNotTerminal) {
155+ say("[tui: %v — running the line mode]", err)
156+ }
157+ say("%s", banner)
158+ agent.Run(ctx, e, config.Cfg.System, agentTools, d)
159+}
160+
161+// assembleTools declares the tools every front end gets: `bash` always;
162+// `read_skill` only when the skills directory holds something to read (its
163+// description carries the catalogue, so declaring it empty would advertise a
164+// tool that can do nothing); the file tools behind `editTools` — the same
165+// binary must run as part 09 did (bash + the `edit` CLI) and as this part
166+// (built-in tools), so the two can be compared on the same prompts. It
167+// returns the tools, their names for the banner, and the skill count.
168+func assembleTools(e *engine.Engine, d *detector.LoopDetector) ([]ai.ToolRef, []string, int) {
169+ agentTools := []ai.ToolRef{tools.Bash(e.G, d)}
115170 toolNames := []string{"bash"}
116- if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {
171+ skillCount := len(skills.List(config.Cfg.SkillsDir))
172+ if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
117173 agentTools = append(agentTools, skillTool)
118174 toolNames = append(toolNames, "read_skill")
119175 }
120-
121- // The file tools are a switch, not a given: the same binary must run as
122- // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
123- // the two can be compared on the same prompts.
124176 if config.Cfg.EditTools {
125177 agentTools = append(agentTools,
126- tools.ReadFile(e.G, loopDetector),
127- tools.WriteFile(e.G, loopDetector),
128- tools.EditFile(e.G, loopDetector))
178+ tools.ReadFile(e.G, d),
179+ tools.WriteFile(e.G, d),
180+ tools.EditFile(e.G, d))
129181 toolNames = append(toolNames, "read_file", "write_file", "edit_file")
130182 }
183+ return agentTools, toolNames, skillCount
184+}
131185
132- if path == "" {
133- path = "built-in defaults"
134- }
135-
136- // The probe runs before the banner so its warnings sit right under the
137- // prompt they explain. It never stops the agent: on a demo machine the
138- // server is often started AFTER the agent.
186+// printBanner probes the server and prints the warnings and the one-line
187+// banner. The probe runs before the banner so its warnings sit right under
188+// the prompt they explain; it never stops the agent — on a demo machine the
189+// server is often started AFTER the agent.
190+func printBanner(ctx context.Context, e *engine.Engine, path string, toolNames []string, skillCount int) {
139191 info := e.Probe(ctx)
140192 ctxCol := "unknown"
141193 if info.ContextWindow > 0 {
@@ -160,18 +212,35 @@ func main() {
160212 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
161213 }
162214 for _, w := range info.Warnings {
163- fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)
215+ say("%s[warning: %s]%s", dim, w, off)
164216 }
165- fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",
217+ say("%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",
166218 dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)
219+}
167220
168- // Two front ends, one wiring: everything above — config, engine, tools,
169- // detector — is strictly identical whichever loop runs below.
170- if *acpMode {
171- acp.Run(ctx, e, config.Cfg.System, agentTools)
172- return
221+// windowMode decides whether the window front end runs, and where the banner
222+// goes. The window owns the screen: the spinner must not draw on it, and
223+// the banner is kept in the returned buffer to be shown INSIDE the window as
224+// its first line. Without a terminal (`mm -tui | tee`) the line mode runs
225+// instead, banner on stdout as usual, after one line saying so.
226+func windowMode(wanted bool) (bool, *bytes.Buffer) {
227+ banner := &bytes.Buffer{}
228+ if !wanted {
229+ return false, banner
173230 }
174- agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
231+ if !tui.Terminal() {
232+ say("[tui: stdin or stdout is not a terminal — running the line mode]")
233+ return false, banner
234+ }
235+ ui.Out = banner
236+ spinner.Disable()
237+ return true, banner
238+}
239+
240+// say prints one line of human-facing text where it belongs (ui.Out). A
241+// write error to a terminal or a log is nothing the agent can act on.
242+func say(format string, args ...any) {
243+ _, _ = fmt.Fprintf(ui.Out, format+"\n", args...)
175244 }
176245
177246 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
@@ -26,7 +26,9 @@
26 package main26 package main
27 27
28 import (28 import (
29+ "bytes"
29 "context"30 "context"
31+ "errors"
30 "flag"32 "flag"
31 "fmt"33 "fmt"
32 "os"34 "os"
@@ -41,6 +43,7 @@ import (
41 "mm/internal/skills"43 "mm/internal/skills"
42 "mm/internal/spinner"44 "mm/internal/spinner"
43 "mm/internal/tools"45 "mm/internal/tools"
46+ "mm/internal/tui"
44 "mm/internal/ui"47 "mm/internal/ui"
45 48
46 "github.com/firebase/genkit/go/ai"49 "github.com/firebase/genkit/go/ai"
@@ -60,12 +63,13 @@ func main() {
60 // saying so is better than ignoring it.63 // saying so is better than ignoring it.
61 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")64 configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)")
62 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")65 acpMode := flag.Bool("acp", false, "serve the Agent Client Protocol on stdio (for Zed, JetBrains, Neovim…) instead of the terminal REPL")
66+ 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")
63 flag.Parse()67 flag.Parse()
64 if *configPath == "" && flag.NArg() > 0 {68 if *configPath == "" && flag.NArg() > 0 {
65 *configPath = flag.Arg(0)69 *configPath = flag.Arg(0)
66 }70 }
67 if flag.NArg() > 1 {71 if flag.NArg() > 1 {
68- fmt.Fprintln(ui.Out, "[usage: at most one config file, got", flag.NArg(), "arguments]")72+ say("[usage: at most one config file, got %d arguments]", flag.NArg())
69 os.Exit(1)73 os.Exit(1)
70 }74 }
71 75
@@ -82,7 +86,7 @@ func main() {
82 // Settings: built-in defaults, overridden by the YAML file if there is one.86 // Settings: built-in defaults, overridden by the YAML file if there is one.
83 path, err := config.Load(*configPath)87 path, err := config.Load(*configPath)
84 if err != nil {88 if err != nil {
85- fmt.Fprintln(ui.Out, "[config error:", err, "]")89+ say("[config error: %v ]", err)
86 os.Exit(1)90 os.Exit(1)
87 }91 }
88 92
@@ -95,47 +99,95 @@ func main() {
95 // is made absolute here, so the banner can say exactly where it looked.99 // is made absolute here, so the banner can say exactly where it looked.
96 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)100 config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode)
97 101
102+ window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode)
103+
98 e, err := engine.New(ctx)104 e, err := engine.New(ctx)
99 if err != nil {105 if err != nil {
100- fmt.Fprintln(ui.Out, "[engine error:", err, "]")106+ say("[engine error: %v ]", err)
101 os.Exit(1)107 os.Exit(1)
102 }108 }
103 109
104 // Initialize the global loop detector.110 // Initialize the global loop detector.
105 loopDetector := detector.NewLoopDetector(10, 3)111 loopDetector := detector.NewLoopDetector(10, 3)
106 112
107- agentTools := []ai.ToolRef{113+ agentTools, toolNames, skillCount := assembleTools(e, loopDetector)
108- tools.Bash(e.G, loopDetector),114+
115+ if path == "" {
116+ path = "built-in defaults"
109 }117 }
118+ printBanner(ctx, e, path, toolNames, skillCount)
110 119
111- // `read_skill` only exists when there is something to read. Its description120+ // Three front ends, one wiring: everything above — config, engine, tools,
112- // carries the catalogue, so declaring it with an empty directory would121+ // detector — is strictly identical whichever loop runs below.
113- // advertise a tool that can do nothing.122+ if *acpMode {
114- skillCount := len(skills.List(config.Cfg.SkillsDir))123+ acp.Run(ctx, e, config.Cfg.System, agentTools)
124+ return
125+ }
126+ if window {
127+ runWindow(ctx, e, agentTools, loopDetector, strings.TrimSpace(banner.String()))
128+ return
129+ }
130+ agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)
131+}
132+
133+// runWindow starts the single-window front end, falling back to the line
134+// mode — banner first — if the terminal cannot be opened after all.
135+func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d *detector.LoopDetector, banner string) {
136+ cwd, _ := os.Getwd()
137+ err := tui.Run(ctx, tui.Options{
138+ Model: e,
139+ Window: func() int { return e.ContextWindow },
140+ Ensure: e.EnsureContextWindow,
141+ Tools: agentTools,
142+ Detector: d,
143+ System: config.Cfg.System,
144+ Cwd: cwd,
145+ AgentName: "mm",
146+ ModelName: config.Cfg.Model,
147+ Provider: e.Backend.Provider,
148+ Banner: banner,
149+ })
150+ if err == nil {
151+ return
152+ }
153+ ui.Out = os.Stdout
154+ if !errors.Is(err, tui.ErrNotTerminal) {
155+ say("[tui: %v — running the line mode]", err)
156+ }
157+ say("%s", banner)
158+ agent.Run(ctx, e, config.Cfg.System, agentTools, d)
159+}
160+
161+// assembleTools declares the tools every front end gets: `bash` always;
162+// `read_skill` only when the skills directory holds something to read (its
163+// description carries the catalogue, so declaring it empty would advertise a
164+// tool that can do nothing); the file tools behind `editTools` — the same
165+// binary must run as part 09 did (bash + the `edit` CLI) and as this part
166+// (built-in tools), so the two can be compared on the same prompts. It
167+// returns the tools, their names for the banner, and the skill count.
168+func assembleTools(e *engine.Engine, d *detector.LoopDetector) ([]ai.ToolRef, []string, int) {
169+ agentTools := []ai.ToolRef{tools.Bash(e.G, d)}
115 toolNames := []string{"bash"}170 toolNames := []string{"bash"}
116- if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, loopDetector); skillTool != nil {171+ skillCount := len(skills.List(config.Cfg.SkillsDir))
172+ if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
117 agentTools = append(agentTools, skillTool)173 agentTools = append(agentTools, skillTool)
118 toolNames = append(toolNames, "read_skill")174 toolNames = append(toolNames, "read_skill")
119 }175 }
120-
121- // The file tools are a switch, not a given: the same binary must run as
122- // part 09 did (bash + the `edit` CLI) and as this part (built-in tools), so
123- // the two can be compared on the same prompts.
124 if config.Cfg.EditTools {176 if config.Cfg.EditTools {
125 agentTools = append(agentTools,177 agentTools = append(agentTools,
126- tools.ReadFile(e.G, loopDetector),178+ tools.ReadFile(e.G, d),
127- tools.WriteFile(e.G, loopDetector),179+ tools.WriteFile(e.G, d),
128- tools.EditFile(e.G, loopDetector))180+ tools.EditFile(e.G, d))
129 toolNames = append(toolNames, "read_file", "write_file", "edit_file")181 toolNames = append(toolNames, "read_file", "write_file", "edit_file")
130 }182 }
183+ return agentTools, toolNames, skillCount
184+}
131 185
132- if path == "" {186+// printBanner probes the server and prints the warnings and the one-line
133- path = "built-in defaults"187+// banner. The probe runs before the banner so its warnings sit right under
134- }188+// the prompt they explain; it never stops the agent — on a demo machine the
135-189+// server is often started AFTER the agent.
136- // The probe runs before the banner so its warnings sit right under the190+func printBanner(ctx context.Context, e *engine.Engine, path string, toolNames []string, skillCount int) {
137- // prompt they explain. It never stops the agent: on a demo machine the
138- // server is often started AFTER the agent.
139 info := e.Probe(ctx)191 info := e.Probe(ctx)
140 ctxCol := "unknown"192 ctxCol := "unknown"
141 if info.ContextWindow > 0 {193 if info.ContextWindow > 0 {
@@ -160,18 +212,35 @@ func main() {
160 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))212 fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir))
161 }213 }
162 for _, w := range info.Warnings {214 for _, w := range info.Warnings {
163- fmt.Fprintf(ui.Out, "%s[warning: %s]%s\n", dim, w, off)215+ say("%s[warning: %s]%s", dim, w, off)
164 }216 }
165- fmt.Fprintf(ui.Out, "%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s\n",217+ say("%sconfig: %s | provider: %s | model: %s | ctx: %s | tools: %s | skills: %d%s",
166 dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)218 dim, path, e.Backend.Provider, config.Cfg.Model, ctxCol, strings.Join(toolNames, ", "), skillCount, off)
219+}
167 220
168- // Two front ends, one wiring: everything above — config, engine, tools,221+// windowMode decides whether the window front end runs, and where the banner
169- // detector — is strictly identical whichever loop runs below.222+// goes. The window owns the screen: the spinner must not draw on it, and
170- if *acpMode {223+// the banner is kept in the returned buffer to be shown INSIDE the window as
171- acp.Run(ctx, e, config.Cfg.System, agentTools)224+// its first line. Without a terminal (`mm -tui | tee`) the line mode runs
172- return225+// instead, banner on stdout as usual, after one line saying so.
226+func windowMode(wanted bool) (bool, *bytes.Buffer) {
227+ banner := &bytes.Buffer{}
228+ if !wanted {
229+ return false, banner
173 }230 }
174- agent.Run(ctx, e, config.Cfg.System, agentTools, loopDetector)231+ if !tui.Terminal() {
232+ say("[tui: stdin or stdout is not a terminal — running the line mode]")
233+ return false, banner
234+ }
235+ ui.Out = banner
236+ spinner.Disable()
237+ return true, banner
238+}
239+
240+// say prints one line of human-facing text where it belongs (ui.Out). A
241+// write error to a terminal or a log is nothing the agent can act on.
242+func say(format string, args ...any) {
243+ _, _ = fmt.Fprintf(ui.Out, format+"\n", args...)
175 }244 }
176 245
177 // resolveSkillsDir makes the skills directory absolute. An absolute dir is246 // resolveSkillsDir makes the skills directory absolute. An absolute dir is
added skills/README.md +2 -0
new file mode 100644
@@ -0,0 +1,2 @@
1+# Demo skills
2+> for test
new file mode 100644
@@ -0,0 +1,2 @@
1+# Demo skills
2+> for test