bots-garden/mini-mepublic Fork
⑂ main
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

File selector and Skill selector #7

Merged@k33g wants to merge feature/selectors into main
Merged as ec8e46ab1267.
24 files changed
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
Large diff collapsed to keep the page fast — load it when you need it.
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
Large diff collapsed to keep the page fast — load it when you need it.
modified internal/tui/view.go +7 -0
Large diff collapsed to keep the page fast — load it when you need it.
modified internal/tui/view_test.go +3 -3
Large diff collapsed to keep the page fast — load it when you need it.
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")