modified
.tickets/epics.yaml +9 -0 | @@ -3,3 +3,12 @@ epics: | ||
| 3 | 3 | - title: Improve TUI |
| 4 | 4 | state: open |
| 5 | 5 | color: "#5319e7" |
| 6 | + - title: CLI | |
| 7 | + state: open | |
| 8 | + color: "#0e8a16" | |
| 9 | + - title: Builtin tools | |
| 10 | + state: open | |
| 11 | + color: "#1d76db" | |
| 12 | + - title: Ideas | |
| 13 | + state: open | |
| 14 | + color: "#bfdadc" | |
| @@ -3,3 +3,12 @@ epics: | |||
| 3 | - title: Improve TUI | 3 | - title: Improve TUI |
| 4 | state: open | 4 | state: open |
| 5 | color: "#5319e7" | 5 | color: "#5319e7" |
| 6 | + - title: CLI | ||
| 7 | + state: open | ||
| 8 | + color: "#0e8a16" | ||
| 9 | + - title: Builtin tools | ||
| 10 | + state: open | ||
| 11 | + color: "#1d76db" | ||
| 12 | + - title: Ideas | ||
| 13 | + state: open | ||
| 14 | + color: "#bfdadc" | ||
modified
.tickets/issues/0029-thinking-mode.yaml +129 -4 | @@ -1,14 +1,139 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 29 |
| 3 | 3 | title: thinking mode |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T03:43:16.681Z |
| 9 | -updatedAt: 2026-09-21T03:43:16.681Z | |
| 9 | +updatedAt: 2026-09-21T04:30:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.3 |
| 12 | -body: "" | |
| 13 | -tasks: [] | |
| 12 | +body: | | |
| 13 | + Some models have a thinking mode. Mini Me showed nothing at all while they thought; it should show the reasoning, in a different colour and in italic. Two configurations to test with in `./tests` (gemma-4-e2b, qwen3.5-2b), both confirmed thinking through the Docker Model Runner CLI. | |
| 14 | + | |
| 15 | + ## What a thinking model actually sends (Claude, 2026-09-21) | |
| 16 | + | |
| 17 | + The reasoning travels in a field of its own, never inside `content`: | |
| 18 | + | |
| 19 | + ``` | |
| 20 | + data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | |
| 21 | + data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | |
| 22 | + ``` | |
| 23 | + | |
| 24 | + `reasoning_content` is what Docker Model Runner, llama.cpp, vLLM and DeepSeek send; a few servers say `reasoning`. Measured on both test models against the real DMR: qwen, 300 reasoning deltas and 0 content; gemma **with tools in the request**, 247 reasoning deltas, 0 content, 0 tool calls. Nothing has to be enabled — the models that think, think. | |
| 25 | + | |
| 26 | + ## Genkit's reasoning mode is a dead end here | |
| 27 | + | |
| 28 | + `ai` does have `PartReasoning` / `NewReasoningPart` / `IsReasoning`, but `plugins/compat_oai` never builds one: it maps only `Usage.CompletionTokensDetails.ReasoningTokens` — a token COUNT — and its streaming loop reads `Delta.Content` and `Delta.ToolCalls`. `openai-go` v1.8.2 has no field for it on the delta either. The text has to be read off the wire. | |
| 29 | + | |
| 30 | + ## The bug this uncovered | |
| 31 | + | |
| 32 | + The plugin calls its chunk handler only `if len(modelChunk.Content) > 0`. `beat()` — the watchdog's heartbeat — lives in that handler. A chunk carrying nothing but reasoning beats nothing. | |
| 33 | + | |
| 34 | + **So a model reasoning for longer than `watchdogTimeout` was killed as "stalled" while its server was streaming perfectly happily.** Measured: 38 seconds of uninterrupted reasoning on qwen3.5-2b at `max_tokens: 2000`. The default timeout is 20 s; the configurations in `./tests` say 30 s. | |
| 35 | + | |
| 36 | + Proved before/after with a fake engine that reasons for 12 s under a 5 s watchdog: | |
| 37 | + | |
| 38 | + | Binary | watchdog fired | thinking shown | answer produced | | |
| 39 | + |---|---|---|---| | |
| 40 | + | before | **yes** | no | **no** | | |
| 41 | + | after | no | yes | yes | | |
| 42 | + | |
| 43 | + This is why `showThinking: false` turns off the display **and nothing else**: the reasoning is always read, because reading it is what keeps the turn alive. Verified — with the display off, the same 12 s run still completes. | |
| 44 | + | |
| 45 | + ## Implementation | |
| 46 | + | |
| 47 | + `internal/engine/reasoning.go` — an `option.WithMiddleware` on the openai-go client, passed through `compat_oai.OpenAICompatible.Opts`, which is the one hook the plugin leaves open. It wraps the SSE body in `sseTap`, a **pass-through `Read`** that scans lines as they are read: no goroutine and no pipe, so the reasoning of a chunk is reported while that chunk is handed to the plugin and can never arrive after the answer it preceded. Every byte goes through untouched — the plugin sees exactly the stream it would have seen. The listener rides on the **request context** (`WithReasoning`), not a package variable, so two turns at once cannot cross their reasoning. | |
| 48 | + | |
| 49 | + `internal/engine/thoughts.go` — the terminal block: `💭` printed once, then each fragment dim and italic (`\033[2;3m`), plain text when the output is not a styled terminal. Closed by the answer's first token or the end of the turn, whichever comes first, idempotent. | |
| 50 | + | |
| 51 | + `Generate` registers a listener that beats **and** displays; `Summarize` registers one that only beats — a summary is written behind the user's back and must not spill its reasoning into the middle of a conversation. | |
| 52 | + | |
| 53 | + `ui.Sink` gained `Reasoning(text)`: | |
| 54 | + | |
| 55 | + | Front end | What it does | | |
| 56 | + |---|---| | |
| 57 | + | Line mode | The `💭` block above, before the `🤖` answer. | | |
| 58 | + | Window (`mm -tui`) | Its own `‣ thinking` entry (`KindThinking`), dim italic purple — an aside, visibly not the answer. | | |
| 59 | + | Editor (ACP) | `agent_thought_chunk`. The protocol has the concept, so this is not a rendering trick: Zed folds it under its own "Thinking" disclosure. | | |
| 60 | + | |
| 61 | + New setting `showThinking`, default `true`, added to the five shipped YAML files. Reasoning never enters the history and never replays on `/resume` — the plugin drops it, so that is free. | |
| 62 | + | |
| 63 | + ## Not handled, on purpose | |
| 64 | + | |
| 65 | + Models that wrap their reasoning in `<think>` tags inside `content`. Those arrive as ordinary answer text and are shown as the answer; the two shapes need different treatment and neither test model does it. Written up as a known limit in `docs/*/explanation/thinking-models.md` rather than left to be rediscovered. | |
| 66 | + | |
| 67 | + ## Verified | |
| 68 | + | |
| 69 | + Unit: `internal/engine/reasoning_test.go` (9 line shapes including a half-received line and `[DONE]`; byte-for-byte pass-through; a fragment split across 7-byte reads; the endless-line cap; two concurrent turns that must not hear each other, plus one request with no listener; a non-streamed response left alone) and `internal/tui/thinking_test.go` (one entry per block and not one per fragment; thought and answer stay separate; the aside's label and style; `showThinking: false` draws nothing). | |
| 70 | + | |
| 71 | + End to end: both `./tests` models against the real DMR — `💭` block then `🤖` answer, dim+italic escapes confirmed under `TERM=xterm-256color`; window mode on a pseudo-terminal — `‣ thinking` label and 146 italic+purple SGR runs, then the answer; the fake engine for the watchdog table above and for `showThinking: false`. | |
| 72 | + | |
| 73 | + Quality gate: run 32 — 10 warnings, 20 smells, the project's baseline. Runs 30/31 had one more (`linesOf` reaching 6 returns with the new entry kind), cleared by giving it a single exit rather than by touching a threshold. `go test ./...`, `gofmt`, `go vet` green; all five shipped YAML files still load. | |
| 74 | + | |
| 75 | + Docs EN+FR: new `explanation/thinking-models.md`, linked from both `docs/*/README.md`; `reference/configuration.md` (`showThinking`), `reference/cli.md` (window-mode row), `explanation/architecture.md` (`engine` row). | |
| 76 | + | |
| 77 | + Branch `thinking`. | |
| 78 | +tasks: | |
| 79 | + - id: 1 | |
| 80 | + title: "Read the reasoning off the wire: openai-go middleware, pass-through SSE tap, both field spellings" | |
| 81 | + state: closed | |
| 82 | + priority: high | |
| 83 | + author: | |
| 84 | + name: k33g | |
| 85 | + email: ph.charriere@gmail.com | |
| 86 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 87 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 88 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 89 | + - id: 2 | |
| 90 | + title: "Beat the watchdog while the model reasons, so a thinking turn is not killed as stalled" | |
| 91 | + state: closed | |
| 92 | + priority: high | |
| 93 | + author: | |
| 94 | + name: k33g | |
| 95 | + email: ph.charriere@gmail.com | |
| 96 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 97 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 98 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 99 | + - id: 3 | |
| 100 | + title: "Line mode: a 💭 block in dim italic before the answer" | |
| 101 | + state: closed | |
| 102 | + priority: medium | |
| 103 | + author: | |
| 104 | + name: k33g | |
| 105 | + email: ph.charriere@gmail.com | |
| 106 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 107 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 108 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 109 | + - id: 4 | |
| 110 | + title: "Window mode: its own thinking entry, a different colour and italic" | |
| 111 | + state: closed | |
| 112 | + priority: medium | |
| 113 | + author: | |
| 114 | + name: k33g | |
| 115 | + email: ph.charriere@gmail.com | |
| 116 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 117 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 118 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 119 | + - id: 5 | |
| 120 | + title: "ACP: agent_thought_chunk, so the editor folds it its own way" | |
| 121 | + state: closed | |
| 122 | + priority: none | |
| 123 | + author: | |
| 124 | + name: k33g | |
| 125 | + email: ph.charriere@gmail.com | |
| 126 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 127 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 128 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 129 | + - id: 6 | |
| 130 | + title: "showThinking setting (display only), tests and EN+FR docs" | |
| 131 | + state: closed | |
| 132 | + priority: none | |
| 133 | + author: | |
| 134 | + name: k33g | |
| 135 | + email: ph.charriere@gmail.com | |
| 136 | + createdAt: 2026-09-21T04:00:00.000Z | |
| 137 | + updatedAt: 2026-09-21T04:30:00.000Z | |
| 138 | + closedAt: 2026-09-21T04:30:00.000Z | |
| 14 | 139 | comments: [] |
| @@ -1,14 +1,139 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 29 | 2 | id: 29 |
| 3 | title: thinking mode | 3 | title: thinking mode |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T03:43:16.681Z | 8 | createdAt: 2026-09-21T03:43:16.681Z |
| 9 | -updatedAt: 2026-09-21T03:43:16.681Z | 9 | +updatedAt: 2026-09-21T04:30:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.3 | 11 | milestone: v0.0.3 |
| 12 | -body: "" | 12 | +body: | |
| 13 | -tasks: [] | 13 | + Some models have a thinking mode. Mini Me showed nothing at all while they thought; it should show the reasoning, in a different colour and in italic. Two configurations to test with in `./tests` (gemma-4-e2b, qwen3.5-2b), both confirmed thinking through the Docker Model Runner CLI. |
| 14 | + | ||
| 15 | + ## What a thinking model actually sends (Claude, 2026-09-21) | ||
| 16 | + | ||
| 17 | + The reasoning travels in a field of its own, never inside `content`: | ||
| 18 | + | ||
| 19 | + ``` | ||
| 20 | + data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | ||
| 21 | + data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | ||
| 22 | + ``` | ||
| 23 | + | ||
| 24 | + `reasoning_content` is what Docker Model Runner, llama.cpp, vLLM and DeepSeek send; a few servers say `reasoning`. Measured on both test models against the real DMR: qwen, 300 reasoning deltas and 0 content; gemma **with tools in the request**, 247 reasoning deltas, 0 content, 0 tool calls. Nothing has to be enabled — the models that think, think. | ||
| 25 | + | ||
| 26 | + ## Genkit's reasoning mode is a dead end here | ||
| 27 | + | ||
| 28 | + `ai` does have `PartReasoning` / `NewReasoningPart` / `IsReasoning`, but `plugins/compat_oai` never builds one: it maps only `Usage.CompletionTokensDetails.ReasoningTokens` — a token COUNT — and its streaming loop reads `Delta.Content` and `Delta.ToolCalls`. `openai-go` v1.8.2 has no field for it on the delta either. The text has to be read off the wire. | ||
| 29 | + | ||
| 30 | + ## The bug this uncovered | ||
| 31 | + | ||
| 32 | + The plugin calls its chunk handler only `if len(modelChunk.Content) > 0`. `beat()` — the watchdog's heartbeat — lives in that handler. A chunk carrying nothing but reasoning beats nothing. | ||
| 33 | + | ||
| 34 | + **So a model reasoning for longer than `watchdogTimeout` was killed as "stalled" while its server was streaming perfectly happily.** Measured: 38 seconds of uninterrupted reasoning on qwen3.5-2b at `max_tokens: 2000`. The default timeout is 20 s; the configurations in `./tests` say 30 s. | ||
| 35 | + | ||
| 36 | + Proved before/after with a fake engine that reasons for 12 s under a 5 s watchdog: | ||
| 37 | + | ||
| 38 | + | Binary | watchdog fired | thinking shown | answer produced | | ||
| 39 | + |---|---|---|---| | ||
| 40 | + | before | **yes** | no | **no** | | ||
| 41 | + | after | no | yes | yes | | ||
| 42 | + | ||
| 43 | + This is why `showThinking: false` turns off the display **and nothing else**: the reasoning is always read, because reading it is what keeps the turn alive. Verified — with the display off, the same 12 s run still completes. | ||
| 44 | + | ||
| 45 | + ## Implementation | ||
| 46 | + | ||
| 47 | + `internal/engine/reasoning.go` — an `option.WithMiddleware` on the openai-go client, passed through `compat_oai.OpenAICompatible.Opts`, which is the one hook the plugin leaves open. It wraps the SSE body in `sseTap`, a **pass-through `Read`** that scans lines as they are read: no goroutine and no pipe, so the reasoning of a chunk is reported while that chunk is handed to the plugin and can never arrive after the answer it preceded. Every byte goes through untouched — the plugin sees exactly the stream it would have seen. The listener rides on the **request context** (`WithReasoning`), not a package variable, so two turns at once cannot cross their reasoning. | ||
| 48 | + | ||
| 49 | + `internal/engine/thoughts.go` — the terminal block: `💭` printed once, then each fragment dim and italic (`\033[2;3m`), plain text when the output is not a styled terminal. Closed by the answer's first token or the end of the turn, whichever comes first, idempotent. | ||
| 50 | + | ||
| 51 | + `Generate` registers a listener that beats **and** displays; `Summarize` registers one that only beats — a summary is written behind the user's back and must not spill its reasoning into the middle of a conversation. | ||
| 52 | + | ||
| 53 | + `ui.Sink` gained `Reasoning(text)`: | ||
| 54 | + | ||
| 55 | + | Front end | What it does | | ||
| 56 | + |---|---| | ||
| 57 | + | Line mode | The `💭` block above, before the `🤖` answer. | | ||
| 58 | + | Window (`mm -tui`) | Its own `‣ thinking` entry (`KindThinking`), dim italic purple — an aside, visibly not the answer. | | ||
| 59 | + | Editor (ACP) | `agent_thought_chunk`. The protocol has the concept, so this is not a rendering trick: Zed folds it under its own "Thinking" disclosure. | | ||
| 60 | + | ||
| 61 | + New setting `showThinking`, default `true`, added to the five shipped YAML files. Reasoning never enters the history and never replays on `/resume` — the plugin drops it, so that is free. | ||
| 62 | + | ||
| 63 | + ## Not handled, on purpose | ||
| 64 | + | ||
| 65 | + Models that wrap their reasoning in `<think>` tags inside `content`. Those arrive as ordinary answer text and are shown as the answer; the two shapes need different treatment and neither test model does it. Written up as a known limit in `docs/*/explanation/thinking-models.md` rather than left to be rediscovered. | ||
| 66 | + | ||
| 67 | + ## Verified | ||
| 68 | + | ||
| 69 | + Unit: `internal/engine/reasoning_test.go` (9 line shapes including a half-received line and `[DONE]`; byte-for-byte pass-through; a fragment split across 7-byte reads; the endless-line cap; two concurrent turns that must not hear each other, plus one request with no listener; a non-streamed response left alone) and `internal/tui/thinking_test.go` (one entry per block and not one per fragment; thought and answer stay separate; the aside's label and style; `showThinking: false` draws nothing). | ||
| 70 | + | ||
| 71 | + End to end: both `./tests` models against the real DMR — `💭` block then `🤖` answer, dim+italic escapes confirmed under `TERM=xterm-256color`; window mode on a pseudo-terminal — `‣ thinking` label and 146 italic+purple SGR runs, then the answer; the fake engine for the watchdog table above and for `showThinking: false`. | ||
| 72 | + | ||
| 73 | + Quality gate: run 32 — 10 warnings, 20 smells, the project's baseline. Runs 30/31 had one more (`linesOf` reaching 6 returns with the new entry kind), cleared by giving it a single exit rather than by touching a threshold. `go test ./...`, `gofmt`, `go vet` green; all five shipped YAML files still load. | ||
| 74 | + | ||
| 75 | + Docs EN+FR: new `explanation/thinking-models.md`, linked from both `docs/*/README.md`; `reference/configuration.md` (`showThinking`), `reference/cli.md` (window-mode row), `explanation/architecture.md` (`engine` row). | ||
| 76 | + | ||
| 77 | + Branch `thinking`. | ||
| 78 | +tasks: | ||
| 79 | + - id: 1 | ||
| 80 | + title: "Read the reasoning off the wire: openai-go middleware, pass-through SSE tap, both field spellings" | ||
| 81 | + state: closed | ||
| 82 | + priority: high | ||
| 83 | + author: | ||
| 84 | + name: k33g | ||
| 85 | + email: ph.charriere@gmail.com | ||
| 86 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 87 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 88 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 89 | + - id: 2 | ||
| 90 | + title: "Beat the watchdog while the model reasons, so a thinking turn is not killed as stalled" | ||
| 91 | + state: closed | ||
| 92 | + priority: high | ||
| 93 | + author: | ||
| 94 | + name: k33g | ||
| 95 | + email: ph.charriere@gmail.com | ||
| 96 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 97 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 98 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 99 | + - id: 3 | ||
| 100 | + title: "Line mode: a 💭 block in dim italic before the answer" | ||
| 101 | + state: closed | ||
| 102 | + priority: medium | ||
| 103 | + author: | ||
| 104 | + name: k33g | ||
| 105 | + email: ph.charriere@gmail.com | ||
| 106 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 107 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 108 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 109 | + - id: 4 | ||
| 110 | + title: "Window mode: its own thinking entry, a different colour and italic" | ||
| 111 | + state: closed | ||
| 112 | + priority: medium | ||
| 113 | + author: | ||
| 114 | + name: k33g | ||
| 115 | + email: ph.charriere@gmail.com | ||
| 116 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 117 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 118 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 119 | + - id: 5 | ||
| 120 | + title: "ACP: agent_thought_chunk, so the editor folds it its own way" | ||
| 121 | + state: closed | ||
| 122 | + priority: none | ||
| 123 | + author: | ||
| 124 | + name: k33g | ||
| 125 | + email: ph.charriere@gmail.com | ||
| 126 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 127 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 128 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 129 | + - id: 6 | ||
| 130 | + title: "showThinking setting (display only), tests and EN+FR docs" | ||
| 131 | + state: closed | ||
| 132 | + priority: none | ||
| 133 | + author: | ||
| 134 | + name: k33g | ||
| 135 | + email: ph.charriere@gmail.com | ||
| 136 | + createdAt: 2026-09-21T04:00:00.000Z | ||
| 137 | + updatedAt: 2026-09-21T04:30:00.000Z | ||
| 138 | + closedAt: 2026-09-21T04:30:00.000Z | ||
| 14 | comments: [] | 139 | comments: [] |
added
.tickets/issues/0030-how-to-use-mini-me-with-claude.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 30 | |
| 3 | +title: How to use Mini Me with Claude | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:00:56.971Z | |
| 9 | +updatedAt: 2026-09-21T04:00:56.971Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v0.0.3 | |
| 12 | +body: "" | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 30 | ||
| 3 | +title: How to use Mini Me with Claude | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:00:56.971Z | ||
| 9 | +updatedAt: 2026-09-21T04:00:56.971Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v0.0.3 | ||
| 12 | +body: "" | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
added
.tickets/issues/0031-hoe-to-use-mini-me-with-gemini.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 31 | |
| 3 | +title: Hoe to use Mini Me with Gemini | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:01:15.166Z | |
| 9 | +updatedAt: 2026-09-21T04:01:15.166Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v0.0.3 | |
| 12 | +body: "" | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 31 | ||
| 3 | +title: Hoe to use Mini Me with Gemini | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:01:15.166Z | ||
| 9 | +updatedAt: 2026-09-21T04:01:15.166Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v0.0.3 | ||
| 12 | +body: "" | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
added
.tickets/issues/0032-add-prompt-flag.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 32 | |
| 3 | +title: Add --prompt flag | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:47:19.089Z | |
| 9 | +updatedAt: 2026-09-21T04:47:19.089Z | |
| 10 | +labels: [] | |
| 11 | +epic: CLI | |
| 12 | +body: "" | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 32 | ||
| 3 | +title: Add --prompt flag | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:47:19.089Z | ||
| 9 | +updatedAt: 2026-09-21T04:47:19.089Z | ||
| 10 | +labels: [] | ||
| 11 | +epic: CLI | ||
| 12 | +body: "" | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
added
.tickets/issues/0033-invoke-another-mini-me.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: 33 | |
| 3 | +title: Invoke (another) Mini Me | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:48:03.596Z | |
| 9 | +updatedAt: 2026-09-21T04:48:35.612Z | |
| 10 | +labels: [] | |
| 11 | +epic: Builtin tools | |
| 12 | +body: | | |
| 13 | + Should I use ACP ? I think yes | |
| 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: 33 | ||
| 3 | +title: Invoke (another) Mini Me | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:48:03.596Z | ||
| 9 | +updatedAt: 2026-09-21T04:48:35.612Z | ||
| 10 | +labels: [] | ||
| 11 | +epic: Builtin tools | ||
| 12 | +body: | | ||
| 13 | + Should I use ACP ? I think yes | ||
| 14 | +tasks: [] | ||
| 15 | +comments: [] | ||
added
.tickets/issues/0034-anatolian-mini-me-s-orchestrator.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 34 | |
| 3 | +title: Anatolian -> Mini Me(s) Orchestrator? | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:49:57.129Z | |
| 9 | +updatedAt: 2026-09-21T04:49:57.129Z | |
| 10 | +labels: [] | |
| 11 | +epic: Ideas | |
| 12 | +body: "" | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 34 | ||
| 3 | +title: Anatolian -> Mini Me(s) Orchestrator? | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:49:57.129Z | ||
| 9 | +updatedAt: 2026-09-21T04:49:57.129Z | ||
| 10 | +labels: [] | ||
| 11 | +epic: Ideas | ||
| 12 | +body: "" | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
added
.tickets/issues/0035-create-sbx-templat-kit-for-mini-me.yaml +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | |
| 2 | +id: 35 | |
| 3 | +title: Create SBX Templat + Kit for Mini-Me | |
| 4 | +state: open | |
| 5 | +author: | |
| 6 | + name: k33g | |
| 7 | + email: ph.charriere@gmail.com | |
| 8 | +createdAt: 2026-09-21T04:51:17.051Z | |
| 9 | +updatedAt: 2026-09-21T04:51:17.051Z | |
| 10 | +labels: [] | |
| 11 | +milestone: v0.0.3 | |
| 12 | +body: "" | |
| 13 | +tasks: [] | |
| 14 | +comments: [] | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +# Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | ||
| 2 | +id: 35 | ||
| 3 | +title: Create SBX Templat + Kit for Mini-Me | ||
| 4 | +state: open | ||
| 5 | +author: | ||
| 6 | + name: k33g | ||
| 7 | + email: ph.charriere@gmail.com | ||
| 8 | +createdAt: 2026-09-21T04:51:17.051Z | ||
| 9 | +updatedAt: 2026-09-21T04:51:17.051Z | ||
| 10 | +labels: [] | ||
| 11 | +milestone: v0.0.3 | ||
| 12 | +body: "" | ||
| 13 | +tasks: [] | ||
| 14 | +comments: [] | ||
modified
agent.dmr.yaml +6 -0 | @@ -165,3 +165,9 @@ context: | ||
| 165 | 165 | showStats: true |
| 166 | 166 | |
| 167 | 167 | displayCommands: true |
| 168 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 169 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 170 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 171 | +# model thinks — this only says whether to show it. | |
| 172 | +showThinking: true | |
| 173 | + | |
| @@ -165,3 +165,9 @@ context: | |||
| 165 | showStats: true | 165 | showStats: true |
| 166 | 166 | ||
| 167 | displayCommands: true | 167 | displayCommands: true |
| 168 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 169 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 170 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 171 | +# model thinks — this only says whether to show it. | ||
| 172 | +showThinking: true | ||
| 173 | + | ||
modified
agent.llamacpp.yaml +6 -0 | @@ -71,6 +71,12 @@ sessions: | ||
| 71 | 71 | previewLines: 20 |
| 72 | 72 | |
| 73 | 73 | displayCommands: true |
| 74 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 75 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 76 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 77 | +# model thinks — this only says whether to show it. | |
| 78 | +showThinking: true | |
| 79 | + | |
| 74 | 80 | |
| 75 | 81 | # The system prompt: what the agent is, and what it is allowed to do. |
| 76 | 82 | # This is the knob to play with — it is the shortest path to changing behaviour. |
| @@ -71,6 +71,12 @@ sessions: | |||
| 71 | previewLines: 20 | 71 | previewLines: 20 |
| 72 | 72 | ||
| 73 | displayCommands: true | 73 | displayCommands: true |
| 74 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 75 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 76 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 77 | +# model thinks — this only says whether to show it. | ||
| 78 | +showThinking: true | ||
| 79 | + | ||
| 74 | 80 | ||
| 75 | # The system prompt: what the agent is, and what it is allowed to do. | 81 | # The system prompt: what the agent is, and what it is allowed to do. |
| 76 | # This is the knob to play with — it is the shortest path to changing behaviour. | 82 | # This is the knob to play with — it is the shortest path to changing behaviour. |
modified
agent.lsp.yaml +6 -0 | @@ -201,3 +201,9 @@ context: | ||
| 201 | 201 | showStats: true |
| 202 | 202 | |
| 203 | 203 | displayCommands: true |
| 204 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 205 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 206 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 207 | +# model thinks — this only says whether to show it. | |
| 208 | +showThinking: true | |
| 209 | + | |
| @@ -201,3 +201,9 @@ context: | |||
| 201 | showStats: true | 201 | showStats: true |
| 202 | 202 | ||
| 203 | displayCommands: true | 203 | displayCommands: true |
| 204 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 205 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 206 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 207 | +# model thinks — this only says whether to show it. | ||
| 208 | +showThinking: true | ||
| 209 | + | ||
modified
docs/en/README.md +1 -0 | @@ -36,6 +36,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 36 | 36 | - [Context compression](explanation/context-compression.md) |
| 37 | 37 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` |
| 38 | 38 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 39 | +- [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive | |
| 39 | 40 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 40 | 41 | |
| 41 | 42 | ## Scope |
| @@ -36,6 +36,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 36 | - [Context compression](explanation/context-compression.md) | 36 | - [Context compression](explanation/context-compression.md) |
| 37 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` | 37 | - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume` |
| 38 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission | 38 | - [The `lsp` tool](explanation/lsp-tool.md) — one tool with an `action` field, a `symbol` field instead of columns, files synced from disk, rename gated by permission |
| 39 | +- [Thinking models](explanation/thinking-models.md) — where a model's reasoning comes from, why `mm` reads it below Genkit, and why reading it keeps the watchdog alive | ||
| 39 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 40 | - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 40 | 41 | ||
| 41 | ## Scope | 42 | ## Scope |
modified
docs/en/explanation/architecture.md +1 -1 | @@ -9,7 +9,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | ||
| 9 | 9 | | Package | Responsibility | |
| 10 | 10 | |---------|----------------| |
| 11 | 11 | | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. | |
| 12 | -| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. | | |
| 12 | +| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, and the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)). | | |
| 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 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | |
| @@ -9,7 +9,7 @@ The entry point, `main.go`, only does wiring: it loads the settings, opens the e | |||
| 9 | | Package | Responsibility | | 9 | | Package | Responsibility | |
| 10 | |---------|----------------| | 10 | |---------|----------------| |
| 11 | | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. | | 11 | | `config` | Every setting in one struct, with built-in defaults overridden by YAML and a few environment variables. | |
| 12 | -| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, and the provider registry. | | 12 | +| `engine` | The connection to the model server and the generation itself: streaming, watchdog, non-streaming retry, summary requests, the provider registry, and the reasoning a thinking model streams beside its answer (see [Thinking models](thinking-models.md)). | |
| 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 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | | 15 | | `lsp` | The language-server client behind the `lsp` tool: starts the configured servers on first use (config `lsp.servers`), syncs files from disk, asks for definition, references, hover, symbols, diagnostics and rename, and shapes the answers as `path:line:column <line>` text. Built on `go.lsp.dev/protocol` and `go.lsp.dev/jsonrpc2`. | |
added
docs/en/explanation/thinking-models.md +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +# Thinking models — explanation | |
| 2 | + | |
| 3 | +> Why `mm` shows a model's reasoning, where that reasoning comes from, and why reading it was not optional. | |
| 4 | + | |
| 5 | +## What a thinking model sends | |
| 6 | + | |
| 7 | +A thinking model answers in two phases. First it reasons — sometimes at length — then it answers. On an OpenAI-compatible server the two do not share a field: | |
| 8 | + | |
| 9 | +``` | |
| 10 | +data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | |
| 11 | +data: {"choices":[{"delta":{"reasoning_content":" 1. Analyze the request ..."}}]} | |
| 12 | +data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | |
| 13 | +``` | |
| 14 | + | |
| 15 | +`reasoning_content` is the spelling used by Docker Model Runner, llama.cpp, vLLM and DeepSeek; a few servers say `reasoning`. `mm` reads both. | |
| 16 | + | |
| 17 | +Keeping them apart is the server being helpful: a client can show the reasoning differently, or not at all, without parsing anything out of the answer. Nothing has to be enabled — the models that think, think. | |
| 18 | + | |
| 19 | +## Why `mm` reads it off the wire itself | |
| 20 | + | |
| 21 | +Genkit's OpenAI-compatible plugin does not forward reasoning. Its streaming loop builds a chunk from `Delta.Content` and `Delta.ToolCalls`, and then calls the handler only if that chunk has something in it. A chunk carrying nothing but reasoning is dropped. | |
| 22 | + | |
| 23 | +Two things went down with it. | |
| 24 | + | |
| 25 | +The reasoning itself, obviously — which is why `mm` used to show nothing at all while a thinking model worked. | |
| 26 | + | |
| 27 | +And **the watchdog's heartbeat**, which is the part that mattered. `mm` cancels a generation when no token has arrived for `watchdogTimeout` (20 s by default): a hung connection must not hang the agent. That heartbeat lives in the same streaming handler the plugin was not calling. So a model that reasoned for longer than the timeout was killed as "stalled" while its server was streaming tokens perfectly happily — measured on `qwen3.5-2b`: 38 seconds of uninterrupted reasoning, not one byte of content. | |
| 28 | + | |
| 29 | +So `mm` reads the reasoning one layer below Genkit: a middleware on the HTTP client wraps the response body and scans the SSE lines as they are read, passing every byte through untouched. The plugin sees exactly the stream it would have seen. | |
| 30 | + | |
| 31 | +This is why `showThinking: false` turns off the *display* and nothing else. The reasoning is always read, because reading it is what keeps the turn alive. | |
| 32 | + | |
| 33 | +## Where it goes | |
| 34 | + | |
| 35 | +| Front end | What you see | | |
| 36 | +|---|---| | |
| 37 | +| Line mode | A `💭` block, dim and italic, before the `🤖` answer. Plain text when the output is not a terminal. | | |
| 38 | +| Window (`mm -tui`) | Its own `‣ thinking` entry, dim italic in a different colour — an aside, visibly not the answer. | | |
| 39 | +| Editor (ACP) | An `agent_thought_chunk` update. The protocol has the concept, so this is not a rendering trick: Zed folds it under its own "Thinking" disclosure. | | |
| 40 | + | |
| 41 | +Three properties hold everywhere: | |
| 42 | + | |
| 43 | +- **Reasoning is never added to the history.** The model does not read its own thoughts back on the next turn, a resumed session does not replay them, and they do not count against the context the way the answer does. | |
| 44 | +- **A summary never shows its reasoning.** Compression happens behind the user's back; a model that thinks about a summary must not spill that into the middle of a conversation. The heartbeat still beats there. | |
| 45 | +- **The listener rides on the request context**, not on a package variable, so two turns running at once cannot cross their reasoning. | |
| 46 | + | |
| 47 | +## What this is not | |
| 48 | + | |
| 49 | +It is not a "reasoning effort" setting: `mm` asks for nothing and configures nothing on the model side. A model thinks or it does not, and `mm` shows what arrives. | |
| 50 | + | |
| 51 | +It is not a parser. Some models instead wrap their reasoning in `<think>` tags inside `content`. Those arrive as ordinary answer text and `mm` shows them as such — the two shapes would need different treatment, and only the field is handled today. | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +# Thinking models — explanation | ||
| 2 | + | ||
| 3 | +> Why `mm` shows a model's reasoning, where that reasoning comes from, and why reading it was not optional. | ||
| 4 | + | ||
| 5 | +## What a thinking model sends | ||
| 6 | + | ||
| 7 | +A thinking model answers in two phases. First it reasons — sometimes at length — then it answers. On an OpenAI-compatible server the two do not share a field: | ||
| 8 | + | ||
| 9 | +``` | ||
| 10 | +data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | ||
| 11 | +data: {"choices":[{"delta":{"reasoning_content":" 1. Analyze the request ..."}}]} | ||
| 12 | +data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +`reasoning_content` is the spelling used by Docker Model Runner, llama.cpp, vLLM and DeepSeek; a few servers say `reasoning`. `mm` reads both. | ||
| 16 | + | ||
| 17 | +Keeping them apart is the server being helpful: a client can show the reasoning differently, or not at all, without parsing anything out of the answer. Nothing has to be enabled — the models that think, think. | ||
| 18 | + | ||
| 19 | +## Why `mm` reads it off the wire itself | ||
| 20 | + | ||
| 21 | +Genkit's OpenAI-compatible plugin does not forward reasoning. Its streaming loop builds a chunk from `Delta.Content` and `Delta.ToolCalls`, and then calls the handler only if that chunk has something in it. A chunk carrying nothing but reasoning is dropped. | ||
| 22 | + | ||
| 23 | +Two things went down with it. | ||
| 24 | + | ||
| 25 | +The reasoning itself, obviously — which is why `mm` used to show nothing at all while a thinking model worked. | ||
| 26 | + | ||
| 27 | +And **the watchdog's heartbeat**, which is the part that mattered. `mm` cancels a generation when no token has arrived for `watchdogTimeout` (20 s by default): a hung connection must not hang the agent. That heartbeat lives in the same streaming handler the plugin was not calling. So a model that reasoned for longer than the timeout was killed as "stalled" while its server was streaming tokens perfectly happily — measured on `qwen3.5-2b`: 38 seconds of uninterrupted reasoning, not one byte of content. | ||
| 28 | + | ||
| 29 | +So `mm` reads the reasoning one layer below Genkit: a middleware on the HTTP client wraps the response body and scans the SSE lines as they are read, passing every byte through untouched. The plugin sees exactly the stream it would have seen. | ||
| 30 | + | ||
| 31 | +This is why `showThinking: false` turns off the *display* and nothing else. The reasoning is always read, because reading it is what keeps the turn alive. | ||
| 32 | + | ||
| 33 | +## Where it goes | ||
| 34 | + | ||
| 35 | +| Front end | What you see | | ||
| 36 | +|---|---| | ||
| 37 | +| Line mode | A `💭` block, dim and italic, before the `🤖` answer. Plain text when the output is not a terminal. | | ||
| 38 | +| Window (`mm -tui`) | Its own `‣ thinking` entry, dim italic in a different colour — an aside, visibly not the answer. | | ||
| 39 | +| Editor (ACP) | An `agent_thought_chunk` update. The protocol has the concept, so this is not a rendering trick: Zed folds it under its own "Thinking" disclosure. | | ||
| 40 | + | ||
| 41 | +Three properties hold everywhere: | ||
| 42 | + | ||
| 43 | +- **Reasoning is never added to the history.** The model does not read its own thoughts back on the next turn, a resumed session does not replay them, and they do not count against the context the way the answer does. | ||
| 44 | +- **A summary never shows its reasoning.** Compression happens behind the user's back; a model that thinks about a summary must not spill that into the middle of a conversation. The heartbeat still beats there. | ||
| 45 | +- **The listener rides on the request context**, not on a package variable, so two turns running at once cannot cross their reasoning. | ||
| 46 | + | ||
| 47 | +## What this is not | ||
| 48 | + | ||
| 49 | +It is not a "reasoning effort" setting: `mm` asks for nothing and configures nothing on the model side. A model thinks or it does not, and `mm` shows what arrives. | ||
| 50 | + | ||
| 51 | +It is not a parser. Some models instead wrap their reasoning in `<think>` tags inside `content`. Those arrive as ordinary answer text and `mm` shows them as such — the two shapes would need different treatment, and only the field is handled today. | ||
modified
docs/en/reference/cli.md +1 -0 | @@ -94,6 +94,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 94 | 94 | | Spinner | Disabled; the rule under the conversation animates instead. | |
| 95 | 95 | | Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` on the left, `<version> · N msgs · N tools` on the right — the version is dropped when the two sides would not fit, so the key hints are never truncated to make room for it). | |
| 96 | 96 | | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. | |
| 97 | +| Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. | | |
| 97 | 98 | | 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)]`. | |
| 98 | 99 | | 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. | |
| 99 | 100 | | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). | |
| @@ -94,6 +94,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | |||
| 94 | | Spinner | Disabled; the rule under the conversation animates instead. | | 94 | | Spinner | Disabled; the rule under the conversation animates instead. | |
| 95 | | Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` on the left, `<version> · N msgs · N tools` on the right — the version is dropped when the two sides would not fit, so the key hints are never truncated to make room for it). | | 95 | | Layout | Header line (agent, model `@` provider, context use as `ctx N% (used/window)` or `ctx ~used (window unknown)`, `session <id>` when sessions are on); conversation with a scroll bar; rule with state and key hints; 3-row input box; status bar (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` on the left, `<version> · N msgs · N tools` on the right — the version is dropped when the two sides would not fit, so the key hints are never truncated to make room for it). | |
| 96 | | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. | | 96 | | Tool calls | `‣ <tool> <detail> <status> <time>` where status is `… pending`, `… waiting for permission`, `… running`, `✓ done` or `✗ failed`; output folded to `previewLines` lines (`… N more line(s)`); `+`/`-` lines of `write_file`/`edit_file` output coloured. | |
| 97 | +| Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. | | ||
| 97 | | Errors | One line `[error: <explanation>]`; a cancelled turn shows `🛑 Generation aborted.`; each turn ends with `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. | | 98 | | 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)]`. | |
| 98 | | Permissions | With `permissions: ask`, the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject` until answered; a turn cancelled while asking rejects. | | 99 | | 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. | |
| 99 | | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). | | 100 | | Keys | See [Slash commands — window keys](slash-commands.md#window-mode-keys). | |
modified
docs/en/reference/configuration.md +1 -0 | @@ -16,6 +16,7 @@ | ||
| 16 | 16 | | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. | |
| 17 | 17 | | `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. | |
| 18 | 18 | | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | |
| 19 | +| `showThinking` | bool | `true` | Show what a thinking model reasons about before it answers. Only the display is optional: the reasoning is always read off the wire, because it is what keeps the watchdog alive while the model thinks. See [Thinking models](../explanation/thinking-models.md). | | |
| 19 | 20 | | `system` | string | built-in prompt | The system prompt, first message of every history. | |
| 20 | 21 | | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. | |
| 21 | 22 | | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. | |
| @@ -16,6 +16,7 @@ | |||
| 16 | | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. | | 16 | | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. | |
| 17 | | `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. | | 17 | | `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. | |
| 18 | | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | | 18 | | `displayCommands` | bool | `false` | Print the numbered list of commands and file operations after each answer. | |
| 19 | +| `showThinking` | bool | `true` | Show what a thinking model reasons about before it answers. Only the display is optional: the reasoning is always read off the wire, because it is what keeps the watchdog alive while the model thinks. See [Thinking models](../explanation/thinking-models.md). | | ||
| 19 | | `system` | string | built-in prompt | The system prompt, first message of every history. | | 20 | | `system` | string | built-in prompt | The system prompt, first message of every history. | |
| 20 | | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. | | 21 | | `skillsDir` | string | `skills` | Directory holding the skills, as `<name>.md` files or `<name>/SKILL.md` directories. A relative path is resolved from the directory `mm` is started in (terminal mode, or ACP mode without a config file), or next to the config file (ACP mode with one); the banner warns with the absolute path when nothing is found there. | |
| 21 | | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. | | 22 | | `bashTool` | bool | `true` | Declare `bash`. `false` removes it: the model can still read and edit files (`editTools`) and load skills, but never runs a command. Reword a system prompt that still announces a bash tool. | |
modified
docs/fr/README.md +1 -0 | @@ -36,6 +36,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 36 | 36 | - [Compression du contexte](explanation/context-compression.md) |
| 37 | 37 | - [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 |
| 38 | 38 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 39 | +- [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie | |
| 39 | 40 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 40 | 41 | |
| 41 | 42 | ## Périmètre |
| @@ -36,6 +36,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 36 | - [Compression du contexte](explanation/context-compression.md) | 36 | - [Compression du contexte](explanation/context-compression.md) |
| 37 | - [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 | 37 | - [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 |
| 38 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission | 38 | - [L'outil `lsp`](explanation/lsp-tool.md) — un seul outil avec un champ `action`, un champ `symbol` plutôt que des colonnes, des fichiers synchronisés depuis le disque, un renommage soumis à permission |
| 39 | +- [Modèles à raisonnement](explanation/thinking-models.md) — d'où vient le raisonnement d'un modèle, pourquoi `mm` le lit sous Genkit, et pourquoi le lire garde le chien de garde en vie | ||
| 39 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) | 40 | - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio) |
| 40 | 41 | ||
| 41 | ## Périmètre | 42 | ## Périmètre |
modified
docs/fr/explanation/architecture.md +1 -1 | @@ -9,7 +9,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | ||
| 9 | 9 | | Paquet | Responsabilité | |
| 10 | 10 | |--------|----------------| |
| 11 | 11 | | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. | |
| 12 | -| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. | | |
| 12 | +| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, le registre des fournisseurs, et le raisonnement qu'un modèle diffuse à côté de sa réponse (voir [Modèles à raisonnement](thinking-models.md)). | | |
| 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 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | |
| @@ -9,7 +9,7 @@ Le point d'entrée, `main.go`, ne fait que du câblage : il charge les réglages | |||
| 9 | | Paquet | Responsabilité | | 9 | | Paquet | Responsabilité | |
| 10 | |--------|----------------| | 10 | |--------|----------------| |
| 11 | | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. | | 11 | | `config` | Tous les réglages dans une structure, avec des valeurs par défaut intégrées surchargées par le YAML et quelques variables d'environnement. | |
| 12 | -| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, et le registre des fournisseurs. | | 12 | +| `engine` | La connexion au serveur de modèle et la génération elle-même : streaming, watchdog, nouvelle tentative sans streaming, requêtes de résumé, le registre des fournisseurs, et le raisonnement qu'un modèle diffuse à côté de sa réponse (voir [Modèles à raisonnement](thinking-models.md)). | |
| 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 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | | 15 | | `lsp` | Le client de serveur de langage derrière l'outil `lsp` : démarre les serveurs configurés au premier usage (clé `lsp.servers`), synchronise les fichiers depuis le disque, demande définition, références, hover, symboles, diagnostics et renommage, et met les réponses en forme `chemin:ligne:colonne <ligne>`. Construit sur `go.lsp.dev/protocol` et `go.lsp.dev/jsonrpc2`. | |
added
docs/fr/explanation/thinking-models.md +51 -0 | new file mode 100644 | ||
| @@ -0,0 +1,51 @@ | ||
| 1 | +# Modèles à raisonnement — explication | |
| 2 | + | |
| 3 | +> Pourquoi `mm` affiche le raisonnement d'un modèle, d'où il vient, et pourquoi le lire n'était pas optionnel. | |
| 4 | + | |
| 5 | +## Ce qu'envoie un modèle à raisonnement | |
| 6 | + | |
| 7 | +Un modèle à raisonnement répond en deux temps. D'abord il réfléchit — parfois longuement — puis il répond. Sur un serveur compatible OpenAI, les deux ne partagent pas le même champ : | |
| 8 | + | |
| 9 | +``` | |
| 10 | +data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | |
| 11 | +data: {"choices":[{"delta":{"reasoning_content":" 1. Analyze the request ..."}}]} | |
| 12 | +data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | |
| 13 | +``` | |
| 14 | + | |
| 15 | +`reasoning_content` est l'orthographe de Docker Model Runner, llama.cpp, vLLM et DeepSeek ; quelques serveurs disent `reasoning`. `mm` lit les deux. | |
| 16 | + | |
| 17 | +Les séparer, c'est le serveur qui rend service : un client peut afficher le raisonnement autrement, ou pas du tout, sans rien extraire de la réponse. Rien à activer — les modèles qui réfléchissent réfléchissent. | |
| 18 | + | |
| 19 | +## Pourquoi `mm` le lit lui-même sur le fil | |
| 20 | + | |
| 21 | +Le greffon compatible OpenAI de Genkit ne transmet pas le raisonnement. Sa boucle de streaming construit un chunk à partir de `Delta.Content` et `Delta.ToolCalls`, puis n'appelle le gestionnaire que si ce chunk contient quelque chose. Un chunk qui ne porte que du raisonnement est jeté. | |
| 22 | + | |
| 23 | +Deux choses tombaient avec lui. | |
| 24 | + | |
| 25 | +Le raisonnement lui-même, évidemment — c'est pourquoi `mm` n'affichait rien du tout pendant qu'un modèle à raisonnement travaillait. | |
| 26 | + | |
| 27 | +Et **le battement du chien de garde**, qui est la partie sérieuse. `mm` annule une génération quand aucun token n'est arrivé depuis `watchdogTimeout` (20 s par défaut) : une connexion pendue ne doit pas pendre l'agent. Ce battement vit dans le gestionnaire de streaming que le greffon n'appelait pas. Un modèle qui raisonnait plus longtemps que le délai était donc tué pour « stalled » alors que son serveur diffusait des tokens sans le moindre problème — mesuré sur `qwen3.5-2b` : 38 secondes de raisonnement ininterrompu, pas un octet de contenu. | |
| 28 | + | |
| 29 | +`mm` lit donc le raisonnement une couche sous Genkit : un intergiciel sur le client HTTP enveloppe le corps de la réponse et analyse les lignes SSE à mesure qu'elles sont lues, en laissant passer chaque octet intact. Le greffon voit exactement le flux qu'il aurait vu. | |
| 30 | + | |
| 31 | +D'où le fait que `showThinking: false` coupe l'*affichage* et rien d'autre. Le raisonnement est toujours lu, parce que le lire est ce qui garde le tour en vie. | |
| 32 | + | |
| 33 | +## Où il va | |
| 34 | + | |
| 35 | +| Façade | Ce que vous voyez | | |
| 36 | +|---|---| | |
| 37 | +| Mode ligne | Un bloc `💭`, grisé et en italique, avant la réponse `🤖`. Texte brut quand la sortie n'est pas un terminal. | | |
| 38 | +| Fenêtre (`mm -tui`) | Son entrée `‣ thinking`, grisée, en italique et d'une autre couleur — un aparté, visiblement pas la réponse. | | |
| 39 | +| Éditeur (ACP) | Une mise à jour `agent_thought_chunk`. Le protocole connaît la notion, ce n'est donc pas une astuce d'affichage : Zed le replie sous son propre volet « Thinking ». | | |
| 40 | + | |
| 41 | +Trois propriétés valent partout : | |
| 42 | + | |
| 43 | +- **Le raisonnement n'entre jamais dans l'historique.** Le modèle ne relit pas ses propres pensées au tour suivant, une session reprise ne les rejoue pas, et elles ne pèsent pas sur le contexte comme le fait la réponse. | |
| 44 | +- **Un résumé n'affiche jamais son raisonnement.** La compression se fait dans le dos de l'utilisateur ; un modèle qui réfléchit à un résumé ne doit pas déverser ça au milieu d'une conversation. Le battement, lui, continue. | |
| 45 | +- **L'écouteur voyage sur le contexte de la requête**, pas dans une variable de paquet : deux tours simultanés ne peuvent pas croiser leurs raisonnements. | |
| 46 | + | |
| 47 | +## Ce que ce n'est pas | |
| 48 | + | |
| 49 | +Ce n'est pas un réglage d'« effort de raisonnement » : `mm` ne demande rien et ne configure rien côté modèle. Un modèle réfléchit ou non, et `mm` affiche ce qui arrive. | |
| 50 | + | |
| 51 | +Ce n'est pas un analyseur. Certains modèles encadrent plutôt leur raisonnement de balises `<think>` à l'intérieur de `content`. Celles-là arrivent comme du texte de réponse ordinaire et `mm` les affiche comme tel — les deux formes demanderaient des traitements différents, et seul le champ est géré aujourd'hui. | |
| new file mode 100644 | |||
| @@ -0,0 +1,51 @@ | |||
| 1 | +# Modèles à raisonnement — explication | ||
| 2 | + | ||
| 3 | +> Pourquoi `mm` affiche le raisonnement d'un modèle, d'où il vient, et pourquoi le lire n'était pas optionnel. | ||
| 4 | + | ||
| 5 | +## Ce qu'envoie un modèle à raisonnement | ||
| 6 | + | ||
| 7 | +Un modèle à raisonnement répond en deux temps. D'abord il réfléchit — parfois longuement — puis il répond. Sur un serveur compatible OpenAI, les deux ne partagent pas le même champ : | ||
| 8 | + | ||
| 9 | +``` | ||
| 10 | +data: {"choices":[{"delta":{"reasoning_content":"Here's a thinking process: ..."}}]} | ||
| 11 | +data: {"choices":[{"delta":{"reasoning_content":" 1. Analyze the request ..."}}]} | ||
| 12 | +data: {"choices":[{"delta":{"content":"Quicksort is usually faster because ..."}}]} | ||
| 13 | +``` | ||
| 14 | + | ||
| 15 | +`reasoning_content` est l'orthographe de Docker Model Runner, llama.cpp, vLLM et DeepSeek ; quelques serveurs disent `reasoning`. `mm` lit les deux. | ||
| 16 | + | ||
| 17 | +Les séparer, c'est le serveur qui rend service : un client peut afficher le raisonnement autrement, ou pas du tout, sans rien extraire de la réponse. Rien à activer — les modèles qui réfléchissent réfléchissent. | ||
| 18 | + | ||
| 19 | +## Pourquoi `mm` le lit lui-même sur le fil | ||
| 20 | + | ||
| 21 | +Le greffon compatible OpenAI de Genkit ne transmet pas le raisonnement. Sa boucle de streaming construit un chunk à partir de `Delta.Content` et `Delta.ToolCalls`, puis n'appelle le gestionnaire que si ce chunk contient quelque chose. Un chunk qui ne porte que du raisonnement est jeté. | ||
| 22 | + | ||
| 23 | +Deux choses tombaient avec lui. | ||
| 24 | + | ||
| 25 | +Le raisonnement lui-même, évidemment — c'est pourquoi `mm` n'affichait rien du tout pendant qu'un modèle à raisonnement travaillait. | ||
| 26 | + | ||
| 27 | +Et **le battement du chien de garde**, qui est la partie sérieuse. `mm` annule une génération quand aucun token n'est arrivé depuis `watchdogTimeout` (20 s par défaut) : une connexion pendue ne doit pas pendre l'agent. Ce battement vit dans le gestionnaire de streaming que le greffon n'appelait pas. Un modèle qui raisonnait plus longtemps que le délai était donc tué pour « stalled » alors que son serveur diffusait des tokens sans le moindre problème — mesuré sur `qwen3.5-2b` : 38 secondes de raisonnement ininterrompu, pas un octet de contenu. | ||
| 28 | + | ||
| 29 | +`mm` lit donc le raisonnement une couche sous Genkit : un intergiciel sur le client HTTP enveloppe le corps de la réponse et analyse les lignes SSE à mesure qu'elles sont lues, en laissant passer chaque octet intact. Le greffon voit exactement le flux qu'il aurait vu. | ||
| 30 | + | ||
| 31 | +D'où le fait que `showThinking: false` coupe l'*affichage* et rien d'autre. Le raisonnement est toujours lu, parce que le lire est ce qui garde le tour en vie. | ||
| 32 | + | ||
| 33 | +## Où il va | ||
| 34 | + | ||
| 35 | +| Façade | Ce que vous voyez | | ||
| 36 | +|---|---| | ||
| 37 | +| Mode ligne | Un bloc `💭`, grisé et en italique, avant la réponse `🤖`. Texte brut quand la sortie n'est pas un terminal. | | ||
| 38 | +| Fenêtre (`mm -tui`) | Son entrée `‣ thinking`, grisée, en italique et d'une autre couleur — un aparté, visiblement pas la réponse. | | ||
| 39 | +| Éditeur (ACP) | Une mise à jour `agent_thought_chunk`. Le protocole connaît la notion, ce n'est donc pas une astuce d'affichage : Zed le replie sous son propre volet « Thinking ». | | ||
| 40 | + | ||
| 41 | +Trois propriétés valent partout : | ||
| 42 | + | ||
| 43 | +- **Le raisonnement n'entre jamais dans l'historique.** Le modèle ne relit pas ses propres pensées au tour suivant, une session reprise ne les rejoue pas, et elles ne pèsent pas sur le contexte comme le fait la réponse. | ||
| 44 | +- **Un résumé n'affiche jamais son raisonnement.** La compression se fait dans le dos de l'utilisateur ; un modèle qui réfléchit à un résumé ne doit pas déverser ça au milieu d'une conversation. Le battement, lui, continue. | ||
| 45 | +- **L'écouteur voyage sur le contexte de la requête**, pas dans une variable de paquet : deux tours simultanés ne peuvent pas croiser leurs raisonnements. | ||
| 46 | + | ||
| 47 | +## Ce que ce n'est pas | ||
| 48 | + | ||
| 49 | +Ce n'est pas un réglage d'« effort de raisonnement » : `mm` ne demande rien et ne configure rien côté modèle. Un modèle réfléchit ou non, et `mm` affiche ce qui arrive. | ||
| 50 | + | ||
| 51 | +Ce n'est pas un analyseur. Certains modèles encadrent plutôt leur raisonnement de balises `<think>` à l'intérieur de `content`. Celles-là arrivent comme du texte de réponse ordinaire et `mm` les affiche comme tel — les deux formes demanderaient des traitements différents, et seul le champ est géré aujourd'hui. | ||
modified
docs/fr/reference/cli.md +1 -0 | @@ -94,6 +94,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | ||
| 94 | 94 | | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. | |
| 95 | 95 | | Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` à gauche, `<version> · N msgs · N tools` à droite — la version disparaît quand les deux côtés ne tiennent pas, pour que les rappels de touches ne soient jamais tronqués à cause d'elle). | |
| 96 | 96 | | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. | |
| 97 | +| Réflexion | Le raisonnement d'un modèle à raisonnement forme son entrée `‣ thinking`, grisée, en italique et d'une autre couleur, avant la réponse. Masquée avec `showThinking: false`. | | |
| 97 | 98 | | 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)]`. | |
| 98 | 99 | | 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. | |
| 99 | 100 | | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). | |
| @@ -94,6 +94,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/ | |||
| 94 | | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. | | 94 | | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. | |
| 95 | | Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` à gauche, `<version> · N msgs · N tools` à droite — la version disparaît quand les deux côtés ne tiennent pas, pour que les rappels de touches ne soient jamais tronqués à cause d'elle). | | 95 | | Disposition | Ligne d'en-tête (agent, modèle `@` fournisseur, usage du contexte en `ctx N% (utilisé/fenêtre)` ou `ctx ~utilisé (window unknown)`, `session <id>` quand les sessions sont actives) ; conversation avec barre de défilement ; règle avec l'état et les rappels de touches ; zone de saisie de 3 lignes ; barre d'état (`F1 help · Ctrl-Y copy · F2 mouse: … · Ctrl-C quit` à gauche, `<version> · N msgs · N tools` à droite — la version disparaît quand les deux côtés ne tiennent pas, pour que les rappels de touches ne soient jamais tronqués à cause d'elle). | |
| 96 | | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. | | 96 | | Appels d'outils | `‣ <outil> <détail> <statut> <durée>` où le statut est `… pending`, `… waiting for permission`, `… running`, `✓ done` ou `✗ failed` ; sortie repliée à `previewLines` lignes (`… N more line(s)`) ; lignes `+`/`-` des sorties de `write_file`/`edit_file` colorées. | |
| 97 | +| Réflexion | Le raisonnement d'un modèle à raisonnement forme son entrée `‣ thinking`, grisée, en italique et d'une autre couleur, avant la réponse. Masquée avec `showThinking: false`. | | ||
| 97 | | Erreurs | Une ligne `[error: <explication>]` ; un tour annulé affiche `🛑 Generation aborted.` ; chaque tour se termine par `⚙ N command(s) [· 📝 N file op(s)] [· 📖 N skill(s)]`. | | 98 | | 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)]`. | |
| 98 | | Permissions | Avec `permissions: ask`, la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject` jusqu'à la réponse ; un tour annulé pendant la question refuse. | | 99 | | 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. | |
| 99 | | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). | | 100 | | Touches | Voir [Commandes slash — touches du mode fenêtre](slash-commands.md#touches-du-mode-fenêtre). | |
modified
docs/fr/reference/configuration.md +1 -0 | @@ -16,6 +16,7 @@ | ||
| 16 | 16 | | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. | |
| 17 | 17 | | `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. | |
| 18 | 18 | | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | |
| 19 | +| `showThinking` | booléen | `true` | Affiche ce qu'un modèle à raisonnement réfléchit avant de répondre. Seul l'affichage est optionnel : le raisonnement est toujours lu sur le fil, car c'est lui qui maintient le chien de garde en vie pendant que le modèle réfléchit. Voir [Modèles à raisonnement](../explanation/thinking-models.md). | | |
| 19 | 20 | | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. | |
| 20 | 21 | | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. | |
| 21 | 22 | | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. | |
| @@ -16,6 +16,7 @@ | |||
| 16 | | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. | | 16 | | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. | |
| 17 | | `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. | | 17 | | `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. | |
| 18 | | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | | 18 | | `displayCommands` | booléen | `false` | Affiche la liste numérotée des commandes et opérations sur fichiers après chaque réponse. | |
| 19 | +| `showThinking` | booléen | `true` | Affiche ce qu'un modèle à raisonnement réfléchit avant de répondre. Seul l'affichage est optionnel : le raisonnement est toujours lu sur le fil, car c'est lui qui maintient le chien de garde en vie pendant que le modèle réfléchit. Voir [Modèles à raisonnement](../explanation/thinking-models.md). | | ||
| 19 | | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. | | 20 | | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. | |
| 20 | | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. | | 21 | | `skillsDir` | chaîne | `skills` | Répertoire des skills, sous forme de fichiers `<nom>.md` ou de répertoires `<nom>/SKILL.md`. Un chemin relatif est résolu depuis le répertoire de lancement de `mm` (mode terminal, ou mode ACP sans fichier de configuration), ou à côté du fichier de configuration (mode ACP avec fichier) ; la bannière avertit avec le chemin absolu quand rien n'y est trouvé. | |
| 21 | | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. | | 22 | | `bashTool` | booléen | `true` | Déclare `bash`. `false` le retire : le modèle peut encore lire et modifier des fichiers (`editTools`) et charger des skills, mais n'exécute jamais de commande. Reformulez un prompt système qui annonce encore un outil bash. | |
modified
internal/acp/sink.go +13 -0 | @@ -3,6 +3,7 @@ package acp | ||
| 3 | 3 | import ( |
| 4 | 4 | "context" |
| 5 | 5 | |
| 6 | + "mm/internal/config" | |
| 6 | 7 | "mm/internal/ui" |
| 7 | 8 | |
| 8 | 9 | sdk "github.com/coder/acp-go-sdk" |
| @@ -35,6 +36,18 @@ func (k *sink) Text(text string) { | ||
| 35 | 36 | k.send(sdk.UpdateAgentMessageText(text)) |
| 36 | 37 | } |
| 37 | 38 | |
| 39 | +// Reasoning streams what a thinking model produced before its answer. ACP | |
| 40 | +// has the concept, so this is not a rendering trick: agent_thought_chunk is | |
| 41 | +// a separate update and the editor shows it its own way — Zed folds it under | |
| 42 | +// a "Thinking" disclosure. It is never added to the history, so a resumed | |
| 43 | +// session does not replay it. | |
| 44 | +func (k *sink) Reasoning(text string) { | |
| 45 | + if !config.Cfg.ShowThinking { | |
| 46 | + return | |
| 47 | + } | |
| 48 | + k.send(sdk.UpdateAgentThoughtText(text)) | |
| 49 | +} | |
| 50 | + | |
| 38 | 51 | // WorkDir is the session's cwd — the project the editor opened. bash runs |
| 39 | 52 | // there, and relative file paths resolve against it (ui.Resolve). |
| 40 | 53 | func (k *sink) WorkDir() string { return k.s.cwd } |
| @@ -3,6 +3,7 @@ package acp | |||
| 3 | import ( | 3 | import ( |
| 4 | "context" | 4 | "context" |
| 5 | 5 | ||
| 6 | + "mm/internal/config" | ||
| 6 | "mm/internal/ui" | 7 | "mm/internal/ui" |
| 7 | 8 | ||
| 8 | sdk "github.com/coder/acp-go-sdk" | 9 | sdk "github.com/coder/acp-go-sdk" |
| @@ -35,6 +36,18 @@ func (k *sink) Text(text string) { | |||
| 35 | k.send(sdk.UpdateAgentMessageText(text)) | 36 | k.send(sdk.UpdateAgentMessageText(text)) |
| 36 | } | 37 | } |
| 37 | 38 | ||
| 39 | +// Reasoning streams what a thinking model produced before its answer. ACP | ||
| 40 | +// has the concept, so this is not a rendering trick: agent_thought_chunk is | ||
| 41 | +// a separate update and the editor shows it its own way — Zed folds it under | ||
| 42 | +// a "Thinking" disclosure. It is never added to the history, so a resumed | ||
| 43 | +// session does not replay it. | ||
| 44 | +func (k *sink) Reasoning(text string) { | ||
| 45 | + if !config.Cfg.ShowThinking { | ||
| 46 | + return | ||
| 47 | + } | ||
| 48 | + k.send(sdk.UpdateAgentThoughtText(text)) | ||
| 49 | +} | ||
| 50 | + | ||
| 38 | // WorkDir is the session's cwd — the project the editor opened. bash runs | 51 | // WorkDir is the session's cwd — the project the editor opened. bash runs |
| 39 | // there, and relative file paths resolve against it (ui.Resolve). | 52 | // there, and relative file paths resolve against it (ui.Resolve). |
| 40 | func (k *sink) WorkDir() string { return k.s.cwd } | 53 | func (k *sink) WorkDir() string { return k.s.cwd } |
modified
internal/config/config.go +8 -0 | @@ -68,6 +68,13 @@ type Config struct { | ||
| 68 | 68 | // WHAT it did — worth showing on a screen, noise in a log. |
| 69 | 69 | DisplayCommands bool `yaml:"displayCommands"` |
| 70 | 70 | |
| 71 | + // ShowThinking: display the reasoning a thinking model streams before its | |
| 72 | + // answer (dim italic in the terminal, its own style in the window, a | |
| 73 | + // thought chunk over ACP). Only the DISPLAY is optional: the reasoning is | |
| 74 | + // always read off the wire, because it is what keeps the watchdog alive | |
| 75 | + // while a model thinks (see internal/engine/reasoning.go). | |
| 76 | + ShowThinking bool `yaml:"showThinking"` | |
| 77 | + | |
| 71 | 78 | // System: the system prompt — what the agent is, and what it may do. |
| 72 | 79 | System string `yaml:"system"` |
| 73 | 80 | |
| @@ -240,6 +247,7 @@ var Cfg = Config{ | ||
| 240 | 247 | // Off by default: the recap is a demo/debug aid, not something every run |
| 241 | 248 | // needs. `displayCommands: true` in the YAML turns it on. |
| 242 | 249 | DisplayCommands: false, |
| 250 | + ShowThinking: true, | |
| 243 | 251 | SkillsDir: "skills", |
| 244 | 252 | BashTool: true, |
| 245 | 253 | EditTools: true, |
| @@ -68,6 +68,13 @@ type Config struct { | |||
| 68 | // WHAT it did — worth showing on a screen, noise in a log. | 68 | // WHAT it did — worth showing on a screen, noise in a log. |
| 69 | DisplayCommands bool `yaml:"displayCommands"` | 69 | DisplayCommands bool `yaml:"displayCommands"` |
| 70 | 70 | ||
| 71 | + // ShowThinking: display the reasoning a thinking model streams before its | ||
| 72 | + // answer (dim italic in the terminal, its own style in the window, a | ||
| 73 | + // thought chunk over ACP). Only the DISPLAY is optional: the reasoning is | ||
| 74 | + // always read off the wire, because it is what keeps the watchdog alive | ||
| 75 | + // while a model thinks (see internal/engine/reasoning.go). | ||
| 76 | + ShowThinking bool `yaml:"showThinking"` | ||
| 77 | + | ||
| 71 | // System: the system prompt — what the agent is, and what it may do. | 78 | // System: the system prompt — what the agent is, and what it may do. |
| 72 | System string `yaml:"system"` | 79 | System string `yaml:"system"` |
| 73 | 80 | ||
| @@ -240,6 +247,7 @@ var Cfg = Config{ | |||
| 240 | // Off by default: the recap is a demo/debug aid, not something every run | 247 | // Off by default: the recap is a demo/debug aid, not something every run |
| 241 | // needs. `displayCommands: true` in the YAML turns it on. | 248 | // needs. `displayCommands: true` in the YAML turns it on. |
| 242 | DisplayCommands: false, | 249 | DisplayCommands: false, |
| 250 | + ShowThinking: true, | ||
| 243 | SkillsDir: "skills", | 251 | SkillsDir: "skills", |
| 244 | BashTool: true, | 252 | BashTool: true, |
| 245 | EditTools: true, | 253 | EditTools: true, |
modified
internal/engine/engine.go +25 -0 | @@ -242,6 +242,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 242 | 242 | // right level. |
| 243 | 243 | var full []*ai.Message |
| 244 | 244 | var gaps gap |
| 245 | + // think is the turn's thought block: see reasoning.go and thoughts below. | |
| 246 | + // Declared here because emit, defined further down, closes over it. | |
| 247 | + think := &thoughts{} | |
| 245 | 248 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { |
| 246 | 249 | return &ai.Hooks{ |
| 247 | 250 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { |
| @@ -288,10 +291,13 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 288 | 291 | if text == "" { |
| 289 | 292 | return |
| 290 | 293 | } |
| 294 | + think.done() | |
| 291 | 295 | printed = true |
| 292 | 296 | s.Text(text) |
| 293 | 297 | return |
| 294 | 298 | } |
| 299 | + // The answer has started: the thinking that led to it is over. | |
| 300 | + think.done() | |
| 295 | 301 | // Trailing whitespace is held back, not printed: see the gap type. |
| 296 | 302 | out := gaps.next(text) |
| 297 | 303 | if out == "" { |
| @@ -322,6 +328,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 322 | 328 | genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout) |
| 323 | 329 | defer cancelGen() |
| 324 | 330 | |
| 331 | + // A thinking model streams its reasoning in a field Genkit drops, so the | |
| 332 | + // streaming callback below never runs while it thinks (reasoning.go). The | |
| 333 | + // beat matters more than the display here: without it the watchdog kills | |
| 334 | + // a turn that is going perfectly well, and a model can reason for a long | |
| 335 | + // time before its first word — 38s measured. The display is a setting; | |
| 336 | + // the heartbeat never is. | |
| 337 | + genCtx = WithReasoning(genCtx, func(text string) { | |
| 338 | + beat() | |
| 339 | + if config.Cfg.ShowThinking { | |
| 340 | + think.emit(text) | |
| 341 | + } | |
| 342 | + }) | |
| 343 | + | |
| 325 | 344 | spinner.Start("Thinking") |
| 326 | 345 | defer spinner.Stop() |
| 327 | 346 | |
| @@ -381,6 +400,8 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | ||
| 381 | 400 | } |
| 382 | 401 | } |
| 383 | 402 | |
| 403 | + think.done() | |
| 404 | + | |
| 384 | 405 | // `full` stops at the last call to the model; its answer is appended after. |
| 385 | 406 | if resp != nil && resp.Message != nil { |
| 386 | 407 | full = append(full, resp.Message) |
| @@ -451,6 +472,10 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | ||
| 451 | 472 | |
| 452 | 473 | genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout) |
| 453 | 474 | defer cancel() |
| 475 | + // The same heartbeat, and deliberately no display: a summary is written | |
| 476 | + // behind the user's back, and a model that thinks about it must not spill | |
| 477 | + // that onto the screen in the middle of their conversation. | |
| 478 | + genCtx = WithReasoning(genCtx, func(string) { beat() }) | |
| 454 | 479 | |
| 455 | 480 | resp, err := genkit.Generate(genCtx, e.G, |
| 456 | 481 | ai.WithModelName(e.Model), |
| @@ -242,6 +242,9 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 242 | // right level. | 242 | // right level. |
| 243 | var full []*ai.Message | 243 | var full []*ai.Message |
| 244 | var gaps gap | 244 | var gaps gap |
| 245 | + // think is the turn's thought block: see reasoning.go and thoughts below. | ||
| 246 | + // Declared here because emit, defined further down, closes over it. | ||
| 247 | + think := &thoughts{} | ||
| 245 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { | 248 | capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) { |
| 246 | return &ai.Hooks{ | 249 | return &ai.Hooks{ |
| 247 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { | 250 | WrapModel: func(ctx context.Context, params *ai.ModelParams, next ai.ModelNext) (*ai.ModelResponse, error) { |
| @@ -288,10 +291,13 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 288 | if text == "" { | 291 | if text == "" { |
| 289 | return | 292 | return |
| 290 | } | 293 | } |
| 294 | + think.done() | ||
| 291 | printed = true | 295 | printed = true |
| 292 | s.Text(text) | 296 | s.Text(text) |
| 293 | return | 297 | return |
| 294 | } | 298 | } |
| 299 | + // The answer has started: the thinking that led to it is over. | ||
| 300 | + think.done() | ||
| 295 | // Trailing whitespace is held back, not printed: see the gap type. | 301 | // Trailing whitespace is held back, not printed: see the gap type. |
| 296 | out := gaps.next(text) | 302 | out := gaps.next(text) |
| 297 | if out == "" { | 303 | if out == "" { |
| @@ -322,6 +328,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 322 | genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout) | 328 | genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout) |
| 323 | defer cancelGen() | 329 | defer cancelGen() |
| 324 | 330 | ||
| 331 | + // A thinking model streams its reasoning in a field Genkit drops, so the | ||
| 332 | + // streaming callback below never runs while it thinks (reasoning.go). The | ||
| 333 | + // beat matters more than the display here: without it the watchdog kills | ||
| 334 | + // a turn that is going perfectly well, and a model can reason for a long | ||
| 335 | + // time before its first word — 38s measured. The display is a setting; | ||
| 336 | + // the heartbeat never is. | ||
| 337 | + genCtx = WithReasoning(genCtx, func(text string) { | ||
| 338 | + beat() | ||
| 339 | + if config.Cfg.ShowThinking { | ||
| 340 | + think.emit(text) | ||
| 341 | + } | ||
| 342 | + }) | ||
| 343 | + | ||
| 325 | spinner.Start("Thinking") | 344 | spinner.Start("Thinking") |
| 326 | defer spinner.Stop() | 345 | defer spinner.Stop() |
| 327 | 346 | ||
| @@ -381,6 +400,8 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a | |||
| 381 | } | 400 | } |
| 382 | } | 401 | } |
| 383 | 402 | ||
| 403 | + think.done() | ||
| 404 | + | ||
| 384 | // `full` stops at the last call to the model; its answer is appended after. | 405 | // `full` stops at the last call to the model; its answer is appended after. |
| 385 | if resp != nil && resp.Message != nil { | 406 | if resp != nil && resp.Message != nil { |
| 386 | full = append(full, resp.Message) | 407 | full = append(full, resp.Message) |
| @@ -451,6 +472,10 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken | |||
| 451 | 472 | ||
| 452 | genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout) | 473 | genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout) |
| 453 | defer cancel() | 474 | defer cancel() |
| 475 | + // The same heartbeat, and deliberately no display: a summary is written | ||
| 476 | + // behind the user's back, and a model that thinks about it must not spill | ||
| 477 | + // that onto the screen in the middle of their conversation. | ||
| 478 | + genCtx = WithReasoning(genCtx, func(string) { beat() }) | ||
| 454 | 479 | ||
| 455 | resp, err := genkit.Generate(genCtx, e.G, | 480 | resp, err := genkit.Generate(genCtx, e.G, |
| 456 | ai.WithModelName(e.Model), | 481 | ai.WithModelName(e.Model), |
modified
internal/engine/openai_compat.go +5 -0 | @@ -14,6 +14,7 @@ import ( | ||
| 14 | 14 | "github.com/firebase/genkit/go/ai" |
| 15 | 15 | "github.com/firebase/genkit/go/genkit" |
| 16 | 16 | "github.com/firebase/genkit/go/plugins/compat_oai" |
| 17 | + "github.com/openai/openai-go/option" | |
| 17 | 18 | ) |
| 18 | 19 | |
| 19 | 20 | // openaiCompat is the Provider for every server that speaks the OpenAI |
| @@ -129,6 +130,10 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str | ||
| 129 | 130 | Provider: p.name, // model prefix: "<provider>/<id>" |
| 130 | 131 | BaseURL: b.BaseURL, |
| 131 | 132 | APIKey: b.APIKey, |
| 133 | + // The one hook the plugin leaves open, and all we need: a middleware | |
| 134 | + // on the HTTP client, so a thinking model's reasoning can be read off | |
| 135 | + // the wire the plugin drops it from (reasoning.go). | |
| 136 | + Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)}, | |
| 132 | 137 | } |
| 133 | 138 | g := genkit.Init(ctx, genkit.WithPlugins(client)) |
| 134 | 139 | client.DefineModel(p.name, b.Model, ai.ModelOptions{ |
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | "github.com/firebase/genkit/go/ai" | 14 | "github.com/firebase/genkit/go/ai" |
| 15 | "github.com/firebase/genkit/go/genkit" | 15 | "github.com/firebase/genkit/go/genkit" |
| 16 | "github.com/firebase/genkit/go/plugins/compat_oai" | 16 | "github.com/firebase/genkit/go/plugins/compat_oai" |
| 17 | + "github.com/openai/openai-go/option" | ||
| 17 | ) | 18 | ) |
| 18 | 19 | ||
| 19 | // openaiCompat is the Provider for every server that speaks the OpenAI | 20 | // openaiCompat is the Provider for every server that speaks the OpenAI |
| @@ -129,6 +130,10 @@ func (p *openaiCompat) Open(ctx context.Context, b Backend) (*genkit.Genkit, str | |||
| 129 | Provider: p.name, // model prefix: "<provider>/<id>" | 130 | Provider: p.name, // model prefix: "<provider>/<id>" |
| 130 | BaseURL: b.BaseURL, | 131 | BaseURL: b.BaseURL, |
| 131 | APIKey: b.APIKey, | 132 | APIKey: b.APIKey, |
| 133 | + // The one hook the plugin leaves open, and all we need: a middleware | ||
| 134 | + // on the HTTP client, so a thinking model's reasoning can be read off | ||
| 135 | + // the wire the plugin drops it from (reasoning.go). | ||
| 136 | + Opts: []option.RequestOption{option.WithMiddleware(reasoningTap)}, | ||
| 132 | } | 137 | } |
| 133 | g := genkit.Init(ctx, genkit.WithPlugins(client)) | 138 | g := genkit.Init(ctx, genkit.WithPlugins(client)) |
| 134 | client.DefineModel(p.name, b.Model, ai.ModelOptions{ | 139 | client.DefineModel(p.name, b.Model, ai.ModelOptions{ |
added
internal/engine/reasoning.go +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "encoding/json" | |
| 6 | + "io" | |
| 7 | + "net/http" | |
| 8 | + "strings" | |
| 9 | + | |
| 10 | + "github.com/openai/openai-go/option" | |
| 11 | +) | |
| 12 | + | |
| 13 | +// Thinking models stream their reasoning in a field of their own — | |
| 14 | +// `reasoning_content` on Docker Model Runner, llama.cpp, vLLM and DeepSeek, | |
| 15 | +// `reasoning` on a few others — kept apart from `content` so a client can | |
| 16 | +// show it differently, or not at all. | |
| 17 | +// | |
| 18 | +// Genkit's compat_oai plugin does not forward it: its streaming loop reads | |
| 19 | +// `Delta.Content` and `Delta.ToolCalls`, builds a chunk from those, and calls | |
| 20 | +// the handler only `if len(modelChunk.Content) > 0`. A chunk carrying nothing | |
| 21 | +// but reasoning is therefore dropped, and with it TWO things: | |
| 22 | +// | |
| 23 | +// - the reasoning itself, which is why mm showed nothing at all while a | |
| 24 | +// thinking model worked; | |
| 25 | +// - the watchdog's heartbeat, since beat() lives in that same handler. | |
| 26 | +// Measured on qwen3.5-2b: 38 seconds of uninterrupted reasoning and not | |
| 27 | +// one byte of content — well past watchdogTimeout, so the turn was killed | |
| 28 | +// as "stalled" while the server was streaming tokens perfectly happily. | |
| 29 | +// | |
| 30 | +// So we read it off the wire ourselves, one layer below Genkit: an openai-go | |
| 31 | +// request middleware wraps the response body and scans the SSE lines as they | |
| 32 | +// are read, passing every byte through untouched. Nothing is consumed, the | |
| 33 | +// plugin still sees exactly the stream it would have seen, and the listener | |
| 34 | +// is carried on the REQUEST CONTEXT rather than in a package variable — so | |
| 35 | +// two turns running at once cannot cross their reasoning. | |
| 36 | + | |
| 37 | +// reasonKey is the context key carrying the current turn's listener. | |
| 38 | +type reasonKey struct{} | |
| 39 | + | |
| 40 | +// WithReasoning returns a context whose requests report the reasoning they | |
| 41 | +// stream to `on`. A context without one is silent, which is what every call | |
| 42 | +// that is not a generation wants. | |
| 43 | +func WithReasoning(ctx context.Context, on func(text string)) context.Context { | |
| 44 | + if on == nil { | |
| 45 | + return ctx | |
| 46 | + } | |
| 47 | + return context.WithValue(ctx, reasonKey{}, on) | |
| 48 | +} | |
| 49 | + | |
| 50 | +func reasoningFrom(ctx context.Context) func(string) { | |
| 51 | + on, _ := ctx.Value(reasonKey{}).(func(string)) | |
| 52 | + return on | |
| 53 | +} | |
| 54 | + | |
| 55 | +// reasoningTap is the openai-go middleware. It only ever wraps a streamed | |
| 56 | +// response, and only when somebody is listening; everything else is handed | |
| 57 | +// back exactly as it came. | |
| 58 | +func reasoningTap(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { | |
| 59 | + on := reasoningFrom(req.Context()) | |
| 60 | + resp, err := next(req) | |
| 61 | + if on == nil || err != nil || resp == nil || resp.Body == nil { | |
| 62 | + return resp, err | |
| 63 | + } | |
| 64 | + if !strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { | |
| 65 | + return resp, nil | |
| 66 | + } | |
| 67 | + resp.Body = &sseTap{rc: resp.Body, on: on} | |
| 68 | + return resp, nil | |
| 69 | +} | |
| 70 | + | |
| 71 | +// maxSSELine caps the line buffer. A server that never sends a newline must | |
| 72 | +// not grow it without end; 1 MiB is far past any real chunk. | |
| 73 | +const maxSSELine = 1 << 20 | |
| 74 | + | |
| 75 | +// sseTap copies the stream through and reports the reasoning it sees. It is | |
| 76 | +// a Reader, not a goroutine on a pipe: the reasoning of a chunk is reported | |
| 77 | +// while that chunk is being handed to the plugin, so it cannot arrive after | |
| 78 | +// the answer it preceded. | |
| 79 | +type sseTap struct { | |
| 80 | + rc io.ReadCloser | |
| 81 | + on func(string) | |
| 82 | + buf []byte | |
| 83 | +} | |
| 84 | + | |
| 85 | +func (t *sseTap) Read(p []byte) (int, error) { | |
| 86 | + n, err := t.rc.Read(p) | |
| 87 | + if n > 0 { | |
| 88 | + t.scan(p[:n]) | |
| 89 | + } | |
| 90 | + return n, err | |
| 91 | +} | |
| 92 | + | |
| 93 | +func (t *sseTap) Close() error { return t.rc.Close() } | |
| 94 | + | |
| 95 | +// scan keeps the bytes that do not yet make a line and reports the ones that do. | |
| 96 | +func (t *sseTap) scan(b []byte) { | |
| 97 | + t.buf = append(t.buf, b...) | |
| 98 | + for { | |
| 99 | + i := strings.IndexByte(string(t.buf), '\n') | |
| 100 | + if i < 0 { | |
| 101 | + if len(t.buf) > maxSSELine { | |
| 102 | + t.buf = nil // a line this long is not one of ours | |
| 103 | + } | |
| 104 | + return | |
| 105 | + } | |
| 106 | + line := string(t.buf[:i]) | |
| 107 | + t.buf = t.buf[i+1:] | |
| 108 | + if text := reasoningOf(line); text != "" { | |
| 109 | + t.on(text) | |
| 110 | + } | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +// sseChunk is the little of a chunk we care about. Two spellings: DMR, | |
| 115 | +// llama.cpp, vLLM and DeepSeek say `reasoning_content`; OpenRouter and a few | |
| 116 | +// others say `reasoning`. Reading both costs one field. | |
| 117 | +type sseChunk struct { | |
| 118 | + Choices []struct { | |
| 119 | + Delta struct { | |
| 120 | + ReasoningContent string `json:"reasoning_content"` | |
| 121 | + Reasoning string `json:"reasoning"` | |
| 122 | + } `json:"delta"` | |
| 123 | + } `json:"choices"` | |
| 124 | +} | |
| 125 | + | |
| 126 | +// reasoningOf returns the reasoning an SSE line carries, "" for anything else | |
| 127 | +// — a comment, the [DONE] mark, a chunk of ordinary content, a line that is | |
| 128 | +// not JSON at all. A malformed line is skipped in silence: this is a display, | |
| 129 | +// and it must never fail a generation. | |
| 130 | +func reasoningOf(line string) string { | |
| 131 | + data, ok := strings.CutPrefix(strings.TrimSpace(line), "data:") | |
| 132 | + if !ok { | |
| 133 | + return "" | |
| 134 | + } | |
| 135 | + data = strings.TrimSpace(data) | |
| 136 | + if data == "" || data == "[DONE]" { | |
| 137 | + return "" | |
| 138 | + } | |
| 139 | + var c sseChunk | |
| 140 | + if json.Unmarshal([]byte(data), &c) != nil || len(c.Choices) == 0 { | |
| 141 | + return "" | |
| 142 | + } | |
| 143 | + if r := c.Choices[0].Delta.ReasoningContent; r != "" { | |
| 144 | + return r | |
| 145 | + } | |
| 146 | + return c.Choices[0].Delta.Reasoning | |
| 147 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "encoding/json" | ||
| 6 | + "io" | ||
| 7 | + "net/http" | ||
| 8 | + "strings" | ||
| 9 | + | ||
| 10 | + "github.com/openai/openai-go/option" | ||
| 11 | +) | ||
| 12 | + | ||
| 13 | +// Thinking models stream their reasoning in a field of their own — | ||
| 14 | +// `reasoning_content` on Docker Model Runner, llama.cpp, vLLM and DeepSeek, | ||
| 15 | +// `reasoning` on a few others — kept apart from `content` so a client can | ||
| 16 | +// show it differently, or not at all. | ||
| 17 | +// | ||
| 18 | +// Genkit's compat_oai plugin does not forward it: its streaming loop reads | ||
| 19 | +// `Delta.Content` and `Delta.ToolCalls`, builds a chunk from those, and calls | ||
| 20 | +// the handler only `if len(modelChunk.Content) > 0`. A chunk carrying nothing | ||
| 21 | +// but reasoning is therefore dropped, and with it TWO things: | ||
| 22 | +// | ||
| 23 | +// - the reasoning itself, which is why mm showed nothing at all while a | ||
| 24 | +// thinking model worked; | ||
| 25 | +// - the watchdog's heartbeat, since beat() lives in that same handler. | ||
| 26 | +// Measured on qwen3.5-2b: 38 seconds of uninterrupted reasoning and not | ||
| 27 | +// one byte of content — well past watchdogTimeout, so the turn was killed | ||
| 28 | +// as "stalled" while the server was streaming tokens perfectly happily. | ||
| 29 | +// | ||
| 30 | +// So we read it off the wire ourselves, one layer below Genkit: an openai-go | ||
| 31 | +// request middleware wraps the response body and scans the SSE lines as they | ||
| 32 | +// are read, passing every byte through untouched. Nothing is consumed, the | ||
| 33 | +// plugin still sees exactly the stream it would have seen, and the listener | ||
| 34 | +// is carried on the REQUEST CONTEXT rather than in a package variable — so | ||
| 35 | +// two turns running at once cannot cross their reasoning. | ||
| 36 | + | ||
| 37 | +// reasonKey is the context key carrying the current turn's listener. | ||
| 38 | +type reasonKey struct{} | ||
| 39 | + | ||
| 40 | +// WithReasoning returns a context whose requests report the reasoning they | ||
| 41 | +// stream to `on`. A context without one is silent, which is what every call | ||
| 42 | +// that is not a generation wants. | ||
| 43 | +func WithReasoning(ctx context.Context, on func(text string)) context.Context { | ||
| 44 | + if on == nil { | ||
| 45 | + return ctx | ||
| 46 | + } | ||
| 47 | + return context.WithValue(ctx, reasonKey{}, on) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +func reasoningFrom(ctx context.Context) func(string) { | ||
| 51 | + on, _ := ctx.Value(reasonKey{}).(func(string)) | ||
| 52 | + return on | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// reasoningTap is the openai-go middleware. It only ever wraps a streamed | ||
| 56 | +// response, and only when somebody is listening; everything else is handed | ||
| 57 | +// back exactly as it came. | ||
| 58 | +func reasoningTap(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { | ||
| 59 | + on := reasoningFrom(req.Context()) | ||
| 60 | + resp, err := next(req) | ||
| 61 | + if on == nil || err != nil || resp == nil || resp.Body == nil { | ||
| 62 | + return resp, err | ||
| 63 | + } | ||
| 64 | + if !strings.Contains(resp.Header.Get("Content-Type"), "text/event-stream") { | ||
| 65 | + return resp, nil | ||
| 66 | + } | ||
| 67 | + resp.Body = &sseTap{rc: resp.Body, on: on} | ||
| 68 | + return resp, nil | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +// maxSSELine caps the line buffer. A server that never sends a newline must | ||
| 72 | +// not grow it without end; 1 MiB is far past any real chunk. | ||
| 73 | +const maxSSELine = 1 << 20 | ||
| 74 | + | ||
| 75 | +// sseTap copies the stream through and reports the reasoning it sees. It is | ||
| 76 | +// a Reader, not a goroutine on a pipe: the reasoning of a chunk is reported | ||
| 77 | +// while that chunk is being handed to the plugin, so it cannot arrive after | ||
| 78 | +// the answer it preceded. | ||
| 79 | +type sseTap struct { | ||
| 80 | + rc io.ReadCloser | ||
| 81 | + on func(string) | ||
| 82 | + buf []byte | ||
| 83 | +} | ||
| 84 | + | ||
| 85 | +func (t *sseTap) Read(p []byte) (int, error) { | ||
| 86 | + n, err := t.rc.Read(p) | ||
| 87 | + if n > 0 { | ||
| 88 | + t.scan(p[:n]) | ||
| 89 | + } | ||
| 90 | + return n, err | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +func (t *sseTap) Close() error { return t.rc.Close() } | ||
| 94 | + | ||
| 95 | +// scan keeps the bytes that do not yet make a line and reports the ones that do. | ||
| 96 | +func (t *sseTap) scan(b []byte) { | ||
| 97 | + t.buf = append(t.buf, b...) | ||
| 98 | + for { | ||
| 99 | + i := strings.IndexByte(string(t.buf), '\n') | ||
| 100 | + if i < 0 { | ||
| 101 | + if len(t.buf) > maxSSELine { | ||
| 102 | + t.buf = nil // a line this long is not one of ours | ||
| 103 | + } | ||
| 104 | + return | ||
| 105 | + } | ||
| 106 | + line := string(t.buf[:i]) | ||
| 107 | + t.buf = t.buf[i+1:] | ||
| 108 | + if text := reasoningOf(line); text != "" { | ||
| 109 | + t.on(text) | ||
| 110 | + } | ||
| 111 | + } | ||
| 112 | +} | ||
| 113 | + | ||
| 114 | +// sseChunk is the little of a chunk we care about. Two spellings: DMR, | ||
| 115 | +// llama.cpp, vLLM and DeepSeek say `reasoning_content`; OpenRouter and a few | ||
| 116 | +// others say `reasoning`. Reading both costs one field. | ||
| 117 | +type sseChunk struct { | ||
| 118 | + Choices []struct { | ||
| 119 | + Delta struct { | ||
| 120 | + ReasoningContent string `json:"reasoning_content"` | ||
| 121 | + Reasoning string `json:"reasoning"` | ||
| 122 | + } `json:"delta"` | ||
| 123 | + } `json:"choices"` | ||
| 124 | +} | ||
| 125 | + | ||
| 126 | +// reasoningOf returns the reasoning an SSE line carries, "" for anything else | ||
| 127 | +// — a comment, the [DONE] mark, a chunk of ordinary content, a line that is | ||
| 128 | +// not JSON at all. A malformed line is skipped in silence: this is a display, | ||
| 129 | +// and it must never fail a generation. | ||
| 130 | +func reasoningOf(line string) string { | ||
| 131 | + data, ok := strings.CutPrefix(strings.TrimSpace(line), "data:") | ||
| 132 | + if !ok { | ||
| 133 | + return "" | ||
| 134 | + } | ||
| 135 | + data = strings.TrimSpace(data) | ||
| 136 | + if data == "" || data == "[DONE]" { | ||
| 137 | + return "" | ||
| 138 | + } | ||
| 139 | + var c sseChunk | ||
| 140 | + if json.Unmarshal([]byte(data), &c) != nil || len(c.Choices) == 0 { | ||
| 141 | + return "" | ||
| 142 | + } | ||
| 143 | + if r := c.Choices[0].Delta.ReasoningContent; r != "" { | ||
| 144 | + return r | ||
| 145 | + } | ||
| 146 | + return c.Choices[0].Delta.Reasoning | ||
| 147 | +} | ||
added
internal/engine/reasoning_test.go +162 -0 | new file mode 100644 | ||
| @@ -0,0 +1,162 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "io" | |
| 6 | + "net/http" | |
| 7 | + "net/http/httptest" | |
| 8 | + "strings" | |
| 9 | + "sync" | |
| 10 | + "testing" | |
| 11 | + | |
| 12 | + "github.com/openai/openai-go/option" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func TestReasoningOfALine(t *testing.T) { | |
| 16 | + cases := []struct{ name, line, want string }{ | |
| 17 | + {"DMR, llama.cpp, vLLM, DeepSeek", `data: {"choices":[{"delta":{"reasoning_content":"Here"}}]}`, "Here"}, | |
| 18 | + {"the other spelling", `data: {"choices":[{"delta":{"reasoning":"Here"}}]}`, "Here"}, | |
| 19 | + {"ordinary content is not reasoning", `data: {"choices":[{"delta":{"content":"Hello"}}]}`, ""}, | |
| 20 | + {"the opening role chunk", `data: {"choices":[{"delta":{"role":"assistant","content":null}}]}`, ""}, | |
| 21 | + {"the end mark", "data: [DONE]", ""}, | |
| 22 | + {"an SSE comment", ": keep-alive", ""}, | |
| 23 | + {"a blank line", "", ""}, | |
| 24 | + {"half a line, not yet JSON", `data: {"choices":[{"delta":{"reason`, ""}, | |
| 25 | + {"no choices at all", `data: {"choices":[]}`, ""}, | |
| 26 | + } | |
| 27 | + for _, tc := range cases { | |
| 28 | + t.Run(tc.name, func(t *testing.T) { | |
| 29 | + if got := reasoningOf(tc.line); got != tc.want { | |
| 30 | + t.Errorf("reasoningOf(%q) = %q, want %q", tc.line, got, tc.want) | |
| 31 | + } | |
| 32 | + }) | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +// The tap must be a pass-through: the plugin reading after us has to see the | |
| 37 | +// stream byte for byte, whatever we made of it. | |
| 38 | +func TestSSETapPassesEveryByteThrough(t *testing.T) { | |
| 39 | + const stream = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n" + | |
| 40 | + "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n" + | |
| 41 | + "data: [DONE]\n\n" | |
| 42 | + | |
| 43 | + var got []string | |
| 44 | + tap := &sseTap{rc: io.NopCloser(strings.NewReader(stream)), on: func(s string) { got = append(got, s) }} | |
| 45 | + read, err := io.ReadAll(tap) | |
| 46 | + if err != nil { | |
| 47 | + t.Fatalf("read: %v", err) | |
| 48 | + } | |
| 49 | + if string(read) != stream { | |
| 50 | + t.Errorf("the stream was altered:\n got %q\nwant %q", read, stream) | |
| 51 | + } | |
| 52 | + if len(got) != 1 || got[0] != "think" { | |
| 53 | + t.Errorf("reasoning reported = %v, want [think]", got) | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +// A fragment split across two Reads is still one line: the tap keeps what does | |
| 58 | +// not yet make a line. This is the normal case on a socket. | |
| 59 | +func TestSSETapAcrossReadBoundaries(t *testing.T) { | |
| 60 | + const line = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"split me\"}}]}\n" | |
| 61 | + var got []string | |
| 62 | + tap := &sseTap{rc: io.NopCloser(strings.NewReader(line)), on: func(s string) { got = append(got, s) }} | |
| 63 | + | |
| 64 | + buf := make([]byte, 7) // small on purpose: many reads, boundaries everywhere | |
| 65 | + for { | |
| 66 | + if _, err := tap.Read(buf); err != nil { | |
| 67 | + break | |
| 68 | + } | |
| 69 | + } | |
| 70 | + if len(got) != 1 || got[0] != "split me" { | |
| 71 | + t.Errorf("reasoning reported = %v, want [split me]", got) | |
| 72 | + } | |
| 73 | +} | |
| 74 | + | |
| 75 | +// A server that never sends a newline must not grow the buffer without end. | |
| 76 | +func TestSSETapCapsAnEndlessLine(t *testing.T) { | |
| 77 | + endless := strings.NewReader(strings.Repeat("x", 3*maxSSELine)) | |
| 78 | + tap := &sseTap{rc: io.NopCloser(endless), on: func(string) {}} | |
| 79 | + if _, err := io.ReadAll(tap); err != nil { | |
| 80 | + t.Fatalf("read: %v", err) | |
| 81 | + } | |
| 82 | + if len(tap.buf) > maxSSELine { | |
| 83 | + t.Errorf("the line buffer grew to %d bytes", len(tap.buf)) | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +// The listener rides on the request context, so two turns at once cannot | |
| 88 | +// cross their reasoning — and a request with no listener is left alone. | |
| 89 | +func TestReasoningTapIsScopedToItsRequest(t *testing.T) { | |
| 90 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 91 | + w.Header().Set("Content-Type", "text/event-stream") | |
| 92 | + name := r.URL.Query().Get("who") | |
| 93 | + _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"`+name+`"}}]}`+"\n") | |
| 94 | + })) | |
| 95 | + defer srv.Close() | |
| 96 | + | |
| 97 | + call := func(ctx context.Context, who string) { | |
| 98 | + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"?who="+who, nil) | |
| 99 | + resp, err := reasoningTap(req, func(r *http.Request) (*http.Response, error) { | |
| 100 | + return srv.Client().Do(r) | |
| 101 | + }) | |
| 102 | + if err != nil { | |
| 103 | + t.Errorf("%s: %v", who, err) | |
| 104 | + return | |
| 105 | + } | |
| 106 | + defer resp.Body.Close() | |
| 107 | + _, _ = io.ReadAll(resp.Body) | |
| 108 | + } | |
| 109 | + | |
| 110 | + var mu sync.Mutex | |
| 111 | + heard := map[string][]string{} | |
| 112 | + listen := func(who string) context.Context { | |
| 113 | + return WithReasoning(context.Background(), func(text string) { | |
| 114 | + mu.Lock() | |
| 115 | + heard[who] = append(heard[who], text) | |
| 116 | + mu.Unlock() | |
| 117 | + }) | |
| 118 | + } | |
| 119 | + | |
| 120 | + var wg sync.WaitGroup | |
| 121 | + for _, who := range []string{"alice", "bob"} { | |
| 122 | + wg.Add(1) | |
| 123 | + go func() { defer wg.Done(); call(listen(who), who) }() | |
| 124 | + } | |
| 125 | + wg.Wait() | |
| 126 | + call(context.Background(), "nobody") // no listener: must not panic, must not be heard | |
| 127 | + | |
| 128 | + for _, who := range []string{"alice", "bob"} { | |
| 129 | + if got := heard[who]; len(got) != 1 || got[0] != who { | |
| 130 | + t.Errorf("%s heard %v, want [%s] — a listener saw another turn's reasoning", who, got, who) | |
| 131 | + } | |
| 132 | + } | |
| 133 | + if len(heard) != 2 { | |
| 134 | + t.Errorf("heard = %v, want alice and bob only", heard) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +// Only a streamed response is wrapped; anything else is handed back as it came. | |
| 139 | +func TestReasoningTapLeavesNonStreamsAlone(t *testing.T) { | |
| 140 | + body := io.NopCloser(strings.NewReader("{}")) | |
| 141 | + plain := &http.Response{Header: http.Header{"Content-Type": []string{"application/json"}}, Body: body} | |
| 142 | + req, _ := http.NewRequestWithContext(WithReasoning(context.Background(), func(string) {}), | |
| 143 | + http.MethodGet, "http://example.invalid", nil) | |
| 144 | + | |
| 145 | + resp, err := reasoningTap(req, func(*http.Request) (*http.Response, error) { return plain, nil }) | |
| 146 | + if err != nil { | |
| 147 | + t.Fatalf("tap: %v", err) | |
| 148 | + } | |
| 149 | + if resp.Body != body { | |
| 150 | + t.Error("a non-streamed body was wrapped") | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +// WithReasoning(nil) is the caller saying "nobody is listening". | |
| 155 | +func TestWithReasoningIgnoresANilListener(t *testing.T) { | |
| 156 | + ctx := WithReasoning(context.Background(), nil) | |
| 157 | + if reasoningFrom(ctx) != nil { | |
| 158 | + t.Error("a nil listener was installed") | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +var _ = option.WithMiddleware(reasoningTap) // the middleware still fits the option | |
| new file mode 100644 | |||
| @@ -0,0 +1,162 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "context" | ||
| 5 | + "io" | ||
| 6 | + "net/http" | ||
| 7 | + "net/http/httptest" | ||
| 8 | + "strings" | ||
| 9 | + "sync" | ||
| 10 | + "testing" | ||
| 11 | + | ||
| 12 | + "github.com/openai/openai-go/option" | ||
| 13 | +) | ||
| 14 | + | ||
| 15 | +func TestReasoningOfALine(t *testing.T) { | ||
| 16 | + cases := []struct{ name, line, want string }{ | ||
| 17 | + {"DMR, llama.cpp, vLLM, DeepSeek", `data: {"choices":[{"delta":{"reasoning_content":"Here"}}]}`, "Here"}, | ||
| 18 | + {"the other spelling", `data: {"choices":[{"delta":{"reasoning":"Here"}}]}`, "Here"}, | ||
| 19 | + {"ordinary content is not reasoning", `data: {"choices":[{"delta":{"content":"Hello"}}]}`, ""}, | ||
| 20 | + {"the opening role chunk", `data: {"choices":[{"delta":{"role":"assistant","content":null}}]}`, ""}, | ||
| 21 | + {"the end mark", "data: [DONE]", ""}, | ||
| 22 | + {"an SSE comment", ": keep-alive", ""}, | ||
| 23 | + {"a blank line", "", ""}, | ||
| 24 | + {"half a line, not yet JSON", `data: {"choices":[{"delta":{"reason`, ""}, | ||
| 25 | + {"no choices at all", `data: {"choices":[]}`, ""}, | ||
| 26 | + } | ||
| 27 | + for _, tc := range cases { | ||
| 28 | + t.Run(tc.name, func(t *testing.T) { | ||
| 29 | + if got := reasoningOf(tc.line); got != tc.want { | ||
| 30 | + t.Errorf("reasoningOf(%q) = %q, want %q", tc.line, got, tc.want) | ||
| 31 | + } | ||
| 32 | + }) | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// The tap must be a pass-through: the plugin reading after us has to see the | ||
| 37 | +// stream byte for byte, whatever we made of it. | ||
| 38 | +func TestSSETapPassesEveryByteThrough(t *testing.T) { | ||
| 39 | + const stream = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"think\"}}]}\n\n" + | ||
| 40 | + "data: {\"choices\":[{\"delta\":{\"content\":\"answer\"}}]}\n\n" + | ||
| 41 | + "data: [DONE]\n\n" | ||
| 42 | + | ||
| 43 | + var got []string | ||
| 44 | + tap := &sseTap{rc: io.NopCloser(strings.NewReader(stream)), on: func(s string) { got = append(got, s) }} | ||
| 45 | + read, err := io.ReadAll(tap) | ||
| 46 | + if err != nil { | ||
| 47 | + t.Fatalf("read: %v", err) | ||
| 48 | + } | ||
| 49 | + if string(read) != stream { | ||
| 50 | + t.Errorf("the stream was altered:\n got %q\nwant %q", read, stream) | ||
| 51 | + } | ||
| 52 | + if len(got) != 1 || got[0] != "think" { | ||
| 53 | + t.Errorf("reasoning reported = %v, want [think]", got) | ||
| 54 | + } | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// A fragment split across two Reads is still one line: the tap keeps what does | ||
| 58 | +// not yet make a line. This is the normal case on a socket. | ||
| 59 | +func TestSSETapAcrossReadBoundaries(t *testing.T) { | ||
| 60 | + const line = "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"split me\"}}]}\n" | ||
| 61 | + var got []string | ||
| 62 | + tap := &sseTap{rc: io.NopCloser(strings.NewReader(line)), on: func(s string) { got = append(got, s) }} | ||
| 63 | + | ||
| 64 | + buf := make([]byte, 7) // small on purpose: many reads, boundaries everywhere | ||
| 65 | + for { | ||
| 66 | + if _, err := tap.Read(buf); err != nil { | ||
| 67 | + break | ||
| 68 | + } | ||
| 69 | + } | ||
| 70 | + if len(got) != 1 || got[0] != "split me" { | ||
| 71 | + t.Errorf("reasoning reported = %v, want [split me]", got) | ||
| 72 | + } | ||
| 73 | +} | ||
| 74 | + | ||
| 75 | +// A server that never sends a newline must not grow the buffer without end. | ||
| 76 | +func TestSSETapCapsAnEndlessLine(t *testing.T) { | ||
| 77 | + endless := strings.NewReader(strings.Repeat("x", 3*maxSSELine)) | ||
| 78 | + tap := &sseTap{rc: io.NopCloser(endless), on: func(string) {}} | ||
| 79 | + if _, err := io.ReadAll(tap); err != nil { | ||
| 80 | + t.Fatalf("read: %v", err) | ||
| 81 | + } | ||
| 82 | + if len(tap.buf) > maxSSELine { | ||
| 83 | + t.Errorf("the line buffer grew to %d bytes", len(tap.buf)) | ||
| 84 | + } | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// The listener rides on the request context, so two turns at once cannot | ||
| 88 | +// cross their reasoning — and a request with no listener is left alone. | ||
| 89 | +func TestReasoningTapIsScopedToItsRequest(t *testing.T) { | ||
| 90 | + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 91 | + w.Header().Set("Content-Type", "text/event-stream") | ||
| 92 | + name := r.URL.Query().Get("who") | ||
| 93 | + _, _ = io.WriteString(w, `data: {"choices":[{"delta":{"reasoning_content":"`+name+`"}}]}`+"\n") | ||
| 94 | + })) | ||
| 95 | + defer srv.Close() | ||
| 96 | + | ||
| 97 | + call := func(ctx context.Context, who string) { | ||
| 98 | + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"?who="+who, nil) | ||
| 99 | + resp, err := reasoningTap(req, func(r *http.Request) (*http.Response, error) { | ||
| 100 | + return srv.Client().Do(r) | ||
| 101 | + }) | ||
| 102 | + if err != nil { | ||
| 103 | + t.Errorf("%s: %v", who, err) | ||
| 104 | + return | ||
| 105 | + } | ||
| 106 | + defer resp.Body.Close() | ||
| 107 | + _, _ = io.ReadAll(resp.Body) | ||
| 108 | + } | ||
| 109 | + | ||
| 110 | + var mu sync.Mutex | ||
| 111 | + heard := map[string][]string{} | ||
| 112 | + listen := func(who string) context.Context { | ||
| 113 | + return WithReasoning(context.Background(), func(text string) { | ||
| 114 | + mu.Lock() | ||
| 115 | + heard[who] = append(heard[who], text) | ||
| 116 | + mu.Unlock() | ||
| 117 | + }) | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + var wg sync.WaitGroup | ||
| 121 | + for _, who := range []string{"alice", "bob"} { | ||
| 122 | + wg.Add(1) | ||
| 123 | + go func() { defer wg.Done(); call(listen(who), who) }() | ||
| 124 | + } | ||
| 125 | + wg.Wait() | ||
| 126 | + call(context.Background(), "nobody") // no listener: must not panic, must not be heard | ||
| 127 | + | ||
| 128 | + for _, who := range []string{"alice", "bob"} { | ||
| 129 | + if got := heard[who]; len(got) != 1 || got[0] != who { | ||
| 130 | + t.Errorf("%s heard %v, want [%s] — a listener saw another turn's reasoning", who, got, who) | ||
| 131 | + } | ||
| 132 | + } | ||
| 133 | + if len(heard) != 2 { | ||
| 134 | + t.Errorf("heard = %v, want alice and bob only", heard) | ||
| 135 | + } | ||
| 136 | +} | ||
| 137 | + | ||
| 138 | +// Only a streamed response is wrapped; anything else is handed back as it came. | ||
| 139 | +func TestReasoningTapLeavesNonStreamsAlone(t *testing.T) { | ||
| 140 | + body := io.NopCloser(strings.NewReader("{}")) | ||
| 141 | + plain := &http.Response{Header: http.Header{"Content-Type": []string{"application/json"}}, Body: body} | ||
| 142 | + req, _ := http.NewRequestWithContext(WithReasoning(context.Background(), func(string) {}), | ||
| 143 | + http.MethodGet, "http://example.invalid", nil) | ||
| 144 | + | ||
| 145 | + resp, err := reasoningTap(req, func(*http.Request) (*http.Response, error) { return plain, nil }) | ||
| 146 | + if err != nil { | ||
| 147 | + t.Fatalf("tap: %v", err) | ||
| 148 | + } | ||
| 149 | + if resp.Body != body { | ||
| 150 | + t.Error("a non-streamed body was wrapped") | ||
| 151 | + } | ||
| 152 | +} | ||
| 153 | + | ||
| 154 | +// WithReasoning(nil) is the caller saying "nobody is listening". | ||
| 155 | +func TestWithReasoningIgnoresANilListener(t *testing.T) { | ||
| 156 | + ctx := WithReasoning(context.Background(), nil) | ||
| 157 | + if reasoningFrom(ctx) != nil { | ||
| 158 | + t.Error("a nil listener was installed") | ||
| 159 | + } | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +var _ = option.WithMiddleware(reasoningTap) // the middleware still fits the option | ||
added
internal/engine/thoughts.go +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +package engine | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "fmt" | |
| 5 | + "sync" | |
| 6 | + | |
| 7 | + "mm/internal/spinner" | |
| 8 | + "mm/internal/ui" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// thoughts is one turn's block of reasoning on its way to the screen. | |
| 12 | +// | |
| 13 | +// It exists because reasoning is not the answer and must not look like it: | |
| 14 | +// dim and italic, behind a 💭, so a glance tells the two apart without | |
| 15 | +// reading a word. A front end that consumes events (the editor, the window) | |
| 16 | +// gets the text instead and decides for itself. | |
| 17 | +// | |
| 18 | +// The state it keeps is one bit — has this block been opened — which buys two | |
| 19 | +// things: the spinner is stopped and the marker printed exactly once, however | |
| 20 | +// many fragments arrive, and the block is closed exactly once, by whichever | |
| 21 | +// of the answer's first token or the end of the turn comes first. | |
| 22 | +type thoughts struct { | |
| 23 | + mu sync.Mutex | |
| 24 | + open bool | |
| 25 | +} | |
| 26 | + | |
| 27 | +// emit shows one fragment of reasoning, opening the block if needed. | |
| 28 | +// Called from the HTTP reader's goroutine, hence the lock. | |
| 29 | +func (t *thoughts) emit(text string) { | |
| 30 | + if text == "" { | |
| 31 | + return | |
| 32 | + } | |
| 33 | + if s := ui.ActiveSink(); s != nil { | |
| 34 | + s.Reasoning(text) | |
| 35 | + return | |
| 36 | + } | |
| 37 | + t.mu.Lock() | |
| 38 | + defer t.mu.Unlock() | |
| 39 | + if !t.open { | |
| 40 | + // Stop() before printing, the rule everything else here follows: | |
| 41 | + // without it the marker lands on the spinner's line and the next | |
| 42 | + // frame erases it. | |
| 43 | + spinner.Stop() | |
| 44 | + fmt.Fprint(ui.Out, "\n💭 ") | |
| 45 | + t.open = true | |
| 46 | + } | |
| 47 | + fmt.Fprint(ui.Out, dim(text)) | |
| 48 | +} | |
| 49 | + | |
| 50 | +// done ends the block: a newline, so the answer starts on a line of its own. | |
| 51 | +// Idempotent, and called from both the answer's first token and the end of | |
| 52 | +// the turn — whichever happens first, and sometimes only one of them does. | |
| 53 | +func (t *thoughts) done() { | |
| 54 | + if s := ui.ActiveSink(); s != nil { | |
| 55 | + return // the sink shapes its own thought block | |
| 56 | + } | |
| 57 | + t.mu.Lock() | |
| 58 | + defer t.mu.Unlock() | |
| 59 | + if !t.open { | |
| 60 | + return | |
| 61 | + } | |
| 62 | + t.open = false | |
| 63 | + fmt.Fprintln(ui.Out) | |
| 64 | +} | |
| 65 | + | |
| 66 | +// dim renders reasoning as dim italic — a different colour and a different | |
| 67 | +// face, so it reads as an aside. Plain text when the output is not a styled | |
| 68 | +// terminal: a log or a pipe gets the words without the escapes. | |
| 69 | +func dim(text string) string { | |
| 70 | + if !spinner.Styled() { | |
| 71 | + return text | |
| 72 | + } | |
| 73 | + return "\033[2;3m" + text + "\033[0m" | |
| 74 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +package engine | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "fmt" | ||
| 5 | + "sync" | ||
| 6 | + | ||
| 7 | + "mm/internal/spinner" | ||
| 8 | + "mm/internal/ui" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// thoughts is one turn's block of reasoning on its way to the screen. | ||
| 12 | +// | ||
| 13 | +// It exists because reasoning is not the answer and must not look like it: | ||
| 14 | +// dim and italic, behind a 💭, so a glance tells the two apart without | ||
| 15 | +// reading a word. A front end that consumes events (the editor, the window) | ||
| 16 | +// gets the text instead and decides for itself. | ||
| 17 | +// | ||
| 18 | +// The state it keeps is one bit — has this block been opened — which buys two | ||
| 19 | +// things: the spinner is stopped and the marker printed exactly once, however | ||
| 20 | +// many fragments arrive, and the block is closed exactly once, by whichever | ||
| 21 | +// of the answer's first token or the end of the turn comes first. | ||
| 22 | +type thoughts struct { | ||
| 23 | + mu sync.Mutex | ||
| 24 | + open bool | ||
| 25 | +} | ||
| 26 | + | ||
| 27 | +// emit shows one fragment of reasoning, opening the block if needed. | ||
| 28 | +// Called from the HTTP reader's goroutine, hence the lock. | ||
| 29 | +func (t *thoughts) emit(text string) { | ||
| 30 | + if text == "" { | ||
| 31 | + return | ||
| 32 | + } | ||
| 33 | + if s := ui.ActiveSink(); s != nil { | ||
| 34 | + s.Reasoning(text) | ||
| 35 | + return | ||
| 36 | + } | ||
| 37 | + t.mu.Lock() | ||
| 38 | + defer t.mu.Unlock() | ||
| 39 | + if !t.open { | ||
| 40 | + // Stop() before printing, the rule everything else here follows: | ||
| 41 | + // without it the marker lands on the spinner's line and the next | ||
| 42 | + // frame erases it. | ||
| 43 | + spinner.Stop() | ||
| 44 | + fmt.Fprint(ui.Out, "\n💭 ") | ||
| 45 | + t.open = true | ||
| 46 | + } | ||
| 47 | + fmt.Fprint(ui.Out, dim(text)) | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +// done ends the block: a newline, so the answer starts on a line of its own. | ||
| 51 | +// Idempotent, and called from both the answer's first token and the end of | ||
| 52 | +// the turn — whichever happens first, and sometimes only one of them does. | ||
| 53 | +func (t *thoughts) done() { | ||
| 54 | + if s := ui.ActiveSink(); s != nil { | ||
| 55 | + return // the sink shapes its own thought block | ||
| 56 | + } | ||
| 57 | + t.mu.Lock() | ||
| 58 | + defer t.mu.Unlock() | ||
| 59 | + if !t.open { | ||
| 60 | + return | ||
| 61 | + } | ||
| 62 | + t.open = false | ||
| 63 | + fmt.Fprintln(ui.Out) | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +// dim renders reasoning as dim italic — a different colour and a different | ||
| 67 | +// face, so it reads as an aside. Plain text when the output is not a styled | ||
| 68 | +// terminal: a log or a pipe gets the words without the escapes. | ||
| 69 | +func dim(text string) string { | ||
| 70 | + if !spinner.Styled() { | ||
| 71 | + return text | ||
| 72 | + } | ||
| 73 | + return "\033[2;3m" + text + "\033[0m" | ||
| 74 | +} | ||
modified
internal/tui/render.go +10 -5 | @@ -21,6 +21,7 @@ const ( | ||
| 21 | 21 | styleCode |
| 22 | 22 | styleDiffAdd |
| 23 | 23 | styleDiffDel |
| 24 | + styleThinking | |
| 24 | 25 | ) |
| 25 | 26 | |
| 26 | 27 | // Line is one drawn line of the conversation: its text, wrapped to the width |
| @@ -54,18 +55,22 @@ func layout(entries []Entry, agentName string, width, previewLines int) []Line { | ||
| 54 | 55 | // linesOf lays one entry out. One switch so a reader can see every kind is |
| 55 | 56 | // covered. |
| 56 | 57 | func linesOf(e Entry, agentName string, width, previewLines int) []Line { |
| 58 | + var lines []Line | |
| 57 | 59 | switch e.Kind { |
| 58 | 60 | case KindUser: |
| 59 | - return append(label("you"), prose(e.Text, width, styleBody)...) | |
| 61 | + lines = append(label("you"), prose(e.Text, width, styleBody)...) | |
| 60 | 62 | case KindAgent: |
| 61 | - return append(label(agentName), body(e.Text, width)...) | |
| 63 | + lines = append(label(agentName), body(e.Text, width)...) | |
| 62 | 64 | case KindTool: |
| 63 | - return toolLines(e, width, previewLines) | |
| 65 | + lines = toolLines(e, width, previewLines) | |
| 66 | + case KindThinking: | |
| 67 | + lines = append(label("thinking"), prose(e.Text, width, styleThinking)...) | |
| 64 | 68 | case KindError: |
| 65 | - return prose(e.Text, width, styleError) | |
| 69 | + lines = prose(e.Text, width, styleError) | |
| 66 | 70 | default: |
| 67 | - return prose(e.Text, width, styleNotice) | |
| 71 | + lines = prose(e.Text, width, styleNotice) | |
| 68 | 72 | } |
| 73 | + return lines | |
| 69 | 74 | } |
| 70 | 75 | |
| 71 | 76 | // label is the one line naming who is speaking. |
| @@ -21,6 +21,7 @@ const ( | |||
| 21 | styleCode | 21 | styleCode |
| 22 | styleDiffAdd | 22 | styleDiffAdd |
| 23 | styleDiffDel | 23 | styleDiffDel |
| 24 | + styleThinking | ||
| 24 | ) | 25 | ) |
| 25 | 26 | ||
| 26 | // Line is one drawn line of the conversation: its text, wrapped to the width | 27 | // Line is one drawn line of the conversation: its text, wrapped to the width |
| @@ -54,18 +55,22 @@ func layout(entries []Entry, agentName string, width, previewLines int) []Line { | |||
| 54 | // linesOf lays one entry out. One switch so a reader can see every kind is | 55 | // linesOf lays one entry out. One switch so a reader can see every kind is |
| 55 | // covered. | 56 | // covered. |
| 56 | func linesOf(e Entry, agentName string, width, previewLines int) []Line { | 57 | func linesOf(e Entry, agentName string, width, previewLines int) []Line { |
| 58 | + var lines []Line | ||
| 57 | switch e.Kind { | 59 | switch e.Kind { |
| 58 | case KindUser: | 60 | case KindUser: |
| 59 | - return append(label("you"), prose(e.Text, width, styleBody)...) | 61 | + lines = append(label("you"), prose(e.Text, width, styleBody)...) |
| 60 | case KindAgent: | 62 | case KindAgent: |
| 61 | - return append(label(agentName), body(e.Text, width)...) | 63 | + lines = append(label(agentName), body(e.Text, width)...) |
| 62 | case KindTool: | 64 | case KindTool: |
| 63 | - return toolLines(e, width, previewLines) | 65 | + lines = toolLines(e, width, previewLines) |
| 66 | + case KindThinking: | ||
| 67 | + lines = append(label("thinking"), prose(e.Text, width, styleThinking)...) | ||
| 64 | case KindError: | 68 | case KindError: |
| 65 | - return prose(e.Text, width, styleError) | 69 | + lines = prose(e.Text, width, styleError) |
| 66 | default: | 70 | default: |
| 67 | - return prose(e.Text, width, styleNotice) | 71 | + lines = prose(e.Text, width, styleNotice) |
| 68 | } | 72 | } |
| 73 | + return lines | ||
| 69 | } | 74 | } |
| 70 | 75 | ||
| 71 | // label is the one line naming who is speaking. | 76 | // label is the one line naming who is speaking. |
modified
internal/tui/sink.go +10 -0 | @@ -21,6 +21,16 @@ func (k *sink) Text(text string) { | ||
| 21 | 21 | k.r.wake() |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | +// Reasoning streams a thinking model's reasoning into its own entry, which | |
| 25 | +// the conversation draws dim and italic — an aside, visibly not the answer. | |
| 26 | +func (k *sink) Reasoning(text string) { | |
| 27 | + if !config.Cfg.ShowThinking { | |
| 28 | + return | |
| 29 | + } | |
| 30 | + k.r.t.Thinking(text) | |
| 31 | + k.r.wake() | |
| 32 | +} | |
| 33 | + | |
| 24 | 34 | func (k *sink) ToolStart(ev ui.ToolEvent) { |
| 25 | 35 | k.r.t.ToolStart(ev) |
| 26 | 36 | k.r.wake() |
| @@ -21,6 +21,16 @@ func (k *sink) Text(text string) { | |||
| 21 | k.r.wake() | 21 | k.r.wake() |
| 22 | } | 22 | } |
| 23 | 23 | ||
| 24 | +// Reasoning streams a thinking model's reasoning into its own entry, which | ||
| 25 | +// the conversation draws dim and italic — an aside, visibly not the answer. | ||
| 26 | +func (k *sink) Reasoning(text string) { | ||
| 27 | + if !config.Cfg.ShowThinking { | ||
| 28 | + return | ||
| 29 | + } | ||
| 30 | + k.r.t.Thinking(text) | ||
| 31 | + k.r.wake() | ||
| 32 | +} | ||
| 33 | + | ||
| 24 | func (k *sink) ToolStart(ev ui.ToolEvent) { | 34 | func (k *sink) ToolStart(ev ui.ToolEvent) { |
| 25 | k.r.t.ToolStart(ev) | 35 | k.r.t.ToolStart(ev) |
| 26 | k.r.wake() | 36 | k.r.wake() |
modified
internal/tui/style.go +2 -0 | @@ -23,6 +23,7 @@ var ( | ||
| 23 | 23 | stPick = tcell.StyleDefault.Reverse(true) |
| 24 | 24 | stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow) |
| 25 | 25 | stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal) |
| 26 | + stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple) | |
| 26 | 27 | stScroll = tcell.StyleDefault.Dim(true) |
| 27 | 28 | stThumb = tcell.StyleDefault.Reverse(true) |
| 28 | 29 | ) |
| @@ -39,6 +40,7 @@ var styles = map[styleKind]tcell.Style{ | ||
| 39 | 40 | styleCode: stCode, |
| 40 | 41 | styleDiffAdd: stDiffAdd, |
| 41 | 42 | styleDiffDel: stDiffDel, |
| 43 | + styleThinking: stThinking, | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | // styleOf returns the screen style of a laid-out line's kind. |
| @@ -23,6 +23,7 @@ var ( | |||
| 23 | stPick = tcell.StyleDefault.Reverse(true) | 23 | stPick = tcell.StyleDefault.Reverse(true) |
| 24 | stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow) | 24 | stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow) |
| 25 | stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal) | 25 | stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal) |
| 26 | + stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple) | ||
| 26 | stScroll = tcell.StyleDefault.Dim(true) | 27 | stScroll = tcell.StyleDefault.Dim(true) |
| 27 | stThumb = tcell.StyleDefault.Reverse(true) | 28 | stThumb = tcell.StyleDefault.Reverse(true) |
| 28 | ) | 29 | ) |
| @@ -39,6 +40,7 @@ var styles = map[styleKind]tcell.Style{ | |||
| 39 | styleCode: stCode, | 40 | styleCode: stCode, |
| 40 | styleDiffAdd: stDiffAdd, | 41 | styleDiffAdd: stDiffAdd, |
| 41 | styleDiffDel: stDiffDel, | 42 | styleDiffDel: stDiffDel, |
| 43 | + styleThinking: stThinking, | ||
| 42 | } | 44 | } |
| 43 | 45 | ||
| 44 | // styleOf returns the screen style of a laid-out line's kind. | 46 | // styleOf returns the screen style of a laid-out line's kind. |
added
internal/tui/thinking_test.go +80 -0 | new file mode 100644 | ||
| @@ -0,0 +1,80 @@ | ||
| 1 | +package tui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "mm/internal/config" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// Reasoning grows ONE entry, as the answer does: a thinking model sends it a | |
| 11 | +// word at a time, and a transcript of four hundred one-word entries is not a | |
| 12 | +// conversation. | |
| 13 | +func TestThinkingGrowsOneEntry(t *testing.T) { | |
| 14 | + tr := &Transcript{} | |
| 15 | + tr.Thinking("weighing ") | |
| 16 | + tr.Thinking("the options") | |
| 17 | + tr.Thinking("") | |
| 18 | + | |
| 19 | + got := tr.Entries() | |
| 20 | + if len(got) != 1 { | |
| 21 | + t.Fatalf("%d entries, want 1: %+v", len(got), got) | |
| 22 | + } | |
| 23 | + if got[0].Kind != KindThinking || got[0].Text != "weighing the options" { | |
| 24 | + t.Errorf("entry = %+v", got[0]) | |
| 25 | + } | |
| 26 | +} | |
| 27 | + | |
| 28 | +// Thinking and the answer are different things and must not merge. | |
| 29 | +func TestThinkingAndTheAnswerStaySeparate(t *testing.T) { | |
| 30 | + tr := &Transcript{} | |
| 31 | + tr.Thinking("first I weigh it") | |
| 32 | + tr.Chunk("then I answer") | |
| 33 | + | |
| 34 | + got := tr.Entries() | |
| 35 | + if len(got) != 2 || got[0].Kind != KindThinking || got[1].Kind != KindAgent { | |
| 36 | + t.Fatalf("entries = %+v, want a thought then an answer", got) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +// The conversation draws it with its own label and its own style — the whole | |
| 41 | +// point of the request: visibly an aside, not the answer. | |
| 42 | +func TestThinkingIsLaidOutAsAnAside(t *testing.T) { | |
| 43 | + lines := linesOf(Entry{Kind: KindThinking, Text: "weighing the options"}, "mm", 40, 20) | |
| 44 | + if len(lines) < 2 { | |
| 45 | + t.Fatalf("lines = %+v", lines) | |
| 46 | + } | |
| 47 | + if lines[0].Text != "‣ thinking" { | |
| 48 | + t.Errorf("label = %q", lines[0].Text) | |
| 49 | + } | |
| 50 | + for _, l := range lines[1:] { | |
| 51 | + if l.Style != styleThinking { | |
| 52 | + t.Errorf("line %q drawn as %v, want styleThinking", l.Text, l.Style) | |
| 53 | + } | |
| 54 | + } | |
| 55 | + if st := styleOf(styleThinking); st == stBody { | |
| 56 | + t.Error("styleThinking has no style of its own") | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +// showThinking: false hides it. The reasoning is still read off the wire — | |
| 61 | +// that is what keeps the watchdog alive — but nothing reaches the screen. | |
| 62 | +func TestShowThinkingFalseDrawsNothing(t *testing.T) { | |
| 63 | + saved := config.Cfg.ShowThinking | |
| 64 | + t.Cleanup(func() { config.Cfg.ShowThinking = saved }) | |
| 65 | + | |
| 66 | + r, tr := newTestRunner(t, &fakeModel{answer: "hi"}) | |
| 67 | + k := &sink{r: r} | |
| 68 | + | |
| 69 | + config.Cfg.ShowThinking = false | |
| 70 | + k.Reasoning("this must not show") | |
| 71 | + if n := len(tr.Entries()); n != 0 { | |
| 72 | + t.Fatalf("%d entries with showThinking off: %s", n, tr.Plain()) | |
| 73 | + } | |
| 74 | + | |
| 75 | + config.Cfg.ShowThinking = true | |
| 76 | + k.Reasoning("this must show") | |
| 77 | + if got := tr.Plain(); !strings.Contains(got, "this must show") { | |
| 78 | + t.Errorf("nothing shown with showThinking on: %q", got) | |
| 79 | + } | |
| 80 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,80 @@ | |||
| 1 | +package tui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "mm/internal/config" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// Reasoning grows ONE entry, as the answer does: a thinking model sends it a | ||
| 11 | +// word at a time, and a transcript of four hundred one-word entries is not a | ||
| 12 | +// conversation. | ||
| 13 | +func TestThinkingGrowsOneEntry(t *testing.T) { | ||
| 14 | + tr := &Transcript{} | ||
| 15 | + tr.Thinking("weighing ") | ||
| 16 | + tr.Thinking("the options") | ||
| 17 | + tr.Thinking("") | ||
| 18 | + | ||
| 19 | + got := tr.Entries() | ||
| 20 | + if len(got) != 1 { | ||
| 21 | + t.Fatalf("%d entries, want 1: %+v", len(got), got) | ||
| 22 | + } | ||
| 23 | + if got[0].Kind != KindThinking || got[0].Text != "weighing the options" { | ||
| 24 | + t.Errorf("entry = %+v", got[0]) | ||
| 25 | + } | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// Thinking and the answer are different things and must not merge. | ||
| 29 | +func TestThinkingAndTheAnswerStaySeparate(t *testing.T) { | ||
| 30 | + tr := &Transcript{} | ||
| 31 | + tr.Thinking("first I weigh it") | ||
| 32 | + tr.Chunk("then I answer") | ||
| 33 | + | ||
| 34 | + got := tr.Entries() | ||
| 35 | + if len(got) != 2 || got[0].Kind != KindThinking || got[1].Kind != KindAgent { | ||
| 36 | + t.Fatalf("entries = %+v, want a thought then an answer", got) | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +// The conversation draws it with its own label and its own style — the whole | ||
| 41 | +// point of the request: visibly an aside, not the answer. | ||
| 42 | +func TestThinkingIsLaidOutAsAnAside(t *testing.T) { | ||
| 43 | + lines := linesOf(Entry{Kind: KindThinking, Text: "weighing the options"}, "mm", 40, 20) | ||
| 44 | + if len(lines) < 2 { | ||
| 45 | + t.Fatalf("lines = %+v", lines) | ||
| 46 | + } | ||
| 47 | + if lines[0].Text != "‣ thinking" { | ||
| 48 | + t.Errorf("label = %q", lines[0].Text) | ||
| 49 | + } | ||
| 50 | + for _, l := range lines[1:] { | ||
| 51 | + if l.Style != styleThinking { | ||
| 52 | + t.Errorf("line %q drawn as %v, want styleThinking", l.Text, l.Style) | ||
| 53 | + } | ||
| 54 | + } | ||
| 55 | + if st := styleOf(styleThinking); st == stBody { | ||
| 56 | + t.Error("styleThinking has no style of its own") | ||
| 57 | + } | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// showThinking: false hides it. The reasoning is still read off the wire — | ||
| 61 | +// that is what keeps the watchdog alive — but nothing reaches the screen. | ||
| 62 | +func TestShowThinkingFalseDrawsNothing(t *testing.T) { | ||
| 63 | + saved := config.Cfg.ShowThinking | ||
| 64 | + t.Cleanup(func() { config.Cfg.ShowThinking = saved }) | ||
| 65 | + | ||
| 66 | + r, tr := newTestRunner(t, &fakeModel{answer: "hi"}) | ||
| 67 | + k := &sink{r: r} | ||
| 68 | + | ||
| 69 | + config.Cfg.ShowThinking = false | ||
| 70 | + k.Reasoning("this must not show") | ||
| 71 | + if n := len(tr.Entries()); n != 0 { | ||
| 72 | + t.Fatalf("%d entries with showThinking off: %s", n, tr.Plain()) | ||
| 73 | + } | ||
| 74 | + | ||
| 75 | + config.Cfg.ShowThinking = true | ||
| 76 | + k.Reasoning("this must show") | ||
| 77 | + if got := tr.Plain(); !strings.Contains(got, "this must show") { | ||
| 78 | + t.Errorf("nothing shown with showThinking on: %q", got) | ||
| 79 | + } | ||
| 80 | +} | ||
modified
internal/tui/transcript.go +19 -0 | @@ -24,6 +24,9 @@ const ( | ||
| 24 | 24 | KindNotice |
| 25 | 25 | // KindError is a failed turn or command, in one line. |
| 26 | 26 | KindError |
| 27 | + // KindThinking is what a thinking model streamed before its answer, | |
| 28 | + // chunk by chunk into ONE entry, as KindAgent is. | |
| 29 | + KindThinking | |
| 27 | 30 | ) |
| 28 | 31 | |
| 29 | 32 | // Tool statuses, as the transcript shows them. |
| @@ -136,6 +139,22 @@ func (t *Transcript) Chunk(text string) { | ||
| 136 | 139 | t.open = true |
| 137 | 140 | } |
| 138 | 141 | |
| 142 | +// Thinking appends a chunk of reasoning, growing the open thought entry | |
| 143 | +// rather than starting one per fragment — the same rule as Chunk, with its | |
| 144 | +// own kind so the conversation can tell an aside from an answer. | |
| 145 | +func (t *Transcript) Thinking(text string) { | |
| 146 | + if text == "" { | |
| 147 | + return | |
| 148 | + } | |
| 149 | + t.mu.Lock() | |
| 150 | + defer t.mu.Unlock() | |
| 151 | + if len(t.entries) > 0 && t.entries[len(t.entries)-1].Kind == KindThinking { | |
| 152 | + t.entries[len(t.entries)-1].Text += text | |
| 153 | + return | |
| 154 | + } | |
| 155 | + t.entries = append(t.entries, Entry{Kind: KindThinking, Text: text}) | |
| 156 | +} | |
| 157 | + | |
| 139 | 158 | // Close ends the agent run in progress: the next Chunk starts a new entry. |
| 140 | 159 | // Called when a turn ends. |
| 141 | 160 | func (t *Transcript) Close() { |
| @@ -24,6 +24,9 @@ const ( | |||
| 24 | KindNotice | 24 | KindNotice |
| 25 | // KindError is a failed turn or command, in one line. | 25 | // KindError is a failed turn or command, in one line. |
| 26 | KindError | 26 | KindError |
| 27 | + // KindThinking is what a thinking model streamed before its answer, | ||
| 28 | + // chunk by chunk into ONE entry, as KindAgent is. | ||
| 29 | + KindThinking | ||
| 27 | ) | 30 | ) |
| 28 | 31 | ||
| 29 | // Tool statuses, as the transcript shows them. | 32 | // Tool statuses, as the transcript shows them. |
| @@ -136,6 +139,22 @@ func (t *Transcript) Chunk(text string) { | |||
| 136 | t.open = true | 139 | t.open = true |
| 137 | } | 140 | } |
| 138 | 141 | ||
| 142 | +// Thinking appends a chunk of reasoning, growing the open thought entry | ||
| 143 | +// rather than starting one per fragment — the same rule as Chunk, with its | ||
| 144 | +// own kind so the conversation can tell an aside from an answer. | ||
| 145 | +func (t *Transcript) Thinking(text string) { | ||
| 146 | + if text == "" { | ||
| 147 | + return | ||
| 148 | + } | ||
| 149 | + t.mu.Lock() | ||
| 150 | + defer t.mu.Unlock() | ||
| 151 | + if len(t.entries) > 0 && t.entries[len(t.entries)-1].Kind == KindThinking { | ||
| 152 | + t.entries[len(t.entries)-1].Text += text | ||
| 153 | + return | ||
| 154 | + } | ||
| 155 | + t.entries = append(t.entries, Entry{Kind: KindThinking, Text: text}) | ||
| 156 | +} | ||
| 157 | + | ||
| 139 | // Close ends the agent run in progress: the next Chunk starts a new entry. | 158 | // Close ends the agent run in progress: the next Chunk starts a new entry. |
| 140 | // Called when a turn ends. | 159 | // Called when a turn ends. |
| 141 | func (t *Transcript) Close() { | 160 | func (t *Transcript) Close() { |
modified
internal/ui/ui.go +5 -0 | @@ -62,6 +62,11 @@ type Diff struct { | ||
| 62 | 62 | type Sink interface { |
| 63 | 63 | // Text is a chunk of the model's streamed answer. |
| 64 | 64 | Text(text string) |
| 65 | + // Reasoning is a chunk of what a thinking model streamed BEFORE its | |
| 66 | + // answer. It is not the answer and must not be shown as one; it is never | |
| 67 | + // added to the history either — the model does not read its own thoughts | |
| 68 | + // back. | |
| 69 | + Reasoning(text string) | |
| 65 | 70 | // ToolStart announces a call before anything runs (ACP: status "pending"). |
| 66 | 71 | ToolStart(ev ToolEvent) |
| 67 | 72 | // Allow asks whether the call may run. The terminal front end has no |
| @@ -62,6 +62,11 @@ type Diff struct { | |||
| 62 | type Sink interface { | 62 | type Sink interface { |
| 63 | // Text is a chunk of the model's streamed answer. | 63 | // Text is a chunk of the model's streamed answer. |
| 64 | Text(text string) | 64 | Text(text string) |
| 65 | + // Reasoning is a chunk of what a thinking model streamed BEFORE its | ||
| 66 | + // answer. It is not the answer and must not be shown as one; it is never | ||
| 67 | + // added to the history either — the model does not read its own thoughts | ||
| 68 | + // back. | ||
| 69 | + Reasoning(text string) | ||
| 65 | // ToolStart announces a call before anything runs (ACP: status "pending"). | 70 | // ToolStart announces a call before anything runs (ACP: status "pending"). |
| 66 | ToolStart(ev ToolEvent) | 71 | ToolStart(ev ToolEvent) |
| 67 | // Allow asks whether the call may run. The terminal front end has no | 72 | // Allow asks whether the call may run. The terminal front end has no |
modified
internal/ui/ui_test.go +1 -0 | @@ -10,6 +10,7 @@ import ( | ||
| 10 | 10 | type stubSink struct{ cwd string } |
| 11 | 11 | |
| 12 | 12 | func (stubSink) Text(string) {} |
| 13 | +func (stubSink) Reasoning(string) {} | |
| 13 | 14 | func (stubSink) ToolStart(ToolEvent) {} |
| 14 | 15 | func (stubSink) Allow(context.Context, ToolEvent) bool { return true } |
| 15 | 16 | func (stubSink) ToolRunning(string) {} |
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | type stubSink struct{ cwd string } | 10 | type stubSink struct{ cwd string } |
| 11 | 11 | ||
| 12 | func (stubSink) Text(string) {} | 12 | func (stubSink) Text(string) {} |
| 13 | +func (stubSink) Reasoning(string) {} | ||
| 13 | func (stubSink) ToolStart(ToolEvent) {} | 14 | func (stubSink) ToolStart(ToolEvent) {} |
| 14 | func (stubSink) Allow(context.Context, ToolEvent) bool { return true } | 15 | func (stubSink) Allow(context.Context, ToolEvent) bool { return true } |
| 15 | func (stubSink) ToolRunning(string) {} | 16 | func (stubSink) ToolRunning(string) {} |
modified
tests/agent.dmr-gemma.yaml +6 -0 | @@ -40,6 +40,12 @@ sessions: | ||
| 40 | 40 | previewLines: 20 |
| 41 | 41 | |
| 42 | 42 | displayCommands: true |
| 43 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 44 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 45 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 46 | +# model thinks — this only says whether to show it. | |
| 47 | +showThinking: true | |
| 48 | + | |
| 43 | 49 | |
| 44 | 50 | # The system prompt: what the agent is, and what it is allowed to do. |
| 45 | 51 | # This is the knob to play with — it is the shortest path to changing behaviour. |
| @@ -40,6 +40,12 @@ sessions: | |||
| 40 | previewLines: 20 | 40 | previewLines: 20 |
| 41 | 41 | ||
| 42 | displayCommands: true | 42 | displayCommands: true |
| 43 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 44 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 45 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 46 | +# model thinks — this only says whether to show it. | ||
| 47 | +showThinking: true | ||
| 48 | + | ||
| 43 | 49 | ||
| 44 | # The system prompt: what the agent is, and what it is allowed to do. | 50 | # The system prompt: what the agent is, and what it is allowed to do. |
| 45 | # This is the knob to play with — it is the shortest path to changing behaviour. | 51 | # This is the knob to play with — it is the shortest path to changing behaviour. |
modified
tests/agent.dmr-qwen.yaml +6 -0 | @@ -40,6 +40,12 @@ sessions: | ||
| 40 | 40 | previewLines: 20 |
| 41 | 41 | |
| 42 | 42 | displayCommands: true |
| 43 | +# Show what a thinking model reasons about before it answers (dim italic in the | |
| 44 | +# terminal, its own style in the window, a thought chunk in an editor). | |
| 45 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | |
| 46 | +# model thinks — this only says whether to show it. | |
| 47 | +showThinking: true | |
| 48 | + | |
| 43 | 49 | |
| 44 | 50 | # The system prompt: what the agent is, and what it is allowed to do. |
| 45 | 51 | # This is the knob to play with — it is the shortest path to changing behaviour. |
| @@ -40,6 +40,12 @@ sessions: | |||
| 40 | previewLines: 20 | 40 | previewLines: 20 |
| 41 | 41 | ||
| 42 | displayCommands: true | 42 | displayCommands: true |
| 43 | +# Show what a thinking model reasons about before it answers (dim italic in the | ||
| 44 | +# terminal, its own style in the window, a thought chunk in an editor). | ||
| 45 | +# The reasoning is always READ — it is what keeps the watchdog alive while the | ||
| 46 | +# model thinks — this only says whether to show it. | ||
| 47 | +showThinking: true | ||
| 48 | + | ||
| 43 | 49 | ||
| 44 | # The system prompt: what the agent is, and what it is allowed to do. | 50 | # The system prompt: what the agent is, and what it is allowed to do. |
| 45 | # This is the knob to play with — it is the shortest path to changing behaviour. | 51 | # This is the knob to play with — it is the shortest path to changing behaviour. |