bots-garden/mini-mepublic Fork
⑂ 2778dac
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-21T21:41:53+02:00 Browse files
2778dac parent: 11f470a
modified .tickets/issues/0004-do-we-need-mcp.yaml +4 -4
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-15T22:38:45.129Z
9-updatedAt: 2026-09-21T05:45:00.000Z
9+updatedAt: 2026-09-21T19:04:58.320Z
1010 labels: []
11-milestone: v0.0.3
11+milestone: v0.04
1212 body: |
1313 I would say no - use a CLI
1414
@@ -91,7 +91,7 @@ body: |
9191 `plugins/mcp` also has a `server.go`: **mm exposing its own tools over MCP** is a different and cheaper question, and deserves its own ticket rather than being folded into this one.
9292 tasks:
9393 - id: 1
94- title: "Run the tool-count experiment (6 → 16 → 26 dummy tools) and record where the model degrades"
94+ title: Run the tool-count experiment (6 → 16 → 26 dummy tools) and record where the model degrades
9595 state: open
9696 priority: medium
9797 author:
@@ -99,7 +99,7 @@ tasks:
9999 email: ph.charriere@gmail.com
100100 createdAt: 2026-09-21T05:45:00.000Z
101101 - id: 2
102- title: "Close this ticket on that evidence, recording the falsifiable reason rather than 'MCP is bloat'"
102+ title: Close this ticket on that evidence, recording the falsifiable reason rather than 'MCP is bloat'
103103 state: open
104104 priority: medium
105105 author:
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-15T22:38:45.129Z8 createdAt: 2026-09-15T22:38:45.129Z
9-updatedAt: 2026-09-21T05:45:00.000Z9+updatedAt: 2026-09-21T19:04:58.320Z
10 labels: []10 labels: []
11-milestone: v0.0.311+milestone: v0.04
12 body: |12 body: |
13 I would say no - use a CLI13 I would say no - use a CLI
14 14
@@ -91,7 +91,7 @@ body: |
91 `plugins/mcp` also has a `server.go`: **mm exposing its own tools over MCP** is a different and cheaper question, and deserves its own ticket rather than being folded into this one.91 `plugins/mcp` also has a `server.go`: **mm exposing its own tools over MCP** is a different and cheaper question, and deserves its own ticket rather than being folded into this one.
92 tasks:92 tasks:
93 - id: 193 - id: 1
94- title: "Run the tool-count experiment (6 → 16 → 26 dummy tools) and record where the model degrades"94+ title: Run the tool-count experiment (6 → 16 → 26 dummy tools) and record where the model degrades
95 state: open95 state: open
96 priority: medium96 priority: medium
97 author:97 author:
@@ -99,7 +99,7 @@ tasks:
99 email: ph.charriere@gmail.com99 email: ph.charriere@gmail.com
100 createdAt: 2026-09-21T05:45:00.000Z100 createdAt: 2026-09-21T05:45:00.000Z
101 - id: 2101 - id: 2
102- title: "Close this ticket on that evidence, recording the falsifiable reason rather than 'MCP is bloat'"102+ title: Close this ticket on that evidence, recording the falsifiable reason rather than 'MCP is bloat'
103 state: open103 state: open
104 priority: medium104 priority: medium
105 author:105 author:
modified .tickets/issues/0030-how-to-use-mini-me-with-claude.yaml +5 -5
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T04:00:56.971Z
9-updatedAt: 2026-09-21T05:45:00.000Z
9+updatedAt: 2026-09-21T19:03:27.354Z
1010 labels: []
11-milestone: v0.0.3
11+milestone: v0.04
1212 body: |
1313 ## Analysis (Claude, 2026-09-21 — not implemented)
1414
@@ -90,7 +90,7 @@ tasks:
9090 email: ph.charriere@gmail.com
9191 createdAt: 2026-09-21T05:45:00.000Z
9292 - id: 2
93- title: "Decide Route A (registry entry) or Route B (native plugins/anthropic) on that result"
93+ title: Decide Route A (registry entry) or Route B (native plugins/anthropic) on that result
9494 state: open
9595 priority: high
9696 author:
@@ -106,7 +106,7 @@ tasks:
106106 email: ph.charriere@gmail.com
107107 createdAt: 2026-09-21T05:45:00.000Z
108108 - id: 4
109- title: "probeRemote with the anthropic-version header, so the banner stops crying wolf"
109+ title: probeRemote with the anthropic-version header, so the banner stops crying wolf
110110 state: open
111111 priority: medium
112112 author:
@@ -114,7 +114,7 @@ tasks:
114114 email: ph.charriere@gmail.com
115115 createdAt: 2026-09-21T05:45:00.000Z
116116 - id: 5
117- title: "agent.claude.yaml and EN+FR how-to + providers/configuration docs"
117+ title: agent.claude.yaml and EN+FR how-to + providers/configuration docs
118118 state: open
119119 priority: low
120120 author:
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T04:00:56.971Z8 createdAt: 2026-09-21T04:00:56.971Z
9-updatedAt: 2026-09-21T05:45:00.000Z9+updatedAt: 2026-09-21T19:03:27.354Z
10 labels: []10 labels: []
11-milestone: v0.0.311+milestone: v0.04
12 body: |12 body: |
13 ## Analysis (Claude, 2026-09-21 — not implemented)13 ## Analysis (Claude, 2026-09-21 — not implemented)
14 14
@@ -90,7 +90,7 @@ tasks:
90 email: ph.charriere@gmail.com90 email: ph.charriere@gmail.com
91 createdAt: 2026-09-21T05:45:00.000Z91 createdAt: 2026-09-21T05:45:00.000Z
92 - id: 292 - id: 2
93- title: "Decide Route A (registry entry) or Route B (native plugins/anthropic) on that result"93+ title: Decide Route A (registry entry) or Route B (native plugins/anthropic) on that result
94 state: open94 state: open
95 priority: high95 priority: high
96 author:96 author:
@@ -106,7 +106,7 @@ tasks:
106 email: ph.charriere@gmail.com106 email: ph.charriere@gmail.com
107 createdAt: 2026-09-21T05:45:00.000Z107 createdAt: 2026-09-21T05:45:00.000Z
108 - id: 4108 - id: 4
109- title: "probeRemote with the anthropic-version header, so the banner stops crying wolf"109+ title: probeRemote with the anthropic-version header, so the banner stops crying wolf
110 state: open110 state: open
111 priority: medium111 priority: medium
112 author:112 author:
@@ -114,7 +114,7 @@ tasks:
114 email: ph.charriere@gmail.com114 email: ph.charriere@gmail.com
115 createdAt: 2026-09-21T05:45:00.000Z115 createdAt: 2026-09-21T05:45:00.000Z
116 - id: 5116 - id: 5
117- title: "agent.claude.yaml and EN+FR how-to + providers/configuration docs"117+ title: agent.claude.yaml and EN+FR how-to + providers/configuration docs
118 state: open118 state: open
119 priority: low119 priority: low
120 author:120 author:
modified .tickets/issues/0031-how-to-use-mini-me-with-gemini.yaml +5 -5
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T04:01:15.166Z
9-updatedAt: 2026-09-21T05:45:00.000Z
9+updatedAt: 2026-09-21T19:04:28.691Z
1010 labels: []
11-milestone: v0.0.3
11+milestone: v0.04
1212 body: |
1313 ## Analysis (Claude, 2026-09-21 — not implemented)
1414
@@ -67,7 +67,7 @@ body: |
6767 5. Metered-API guardrails: `maxTurns: 40` plus a `bash` tool is a different proposition when every turn is billed.
6868 tasks:
6969 - id: 1
70- title: "Registry entry for the Gemini OpenAI-compatible endpoint (baseURL, GEMINI_API_KEY, keyRequired)"
70+ title: Registry entry for the Gemini OpenAI-compatible endpoint (baseURL, GEMINI_API_KEY, keyRequired)
7171 state: open
7272 priority: medium
7373 author:
@@ -83,7 +83,7 @@ tasks:
8383 email: ph.charriere@gmail.com
8484 createdAt: 2026-09-21T05:45:00.000Z
8585 - id: 3
86- title: "Check whether the endpoint streams reasoning, and whether reasoningTap sees it"
86+ title: Check whether the endpoint streams reasoning, and whether reasoningTap sees it
8787 state: open
8888 priority: high
8989 author:
@@ -99,7 +99,7 @@ tasks:
9999 email: ph.charriere@gmail.com
100100 createdAt: 2026-09-21T05:45:00.000Z
101101 - id: 5
102- title: "agent.gemini.yaml with an explicit contextWindow, and EN+FR how-to + providers/configuration docs"
102+ title: agent.gemini.yaml with an explicit contextWindow, and EN+FR how-to + providers/configuration docs
103103 state: open
104104 priority: low
105105 author:
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T04:01:15.166Z8 createdAt: 2026-09-21T04:01:15.166Z
9-updatedAt: 2026-09-21T05:45:00.000Z9+updatedAt: 2026-09-21T19:04:28.691Z
10 labels: []10 labels: []
11-milestone: v0.0.311+milestone: v0.04
12 body: |12 body: |
13 ## Analysis (Claude, 2026-09-21 — not implemented)13 ## Analysis (Claude, 2026-09-21 — not implemented)
14 14
@@ -67,7 +67,7 @@ body: |
67 5. Metered-API guardrails: `maxTurns: 40` plus a `bash` tool is a different proposition when every turn is billed.67 5. Metered-API guardrails: `maxTurns: 40` plus a `bash` tool is a different proposition when every turn is billed.
68 tasks:68 tasks:
69 - id: 169 - id: 1
70- title: "Registry entry for the Gemini OpenAI-compatible endpoint (baseURL, GEMINI_API_KEY, keyRequired)"70+ title: Registry entry for the Gemini OpenAI-compatible endpoint (baseURL, GEMINI_API_KEY, keyRequired)
71 state: open71 state: open
72 priority: medium72 priority: medium
73 author:73 author:
@@ -83,7 +83,7 @@ tasks:
83 email: ph.charriere@gmail.com83 email: ph.charriere@gmail.com
84 createdAt: 2026-09-21T05:45:00.000Z84 createdAt: 2026-09-21T05:45:00.000Z
85 - id: 385 - id: 3
86- title: "Check whether the endpoint streams reasoning, and whether reasoningTap sees it"86+ title: Check whether the endpoint streams reasoning, and whether reasoningTap sees it
87 state: open87 state: open
88 priority: high88 priority: high
89 author:89 author:
@@ -99,7 +99,7 @@ tasks:
99 email: ph.charriere@gmail.com99 email: ph.charriere@gmail.com
100 createdAt: 2026-09-21T05:45:00.000Z100 createdAt: 2026-09-21T05:45:00.000Z
101 - id: 5101 - id: 5
102- title: "agent.gemini.yaml with an explicit contextWindow, and EN+FR how-to + providers/configuration docs"102+ title: agent.gemini.yaml with an explicit contextWindow, and EN+FR how-to + providers/configuration docs
103 state: open103 state: open
104 priority: low104 priority: low
105 author:105 author:
modified .tickets/issues/0035-create-sbx-template-kit-for-mini-me.yaml +4 -2
@@ -6,9 +6,11 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T04:51:17.051Z
9-updatedAt: 2026-09-21T04:55:19.972Z
9+updatedAt: 2026-09-21T19:06:52.839Z
1010 labels: []
1111 milestone: v0.0.3
12-body: ""
12+body: |
13+ - --tui mode
14+ - acp mode (sbx exec)
1315 tasks: []
1416 comments: []
@@ -6,9 +6,11 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T04:51:17.051Z8 createdAt: 2026-09-21T04:51:17.051Z
9-updatedAt: 2026-09-21T04:55:19.972Z9+updatedAt: 2026-09-21T19:06:52.839Z
10 labels: []10 labels: []
11 milestone: v0.0.311 milestone: v0.0.3
12-body: ""12+body: |
13+ - --tui mode
14+ - acp mode (sbx exec)
13 tasks: []15 tasks: []
14 comments: []16 comments: []
modified .tickets/issues/0037-add-a-file-selector-to-the-tui-mode.yaml +2 -1
@@ -6,8 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T18:55:37.512Z
9-updatedAt: 2026-09-21T18:55:37.512Z
9+updatedAt: 2026-09-21T19:03:09.902Z
1010 labels: []
11+milestone: v0.0.3
1112 body: ""
1213 tasks: []
1314 comments: []
@@ -6,8 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T18:55:37.512Z8 createdAt: 2026-09-21T18:55:37.512Z
9-updatedAt: 2026-09-21T18:55:37.512Z9+updatedAt: 2026-09-21T19:03:09.902Z
10 labels: []10 labels: []
11+milestone: v0.0.3
11 body: ""12 body: ""
12 tasks: []13 tasks: []
13 comments: []14 comments: []
modified .tickets/issues/0038-add-a-skill-selector.yaml +2 -1
@@ -6,8 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-21T19:00:12.508Z
9-updatedAt: 2026-09-21T19:00:12.508Z
9+updatedAt: 2026-09-21T19:02:47.785Z
1010 labels: []
11+milestone: v0.0.3
1112 body: ""
1213 tasks: []
1314 comments: []
@@ -6,8 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-21T19:00:12.508Z8 createdAt: 2026-09-21T19:00:12.508Z
9-updatedAt: 2026-09-21T19:00:12.508Z9+updatedAt: 2026-09-21T19:02:47.785Z
10 labels: []10 labels: []
11+milestone: v0.0.3
11 body: ""12 body: ""
12 tasks: []13 tasks: []
13 comments: []14 comments: []
modified docs/en/reference/cli.md +1 -0
@@ -96,6 +96,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
9696 | Model name | The header shows a short form of the model identifier: registry host and organisation dropped, the `-gguf` format suffix dropped, the tag kept, capped at 28 characters (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). The full identifier is in the start-up banner, which the window keeps as the conversation's first line. |
9797 | 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). |
9898 | 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. |
99+| Pickers | `/` completes a command, `@` a path under the working directory, `$` a skill of the catalogue — the last two anywhere in a sentence, keeping their prefix. See [Slash commands](slash-commands.md#the-three-pickers). |
99100 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |
100101 | 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)]`. |
101102 | 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. |
@@ -96,6 +96,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
96 | Model name | The header shows a short form of the model identifier: registry host and organisation dropped, the `-gguf` format suffix dropped, the tag kept, capped at 28 characters (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). The full identifier is in the start-up banner, which the window keeps as the conversation's first line. |96 | Model name | The header shows a short form of the model identifier: registry host and organisation dropped, the `-gguf` format suffix dropped, the tag kept, capped at 28 characters (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). The full identifier is in the start-up banner, which the window keeps as the conversation's first line. |
97 | 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). |97 | 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). |
98 | 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. |98 | 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. |
99+| Pickers | `/` completes a command, `@` a path under the working directory, `$` a skill of the catalogue — the last two anywhere in a sentence, keeping their prefix. See [Slash commands](slash-commands.md#the-three-pickers). |
99 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |100 | Thinking | A thinking model's reasoning is its own `‣ thinking` entry, dim italic in a different colour, before the answer. Hidden with `showThinking: false`. |
100 | 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)]`. |101 | 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)]`. |
101 | 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. |102 | Permissions | With `permissions: ask`, the box shows `? <tool title>` with `[a] allow once [s] allow <tool> for this session [r] reject` until answered; a turn cancelled while asking rejects. |
modified docs/en/reference/skill-format.md +4 -0
@@ -57,6 +57,10 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
5757
5858 The `keywords` of that example reach the catalogue as `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
5959
60+## Asking for a skill by name
61+
62+`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).
63+
6064 ## Writing a skill the model will ACT on
6165
6266 A skill that tells the model to run a command has one failure mode, and it is not the one you expect: the model **reproduces the command instead of running it**, in a fenced code block, and answers as if it had done the work.
@@ -57,6 +57,10 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
57 57
58 The `keywords` of that example reach the catalogue as `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.58 The `keywords` of that example reach the catalogue as `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
59 59
60+## Asking for a skill by name
61+
62+`$name` in a question names a skill to carry out, with the rest of the line as its parameters — `use $greetings Bob`. The window's `$` picker writes it for you; typed by hand it works in the line mode and over ACP too. It appends one line to what the model reads, and the model still makes the `read_skill` call itself. See [Slash commands — the three pickers](slash-commands.md#the-three-pickers).
63+
60 ## Writing a skill the model will ACT on64 ## Writing a skill the model will ACT on
61 65
62 A skill that tells the model to run a command has one failure mode, and it is not the one you expect: the model **reproduces the command instead of running it**, in a fenced code block, and answers as if it had done the work.66 A skill that tells the model to run a command has one failure mode, and it is not the one you expect: the model **reproduces the command instead of running it**, in a fenced code block, and answers as if it had done the work.
modified docs/en/reference/slash-commands.md +33 -2
@@ -41,7 +41,7 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
4141
4242 ## Window mode keys
4343
44-In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them.
44+In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them — see [The three pickers](#the-three-pickers).
4545
4646 `/quit` and `/abort` take effect **immediately**, even while a turn is running. Every other command and every question waits for the turn to end, because it needs the history the turn is still writing. See [Leaving a turn that will not stop](#leaving-a-turn-that-will-not-stop).
4747
@@ -53,7 +53,7 @@ The keys:
5353 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
5454 | `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. |
5555 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
56-| `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. |
56+| `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). |
5757 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |
5858 | `PgUp` `PgDn` | Scroll one page. |
5959 | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |
@@ -81,6 +81,37 @@ The way out, in order:
8181
8282 Leaving this way does not wait for the stuck job: the session file holds everything saved up to the last completed turn.
8383
84+## The three pickers
85+
86+A popup opens over the conversation as you type one of three triggers, and narrows as you type more. **Tab** completes the highlighted row, **↑/↓** move, **Esc** dismisses it until the text changes.
87+
88+| Trigger | Offers | Where it works | What Tab writes |
89+|---|---|---|---|
90+| `/` | the window's commands | only as the whole first word of the box — a command *is* the message | the command, replacing the box, with a space when it takes an argument |
91+| `@` | files and directories under the working directory | anywhere in a sentence | the path with its `@` kept. A **directory** gets a trailing `/` and no space, so Tab again walks down the tree; a **file** gets a space, so the sentence continues |
92+| `$` | the skills of the catalogue | anywhere in a sentence | `$name` — the `$` **kept** — plus a space, with the cursor after it, ready for the skill's parameters |
93+
94+Three rules keep the popup out of the way:
95+
96+- A trigger glued to the end of a word is not a trigger: `bob@example.com` opens nothing, and neither does `5$`.
97+- `@` hides dotfiles until you type the `.` yourself.
98+- `$` offers nothing when no skill matches, so `echo $HOME` and `$PATH` are left alone.
99+
100+`Enter` on a `@` or `$` token completes it rather than sending: the message is not finished. `Enter` on a command whose name is already complete sends it.
101+
102+### What `$name` does when sent
103+
104+`$greetings Bob Morane` appends one line to what the model reads, the same way `@path` appends an attachment line:
105+
106+```
107+use $greetings Bob Morane
108+[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]
109+```
110+
111+The parameters are the rest of the line. The model still makes the `read_skill` call itself, so the loop detector, the permission gate and the `📖 N skill(s)` recap all see it — `mm` does not load the skill behind the model's back.
112+
113+The notation works in the line mode and over ACP too, typed by hand: a `$skill` means the same thing wherever it is written, as `@path` does. A `$name` that matches no skill is left alone, and a `$` inside a collapsed paste is not looked up.
114+
84115 ## Messages printed at the end of a turn
85116
86117 | Message | Meaning |
@@ -41,7 +41,7 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
41 41
42 ## Window mode keys42 ## Window mode keys
43 43
44-In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them.44+In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No generation in progress.` as a notice, and the picker opened by `/` lists them — see [The three pickers](#the-three-pickers).
45 45
46 `/quit` and `/abort` take effect **immediately**, even while a turn is running. Every other command and every question waits for the turn to end, because it needs the history the turn is still writing. See [Leaving a turn that will not stop](#leaving-a-turn-that-will-not-stop).46 `/quit` and `/abort` take effect **immediately**, even while a turn is running. Every other command and every question waits for the turn to end, because it needs the history the turn is still writing. See [Leaving a turn that will not stop](#leaving-a-turn-that-will-not-stop).
47 47
@@ -53,7 +53,7 @@ The keys:
53 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |53 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
54 | `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. |54 | `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. |
55 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |55 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
56-| `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. |56+| `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). |
57 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |57 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. |
58 | `PgUp` `PgDn` | Scroll one page. |58 | `PgUp` `PgDn` | Scroll one page. |
59 | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |59 | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |
@@ -81,6 +81,37 @@ The way out, in order:
81 81
82 Leaving this way does not wait for the stuck job: the session file holds everything saved up to the last completed turn.82 Leaving this way does not wait for the stuck job: the session file holds everything saved up to the last completed turn.
83 83
84+## The three pickers
85+
86+A popup opens over the conversation as you type one of three triggers, and narrows as you type more. **Tab** completes the highlighted row, **↑/↓** move, **Esc** dismisses it until the text changes.
87+
88+| Trigger | Offers | Where it works | What Tab writes |
89+|---|---|---|---|
90+| `/` | the window's commands | only as the whole first word of the box — a command *is* the message | the command, replacing the box, with a space when it takes an argument |
91+| `@` | files and directories under the working directory | anywhere in a sentence | the path with its `@` kept. A **directory** gets a trailing `/` and no space, so Tab again walks down the tree; a **file** gets a space, so the sentence continues |
92+| `$` | the skills of the catalogue | anywhere in a sentence | `$name` — the `$` **kept** — plus a space, with the cursor after it, ready for the skill's parameters |
93+
94+Three rules keep the popup out of the way:
95+
96+- A trigger glued to the end of a word is not a trigger: `bob@example.com` opens nothing, and neither does `5$`.
97+- `@` hides dotfiles until you type the `.` yourself.
98+- `$` offers nothing when no skill matches, so `echo $HOME` and `$PATH` are left alone.
99+
100+`Enter` on a `@` or `$` token completes it rather than sending: the message is not finished. `Enter` on a command whose name is already complete sends it.
101+
102+### What `$name` does when sent
103+
104+`$greetings Bob Morane` appends one line to what the model reads, the same way `@path` appends an attachment line:
105+
106+```
107+use $greetings Bob Morane
108+[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]
109+```
110+
111+The parameters are the rest of the line. The model still makes the `read_skill` call itself, so the loop detector, the permission gate and the `📖 N skill(s)` recap all see it — `mm` does not load the skill behind the model's back.
112+
113+The notation works in the line mode and over ACP too, typed by hand: a `$skill` means the same thing wherever it is written, as `@path` does. A `$name` that matches no skill is left alone, and a `$` inside a collapsed paste is not looked up.
114+
84 ## Messages printed at the end of a turn115 ## Messages printed at the end of a turn
85 116
86 | Message | Meaning |117 | Message | Meaning |
modified docs/fr/reference/cli.md +1 -0
@@ -96,6 +96,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
9696 | Nom du modèle | L'en-tête affiche une forme courte de l'identifiant : hôte du registre et organisation retirés, suffixe de format `-gguf` retiré, tag conservé, plafonné à 28 caractères (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). L'identifiant complet figure dans la bannière de démarrage, que la fenêtre conserve comme première ligne de la conversation. |
9797 | 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). |
9898 | 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. |
99+| Sélecteurs | `/` complète une commande, `@` un chemin sous le répertoire de travail, `$` un skill du catalogue — les deux derniers n'importe où dans une phrase, en conservant leur préfixe. Voir [Commandes slash](slash-commands.md#les-trois-sélecteurs). |
99100 | 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`. |
100101 | 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)]`. |
101102 | 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. |
@@ -96,6 +96,7 @@ mm v0.0.3 | config: agent.llamacpp.yaml | provider: llamacpp | model: jetbrains/
96 | Nom du modèle | L'en-tête affiche une forme courte de l'identifiant : hôte du registre et organisation retirés, suffixe de format `-gguf` retiré, tag conservé, plafonné à 28 caractères (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). L'identifiant complet figure dans la bannière de démarrage, que la fenêtre conserve comme première ligne de la conversation. |96 | Nom du modèle | L'en-tête affiche une forme courte de l'identifiant : hôte du registre et organisation retirés, suffixe de format `-gguf` retiré, tag conservé, plafonné à 28 caractères (`huggingface.co/unsloth/qwen3.5-2b-gguf:Q4_K_M` → `qwen3.5-2b:Q4_K_M`). L'identifiant complet figure dans la bannière de démarrage, que la fenêtre conserve comme première ligne de la conversation. |
97 | 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). |97 | 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). |
98 | 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. |98 | 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. |
99+| Sélecteurs | `/` complète une commande, `@` un chemin sous le répertoire de travail, `$` un skill du catalogue — les deux derniers n'importe où dans une phrase, en conservant leur préfixe. Voir [Commandes slash](slash-commands.md#les-trois-sélecteurs). |
99 | 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`. |100 | 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`. |
100 | 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)]`. |101 | 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)]`. |
101 | 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. |102 | Permissions | Avec `permissions: ask`, la zone affiche `? <titre de l'outil>` et `[a] allow once [s] allow <outil> for this session [r] reject` jusqu'à la réponse ; un tour annulé pendant la question refuse. |
modified docs/fr/reference/skill-format.md +4 -0
@@ -56,6 +56,10 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
5656
5757 Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
5858
59+## Demander un skill par son nom
60+
61+`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).
62+
5963 ## Écrire un skill que le modèle va EXÉCUTER
6064
6165 Un skill qui demande au modèle de lancer une commande a un mode d'échec, et ce n'est pas celui qu'on attend : le modèle **recopie la commande au lieu de l'exécuter**, dans un bloc de code clôturé, et répond comme s'il avait fait le travail.
@@ -56,6 +56,10 @@ date "+%A %d %B %Y — %H:%M:%S (%Z)"
56 56
57 Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.57 Les `keywords` de cet exemple arrivent dans le catalogue sous la forme `what-time-is-it — Give the current date and time. (time, date, clock, now, today, heure, date du jour, quelle heure)`.
58 58
59+## Demander un skill par son nom
60+
61+`$nom` dans une question désigne un skill à exécuter, avec le reste de la ligne comme paramètres — `use $greetings Bob`. Le sélecteur `$` de la fenêtre l'écrit pour vous ; tapé à la main il marche aussi en mode ligne et en ACP. Il ajoute une ligne à ce que lit le modèle, et c'est toujours le modèle qui fait l'appel `read_skill`. Voir [Commandes slash — les trois sélecteurs](slash-commands.md#les-trois-sélecteurs).
62+
59 ## Écrire un skill que le modèle va EXÉCUTER63 ## Écrire un skill que le modèle va EXÉCUTER
60 64
61 Un skill qui demande au modèle de lancer une commande a un mode d'échec, et ce n'est pas celui qu'on attend : le modèle **recopie la commande au lieu de l'exécuter**, dans un bloc de code clôturé, et répond comme s'il avait fait le travail.65 Un skill qui demande au modèle de lancer une commande a un mode d'échec, et ce n'est pas celui qu'on attend : le modèle **recopie la commande au lieu de l'exécuter**, dans un bloc de code clôturé, et répond comme s'il avait fait le travail.
modified docs/fr/reference/slash-commands.md +32 -1
@@ -53,7 +53,7 @@ Les touches :
5353 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
5454 | `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. |
5555 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
56-| `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. |
56+| `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert (`/`, `@` ou `$`). |
5757 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
5858 | `PgUp` `PgDn` | Défilent d'une page. |
5959 | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |
@@ -81,6 +81,37 @@ La sortie, dans l'ordre :
8181
8282 Sortir ainsi n'attend pas le travail bloqué : le fichier de session contient tout ce qui a été enregistré jusqu'au dernier tour terminé.
8383
84+## Les trois sélecteurs
85+
86+Une fenêtre surgissante s'ouvre au-dessus de la conversation dès que vous tapez l'un des trois déclencheurs, et se resserre à mesure que vous tapez. **Tab** complète la ligne surlignée, **↑/↓** déplacent, **Échap** la referme jusqu'à ce que le texte change.
87+
88+| Déclencheur | Propose | Où ça marche | Ce que Tab écrit |
89+|---|---|---|---|
90+| `/` | les commandes de la fenêtre | seulement comme premier mot entier de la zone — une commande *est* le message | la commande, en remplaçant la zone, avec une espace si elle prend un argument |
91+| `@` | fichiers et dossiers sous le répertoire de travail | n'importe où dans une phrase | le chemin avec son `@` conservé. Un **dossier** reçoit un `/` final et pas d'espace, donc Tab à nouveau descend dans l'arbre ; un **fichier** reçoit une espace, donc la phrase continue |
92+| `$` | les skills du catalogue | n'importe où dans une phrase | `$nom` — le `$` **conservé** — plus une espace, curseur après, prêt pour les paramètres du skill |
93+
94+Trois règles gardent la fenêtre à sa place :
95+
96+- Un déclencheur collé à la fin d'un mot n'en est pas un : `bob@example.com` n'ouvre rien, ni `5$`.
97+- `@` masque les fichiers cachés jusqu'à ce que vous tapiez le `.` vous-même.
98+- `$` ne propose rien quand aucun skill ne correspond, donc `echo $HOME` et `$PATH` sont laissés tranquilles.
99+
100+`Entrée` sur un token `@` ou `$` le complète au lieu d'envoyer : le message n'est pas fini. `Entrée` sur une commande dont le nom est déjà complet l'envoie.
101+
102+### Ce que fait `$nom` à l'envoi
103+
104+`$greetings Bob Morane` ajoute une ligne à ce que lit le modèle, comme `@chemin` ajoute une ligne de pièce jointe :
105+
106+```
107+use $greetings Bob Morane
108+[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]
109+```
110+
111+Les paramètres sont le reste de la ligne. C'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous — `mm` ne charge pas le skill dans le dos du modèle.
112+
113+La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill` veut dire la même chose partout où il est écrit, comme `@chemin`. Un `$nom` qui ne correspond à aucun skill est laissé tel quel, et un `$` dans un collage replié n'est pas résolu.
114+
84115 ## Messages affichés en fin de tour
85116
86117 | Message | Signification |
@@ -53,7 +53,7 @@ Les touches :
53 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |53 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
54 | `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. |54 | `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. |
55 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |55 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
56-| `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. |56+| `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert (`/`, `@` ou `$`). |
57 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |57 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
58 | `PgUp` `PgDn` | Défilent d'une page. |58 | `PgUp` `PgDn` | Défilent d'une page. |
59 | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |59 | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |
@@ -81,6 +81,37 @@ La sortie, dans l'ordre :
81 81
82 Sortir ainsi n'attend pas le travail bloqué : le fichier de session contient tout ce qui a été enregistré jusqu'au dernier tour terminé.82 Sortir ainsi n'attend pas le travail bloqué : le fichier de session contient tout ce qui a été enregistré jusqu'au dernier tour terminé.
83 83
84+## Les trois sélecteurs
85+
86+Une fenêtre surgissante s'ouvre au-dessus de la conversation dès que vous tapez l'un des trois déclencheurs, et se resserre à mesure que vous tapez. **Tab** complète la ligne surlignée, **↑/↓** déplacent, **Échap** la referme jusqu'à ce que le texte change.
87+
88+| Déclencheur | Propose | Où ça marche | Ce que Tab écrit |
89+|---|---|---|---|
90+| `/` | les commandes de la fenêtre | seulement comme premier mot entier de la zone — une commande *est* le message | la commande, en remplaçant la zone, avec une espace si elle prend un argument |
91+| `@` | fichiers et dossiers sous le répertoire de travail | n'importe où dans une phrase | le chemin avec son `@` conservé. Un **dossier** reçoit un `/` final et pas d'espace, donc Tab à nouveau descend dans l'arbre ; un **fichier** reçoit une espace, donc la phrase continue |
92+| `$` | les skills du catalogue | n'importe où dans une phrase | `$nom` — le `$` **conservé** — plus une espace, curseur après, prêt pour les paramètres du skill |
93+
94+Trois règles gardent la fenêtre à sa place :
95+
96+- Un déclencheur collé à la fin d'un mot n'en est pas un : `bob@example.com` n'ouvre rien, ni `5$`.
97+- `@` masque les fichiers cachés jusqu'à ce que vous tapiez le `.` vous-même.
98+- `$` ne propose rien quand aucun skill ne correspond, donc `echo $HOME` et `$PATH` sont laissés tranquilles.
99+
100+`Entrée` sur un token `@` ou `$` le complète au lieu d'envoyer : le message n'est pas fini. `Entrée` sur une commande dont le nom est déjà complet l'envoie.
101+
102+### Ce que fait `$nom` à l'envoi
103+
104+`$greetings Bob Morane` ajoute une ligne à ce que lit le modèle, comme `@chemin` ajoute une ligne de pièce jointe :
105+
106+```
107+use $greetings Bob Morane
108+[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]
109+```
110+
111+Les paramètres sont le reste de la ligne. C'est toujours le modèle qui fait l'appel `read_skill`, donc le détecteur de boucle, la demande de permission et le recap `📖 N skill(s)` le voient tous — `mm` ne charge pas le skill dans le dos du modèle.
112+
113+La notation marche aussi en mode ligne et en ACP, tapée à la main : un `$skill` veut dire la même chose partout où il est écrit, comme `@chemin`. Un `$nom` qui ne correspond à aucun skill est laissé tel quel, et un `$` dans un collage replié n'est pas résolu.
114+
84 ## Messages affichés en fin de tour115 ## Messages affichés en fin de tour
85 116
86 | Message | Signification |117 | Message | Signification |
modified internal/acp/acp.go +7 -0
@@ -35,6 +35,7 @@ import (
3535 "mm/internal/engine"
3636 "mm/internal/mention"
3737 history "mm/internal/session"
38+ "mm/internal/skills"
3839 "mm/internal/store"
3940 "mm/internal/ui"
4041
@@ -304,6 +305,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
304305 for _, a := range attached {
305306 fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)
306307 }
308+ // Same for "$skill": the editor has no picker for it, but the notation
309+ // travels in the text and must mean what it means in the terminal.
310+ input, refs := skills.ExpandRefs(input, skills.Declared())
311+ for _, ref := range refs {
312+ fmt.Fprintf(os.Stderr, "[acp] session %s: skill %s\n", p.SessionId, ref.Name)
313+ }
307314 s.messages = append(s.messages, ai.NewUserTextMessage(input))
308315 before := len(s.messages)
309316
@@ -35,6 +35,7 @@ import (
35 "mm/internal/engine"35 "mm/internal/engine"
36 "mm/internal/mention"36 "mm/internal/mention"
37 history "mm/internal/session"37 history "mm/internal/session"
38+ "mm/internal/skills"
38 "mm/internal/store"39 "mm/internal/store"
39 "mm/internal/ui"40 "mm/internal/ui"
40 41
@@ -304,6 +305,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp
304 for _, a := range attached {305 for _, a := range attached {
305 fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)306 fmt.Fprintf(os.Stderr, "[acp] session %s: attached %s\n", p.SessionId, a.Path)
306 }307 }
308+ // Same for "$skill": the editor has no picker for it, but the notation
309+ // travels in the text and must mean what it means in the terminal.
310+ input, refs := skills.ExpandRefs(input, skills.Declared())
311+ for _, ref := range refs {
312+ fmt.Fprintf(os.Stderr, "[acp] session %s: skill %s\n", p.SessionId, ref.Name)
313+ }
307 s.messages = append(s.messages, ai.NewUserTextMessage(input))314 s.messages = append(s.messages, ai.NewUserTextMessage(input))
308 before := len(s.messages)315 before := len(s.messages)
309 316
modified internal/agent/agent.go +8 -0
@@ -18,6 +18,7 @@ import (
1818 "mm/internal/engine"
1919 "mm/internal/mention"
2020 "mm/internal/session"
21+ "mm/internal/skills"
2122 "mm/internal/spinner"
2223 "mm/internal/store"
2324
@@ -177,6 +178,13 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
177178 for _, a := range attached {
178179 dimln("📎 " + a.Path)
179180 }
181+ // "$skill" names a procedure to carry out, with the rest of the line
182+ // as its parameters — the same notation the window's `$` picker
183+ // writes, so it means the same thing typed by hand.
184+ input, refs := skills.ExpandRefs(input, skills.Declared())
185+ for _, ref := range refs {
186+ dimln("📖 $" + ref.Name)
187+ }
180188 messages = append(messages, ai.NewUserTextMessage(input))
181189 before := len(messages)
182190 fmt.Println()
@@ -18,6 +18,7 @@ import (
18 "mm/internal/engine"18 "mm/internal/engine"
19 "mm/internal/mention"19 "mm/internal/mention"
20 "mm/internal/session"20 "mm/internal/session"
21+ "mm/internal/skills"
21 "mm/internal/spinner"22 "mm/internal/spinner"
22 "mm/internal/store"23 "mm/internal/store"
23 24
@@ -177,6 +178,13 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
177 for _, a := range attached {178 for _, a := range attached {
178 dimln("📎 " + a.Path)179 dimln("📎 " + a.Path)
179 }180 }
181+ // "$skill" names a procedure to carry out, with the rest of the line
182+ // as its parameters — the same notation the window's `$` picker
183+ // writes, so it means the same thing typed by hand.
184+ input, refs := skills.ExpandRefs(input, skills.Declared())
185+ for _, ref := range refs {
186+ dimln("📖 $" + ref.Name)
187+ }
180 messages = append(messages, ai.NewUserTextMessage(input))188 messages = append(messages, ai.NewUserTextMessage(input))
181 before := len(messages)189 before := len(messages)
182 fmt.Println()190 fmt.Println()
added internal/skills/refs.go +112 -0
new file mode 100644
@@ -0,0 +1,112 @@
1+// The `$skill` notation of a question: "$greetings Bob" asks for a named
2+// procedure to be carried out, with what follows as its parameters.
3+//
4+// It is the sibling of the `@path` notation in internal/mention, and it is
5+// built the same way and for the same reason: the front end expands the
6+// message into a line the MODEL reads, rather than acting behind its back.
7+// A `$skill` therefore means the same thing typed in the terminal, typed in
8+// the window's picker, or typed in an editor over ACP — and the loop
9+// detector, the permission gate and the `📖` recap all still see the
10+// `read_skill` call, because it is still the model that makes it.
11+
12+package skills
13+
14+import (
15+ "fmt"
16+ "strings"
17+)
18+
19+// declared is the catalogue this process was started with, set once by main.
20+//
21+// It is package state, which is deliberate and is the same choice as
22+// ui.Out, spinner.Disable and engine.DeclareSkills: the skills directory is
23+// read at start-up and never moves, every front end needs the same list, and
24+// threading it through three Run signatures pushed all three past the point
25+// where an argument list still reads. ExpandRefs keeps taking the list, so
26+// it stays a pure function and the tests stay hermetic; only the front ends
27+// go through Declared.
28+var declared []Skill
29+
30+// Declare records the catalogue the process was started with.
31+func Declare(list []Skill) { declared = list }
32+
33+// Declared is that catalogue, for a front end resolving a `$skill`.
34+func Declared() []Skill { return declared }
35+
36+// Reference is one `$skill` that named a skill of the catalogue.
37+type Reference struct {
38+ Name string // the skill's name, as the catalogue spells it
39+ Args string // whatever followed on the same line, trimmed; "" when none
40+}
41+
42+// refTrailing is the sentence punctuation a reference may be glued to, so
43+// "use $greetings, then stop" names `greetings` and not `greetings,`.
44+const refTrailing = ",.;:!?)]}'\"`"
45+
46+// ExpandRefs finds the `$name` references of input, resolves them against the
47+// catalogue, and returns the input followed by one line per reference, in
48+// order of first appearance and each skill once:
49+//
50+// [skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob]
51+//
52+// A `$word` that names no skill is left alone: `$HOME` in a question is a
53+// shell variable, not a skill, and inventing a line for it would send the
54+// model after something that does not exist. A `$` glued to the end of a
55+// word is not a reference either.
56+//
57+// The parameters are the rest of the line after the name. That is the whole
58+// point of keeping the `$` in front when the picker completes: the name goes
59+// in, the cursor lands after it, and what the user types next is what the
60+// procedure needs.
61+func ExpandRefs(input string, list []Skill) (string, []Reference) {
62+ var out []Reference
63+ seen := map[string]bool{}
64+
65+ for _, line := range strings.Split(input, "\n") {
66+ for _, ref := range refsInLine(line, list) {
67+ if seen[ref.Name] {
68+ continue
69+ }
70+ seen[ref.Name] = true
71+ out = append(out, ref)
72+ }
73+ }
74+ if len(out) == 0 {
75+ return input, nil
76+ }
77+
78+ var b strings.Builder
79+ b.WriteString(input)
80+ for _, ref := range out {
81+ b.WriteString("\n" + ref.Line())
82+ }
83+ return b.String(), out
84+}
85+
86+// Line is the reference as the model reads it.
87+func (r Reference) Line() string {
88+ line := fmt.Sprintf("[skill: %s — load it with read_skill and carry out its steps now", r.Name)
89+ if r.Args != "" {
90+ line += "; parameters: " + r.Args
91+ }
92+ return line + "]"
93+}
94+
95+// refsInLine returns the references of one line. Only the FIRST reference of
96+// a line takes parameters — everything after its name is its argument, so a
97+// second `$skill` on the same line would be part of them.
98+func refsInLine(line string, list []Skill) []Reference {
99+ fields := strings.Fields(line)
100+ for i, f := range fields {
101+ if !strings.HasPrefix(f, "$") || len(f) == 1 {
102+ continue
103+ }
104+ name := strings.TrimRight(f[1:], refTrailing)
105+ s, ok := Find(list, name)
106+ if !ok {
107+ continue
108+ }
109+ return []Reference{{Name: s.Name, Args: strings.Join(fields[i+1:], " ")}}
110+ }
111+ return nil
112+}
new file mode 100644
@@ -0,0 +1,112 @@
1+// The `$skill` notation of a question: "$greetings Bob" asks for a named
2+// procedure to be carried out, with what follows as its parameters.
3+//
4+// It is the sibling of the `@path` notation in internal/mention, and it is
5+// built the same way and for the same reason: the front end expands the
6+// message into a line the MODEL reads, rather than acting behind its back.
7+// A `$skill` therefore means the same thing typed in the terminal, typed in
8+// the window's picker, or typed in an editor over ACP — and the loop
9+// detector, the permission gate and the `📖` recap all still see the
10+// `read_skill` call, because it is still the model that makes it.
11+
12+package skills
13+
14+import (
15+ "fmt"
16+ "strings"
17+)
18+
19+// declared is the catalogue this process was started with, set once by main.
20+//
21+// It is package state, which is deliberate and is the same choice as
22+// ui.Out, spinner.Disable and engine.DeclareSkills: the skills directory is
23+// read at start-up and never moves, every front end needs the same list, and
24+// threading it through three Run signatures pushed all three past the point
25+// where an argument list still reads. ExpandRefs keeps taking the list, so
26+// it stays a pure function and the tests stay hermetic; only the front ends
27+// go through Declared.
28+var declared []Skill
29+
30+// Declare records the catalogue the process was started with.
31+func Declare(list []Skill) { declared = list }
32+
33+// Declared is that catalogue, for a front end resolving a `$skill`.
34+func Declared() []Skill { return declared }
35+
36+// Reference is one `$skill` that named a skill of the catalogue.
37+type Reference struct {
38+ Name string // the skill's name, as the catalogue spells it
39+ Args string // whatever followed on the same line, trimmed; "" when none
40+}
41+
42+// refTrailing is the sentence punctuation a reference may be glued to, so
43+// "use $greetings, then stop" names `greetings` and not `greetings,`.
44+const refTrailing = ",.;:!?)]}'\"`"
45+
46+// ExpandRefs finds the `$name` references of input, resolves them against the
47+// catalogue, and returns the input followed by one line per reference, in
48+// order of first appearance and each skill once:
49+//
50+// [skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob]
51+//
52+// A `$word` that names no skill is left alone: `$HOME` in a question is a
53+// shell variable, not a skill, and inventing a line for it would send the
54+// model after something that does not exist. A `$` glued to the end of a
55+// word is not a reference either.
56+//
57+// The parameters are the rest of the line after the name. That is the whole
58+// point of keeping the `$` in front when the picker completes: the name goes
59+// in, the cursor lands after it, and what the user types next is what the
60+// procedure needs.
61+func ExpandRefs(input string, list []Skill) (string, []Reference) {
62+ var out []Reference
63+ seen := map[string]bool{}
64+
65+ for _, line := range strings.Split(input, "\n") {
66+ for _, ref := range refsInLine(line, list) {
67+ if seen[ref.Name] {
68+ continue
69+ }
70+ seen[ref.Name] = true
71+ out = append(out, ref)
72+ }
73+ }
74+ if len(out) == 0 {
75+ return input, nil
76+ }
77+
78+ var b strings.Builder
79+ b.WriteString(input)
80+ for _, ref := range out {
81+ b.WriteString("\n" + ref.Line())
82+ }
83+ return b.String(), out
84+}
85+
86+// Line is the reference as the model reads it.
87+func (r Reference) Line() string {
88+ line := fmt.Sprintf("[skill: %s — load it with read_skill and carry out its steps now", r.Name)
89+ if r.Args != "" {
90+ line += "; parameters: " + r.Args
91+ }
92+ return line + "]"
93+}
94+
95+// refsInLine returns the references of one line. Only the FIRST reference of
96+// a line takes parameters — everything after its name is its argument, so a
97+// second `$skill` on the same line would be part of them.
98+func refsInLine(line string, list []Skill) []Reference {
99+ fields := strings.Fields(line)
100+ for i, f := range fields {
101+ if !strings.HasPrefix(f, "$") || len(f) == 1 {
102+ continue
103+ }
104+ name := strings.TrimRight(f[1:], refTrailing)
105+ s, ok := Find(list, name)
106+ if !ok {
107+ continue
108+ }
109+ return []Reference{{Name: s.Name, Args: strings.Join(fields[i+1:], " ")}}
110+ }
111+ return nil
112+}
added internal/skills/refs_test.go +94 -0
new file mode 100644
@@ -0,0 +1,94 @@
1+package skills
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func catalogue() []Skill {
9+ return []Skill{
10+ {Name: "greetings", Description: "greet somebody"},
11+ {Name: "bg-jobs", Description: "background jobs"},
12+ }
13+}
14+
15+func TestExpandRefs(t *testing.T) {
16+ cases := []struct {
17+ name, input string
18+ wantRefs []string
19+ wantLine string // a substring the expansion must contain; "" = no expansion
20+ }{
21+ {"a reference with parameters", "use $greetings Bob Morane",
22+ []string{"greetings"}, "[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]"},
23+ {"a reference alone", "$bg-jobs",
24+ []string{"bg-jobs"}, "[skill: bg-jobs — load it with read_skill and carry out its steps now]"},
25+ {"glued punctuation is not part of the name", "run $greetings, then stop",
26+ []string{"greetings"}, "parameters: then stop"},
27+ {"a shell variable is not a skill", "echo $HOME and $PATH", nil, ""},
28+ {"an unknown name is left alone", "$nosuchskill please", nil, ""},
29+ {"a bare dollar is not a reference", "it costs $ 5", nil, ""},
30+ {"the same skill twice gives one line", "$greetings then $greetings",
31+ []string{"greetings"}, "parameters: then $greetings"},
32+ {"no reference at all", "just a question", nil, ""},
33+ }
34+ for _, tc := range cases {
35+ t.Run(tc.name, func(t *testing.T) {
36+ got, refs := ExpandRefs(tc.input, catalogue())
37+
38+ var names []string
39+ for _, r := range refs {
40+ names = append(names, r.Name)
41+ }
42+ if strings.Join(names, ",") != strings.Join(tc.wantRefs, ",") {
43+ t.Errorf("refs = %v, want %v", names, tc.wantRefs)
44+ }
45+ if tc.wantLine == "" {
46+ if got != tc.input {
47+ t.Errorf("input was expanded when it should not be:\n%q", got)
48+ }
49+ return
50+ }
51+ if !strings.HasPrefix(got, tc.input) {
52+ t.Errorf("the expansion dropped the question:\n%q", got)
53+ }
54+ if !strings.Contains(got, tc.wantLine) {
55+ t.Errorf("expansion =\n%q\nwant it to contain\n%q", got, tc.wantLine)
56+ }
57+ })
58+ }
59+}
60+
61+// The name is resolved through the catalogue, so the forgiveness Find offers
62+// applies here too — but never at the cost of naming a different skill.
63+func TestExpandRefsResolvesThroughTheCatalogue(t *testing.T) {
64+ got, refs := ExpandRefs("use $GREETINGS Bob", catalogue())
65+ if len(refs) != 1 || refs[0].Name != "greetings" {
66+ t.Fatalf("refs = %+v, want the catalogue's spelling", refs)
67+ }
68+ if !strings.Contains(got, "[skill: greetings") {
69+ t.Errorf("the line does not use the catalogue's name: %q", got)
70+ }
71+}
72+
73+// Several lines, one reference each: the parameters of a reference stop at
74+// the end of its line.
75+func TestExpandRefsPerLine(t *testing.T) {
76+ got, refs := ExpandRefs("$greetings Bob\n$bg-jobs", catalogue())
77+ if len(refs) != 2 {
78+ t.Fatalf("refs = %+v, want two", refs)
79+ }
80+ if refs[0].Args != "Bob" || refs[1].Args != "" {
81+ t.Errorf("args = %q and %q", refs[0].Args, refs[1].Args)
82+ }
83+ if strings.Count(got, "[skill:") != 2 {
84+ t.Errorf("expansion = %q", got)
85+ }
86+}
87+
88+// An empty catalogue cannot produce a reference — the notation is only ever
89+// resolved against skills that exist.
90+func TestExpandRefsWithNoSkills(t *testing.T) {
91+ if got, refs := ExpandRefs("$greetings Bob", nil); refs != nil || got != "$greetings Bob" {
92+ t.Errorf("ExpandRefs with no catalogue = %q, %+v", got, refs)
93+ }
94+}
new file mode 100644
@@ -0,0 +1,94 @@
1+package skills
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func catalogue() []Skill {
9+ return []Skill{
10+ {Name: "greetings", Description: "greet somebody"},
11+ {Name: "bg-jobs", Description: "background jobs"},
12+ }
13+}
14+
15+func TestExpandRefs(t *testing.T) {
16+ cases := []struct {
17+ name, input string
18+ wantRefs []string
19+ wantLine string // a substring the expansion must contain; "" = no expansion
20+ }{
21+ {"a reference with parameters", "use $greetings Bob Morane",
22+ []string{"greetings"}, "[skill: greetings — load it with read_skill and carry out its steps now; parameters: Bob Morane]"},
23+ {"a reference alone", "$bg-jobs",
24+ []string{"bg-jobs"}, "[skill: bg-jobs — load it with read_skill and carry out its steps now]"},
25+ {"glued punctuation is not part of the name", "run $greetings, then stop",
26+ []string{"greetings"}, "parameters: then stop"},
27+ {"a shell variable is not a skill", "echo $HOME and $PATH", nil, ""},
28+ {"an unknown name is left alone", "$nosuchskill please", nil, ""},
29+ {"a bare dollar is not a reference", "it costs $ 5", nil, ""},
30+ {"the same skill twice gives one line", "$greetings then $greetings",
31+ []string{"greetings"}, "parameters: then $greetings"},
32+ {"no reference at all", "just a question", nil, ""},
33+ }
34+ for _, tc := range cases {
35+ t.Run(tc.name, func(t *testing.T) {
36+ got, refs := ExpandRefs(tc.input, catalogue())
37+
38+ var names []string
39+ for _, r := range refs {
40+ names = append(names, r.Name)
41+ }
42+ if strings.Join(names, ",") != strings.Join(tc.wantRefs, ",") {
43+ t.Errorf("refs = %v, want %v", names, tc.wantRefs)
44+ }
45+ if tc.wantLine == "" {
46+ if got != tc.input {
47+ t.Errorf("input was expanded when it should not be:\n%q", got)
48+ }
49+ return
50+ }
51+ if !strings.HasPrefix(got, tc.input) {
52+ t.Errorf("the expansion dropped the question:\n%q", got)
53+ }
54+ if !strings.Contains(got, tc.wantLine) {
55+ t.Errorf("expansion =\n%q\nwant it to contain\n%q", got, tc.wantLine)
56+ }
57+ })
58+ }
59+}
60+
61+// The name is resolved through the catalogue, so the forgiveness Find offers
62+// applies here too — but never at the cost of naming a different skill.
63+func TestExpandRefsResolvesThroughTheCatalogue(t *testing.T) {
64+ got, refs := ExpandRefs("use $GREETINGS Bob", catalogue())
65+ if len(refs) != 1 || refs[0].Name != "greetings" {
66+ t.Fatalf("refs = %+v, want the catalogue's spelling", refs)
67+ }
68+ if !strings.Contains(got, "[skill: greetings") {
69+ t.Errorf("the line does not use the catalogue's name: %q", got)
70+ }
71+}
72+
73+// Several lines, one reference each: the parameters of a reference stop at
74+// the end of its line.
75+func TestExpandRefsPerLine(t *testing.T) {
76+ got, refs := ExpandRefs("$greetings Bob\n$bg-jobs", catalogue())
77+ if len(refs) != 2 {
78+ t.Fatalf("refs = %+v, want two", refs)
79+ }
80+ if refs[0].Args != "Bob" || refs[1].Args != "" {
81+ t.Errorf("args = %q and %q", refs[0].Args, refs[1].Args)
82+ }
83+ if strings.Count(got, "[skill:") != 2 {
84+ t.Errorf("expansion = %q", got)
85+ }
86+}
87+
88+// An empty catalogue cannot produce a reference — the notation is only ever
89+// resolved against skills that exist.
90+func TestExpandRefsWithNoSkills(t *testing.T) {
91+ if got, refs := ExpandRefs("$greetings Bob", nil); refs != nil || got != "$greetings Bob" {
92+ t.Errorf("ExpandRefs with no catalogue = %q, %+v", got, refs)
93+ }
94+}
modified internal/tui/draw.go +10 -3
@@ -95,7 +95,7 @@ func (v *View) ruleLabel() string {
9595 case !v.onInput:
9696 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
9797 default:
98- return " Enter sends · Ctrl-J new line · / commands · @ files · Tab scroll "
98+ return " Enter sends · Ctrl-J new line · / commands · @ files · $ skills · Tab scroll "
9999 }
100100 }
101101
@@ -203,9 +203,16 @@ func (v *View) drawPicker(p painter) {
203203 }
204204 rows := min(len(cs), pickerRows, p.height())
205205 top := p.height() - rows
206+ // The label column is as wide as the widest label, so the details line
207+ // up whichever trigger is open — commands, paths and skills all differ.
208+ labels := 0
206209 width := 0
207210 for _, c := range cs {
208- width = max(width, len([]rune(c.Slash()))+2+len([]rune(c.Description)))
211+ labels = max(labels, len([]rune(c.label)))
212+ }
213+ labels = min(labels, p.width()/2)
214+ for _, c := range cs {
215+ width = max(width, labels+2+len([]rune(c.detail)))
209216 }
210217 width = min(width+4, p.width())
211218 for i := 0; i < rows; i++ {
@@ -214,7 +221,7 @@ func (v *View) drawPicker(p painter) {
214221 if i == v.pick.selected {
215222 style = stPick
216223 }
217- text := " " + c.Slash() + strings.Repeat(" ", max(10-len([]rune(c.Slash())), 1)) + c.Description
224+ text := " " + c.label + strings.Repeat(" ", max(labels-len([]rune(c.label))+2, 1)) + c.detail
218225 p.hline(0, top+i, width, ' ', style)
219226 p.textLimited(0, top+i, width, text, style)
220227 }
@@ -95,7 +95,7 @@ func (v *View) ruleLabel() string {
95 case !v.onInput:95 case !v.onInput:
96 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "96 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
97 default:97 default:
98- return " Enter sends · Ctrl-J new line · / commands · @ files · Tab scroll "98+ return " Enter sends · Ctrl-J new line · / commands · @ files · $ skills · Tab scroll "
99 }99 }
100 }100 }
101 101
@@ -203,9 +203,16 @@ func (v *View) drawPicker(p painter) {
203 }203 }
204 rows := min(len(cs), pickerRows, p.height())204 rows := min(len(cs), pickerRows, p.height())
205 top := p.height() - rows205 top := p.height() - rows
206+ // The label column is as wide as the widest label, so the details line
207+ // up whichever trigger is open — commands, paths and skills all differ.
208+ labels := 0
206 width := 0209 width := 0
207 for _, c := range cs {210 for _, c := range cs {
208- width = max(width, len([]rune(c.Slash()))+2+len([]rune(c.Description)))211+ labels = max(labels, len([]rune(c.label)))
212+ }
213+ labels = min(labels, p.width()/2)
214+ for _, c := range cs {
215+ width = max(width, labels+2+len([]rune(c.detail)))
209 }216 }
210 width = min(width+4, p.width())217 width = min(width+4, p.width())
211 for i := 0; i < rows; i++ {218 for i := 0; i < rows; i++ {
@@ -214,7 +221,7 @@ func (v *View) drawPicker(p painter) {
214 if i == v.pick.selected {221 if i == v.pick.selected {
215 style = stPick222 style = stPick
216 }223 }
217- text := " " + c.Slash() + strings.Repeat(" ", max(10-len([]rune(c.Slash())), 1)) + c.Description224+ text := " " + c.label + strings.Repeat(" ", max(labels-len([]rune(c.label))+2, 1)) + c.detail
218 p.hline(0, top+i, width, ' ', style)225 p.hline(0, top+i, width, ' ', style)
219 p.textLimited(0, top+i, width, text, style)226 p.textLimited(0, top+i, width, text, style)
220 }227 }
modified internal/tui/picker.go +215 -30
@@ -1,45 +1,191 @@
11 package tui
22
33 import (
4+ "os"
5+ "path/filepath"
6+ "sort"
47 "strings"
58
69 "mm/internal/command"
10+ "mm/internal/skills"
711 )
812
913 // pickerRows is how many choices the popup shows at most.
1014 const pickerRows = 8
1115
12-// picker is the popup that opens over the conversation while a "/" command
13-// is being typed at the very start of the box: it lists the window's
14-// commands, narrowed by what has been typed, and completes the chosen one.
15-// Escape dismisses it until the text changes.
16+// The popup that completes what is being typed. Three triggers, three
17+// sources, one mechanism:
18+//
19+// / the window's commands only as the whole first word of the box
20+// @ files and directories anywhere in the text
21+// $ the skills of the catalogue anywhere in the text
22+//
23+// The `/` case is the original one and keeps its rule: a command is the whole
24+// message, so it only makes sense at the start, and completing it replaces
25+// the box. The other two are mid-sentence — "explain @internal/acp/acp.go",
26+// "use $greetings Bob" — so the popup reads the TOKEN the cursor is in and
27+// completes only that token, leaving the rest of the sentence alone.
28+//
29+// Both mid-sentence triggers keep their prefix when completed. For `@` it is
30+// what `mention.Expand` looks for; for `$` it is what marks a skill to be
31+// carried out (see skills.ExpandRefs), and the cursor lands after the name so
32+// the parameters can be typed straight away.
33+
34+// choice is one row of the popup: what goes in the box, and what is shown
35+// beside it.
36+type choice struct {
37+ insert string // the full token, prefix included
38+ label string // the left column
39+ detail string // the right column, may be empty
40+}
41+
42+// picker remembers the selection and Escape's "not now".
1643 type picker struct {
1744 selected int
1845 dismissedFor string // the input text Escape was pressed on; "" = not dismissed
1946 dismissed bool
2047 }
2148
22-// choices returns the commands the picker offers for what is typed: the
23-// window's commands whose name starts with the word after the slash. Nothing
24-// when the box holds more than one line, does not start with "/", already
25-// has a space (the command is complete), or was dismissed on this text.
26-func (v *View) choices() []command.Command {
27- if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") {
28- return nil
49+// token is the word the popup is completing: where it sits in the current
50+// line, and what has been typed of it.
51+type token struct {
52+ trigger byte
53+ from int // rune index in the line where the trigger sits
54+ to int // rune index just past what has been typed
55+ partial string // what follows the trigger, as typed
56+}
57+
58+// tokenAtCursor finds the trigger token the cursor is inside or at the end
59+// of. Nothing when the cursor is not in one: a `@` or `$` that is glued to
60+// the end of a word is not a trigger ("me@example.com", "5$"), and neither
61+// is a trigger whose token the cursor has left.
62+func (v *View) tokenAtCursor() (token, bool) {
63+ if v.cursor >= len(v.input) {
64+ return token{}, false
2965 }
30- if v.pick.dismissed && v.pick.dismissedFor == v.input[0] {
66+ line := []rune(v.input[v.cursor])
67+ col := min(v.column, len(line))
68+
69+ // Walk back to the start of the word the cursor is in.
70+ i := col
71+ for i > 0 && !isSpaceRune(line[i-1]) {
72+ i--
73+ }
74+ if i >= col && i >= len(line) {
75+ return token{}, false
76+ }
77+ if i >= len(line) || (line[i] != '@' && line[i] != '$' && line[i] != '/') {
78+ return token{}, false
79+ }
80+ // The word must END at the cursor: completing a token the cursor has
81+ // already passed would rewrite text the user moved away from.
82+ end := col
83+ if end < len(line) && !isSpaceRune(line[end]) {
84+ return token{}, false
85+ }
86+ return token{trigger: byte(line[i]), from: i, to: end, partial: string(line[i+1 : end])}, true
87+}
88+
89+func isSpaceRune(r rune) bool { return r == ' ' || r == '\t' }
90+
91+// choices returns what the popup offers, or nothing when it must stay shut.
92+func (v *View) choices() []choice {
93+ tok, ok := v.tokenAtCursor()
94+ if !ok || (v.pick.dismissed && v.pick.dismissedFor == v.Input()) {
3195 return nil
3296 }
33- word := strings.TrimPrefix(v.input[0], "/")
34- var out []command.Command
97+ var out []choice
98+ switch tok.trigger {
99+ case '/':
100+ // A command is the whole message: only offered as the first word of
101+ // a one-line box, exactly as before.
102+ if len(v.input) == 1 && tok.from == 0 {
103+ out = commandChoices(tok.partial)
104+ }
105+ case '$':
106+ out = skillChoices(v.skills, tok.partial)
107+ case '@':
108+ out = pathChoices(v.cwd, tok.partial)
109+ }
110+ return out
111+}
112+
113+func commandChoices(partial string) []choice {
114+ var out []choice
35115 for _, c := range command.For(command.Window) {
36- if strings.HasPrefix(c.Name, word) {
37- out = append(out, c)
116+ if strings.HasPrefix(c.Name, partial) {
117+ out = append(out, choice{insert: c.Slash(), label: c.Slash(), detail: c.Description})
38118 }
39119 }
40120 return out
41121 }
42122
123+// skillChoices narrows the catalogue by what follows the `$`. The `$` is kept
124+// in the inserted text: it is what tells the turn this skill is to be carried
125+// out, and it leaves the cursor in front of the parameters.
126+func skillChoices(list []skills.Skill, partial string) []choice {
127+ var out []choice
128+ for _, s := range list {
129+ if !strings.HasPrefix(strings.ToLower(s.Name), strings.ToLower(partial)) {
130+ continue
131+ }
132+ out = append(out, choice{insert: "$" + s.Name, label: "$" + s.Name, detail: s.Description})
133+ }
134+ return out
135+}
136+
137+// pathChoices lists what is on disk under the directory the partial path
138+// names, narrowed by its last segment. A directory is offered with a
139+// trailing "/" so the next keystroke walks into it.
140+//
141+// It reads ONE directory per keystroke and stops at pickerRows*4 entries:
142+// the popup shows eight, and a node_modules must not stall the window.
143+func pathChoices(cwd, partial string) []choice {
144+ dir, prefix := filepath.Split(partial)
145+ base := filepath.Join(cwd, dir)
146+ if strings.HasPrefix(dir, "~/") {
147+ home, err := os.UserHomeDir()
148+ if err != nil {
149+ return nil
150+ }
151+ base = filepath.Join(home, strings.TrimPrefix(dir, "~/"))
152+ } else if filepath.IsAbs(dir) {
153+ base = dir
154+ }
155+
156+ entries, err := os.ReadDir(base)
157+ if err != nil {
158+ return nil
159+ }
160+ var out []choice
161+ for _, e := range entries {
162+ name := e.Name()
163+ if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") {
164+ continue // hidden files only once the user asks for them
165+ }
166+ if !strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) {
167+ continue
168+ }
169+ shown, detail := name, "file"
170+ if e.IsDir() {
171+ shown, detail = name+"/", "directory"
172+ }
173+ out = append(out, choice{insert: "@" + dir + shown, label: shown, detail: detail})
174+ if len(out) >= pickerRows*4 {
175+ break
176+ }
177+ }
178+ // Directories first, then names: walking a tree is the common case.
179+ sort.SliceStable(out, func(i, j int) bool {
180+ di, dj := strings.HasSuffix(out[i].label, "/"), strings.HasSuffix(out[j].label, "/")
181+ if di != dj {
182+ return di
183+ }
184+ return out[i].label < out[j].label
185+ })
186+ return out
187+}
188+
43189 // pickerOpen reports whether the popup is showing.
44190 func (v *View) pickerOpen() bool { return len(v.choices()) > 0 }
45191
@@ -59,34 +205,73 @@ func (v *View) movePick(by int) {
59205 v.pick.selected = ((v.pick.selected+by)%n + n) % n
60206 }
61207
62-// selectedChoice is the highlighted command, if the popup is open.
63-func (v *View) selectedChoice() (command.Command, bool) {
208+// selectedChoice is the highlighted row, if the popup is open.
209+func (v *View) selectedChoice() (choice, bool) {
64210 cs := v.choices()
65211 if len(cs) == 0 {
66- return command.Command{}, false
212+ return choice{}, false
67213 }
68214 return cs[min(v.pick.selected, len(cs)-1)], true
69215 }
70216
71-// complete puts the highlighted command in the box, with a space after it
72-// when it takes an argument, and closes the popup. Enter on a command that is
73-// already complete sends it instead: see events.go.
217+// complete puts the highlighted choice in the box and closes the popup.
218+//
219+// A command replaces the whole box, since a command IS the message. A `@` or
220+// `$` token replaces only itself, and the cursor lands just after it — after
221+// a directory's "/" there is more path to type, after a skill's name there
222+// are parameters, and after a file there is the rest of the sentence.
74223 func (v *View) complete() {
75224 c, ok := v.selectedChoice()
76225 if !ok {
77226 return
78227 }
79- text := c.Slash()
80- if c.TakesArg {
81- text += " "
228+ tok, ok := v.tokenAtCursor()
229+ if !ok {
230+ return
231+ }
232+ if tok.trigger == '/' {
233+ text := c.insert
234+ if cmd, _, found := command.Parse(text); found && cmd.TakesArg {
235+ text += " "
236+ }
237+ v.setInput(text)
238+ v.pick.dismiss(text)
239+ return
240+ }
241+
242+ line := []rune(v.input[v.cursor])
243+ insert := []rune(c.insert)
244+ // A file or a skill is followed by a space; a directory is not, so the
245+ // next keystroke continues the path.
246+ walkingIn := strings.HasSuffix(c.insert, "/")
247+ if !walkingIn {
248+ insert = append(insert, ' ')
249+ }
250+ updated := append([]rune{}, line[:tok.from]...)
251+ updated = append(updated, insert...)
252+ updated = append(updated, line[tok.to:]...)
253+ v.input[v.cursor] = string(updated)
254+ v.column = tok.from + len(insert)
255+ v.pastes.prune(v.Input())
256+ if walkingIn {
257+ // Completing a directory is a step, not an answer: leave the popup
258+ // open on its contents so Tab walks down the tree.
259+ v.pick.dismiss("")
260+ return
82261 }
83- v.setInput(text)
84- v.pick.dismiss(text)
262+ v.pick.dismiss(v.Input())
85263 }
86264
87-// typedIsComplete reports whether the box holds exactly the highlighted
88-// command's name: Enter then means "send", not "complete".
265+// typedIsComplete reports whether the box already holds exactly the
266+// highlighted choice: Enter then means "send", not "complete".
267+//
268+// Only ever true for a command. A `@` or `$` token is part of a sentence, so
269+// Enter on one completes it — the message is not finished.
89270 func (v *View) typedIsComplete() bool {
90271 c, ok := v.selectedChoice()
91- return ok && v.input[0] == c.Slash()
272+ if !ok {
273+ return false
274+ }
275+ tok, ok := v.tokenAtCursor()
276+ return ok && tok.trigger == '/' && len(v.input) == 1 && v.input[0] == c.insert
92277 }
@@ -1,45 +1,191 @@
1 package tui1 package tui
2 2
3 import (3 import (
4+ "os"
5+ "path/filepath"
6+ "sort"
4 "strings"7 "strings"
5 8
6 "mm/internal/command"9 "mm/internal/command"
10+ "mm/internal/skills"
7 )11 )
8 12
9 // pickerRows is how many choices the popup shows at most.13 // pickerRows is how many choices the popup shows at most.
10 const pickerRows = 814 const pickerRows = 8
11 15
12-// picker is the popup that opens over the conversation while a "/" command16+// The popup that completes what is being typed. Three triggers, three
13-// is being typed at the very start of the box: it lists the window's17+// sources, one mechanism:
14-// commands, narrowed by what has been typed, and completes the chosen one.18+//
15-// Escape dismisses it until the text changes.19+// / the window's commands only as the whole first word of the box
20+// @ files and directories anywhere in the text
21+// $ the skills of the catalogue anywhere in the text
22+//
23+// The `/` case is the original one and keeps its rule: a command is the whole
24+// message, so it only makes sense at the start, and completing it replaces
25+// the box. The other two are mid-sentence — "explain @internal/acp/acp.go",
26+// "use $greetings Bob" — so the popup reads the TOKEN the cursor is in and
27+// completes only that token, leaving the rest of the sentence alone.
28+//
29+// Both mid-sentence triggers keep their prefix when completed. For `@` it is
30+// what `mention.Expand` looks for; for `$` it is what marks a skill to be
31+// carried out (see skills.ExpandRefs), and the cursor lands after the name so
32+// the parameters can be typed straight away.
33+
34+// choice is one row of the popup: what goes in the box, and what is shown
35+// beside it.
36+type choice struct {
37+ insert string // the full token, prefix included
38+ label string // the left column
39+ detail string // the right column, may be empty
40+}
41+
42+// picker remembers the selection and Escape's "not now".
16 type picker struct {43 type picker struct {
17 selected int44 selected int
18 dismissedFor string // the input text Escape was pressed on; "" = not dismissed45 dismissedFor string // the input text Escape was pressed on; "" = not dismissed
19 dismissed bool46 dismissed bool
20 }47 }
21 48
22-// choices returns the commands the picker offers for what is typed: the49+// token is the word the popup is completing: where it sits in the current
23-// window's commands whose name starts with the word after the slash. Nothing50+// line, and what has been typed of it.
24-// when the box holds more than one line, does not start with "/", already51+type token struct {
25-// has a space (the command is complete), or was dismissed on this text.52+ trigger byte
26-func (v *View) choices() []command.Command {53+ from int // rune index in the line where the trigger sits
27- if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") {54+ to int // rune index just past what has been typed
28- return nil55+ partial string // what follows the trigger, as typed
56+}
57+
58+// tokenAtCursor finds the trigger token the cursor is inside or at the end
59+// of. Nothing when the cursor is not in one: a `@` or `$` that is glued to
60+// the end of a word is not a trigger ("me@example.com", "5$"), and neither
61+// is a trigger whose token the cursor has left.
62+func (v *View) tokenAtCursor() (token, bool) {
63+ if v.cursor >= len(v.input) {
64+ return token{}, false
29 }65 }
30- if v.pick.dismissed && v.pick.dismissedFor == v.input[0] {66+ line := []rune(v.input[v.cursor])
67+ col := min(v.column, len(line))
68+
69+ // Walk back to the start of the word the cursor is in.
70+ i := col
71+ for i > 0 && !isSpaceRune(line[i-1]) {
72+ i--
73+ }
74+ if i >= col && i >= len(line) {
75+ return token{}, false
76+ }
77+ if i >= len(line) || (line[i] != '@' && line[i] != '$' && line[i] != '/') {
78+ return token{}, false
79+ }
80+ // The word must END at the cursor: completing a token the cursor has
81+ // already passed would rewrite text the user moved away from.
82+ end := col
83+ if end < len(line) && !isSpaceRune(line[end]) {
84+ return token{}, false
85+ }
86+ return token{trigger: byte(line[i]), from: i, to: end, partial: string(line[i+1 : end])}, true
87+}
88+
89+func isSpaceRune(r rune) bool { return r == ' ' || r == '\t' }
90+
91+// choices returns what the popup offers, or nothing when it must stay shut.
92+func (v *View) choices() []choice {
93+ tok, ok := v.tokenAtCursor()
94+ if !ok || (v.pick.dismissed && v.pick.dismissedFor == v.Input()) {
31 return nil95 return nil
32 }96 }
33- word := strings.TrimPrefix(v.input[0], "/")97+ var out []choice
34- var out []command.Command98+ switch tok.trigger {
99+ case '/':
100+ // A command is the whole message: only offered as the first word of
101+ // a one-line box, exactly as before.
102+ if len(v.input) == 1 && tok.from == 0 {
103+ out = commandChoices(tok.partial)
104+ }
105+ case '$':
106+ out = skillChoices(v.skills, tok.partial)
107+ case '@':
108+ out = pathChoices(v.cwd, tok.partial)
109+ }
110+ return out
111+}
112+
113+func commandChoices(partial string) []choice {
114+ var out []choice
35 for _, c := range command.For(command.Window) {115 for _, c := range command.For(command.Window) {
36- if strings.HasPrefix(c.Name, word) {116+ if strings.HasPrefix(c.Name, partial) {
37- out = append(out, c)117+ out = append(out, choice{insert: c.Slash(), label: c.Slash(), detail: c.Description})
38 }118 }
39 }119 }
40 return out120 return out
41 }121 }
42 122
123+// skillChoices narrows the catalogue by what follows the `$`. The `$` is kept
124+// in the inserted text: it is what tells the turn this skill is to be carried
125+// out, and it leaves the cursor in front of the parameters.
126+func skillChoices(list []skills.Skill, partial string) []choice {
127+ var out []choice
128+ for _, s := range list {
129+ if !strings.HasPrefix(strings.ToLower(s.Name), strings.ToLower(partial)) {
130+ continue
131+ }
132+ out = append(out, choice{insert: "$" + s.Name, label: "$" + s.Name, detail: s.Description})
133+ }
134+ return out
135+}
136+
137+// pathChoices lists what is on disk under the directory the partial path
138+// names, narrowed by its last segment. A directory is offered with a
139+// trailing "/" so the next keystroke walks into it.
140+//
141+// It reads ONE directory per keystroke and stops at pickerRows*4 entries:
142+// the popup shows eight, and a node_modules must not stall the window.
143+func pathChoices(cwd, partial string) []choice {
144+ dir, prefix := filepath.Split(partial)
145+ base := filepath.Join(cwd, dir)
146+ if strings.HasPrefix(dir, "~/") {
147+ home, err := os.UserHomeDir()
148+ if err != nil {
149+ return nil
150+ }
151+ base = filepath.Join(home, strings.TrimPrefix(dir, "~/"))
152+ } else if filepath.IsAbs(dir) {
153+ base = dir
154+ }
155+
156+ entries, err := os.ReadDir(base)
157+ if err != nil {
158+ return nil
159+ }
160+ var out []choice
161+ for _, e := range entries {
162+ name := e.Name()
163+ if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") {
164+ continue // hidden files only once the user asks for them
165+ }
166+ if !strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) {
167+ continue
168+ }
169+ shown, detail := name, "file"
170+ if e.IsDir() {
171+ shown, detail = name+"/", "directory"
172+ }
173+ out = append(out, choice{insert: "@" + dir + shown, label: shown, detail: detail})
174+ if len(out) >= pickerRows*4 {
175+ break
176+ }
177+ }
178+ // Directories first, then names: walking a tree is the common case.
179+ sort.SliceStable(out, func(i, j int) bool {
180+ di, dj := strings.HasSuffix(out[i].label, "/"), strings.HasSuffix(out[j].label, "/")
181+ if di != dj {
182+ return di
183+ }
184+ return out[i].label < out[j].label
185+ })
186+ return out
187+}
188+
43 // pickerOpen reports whether the popup is showing.189 // pickerOpen reports whether the popup is showing.
44 func (v *View) pickerOpen() bool { return len(v.choices()) > 0 }190 func (v *View) pickerOpen() bool { return len(v.choices()) > 0 }
45 191
@@ -59,34 +205,73 @@ func (v *View) movePick(by int) {
59 v.pick.selected = ((v.pick.selected+by)%n + n) % n205 v.pick.selected = ((v.pick.selected+by)%n + n) % n
60 }206 }
61 207
62-// selectedChoice is the highlighted command, if the popup is open.208+// selectedChoice is the highlighted row, if the popup is open.
63-func (v *View) selectedChoice() (command.Command, bool) {209+func (v *View) selectedChoice() (choice, bool) {
64 cs := v.choices()210 cs := v.choices()
65 if len(cs) == 0 {211 if len(cs) == 0 {
66- return command.Command{}, false212+ return choice{}, false
67 }213 }
68 return cs[min(v.pick.selected, len(cs)-1)], true214 return cs[min(v.pick.selected, len(cs)-1)], true
69 }215 }
70 216
71-// complete puts the highlighted command in the box, with a space after it217+// complete puts the highlighted choice in the box and closes the popup.
72-// when it takes an argument, and closes the popup. Enter on a command that is218+//
73-// already complete sends it instead: see events.go.219+// A command replaces the whole box, since a command IS the message. A `@` or
220+// `$` token replaces only itself, and the cursor lands just after it — after
221+// a directory's "/" there is more path to type, after a skill's name there
222+// are parameters, and after a file there is the rest of the sentence.
74 func (v *View) complete() {223 func (v *View) complete() {
75 c, ok := v.selectedChoice()224 c, ok := v.selectedChoice()
76 if !ok {225 if !ok {
77 return226 return
78 }227 }
79- text := c.Slash()228+ tok, ok := v.tokenAtCursor()
80- if c.TakesArg {229+ if !ok {
81- text += " "230+ return
231+ }
232+ if tok.trigger == '/' {
233+ text := c.insert
234+ if cmd, _, found := command.Parse(text); found && cmd.TakesArg {
235+ text += " "
236+ }
237+ v.setInput(text)
238+ v.pick.dismiss(text)
239+ return
240+ }
241+
242+ line := []rune(v.input[v.cursor])
243+ insert := []rune(c.insert)
244+ // A file or a skill is followed by a space; a directory is not, so the
245+ // next keystroke continues the path.
246+ walkingIn := strings.HasSuffix(c.insert, "/")
247+ if !walkingIn {
248+ insert = append(insert, ' ')
249+ }
250+ updated := append([]rune{}, line[:tok.from]...)
251+ updated = append(updated, insert...)
252+ updated = append(updated, line[tok.to:]...)
253+ v.input[v.cursor] = string(updated)
254+ v.column = tok.from + len(insert)
255+ v.pastes.prune(v.Input())
256+ if walkingIn {
257+ // Completing a directory is a step, not an answer: leave the popup
258+ // open on its contents so Tab walks down the tree.
259+ v.pick.dismiss("")
260+ return
82 }261 }
83- v.setInput(text)262+ v.pick.dismiss(v.Input())
84- v.pick.dismiss(text)
85 }263 }
86 264
87-// typedIsComplete reports whether the box holds exactly the highlighted265+// typedIsComplete reports whether the box already holds exactly the
88-// command's name: Enter then means "send", not "complete".266+// highlighted choice: Enter then means "send", not "complete".
267+//
268+// Only ever true for a command. A `@` or `$` token is part of a sentence, so
269+// Enter on one completes it — the message is not finished.
89 func (v *View) typedIsComplete() bool {270 func (v *View) typedIsComplete() bool {
90 c, ok := v.selectedChoice()271 c, ok := v.selectedChoice()
91- return ok && v.input[0] == c.Slash()272+ if !ok {
273+ return false
274+ }
275+ tok, ok := v.tokenAtCursor()
276+ return ok && tok.trigger == '/' && len(v.input) == 1 && v.input[0] == c.insert
92 }277 }
added internal/tui/pickers_test.go +197 -0
new file mode 100644
@@ -0,0 +1,197 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/skills"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// withSources gives a harness the two things the `@` and `$` pickers read.
15+func withSources(t *testing.T, hs *harness, cwd string) {
16+ t.Helper()
17+ hs.v.cwd = cwd
18+ hs.v.skills = []skills.Skill{
19+ {Name: "bg-jobs", Description: "background jobs"},
20+ {Name: "greetings", Description: "greet somebody"},
21+ {Name: "grep-like", Description: "a second g, to check narrowing"},
22+ }
23+}
24+
25+func labels(cs []choice) []string {
26+ out := make([]string, 0, len(cs))
27+ for _, c := range cs {
28+ out = append(out, c.label)
29+ }
30+ return out
31+}
32+
33+// The `$` picker lists the catalogue, narrows as you type, and Tab writes the
34+// name with the `$` KEPT — that prefix is what marks the skill to be carried
35+// out — leaving the cursor after it so parameters can be typed straight away.
36+func TestSkillPicker(t *testing.T) {
37+ hs := newHarness(t, 70, 20)
38+ withSources(t, hs, t.TempDir())
39+
40+ hs.typeText("use $")
41+ if got := len(hs.v.choices()); got != 3 {
42+ t.Fatalf("%d choices on '$', want the whole catalogue: %v", got, labels(hs.v.choices()))
43+ }
44+ if text := hs.screenText(); !strings.Contains(text, "$greetings") || !strings.Contains(text, "greet somebody") {
45+ t.Errorf("the popup is not drawn:\n%s", text)
46+ }
47+
48+ hs.typeText("gr")
49+ if got := labels(hs.v.choices()); len(got) != 2 {
50+ t.Fatalf("choices for '$gr' = %v, want greetings and grep-like", got)
51+ }
52+ hs.typeText("ee")
53+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "$greetings" {
54+ t.Fatalf("choices for '$gree' = %v", got)
55+ }
56+
57+ hs.key(tcell.KeyTab, tcell.ModNone)
58+ if hs.v.Input() != "use $greetings " {
59+ t.Errorf("after Tab the box is %q, want the name with its $ and a space", hs.v.Input())
60+ }
61+ if hs.v.pickerOpen() {
62+ t.Error("the popup stayed open after completing")
63+ }
64+
65+ // The cursor is after the name, so parameters go straight in.
66+ hs.typeText("Bob Morane")
67+ if hs.v.Input() != "use $greetings Bob Morane" {
68+ t.Errorf("box = %q", hs.v.Input())
69+ }
70+}
71+
72+// Enter completes a `$` token rather than sending: the message is not
73+// finished, the parameters still have to be typed.
74+func TestSkillPickerEnterCompletesRatherThanSends(t *testing.T) {
75+ hs := newHarness(t, 70, 20)
76+ withSources(t, hs, t.TempDir())
77+
78+ hs.typeText("use $gree")
79+ hs.key(tcell.KeyEnter, tcell.ModNone)
80+ if len(hs.sent) != 0 {
81+ t.Fatalf("Enter sent %q instead of completing", hs.sent)
82+ }
83+ if hs.v.Input() != "use $greetings " {
84+ t.Errorf("box = %q", hs.v.Input())
85+ }
86+}
87+
88+// A `$` that names nothing offers nothing — `$HOME` in a question is a shell
89+// variable, not a skill, and the popup must not jump in front of it.
90+func TestSkillPickerStaysShutOnAShellVariable(t *testing.T) {
91+ hs := newHarness(t, 70, 20)
92+ withSources(t, hs, t.TempDir())
93+
94+ hs.typeText("echo $HOME")
95+ if hs.v.pickerOpen() {
96+ t.Errorf("the popup opened on $HOME: %v", labels(hs.v.choices()))
97+ }
98+}
99+
100+// The `@` picker reads the working directory, offers directories first with a
101+// trailing slash so the next keystroke walks in, and completes only the token.
102+func TestPathPicker(t *testing.T) {
103+ dir := t.TempDir()
104+ for _, d := range []string{"internal", "docs"} {
105+ if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
106+ t.Fatal(err)
107+ }
108+ }
109+ for _, f := range []string{"main.go", "go.mod", ".hidden"} {
110+ if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if err := os.WriteFile(filepath.Join(dir, "internal", "acp.go"), []byte("x"), 0o644); err != nil {
115+ t.Fatal(err)
116+ }
117+
118+ hs := newHarness(t, 70, 20)
119+ withSources(t, hs, dir)
120+
121+ hs.typeText("explain @")
122+ got := labels(hs.v.choices())
123+ if len(got) != 4 {
124+ t.Fatalf("choices on '@' = %v, want the two directories and two files (not .hidden)", got)
125+ }
126+ if got[0] != "docs/" || got[1] != "internal/" {
127+ t.Errorf("directories are not first: %v", got)
128+ }
129+
130+ // Narrowing, then completing a DIRECTORY: no trailing space, so the path
131+ // can be continued.
132+ hs.typeText("int")
133+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "internal/" {
134+ t.Fatalf("choices for '@int' = %v", got)
135+ }
136+ hs.key(tcell.KeyTab, tcell.ModNone)
137+ if hs.v.Input() != "explain @internal/" {
138+ t.Fatalf("after completing a directory: %q", hs.v.Input())
139+ }
140+
141+ // One more level, and a FILE completes with a space.
142+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "acp.go" {
143+ t.Fatalf("choices inside internal/ = %v", got)
144+ }
145+ hs.key(tcell.KeyTab, tcell.ModNone)
146+ if hs.v.Input() != "explain @internal/acp.go " {
147+ t.Errorf("after completing a file: %q", hs.v.Input())
148+ }
149+
150+ hs.typeText("please")
151+ if hs.v.Input() != "explain @internal/acp.go please" {
152+ t.Errorf("the rest of the sentence: %q", hs.v.Input())
153+ }
154+}
155+
156+// A hidden file is offered only once the user asks for one.
157+func TestPathPickerHidesDotfilesUntilAsked(t *testing.T) {
158+ dir := t.TempDir()
159+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("x"), 0o644); err != nil {
160+ t.Fatal(err)
161+ }
162+ hs := newHarness(t, 70, 20)
163+ withSources(t, hs, dir)
164+
165+ hs.typeText("look @")
166+ if hs.v.pickerOpen() {
167+ t.Errorf("a dotfile was offered unasked: %v", labels(hs.v.choices()))
168+ }
169+ hs.typeText(".")
170+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != ".gitignore" {
171+ t.Errorf("choices for '@.' = %v", got)
172+ }
173+}
174+
175+// An @ glued to the end of a word is not a trigger: an e-mail address must
176+// not open a file picker.
177+func TestPickerIgnoresAGluedTrigger(t *testing.T) {
178+ hs := newHarness(t, 70, 20)
179+ withSources(t, hs, t.TempDir())
180+
181+ hs.typeText("mail bob@")
182+ if hs.v.pickerOpen() {
183+ t.Errorf("the popup opened inside an e-mail address: %v", labels(hs.v.choices()))
184+ }
185+}
186+
187+// A `/` is still a command only as the whole first word: "see /tmp" is a path
188+// in a sentence, not a command.
189+func TestSlashIsACommandOnlyAtTheStart(t *testing.T) {
190+ hs := newHarness(t, 70, 20)
191+ withSources(t, hs, t.TempDir())
192+
193+ hs.typeText("see /")
194+ if hs.v.pickerOpen() {
195+ t.Errorf("the command popup opened mid-sentence: %v", labels(hs.v.choices()))
196+ }
197+}
new file mode 100644
@@ -0,0 +1,197 @@
1+package tui
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+ "testing"
8+
9+ "mm/internal/skills"
10+
11+ "github.com/gdamore/tcell/v2"
12+)
13+
14+// withSources gives a harness the two things the `@` and `$` pickers read.
15+func withSources(t *testing.T, hs *harness, cwd string) {
16+ t.Helper()
17+ hs.v.cwd = cwd
18+ hs.v.skills = []skills.Skill{
19+ {Name: "bg-jobs", Description: "background jobs"},
20+ {Name: "greetings", Description: "greet somebody"},
21+ {Name: "grep-like", Description: "a second g, to check narrowing"},
22+ }
23+}
24+
25+func labels(cs []choice) []string {
26+ out := make([]string, 0, len(cs))
27+ for _, c := range cs {
28+ out = append(out, c.label)
29+ }
30+ return out
31+}
32+
33+// The `$` picker lists the catalogue, narrows as you type, and Tab writes the
34+// name with the `$` KEPT — that prefix is what marks the skill to be carried
35+// out — leaving the cursor after it so parameters can be typed straight away.
36+func TestSkillPicker(t *testing.T) {
37+ hs := newHarness(t, 70, 20)
38+ withSources(t, hs, t.TempDir())
39+
40+ hs.typeText("use $")
41+ if got := len(hs.v.choices()); got != 3 {
42+ t.Fatalf("%d choices on '$', want the whole catalogue: %v", got, labels(hs.v.choices()))
43+ }
44+ if text := hs.screenText(); !strings.Contains(text, "$greetings") || !strings.Contains(text, "greet somebody") {
45+ t.Errorf("the popup is not drawn:\n%s", text)
46+ }
47+
48+ hs.typeText("gr")
49+ if got := labels(hs.v.choices()); len(got) != 2 {
50+ t.Fatalf("choices for '$gr' = %v, want greetings and grep-like", got)
51+ }
52+ hs.typeText("ee")
53+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "$greetings" {
54+ t.Fatalf("choices for '$gree' = %v", got)
55+ }
56+
57+ hs.key(tcell.KeyTab, tcell.ModNone)
58+ if hs.v.Input() != "use $greetings " {
59+ t.Errorf("after Tab the box is %q, want the name with its $ and a space", hs.v.Input())
60+ }
61+ if hs.v.pickerOpen() {
62+ t.Error("the popup stayed open after completing")
63+ }
64+
65+ // The cursor is after the name, so parameters go straight in.
66+ hs.typeText("Bob Morane")
67+ if hs.v.Input() != "use $greetings Bob Morane" {
68+ t.Errorf("box = %q", hs.v.Input())
69+ }
70+}
71+
72+// Enter completes a `$` token rather than sending: the message is not
73+// finished, the parameters still have to be typed.
74+func TestSkillPickerEnterCompletesRatherThanSends(t *testing.T) {
75+ hs := newHarness(t, 70, 20)
76+ withSources(t, hs, t.TempDir())
77+
78+ hs.typeText("use $gree")
79+ hs.key(tcell.KeyEnter, tcell.ModNone)
80+ if len(hs.sent) != 0 {
81+ t.Fatalf("Enter sent %q instead of completing", hs.sent)
82+ }
83+ if hs.v.Input() != "use $greetings " {
84+ t.Errorf("box = %q", hs.v.Input())
85+ }
86+}
87+
88+// A `$` that names nothing offers nothing — `$HOME` in a question is a shell
89+// variable, not a skill, and the popup must not jump in front of it.
90+func TestSkillPickerStaysShutOnAShellVariable(t *testing.T) {
91+ hs := newHarness(t, 70, 20)
92+ withSources(t, hs, t.TempDir())
93+
94+ hs.typeText("echo $HOME")
95+ if hs.v.pickerOpen() {
96+ t.Errorf("the popup opened on $HOME: %v", labels(hs.v.choices()))
97+ }
98+}
99+
100+// The `@` picker reads the working directory, offers directories first with a
101+// trailing slash so the next keystroke walks in, and completes only the token.
102+func TestPathPicker(t *testing.T) {
103+ dir := t.TempDir()
104+ for _, d := range []string{"internal", "docs"} {
105+ if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
106+ t.Fatal(err)
107+ }
108+ }
109+ for _, f := range []string{"main.go", "go.mod", ".hidden"} {
110+ if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
111+ t.Fatal(err)
112+ }
113+ }
114+ if err := os.WriteFile(filepath.Join(dir, "internal", "acp.go"), []byte("x"), 0o644); err != nil {
115+ t.Fatal(err)
116+ }
117+
118+ hs := newHarness(t, 70, 20)
119+ withSources(t, hs, dir)
120+
121+ hs.typeText("explain @")
122+ got := labels(hs.v.choices())
123+ if len(got) != 4 {
124+ t.Fatalf("choices on '@' = %v, want the two directories and two files (not .hidden)", got)
125+ }
126+ if got[0] != "docs/" || got[1] != "internal/" {
127+ t.Errorf("directories are not first: %v", got)
128+ }
129+
130+ // Narrowing, then completing a DIRECTORY: no trailing space, so the path
131+ // can be continued.
132+ hs.typeText("int")
133+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "internal/" {
134+ t.Fatalf("choices for '@int' = %v", got)
135+ }
136+ hs.key(tcell.KeyTab, tcell.ModNone)
137+ if hs.v.Input() != "explain @internal/" {
138+ t.Fatalf("after completing a directory: %q", hs.v.Input())
139+ }
140+
141+ // One more level, and a FILE completes with a space.
142+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "acp.go" {
143+ t.Fatalf("choices inside internal/ = %v", got)
144+ }
145+ hs.key(tcell.KeyTab, tcell.ModNone)
146+ if hs.v.Input() != "explain @internal/acp.go " {
147+ t.Errorf("after completing a file: %q", hs.v.Input())
148+ }
149+
150+ hs.typeText("please")
151+ if hs.v.Input() != "explain @internal/acp.go please" {
152+ t.Errorf("the rest of the sentence: %q", hs.v.Input())
153+ }
154+}
155+
156+// A hidden file is offered only once the user asks for one.
157+func TestPathPickerHidesDotfilesUntilAsked(t *testing.T) {
158+ dir := t.TempDir()
159+ if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("x"), 0o644); err != nil {
160+ t.Fatal(err)
161+ }
162+ hs := newHarness(t, 70, 20)
163+ withSources(t, hs, dir)
164+
165+ hs.typeText("look @")
166+ if hs.v.pickerOpen() {
167+ t.Errorf("a dotfile was offered unasked: %v", labels(hs.v.choices()))
168+ }
169+ hs.typeText(".")
170+ if got := labels(hs.v.choices()); len(got) != 1 || got[0] != ".gitignore" {
171+ t.Errorf("choices for '@.' = %v", got)
172+ }
173+}
174+
175+// An @ glued to the end of a word is not a trigger: an e-mail address must
176+// not open a file picker.
177+func TestPickerIgnoresAGluedTrigger(t *testing.T) {
178+ hs := newHarness(t, 70, 20)
179+ withSources(t, hs, t.TempDir())
180+
181+ hs.typeText("mail bob@")
182+ if hs.v.pickerOpen() {
183+ t.Errorf("the popup opened inside an e-mail address: %v", labels(hs.v.choices()))
184+ }
185+}
186+
187+// A `/` is still a command only as the whole first word: "see /tmp" is a path
188+// in a sentence, not a command.
189+func TestSlashIsACommandOnlyAtTheStart(t *testing.T) {
190+ hs := newHarness(t, 70, 20)
191+ withSources(t, hs, t.TempDir())
192+
193+ hs.typeText("see /")
194+ if hs.v.pickerOpen() {
195+ t.Errorf("the command popup opened mid-sentence: %v", labels(hs.v.choices()))
196+ }
197+}
modified internal/tui/tui.go +1 -0
@@ -74,6 +74,7 @@ func newApp(screen tcell.Screen, opts Options) *app {
7474 copy: func() { r.copy("") },
7575 })
7676 v.previewLines = config.Cfg.PreviewLines
77+ v.skills, v.cwd = opts.Skills, opts.Cwd
7778 if opts.Banner != "" {
7879 t.Notice(opts.Banner)
7980 }
@@ -74,6 +74,7 @@ func newApp(screen tcell.Screen, opts Options) *app {
74 copy: func() { r.copy("") },74 copy: func() { r.copy("") },
75 })75 })
76 v.previewLines = config.Cfg.PreviewLines76 v.previewLines = config.Cfg.PreviewLines
77+ v.skills, v.cwd = opts.Skills, opts.Cwd
77 if opts.Banner != "" {78 if opts.Banner != "" {
78 t.Notice(opts.Banner)79 t.Notice(opts.Banner)
79 }80 }
modified internal/tui/turn.go +12 -0
@@ -13,6 +13,7 @@ import (
1313 "mm/internal/engine"
1414 "mm/internal/mention"
1515 "mm/internal/session"
16+ "mm/internal/skills"
1617 "mm/internal/store"
1718 "mm/internal/ui"
1819
@@ -43,6 +44,10 @@ type Options struct {
4344 AgentName string // "mm"
4445 ModelName string
4546 Provider string
47+ // Skills is the catalogue the `$` picker offers, and what a `$name` in a
48+ // question is resolved against. Empty means no `$` picker and no `$`
49+ // expansion, which is right for an agent that ships no skill.
50+ Skills []skills.Skill
4651 // Version is what the status bar shows — the window's only place for it,
4752 // since the banner scrolls away with the conversation. Empty is allowed:
4853 // the bar then shows the counters alone.
@@ -141,7 +146,14 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" }
141146 func (r *runner) startTurn(p Prompt) {
142147 r.t.AddUserFull(p.Display(), p.Text())
143148 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
149+ // `$skill` is resolved on the same text and for the same reason as
150+ // `@path`: a pasted block is data the user brought, not a request to run
151+ // whatever procedure it happens to name.
152+ expanded, refs := skills.ExpandRefs(expanded, r.opts.Skills)
144153 input := p.Expand(expanded)
154+ for _, ref := range refs {
155+ r.t.Notice("📖 $" + ref.Name)
156+ }
145157 for _, a := range attached {
146158 r.t.Notice("📎 " + a.Path)
147159 }
@@ -13,6 +13,7 @@ import (
13 "mm/internal/engine"13 "mm/internal/engine"
14 "mm/internal/mention"14 "mm/internal/mention"
15 "mm/internal/session"15 "mm/internal/session"
16+ "mm/internal/skills"
16 "mm/internal/store"17 "mm/internal/store"
17 "mm/internal/ui"18 "mm/internal/ui"
18 19
@@ -43,6 +44,10 @@ type Options struct {
43 AgentName string // "mm"44 AgentName string // "mm"
44 ModelName string45 ModelName string
45 Provider string46 Provider string
47+ // Skills is the catalogue the `$` picker offers, and what a `$name` in a
48+ // question is resolved against. Empty means no `$` picker and no `$`
49+ // expansion, which is right for an agent that ships no skill.
50+ Skills []skills.Skill
46 // Version is what the status bar shows — the window's only place for it,51 // Version is what the status bar shows — the window's only place for it,
47 // since the banner scrolls away with the conversation. Empty is allowed:52 // since the banner scrolls away with the conversation. Empty is allowed:
48 // the bar then shows the counters alone.53 // the bar then shows the counters alone.
@@ -141,7 +146,14 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" }
141 func (r *runner) startTurn(p Prompt) {146 func (r *runner) startTurn(p Prompt) {
142 r.t.AddUserFull(p.Display(), p.Text())147 r.t.AddUserFull(p.Display(), p.Text())
143 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)148 expanded, attached := mention.Expand(p.Typed, r.opts.Cwd)
149+ // `$skill` is resolved on the same text and for the same reason as
150+ // `@path`: a pasted block is data the user brought, not a request to run
151+ // whatever procedure it happens to name.
152+ expanded, refs := skills.ExpandRefs(expanded, r.opts.Skills)
144 input := p.Expand(expanded)153 input := p.Expand(expanded)
154+ for _, ref := range refs {
155+ r.t.Notice("📖 $" + ref.Name)
156+ }
145 for _, a := range attached {157 for _, a := range attached {
146 r.t.Notice("📎 " + a.Path)158 r.t.Notice("📎 " + a.Path)
147 }159 }
modified internal/tui/view.go +7 -0
@@ -4,6 +4,8 @@ import (
44 "strings"
55 "time"
66
7+ "mm/internal/skills"
8+
79 "github.com/gdamore/tcell/v2"
810 )
911
@@ -66,6 +68,11 @@ type View struct {
6668
6769 agentName string
6870 previewLines int
71+ // What the picker completes against. Static for the window's life, so
72+ // set once at construction rather than refreshed per frame: the skills
73+ // catalogue is read at start-up, and the working directory does not move.
74+ skills []skills.Skill
75+ cwd string
6976
7077 // What the runner reports for the chrome, refreshed before each frame.
7178 header string // the header line
@@ -4,6 +4,8 @@ import (
4 "strings"4 "strings"
5 "time"5 "time"
6 6
7+ "mm/internal/skills"
8+
7 "github.com/gdamore/tcell/v2"9 "github.com/gdamore/tcell/v2"
8 )10 )
9 11
@@ -66,6 +68,11 @@ type View struct {
66 68
67 agentName string69 agentName string
68 previewLines int70 previewLines int
71+ // What the picker completes against. Static for the window's life, so
72+ // set once at construction rather than refreshed per frame: the skills
73+ // catalogue is read at start-up, and the working directory does not move.
74+ skills []skills.Skill
75+ cwd string
69 76
70 // What the runner reports for the chrome, refreshed before each frame.77 // What the runner reports for the chrome, refreshed before each frame.
71 header string // the header line78 header string // the header line
modified internal/tui/view_test.go +3 -3
@@ -171,7 +171,7 @@ func TestPicker(t *testing.T) {
171171 }
172172 hs.typeText("re")
173173 cs := hs.v.choices()
174- if len(cs) != 1 || cs[0].Name != "resume" {
174+ if len(cs) != 1 || cs[0].insert != "/resume" {
175175 t.Fatalf("choices for /re: %+v", cs)
176176 }
177177 hs.key(tcell.KeyTab, tcell.ModNone)
@@ -197,8 +197,8 @@ func TestPicker(t *testing.T) {
197197 // Down wraps; Escape dismisses for this text only.
198198 hs.typeText("/")
199199 hs.key(tcell.KeyDown, tcell.ModNone)
200- if c, _ := hs.v.selectedChoice(); c.Name != "abort" {
201- t.Errorf("Down selected %q", c.Name)
200+ if c, _ := hs.v.selectedChoice(); c.insert != "/abort" {
201+ t.Errorf("Down selected %q", c.insert)
202202 }
203203 hs.key(tcell.KeyEscape, tcell.ModNone)
204204 if hs.v.pickerOpen() || hs.cancelled != 0 {
@@ -171,7 +171,7 @@ func TestPicker(t *testing.T) {
171 }171 }
172 hs.typeText("re")172 hs.typeText("re")
173 cs := hs.v.choices()173 cs := hs.v.choices()
174- if len(cs) != 1 || cs[0].Name != "resume" {174+ if len(cs) != 1 || cs[0].insert != "/resume" {
175 t.Fatalf("choices for /re: %+v", cs)175 t.Fatalf("choices for /re: %+v", cs)
176 }176 }
177 hs.key(tcell.KeyTab, tcell.ModNone)177 hs.key(tcell.KeyTab, tcell.ModNone)
@@ -197,8 +197,8 @@ func TestPicker(t *testing.T) {
197 // Down wraps; Escape dismisses for this text only.197 // Down wraps; Escape dismisses for this text only.
198 hs.typeText("/")198 hs.typeText("/")
199 hs.key(tcell.KeyDown, tcell.ModNone)199 hs.key(tcell.KeyDown, tcell.ModNone)
200- if c, _ := hs.v.selectedChoice(); c.Name != "abort" {200+ if c, _ := hs.v.selectedChoice(); c.insert != "/abort" {
201- t.Errorf("Down selected %q", c.Name)201+ t.Errorf("Down selected %q", c.insert)
202 }202 }
203 hs.key(tcell.KeyEscape, tcell.ModNone)203 hs.key(tcell.KeyEscape, tcell.ModNone)
204 if hs.v.pickerOpen() || hs.cancelled != 0 {204 if hs.v.pickerOpen() || hs.cancelled != 0 {
modified main.go +3 -0
@@ -170,6 +170,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
170170 AgentName: "mm",
171171 ModelName: config.Cfg.Model,
172172 Provider: e.Backend.Provider,
173+ Skills: skills.Declared(),
173174 Version: version.Short(),
174175 Banner: banner,
175176 })
@@ -218,6 +219,8 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
218219 // The turn recap shows `📖 0 skill(s)` only where a skill could have been
219220 // used — see engine.SkillsAvailable.
220221 engine.DeclareSkills(set.skillCount > 0)
222+ // The catalogue every front end resolves `$skill` against.
223+ skills.Declare(set.skills)
221224 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
222225 set.tools = append(set.tools, skillTool)
223226 set.names = append(set.names, "read_skill")
@@ -170,6 +170,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d
170 AgentName: "mm",170 AgentName: "mm",
171 ModelName: config.Cfg.Model,171 ModelName: config.Cfg.Model,
172 Provider: e.Backend.Provider,172 Provider: e.Backend.Provider,
173+ Skills: skills.Declared(),
173 Version: version.Short(),174 Version: version.Short(),
174 Banner: banner,175 Banner: banner,
175 })176 })
@@ -218,6 +219,8 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet {
218 // The turn recap shows `📖 0 skill(s)` only where a skill could have been219 // The turn recap shows `📖 0 skill(s)` only where a skill could have been
219 // used — see engine.SkillsAvailable.220 // used — see engine.SkillsAvailable.
220 engine.DeclareSkills(set.skillCount > 0)221 engine.DeclareSkills(set.skillCount > 0)
222+ // The catalogue every front end resolves `$skill` against.
223+ skills.Declare(set.skills)
221 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {224 if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil {
222 set.tools = append(set.tools, skillTool)225 set.tools = append(set.tools, skillTool)
223 set.names = append(set.names, "read_skill")226 set.names = append(set.names, "read_skill")