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 | @@ -1,45 +1,191 @@ | ||
| 1 | 1 | package tui |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "sort" | |
| 4 | 7 | "strings" |
| 5 | 8 | |
| 6 | 9 | "mm/internal/command" |
| 10 | + "mm/internal/skills" | |
| 7 | 11 | ) |
| 8 | 12 | |
| 9 | 13 | // pickerRows is how many choices the popup shows at most. |
| 10 | 14 | const pickerRows = 8 |
| 11 | 15 | |
| 12 | -// picker is the popup that opens over the conversation while a "/" command | |
| 13 | -// is being typed at the very start of the box: it lists the window's | |
| 14 | -// commands, narrowed by what has been typed, and completes the chosen one. | |
| 15 | -// Escape dismisses it until the text changes. | |
| 16 | +// The popup that completes what is being typed. Three triggers, three | |
| 17 | +// sources, one mechanism: | |
| 18 | +// | |
| 19 | +// / the window's commands only as the whole first word of the box | |
| 20 | +// @ files and directories anywhere in the text | |
| 21 | +// $ the skills of the catalogue anywhere in the text | |
| 22 | +// | |
| 23 | +// The `/` case is the original one and keeps its rule: a command is the whole | |
| 24 | +// message, so it only makes sense at the start, and completing it replaces | |
| 25 | +// the box. The other two are mid-sentence — "explain @internal/acp/acp.go", | |
| 26 | +// "use $greetings Bob" — so the popup reads the TOKEN the cursor is in and | |
| 27 | +// completes only that token, leaving the rest of the sentence alone. | |
| 28 | +// | |
| 29 | +// Both mid-sentence triggers keep their prefix when completed. For `@` it is | |
| 30 | +// what `mention.Expand` looks for; for `$` it is what marks a skill to be | |
| 31 | +// carried out (see skills.ExpandRefs), and the cursor lands after the name so | |
| 32 | +// the parameters can be typed straight away. | |
| 33 | + | |
| 34 | +// choice is one row of the popup: what goes in the box, and what is shown | |
| 35 | +// beside it. | |
| 36 | +type choice struct { | |
| 37 | + insert string // the full token, prefix included | |
| 38 | + label string // the left column | |
| 39 | + detail string // the right column, may be empty | |
| 40 | +} | |
| 41 | + | |
| 42 | +// picker remembers the selection and Escape's "not now". | |
| 16 | 43 | type picker struct { |
| 17 | 44 | selected int |
| 18 | 45 | dismissedFor string // the input text Escape was pressed on; "" = not dismissed |
| 19 | 46 | dismissed bool |
| 20 | 47 | } |
| 21 | 48 | |
| 22 | -// choices returns the commands the picker offers for what is typed: the | |
| 23 | -// window's commands whose name starts with the word after the slash. Nothing | |
| 24 | -// when the box holds more than one line, does not start with "/", already | |
| 25 | -// has a space (the command is complete), or was dismissed on this text. | |
| 26 | -func (v *View) choices() []command.Command { | |
| 27 | - if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") { | |
| 28 | - return nil | |
| 49 | +// token is the word the popup is completing: where it sits in the current | |
| 50 | +// line, and what has been typed of it. | |
| 51 | +type token struct { | |
| 52 | + trigger byte | |
| 53 | + from int // rune index in the line where the trigger sits | |
| 54 | + to int // rune index just past what has been typed | |
| 55 | + partial string // what follows the trigger, as typed | |
| 56 | +} | |
| 57 | + | |
| 58 | +// tokenAtCursor finds the trigger token the cursor is inside or at the end | |
| 59 | +// of. Nothing when the cursor is not in one: a `@` or `$` that is glued to | |
| 60 | +// the end of a word is not a trigger ("me@example.com", "5$"), and neither | |
| 61 | +// is a trigger whose token the cursor has left. | |
| 62 | +func (v *View) tokenAtCursor() (token, bool) { | |
| 63 | + if v.cursor >= len(v.input) { | |
| 64 | + return token{}, false | |
| 29 | 65 | } |
| 30 | - if v.pick.dismissed && v.pick.dismissedFor == v.input[0] { | |
| 66 | + line := []rune(v.input[v.cursor]) | |
| 67 | + col := min(v.column, len(line)) | |
| 68 | + | |
| 69 | + // Walk back to the start of the word the cursor is in. | |
| 70 | + i := col | |
| 71 | + for i > 0 && !isSpaceRune(line[i-1]) { | |
| 72 | + i-- | |
| 73 | + } | |
| 74 | + if i >= col && i >= len(line) { | |
| 75 | + return token{}, false | |
| 76 | + } | |
| 77 | + if i >= len(line) || (line[i] != '@' && line[i] != '$' && line[i] != '/') { | |
| 78 | + return token{}, false | |
| 79 | + } | |
| 80 | + // The word must END at the cursor: completing a token the cursor has | |
| 81 | + // already passed would rewrite text the user moved away from. | |
| 82 | + end := col | |
| 83 | + if end < len(line) && !isSpaceRune(line[end]) { | |
| 84 | + return token{}, false | |
| 85 | + } | |
| 86 | + return token{trigger: byte(line[i]), from: i, to: end, partial: string(line[i+1 : end])}, true | |
| 87 | +} | |
| 88 | + | |
| 89 | +func isSpaceRune(r rune) bool { return r == ' ' || r == '\t' } | |
| 90 | + | |
| 91 | +// choices returns what the popup offers, or nothing when it must stay shut. | |
| 92 | +func (v *View) choices() []choice { | |
| 93 | + tok, ok := v.tokenAtCursor() | |
| 94 | + if !ok || (v.pick.dismissed && v.pick.dismissedFor == v.Input()) { | |
| 31 | 95 | return nil |
| 32 | 96 | } |
| 33 | - word := strings.TrimPrefix(v.input[0], "/") | |
| 34 | - var out []command.Command | |
| 97 | + var out []choice | |
| 98 | + switch tok.trigger { | |
| 99 | + case '/': | |
| 100 | + // A command is the whole message: only offered as the first word of | |
| 101 | + // a one-line box, exactly as before. | |
| 102 | + if len(v.input) == 1 && tok.from == 0 { | |
| 103 | + out = commandChoices(tok.partial) | |
| 104 | + } | |
| 105 | + case '$': | |
| 106 | + out = skillChoices(v.skills, tok.partial) | |
| 107 | + case '@': | |
| 108 | + out = pathChoices(v.cwd, tok.partial) | |
| 109 | + } | |
| 110 | + return out | |
| 111 | +} | |
| 112 | + | |
| 113 | +func commandChoices(partial string) []choice { | |
| 114 | + var out []choice | |
| 35 | 115 | for _, c := range command.For(command.Window) { |
| 36 | - if strings.HasPrefix(c.Name, word) { | |
| 37 | - out = append(out, c) | |
| 116 | + if strings.HasPrefix(c.Name, partial) { | |
| 117 | + out = append(out, choice{insert: c.Slash(), label: c.Slash(), detail: c.Description}) | |
| 38 | 118 | } |
| 39 | 119 | } |
| 40 | 120 | return out |
| 41 | 121 | } |
| 42 | 122 | |
| 123 | +// skillChoices narrows the catalogue by what follows the `$`. The `$` is kept | |
| 124 | +// in the inserted text: it is what tells the turn this skill is to be carried | |
| 125 | +// out, and it leaves the cursor in front of the parameters. | |
| 126 | +func skillChoices(list []skills.Skill, partial string) []choice { | |
| 127 | + var out []choice | |
| 128 | + for _, s := range list { | |
| 129 | + if !strings.HasPrefix(strings.ToLower(s.Name), strings.ToLower(partial)) { | |
| 130 | + continue | |
| 131 | + } | |
| 132 | + out = append(out, choice{insert: "$" + s.Name, label: "$" + s.Name, detail: s.Description}) | |
| 133 | + } | |
| 134 | + return out | |
| 135 | +} | |
| 136 | + | |
| 137 | +// pathChoices lists what is on disk under the directory the partial path | |
| 138 | +// names, narrowed by its last segment. A directory is offered with a | |
| 139 | +// trailing "/" so the next keystroke walks into it. | |
| 140 | +// | |
| 141 | +// It reads ONE directory per keystroke and stops at pickerRows*4 entries: | |
| 142 | +// the popup shows eight, and a node_modules must not stall the window. | |
| 143 | +func pathChoices(cwd, partial string) []choice { | |
| 144 | + dir, prefix := filepath.Split(partial) | |
| 145 | + base := filepath.Join(cwd, dir) | |
| 146 | + if strings.HasPrefix(dir, "~/") { | |
| 147 | + home, err := os.UserHomeDir() | |
| 148 | + if err != nil { | |
| 149 | + return nil | |
| 150 | + } | |
| 151 | + base = filepath.Join(home, strings.TrimPrefix(dir, "~/")) | |
| 152 | + } else if filepath.IsAbs(dir) { | |
| 153 | + base = dir | |
| 154 | + } | |
| 155 | + | |
| 156 | + entries, err := os.ReadDir(base) | |
| 157 | + if err != nil { | |
| 158 | + return nil | |
| 159 | + } | |
| 160 | + var out []choice | |
| 161 | + for _, e := range entries { | |
| 162 | + name := e.Name() | |
| 163 | + if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") { | |
| 164 | + continue // hidden files only once the user asks for them | |
| 165 | + } | |
| 166 | + if !strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) { | |
| 167 | + continue | |
| 168 | + } | |
| 169 | + shown, detail := name, "file" | |
| 170 | + if e.IsDir() { | |
| 171 | + shown, detail = name+"/", "directory" | |
| 172 | + } | |
| 173 | + out = append(out, choice{insert: "@" + dir + shown, label: shown, detail: detail}) | |
| 174 | + if len(out) >= pickerRows*4 { | |
| 175 | + break | |
| 176 | + } | |
| 177 | + } | |
| 178 | + // Directories first, then names: walking a tree is the common case. | |
| 179 | + sort.SliceStable(out, func(i, j int) bool { | |
| 180 | + di, dj := strings.HasSuffix(out[i].label, "/"), strings.HasSuffix(out[j].label, "/") | |
| 181 | + if di != dj { | |
| 182 | + return di | |
| 183 | + } | |
| 184 | + return out[i].label < out[j].label | |
| 185 | + }) | |
| 186 | + return out | |
| 187 | +} | |
| 188 | + | |
| 43 | 189 | // pickerOpen reports whether the popup is showing. |
| 44 | 190 | func (v *View) pickerOpen() bool { return len(v.choices()) > 0 } |
| 45 | 191 | |
| @@ -59,34 +205,73 @@ func (v *View) movePick(by int) { | ||
| 59 | 205 | v.pick.selected = ((v.pick.selected+by)%n + n) % n |
| 60 | 206 | } |
| 61 | 207 | |
| 62 | -// selectedChoice is the highlighted command, if the popup is open. | |
| 63 | -func (v *View) selectedChoice() (command.Command, bool) { | |
| 208 | +// selectedChoice is the highlighted row, if the popup is open. | |
| 209 | +func (v *View) selectedChoice() (choice, bool) { | |
| 64 | 210 | cs := v.choices() |
| 65 | 211 | if len(cs) == 0 { |
| 66 | - return command.Command{}, false | |
| 212 | + return choice{}, false | |
| 67 | 213 | } |
| 68 | 214 | return cs[min(v.pick.selected, len(cs)-1)], true |
| 69 | 215 | } |
| 70 | 216 | |
| 71 | -// complete puts the highlighted command in the box, with a space after it | |
| 72 | -// when it takes an argument, and closes the popup. Enter on a command that is | |
| 73 | -// already complete sends it instead: see events.go. | |
| 217 | +// complete puts the highlighted choice in the box and closes the popup. | |
| 218 | +// | |
| 219 | +// A command replaces the whole box, since a command IS the message. A `@` or | |
| 220 | +// `$` token replaces only itself, and the cursor lands just after it — after | |
| 221 | +// a directory's "/" there is more path to type, after a skill's name there | |
| 222 | +// are parameters, and after a file there is the rest of the sentence. | |
| 74 | 223 | func (v *View) complete() { |
| 75 | 224 | c, ok := v.selectedChoice() |
| 76 | 225 | if !ok { |
| 77 | 226 | return |
| 78 | 227 | } |
| 79 | - text := c.Slash() | |
| 80 | - if c.TakesArg { | |
| 81 | - text += " " | |
| 228 | + tok, ok := v.tokenAtCursor() | |
| 229 | + if !ok { | |
| 230 | + return | |
| 231 | + } | |
| 232 | + if tok.trigger == '/' { | |
| 233 | + text := c.insert | |
| 234 | + if cmd, _, found := command.Parse(text); found && cmd.TakesArg { | |
| 235 | + text += " " | |
| 236 | + } | |
| 237 | + v.setInput(text) | |
| 238 | + v.pick.dismiss(text) | |
| 239 | + return | |
| 240 | + } | |
| 241 | + | |
| 242 | + line := []rune(v.input[v.cursor]) | |
| 243 | + insert := []rune(c.insert) | |
| 244 | + // A file or a skill is followed by a space; a directory is not, so the | |
| 245 | + // next keystroke continues the path. | |
| 246 | + walkingIn := strings.HasSuffix(c.insert, "/") | |
| 247 | + if !walkingIn { | |
| 248 | + insert = append(insert, ' ') | |
| 249 | + } | |
| 250 | + updated := append([]rune{}, line[:tok.from]...) | |
| 251 | + updated = append(updated, insert...) | |
| 252 | + updated = append(updated, line[tok.to:]...) | |
| 253 | + v.input[v.cursor] = string(updated) | |
| 254 | + v.column = tok.from + len(insert) | |
| 255 | + v.pastes.prune(v.Input()) | |
| 256 | + if walkingIn { | |
| 257 | + // Completing a directory is a step, not an answer: leave the popup | |
| 258 | + // open on its contents so Tab walks down the tree. | |
| 259 | + v.pick.dismiss("") | |
| 260 | + return | |
| 82 | 261 | } |
| 83 | - v.setInput(text) | |
| 84 | - v.pick.dismiss(text) | |
| 262 | + v.pick.dismiss(v.Input()) | |
| 85 | 263 | } |
| 86 | 264 | |
| 87 | -// typedIsComplete reports whether the box holds exactly the highlighted | |
| 88 | -// command's name: Enter then means "send", not "complete". | |
| 265 | +// typedIsComplete reports whether the box already holds exactly the | |
| 266 | +// highlighted choice: Enter then means "send", not "complete". | |
| 267 | +// | |
| 268 | +// Only ever true for a command. A `@` or `$` token is part of a sentence, so | |
| 269 | +// Enter on one completes it — the message is not finished. | |
| 89 | 270 | func (v *View) typedIsComplete() bool { |
| 90 | 271 | c, ok := v.selectedChoice() |
| 91 | - return ok && v.input[0] == c.Slash() | |
| 272 | + if !ok { | |
| 273 | + return false | |
| 274 | + } | |
| 275 | + tok, ok := v.tokenAtCursor() | |
| 276 | + return ok && tok.trigger == '/' && len(v.input) == 1 && v.input[0] == c.insert | |
| 92 | 277 | } |
| @@ -1,45 +1,191 @@ | |||
| 1 | package tui | 1 | package tui |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "sort" | ||
| 4 | "strings" | 7 | "strings" |
| 5 | 8 | ||
| 6 | "mm/internal/command" | 9 | "mm/internal/command" |
| 10 | + "mm/internal/skills" | ||
| 7 | ) | 11 | ) |
| 8 | 12 | ||
| 9 | // pickerRows is how many choices the popup shows at most. | 13 | // pickerRows is how many choices the popup shows at most. |
| 10 | const pickerRows = 8 | 14 | const pickerRows = 8 |
| 11 | 15 | ||
| 12 | -// picker is the popup that opens over the conversation while a "/" command | 16 | +// The popup that completes what is being typed. Three triggers, three |
| 13 | -// is being typed at the very start of the box: it lists the window's | 17 | +// sources, one mechanism: |
| 14 | -// commands, narrowed by what has been typed, and completes the chosen one. | 18 | +// |
| 15 | -// Escape dismisses it until the text changes. | 19 | +// / the window's commands only as the whole first word of the box |
| 20 | +// @ files and directories anywhere in the text | ||
| 21 | +// $ the skills of the catalogue anywhere in the text | ||
| 22 | +// | ||
| 23 | +// The `/` case is the original one and keeps its rule: a command is the whole | ||
| 24 | +// message, so it only makes sense at the start, and completing it replaces | ||
| 25 | +// the box. The other two are mid-sentence — "explain @internal/acp/acp.go", | ||
| 26 | +// "use $greetings Bob" — so the popup reads the TOKEN the cursor is in and | ||
| 27 | +// completes only that token, leaving the rest of the sentence alone. | ||
| 28 | +// | ||
| 29 | +// Both mid-sentence triggers keep their prefix when completed. For `@` it is | ||
| 30 | +// what `mention.Expand` looks for; for `$` it is what marks a skill to be | ||
| 31 | +// carried out (see skills.ExpandRefs), and the cursor lands after the name so | ||
| 32 | +// the parameters can be typed straight away. | ||
| 33 | + | ||
| 34 | +// choice is one row of the popup: what goes in the box, and what is shown | ||
| 35 | +// beside it. | ||
| 36 | +type choice struct { | ||
| 37 | + insert string // the full token, prefix included | ||
| 38 | + label string // the left column | ||
| 39 | + detail string // the right column, may be empty | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +// picker remembers the selection and Escape's "not now". | ||
| 16 | type picker struct { | 43 | type picker struct { |
| 17 | selected int | 44 | selected int |
| 18 | dismissedFor string // the input text Escape was pressed on; "" = not dismissed | 45 | dismissedFor string // the input text Escape was pressed on; "" = not dismissed |
| 19 | dismissed bool | 46 | dismissed bool |
| 20 | } | 47 | } |
| 21 | 48 | ||
| 22 | -// choices returns the commands the picker offers for what is typed: the | 49 | +// token is the word the popup is completing: where it sits in the current |
| 23 | -// window's commands whose name starts with the word after the slash. Nothing | 50 | +// line, and what has been typed of it. |
| 24 | -// when the box holds more than one line, does not start with "/", already | 51 | +type token struct { |
| 25 | -// has a space (the command is complete), or was dismissed on this text. | 52 | + trigger byte |
| 26 | -func (v *View) choices() []command.Command { | 53 | + from int // rune index in the line where the trigger sits |
| 27 | - if len(v.input) != 1 || !strings.HasPrefix(v.input[0], "/") || strings.Contains(v.input[0], " ") { | 54 | + to int // rune index just past what has been typed |
| 28 | - return nil | 55 | + partial string // what follows the trigger, as typed |
| 56 | +} | ||
| 57 | + | ||
| 58 | +// tokenAtCursor finds the trigger token the cursor is inside or at the end | ||
| 59 | +// of. Nothing when the cursor is not in one: a `@` or `$` that is glued to | ||
| 60 | +// the end of a word is not a trigger ("me@example.com", "5$"), and neither | ||
| 61 | +// is a trigger whose token the cursor has left. | ||
| 62 | +func (v *View) tokenAtCursor() (token, bool) { | ||
| 63 | + if v.cursor >= len(v.input) { | ||
| 64 | + return token{}, false | ||
| 29 | } | 65 | } |
| 30 | - if v.pick.dismissed && v.pick.dismissedFor == v.input[0] { | 66 | + line := []rune(v.input[v.cursor]) |
| 67 | + col := min(v.column, len(line)) | ||
| 68 | + | ||
| 69 | + // Walk back to the start of the word the cursor is in. | ||
| 70 | + i := col | ||
| 71 | + for i > 0 && !isSpaceRune(line[i-1]) { | ||
| 72 | + i-- | ||
| 73 | + } | ||
| 74 | + if i >= col && i >= len(line) { | ||
| 75 | + return token{}, false | ||
| 76 | + } | ||
| 77 | + if i >= len(line) || (line[i] != '@' && line[i] != '$' && line[i] != '/') { | ||
| 78 | + return token{}, false | ||
| 79 | + } | ||
| 80 | + // The word must END at the cursor: completing a token the cursor has | ||
| 81 | + // already passed would rewrite text the user moved away from. | ||
| 82 | + end := col | ||
| 83 | + if end < len(line) && !isSpaceRune(line[end]) { | ||
| 84 | + return token{}, false | ||
| 85 | + } | ||
| 86 | + return token{trigger: byte(line[i]), from: i, to: end, partial: string(line[i+1 : end])}, true | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +func isSpaceRune(r rune) bool { return r == ' ' || r == '\t' } | ||
| 90 | + | ||
| 91 | +// choices returns what the popup offers, or nothing when it must stay shut. | ||
| 92 | +func (v *View) choices() []choice { | ||
| 93 | + tok, ok := v.tokenAtCursor() | ||
| 94 | + if !ok || (v.pick.dismissed && v.pick.dismissedFor == v.Input()) { | ||
| 31 | return nil | 95 | return nil |
| 32 | } | 96 | } |
| 33 | - word := strings.TrimPrefix(v.input[0], "/") | 97 | + var out []choice |
| 34 | - var out []command.Command | 98 | + switch tok.trigger { |
| 99 | + case '/': | ||
| 100 | + // A command is the whole message: only offered as the first word of | ||
| 101 | + // a one-line box, exactly as before. | ||
| 102 | + if len(v.input) == 1 && tok.from == 0 { | ||
| 103 | + out = commandChoices(tok.partial) | ||
| 104 | + } | ||
| 105 | + case '$': | ||
| 106 | + out = skillChoices(v.skills, tok.partial) | ||
| 107 | + case '@': | ||
| 108 | + out = pathChoices(v.cwd, tok.partial) | ||
| 109 | + } | ||
| 110 | + return out | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +func commandChoices(partial string) []choice { | ||
| 114 | + var out []choice | ||
| 35 | for _, c := range command.For(command.Window) { | 115 | for _, c := range command.For(command.Window) { |
| 36 | - if strings.HasPrefix(c.Name, word) { | 116 | + if strings.HasPrefix(c.Name, partial) { |
| 37 | - out = append(out, c) | 117 | + out = append(out, choice{insert: c.Slash(), label: c.Slash(), detail: c.Description}) |
| 38 | } | 118 | } |
| 39 | } | 119 | } |
| 40 | return out | 120 | return out |
| 41 | } | 121 | } |
| 42 | 122 | ||
| 123 | +// skillChoices narrows the catalogue by what follows the `$`. The `$` is kept | ||
| 124 | +// in the inserted text: it is what tells the turn this skill is to be carried | ||
| 125 | +// out, and it leaves the cursor in front of the parameters. | ||
| 126 | +func skillChoices(list []skills.Skill, partial string) []choice { | ||
| 127 | + var out []choice | ||
| 128 | + for _, s := range list { | ||
| 129 | + if !strings.HasPrefix(strings.ToLower(s.Name), strings.ToLower(partial)) { | ||
| 130 | + continue | ||
| 131 | + } | ||
| 132 | + out = append(out, choice{insert: "$" + s.Name, label: "$" + s.Name, detail: s.Description}) | ||
| 133 | + } | ||
| 134 | + return out | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +// pathChoices lists what is on disk under the directory the partial path | ||
| 138 | +// names, narrowed by its last segment. A directory is offered with a | ||
| 139 | +// trailing "/" so the next keystroke walks into it. | ||
| 140 | +// | ||
| 141 | +// It reads ONE directory per keystroke and stops at pickerRows*4 entries: | ||
| 142 | +// the popup shows eight, and a node_modules must not stall the window. | ||
| 143 | +func pathChoices(cwd, partial string) []choice { | ||
| 144 | + dir, prefix := filepath.Split(partial) | ||
| 145 | + base := filepath.Join(cwd, dir) | ||
| 146 | + if strings.HasPrefix(dir, "~/") { | ||
| 147 | + home, err := os.UserHomeDir() | ||
| 148 | + if err != nil { | ||
| 149 | + return nil | ||
| 150 | + } | ||
| 151 | + base = filepath.Join(home, strings.TrimPrefix(dir, "~/")) | ||
| 152 | + } else if filepath.IsAbs(dir) { | ||
| 153 | + base = dir | ||
| 154 | + } | ||
| 155 | + | ||
| 156 | + entries, err := os.ReadDir(base) | ||
| 157 | + if err != nil { | ||
| 158 | + return nil | ||
| 159 | + } | ||
| 160 | + var out []choice | ||
| 161 | + for _, e := range entries { | ||
| 162 | + name := e.Name() | ||
| 163 | + if strings.HasPrefix(name, ".") && !strings.HasPrefix(prefix, ".") { | ||
| 164 | + continue // hidden files only once the user asks for them | ||
| 165 | + } | ||
| 166 | + if !strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) { | ||
| 167 | + continue | ||
| 168 | + } | ||
| 169 | + shown, detail := name, "file" | ||
| 170 | + if e.IsDir() { | ||
| 171 | + shown, detail = name+"/", "directory" | ||
| 172 | + } | ||
| 173 | + out = append(out, choice{insert: "@" + dir + shown, label: shown, detail: detail}) | ||
| 174 | + if len(out) >= pickerRows*4 { | ||
| 175 | + break | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + // Directories first, then names: walking a tree is the common case. | ||
| 179 | + sort.SliceStable(out, func(i, j int) bool { | ||
| 180 | + di, dj := strings.HasSuffix(out[i].label, "/"), strings.HasSuffix(out[j].label, "/") | ||
| 181 | + if di != dj { | ||
| 182 | + return di | ||
| 183 | + } | ||
| 184 | + return out[i].label < out[j].label | ||
| 185 | + }) | ||
| 186 | + return out | ||
| 187 | +} | ||
| 188 | + | ||
| 43 | // pickerOpen reports whether the popup is showing. | 189 | // pickerOpen reports whether the popup is showing. |
| 44 | func (v *View) pickerOpen() bool { return len(v.choices()) > 0 } | 190 | func (v *View) pickerOpen() bool { return len(v.choices()) > 0 } |
| 45 | 191 | ||
| @@ -59,34 +205,73 @@ func (v *View) movePick(by int) { | |||
| 59 | v.pick.selected = ((v.pick.selected+by)%n + n) % n | 205 | v.pick.selected = ((v.pick.selected+by)%n + n) % n |
| 60 | } | 206 | } |
| 61 | 207 | ||
| 62 | -// selectedChoice is the highlighted command, if the popup is open. | 208 | +// selectedChoice is the highlighted row, if the popup is open. |
| 63 | -func (v *View) selectedChoice() (command.Command, bool) { | 209 | +func (v *View) selectedChoice() (choice, bool) { |
| 64 | cs := v.choices() | 210 | cs := v.choices() |
| 65 | if len(cs) == 0 { | 211 | if len(cs) == 0 { |
| 66 | - return command.Command{}, false | 212 | + return choice{}, false |
| 67 | } | 213 | } |
| 68 | return cs[min(v.pick.selected, len(cs)-1)], true | 214 | return cs[min(v.pick.selected, len(cs)-1)], true |
| 69 | } | 215 | } |
| 70 | 216 | ||
| 71 | -// complete puts the highlighted command in the box, with a space after it | 217 | +// complete puts the highlighted choice in the box and closes the popup. |
| 72 | -// when it takes an argument, and closes the popup. Enter on a command that is | 218 | +// |
| 73 | -// already complete sends it instead: see events.go. | 219 | +// A command replaces the whole box, since a command IS the message. A `@` or |
| 220 | +// `$` token replaces only itself, and the cursor lands just after it — after | ||
| 221 | +// a directory's "/" there is more path to type, after a skill's name there | ||
| 222 | +// are parameters, and after a file there is the rest of the sentence. | ||
| 74 | func (v *View) complete() { | 223 | func (v *View) complete() { |
| 75 | c, ok := v.selectedChoice() | 224 | c, ok := v.selectedChoice() |
| 76 | if !ok { | 225 | if !ok { |
| 77 | return | 226 | return |
| 78 | } | 227 | } |
| 79 | - text := c.Slash() | 228 | + tok, ok := v.tokenAtCursor() |
| 80 | - if c.TakesArg { | 229 | + if !ok { |
| 81 | - text += " " | 230 | + return |
| 231 | + } | ||
| 232 | + if tok.trigger == '/' { | ||
| 233 | + text := c.insert | ||
| 234 | + if cmd, _, found := command.Parse(text); found && cmd.TakesArg { | ||
| 235 | + text += " " | ||
| 236 | + } | ||
| 237 | + v.setInput(text) | ||
| 238 | + v.pick.dismiss(text) | ||
| 239 | + return | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + line := []rune(v.input[v.cursor]) | ||
| 243 | + insert := []rune(c.insert) | ||
| 244 | + // A file or a skill is followed by a space; a directory is not, so the | ||
| 245 | + // next keystroke continues the path. | ||
| 246 | + walkingIn := strings.HasSuffix(c.insert, "/") | ||
| 247 | + if !walkingIn { | ||
| 248 | + insert = append(insert, ' ') | ||
| 249 | + } | ||
| 250 | + updated := append([]rune{}, line[:tok.from]...) | ||
| 251 | + updated = append(updated, insert...) | ||
| 252 | + updated = append(updated, line[tok.to:]...) | ||
| 253 | + v.input[v.cursor] = string(updated) | ||
| 254 | + v.column = tok.from + len(insert) | ||
| 255 | + v.pastes.prune(v.Input()) | ||
| 256 | + if walkingIn { | ||
| 257 | + // Completing a directory is a step, not an answer: leave the popup | ||
| 258 | + // open on its contents so Tab walks down the tree. | ||
| 259 | + v.pick.dismiss("") | ||
| 260 | + return | ||
| 82 | } | 261 | } |
| 83 | - v.setInput(text) | 262 | + v.pick.dismiss(v.Input()) |
| 84 | - v.pick.dismiss(text) | ||
| 85 | } | 263 | } |
| 86 | 264 | ||
| 87 | -// typedIsComplete reports whether the box holds exactly the highlighted | 265 | +// typedIsComplete reports whether the box already holds exactly the |
| 88 | -// command's name: Enter then means "send", not "complete". | 266 | +// highlighted choice: Enter then means "send", not "complete". |
| 267 | +// | ||
| 268 | +// Only ever true for a command. A `@` or `$` token is part of a sentence, so | ||
| 269 | +// Enter on one completes it — the message is not finished. | ||
| 89 | func (v *View) typedIsComplete() bool { | 270 | func (v *View) typedIsComplete() bool { |
| 90 | c, ok := v.selectedChoice() | 271 | c, ok := v.selectedChoice() |
| 91 | - return ok && v.input[0] == c.Slash() | 272 | + if !ok { |
| 273 | + return false | ||
| 274 | + } | ||
| 275 | + tok, ok := v.tokenAtCursor() | ||
| 276 | + return ok && tok.trigger == '/' && len(v.input) == 1 && v.input[0] == c.insert | ||
| 92 | } | 277 | } |
added
internal/tui/pickers_test.go +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +package tui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | + | |
| 9 | + "mm/internal/skills" | |
| 10 | + | |
| 11 | + "github.com/gdamore/tcell/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// withSources gives a harness the two things the `@` and `$` pickers read. | |
| 15 | +func withSources(t *testing.T, hs *harness, cwd string) { | |
| 16 | + t.Helper() | |
| 17 | + hs.v.cwd = cwd | |
| 18 | + hs.v.skills = []skills.Skill{ | |
| 19 | + {Name: "bg-jobs", Description: "background jobs"}, | |
| 20 | + {Name: "greetings", Description: "greet somebody"}, | |
| 21 | + {Name: "grep-like", Description: "a second g, to check narrowing"}, | |
| 22 | + } | |
| 23 | +} | |
| 24 | + | |
| 25 | +func labels(cs []choice) []string { | |
| 26 | + out := make([]string, 0, len(cs)) | |
| 27 | + for _, c := range cs { | |
| 28 | + out = append(out, c.label) | |
| 29 | + } | |
| 30 | + return out | |
| 31 | +} | |
| 32 | + | |
| 33 | +// The `$` picker lists the catalogue, narrows as you type, and Tab writes the | |
| 34 | +// name with the `$` KEPT — that prefix is what marks the skill to be carried | |
| 35 | +// out — leaving the cursor after it so parameters can be typed straight away. | |
| 36 | +func TestSkillPicker(t *testing.T) { | |
| 37 | + hs := newHarness(t, 70, 20) | |
| 38 | + withSources(t, hs, t.TempDir()) | |
| 39 | + | |
| 40 | + hs.typeText("use $") | |
| 41 | + if got := len(hs.v.choices()); got != 3 { | |
| 42 | + t.Fatalf("%d choices on '$', want the whole catalogue: %v", got, labels(hs.v.choices())) | |
| 43 | + } | |
| 44 | + if text := hs.screenText(); !strings.Contains(text, "$greetings") || !strings.Contains(text, "greet somebody") { | |
| 45 | + t.Errorf("the popup is not drawn:\n%s", text) | |
| 46 | + } | |
| 47 | + | |
| 48 | + hs.typeText("gr") | |
| 49 | + if got := labels(hs.v.choices()); len(got) != 2 { | |
| 50 | + t.Fatalf("choices for '$gr' = %v, want greetings and grep-like", got) | |
| 51 | + } | |
| 52 | + hs.typeText("ee") | |
| 53 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "$greetings" { | |
| 54 | + t.Fatalf("choices for '$gree' = %v", got) | |
| 55 | + } | |
| 56 | + | |
| 57 | + hs.key(tcell.KeyTab, tcell.ModNone) | |
| 58 | + if hs.v.Input() != "use $greetings " { | |
| 59 | + t.Errorf("after Tab the box is %q, want the name with its $ and a space", hs.v.Input()) | |
| 60 | + } | |
| 61 | + if hs.v.pickerOpen() { | |
| 62 | + t.Error("the popup stayed open after completing") | |
| 63 | + } | |
| 64 | + | |
| 65 | + // The cursor is after the name, so parameters go straight in. | |
| 66 | + hs.typeText("Bob Morane") | |
| 67 | + if hs.v.Input() != "use $greetings Bob Morane" { | |
| 68 | + t.Errorf("box = %q", hs.v.Input()) | |
| 69 | + } | |
| 70 | +} | |
| 71 | + | |
| 72 | +// Enter completes a `$` token rather than sending: the message is not | |
| 73 | +// finished, the parameters still have to be typed. | |
| 74 | +func TestSkillPickerEnterCompletesRatherThanSends(t *testing.T) { | |
| 75 | + hs := newHarness(t, 70, 20) | |
| 76 | + withSources(t, hs, t.TempDir()) | |
| 77 | + | |
| 78 | + hs.typeText("use $gree") | |
| 79 | + hs.key(tcell.KeyEnter, tcell.ModNone) | |
| 80 | + if len(hs.sent) != 0 { | |
| 81 | + t.Fatalf("Enter sent %q instead of completing", hs.sent) | |
| 82 | + } | |
| 83 | + if hs.v.Input() != "use $greetings " { | |
| 84 | + t.Errorf("box = %q", hs.v.Input()) | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +// A `$` that names nothing offers nothing — `$HOME` in a question is a shell | |
| 89 | +// variable, not a skill, and the popup must not jump in front of it. | |
| 90 | +func TestSkillPickerStaysShutOnAShellVariable(t *testing.T) { | |
| 91 | + hs := newHarness(t, 70, 20) | |
| 92 | + withSources(t, hs, t.TempDir()) | |
| 93 | + | |
| 94 | + hs.typeText("echo $HOME") | |
| 95 | + if hs.v.pickerOpen() { | |
| 96 | + t.Errorf("the popup opened on $HOME: %v", labels(hs.v.choices())) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +// The `@` picker reads the working directory, offers directories first with a | |
| 101 | +// trailing slash so the next keystroke walks in, and completes only the token. | |
| 102 | +func TestPathPicker(t *testing.T) { | |
| 103 | + dir := t.TempDir() | |
| 104 | + for _, d := range []string{"internal", "docs"} { | |
| 105 | + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { | |
| 106 | + t.Fatal(err) | |
| 107 | + } | |
| 108 | + } | |
| 109 | + for _, f := range []string{"main.go", "go.mod", ".hidden"} { | |
| 110 | + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil { | |
| 111 | + t.Fatal(err) | |
| 112 | + } | |
| 113 | + } | |
| 114 | + if err := os.WriteFile(filepath.Join(dir, "internal", "acp.go"), []byte("x"), 0o644); err != nil { | |
| 115 | + t.Fatal(err) | |
| 116 | + } | |
| 117 | + | |
| 118 | + hs := newHarness(t, 70, 20) | |
| 119 | + withSources(t, hs, dir) | |
| 120 | + | |
| 121 | + hs.typeText("explain @") | |
| 122 | + got := labels(hs.v.choices()) | |
| 123 | + if len(got) != 4 { | |
| 124 | + t.Fatalf("choices on '@' = %v, want the two directories and two files (not .hidden)", got) | |
| 125 | + } | |
| 126 | + if got[0] != "docs/" || got[1] != "internal/" { | |
| 127 | + t.Errorf("directories are not first: %v", got) | |
| 128 | + } | |
| 129 | + | |
| 130 | + // Narrowing, then completing a DIRECTORY: no trailing space, so the path | |
| 131 | + // can be continued. | |
| 132 | + hs.typeText("int") | |
| 133 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "internal/" { | |
| 134 | + t.Fatalf("choices for '@int' = %v", got) | |
| 135 | + } | |
| 136 | + hs.key(tcell.KeyTab, tcell.ModNone) | |
| 137 | + if hs.v.Input() != "explain @internal/" { | |
| 138 | + t.Fatalf("after completing a directory: %q", hs.v.Input()) | |
| 139 | + } | |
| 140 | + | |
| 141 | + // One more level, and a FILE completes with a space. | |
| 142 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "acp.go" { | |
| 143 | + t.Fatalf("choices inside internal/ = %v", got) | |
| 144 | + } | |
| 145 | + hs.key(tcell.KeyTab, tcell.ModNone) | |
| 146 | + if hs.v.Input() != "explain @internal/acp.go " { | |
| 147 | + t.Errorf("after completing a file: %q", hs.v.Input()) | |
| 148 | + } | |
| 149 | + | |
| 150 | + hs.typeText("please") | |
| 151 | + if hs.v.Input() != "explain @internal/acp.go please" { | |
| 152 | + t.Errorf("the rest of the sentence: %q", hs.v.Input()) | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +// A hidden file is offered only once the user asks for one. | |
| 157 | +func TestPathPickerHidesDotfilesUntilAsked(t *testing.T) { | |
| 158 | + dir := t.TempDir() | |
| 159 | + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("x"), 0o644); err != nil { | |
| 160 | + t.Fatal(err) | |
| 161 | + } | |
| 162 | + hs := newHarness(t, 70, 20) | |
| 163 | + withSources(t, hs, dir) | |
| 164 | + | |
| 165 | + hs.typeText("look @") | |
| 166 | + if hs.v.pickerOpen() { | |
| 167 | + t.Errorf("a dotfile was offered unasked: %v", labels(hs.v.choices())) | |
| 168 | + } | |
| 169 | + hs.typeText(".") | |
| 170 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != ".gitignore" { | |
| 171 | + t.Errorf("choices for '@.' = %v", got) | |
| 172 | + } | |
| 173 | +} | |
| 174 | + | |
| 175 | +// An @ glued to the end of a word is not a trigger: an e-mail address must | |
| 176 | +// not open a file picker. | |
| 177 | +func TestPickerIgnoresAGluedTrigger(t *testing.T) { | |
| 178 | + hs := newHarness(t, 70, 20) | |
| 179 | + withSources(t, hs, t.TempDir()) | |
| 180 | + | |
| 181 | + hs.typeText("mail bob@") | |
| 182 | + if hs.v.pickerOpen() { | |
| 183 | + t.Errorf("the popup opened inside an e-mail address: %v", labels(hs.v.choices())) | |
| 184 | + } | |
| 185 | +} | |
| 186 | + | |
| 187 | +// A `/` is still a command only as the whole first word: "see /tmp" is a path | |
| 188 | +// in a sentence, not a command. | |
| 189 | +func TestSlashIsACommandOnlyAtTheStart(t *testing.T) { | |
| 190 | + hs := newHarness(t, 70, 20) | |
| 191 | + withSources(t, hs, t.TempDir()) | |
| 192 | + | |
| 193 | + hs.typeText("see /") | |
| 194 | + if hs.v.pickerOpen() { | |
| 195 | + t.Errorf("the command popup opened mid-sentence: %v", labels(hs.v.choices())) | |
| 196 | + } | |
| 197 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +package tui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "os" | ||
| 5 | + "path/filepath" | ||
| 6 | + "strings" | ||
| 7 | + "testing" | ||
| 8 | + | ||
| 9 | + "mm/internal/skills" | ||
| 10 | + | ||
| 11 | + "github.com/gdamore/tcell/v2" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// withSources gives a harness the two things the `@` and `$` pickers read. | ||
| 15 | +func withSources(t *testing.T, hs *harness, cwd string) { | ||
| 16 | + t.Helper() | ||
| 17 | + hs.v.cwd = cwd | ||
| 18 | + hs.v.skills = []skills.Skill{ | ||
| 19 | + {Name: "bg-jobs", Description: "background jobs"}, | ||
| 20 | + {Name: "greetings", Description: "greet somebody"}, | ||
| 21 | + {Name: "grep-like", Description: "a second g, to check narrowing"}, | ||
| 22 | + } | ||
| 23 | +} | ||
| 24 | + | ||
| 25 | +func labels(cs []choice) []string { | ||
| 26 | + out := make([]string, 0, len(cs)) | ||
| 27 | + for _, c := range cs { | ||
| 28 | + out = append(out, c.label) | ||
| 29 | + } | ||
| 30 | + return out | ||
| 31 | +} | ||
| 32 | + | ||
| 33 | +// The `$` picker lists the catalogue, narrows as you type, and Tab writes the | ||
| 34 | +// name with the `$` KEPT — that prefix is what marks the skill to be carried | ||
| 35 | +// out — leaving the cursor after it so parameters can be typed straight away. | ||
| 36 | +func TestSkillPicker(t *testing.T) { | ||
| 37 | + hs := newHarness(t, 70, 20) | ||
| 38 | + withSources(t, hs, t.TempDir()) | ||
| 39 | + | ||
| 40 | + hs.typeText("use $") | ||
| 41 | + if got := len(hs.v.choices()); got != 3 { | ||
| 42 | + t.Fatalf("%d choices on '$', want the whole catalogue: %v", got, labels(hs.v.choices())) | ||
| 43 | + } | ||
| 44 | + if text := hs.screenText(); !strings.Contains(text, "$greetings") || !strings.Contains(text, "greet somebody") { | ||
| 45 | + t.Errorf("the popup is not drawn:\n%s", text) | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + hs.typeText("gr") | ||
| 49 | + if got := labels(hs.v.choices()); len(got) != 2 { | ||
| 50 | + t.Fatalf("choices for '$gr' = %v, want greetings and grep-like", got) | ||
| 51 | + } | ||
| 52 | + hs.typeText("ee") | ||
| 53 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "$greetings" { | ||
| 54 | + t.Fatalf("choices for '$gree' = %v", got) | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + hs.key(tcell.KeyTab, tcell.ModNone) | ||
| 58 | + if hs.v.Input() != "use $greetings " { | ||
| 59 | + t.Errorf("after Tab the box is %q, want the name with its $ and a space", hs.v.Input()) | ||
| 60 | + } | ||
| 61 | + if hs.v.pickerOpen() { | ||
| 62 | + t.Error("the popup stayed open after completing") | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + // The cursor is after the name, so parameters go straight in. | ||
| 66 | + hs.typeText("Bob Morane") | ||
| 67 | + if hs.v.Input() != "use $greetings Bob Morane" { | ||
| 68 | + t.Errorf("box = %q", hs.v.Input()) | ||
| 69 | + } | ||
| 70 | +} | ||
| 71 | + | ||
| 72 | +// Enter completes a `$` token rather than sending: the message is not | ||
| 73 | +// finished, the parameters still have to be typed. | ||
| 74 | +func TestSkillPickerEnterCompletesRatherThanSends(t *testing.T) { | ||
| 75 | + hs := newHarness(t, 70, 20) | ||
| 76 | + withSources(t, hs, t.TempDir()) | ||
| 77 | + | ||
| 78 | + hs.typeText("use $gree") | ||
| 79 | + hs.key(tcell.KeyEnter, tcell.ModNone) | ||
| 80 | + if len(hs.sent) != 0 { | ||
| 81 | + t.Fatalf("Enter sent %q instead of completing", hs.sent) | ||
| 82 | + } | ||
| 83 | + if hs.v.Input() != "use $greetings " { | ||
| 84 | + t.Errorf("box = %q", hs.v.Input()) | ||
| 85 | + } | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// A `$` that names nothing offers nothing — `$HOME` in a question is a shell | ||
| 89 | +// variable, not a skill, and the popup must not jump in front of it. | ||
| 90 | +func TestSkillPickerStaysShutOnAShellVariable(t *testing.T) { | ||
| 91 | + hs := newHarness(t, 70, 20) | ||
| 92 | + withSources(t, hs, t.TempDir()) | ||
| 93 | + | ||
| 94 | + hs.typeText("echo $HOME") | ||
| 95 | + if hs.v.pickerOpen() { | ||
| 96 | + t.Errorf("the popup opened on $HOME: %v", labels(hs.v.choices())) | ||
| 97 | + } | ||
| 98 | +} | ||
| 99 | + | ||
| 100 | +// The `@` picker reads the working directory, offers directories first with a | ||
| 101 | +// trailing slash so the next keystroke walks in, and completes only the token. | ||
| 102 | +func TestPathPicker(t *testing.T) { | ||
| 103 | + dir := t.TempDir() | ||
| 104 | + for _, d := range []string{"internal", "docs"} { | ||
| 105 | + if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil { | ||
| 106 | + t.Fatal(err) | ||
| 107 | + } | ||
| 108 | + } | ||
| 109 | + for _, f := range []string{"main.go", "go.mod", ".hidden"} { | ||
| 110 | + if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil { | ||
| 111 | + t.Fatal(err) | ||
| 112 | + } | ||
| 113 | + } | ||
| 114 | + if err := os.WriteFile(filepath.Join(dir, "internal", "acp.go"), []byte("x"), 0o644); err != nil { | ||
| 115 | + t.Fatal(err) | ||
| 116 | + } | ||
| 117 | + | ||
| 118 | + hs := newHarness(t, 70, 20) | ||
| 119 | + withSources(t, hs, dir) | ||
| 120 | + | ||
| 121 | + hs.typeText("explain @") | ||
| 122 | + got := labels(hs.v.choices()) | ||
| 123 | + if len(got) != 4 { | ||
| 124 | + t.Fatalf("choices on '@' = %v, want the two directories and two files (not .hidden)", got) | ||
| 125 | + } | ||
| 126 | + if got[0] != "docs/" || got[1] != "internal/" { | ||
| 127 | + t.Errorf("directories are not first: %v", got) | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + // Narrowing, then completing a DIRECTORY: no trailing space, so the path | ||
| 131 | + // can be continued. | ||
| 132 | + hs.typeText("int") | ||
| 133 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "internal/" { | ||
| 134 | + t.Fatalf("choices for '@int' = %v", got) | ||
| 135 | + } | ||
| 136 | + hs.key(tcell.KeyTab, tcell.ModNone) | ||
| 137 | + if hs.v.Input() != "explain @internal/" { | ||
| 138 | + t.Fatalf("after completing a directory: %q", hs.v.Input()) | ||
| 139 | + } | ||
| 140 | + | ||
| 141 | + // One more level, and a FILE completes with a space. | ||
| 142 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != "acp.go" { | ||
| 143 | + t.Fatalf("choices inside internal/ = %v", got) | ||
| 144 | + } | ||
| 145 | + hs.key(tcell.KeyTab, tcell.ModNone) | ||
| 146 | + if hs.v.Input() != "explain @internal/acp.go " { | ||
| 147 | + t.Errorf("after completing a file: %q", hs.v.Input()) | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | + hs.typeText("please") | ||
| 151 | + if hs.v.Input() != "explain @internal/acp.go please" { | ||
| 152 | + t.Errorf("the rest of the sentence: %q", hs.v.Input()) | ||
| 153 | + } | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +// A hidden file is offered only once the user asks for one. | ||
| 157 | +func TestPathPickerHidesDotfilesUntilAsked(t *testing.T) { | ||
| 158 | + dir := t.TempDir() | ||
| 159 | + if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("x"), 0o644); err != nil { | ||
| 160 | + t.Fatal(err) | ||
| 161 | + } | ||
| 162 | + hs := newHarness(t, 70, 20) | ||
| 163 | + withSources(t, hs, dir) | ||
| 164 | + | ||
| 165 | + hs.typeText("look @") | ||
| 166 | + if hs.v.pickerOpen() { | ||
| 167 | + t.Errorf("a dotfile was offered unasked: %v", labels(hs.v.choices())) | ||
| 168 | + } | ||
| 169 | + hs.typeText(".") | ||
| 170 | + if got := labels(hs.v.choices()); len(got) != 1 || got[0] != ".gitignore" { | ||
| 171 | + t.Errorf("choices for '@.' = %v", got) | ||
| 172 | + } | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +// An @ glued to the end of a word is not a trigger: an e-mail address must | ||
| 176 | +// not open a file picker. | ||
| 177 | +func TestPickerIgnoresAGluedTrigger(t *testing.T) { | ||
| 178 | + hs := newHarness(t, 70, 20) | ||
| 179 | + withSources(t, hs, t.TempDir()) | ||
| 180 | + | ||
| 181 | + hs.typeText("mail bob@") | ||
| 182 | + if hs.v.pickerOpen() { | ||
| 183 | + t.Errorf("the popup opened inside an e-mail address: %v", labels(hs.v.choices())) | ||
| 184 | + } | ||
| 185 | +} | ||
| 186 | + | ||
| 187 | +// A `/` is still a command only as the whole first word: "see /tmp" is a path | ||
| 188 | +// in a sentence, not a command. | ||
| 189 | +func TestSlashIsACommandOnlyAtTheStart(t *testing.T) { | ||
| 190 | + hs := newHarness(t, 70, 20) | ||
| 191 | + withSources(t, hs, t.TempDir()) | ||
| 192 | + | ||
| 193 | + hs.typeText("see /") | ||
| 194 | + if hs.v.pickerOpen() { | ||
| 195 | + t.Errorf("the command popup opened mid-sentence: %v", labels(hs.v.choices())) | ||
| 196 | + } | ||
| 197 | +} | ||
modified
internal/tui/tui.go +1 -0 | @@ -74,6 +74,7 @@ func newApp(screen tcell.Screen, opts Options) *app { | ||
| 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 | @@ -13,6 +13,7 @@ import ( | ||
| 13 | 13 | "mm/internal/engine" |
| 14 | 14 | "mm/internal/mention" |
| 15 | 15 | "mm/internal/session" |
| 16 | + "mm/internal/skills" | |
| 16 | 17 | "mm/internal/store" |
| 17 | 18 | "mm/internal/ui" |
| 18 | 19 | |
| @@ -43,6 +44,10 @@ type Options struct { | ||
| 43 | 44 | AgentName string // "mm" |
| 44 | 45 | ModelName string |
| 45 | 46 | Provider string |
| 47 | + // Skills is the catalogue the `$` picker offers, and what a `$name` in a | |
| 48 | + // question is resolved against. Empty means no `$` picker and no `$` | |
| 49 | + // expansion, which is right for an agent that ships no skill. | |
| 50 | + Skills []skills.Skill | |
| 46 | 51 | // Version is what the status bar shows — the window's only place for it, |
| 47 | 52 | // since the banner scrolls away with the conversation. Empty is allowed: |
| 48 | 53 | // the bar then shows the counters alone. |
| @@ -141,7 +146,14 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" } | ||
| 141 | 146 | func (r *runner) startTurn(p Prompt) { |
| 142 | 147 | r.t.AddUserFull(p.Display(), p.Text()) |
| 143 | 148 | expanded, attached := mention.Expand(p.Typed, r.opts.Cwd) |
| 149 | + // `$skill` is resolved on the same text and for the same reason as | |
| 150 | + // `@path`: a pasted block is data the user brought, not a request to run | |
| 151 | + // whatever procedure it happens to name. | |
| 152 | + expanded, refs := skills.ExpandRefs(expanded, r.opts.Skills) | |
| 144 | 153 | input := p.Expand(expanded) |
| 154 | + for _, ref := range refs { | |
| 155 | + r.t.Notice("📖 $" + ref.Name) | |
| 156 | + } | |
| 145 | 157 | for _, a := range attached { |
| 146 | 158 | r.t.Notice("📎 " + a.Path) |
| 147 | 159 | } |
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | "mm/internal/engine" | 13 | "mm/internal/engine" |
| 14 | "mm/internal/mention" | 14 | "mm/internal/mention" |
| 15 | "mm/internal/session" | 15 | "mm/internal/session" |
| 16 | + "mm/internal/skills" | ||
| 16 | "mm/internal/store" | 17 | "mm/internal/store" |
| 17 | "mm/internal/ui" | 18 | "mm/internal/ui" |
| 18 | 19 | ||
| @@ -43,6 +44,10 @@ type Options struct { | |||
| 43 | AgentName string // "mm" | 44 | AgentName string // "mm" |
| 44 | ModelName string | 45 | ModelName string |
| 45 | Provider string | 46 | Provider string |
| 47 | + // Skills is the catalogue the `$` picker offers, and what a `$name` in a | ||
| 48 | + // question is resolved against. Empty means no `$` picker and no `$` | ||
| 49 | + // expansion, which is right for an agent that ships no skill. | ||
| 50 | + Skills []skills.Skill | ||
| 46 | // Version is what the status bar shows — the window's only place for it, | 51 | // Version is what the status bar shows — the window's only place for it, |
| 47 | // since the banner scrolls away with the conversation. Empty is allowed: | 52 | // since the banner scrolls away with the conversation. Empty is allowed: |
| 48 | // the bar then shows the counters alone. | 53 | // the bar then shows the counters alone. |
| @@ -141,7 +146,14 @@ func (r *runner) isBusy() bool { return r.busyLabel() != "" } | |||
| 141 | func (r *runner) startTurn(p Prompt) { | 146 | func (r *runner) startTurn(p Prompt) { |
| 142 | r.t.AddUserFull(p.Display(), p.Text()) | 147 | r.t.AddUserFull(p.Display(), p.Text()) |
| 143 | expanded, attached := mention.Expand(p.Typed, r.opts.Cwd) | 148 | expanded, attached := mention.Expand(p.Typed, r.opts.Cwd) |
| 149 | + // `$skill` is resolved on the same text and for the same reason as | ||
| 150 | + // `@path`: a pasted block is data the user brought, not a request to run | ||
| 151 | + // whatever procedure it happens to name. | ||
| 152 | + expanded, refs := skills.ExpandRefs(expanded, r.opts.Skills) | ||
| 144 | input := p.Expand(expanded) | 153 | input := p.Expand(expanded) |
| 154 | + for _, ref := range refs { | ||
| 155 | + r.t.Notice("📖 $" + ref.Name) | ||
| 156 | + } | ||
| 145 | for _, a := range attached { | 157 | for _, a := range attached { |
| 146 | r.t.Notice("📎 " + a.Path) | 158 | r.t.Notice("📎 " + a.Path) |
| 147 | } | 159 | } |
modified
internal/tui/view.go +7 -0 | @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "strings" |
| 5 | 5 | "time" |
| 6 | 6 | |
| 7 | + "mm/internal/skills" | |
| 8 | + | |
| 7 | 9 | "github.com/gdamore/tcell/v2" |
| 8 | 10 | ) |
| 9 | 11 | |
| @@ -66,6 +68,11 @@ type View struct { | ||
| 66 | 68 | |
| 67 | 69 | agentName string |
| 68 | 70 | previewLines int |
| 71 | + // What the picker completes against. Static for the window's life, so | |
| 72 | + // set once at construction rather than refreshed per frame: the skills | |
| 73 | + // catalogue is read at start-up, and the working directory does not move. | |
| 74 | + skills []skills.Skill | |
| 75 | + cwd string | |
| 69 | 76 | |
| 70 | 77 | // What the runner reports for the chrome, refreshed before each frame. |
| 71 | 78 | header string // the header line |
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "strings" | 4 | "strings" |
| 5 | "time" | 5 | "time" |
| 6 | 6 | ||
| 7 | + "mm/internal/skills" | ||
| 8 | + | ||
| 7 | "github.com/gdamore/tcell/v2" | 9 | "github.com/gdamore/tcell/v2" |
| 8 | ) | 10 | ) |
| 9 | 11 | ||
| @@ -66,6 +68,11 @@ type View struct { | |||
| 66 | 68 | ||
| 67 | agentName string | 69 | agentName string |
| 68 | previewLines int | 70 | previewLines int |
| 71 | + // What the picker completes against. Static for the window's life, so | ||
| 72 | + // set once at construction rather than refreshed per frame: the skills | ||
| 73 | + // catalogue is read at start-up, and the working directory does not move. | ||
| 74 | + skills []skills.Skill | ||
| 75 | + cwd string | ||
| 69 | 76 | ||
| 70 | // What the runner reports for the chrome, refreshed before each frame. | 77 | // What the runner reports for the chrome, refreshed before each frame. |
| 71 | header string // the header line | 78 | header string // the header line |
modified
internal/tui/view_test.go +3 -3 | @@ -171,7 +171,7 @@ func TestPicker(t *testing.T) { | ||
| 171 | 171 | } |
| 172 | 172 | hs.typeText("re") |
| 173 | 173 | cs := hs.v.choices() |
| 174 | - if len(cs) != 1 || cs[0].Name != "resume" { | |
| 174 | + if len(cs) != 1 || cs[0].insert != "/resume" { | |
| 175 | 175 | t.Fatalf("choices for /re: %+v", cs) |
| 176 | 176 | } |
| 177 | 177 | hs.key(tcell.KeyTab, tcell.ModNone) |
| @@ -197,8 +197,8 @@ func TestPicker(t *testing.T) { | ||
| 197 | 197 | // Down wraps; Escape dismisses for this text only. |
| 198 | 198 | hs.typeText("/") |
| 199 | 199 | hs.key(tcell.KeyDown, tcell.ModNone) |
| 200 | - if c, _ := hs.v.selectedChoice(); c.Name != "abort" { | |
| 201 | - t.Errorf("Down selected %q", c.Name) | |
| 200 | + if c, _ := hs.v.selectedChoice(); c.insert != "/abort" { | |
| 201 | + t.Errorf("Down selected %q", c.insert) | |
| 202 | 202 | } |
| 203 | 203 | hs.key(tcell.KeyEscape, tcell.ModNone) |
| 204 | 204 | if hs.v.pickerOpen() || hs.cancelled != 0 { |
| @@ -171,7 +171,7 @@ func TestPicker(t *testing.T) { | |||
| 171 | } | 171 | } |
| 172 | hs.typeText("re") | 172 | hs.typeText("re") |
| 173 | cs := hs.v.choices() | 173 | cs := hs.v.choices() |
| 174 | - if len(cs) != 1 || cs[0].Name != "resume" { | 174 | + if len(cs) != 1 || cs[0].insert != "/resume" { |
| 175 | t.Fatalf("choices for /re: %+v", cs) | 175 | t.Fatalf("choices for /re: %+v", cs) |
| 176 | } | 176 | } |
| 177 | hs.key(tcell.KeyTab, tcell.ModNone) | 177 | hs.key(tcell.KeyTab, tcell.ModNone) |
| @@ -197,8 +197,8 @@ func TestPicker(t *testing.T) { | |||
| 197 | // Down wraps; Escape dismisses for this text only. | 197 | // Down wraps; Escape dismisses for this text only. |
| 198 | hs.typeText("/") | 198 | hs.typeText("/") |
| 199 | hs.key(tcell.KeyDown, tcell.ModNone) | 199 | hs.key(tcell.KeyDown, tcell.ModNone) |
| 200 | - if c, _ := hs.v.selectedChoice(); c.Name != "abort" { | 200 | + if c, _ := hs.v.selectedChoice(); c.insert != "/abort" { |
| 201 | - t.Errorf("Down selected %q", c.Name) | 201 | + t.Errorf("Down selected %q", c.insert) |
| 202 | } | 202 | } |
| 203 | hs.key(tcell.KeyEscape, tcell.ModNone) | 203 | hs.key(tcell.KeyEscape, tcell.ModNone) |
| 204 | if hs.v.pickerOpen() || hs.cancelled != 0 { | 204 | if hs.v.pickerOpen() || hs.cancelled != 0 { |
modified
main.go +3 -0 | @@ -170,6 +170,7 @@ func runWindow(ctx context.Context, e *engine.Engine, agentTools []ai.ToolRef, d | ||
| 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") |