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

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

Download ZIP

🛟 Updated.

k33g committed 2026-09-21T06:22:47+02:00 Browse files
0394624 parent: 9136e38
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: []
modified agent.dmr.yaml +6 -0
@@ -165,3 +165,9 @@ context:
165165 showStats: true
166166
167167 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: true165 showStats: true
166 166
167 displayCommands: true167 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:
7171 previewLines: 20
7272
7373 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+
7480
7581 # The system prompt: what the agent is, and what it is allowed to do.
7682 # This is the knob to play with — it is the shortest path to changing behaviour.
@@ -71,6 +71,12 @@ sessions:
71 previewLines: 2071 previewLines: 20
72 72
73 displayCommands: true73 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:
201201 showStats: true
202202
203203 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: true201 showStats: true
202 202
203 displayCommands: true203 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)
3636 - [Context compression](explanation/context-compression.md)
3737 - [Persistent sessions](explanation/persistent-sessions.md) — files per project, why a start is a new session, `session/load` versus docker agent's `session/resume`
3838 - [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
3940 - Package diagram (draw.io source): [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
4041
4142 ## 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 permission38 - [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 ## Scope42 ## 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
99 | Package | Responsibility |
1010 |---------|----------------|
1111 | `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)). |
1313 | `tools` | The built-in tools the model can call. |
1414 | `fileedit` | Exact-replacement file editing, the rules behind `edit_file`. |
1515 | `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/
9494 | Spinner | Disabled; the rule under the conversation animates instead. |
9595 | 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). |
9696 | 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`. |
9798 | 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)]`. |
9899 | 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. |
99100 | 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 @@
1616 | `maxTurns` | int | `10` | Maximum model ↔ tools round trips per question. Must be > 0. |
1717 | `previewLines` | int | `20` | Lines of a command's output echoed to the terminal. `0` disables the echo. Must be ≥ 0. |
1818 | `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). |
1920 | `system` | string | built-in prompt | The system prompt, first message of every history. |
2021 | `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. |
2122 | `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:
3636 - [Compression du contexte](explanation/context-compression.md)
3737 - [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
3838 - [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
3940 - Diagramme des paquets (source draw.io) : [`docs/diagrams/packages.drawio`](../diagrams/packages.drawio)
4041
4142 ## 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 agent37 - [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 à permission38 - [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ètre42 ## 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
99 | Paquet | Responsabilité |
1010 |--------|----------------|
1111 | `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)). |
1313 | `tools` | Les outils intégrés que le modèle peut appeler. |
1414 | `fileedit` | L'édition de fichiers par remplacement exact, les règles derrière `edit_file`. |
1515 | `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/
9494 | Spinner | Désactivé ; la règle sous la conversation s'anime à sa place. |
9595 | 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). |
9696 | 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`. |
9798 | 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)]`. |
9899 | 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. |
99100 | 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 @@
1616 | `maxTurns` | entier | `10` | Nombre maximal d'allers-retours modèle ↔ outils par question. Doit être > 0. |
1717 | `previewLines` | entier | `20` | Lignes de la sortie d'une commande affichées dans le terminal. `0` désactive l'affichage. Doit être ≥ 0. |
1818 | `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). |
1920 | `system` | chaîne | prompt intégré | Le prompt système, premier message de chaque historique. |
2021 | `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é. |
2122 | `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
33 import (
44 "context"
55
6+ "mm/internal/config"
67 "mm/internal/ui"
78
89 sdk "github.com/coder/acp-go-sdk"
@@ -35,6 +36,18 @@ func (k *sink) Text(text string) {
3536 k.send(sdk.UpdateAgentMessageText(text))
3637 }
3738
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+
3851 // WorkDir is the session's cwd — the project the editor opened. bash runs
3952 // there, and relative file paths resolve against it (ui.Resolve).
4053 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 runs51 // 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 {
6868 // WHAT it did — worth showing on a screen, noise in a log.
6969 DisplayCommands bool `yaml:"displayCommands"`
7070
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+
7178 // System: the system prompt — what the agent is, and what it may do.
7279 System string `yaml:"system"`
7380
@@ -240,6 +247,7 @@ var Cfg = Config{
240247 // Off by default: the recap is a demo/debug aid, not something every run
241248 // needs. `displayCommands: true` in the YAML turns it on.
242249 DisplayCommands: false,
250+ ShowThinking: true,
243251 SkillsDir: "skills",
244252 BashTool: true,
245253 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 run247 // 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
242242 // right level.
243243 var full []*ai.Message
244244 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{}
245248 capture := ai.MiddlewareFunc(func(context.Context) (*ai.Hooks, error) {
246249 return &ai.Hooks{
247250 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
288291 if text == "" {
289292 return
290293 }
294+ think.done()
291295 printed = true
292296 s.Text(text)
293297 return
294298 }
299+ // The answer has started: the thinking that led to it is over.
300+ think.done()
295301 // Trailing whitespace is held back, not printed: see the gap type.
296302 out := gaps.next(text)
297303 if out == "" {
@@ -322,6 +328,19 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
322328 genCtx, beat, cancelGen := watchdog(ctx, config.Cfg.WatchdogTimeout)
323329 defer cancelGen()
324330
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+
325344 spinner.Start("Thinking")
326345 defer spinner.Stop()
327346
@@ -381,6 +400,8 @@ func (e *Engine) Generate(ctx context.Context, messages []*ai.Message, tools []a
381400 }
382401 }
383402
403+ think.done()
404+
384405 // `full` stops at the last call to the model; its answer is appended after.
385406 if resp != nil && resp.Message != nil {
386407 full = append(full, resp.Message)
@@ -451,6 +472,10 @@ func (e *Engine) Summarize(ctx context.Context, messages []*ai.Message, maxToken
451472
452473 genCtx, beat, cancel := watchdog(ctx, 2*config.Cfg.WatchdogTimeout)
453474 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() })
454479
455480 resp, err := genkit.Generate(genCtx, e.G,
456481 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.Message243 var full []*ai.Message
244 var gaps gap244 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 return292 return
290 }293 }
294+ think.done()
291 printed = true295 printed = true
292 s.Text(text)296 s.Text(text)
293 return297 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 (
1414 "github.com/firebase/genkit/go/ai"
1515 "github.com/firebase/genkit/go/genkit"
1616 "github.com/firebase/genkit/go/plugins/compat_oai"
17+ "github.com/openai/openai-go/option"
1718 )
1819
1920 // 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
129130 Provider: p.name, // model prefix: "<provider>/<id>"
130131 BaseURL: b.BaseURL,
131132 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)},
132137 }
133138 g := genkit.Init(ctx, genkit.WithPlugins(client))
134139 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 OpenAI20 // 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 (
2121 styleCode
2222 styleDiffAdd
2323 styleDiffDel
24+ styleThinking
2425 )
2526
2627 // 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 {
5455 // linesOf lays one entry out. One switch so a reader can see every kind is
5556 // covered.
5657 func linesOf(e Entry, agentName string, width, previewLines int) []Line {
58+ var lines []Line
5759 switch e.Kind {
5860 case KindUser:
59- return append(label("you"), prose(e.Text, width, styleBody)...)
61+ lines = append(label("you"), prose(e.Text, width, styleBody)...)
6062 case KindAgent:
61- return append(label(agentName), body(e.Text, width)...)
63+ lines = append(label(agentName), body(e.Text, width)...)
6264 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)...)
6468 case KindError:
65- return prose(e.Text, width, styleError)
69+ lines = prose(e.Text, width, styleError)
6670 default:
67- return prose(e.Text, width, styleNotice)
71+ lines = prose(e.Text, width, styleNotice)
6872 }
73+ return lines
6974 }
7075
7176 // label is the one line naming who is speaking.
@@ -21,6 +21,7 @@ const (
21 styleCode21 styleCode
22 styleDiffAdd22 styleDiffAdd
23 styleDiffDel23 styleDiffDel
24+ styleThinking
24 )25 )
25 26
26 // Line is one drawn line of the conversation: its text, wrapped to the width27 // 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 is55 // 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) {
2121 k.r.wake()
2222 }
2323
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+
2434 func (k *sink) ToolStart(ev ui.ToolEvent) {
2535 k.r.t.ToolStart(ev)
2636 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 (
2323 stPick = tcell.StyleDefault.Reverse(true)
2424 stAsk = tcell.StyleDefault.Bold(true).Foreground(tcell.ColorYellow)
2525 stPaste = tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorTeal)
26+ stThinking = tcell.StyleDefault.Dim(true).Italic(true).Foreground(tcell.ColorPurple)
2627 stScroll = tcell.StyleDefault.Dim(true)
2728 stThumb = tcell.StyleDefault.Reverse(true)
2829 )
@@ -39,6 +40,7 @@ var styles = map[styleKind]tcell.Style{
3940 styleCode: stCode,
4041 styleDiffAdd: stDiffAdd,
4142 styleDiffDel: stDiffDel,
43+ styleThinking: stThinking,
4244 }
4345
4446 // 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 (
2424 KindNotice
2525 // KindError is a failed turn or command, in one line.
2626 KindError
27+ // KindThinking is what a thinking model streamed before its answer,
28+ // chunk by chunk into ONE entry, as KindAgent is.
29+ KindThinking
2730 )
2831
2932 // Tool statuses, as the transcript shows them.
@@ -136,6 +139,22 @@ func (t *Transcript) Chunk(text string) {
136139 t.open = true
137140 }
138141
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+
139158 // Close ends the agent run in progress: the next Chunk starts a new entry.
140159 // Called when a turn ends.
141160 func (t *Transcript) Close() {
@@ -24,6 +24,9 @@ const (
24 KindNotice24 KindNotice
25 // KindError is a failed turn or command, in one line.25 // KindError is a failed turn or command, in one line.
26 KindError26 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 = true139 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 {
6262 type Sink interface {
6363 // Text is a chunk of the model's streamed answer.
6464 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)
6570 // ToolStart announces a call before anything runs (ACP: status "pending").
6671 ToolStart(ev ToolEvent)
6772 // 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 no72 // 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 (
1010 type stubSink struct{ cwd string }
1111
1212 func (stubSink) Text(string) {}
13+func (stubSink) Reasoning(string) {}
1314 func (stubSink) ToolStart(ToolEvent) {}
1415 func (stubSink) Allow(context.Context, ToolEvent) bool { return true }
1516 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:
4040 previewLines: 20
4141
4242 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+
4349
4450 # The system prompt: what the agent is, and what it is allowed to do.
4551 # This is the knob to play with — it is the shortest path to changing behaviour.
@@ -40,6 +40,12 @@ sessions:
40 previewLines: 2040 previewLines: 20
41 41
42 displayCommands: true42 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:
4040 previewLines: 20
4141
4242 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+
4349
4450 # The system prompt: what the agent is, and what it is allowed to do.
4551 # This is the knob to play with — it is the shortest path to changing behaviour.
@@ -40,6 +40,12 @@ sessions:
40 previewLines: 2040 previewLines: 20
41 41
42 displayCommands: true42 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.