💾 Saved.
45c23b4 parent: d722711 modified
.gitignore +2 -0 | @@ -2,3 +2,5 @@ kits | ||
| 2 | 2 | notes.txt |
| 3 | 3 | tmp |
| 4 | 4 | mm |
| 5 | +.mm/ | |
| 6 | +blog | |
| @@ -2,3 +2,5 @@ kits | |||
| 2 | notes.txt | 2 | notes.txt |
| 3 | tmp | 3 | tmp |
| 4 | mm | 4 | 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. | ||
| 67 | 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 | 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 | 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. | |
| @@ -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 @@ | ||
| 2 | 2 | |
| 3 | 3 | ## 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 | 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 | 11 | ## 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 | 15 | | Build | `go build -o mm .` | |
| 16 | 16 | | Install | `./install.sh` (builds, copies to `/usr/local/bin`) | |
| 17 | 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 | 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 | 22 | | Editor | `mm -acp` with `AGENT_CONFIG=<absolute path>` | |
| 21 | 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 | 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 | 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 | 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 | 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 | 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 | 48 | ## Known defects (verified 2026-09-15, not fixed) |
| 42 | 49 | |
| 43 | 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 | 56 | ## 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 | 62 | ## 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 | 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 | 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. |
| @@ -2,11 +2,11 @@ | |||
| 2 | 2 | ||
| 3 | ## What it is | 3 | ## 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 | ## Commands | 11 | ## 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 oddities | 56 | ## 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 established | 62 | ## 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 @@ | ||
| 1 | 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 | 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"} | |
| @@ -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 | |
| 2 | 2 | |
| 3 | 3 | - **Gate**: ❌ **FAIL** |
| 4 | -- **Commit**: `79fc998` on `main` | |
| 4 | +- **Commit**: `d722711` on `main` | |
| 5 | 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 | 8 | ## Gate violations |
| 9 | 9 | |
| 10 | -- warning-level issues: 67 (max 0) | |
| 10 | +- warning-level issues: 63 (max 0) | |
| 11 | 11 | - code smells: 18 (max 0) |
| 12 | 12 | |
| 13 | 13 | ## Lint issues (`qlty check`) |
| 14 | 14 | |
| 15 | 15 | | level | count | vs previous | |
| 16 | 16 | |---|---|---| |
| 17 | -| warning | 67 | -2 | | |
| 17 | +| warning | 63 | ±0 | | |
| 18 | 18 | |
| 19 | 19 | ### Top rules |
| 20 | 20 | |
| 21 | 21 | | rule | count | |
| 22 | 22 | |---|---| |
| 23 | -| golangci-lint:errcheck | 12 | | |
| 23 | +| golangci-lint:errcheck | 7 | | |
| 24 | 24 | | osv-scanner:CVE-2026-32285 | 2 | |
| 25 | 25 | | osv-scanner:CVE-2026-24051 | 2 | |
| 26 | 26 | | osv-scanner:CVE-2026-39883 | 2 | |
| 27 | 27 | | osv-scanner:CVE-2026-29181 | 2 | |
| 28 | 28 | | osv-scanner:CVE-2026-39824 | 1 | |
| 29 | +| osv-scanner:CVE-2026-56852 | 1 | | |
| 29 | 30 | | osv-scanner:CVE-2025-47910 | 1 | |
| 30 | 31 | | osv-scanner:CVE-2025-47912 | 1 | |
| 31 | 32 | | osv-scanner:CVE-2025-58183 | 1 | |
| @@ -34,14 +35,12 @@ | ||
| 34 | 35 | | osv-scanner:CVE-2025-58187 | 1 | |
| 35 | 36 | | osv-scanner:CVE-2025-58188 | 1 | |
| 36 | 37 | | osv-scanner:CVE-2025-58189 | 1 | |
| 37 | -| osv-scanner:CVE-2025-61723 | 1 | | |
| 38 | 38 | |
| 39 | 39 | ### Most affected files |
| 40 | 40 | |
| 41 | 41 | | file | issues | |
| 42 | 42 | |---|---| |
| 43 | -| go.mod | 55 | | |
| 44 | -| main.go | 5 | | |
| 43 | +| go.mod | 56 | | |
| 45 | 44 | | internal/engine/openai_compat.go | 2 | |
| 46 | 45 | | internal/fileedit/fileedit_test.go | 2 | |
| 47 | 46 | | internal/fileedit/textfile.go | 2 | |
| @@ -49,24 +48,24 @@ | ||
| 49 | 48 | |
| 50 | 49 | ## Code smells (`qlty smells`) |
| 51 | 50 | |
| 52 | -Total: **18** (vs previous: ±0) | |
| 51 | +Total: **18** (vs previous: -1) | |
| 53 | 52 | |
| 54 | 53 | | smell | file | line | detail | |
| 55 | 54 | |---|---|---|---| |
| 56 | 55 | | qlty:return-statements | internal/fileedit/textfile.go | 94 | Function with many returns (count = 7): write | |
| 57 | 56 | | qlty:file-complexity | internal/fileedit/diff.go | 1 | High total complexity (count = 89) | |
| 58 | 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 | | |
| 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 | | |
| 70 | 69 | | qlty:return-statements | internal/engine/engine.go | 226 | Function with many returns (count = 7): Generate | |
| 71 | 70 | | qlty:file-complexity | internal/engine/engine.go | 1 | High total complexity (count = 91) | |
| 72 | 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 | 76 | | metric | total | vs previous | |
| 78 | 77 | |---|---|---| |
| 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 | | |
| 84 | 83 | | lcom | 0 | ±0 | |
| 85 | -| lines | 4719 | +1 | | |
| 86 | -| loc | 2956 | +2 | | |
| 84 | +| lines | 8662 | +2 | | |
| 85 | +| loc | 5736 | +1 | | |
| 87 | 86 | |
| 88 | 87 | ### Most complex files |
| 89 | 88 | |
| 90 | 89 | | file | complex | cyclo | loc | |
| 91 | 90 | |---|---|---|---| |
| 92 | -| internal/agent/agent.go | 131 | 97 | 270 | | |
| 91 | +| internal/agent/agent.go | 126 | 93 | 299 | | |
| 93 | 92 | | internal/engine/engine.go | 91 | 121 | 347 | |
| 94 | 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 | 99 | | internal/tools/files.go | 32 | 50 | 238 | |
| 100 | +| internal/store/keeper.go | 31 | 60 | 131 | | |
| 97 | 101 | | internal/engine/openai_compat.go | 29 | 51 | 176 | |
| 102 | +| internal/store/store.go | 29 | 53 | 152 | | |
| 98 | 103 | | internal/tools/tools.go | 28 | 47 | 191 | |
| 99 | 104 | | 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 | | |
| 107 | 106 | |
| 108 | 107 | ## Trend |
| 109 | 108 | |
| 110 | 109 | | run | timestamp | error | warning | smells | complex | gate | |
| 111 | 110 | |---|---|---|---|---|---|---| |
| 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:07Z | 1 | +# 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 violations | 8 | ## 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 rules | 19 | ### 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 files | 39 | ### 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 files | 87 | ### 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 | ## Trend | 107 | ## 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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | 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 | |
| 13 | 54 | comments: [] |
| @@ -6,8 +6,49 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-15T22:22:54.491Z | 8 | createdAt: 2026-09-15T22:22:54.491Z |
| 9 | -updatedAt: 2026-09-15T22:22:54.491Z | 9 | +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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | 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). | |
| 12 | 98 | tasks: [] |
| 13 | 99 | comments: [] |
| @@ -6,8 +6,94 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-15T22:25:16.449Z | 8 | createdAt: 2026-09-15T22:25:16.449Z |
| 9 | -updatedAt: 2026-09-15T22:25:16.449Z | 9 | +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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-15T23:34:42.762Z |
| 9 | -updatedAt: 2026-09-15T23:34:54.131Z | |
| 9 | +updatedAt: 2026-09-16T05:30:00.000Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::medium |
| 12 | 12 | - 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 | |
| 15 | 110 | comments: [] |
| @@ -6,10 +6,105 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-15T23:34:42.762Z | 8 | createdAt: 2026-09-15T23:34:42.762Z |
| 9 | -updatedAt: 2026-09-15T23:34:54.131Z | 9 | +updatedAt: 2026-09-16T05:30:00.000Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::medium | 11 | - priority::medium |
| 12 | - kind::bug | 12 | - 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: | ||
| 36 | 36 | - name: kind::chore |
| 37 | 37 | color: "#bfdadc" |
| 38 | 38 | description: Maintenance, no visible change |
| 39 | + - name: llama.cpp | |
| 40 | + color: "#fbca04" | |
| @@ -36,3 +36,5 @@ labels: | |||
| 36 | - name: kind::chore | 36 | - name: kind::chore |
| 37 | color: "#bfdadc" | 37 | color: "#bfdadc" |
| 38 | description: Maintenance, no visible change | 38 | description: Maintenance, no visible change |
| 39 | + - name: llama.cpp | ||
| 40 | + color: "#fbca04" | ||
modified
.vscode/settings.json +2 -1 | @@ -58,5 +58,6 @@ | ||
| 58 | 58 | "gopls": { |
| 59 | 59 | "ui.semanticTokens": true, |
| 60 | 60 | "ui.completion.usePlaceholders": true |
| 61 | - } | |
| 61 | + }, | |
| 62 | + "issuespec.groupBy": "label" | |
| 62 | 63 | } |
| \ 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": true | 60 | "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 | ||
| 68 | 68 | # is not declared at all. |
| 69 | 69 | 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 | 81 | # How many lines of a command's output are echoed to the terminal. "Show me |
| 72 | 82 | # that file" means show it: running `cat` is not displaying it. 0 disables it. |
| 73 | 83 | previewLines: 20 |
| @@ -68,6 +68,16 @@ editTools: true | |||
| 68 | # is not declared at all. | 68 | # is not declared at all. |
| 69 | skillsDir: skills | 69 | 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 me | 81 | # 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: 20 | 83 | previewLines: 20 |
modified
agent.llamacpp.yaml +29 -0 | @@ -54,6 +54,35 @@ contextWindow: 0 | ||
| 54 | 54 | maxOutput: 16000 |
| 55 | 55 | maxTurns: 40 |
| 56 | 56 | 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 | 86 | previewLines: 20 |
| 58 | 87 | displayCommands: true |
| 59 | 88 | |
| @@ -54,6 +54,35 @@ contextWindow: 0 | |||
| 54 | maxOutput: 16000 | 54 | maxOutput: 16000 |
| 55 | maxTurns: 40 | 55 | maxTurns: 40 |
| 56 | skillsDir: skills | 56 | 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: 20 | 86 | previewLines: 20 |
| 58 | displayCommands: true | 87 | 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="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| 1 | +<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | |
| \ No newline at end of file | ||
| @@ -1 +1 @@ | |||
| 1 | -<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
| 1 | +<mxfile host="app.diagrams.net" modified="2026-09-15T00:00:00.000Z" agent="mm docs" version="24.0.0" type="device"><diagram id="packages" name="packages"><mxGraphModel dx="1400" dy="900" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="900"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="main" value="<b>main (mm)</b><br/><font style="font-size:10px">wiring: config, engine, tools, then one front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;" vertex="1" parent="1"><mxGeometry x="420" y="40" width="200" height="60" as="geometry"/></mxCell><mxCell id="agent" value="<b>internal/agent</b><br/><font style="font-size:10px">terminal REPL: /quit /abort /compact /new</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="160" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="acp" value="<b>internal/acp</b><br/><font style="font-size:10px">editor front end: Agent Client Protocol on stdio</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="700" y="180" width="200" height="60" as="geometry"/></mxCell><mxCell id="session" value="<b>internal/session</b><br/><font style="font-size:10px">what a conversation starts from; the /new reset</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="compact" value="<b>internal/compact</b><br/><font style="font-size:10px">history compression into a model-written summary</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="40" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="engine" value="<b>internal/engine</b><br/><font style="font-size:10px">LLM connection + generation, one Provider per server</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="700" y="320" width="200" height="60" as="geometry"/></mxCell><mxCell id="tools" value="<b>internal/tools</b><br/><font style="font-size:10px">built-in tools: bash, read_skill, file tools</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="460" width="200" height="60" as="geometry"/></mxCell><mxCell id="detector" value="<b>internal/detector</b><br/><font style="font-size:10px">repeated-action (loop) detection</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="40" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="config" value="<b>internal/config</b><br/><font style="font-size:10px">settings with YAML overrides (agent.yaml)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="260" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="spinner" value="<b>internal/spinner</b><br/><font style="font-size:10px">terminal spinner and styling</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="480" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="ui" value="<b>internal/ui</b><br/><font style="font-size:10px">human output destination + shared event sink</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="700" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="fileedit" value="<b>internal/fileedit</b><br/><font style="font-size:10px">exact-replacement file editing</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="920" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="skills" value="<b>internal/skills</b><br/><font style="font-size:10px">markdown skills catalogue (./skills)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="600" width="200" height="60" as="geometry"/></mxCell><mxCell id="genkit" value="<b>github.com/firebase/genkit</b><br/><font style="font-size:10px">ai.Message, tools, model calls</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="160" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="acpsdk" value="<b>github.com/coder/acp-go-sdk</b><br/><font style="font-size:10px">JSON-RPC transport for ACP</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="700" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="openai" value="<b>github.com/openai/openai-go</b><br/><font style="font-size:10px">OpenAI-compatible HTTP client</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="yaml" value="<b>gopkg.in/yaml.v3</b><br/><font style="font-size:10px">YAML parsing</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="400" y="760" width="200" height="60" as="geometry"/></mxCell><mxCell id="e0" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="agent"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e1" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="acp"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e2" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e3" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e4" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e5" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e6" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e7" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tools"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e8" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e9" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e10" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e11" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e12" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="acpsdk"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e13" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="acp" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e14" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e15" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e16" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e17" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e18" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e19" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e20" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="agent" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e21" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="compact" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e22" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="compact" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e23" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="config" target="yaml"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e24" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e25" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e26" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="engine" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e27" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e28" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="engine" target="openai"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e29" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e30" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e31" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="fileedit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e32" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="skills"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e33" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="spinner"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e34" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tools" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e35" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="tools" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e36" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;dashed=1;" edge="1" parent="1" source="session" target="genkit"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="mention" value="<b>internal/mention</b><br/><font style="font-size:10px">the @path notation of a question</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="400" width="200" height="60" as="geometry"/></mxCell><mxCell id="e37" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e38" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="store" value="<b>internal/store</b><br/><font style="font-size:10px">per-project session files (.mm/sessions/<id>.json)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="480" width="200" height="60" as="geometry"/></mxCell><mxCell id="e39" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e40" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="tui" value="<b>internal/tui</b><br/><font style="font-size:10px">window front end: one screen on tcell (mm -tui)</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1"><mxGeometry x="430" y="160" width="200" height="60" as="geometry"/></mxCell><mxCell id="command" value="<b>internal/command</b><br/><font style="font-size:10px">the one slash-command table, per front end</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;" vertex="1" parent="1"><mxGeometry x="430" y="560" width="200" height="60" as="geometry"/></mxCell><mxCell id="tcell" value="<b>github.com/gdamore/tcell/v2</b><br/><font style="font-size:10px">terminal screen, keys, mouse</font>" style="rounded=1;whiteSpace=wrap;html=1;dashed=1;fillColor=#e1d5e7;strokeColor=#9673a6;" vertex="1" parent="1"><mxGeometry x="920" y="840" width="200" height="60" as="geometry"/></mxCell><mxCell id="e41" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="main" target="tui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e42" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="ui"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e43" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="engine"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e44" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="compact"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e45" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="store"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e46" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="mention"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e47" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e48" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="detector"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e49" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="config"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e50" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e51" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="tcell"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e52" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e53" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="acp" target="command"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e54" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="command" target="session"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="clipboard" value="<b>internal/clipboard</b><br/><font style="font-size:10px">system clipboard: native tool, then OSC 52</font>" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" vertex="1" parent="1"><mxGeometry x="1140" y="680" width="200" height="60" as="geometry"/></mxCell><mxCell id="e55" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="agent" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="e56" style="endArrow=block;html=1;rounded=0;edgeStyle=orthogonalEdgeStyle;" edge="1" parent="1" source="tui" target="clipboard"><mxGeometry relative="1" as="geometry"/></mxCell><mxCell id="legend" value="<b>mm — package dependencies</b><br/>Arrow: "imports". Yellow: entry point. Blue: front ends. Green: shared logic. Grey: leaf packages. Dashed purple: third-party modules.<br/>Regenerate the edge list from: go list -f '{{.ImportPath}}: {{join .Imports " "}}' ./..." style="text;html=1;whiteSpace=wrap;align=left;verticalAlign=top;fillColor=#ffffff;strokeColor=#999999;" vertex="1" parent="1"><mxGeometry x="900" y="40" width="440" height="90" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile> | ||
| \ No newline at end of file | \ No newline at end of file | ||
modified
docs/en/README.md +6 -3 | @@ -1,6 +1,6 @@ | ||
| 1 | 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 | 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 | 16 | - [Keep a long session inside the context window](how-to/manage-the-context-window.md) |
| 17 | 17 | - [Start a new session without restarting](how-to/start-a-new-session.md) |
| 18 | 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 | 22 | ## Reference — look it up |
| 21 | 23 | |
| 22 | 24 | - [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 | |
| 24 | 26 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule |
| 25 | 27 | - [Built-in tools](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` |
| 26 | 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 | 31 | ## 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 | 34 | - [Providers: one implementation per protocol](explanation/providers.md) |
| 33 | 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 | 37 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 35 | 38 | |
| 36 | 39 | ## 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 up | 22 | ## Reference — look it up |
| 21 | 23 | ||
| 22 | - [Command line](reference/cli.md) — flags, environment variables, start-up banner | 24 | - [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 |
| 24 | - [Configuration file](reference/configuration.md) — every YAML key, its default and its validation rule | 26 | - [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 — understand | 31 | ## 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 | ## Scope | 39 | ## 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 | |
| 2 | 2 | |
| 3 | 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 | 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 | 13 | | `tools` | The built-in tools the model can call. | |
| 14 | 14 | | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. | |
| 15 | 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 | 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 | 20 | | `detector` | Detection of the same action repeated with the same result. | |
| 18 | 21 | | `compact` | Compression of the history into a model-written summary. | |
| 19 | 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 | 24 | | `agent` | The terminal REPL. | |
| 22 | 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 | 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 | 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 | 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 | |
| @@ -1,10 +1,10 @@ | |||
| 1 | -# Architecture: one loop, two front ends — explanation | 1 | +# 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 | ||
| 25 | 25 | |
| 26 | 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 | 32 | ## What `/new` resets, and what it keeps |
| 29 | 33 | |
| 30 | 34 | | 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 keeps | 32 | ## 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 | ||
| 31 | 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 | 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 | 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 | 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 | 37 | ## 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 also | 37 | ## 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 @@ | ||
| 16 | 16 | | Field | Value | |
| 17 | 17 | |-------|-------| |
| 18 | 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 | 20 | | `agentInfo.name` | `bob` | |
| 21 | 21 | | `agentInfo.title` | `Bob (bash-first agent)` | |
| 22 | 22 | | `agentInfo.version` | `0.11.0` | |
| @@ -28,11 +28,26 @@ | ||
| 28 | 28 | |--------|-----------| |
| 29 | 29 | | `initialize` | Logs the client name and protocol version on stderr; returns the values above. | |
| 30 | 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 | 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 | 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 | 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 | 51 | ## `session/prompt` |
| 37 | 52 | |
| 38 | 53 | | Aspect | Behaviour | |
| @@ -40,7 +55,7 @@ | ||
| 40 | 55 | | Unknown `sessionId` | `invalid params` error carrying the `sessionId`. | |
| 41 | 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 | 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 | 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 | 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 | 61 | | Success | `stopReason: end_turn`. | |
| @@ -52,7 +67,7 @@ | ||
| 52 | 67 | |
| 53 | 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 | 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 | 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 | 73 | | `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>] | ||
| 13 | 13 | | `-config <path>` | string | `""` | Path of the YAML configuration file. | |
| 14 | 14 | | `<path>` | positional | none | Same as `-config`, when `-config` is not given. A second positional argument is an error. | |
| 15 | 15 | | `-acp` | bool | `false` | Serve the Agent Client Protocol on stdin/stdout instead of the terminal REPL. | |
| 16 | +| `-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 | 17 | | `-h` | — | — | Print the flag usage and exit. | |
| 17 | 18 | |
| 18 | 19 | ## Configuration file resolution |
| @@ -46,6 +47,7 @@ Printed on stdout in terminal mode, on stderr in ACP mode. Dimmed when stdout is | ||
| 46 | 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 | 48 | | `Agent ready. Commands: …` | Terminal mode only. | |
| 48 | 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 | 52 | ### Example |
| 51 | 53 | |
| @@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b- | ||
| 63 | 65 | | Spinner | Disabled. | |
| 64 | 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 | 84 | ## Exit codes |
| 67 | 85 | |
| 68 | 86 | | 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 resolution | 19 | ## 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 | ### Example | 52 | ### 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 codes | 84 | ## Exit codes |
| 67 | 85 | ||
| 68 | | Code | Cause | | 86 | | Code | Cause | |
modified
docs/en/reference/configuration.md +12 -0 | @@ -22,6 +22,9 @@ | ||
| 22 | 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 | 23 | | `watchdogTimeout` | duration | `20s` | Silence tolerated between two streamed tokens before the connection is declared hung. Doubled for summary requests. | |
| 24 | 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 | 29 | ## `context` keys |
| 27 | 30 | |
| @@ -35,6 +38,13 @@ | ||
| 35 | 38 | | `prompt` | string | `""` | Replaces the built-in summary prompt when not empty. | |
| 36 | 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 | 48 | ## Provider defaults |
| 39 | 49 | |
| 40 | 50 | | Provider | `baseUrl` | `fallback` | `apiKeyEnv` | Key required | |
| @@ -58,6 +68,8 @@ Reported as `[config error: <path>: <message>]`, exit code 1. | ||
| 58 | 68 | | `context.keepLastTurns must be >= 1` | zero or negative | |
| 59 | 69 | | `context.summaryMaxTokens must be > 0` | zero or negative | |
| 60 | 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 | 73 | | `unknown provider "x" (known: dmr, llamacpp)` | Reported by the engine, as `[engine error: …]`. | |
| 62 | 74 | |
| 63 | 75 | 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` keys | 29 | ## `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 defaults | 48 | ## 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 @@ | ||
| 7 | 7 | | Rule | Value | |
| 8 | 8 | |------|-------| |
| 9 | 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 | 11 | | Skill name on disk | The file name without `.md`, or the directory name for a `SKILL.md`. | |
| 12 | 12 | | Both forms for one name | `read_skill` returns the flat file. | |
| 13 | 13 | | 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 @@ | ||
| 10 | 10 | | `/abort` | Prints `⚠️ No generation in progress.` | Cancels the generation, prints `🛑 Aborting...`; the commands already run stay in the history. | |
| 11 | 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 | 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 | 20 | ## `@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 | 39 | | `Ctrl+C` | Prints `Goodbye.` and exits. | Prints `🛑 Interrupted (Ctrl+C).` and cancels the generation; the prompt comes back. | |
| 36 | 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 | 64 | ## Messages printed at the end of a turn |
| 39 | 65 | |
| 40 | 66 | | 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 question | 20 | ## `@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 turn | 64 | ## 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. | ||
| 19 | 19 | | Non-zero exit | Output ends with `[exit code: exit status N]`. | |
| 20 | 20 | | Terminal display | `🛠️ bash: <command>` before running, then up to `previewLines` lines dimmed and indented, or `(no output)`. | |
| 21 | 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 | 24 | ## `read_skill` |
| 24 | 25 | |
| @@ -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 | ||
| 33 | 33 | |
| 34 | 34 | ``` |
| 35 | 35 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 |
| 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 | 37 | Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. |
| 38 | 38 | |
| 39 | 39 | > |
| @@ -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: 0 | 35 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 |
| 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 @@ | ||
| 1 | 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 | 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 | 16 | - [Garder une longue session dans la fenêtre de contexte](how-to/manage-the-context-window.md) |
| 17 | 17 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) |
| 18 | 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 | 22 | ## Référence — consulter |
| 21 | 23 | |
| 22 | 24 | - [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 | |
| 24 | 26 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation |
| 25 | 27 | - [Outils intégrés](reference/tools.md) — `bash`, `read_skill`, `read_file`, `write_file`, `edit_file` |
| 26 | 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 | 31 | ## 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 | 34 | - [Fournisseurs : une implémentation par protocole](explanation/providers.md) |
| 33 | 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 | 37 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 35 | 38 | |
| 36 | 39 | ## 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 — consulter | 22 | ## Référence — consulter |
| 21 | 23 | ||
| 22 | - [Ligne de commande](reference/cli.md) — options, variables d'environnement, bannière de démarrage | 24 | - [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 |
| 24 | - [Fichier de configuration](reference/configuration.md) — chaque clé YAML, sa valeur par défaut et sa règle de validation | 26 | - [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 — comprendre | 31 | ## 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ètre | 39 | ## 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 | |
| 2 | 2 | |
| 3 | 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 | 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 | 13 | | `tools` | Les outils intégrés que le modèle peut appeler. | |
| 14 | 14 | | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. | |
| 15 | 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 | 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 | 20 | | `detector` | La détection d'une même action répétée avec le même résultat. | |
| 18 | 21 | | `compact` | La compression de l'historique en un résumé écrit par le modèle. | |
| 19 | 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 | 24 | | `agent` | Le REPL du terminal. | |
| 22 | 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 | 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 | 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 | 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 | |
| @@ -1,10 +1,10 @@ | |||
| 1 | -# Architecture : une boucle, deux façades — explication | 1 | +# 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 | ||
| 25 | 25 | |
| 26 | 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 | 32 | ## Ce que `/new` réinitialise, et ce qu'il conserve |
| 29 | 33 | |
| 30 | 34 | | 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 conserve | 32 | ## 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 | ||
| 31 | 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 | 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 | 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 | 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 | 37 | ## 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 aussi | 37 | ## 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 @@ | ||
| 16 | 16 | | Champ | Valeur | |
| 17 | 17 | |-------|--------| |
| 18 | 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 | 20 | | `agentInfo.name` | `bob` | |
| 21 | 21 | | `agentInfo.title` | `Bob (bash-first agent)` | |
| 22 | 22 | | `agentInfo.version` | `0.11.0` | |
| @@ -28,11 +28,26 @@ | ||
| 28 | 28 | |---------|--------------| |
| 29 | 29 | | `initialize` | Journalise le nom du client et la version du protocole sur stderr ; renvoie les valeurs ci-dessus. | |
| 30 | 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 | 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 | 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 | 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 | 51 | ## `session/prompt` |
| 37 | 52 | |
| 38 | 53 | | Aspect | Comportement | |
| @@ -40,7 +55,7 @@ | ||
| 40 | 55 | | `sessionId` inconnu | Erreur `invalid params` portant le `sessionId`. | |
| 41 | 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 | 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 | 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 | 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 | 61 | | Succès | `stopReason: end_turn`. | |
| @@ -52,7 +67,7 @@ | ||
| 52 | 67 | |
| 53 | 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 | 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 | 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 | 73 | | `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>] | ||
| 13 | 13 | | `-config <chemin>` | chaîne | `""` | Chemin du fichier de configuration YAML. | |
| 14 | 14 | | `<chemin>` | positionnel | aucun | Équivalent de `-config` quand `-config` n'est pas donné. Un second argument positionnel est une erreur. | |
| 15 | 15 | | `-acp` | booléen | `false` | Sert l'Agent Client Protocol sur stdin/stdout au lieu du REPL du terminal. | |
| 16 | +| `-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 | 17 | | `-h` | — | — | Affiche l'usage des options et quitte. | |
| 17 | 18 | |
| 18 | 19 | ## 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 | 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 | 48 | | `Agent ready. Commands: …` | Mode terminal seulement. | |
| 48 | 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 | 52 | ### Exemple |
| 51 | 53 | |
| @@ -63,6 +65,22 @@ config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/mellum2-12b- | ||
| 63 | 65 | | Spinner | Désactivé. | |
| 64 | 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 | 84 | ## Codes de sortie |
| 67 | 85 | |
| 68 | 86 | | 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 configuration | 19 | ## 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 | ### Exemple | 52 | ### 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 sortie | 84 | ## Codes de sortie |
| 67 | 85 | ||
| 68 | | Code | Cause | | 86 | | Code | Cause | |
modified
docs/fr/reference/configuration.md +12 -0 | @@ -22,6 +22,9 @@ | ||
| 22 | 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 | 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 | 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 | 29 | ## Clés de `context` |
| 27 | 30 | |
| @@ -35,6 +38,13 @@ | ||
| 35 | 38 | | `prompt` | chaîne | `""` | Remplace le prompt de résumé intégré quand non vide. | |
| 36 | 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 | 48 | ## Valeurs par défaut des fournisseurs |
| 39 | 49 | |
| 40 | 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 | 68 | | `context.keepLastTurns must be >= 1` | zéro ou négatif | |
| 59 | 69 | | `context.summaryMaxTokens must be > 0` | zéro ou négatif | |
| 60 | 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 | 73 | | `unknown provider "x" (known: dmr, llamacpp)` | Signalée par le moteur, sous la forme `[engine error: …]`. | |
| 62 | 74 | |
| 63 | 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`. |
| @@ -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 fournisseurs | 48 | ## 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 @@ | ||
| 7 | 7 | | Règle | Valeur | |
| 8 | 8 | |-------|--------| |
| 9 | 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 | 11 | | Nom du skill sur disque | Le nom du fichier sans `.md`, ou le nom du répertoire pour un `SKILL.md`. | |
| 12 | 12 | | Les deux formes pour un même nom | `read_skill` renvoie le fichier à plat. | |
| 13 | 13 | | 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 @@ | ||
| 10 | 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 | 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 | 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 | 20 | ## `@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 | 39 | | `Ctrl+C` | Affiche `Goodbye.` et quitte. | Affiche `🛑 Interrupted (Ctrl+C).` et annule la génération ; l'invite revient. | |
| 36 | 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 | 64 | ## Messages affichés en fin de tour |
| 39 | 65 | |
| 40 | 66 | | 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 question | 20 | ## `@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 tour | 64 | ## 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é. | ||
| 19 | 19 | | Code de sortie non nul | La sortie se termine par `[exit code: exit status N]`. | |
| 20 | 20 | | Affichage terminal | `🛠️ bash: <command>` avant exécution, puis jusqu'à `previewLines` lignes grisées et indentées, ou `(no output)`. | |
| 21 | 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 | 24 | ## `read_skill` |
| 24 | 25 | |
| @@ -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 | ||
| 33 | 33 | |
| 34 | 34 | ``` |
| 35 | 35 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 |
| 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 | 37 | Shortcut: Ctrl+C to abort generation, or Ctrl+C at prompt to quit. |
| 38 | 38 | |
| 39 | 39 | > |
| @@ -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: 0 | 35 | config: agent.yaml | provider: dmr | model: huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M | ctx: unknown | tools: bash, read_file, write_file, edit_file | skills: 0 |
| 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 | ||
| 4 | 4 | |
| 5 | 5 | require ( |
| 6 | 6 | github.com/firebase/genkit/go v1.10.0 |
| 7 | + github.com/gdamore/tcell/v2 v2.13.10 | |
| 7 | 8 | github.com/openai/openai-go v1.8.2 |
| 8 | 9 | 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 | 20 | require ( |
| 12 | 21 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 13 | 22 | github.com/buger/jsonparser v1.1.1 // indirect |
| @@ -35,5 +44,5 @@ require ( | ||
| 35 | 44 | go.opentelemetry.io/otel/metric v1.36.0 // indirect |
| 36 | 45 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect |
| 37 | 46 | 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 | |
| 39 | 48 | ) |
| @@ -4,10 +4,19 @@ go 1.25.0 | |||
| 4 | 4 | ||
| 5 | require ( | 5 | require ( |
| 6 | github.com/firebase/genkit/go v1.10.0 | 6 | 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.2 | 8 | github.com/openai/openai-go v1.8.2 |
| 8 | gopkg.in/yaml.v3 v3.0.1 | 9 | 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 // indirect | 21 | github.com/bahlo/generic-list-go v0.2.0 // indirect |
| 13 | github.com/buger/jsonparser v1.1.1 // indirect | 22 | github.com/buger/jsonparser v1.1.1 // indirect |
| @@ -35,5 +44,5 @@ require ( | |||
| 35 | go.opentelemetry.io/otel/metric v1.36.0 // indirect | 44 | go.opentelemetry.io/otel/metric v1.36.0 // indirect |
| 36 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | 45 | go.opentelemetry.io/otel/sdk v1.36.0 // indirect |
| 37 | go.opentelemetry.io/otel/trace v1.36.0 // indirect | 46 | 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 |
| 39 | ) | 48 | ) |
modified
go.sum +45 -2 | @@ -11,6 +11,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | ||
| 11 | 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
| 12 | 12 | github.com/firebase/genkit/go v1.10.0 h1:kOu3MKfgqRPk9yYHg2HFoCg8VWzcHJtfRyQw7OuYqMs= |
| 13 | 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 | 18 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= |
| 15 | 19 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= |
| 16 | 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 | 34 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= |
| 31 | 35 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= |
| 32 | 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 | 39 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= |
| 34 | 40 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= |
| 35 | 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 | 44 | github.com/openai/openai-go v1.8.2/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= |
| 39 | 45 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
| 40 | 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 | 49 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= |
| 42 | 50 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= |
| 43 | 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 | 73 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= |
| 66 | 74 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= |
| 67 | 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 | 77 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= |
| 69 | 78 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= |
| 70 | 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 | 86 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= |
| 78 | 87 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= |
| 79 | 88 | 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= | |
| 82 | 125 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
| 83 | 126 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= |
| 84 | 127 | 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 | ||
| 22 | 22 | import ( |
| 23 | 23 | "context" |
| 24 | 24 | "encoding/json" |
| 25 | - "errors" | |
| 26 | 25 | "fmt" |
| 27 | 26 | "io" |
| 28 | 27 | "log/slog" |
| @@ -30,11 +29,13 @@ import ( | ||
| 30 | 29 | "strings" |
| 31 | 30 | "sync" |
| 32 | 31 | |
| 32 | + "mm/internal/command" | |
| 33 | 33 | "mm/internal/compact" |
| 34 | 34 | "mm/internal/config" |
| 35 | 35 | "mm/internal/engine" |
| 36 | 36 | "mm/internal/mention" |
| 37 | 37 | history "mm/internal/session" |
| 38 | + "mm/internal/store" | |
| 38 | 39 | "mm/internal/ui" |
| 39 | 40 | |
| 40 | 41 | sdk "github.com/coder/acp-go-sdk" |
| @@ -52,6 +53,11 @@ type session struct { | ||
| 52 | 53 | cwd string |
| 53 | 54 | messages []*ai.Message |
| 54 | 55 | 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 | 61 | // allowAlways remembers the tools the user granted for the whole session |
| 56 | 62 | // ("allow always" in the permission dialog). |
| 57 | 63 | allowAlways map[string]bool |
| @@ -64,9 +70,13 @@ type front struct { | ||
| 64 | 70 | conn *sdk.AgentSideConnection |
| 65 | 71 | out *responseWriter // what the SDK writes to; see responseWriter |
| 66 | 72 | |
| 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 | |
| 68 | 79 | sessions map[sdk.SessionId]*session |
| 69 | - nextID int | |
| 70 | 80 | |
| 71 | 81 | // turnMu serialises the turns. Two reasons, both structural: the active |
| 72 | 82 | // 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 | 103 | // we read the client's messages. |
| 94 | 104 | func newFront(e *engine.Engine, system string, tools []ai.ToolRef, w io.Writer, r io.Reader) *front { |
| 95 | 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 | 109 | f.out = &responseWriter{Writer: w, hooks: map[sdk.SessionId]func(){}} |
| 97 | 110 | conn := sdk.NewAgentSideConnection(f, f.out, r) |
| 98 | 111 | conn.SetLogger(slog.New(slog.NewTextHandler(os.Stderr, nil))) |
| 99 | 112 | f.conn = conn |
| 113 | + lastFront = f | |
| 100 | 114 | 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 | 122 | // responseWriter is the io.Writer handed to the SDK: the real output plus one |
| 104 | 123 | // hook per session, run once the session/new response carrying that session |
| 105 | 124 | // 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 | 186 | clientName(p.ClientInfo), p.ProtocolVersion) |
| 168 | 187 | return sdk.InitializeResponse{ |
| 169 | 188 | 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 != ""}, | |
| 173 | 194 | AgentInfo: &sdk.Implementation{Name: "bob", Title: sdk.Ptr("Bob (bash-first agent)"), Version: version}, |
| 174 | 195 | AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into |
| 175 | 196 | }, nil |
| @@ -189,14 +210,18 @@ func (f *front) Authenticate(context.Context, sdk.AuthenticateRequest) (sdk.Auth | ||
| 189 | 210 | // --- session/new -------------------------------------------------------------- |
| 190 | 211 | |
| 191 | 212 | 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{ | |
| 196 | 218 | cwd: p.Cwd, // absolute, per the spec; where bash runs for this session |
| 197 | 219 | messages: history.Fresh(f.system), |
| 198 | 220 | allowAlways: map[string]bool{}, |
| 199 | 221 | } |
| 222 | + f.persistNew(sid, s) | |
| 223 | + f.mu.Lock() | |
| 224 | + f.sessions[sid] = s | |
| 200 | 225 | f.mu.Unlock() |
| 201 | 226 | // mcpServers is where the client offers extra tools. bob's whole point is |
| 202 | 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 | 241 | 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 | 244 | // availableCommands lists the slash commands this front end handles itself, |
| 233 | 245 | // 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. | |
| 237 | 250 | 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}) | |
| 241 | 254 | } |
| 255 | + return out | |
| 242 | 256 | } |
| 243 | 257 | |
| 244 | 258 | // --- session/prompt ----------------------------------------------------------- |
| @@ -259,11 +273,8 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 259 | 273 | // A slash command is answered here, without a turn: it is the REPL's "only |
| 260 | 274 | // between two generations" rule, met for free — turnMu guarantees no |
| 261 | 275 | // 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 | |
| 267 | 278 | } |
| 268 | 279 | |
| 269 | 280 | // 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 | 307 | s.messages = append(s.messages, ai.NewUserTextMessage(input)) |
| 297 | 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 | 312 | // Same rule as the REPL, for the same reason: keep the FULL history even |
| 302 | 313 | // when the turn failed or was cancelled — the commands that already ran are |
| 303 | 314 | // part of the conversation, and forgetting them makes the model re-run or |
| 304 | 315 | // 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) | |
| 313 | 317 | |
| 314 | 318 | // The ⚙ recap of the REPL, one line on stderr: the editor already renders |
| 315 | 319 | // 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 | 321 | turn := s.messages[min(before, len(s.messages)):] |
| 318 | 322 | fmt.Fprintf(os.Stderr, "[acp] turn done: %d command(s) · %d file op(s) · %d skill(s)\n", |
| 319 | 323 | len(engine.CommandList(turn)), len(engine.FileOps(turn)), engine.Skills(turn)) |
| 324 | + f.persist(p.SessionId, s) | |
| 320 | 325 | |
| 321 | 326 | switch { |
| 322 | 327 | case turnCtx.Err() != nil: |
| @@ -336,6 +341,18 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 336 | 341 | 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 | 356 | // startNewSession is the ACP side of /new: the session keeps its id, its cwd |
| 340 | 357 | // and the "allow always" grants — the dialog promised them "for this session", |
| 341 | 358 | // 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 | 361 | // REPL prints travels as an agent message so the editor shows it in the |
| 345 | 362 | // thread, and the turn ends right there: the model is not consulted. |
| 346 | 363 | func (f *front) startNewSession(ctx context.Context, sid sdk.SessionId, s *session) sdk.PromptResponse { |
| 364 | + f.archive(sid, s) | |
| 347 | 365 | forgotten := resetSession(s, f.system) |
| 348 | 366 | f.e.ForgetInputTokens() |
| 367 | + f.persist(sid, s) | |
| 349 | 368 | fmt.Fprintf(os.Stderr, "[acp] session %s: new session, %d message(s) forgotten\n", sid, forgotten) |
| 350 | 369 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ |
| 351 | 370 | SessionId: sid, |
| @@ -366,6 +385,7 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio | ||
| 366 | 385 | }, f.e.Explain) |
| 367 | 386 | if compressed { |
| 368 | 387 | f.e.ForgetInputTokens() // the server's measure described the history just replaced |
| 388 | + f.persist(sid, s) | |
| 369 | 389 | } |
| 370 | 390 | fmt.Fprintf(os.Stderr, "[acp] session %s: /compact — %s\n", sid, line) |
| 371 | 391 | _ = f.conn.SessionUpdate(ctx, sdk.SessionNotification{ |
| @@ -382,15 +402,9 @@ func (f *front) compactSession(ctx context.Context, sid sdk.SessionId, s *sessio | ||
| 382 | 402 | // injected, so the three outcomes are testable without an engine or a |
| 383 | 403 | // connection. |
| 384 | 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) | |
| 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 | |
| 394 | 408 | } |
| 395 | 409 | |
| 396 | 410 | // 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 string | 53 | cwd string |
| 53 | messages []*ai.Message | 54 | messages []*ai.Message |
| 54 | cancel context.CancelFunc // cancels the turn in flight, if any | 55 | 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 session | 61 | // 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]bool | 63 | allowAlways map[string]bool |
| @@ -64,9 +70,13 @@ type front struct { | |||
| 64 | conn *sdk.AgentSideConnection | 70 | conn *sdk.AgentSideConnection |
| 65 | out *responseWriter // what the SDK writes to; see responseWriter | 71 | out *responseWriter // what the SDK writes to; see responseWriter |
| 66 | 72 | ||
| 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 | ||
| 68 | sessions map[sdk.SessionId]*session | 79 | sessions map[sdk.SessionId]*session |
| 69 | - nextID int | ||
| 70 | 80 | ||
| 71 | // turnMu serialises the turns. Two reasons, both structural: the active | 81 | // turnMu serialises the turns. Two reasons, both structural: the active |
| 72 | // ui.Sink is process-global, and engine.Generate was never designed for two | 82 | // 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 = conn | 112 | f.conn = conn |
| 113 | + lastFront = f | ||
| 100 | return f | 114 | 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 one | 122 | // 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 session | 123 | // 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 says | 124 | // 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, // 1 | 188 | 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 into | 195 | AuthMethods: []sdk.AuthMethod{}, // the engine is local: nothing to log into |
| 175 | }, nil | 196 | }, 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 session | 218 | 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 is | 226 | // 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}, nil | 241 | 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 — announcing | 246 | +// 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, would | 247 | +// 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 "only | 273 | // 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 no | 274 | // 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, the | 280 | // 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 even | 312 | // 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 are | 313 | // 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 or | 314 | // 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 renders | 318 | // 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 a | 319 | // 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}, nil | 341 | 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 cwd | 356 | // 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 history | 358 | // 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 the | 361 | // 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 replaced | 387 | 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 a | 402 | // 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 how | 410 | // 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 ( | ||
| 10 | 10 | "testing" |
| 11 | 11 | "time" |
| 12 | 12 | |
| 13 | + "mm/internal/command" | |
| 13 | 14 | "mm/internal/config" |
| 14 | 15 | history "mm/internal/session" |
| 15 | 16 | |
| @@ -54,7 +55,7 @@ func TestToolKindDefaultsToExecute(t *testing.T) { | ||
| 54 | 55 | // command that works but is not listed is one the user never finds). Names |
| 55 | 56 | // are bare: ACP clients add the slash. |
| 56 | 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 | 59 | cmds := availableCommands() |
| 59 | 60 | if len(cmds) != len(want) { |
| 60 | 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 | 73 | // Each advertised name must be recognised when it comes back as "/name". |
| 73 | 74 | typed := "/" + c.Name |
| 74 | - if !history.IsNewCommand(typed) && !isCommand(typed, compactCommand) { | |
| 75 | + if !command.Is(typed, command.New) && !command.Is(typed, command.Compact) { | |
| 75 | 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 | 81 | // resetSession is what /new does to a session: the history goes back to the |
| 97 | 82 | // system prompt alone, the count says how much was dropped, and everything |
| 98 | 83 | // 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). Names | 55 | // 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.Name | 74 | 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 the | 81 | // 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 everything | 82 | // system prompt alone, the count says how much was dropped, and everything |
| 98 | // that is NOT the history — cwd, the "allow always" grants — survives, because | 83 | // 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 | ||
| 4 | 4 | import ( |
| 5 | 5 | "bufio" |
| 6 | 6 | "context" |
| 7 | - "errors" | |
| 8 | 7 | "fmt" |
| 9 | 8 | "io" |
| 10 | 9 | "os" |
| 11 | 10 | "os/signal" |
| 12 | 11 | "strings" |
| 13 | 12 | |
| 13 | + "mm/internal/clipboard" | |
| 14 | + "mm/internal/command" | |
| 14 | 15 | "mm/internal/compact" |
| 15 | 16 | "mm/internal/config" |
| 16 | 17 | "mm/internal/detector" |
| @@ -18,6 +19,7 @@ import ( | ||
| 18 | 19 | "mm/internal/mention" |
| 19 | 20 | "mm/internal/session" |
| 20 | 21 | "mm/internal/spinner" |
| 22 | + "mm/internal/store" | |
| 21 | 23 | |
| 22 | 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 | 41 | ctx, cancelAll := context.WithCancel(ctx) |
| 40 | 42 | defer cancelAll() |
| 41 | 43 | |
| 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. | |
| 44 | 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 | 54 | // sigCh captures Ctrl+C (SIGINT). |
| 47 | 55 | sigCh := make(chan os.Signal, 1) |
| 48 | 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 | 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 | 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 | 98 | 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. | |
| 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) { | |
| 112 | 124 | break |
| 113 | 125 | } |
| 114 | 126 | if input == "" { |
| 115 | 127 | continue |
| 116 | 128 | } |
| 117 | - if input == "/abort" { | |
| 129 | + if command.Is(input, command.Abort) { | |
| 118 | 130 | spinner.Stop() |
| 119 | 131 | fmt.Println("⚠️ No generation in progress.") |
| 120 | 132 | continue |
| 121 | 133 | } |
| 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 | |
| 136 | 143 | 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 | 168 | if dec := compact.Decide(messages, e.LastInputTokens(), e.ContextWindow, config.Cfg.Context); dec.Compact { |
| 162 | 169 | messages = compactHistory(ctx, e, messages, false) |
| 170 | + save(keep, messages) | |
| 163 | 171 | } |
| 164 | 172 | // "@path" in the question names a file for the model to look at. The |
| 165 | 173 | // 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 | 206 | // overrun or a silence caught by the watchdog, all the work of |
| 199 | 207 | // the turn is in `history`, and Genkit itself returns nothing. |
| 200 | 208 | // (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) | |
| 209 | 211 | |
| 210 | 212 | if res.err != nil { |
| 211 | 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 | 272 | case nextInput := <-inputCh: |
| 271 | 273 | 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): | |
| 274 | 276 | cancelGen() |
| 275 | 277 | fmt.Println("🛑 Aborting...") |
| 276 | - case "/quit": | |
| 278 | + case command.Is(nextInput, command.Quit): | |
| 277 | 279 | cancelGen() |
| 278 | 280 | fmt.Println("Goodbye.") |
| 279 | 281 | return |
| @@ -288,15 +290,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 288 | 290 | // Same principle as above: the commands already run stay in |
| 289 | 291 | // the history, otherwise the next question starts again |
| 290 | 292 | // 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) + "]") | |
| 299 | 296 | } |
| 297 | + save(keep, messages) | |
| 300 | 298 | generationRunning = false |
| 301 | 299 | generating = false |
| 302 | 300 | |
| @@ -370,30 +368,34 @@ func compactHistory(ctx context.Context, e *engine.Engine, msgs []*ai.Message, f | ||
| 370 | 368 | // while it runs: without a label the agent looks hung, exactly the case |
| 371 | 369 | // the spinner exists for. |
| 372 | 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 | 372 | return e.Summarize(ctx, request, cfg.SummaryMaxTokens) |
| 375 | - }) | |
| 373 | + }, e.Explain) | |
| 376 | 374 | spinner.Stop() |
| 377 | 375 | |
| 378 | - switch { | |
| 379 | - case errors.Is(err, compact.ErrNothingToCompact): | |
| 376 | + switch outcome { | |
| 377 | + case compact.Nothing: | |
| 380 | 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. | |
| 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() + "]") | |
| 395 | 398 | } |
| 396 | - return res.Messages | |
| 397 | 399 | } |
| 398 | 400 | |
| 399 | 401 | // startNewSession forgets the conversation and returns the history of a fresh |
| @@ -423,3 +425,52 @@ func dimln(line string) { | ||
| 423 | 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 | +} | |
| @@ -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 anchor | 44 | + // @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 := false | 98 | 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 | break | 124 | break |
| 113 | } | 125 | } |
| 114 | if input == "" { | 126 | if input == "" { |
| 115 | continue | 127 | 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 | continue | 132 | 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 a | 135 | + // /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 | - continue | 139 | + // 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 history | 141 | + 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, the | 142 | + 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 | continue | 143 | 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. The | 172 | // "@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 an | 173 | // 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 of | 206 | // 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 ligne | 273 | 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 | return | 281 | 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 in | 290 | // Same principle as above: the commands already run stay in |
| 289 | // the history, otherwise the next question starts again | 291 | // 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.history | 295 | + 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 = false | 298 | generationRunning = false |
| 301 | generating = false | 299 | 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 case | 368 | // 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 fresh | 401 | // 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 { | ||
| 50 | 50 | // compressed 12 messages → 1 summary + 4 kept (18.2k → 6.1k tokens, 3.4s) |
| 51 | 51 | func (r Result) Report() string { |
| 52 | 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. | |
| 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 { | |
| 58 | 60 | if n < 1000 { |
| 59 | 61 | return fmt.Sprintf("%d", n) |
| 60 | 62 | } |
| @@ -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 { | ||
| 90 | 90 | // measures against is the top-level ContextWindow above — or, when that is |
| 91 | 91 | // 0, what the provider's probe learned from the server. |
| 92 | 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 | 129 | // ContextConfig drives the compression of the history (see |
| @@ -179,6 +213,11 @@ which ones you left running.`, | ||
| 179 | 213 | "max_tokens": 4096, |
| 180 | 214 | }, |
| 181 | 215 | WatchdogTimeout: 20 * time.Second, |
| 216 | + Sessions: SessionsConfig{ | |
| 217 | + Enabled: false, | |
| 218 | + Dir: ".mm/sessions", | |
| 219 | + }, | |
| 220 | + Permissions: PermissionsAuto, | |
| 182 | 221 | Context: ContextConfig{ |
| 183 | 222 | Enabled: false, |
| 184 | 223 | // 75 % leaves a quarter of the window for the next question, the tool |
| @@ -273,6 +312,10 @@ func (c Config) validate() error { | ||
| 273 | 312 | return errors.New("context.summaryMaxTokens must be > 0") |
| 274 | 313 | case c.Context.MaxMessages < 0: |
| 275 | 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 | 320 | // `enabled: true` with no window and no maxMessages would never trigger — |
| 278 | 321 | // 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 is | 90 | // 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 (see | 129 | // 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 tool | 223 | // 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, so | 321 | // 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 { | ||
| 59 | 59 | } |
| 60 | 60 | 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 | +} | |
| @@ -59,3 +59,41 @@ func Forgotten(msgs []*ai.Message) int { | |||
| 59 | } | 59 | } |
| 60 | return n | 60 | 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 @@ | ||
| 1 | 1 | package session |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "errors" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "testing" |
| 6 | 7 | |
| @@ -87,3 +88,46 @@ func ExampleFresh() { | ||
| 87 | 88 | fmt.Println(len(msgs), msgs[0].Role, Forgotten(msgs)) |
| 88 | 89 | // 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 | +} | |
| @@ -1,6 +1,7 @@ | |||
| 1 | package session | 1 | 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 0 | 89 | // 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 { | ||
| 87 | 87 | } |
| 88 | 88 | var out []Skill |
| 89 | 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 | 93 | content, err := os.ReadFile(p) |
| 91 | 94 | if err != nil { |
| 92 | 95 | continue |
| @@ -97,6 +100,12 @@ func List(dir string) []Skill { | ||
| 97 | 100 | 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 | 109 | // Read returns the full markdown of one skill, whichever layout it uses: the |
| 101 | 110 | // flat `<name>.md` is tried first, then `<name>/SKILL.md`. |
| 102 | 111 | func Read(dir, name string) (string, error) { |
| @@ -87,6 +87,9 @@ func List(dir string) []Skill { | |||
| 87 | } | 87 | } |
| 88 | var out []Skill | 88 | 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 | continue | 95 | continue |
| @@ -97,6 +100,12 @@ func List(dir string) []Skill { | |||
| 97 | return out | 100 | 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: the | 109 | // 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) { | ||
| 240 | 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 | +} | |
| @@ -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 @@ | ||
| 26 | 26 | package main |
| 27 | 27 | |
| 28 | 28 | import ( |
| 29 | + "bytes" | |
| 29 | 30 | "context" |
| 31 | + "errors" | |
| 30 | 32 | "flag" |
| 31 | 33 | "fmt" |
| 32 | 34 | "os" |
| @@ -41,6 +43,7 @@ import ( | ||
| 41 | 43 | "mm/internal/skills" |
| 42 | 44 | "mm/internal/spinner" |
| 43 | 45 | "mm/internal/tools" |
| 46 | + "mm/internal/tui" | |
| 44 | 47 | "mm/internal/ui" |
| 45 | 48 | |
| 46 | 49 | "github.com/firebase/genkit/go/ai" |
| @@ -60,12 +63,13 @@ func main() { | ||
| 60 | 63 | // saying so is better than ignoring it. |
| 61 | 64 | configPath := flag.String("config", "", "path to the YAML config file (default: $AGENT_CONFIG, then ./agent.yaml)") |
| 62 | 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 | 67 | flag.Parse() |
| 64 | 68 | if *configPath == "" && flag.NArg() > 0 { |
| 65 | 69 | *configPath = flag.Arg(0) |
| 66 | 70 | } |
| 67 | 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 | 73 | os.Exit(1) |
| 70 | 74 | } |
| 71 | 75 | |
| @@ -82,7 +86,7 @@ func main() { | ||
| 82 | 86 | // Settings: built-in defaults, overridden by the YAML file if there is one. |
| 83 | 87 | path, err := config.Load(*configPath) |
| 84 | 88 | if err != nil { |
| 85 | - fmt.Fprintln(ui.Out, "[config error:", err, "]") | |
| 89 | + say("[config error: %v ]", err) | |
| 86 | 90 | os.Exit(1) |
| 87 | 91 | } |
| 88 | 92 | |
| @@ -95,47 +99,95 @@ func main() { | ||
| 95 | 99 | // is made absolute here, so the banner can say exactly where it looked. |
| 96 | 100 | config.Cfg.SkillsDir = resolveSkillsDir(config.Cfg.SkillsDir, path, *acpMode) |
| 97 | 101 | |
| 102 | + window, banner := windowMode((*tuiMode || config.Cfg.TUI) && !*acpMode) | |
| 103 | + | |
| 98 | 104 | e, err := engine.New(ctx) |
| 99 | 105 | if err != nil { |
| 100 | - fmt.Fprintln(ui.Out, "[engine error:", err, "]") | |
| 106 | + say("[engine error: %v ]", err) | |
| 101 | 107 | os.Exit(1) |
| 102 | 108 | } |
| 103 | 109 | |
| 104 | 110 | // Initialize the global loop detector. |
| 105 | 111 | loopDetector := detector.NewLoopDetector(10, 3) |
| 106 | 112 | |
| 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" | |
| 109 | 117 | } |
| 118 | + printBanner(ctx, e, path, toolNames, skillCount) | |
| 110 | 119 | |
| 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)} | |
| 115 | 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 | 173 | agentTools = append(agentTools, skillTool) |
| 118 | 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 | 176 | if config.Cfg.EditTools { |
| 125 | 177 | 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)) | |
| 129 | 181 | toolNames = append(toolNames, "read_file", "write_file", "edit_file") |
| 130 | 182 | } |
| 183 | + return agentTools, toolNames, skillCount | |
| 184 | +} | |
| 131 | 185 | |
| 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) { | |
| 139 | 191 | info := e.Probe(ctx) |
| 140 | 192 | ctxCol := "unknown" |
| 141 | 193 | if info.ContextWindow > 0 { |
| @@ -160,18 +212,35 @@ func main() { | ||
| 160 | 212 | fmt.Sprintf("no skills found in %s (expected <name>.md or <name>/SKILL.md there)", config.Cfg.SkillsDir)) |
| 161 | 213 | } |
| 162 | 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 | 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, | |
| 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 | |
| 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 | 246 | // resolveSkillsDir makes the skills directory absolute. An absolute dir is |
| @@ -26,7 +26,9 @@ | |||
| 26 | package main | 26 | 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 description | 120 | + // Three front ends, one wiring: everything above — config, engine, tools, |
| 112 | - // carries the catalogue, so declaring it with an empty directory would | 121 | + // 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 the | 190 | +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 | - return | 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 | ||
| 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 is | 246 | // 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 | ||