File selector and Skill selector #7
Merged@k33g wants to merge
feature/selectors into mainMerged as
ec8e46ab1267.24 files changed
modified
.tickets/issues/0004-do-we-need-mcp.yaml +4 -4 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-15T22:38:45.129Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | |
| 9 | +updatedAt: 2026-09-21T19:04:58.320Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | |
| 11 | +milestone: v0.04 | |
| 12 | 12 | body: | |
| 13 | 13 | I would say no - use a CLI |
| 14 | 14 | |
| @@ -91,7 +91,7 @@ body: | | ||
| 91 | 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 | 92 | tasks: |
| 93 | 93 | - 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 | 95 | state: open |
| 96 | 96 | priority: medium |
| 97 | 97 | author: |
| @@ -99,7 +99,7 @@ tasks: | ||
| 99 | 99 | email: ph.charriere@gmail.com |
| 100 | 100 | createdAt: 2026-09-21T05:45:00.000Z |
| 101 | 101 | - 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 | 103 | state: open |
| 104 | 104 | priority: medium |
| 105 | 105 | author: |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-15T22:38:45.129Z | 8 | createdAt: 2026-09-15T22:38:45.129Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | 9 | +updatedAt: 2026-09-21T19:04:58.320Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | 11 | +milestone: v0.04 |
| 12 | body: | | 12 | body: | |
| 13 | I would say no - use a CLI | 13 | 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: 1 | 93 | - 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: open | 95 | state: open |
| 96 | priority: medium | 96 | priority: medium |
| 97 | author: | 97 | author: |
| @@ -99,7 +99,7 @@ tasks: | |||
| 99 | email: ph.charriere@gmail.com | 99 | email: ph.charriere@gmail.com |
| 100 | createdAt: 2026-09-21T05:45:00.000Z | 100 | createdAt: 2026-09-21T05:45:00.000Z |
| 101 | - id: 2 | 101 | - 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: open | 103 | state: open |
| 104 | priority: medium | 104 | 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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T04:00:56.971Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | |
| 9 | +updatedAt: 2026-09-21T19:03:27.354Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | |
| 11 | +milestone: v0.04 | |
| 12 | 12 | body: | |
| 13 | 13 | ## Analysis (Claude, 2026-09-21 — not implemented) |
| 14 | 14 | |
| @@ -90,7 +90,7 @@ tasks: | ||
| 90 | 90 | email: ph.charriere@gmail.com |
| 91 | 91 | createdAt: 2026-09-21T05:45:00.000Z |
| 92 | 92 | - 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 | 94 | state: open |
| 95 | 95 | priority: high |
| 96 | 96 | author: |
| @@ -106,7 +106,7 @@ tasks: | ||
| 106 | 106 | email: ph.charriere@gmail.com |
| 107 | 107 | createdAt: 2026-09-21T05:45:00.000Z |
| 108 | 108 | - 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 | 110 | state: open |
| 111 | 111 | priority: medium |
| 112 | 112 | author: |
| @@ -114,7 +114,7 @@ tasks: | ||
| 114 | 114 | email: ph.charriere@gmail.com |
| 115 | 115 | createdAt: 2026-09-21T05:45:00.000Z |
| 116 | 116 | - 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 | 118 | state: open |
| 119 | 119 | priority: low |
| 120 | 120 | author: |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T04:00:56.971Z | 8 | createdAt: 2026-09-21T04:00:56.971Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | 9 | +updatedAt: 2026-09-21T19:03:27.354Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | 11 | +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.com | 90 | email: ph.charriere@gmail.com |
| 91 | createdAt: 2026-09-21T05:45:00.000Z | 91 | createdAt: 2026-09-21T05:45:00.000Z |
| 92 | - id: 2 | 92 | - 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: open | 94 | state: open |
| 95 | priority: high | 95 | priority: high |
| 96 | author: | 96 | author: |
| @@ -106,7 +106,7 @@ tasks: | |||
| 106 | email: ph.charriere@gmail.com | 106 | email: ph.charriere@gmail.com |
| 107 | createdAt: 2026-09-21T05:45:00.000Z | 107 | createdAt: 2026-09-21T05:45:00.000Z |
| 108 | - id: 4 | 108 | - 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: open | 110 | state: open |
| 111 | priority: medium | 111 | priority: medium |
| 112 | author: | 112 | author: |
| @@ -114,7 +114,7 @@ tasks: | |||
| 114 | email: ph.charriere@gmail.com | 114 | email: ph.charriere@gmail.com |
| 115 | createdAt: 2026-09-21T05:45:00.000Z | 115 | createdAt: 2026-09-21T05:45:00.000Z |
| 116 | - id: 5 | 116 | - 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: open | 118 | state: open |
| 119 | priority: low | 119 | 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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T04:01:15.166Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | |
| 9 | +updatedAt: 2026-09-21T19:04:28.691Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | |
| 11 | +milestone: v0.04 | |
| 12 | 12 | body: | |
| 13 | 13 | ## Analysis (Claude, 2026-09-21 — not implemented) |
| 14 | 14 | |
| @@ -67,7 +67,7 @@ body: | | ||
| 67 | 67 | 5. Metered-API guardrails: `maxTurns: 40` plus a `bash` tool is a different proposition when every turn is billed. |
| 68 | 68 | tasks: |
| 69 | 69 | - 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 | 71 | state: open |
| 72 | 72 | priority: medium |
| 73 | 73 | author: |
| @@ -83,7 +83,7 @@ tasks: | ||
| 83 | 83 | email: ph.charriere@gmail.com |
| 84 | 84 | createdAt: 2026-09-21T05:45:00.000Z |
| 85 | 85 | - 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 | 87 | state: open |
| 88 | 88 | priority: high |
| 89 | 89 | author: |
| @@ -99,7 +99,7 @@ tasks: | ||
| 99 | 99 | email: ph.charriere@gmail.com |
| 100 | 100 | createdAt: 2026-09-21T05:45:00.000Z |
| 101 | 101 | - 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 | 103 | state: open |
| 104 | 104 | priority: low |
| 105 | 105 | author: |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T04:01:15.166Z | 8 | createdAt: 2026-09-21T04:01:15.166Z |
| 9 | -updatedAt: 2026-09-21T05:45:00.000Z | 9 | +updatedAt: 2026-09-21T19:04:28.691Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v0.0.3 | 11 | +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: 1 | 69 | - 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: open | 71 | state: open |
| 72 | priority: medium | 72 | priority: medium |
| 73 | author: | 73 | author: |
| @@ -83,7 +83,7 @@ tasks: | |||
| 83 | email: ph.charriere@gmail.com | 83 | email: ph.charriere@gmail.com |
| 84 | createdAt: 2026-09-21T05:45:00.000Z | 84 | createdAt: 2026-09-21T05:45:00.000Z |
| 85 | - id: 3 | 85 | - 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: open | 87 | state: open |
| 88 | priority: high | 88 | priority: high |
| 89 | author: | 89 | author: |
| @@ -99,7 +99,7 @@ tasks: | |||
| 99 | email: ph.charriere@gmail.com | 99 | email: ph.charriere@gmail.com |
| 100 | createdAt: 2026-09-21T05:45:00.000Z | 100 | createdAt: 2026-09-21T05:45:00.000Z |
| 101 | - id: 5 | 101 | - 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: open | 103 | state: open |
| 104 | priority: low | 104 | 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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T04:51:17.051Z |
| 9 | -updatedAt: 2026-09-21T04:55:19.972Z | |
| 9 | +updatedAt: 2026-09-21T19:06:52.839Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.3 |
| 12 | -body: "" | |
| 12 | +body: | | |
| 13 | + - --tui mode | |
| 14 | + - acp mode (sbx exec) | |
| 13 | 15 | tasks: [] |
| 14 | 16 | comments: [] |
| @@ -6,9 +6,11 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T04:51:17.051Z | 8 | createdAt: 2026-09-21T04:51:17.051Z |
| 9 | -updatedAt: 2026-09-21T04:55:19.972Z | 9 | +updatedAt: 2026-09-21T19:06:52.839Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.3 | 11 | 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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T18:55:37.512Z |
| 9 | -updatedAt: 2026-09-21T18:55:37.512Z | |
| 9 | +updatedAt: 2026-09-21T19:03:09.902Z | |
| 10 | 10 | labels: [] |
| 11 | +milestone: v0.0.3 | |
| 11 | 12 | body: "" |
| 12 | 13 | tasks: [] |
| 13 | 14 | comments: [] |
| @@ -6,8 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T18:55:37.512Z | 8 | createdAt: 2026-09-21T18:55:37.512Z |
| 9 | -updatedAt: 2026-09-21T18:55:37.512Z | 9 | +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: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-21T19:00:12.508Z |
| 9 | -updatedAt: 2026-09-21T19:00:12.508Z | |
| 9 | +updatedAt: 2026-09-21T19:02:47.785Z | |
| 10 | 10 | labels: [] |
| 11 | +milestone: v0.0.3 | |
| 11 | 12 | body: "" |
| 12 | 13 | tasks: [] |
| 13 | 14 | comments: [] |
| @@ -6,8 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-21T19:00:12.508Z | 8 | createdAt: 2026-09-21T19:00:12.508Z |
| 9 | -updatedAt: 2026-09-21T19:00:12.508Z | 9 | +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/ | ||
| 96 | 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 | 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 | 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 | 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 | 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 | 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. | |
| @@ -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)" | ||
| 57 | 57 | |
| 58 | 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 | 64 | ## Writing a skill the model will ACT on |
| 61 | 65 | |
| 62 | 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. |
| @@ -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 on | 64 | ## 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- | ||
| 41 | 41 | |
| 42 | 42 | ## 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 | 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 | 53 | | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. | |
| 54 | 54 | | `Esc` | Cancels the running turn (same as `/abort`); closes the picker; rejects a pending permission. | |
| 55 | 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 | 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 | 58 | | `PgUp` `PgDn` | Scroll one page. | |
| 59 | 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 | 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 | 115 | ## Messages printed at the end of a turn |
| 85 | 116 | |
| 86 | 117 | | 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 keys | 42 | ## 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 turn | 115 | ## 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/ | ||
| 96 | 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 | 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 | 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 | 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 | 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 | 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. | |
| @@ -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)" | ||
| 56 | 56 | |
| 57 | 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 | 63 | ## Écrire un skill que le modèle va EXÉCUTER |
| 60 | 64 | |
| 61 | 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. |
| @@ -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ÉCUTER | 63 | ## É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 : | ||
| 53 | 53 | | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. | |
| 54 | 54 | | `Échap` | Annule le tour en cours (comme `/abort`) ; ferme le sélecteur ; refuse une permission en attente. | |
| 55 | 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 | 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 | 58 | | `PgUp` `PgDn` | Défilent d'une page. | |
| 59 | 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 | 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 | 115 | ## Messages affichés en fin de tour |
| 85 | 116 | |
| 86 | 117 | | 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 tour | 115 | ## 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 ( | ||
| 35 | 35 | "mm/internal/engine" |
| 36 | 36 | "mm/internal/mention" |
| 37 | 37 | history "mm/internal/session" |
| 38 | + "mm/internal/skills" | |
| 38 | 39 | "mm/internal/store" |
| 39 | 40 | "mm/internal/ui" |
| 40 | 41 | |
| @@ -304,6 +305,12 @@ func (f *front) Prompt(ctx context.Context, p sdk.PromptRequest) (sdk.PromptResp | ||
| 304 | 305 | for _, a := range attached { |
| 305 | 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 | 314 | s.messages = append(s.messages, ai.NewUserTextMessage(input)) |
| 308 | 315 | before := len(s.messages) |
| 309 | 316 | |
| @@ -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 ( | ||
| 18 | 18 | "mm/internal/engine" |
| 19 | 19 | "mm/internal/mention" |
| 20 | 20 | "mm/internal/session" |
| 21 | + "mm/internal/skills" | |
| 21 | 22 | "mm/internal/spinner" |
| 22 | 23 | "mm/internal/store" |
| 23 | 24 | |
| @@ -177,6 +178,13 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 177 | 178 | for _, a := range attached { |
| 178 | 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 | 188 | messages = append(messages, ai.NewUserTextMessage(input)) |
| 181 | 189 | before := len(messages) |
| 182 | 190 | 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 { | ||
| 95 | 95 | case !v.onInput: |
| 96 | 96 | return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box " |
| 97 | 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 | 204 | rows := min(len(cs), pickerRows, p.height()) |
| 205 | 205 | 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 | 209 | width := 0 |
| 207 | 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 | 217 | width = min(width+4, p.width()) |
| 211 | 218 | for i := 0; i < rows; i++ { |
| @@ -214,7 +221,7 @@ func (v *View) drawPicker(p painter) { | ||
| 214 | 221 | if i == v.pick.selected { |
| 215 | 222 | style = stPick |
| 216 | 223 | } |
| 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 | |
| 218 | 225 | p.hline(0, top+i, width, ' ', style) |
| 219 | 226 | p.textLimited(0, top+i, width, text, style) |
| 220 | 227 | } |
| @@ -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() - rows | 205 | 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 := 0 | 209 | 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 = stPick | 222 | style = stPick |
| 216 | } | 223 | } |
| 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 |
| 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 { | ||
| 74 | 74 | copy: func() { r.copy("") }, |
| 75 | 75 | }) |
| 76 | 76 | v.previewLines = config.Cfg.PreviewLines |
| 77 | + v.skills, v.cwd = opts.Skills, opts.Cwd | |
| 77 | 78 | if opts.Banner != "" { |
| 78 | 79 | t.Notice(opts.Banner) |
| 79 | 80 | } |
| @@ -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.PreviewLines | 76 | 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 | ||
| 170 | 170 | AgentName: "mm", |
| 171 | 171 | ModelName: config.Cfg.Model, |
| 172 | 172 | Provider: e.Backend.Provider, |
| 173 | + Skills: skills.Declared(), | |
| 173 | 174 | Version: version.Short(), |
| 174 | 175 | Banner: banner, |
| 175 | 176 | }) |
| @@ -218,6 +219,8 @@ func assembleTools(e *engine.Engine, d *detector.LoopDetector) toolSet { | ||
| 218 | 219 | // The turn recap shows `📖 0 skill(s)` only where a skill could have been |
| 219 | 220 | // used — see engine.SkillsAvailable. |
| 220 | 221 | engine.DeclareSkills(set.skillCount > 0) |
| 222 | + // The catalogue every front end resolves `$skill` against. | |
| 223 | + skills.Declare(set.skills) | |
| 221 | 224 | if skillTool := tools.ReadSkill(e.G, config.Cfg.SkillsDir, d); skillTool != nil { |
| 222 | 225 | set.tools = append(set.tools, skillTool) |
| 223 | 226 | 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 been | 219 | // 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") |