🛟 Updated.
4478b61 parent: cc53fa7 modified
.tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-21T11:45:30.797Z | |
| 9 | +updatedAt: 2026-10-22T17:11:46.417Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 11 | +milestone: v1.0.1 | |
| 12 | 12 | epic: Providers (LLM Engine) |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -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-10-01T05:48:44.919Z | 8 | createdAt: 2026-10-01T05:48:44.919Z |
| 9 | -updatedAt: 2026-10-21T11:45:30.797Z | 9 | +updatedAt: 2026-10-22T17:11:46.417Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | 11 | +milestone: v1.0.1 |
| 12 | epic: Providers (LLM Engine) | 12 | epic: Providers (LLM Engine) |
| 13 | body: "" | 13 | body: "" |
| 14 | tasks: [] | 14 | tasks: [] |
modified
.tickets/issues/0071-mini-me-as-a-sdk.yaml +1 -2 | @@ -6,11 +6,10 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-02T05:32:33.878Z |
| 9 | -updatedAt: 2026-10-20T07:50:44.919Z | |
| 9 | +updatedAt: 2026-10-22T17:07:15.939Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | - kind::feature |
| 13 | -milestone: v1.0.0 | |
| 14 | 13 | epic: SDK |
| 15 | 14 | body: | |
| 16 | 15 | ## Proposal: what "Mini Me as an SDK" could look like |
| @@ -6,11 +6,10 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-02T05:32:33.878Z | 8 | createdAt: 2026-10-02T05:32:33.878Z |
| 9 | -updatedAt: 2026-10-20T07:50:44.919Z | 9 | +updatedAt: 2026-10-22T17:07:15.939Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | - kind::feature | 12 | - kind::feature |
| 13 | -milestone: v1.0.0 | ||
| 14 | epic: SDK | 13 | epic: SDK |
| 15 | body: | | 14 | body: | |
| 16 | ## Proposal: what "Mini Me as an SDK" could look like | 15 | ## Proposal: what "Mini Me as an SDK" could look like |
modified
.tickets/issues/0075-add-gronk-provider.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-21T11:49:01.282Z | |
| 9 | +updatedAt: 2026-10-22T17:11:08.086Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 11 | +milestone: v1.0.1 | |
| 12 | 12 | epic: Providers (LLM Engine) |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -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-10-06T03:58:37.611Z | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-21T11:49:01.282Z | 9 | +updatedAt: 2026-10-22T17:11:08.086Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | 11 | +milestone: v1.0.1 |
| 12 | epic: Providers (LLM Engine) | 12 | epic: Providers (LLM Engine) |
| 13 | body: "" | 13 | body: "" |
| 14 | tasks: [] | 14 | tasks: [] |
modified
.tickets/issues/0105-mini-me-as-a-sdk-2.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-20T07:51:18.410Z |
| 9 | -updatedAt: 2026-10-20T07:52:09.908Z | |
| 9 | +updatedAt: 2026-10-22T17:07:42.565Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 11 | +epic: SDK | |
| 12 | 12 | related: |
| 13 | 13 | - 71 |
| 14 | 14 | body: | |
| @@ -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-10-20T07:51:18.410Z | 8 | createdAt: 2026-10-20T07:51:18.410Z |
| 9 | -updatedAt: 2026-10-20T07:52:09.908Z | 9 | +updatedAt: 2026-10-22T17:07:42.565Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | 11 | +epic: SDK |
| 12 | related: | 12 | related: |
| 13 | - 71 | 13 | - 71 |
| 14 | body: | | 14 | body: | |
modified
.tickets/issues/0111-using-the-settings-guide-create-a-claude-skill.yaml +1 -2 | @@ -6,9 +6,8 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-21T04:27:19.433Z |
| 9 | -updatedAt: 2026-10-21T04:41:59.368Z | |
| 9 | +updatedAt: 2026-10-22T17:10:36.153Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 12 | 11 | body: | |
| 13 | 12 | the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`) |
| 14 | 13 | tasks: [] |
| @@ -6,9 +6,8 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-21T04:27:19.433Z | 8 | createdAt: 2026-10-21T04:27:19.433Z |
| 9 | -updatedAt: 2026-10-21T04:41:59.368Z | 9 | +updatedAt: 2026-10-22T17:10:36.153Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | ||
| 12 | body: | | 11 | body: | |
| 13 | the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`) | 12 | the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`) |
| 14 | tasks: [] | 13 | tasks: [] |
renamed
.tickets/issues/0113-mini-me-as-a-service-turn-mm-into-a-dedicated-ap.yaml +3 -4 | similarity index 96% | ||
| rename from .tickets/issues/0113-mini-me-as-a-service-ideas.yaml | ||
| rename to .tickets/issues/0113-mini-me-as-a-service-turn-mm-into-a-dedicated-ap.yaml | ||
| @@ -6,9 +6,8 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-21T13:00:00.000Z |
| 9 | -updatedAt: 2026-10-21T13:00:00.000Z | |
| 9 | +updatedAt: 2026-10-22T17:09:28.961Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 12 | 11 | epic: Mini Me as a service |
| 13 | 12 | related: |
| 14 | 13 | - 71 |
| @@ -117,7 +116,7 @@ body: | | ||
| 117 | 116 | - Go library (ticket 71) first, or a protocol (MCP / HTTP) first? |
| 118 | 117 | tasks: |
| 119 | 118 | - id: 1 |
| 120 | - title: "Pick the first target application and the direction (A dedicated app, B services, C plugins)" | |
| 119 | + title: Pick the first target application and the direction (A dedicated app, B services, C plugins) | |
| 121 | 120 | state: open |
| 122 | 121 | priority: none |
| 123 | 122 | author: |
| @@ -153,7 +152,7 @@ tasks: | ||
| 153 | 152 | createdAt: 2026-10-21T13:00:00.000Z |
| 154 | 153 | updatedAt: 2026-10-21T13:00:00.000Z |
| 155 | 154 | - id: 5 |
| 156 | - title: "List the prerequisites to lift (global state, concurrency, permissions without a human, auth) and link them to tickets 71, 89, 108" | |
| 155 | + title: List the prerequisites to lift (global state, concurrency, permissions without a human, auth) and link them to tickets 71, 89, 108 | |
| 157 | 156 | state: open |
| 158 | 157 | priority: none |
| 159 | 158 | author: |
| similarity index 96% | |||
| rename from .tickets/issues/0113-mini-me-as-a-service-ideas.yaml | |||
| rename to .tickets/issues/0113-mini-me-as-a-service-turn-mm-into-a-dedicated-ap.yaml | |||
| @@ -6,9 +6,8 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-21T13:00:00.000Z | 8 | createdAt: 2026-10-21T13:00:00.000Z |
| 9 | -updatedAt: 2026-10-21T13:00:00.000Z | 9 | +updatedAt: 2026-10-22T17:09:28.961Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | ||
| 12 | epic: Mini Me as a service | 11 | epic: Mini Me as a service |
| 13 | related: | 12 | related: |
| 14 | - 71 | 13 | - 71 |
| @@ -117,7 +116,7 @@ body: | | |||
| 117 | - Go library (ticket 71) first, or a protocol (MCP / HTTP) first? | 116 | - Go library (ticket 71) first, or a protocol (MCP / HTTP) first? |
| 118 | tasks: | 117 | tasks: |
| 119 | - id: 1 | 118 | - id: 1 |
| 120 | - title: "Pick the first target application and the direction (A dedicated app, B services, C plugins)" | 119 | + title: Pick the first target application and the direction (A dedicated app, B services, C plugins) |
| 121 | state: open | 120 | state: open |
| 122 | priority: none | 121 | priority: none |
| 123 | author: | 122 | author: |
| @@ -153,7 +152,7 @@ tasks: | |||
| 153 | createdAt: 2026-10-21T13:00:00.000Z | 152 | createdAt: 2026-10-21T13:00:00.000Z |
| 154 | updatedAt: 2026-10-21T13:00:00.000Z | 153 | updatedAt: 2026-10-21T13:00:00.000Z |
| 155 | - id: 5 | 154 | - id: 5 |
| 156 | - title: "List the prerequisites to lift (global state, concurrency, permissions without a human, auth) and link them to tickets 71, 89, 108" | 155 | + title: List the prerequisites to lift (global state, concurrency, permissions without a human, auth) and link them to tickets 71, 89, 108 |
| 157 | state: open | 156 | state: open |
| 158 | priority: none | 157 | priority: none |
| 159 | author: | 158 | author: |
renamed
.tickets/issues/0114-openai-compatible-http-service-each-mm-instance.yaml +3 -4 | similarity index 96% | ||
| rename from .tickets/issues/0114-openai-compatible-http-service.yaml | ||
| rename to .tickets/issues/0114-openai-compatible-http-service-each-mm-instance.yaml | ||
| @@ -6,10 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-21T14:00:00.000Z |
| 9 | -updatedAt: 2026-10-21T14:00:00.000Z | |
| 9 | +updatedAt: 2026-10-22T17:08:57.704Z | |
| 10 | 10 | labels: |
| 11 | 11 | - kind::feature |
| 12 | -milestone: v1.0.0 | |
| 13 | 12 | epic: Mini Me as a service |
| 14 | 13 | related: |
| 15 | 14 | - 113 |
| @@ -130,7 +129,7 @@ body: | | ||
| 130 | 129 | - Sessions: stateless only, or save / continue sessions server-side? |
| 131 | 130 | tasks: |
| 132 | 131 | - id: 1 |
| 133 | - title: "Agree on the open questions (system message, sampling, tool activity in the stream, cancelled status, sessions)" | |
| 132 | + title: Agree on the open questions (system message, sampling, tool activity in the stream, cancelled status, sessions) | |
| 134 | 133 | state: open |
| 135 | 134 | priority: none |
| 136 | 135 | author: |
| @@ -184,7 +183,7 @@ tasks: | ||
| 184 | 183 | createdAt: 2026-10-21T14:00:00.000Z |
| 185 | 184 | updatedAt: 2026-10-21T14:00:00.000Z |
| 186 | 185 | - id: 7 |
| 187 | - title: "Check with curl, the openai Python client, Open WebUI; a measured demo; docs EN+FR (how-to, reference, explanation)" | |
| 186 | + title: Check with curl, the openai Python client, Open WebUI; a measured demo; docs EN+FR (how-to, reference, explanation) | |
| 188 | 187 | state: open |
| 189 | 188 | priority: none |
| 190 | 189 | author: |
| similarity index 96% | |||
| rename from .tickets/issues/0114-openai-compatible-http-service.yaml | |||
| rename to .tickets/issues/0114-openai-compatible-http-service-each-mm-instance.yaml | |||
| @@ -6,10 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-21T14:00:00.000Z | 8 | createdAt: 2026-10-21T14:00:00.000Z |
| 9 | -updatedAt: 2026-10-21T14:00:00.000Z | 9 | +updatedAt: 2026-10-22T17:08:57.704Z |
| 10 | labels: | 10 | labels: |
| 11 | - kind::feature | 11 | - kind::feature |
| 12 | -milestone: v1.0.0 | ||
| 13 | epic: Mini Me as a service | 12 | epic: Mini Me as a service |
| 14 | related: | 13 | related: |
| 15 | - 113 | 14 | - 113 |
| @@ -130,7 +129,7 @@ body: | | |||
| 130 | - Sessions: stateless only, or save / continue sessions server-side? | 129 | - Sessions: stateless only, or save / continue sessions server-side? |
| 131 | tasks: | 130 | tasks: |
| 132 | - id: 1 | 131 | - id: 1 |
| 133 | - title: "Agree on the open questions (system message, sampling, tool activity in the stream, cancelled status, sessions)" | 132 | + title: Agree on the open questions (system message, sampling, tool activity in the stream, cancelled status, sessions) |
| 134 | state: open | 133 | state: open |
| 135 | priority: none | 134 | priority: none |
| 136 | author: | 135 | author: |
| @@ -184,7 +183,7 @@ tasks: | |||
| 184 | createdAt: 2026-10-21T14:00:00.000Z | 183 | createdAt: 2026-10-21T14:00:00.000Z |
| 185 | updatedAt: 2026-10-21T14:00:00.000Z | 184 | updatedAt: 2026-10-21T14:00:00.000Z |
| 186 | - id: 7 | 185 | - id: 7 |
| 187 | - title: "Check with curl, the openai Python client, Open WebUI; a measured demo; docs EN+FR (how-to, reference, explanation)" | 186 | + title: Check with curl, the openai Python client, Open WebUI; a measured demo; docs EN+FR (how-to, reference, explanation) |
| 188 | state: open | 187 | state: open |
| 189 | priority: none | 188 | priority: none |
| 190 | author: | 189 | author: |
modified
.tickets/issues/0122-open-a-shell-window.yaml +33 -4 | @@ -1,14 +1,43 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 122 |
| 3 | 3 | title: Open a shell window |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-22T03:58:02.231Z |
| 9 | -updatedAt: 2026-10-22T03:58:02.231Z | |
| 9 | +updatedAt: 2026-10-22T23:30:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v1.0.0 |
| 12 | -body: "" | |
| 12 | +body: | | |
| 13 | + En mode tui je voudrais une commande /shell qui ouvre une popup qui soient un terminal | |
| 14 | + | |
| 15 | + si tu trouve que shell n'est pas le terme approprié propose autre chose | |
| 13 | 16 | tasks: [] |
| 14 | -comments: [] | |
| 17 | +comments: | |
| 18 | + - id: 1 | |
| 19 | + author: | |
| 20 | + name: k33g | |
| 21 | + email: ph.charriere@gmail.com | |
| 22 | + createdAt: 2026-10-22T23:00:00.000Z | |
| 23 | + body: | | |
| 24 | + Done (2026-10-22), uncommitted on feature/open-terminal. User's choices: suspend | |
| 25 | + the window rather than draw a pop-up terminal; keep the name /shell. | |
| 26 | + | |
| 27 | + - command: `/shell`, Window only, run as a control command (at once, even | |
| 28 | + during a turn, which goes on and is not cancelled). | |
| 29 | + - tui/shell.go: tcell Suspend → $SHELL (else /bin/sh; Windows %COMSPEC%, | |
| 30 | + else cmd.exe) in the window's cwd → Resume + Sync; notices | |
| 31 | + "🐚 Back from the shell." / "(exit status N)" / "⚠️ /shell: <error>". | |
| 32 | + SIGINT/SIGQUIT caught and dropped while the shell runs (the window has no | |
| 33 | + handler; a shell that does not take its own process group would let | |
| 34 | + Ctrl-C kill mm). | |
| 35 | + - Tests: tui/shell_test.go (6); command and picker counts updated. Live | |
| 36 | + through a pty: shell in the right dir, Ctrl-C did not kill mm, exit 3 | |
| 37 | + notice, /quit clean. Cross-builds windows/amd64 and darwin/arm64 OK. | |
| 38 | + - Docs EN/FR: slash-commands reference (### /shell), use-the-window-mode | |
| 39 | + how-to, architecture rejected alternative, indexes, root README. | |
| 40 | + - Prompt: "(mm) " in front of the user's own (bash --rcfile, zsh ZDOTDIR | |
| 41 | + relay, fish -C, sh-family PS1, cmd PROMPT) and MM_SHELL=1 for every | |
| 42 | + shell; rc files in a temp dir removed on exit. Tested with real bash, | |
| 43 | + zsh, dash, fish. | |
| @@ -1,14 +1,43 @@ | |||
| 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | id: 122 | 2 | id: 122 |
| 3 | title: Open a shell window | 3 | title: Open a shell window |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-22T03:58:02.231Z | 8 | createdAt: 2026-10-22T03:58:02.231Z |
| 9 | -updatedAt: 2026-10-22T03:58:02.231Z | 9 | +updatedAt: 2026-10-22T23:30:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v1.0.0 | 11 | milestone: v1.0.0 |
| 12 | -body: "" | 12 | +body: | |
| 13 | + En mode tui je voudrais une commande /shell qui ouvre une popup qui soient un terminal | ||
| 14 | + | ||
| 15 | + si tu trouve que shell n'est pas le terme approprié propose autre chose | ||
| 13 | tasks: [] | 16 | tasks: [] |
| 14 | -comments: [] | 17 | +comments: |
| 18 | + - id: 1 | ||
| 19 | + author: | ||
| 20 | + name: k33g | ||
| 21 | + email: ph.charriere@gmail.com | ||
| 22 | + createdAt: 2026-10-22T23:00:00.000Z | ||
| 23 | + body: | | ||
| 24 | + Done (2026-10-22), uncommitted on feature/open-terminal. User's choices: suspend | ||
| 25 | + the window rather than draw a pop-up terminal; keep the name /shell. | ||
| 26 | + | ||
| 27 | + - command: `/shell`, Window only, run as a control command (at once, even | ||
| 28 | + during a turn, which goes on and is not cancelled). | ||
| 29 | + - tui/shell.go: tcell Suspend → $SHELL (else /bin/sh; Windows %COMSPEC%, | ||
| 30 | + else cmd.exe) in the window's cwd → Resume + Sync; notices | ||
| 31 | + "🐚 Back from the shell." / "(exit status N)" / "⚠️ /shell: <error>". | ||
| 32 | + SIGINT/SIGQUIT caught and dropped while the shell runs (the window has no | ||
| 33 | + handler; a shell that does not take its own process group would let | ||
| 34 | + Ctrl-C kill mm). | ||
| 35 | + - Tests: tui/shell_test.go (6); command and picker counts updated. Live | ||
| 36 | + through a pty: shell in the right dir, Ctrl-C did not kill mm, exit 3 | ||
| 37 | + notice, /quit clean. Cross-builds windows/amd64 and darwin/arm64 OK. | ||
| 38 | + - Docs EN/FR: slash-commands reference (### /shell), use-the-window-mode | ||
| 39 | + how-to, architecture rejected alternative, indexes, root README. | ||
| 40 | + - Prompt: "(mm) " in front of the user's own (bash --rcfile, zsh ZDOTDIR | ||
| 41 | + relay, fish -C, sh-family PS1, cmd PROMPT) and MM_SHELL=1 for every | ||
| 42 | + shell; rc files in a temp dir removed on exit. Tested with real bash, | ||
| 43 | + zsh, dash, fish. | ||
modified
.tickets/issues/0124-create-a-blog-post-about-llmman-alone-and-llmman.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-22T06:06:35.493Z |
| 9 | -updatedAt: 2026-10-22T06:06:35.493Z | |
| 9 | +updatedAt: 2026-10-22T17:08:19.090Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | |
| 11 | +milestone: v1.0.1 | |
| 12 | 12 | body: "" |
| 13 | 13 | tasks: [] |
| 14 | 14 | comments: [] |
| @@ -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-10-22T06:06:35.493Z | 8 | createdAt: 2026-10-22T06:06:35.493Z |
| 9 | -updatedAt: 2026-10-22T06:06:35.493Z | 9 | +updatedAt: 2026-10-22T17:08:19.090Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.0 | 11 | +milestone: v1.0.1 |
| 12 | body: "" | 12 | body: "" |
| 13 | tasks: [] | 13 | tasks: [] |
| 14 | comments: [] | 14 | comments: [] |
modified
README.md +1 -1 | @@ -65,7 +65,7 @@ Type a question at the `>` prompt. Each command the model runs is shown with its | ||
| 65 | 65 | mm -tui |
| 66 | 66 | ``` |
| 67 | 67 | |
| 68 | -The conversation above, a box to type in below. **Enter** sends, **Ctrl-J** adds a line, **Esc** cancels a turn (or clears the box when nothing runs), **F1** lists the keys. With sessions on, **↑** in the box brings back your earlier questions, from this session and the project's previous ones. With `yolo: false` in the YAML, the agent asks before running a command or writing a file — in the window, the line mode and an editor alike (`yolo: true`, the default, runs them unasked). | |
| 68 | +The conversation above, a box to type in below. **Enter** sends, **Ctrl-J** adds a line, **Esc** cancels a turn (or clears the box when nothing runs), **F1** lists the keys. With sessions on, **↑** in the box brings back your earlier questions, from this session and the project's previous ones. `/shell` hands the terminal to your shell; `exit` brings the conversation back. With `yolo: false` in the YAML, the agent asks before running a command or writing a file — in the window, the line mode and an editor alike (`yolo: true`, the default, runs them unasked). | |
| 69 | 69 | |
| 70 | 70 | ### 3. Inside an editor (ACP) |
| 71 | 71 | |
| @@ -65,7 +65,7 @@ Type a question at the `>` prompt. Each command the model runs is shown with its | |||
| 65 | mm -tui | 65 | mm -tui |
| 66 | ``` | 66 | ``` |
| 67 | 67 | ||
| 68 | -The conversation above, a box to type in below. **Enter** sends, **Ctrl-J** adds a line, **Esc** cancels a turn (or clears the box when nothing runs), **F1** lists the keys. With sessions on, **↑** in the box brings back your earlier questions, from this session and the project's previous ones. With `yolo: false` in the YAML, the agent asks before running a command or writing a file — in the window, the line mode and an editor alike (`yolo: true`, the default, runs them unasked). | 68 | +The conversation above, a box to type in below. **Enter** sends, **Ctrl-J** adds a line, **Esc** cancels a turn (or clears the box when nothing runs), **F1** lists the keys. With sessions on, **↑** in the box brings back your earlier questions, from this session and the project's previous ones. `/shell` hands the terminal to your shell; `exit` brings the conversation back. With `yolo: false` in the YAML, the agent asks before running a command or writing a file — in the window, the line mode and an editor alike (`yolo: true`, the default, runs them unasked). |
| 69 | 69 | ||
| 70 | ### 3. Inside an editor (ACP) | 70 | ### 3. Inside an editor (ACP) |
| 71 | 71 | ||
modified
docs/en/README.md +1 -1 | @@ -39,7 +39,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | ||
| 39 | 39 | - [Start a new session without restarting](how-to/start-a-new-session.md) |
| 40 | 40 | - [Point the model at a file with `@path`](how-to/attach-a-file.md) |
| 41 | 41 | - [Keep sessions and resume them](how-to/keep-sessions.md) |
| 42 | -- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) | |
| 42 | +- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) — including `/shell`, your shell without leaving mm | |
| 43 | 43 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 44 | 44 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 45 | 45 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 |
| @@ -39,7 +39,7 @@ This documentation is organised along the four [Diátaxis](https://diataxis.fr) | |||
| 39 | - [Start a new session without restarting](how-to/start-a-new-session.md) | 39 | - [Start a new session without restarting](how-to/start-a-new-session.md) |
| 40 | - [Point the model at a file with `@path`](how-to/attach-a-file.md) | 40 | - [Point the model at a file with `@path`](how-to/attach-a-file.md) |
| 41 | - [Keep sessions and resume them](how-to/keep-sessions.md) | 41 | - [Keep sessions and resume them](how-to/keep-sessions.md) |
| 42 | -- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) | 42 | +- [Use the window mode (`mm -tui`)](how-to/use-the-window-mode.md) — including `/shell`, your shell without leaving mm |
| 43 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) | 43 | - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 44 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) | 44 | - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md) |
| 45 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 | 45 | - [Check that the tools go out in a stable order](how-to/verify-tool-order.md) — tests, a recording proxy, and the prompt-cache gain before and after ticket 53 |
modified
docs/en/explanation/architecture.md +1 -0 | @@ -59,6 +59,7 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia | ||
| 59 | 59 | - **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible. |
| 60 | 60 | - **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout. |
| 61 | 61 | - **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover. |
| 62 | +- **A pop-up terminal drawn inside the window for `/shell`.** It would need a pseudo-terminal and a VT emulator: two new dependencies, Unix only, and full-screen programs drawn worse than the terminal itself draws them. The window suspends instead (tcell's own way to run a sub-shell) and gives the shell the real terminal, on every platform mm ships for. The cost: the conversation is hidden while the shell runs. | |
| 62 | 63 | |
| 63 | 64 | ## How it relates to the rest |
| 64 | 65 | |
| @@ -59,6 +59,7 @@ A draw.io diagram of these packages and their dependencies is kept at [`docs/dia | |||
| 59 | - **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible. | 59 | - **One binary per front end.** Rejected because the point of the exercise is to prove the loop is the same; sharing the wiring in `main.go` is what makes that visible. |
| 60 | - **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout. | 60 | - **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout. |
| 61 | - **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover. | 61 | - **Returning Go errors from tools.** Rejected because it ends the turn instead of letting the model recover. |
| 62 | +- **A pop-up terminal drawn inside the window for `/shell`.** It would need a pseudo-terminal and a VT emulator: two new dependencies, Unix only, and full-screen programs drawn worse than the terminal itself draws them. The window suspends instead (tcell's own way to run a sub-shell) and gives the shell the real terminal, on every platform mm ships for. The cost: the conversation is hidden while the shell runs. | ||
| 62 | 63 | ||
| 63 | ## How it relates to the rest | 64 | ## How it relates to the rest |
| 64 | 65 | ||
modified
docs/en/how-to/use-the-window-mode.md +10 -0 | @@ -81,6 +81,16 @@ With `sessions.enabled: true`, the box remembers what you asked. | ||
| 81 | 81 | |
| 82 | 82 | A recalled question is the text mm stored: an `@path` question comes back with its `[attached file: …]` lines, a collapsed paste comes back as its full text, and an `/ask` as `[asked <agent> (…): …]`. Slash commands are not stored and are not recalled. A question whose turn failed or was cancelled is recalled until you ask the next one, even though it is not kept in the session file. With sessions off, or before anything was asked, ↑ on the first row scrolls the conversation as it always did. |
| 83 | 83 | |
| 84 | +## Run commands in your shell without leaving mm | |
| 85 | + | |
| 86 | +1. Type `/shell` and press **Enter**. The window disappears and your shell (`$SHELL`) starts in the window's working directory, under the line `mm: /bin/zsh in /path/to/project — type exit to come back to the conversation.` | |
| 87 | +2. Run what you need: `git diff`, your tests, an editor. `Ctrl-C` stops the command you started, not mm. | |
| 88 | +3. Type `exit`. The conversation comes back as you left it, with `🐚 Back from the shell.` under it. | |
| 89 | + | |
| 90 | +Your prompt starts with `(mm) ` while you are in that shell, so you do not type `exit` in it thinking you are closing a terminal tab. If your prompt theme hides the prefix (powerlevel10k redraws the whole prompt), test the `MM_SHELL` variable, set to `1` in that shell, in your theme's configuration to show your own marker. | |
| 91 | + | |
| 92 | +A turn that was running goes on while you are in the shell, and its answer is there when you come back. If it needs a permission, it waits for you. | |
| 93 | + | |
| 84 | 94 | ## See also |
| 85 | 95 | |
| 86 | 96 | - [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics |
| @@ -81,6 +81,16 @@ With `sessions.enabled: true`, the box remembers what you asked. | |||
| 81 | 81 | ||
| 82 | A recalled question is the text mm stored: an `@path` question comes back with its `[attached file: …]` lines, a collapsed paste comes back as its full text, and an `/ask` as `[asked <agent> (…): …]`. Slash commands are not stored and are not recalled. A question whose turn failed or was cancelled is recalled until you ask the next one, even though it is not kept in the session file. With sessions off, or before anything was asked, ↑ on the first row scrolls the conversation as it always did. | 82 | A recalled question is the text mm stored: an `@path` question comes back with its `[attached file: …]` lines, a collapsed paste comes back as its full text, and an `/ask` as `[asked <agent> (…): …]`. Slash commands are not stored and are not recalled. A question whose turn failed or was cancelled is recalled until you ask the next one, even though it is not kept in the session file. With sessions off, or before anything was asked, ↑ on the first row scrolls the conversation as it always did. |
| 83 | 83 | ||
| 84 | +## Run commands in your shell without leaving mm | ||
| 85 | + | ||
| 86 | +1. Type `/shell` and press **Enter**. The window disappears and your shell (`$SHELL`) starts in the window's working directory, under the line `mm: /bin/zsh in /path/to/project — type exit to come back to the conversation.` | ||
| 87 | +2. Run what you need: `git diff`, your tests, an editor. `Ctrl-C` stops the command you started, not mm. | ||
| 88 | +3. Type `exit`. The conversation comes back as you left it, with `🐚 Back from the shell.` under it. | ||
| 89 | + | ||
| 90 | +Your prompt starts with `(mm) ` while you are in that shell, so you do not type `exit` in it thinking you are closing a terminal tab. If your prompt theme hides the prefix (powerlevel10k redraws the whole prompt), test the `MM_SHELL` variable, set to `1` in that shell, in your theme's configuration to show your own marker. | ||
| 91 | + | ||
| 92 | +A turn that was running goes on while you are in the shell, and its answer is there when you come back. If it needs a permission, it waits for you. | ||
| 93 | + | ||
| 84 | ## See also | 94 | ## See also |
| 85 | 95 | ||
| 86 | - [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics | 96 | - [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics |
modified
docs/en/reference/slash-commands.md +36 -2 | @@ -88,9 +88,43 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach- | ||
| 88 | 88 | |
| 89 | 89 | ## Window mode keys |
| 90 | 90 | |
| 91 | -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). | |
| 91 | +In `mm -tui` the same commands exist, plus `/shell`; `/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). | |
| 92 | 92 | |
| 93 | -`/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). | |
| 93 | +`/quit`, `/abort` and `/shell` 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). | |
| 94 | + | |
| 95 | +### `/shell` | |
| 96 | + | |
| 97 | +Window mode only. Takes no argument: `/shell <text>` is sent to the model. | |
| 98 | + | |
| 99 | +| Step | Behaviour | | |
| 100 | +|------|-----------| | |
| 101 | +| Start | The window gives the terminal back (tcell `Suspend`: normal screen, cooked mode, mouse and paste modes off), prints `mm: <shell> in <dir> — type exit to come back to the conversation.` and runs the shell with the terminal as its input and output. | | |
| 102 | +| Shell | `$SHELL`, else `/bin/sh`; on Windows `%COMSPEC%`, else `cmd.exe`. Started in the window's working directory, with mm's environment plus `MM_SHELL=1`. | | |
| 103 | +| Prompt | Starts with `(mm) `, in front of the prompt your own configuration sets (see below). | | |
| 104 | +| `Ctrl-C`, `Ctrl-\` | Reach the shell and what it runs; `mm` ignores them while the shell runs. | | |
| 105 | +| A turn in progress | Goes on, and is not cancelled. What it streams is shown on the way back. A permission it asks for waits until the window is back. | | |
| 106 | +| Return | When the shell exits: the window is redrawn whole, and a notice says how it ended. | | |
| 107 | + | |
| 108 | +| Notice | When | | |
| 109 | +|--------|------| | |
| 110 | +| `🐚 Back from the shell.` | The shell exited with status 0. | | |
| 111 | +| `🐚 Back from the shell (exit status N).` | The shell exited with status N (`exit 3`, or the last command's status). | | |
| 112 | +| `⚠️ /shell: <error>` | The shell could not start (for example `exec: "/bin/fish": no such file or directory`), or the terminal could not be suspended or resumed. | | |
| 113 | + | |
| 114 | +The history is not touched and the model is not called. | |
| 115 | + | |
| 116 | +How the `(mm) ` prefix is added, per shell (recognised by the program's name): | |
| 117 | + | |
| 118 | +| Shell | How | Your configuration | | |
| 119 | +|-------|-----|--------------------| | |
| 120 | +| `bash` | `--rcfile` with a file of mm's that runs `~/.bashrc`, then sets `PS1="(mm) $PS1"` | `~/.bashrc` runs as usual | | |
| 121 | +| `zsh` | `ZDOTDIR` points at a directory of mm's whose `.zshenv` and `.zshrc` run yours (from your `ZDOTDIR`, else `$HOME`), then set `PROMPT="(mm) $PROMPT"`; `ZDOTDIR` is set back to yours | `.zshenv` and `.zshrc` run as usual | | |
| 122 | +| `fish` | `-C` wraps your `fish_prompt` function after your configuration is read | `config.fish` runs as usual | | |
| 123 | +| `sh`, `dash`, `ash`, `ksh`, `mksh` | `PS1="(mm) $ "` in the environment | a file named by `$ENV` that sets `PS1` replaces the prefix | | |
| 124 | +| `cmd` | `PROMPT="(mm) $P$G"` in the environment | — | | |
| 125 | +| any other | nothing: no prefix | — | | |
| 126 | + | |
| 127 | +mm's files are written in a temporary directory (`mm-shell-*`), removed when the shell exits; when they cannot be written, the shell starts without the prefix. A theme that rebuilds the prompt before each line (powerlevel10k, for instance) replaces the prefix: test `MM_SHELL` in its configuration instead. | |
| 94 | 128 | |
| 95 | 129 | The keys: |
| 96 | 130 | |
| @@ -88,9 +88,43 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach- | |||
| 88 | 88 | ||
| 89 | ## Window mode keys | 89 | ## Window mode keys |
| 90 | 90 | ||
| 91 | -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). | 91 | +In `mm -tui` the same commands exist, plus `/shell`; `/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). |
| 92 | 92 | ||
| 93 | -`/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). | 93 | +`/quit`, `/abort` and `/shell` 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). |
| 94 | + | ||
| 95 | +### `/shell` | ||
| 96 | + | ||
| 97 | +Window mode only. Takes no argument: `/shell <text>` is sent to the model. | ||
| 98 | + | ||
| 99 | +| Step | Behaviour | | ||
| 100 | +|------|-----------| | ||
| 101 | +| Start | The window gives the terminal back (tcell `Suspend`: normal screen, cooked mode, mouse and paste modes off), prints `mm: <shell> in <dir> — type exit to come back to the conversation.` and runs the shell with the terminal as its input and output. | | ||
| 102 | +| Shell | `$SHELL`, else `/bin/sh`; on Windows `%COMSPEC%`, else `cmd.exe`. Started in the window's working directory, with mm's environment plus `MM_SHELL=1`. | | ||
| 103 | +| Prompt | Starts with `(mm) `, in front of the prompt your own configuration sets (see below). | | ||
| 104 | +| `Ctrl-C`, `Ctrl-\` | Reach the shell and what it runs; `mm` ignores them while the shell runs. | | ||
| 105 | +| A turn in progress | Goes on, and is not cancelled. What it streams is shown on the way back. A permission it asks for waits until the window is back. | | ||
| 106 | +| Return | When the shell exits: the window is redrawn whole, and a notice says how it ended. | | ||
| 107 | + | ||
| 108 | +| Notice | When | | ||
| 109 | +|--------|------| | ||
| 110 | +| `🐚 Back from the shell.` | The shell exited with status 0. | | ||
| 111 | +| `🐚 Back from the shell (exit status N).` | The shell exited with status N (`exit 3`, or the last command's status). | | ||
| 112 | +| `⚠️ /shell: <error>` | The shell could not start (for example `exec: "/bin/fish": no such file or directory`), or the terminal could not be suspended or resumed. | | ||
| 113 | + | ||
| 114 | +The history is not touched and the model is not called. | ||
| 115 | + | ||
| 116 | +How the `(mm) ` prefix is added, per shell (recognised by the program's name): | ||
| 117 | + | ||
| 118 | +| Shell | How | Your configuration | | ||
| 119 | +|-------|-----|--------------------| | ||
| 120 | +| `bash` | `--rcfile` with a file of mm's that runs `~/.bashrc`, then sets `PS1="(mm) $PS1"` | `~/.bashrc` runs as usual | | ||
| 121 | +| `zsh` | `ZDOTDIR` points at a directory of mm's whose `.zshenv` and `.zshrc` run yours (from your `ZDOTDIR`, else `$HOME`), then set `PROMPT="(mm) $PROMPT"`; `ZDOTDIR` is set back to yours | `.zshenv` and `.zshrc` run as usual | | ||
| 122 | +| `fish` | `-C` wraps your `fish_prompt` function after your configuration is read | `config.fish` runs as usual | | ||
| 123 | +| `sh`, `dash`, `ash`, `ksh`, `mksh` | `PS1="(mm) $ "` in the environment | a file named by `$ENV` that sets `PS1` replaces the prefix | | ||
| 124 | +| `cmd` | `PROMPT="(mm) $P$G"` in the environment | — | | ||
| 125 | +| any other | nothing: no prefix | — | | ||
| 126 | + | ||
| 127 | +mm's files are written in a temporary directory (`mm-shell-*`), removed when the shell exits; when they cannot be written, the shell starts without the prefix. A theme that rebuilds the prompt before each line (powerlevel10k, for instance) replaces the prefix: test `MM_SHELL` in its configuration instead. | ||
| 94 | 128 | ||
| 95 | The keys: | 129 | The keys: |
| 96 | 130 | ||
modified
docs/fr/README.md +1 -1 | @@ -39,7 +39,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | ||
| 39 | 39 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) |
| 40 | 40 | - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) |
| 41 | 41 | - [Conserver les sessions et les reprendre](how-to/keep-sessions.md) |
| 42 | -- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) | |
| 42 | +- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) — dont `/shell`, votre shell sans quitter mm | |
| 43 | 43 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 44 | 44 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 45 | 45 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 |
| @@ -39,7 +39,7 @@ Cette documentation est organisée selon les quatre quadrants [Diátaxis](https: | |||
| 39 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) | 39 | - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md) |
| 40 | - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) | 40 | - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md) |
| 41 | - [Conserver les sessions et les reprendre](how-to/keep-sessions.md) | 41 | - [Conserver les sessions et les reprendre](how-to/keep-sessions.md) |
| 42 | -- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) | 42 | +- [Utiliser le mode fenêtre (`mm -tui`)](how-to/use-the-window-mode.md) — dont `/shell`, votre shell sans quitter mm |
| 43 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) | 43 | - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md) |
| 44 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) | 44 | - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md) |
| 45 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 | 45 | - [Vérifier que les outils partent dans un ordre stable](how-to/verify-tool-order.md) — tests, proxy d'enregistrement, et gain du cache de prompt avant et après le ticket 53 |
modified
docs/fr/explanation/architecture.md +1 -0 | @@ -59,6 +59,7 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans | ||
| 59 | 59 | - **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible. |
| 60 | 60 | - **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout. |
| 61 | 61 | - **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper. |
| 62 | +- **Un terminal en popup dessiné dans la fenêtre pour `/shell`.** Il faudrait un pseudo-terminal et un émulateur VT : deux nouvelles dépendances, Unix seulement, et des programmes plein écran moins bien dessinés que par le terminal lui-même. La fenêtre se suspend à la place (la manière prévue par tcell pour lancer un sous-shell) et donne au shell le vrai terminal, sur toutes les plateformes où mm est distribué. Le prix : la conversation est cachée tant que le shell tourne. | |
| 62 | 63 | |
| 63 | 64 | ## Liens avec le reste |
| 64 | 65 | |
| @@ -59,6 +59,7 @@ Un diagramme draw.io de ces paquets et de leurs dépendances est conservé dans | |||
| 59 | - **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible. | 59 | - **Un binaire par façade.** Écarté parce que le but de l'exercice est de prouver que la boucle est la même ; partager le câblage dans `main.go` est ce qui le rend visible. |
| 60 | - **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout. | 60 | - **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout. |
| 61 | - **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper. | 61 | - **Renvoyer des erreurs Go depuis les outils.** Écarté parce que cela termine le tour au lieu de laisser le modèle se rattraper. |
| 62 | +- **Un terminal en popup dessiné dans la fenêtre pour `/shell`.** Il faudrait un pseudo-terminal et un émulateur VT : deux nouvelles dépendances, Unix seulement, et des programmes plein écran moins bien dessinés que par le terminal lui-même. La fenêtre se suspend à la place (la manière prévue par tcell pour lancer un sous-shell) et donne au shell le vrai terminal, sur toutes les plateformes où mm est distribué. Le prix : la conversation est cachée tant que le shell tourne. | ||
| 62 | 63 | ||
| 63 | ## Liens avec le reste | 64 | ## Liens avec le reste |
| 64 | 65 | ||
modified
docs/fr/how-to/use-the-window-mode.md +10 -0 | @@ -81,6 +81,16 @@ Avec `sessions.enabled: true`, la zone de saisie se souvient de ce que vous avez | ||
| 81 | 81 | |
| 82 | 82 | Une question rappelée est le texte que mm a enregistré : une question avec `@chemin` revient avec ses lignes `[attached file: …]`, un collage replié revient en texte complet, un `/ask` sous la forme `[asked <agent> (…): …]`. Les commandes `/` ne sont pas enregistrées et ne sont pas rappelées. La question d'un tour qui a échoué ou a été annulé reste rappelable jusqu'à la question suivante, bien qu'elle ne soit pas gardée dans le fichier de session. Sessions désactivées, ou avant toute question, ↑ depuis la première rangée fait défiler la conversation comme avant. |
| 83 | 83 | |
| 84 | +## Lancer des commandes dans votre shell sans quitter mm | |
| 85 | + | |
| 86 | +1. Tapez `/shell` et appuyez sur **Entrée**. La fenêtre disparaît et votre shell (`$SHELL`) démarre dans le dossier de travail de la fenêtre, sous la ligne `mm: /bin/zsh in /chemin/du/projet — type exit to come back to the conversation.` | |
| 87 | +2. Lancez ce dont vous avez besoin : `git diff`, vos tests, un éditeur. `Ctrl-C` arrête la commande que vous avez lancée, pas mm. | |
| 88 | +3. Tapez `exit`. La conversation revient telle que vous l'avez laissée, avec `🐚 Back from the shell.` en dessous. | |
| 89 | + | |
| 90 | +Votre prompt commence par `(mm) ` tant que vous êtes dans ce shell : vous ne tapez donc pas `exit` en croyant fermer un onglet de terminal. Si votre thème de prompt masque le préfixe (powerlevel10k redessine tout le prompt), testez la variable `MM_SHELL`, qui vaut `1` dans ce shell, dans la configuration de votre thème pour afficher votre propre repère. | |
| 91 | + | |
| 92 | +Un tour en cours continue pendant que vous êtes dans le shell, et sa réponse est là à votre retour. S'il a besoin d'une permission, il vous attend. | |
| 93 | + | |
| 84 | 94 | ## Voir aussi |
| 85 | 95 | |
| 86 | 96 | - [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre |
| @@ -81,6 +81,16 @@ Avec `sessions.enabled: true`, la zone de saisie se souvient de ce que vous avez | |||
| 81 | 81 | ||
| 82 | Une question rappelée est le texte que mm a enregistré : une question avec `@chemin` revient avec ses lignes `[attached file: …]`, un collage replié revient en texte complet, un `/ask` sous la forme `[asked <agent> (…): …]`. Les commandes `/` ne sont pas enregistrées et ne sont pas rappelées. La question d'un tour qui a échoué ou a été annulé reste rappelable jusqu'à la question suivante, bien qu'elle ne soit pas gardée dans le fichier de session. Sessions désactivées, ou avant toute question, ↑ depuis la première rangée fait défiler la conversation comme avant. | 82 | Une question rappelée est le texte que mm a enregistré : une question avec `@chemin` revient avec ses lignes `[attached file: …]`, un collage replié revient en texte complet, un `/ask` sous la forme `[asked <agent> (…): …]`. Les commandes `/` ne sont pas enregistrées et ne sont pas rappelées. La question d'un tour qui a échoué ou a été annulé reste rappelable jusqu'à la question suivante, bien qu'elle ne soit pas gardée dans le fichier de session. Sessions désactivées, ou avant toute question, ↑ depuis la première rangée fait défiler la conversation comme avant. |
| 83 | 83 | ||
| 84 | +## Lancer des commandes dans votre shell sans quitter mm | ||
| 85 | + | ||
| 86 | +1. Tapez `/shell` et appuyez sur **Entrée**. La fenêtre disparaît et votre shell (`$SHELL`) démarre dans le dossier de travail de la fenêtre, sous la ligne `mm: /bin/zsh in /chemin/du/projet — type exit to come back to the conversation.` | ||
| 87 | +2. Lancez ce dont vous avez besoin : `git diff`, vos tests, un éditeur. `Ctrl-C` arrête la commande que vous avez lancée, pas mm. | ||
| 88 | +3. Tapez `exit`. La conversation revient telle que vous l'avez laissée, avec `🐚 Back from the shell.` en dessous. | ||
| 89 | + | ||
| 90 | +Votre prompt commence par `(mm) ` tant que vous êtes dans ce shell : vous ne tapez donc pas `exit` en croyant fermer un onglet de terminal. Si votre thème de prompt masque le préfixe (powerlevel10k redessine tout le prompt), testez la variable `MM_SHELL`, qui vaut `1` dans ce shell, dans la configuration de votre thème pour afficher votre propre repère. | ||
| 91 | + | ||
| 92 | +Un tour en cours continue pendant que vous êtes dans le shell, et sa réponse est là à votre retour. S'il a besoin d'une permission, il vous attend. | ||
| 93 | + | ||
| 84 | ## Voir aussi | 94 | ## Voir aussi |
| 85 | 95 | ||
| 86 | - [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre | 96 | - [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre |
modified
docs/fr/reference/slash-commands.md +36 -2 | @@ -88,9 +88,43 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t | ||
| 88 | 88 | |
| 89 | 89 | ## Touches du mode fenêtre |
| 90 | 90 | |
| 91 | -Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. | |
| 91 | +Dans `mm -tui` les mêmes commandes existent, plus `/shell` ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. | |
| 92 | 92 | |
| 93 | -`/quit` et `/abort` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas). | |
| 93 | +`/quit`, `/abort` et `/shell` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas). | |
| 94 | + | |
| 95 | +### `/shell` | |
| 96 | + | |
| 97 | +Mode fenêtre uniquement. Ne prend pas d'argument : `/shell <texte>` est envoyé au modèle. | |
| 98 | + | |
| 99 | +| Étape | Comportement | | |
| 100 | +|-------|--------------| | |
| 101 | +| Départ | La fenêtre rend le terminal (`Suspend` de tcell : écran normal, mode ligne, souris et collage désactivés), affiche `mm: <shell> in <dossier> — type exit to come back to the conversation.` et lance le shell, avec le terminal comme entrée et sortie. | | |
| 102 | +| Shell | `$SHELL`, sinon `/bin/sh` ; sous Windows `%COMSPEC%`, sinon `cmd.exe`. Lancé dans le dossier de travail de la fenêtre, avec l'environnement de mm plus `MM_SHELL=1`. | | |
| 103 | +| Prompt | Commence par `(mm) `, devant le prompt que définit votre propre configuration (voir ci-dessous). | | |
| 104 | +| `Ctrl-C`, `Ctrl-\` | Atteignent le shell et ce qu'il exécute ; `mm` les ignore pendant que le shell tourne. | | |
| 105 | +| Un tour en cours | Continue, sans être annulé. Ce qu'il diffuse s'affiche au retour. Une permission qu'il demande attend le retour de la fenêtre. | | |
| 106 | +| Retour | Quand le shell se termine : la fenêtre est entièrement redessinée, et une notice dit comment il s'est terminé. | | |
| 107 | + | |
| 108 | +| Notice | Quand | | |
| 109 | +|--------|-------| | |
| 110 | +| `🐚 Back from the shell.` | Le shell s'est terminé avec le statut 0. | | |
| 111 | +| `🐚 Back from the shell (exit status N).` | Le shell s'est terminé avec le statut N (`exit 3`, ou le statut de la dernière commande). | | |
| 112 | +| `⚠️ /shell: <erreur>` | Le shell n'a pas pu démarrer (par exemple `exec: "/bin/fish": no such file or directory`), ou le terminal n'a pas pu être suspendu ou repris. | | |
| 113 | + | |
| 114 | +L'historique n'est pas modifié et le modèle n'est pas appelé. | |
| 115 | + | |
| 116 | +Comment le préfixe `(mm) ` est ajouté, selon le shell (reconnu au nom du programme) : | |
| 117 | + | |
| 118 | +| Shell | Comment | Votre configuration | | |
| 119 | +|-------|---------|---------------------| | |
| 120 | +| `bash` | `--rcfile` avec un fichier de mm qui exécute `~/.bashrc`, puis fixe `PS1="(mm) $PS1"` | `~/.bashrc` s'exécute comme d'habitude | | |
| 121 | +| `zsh` | `ZDOTDIR` pointe vers un dossier de mm dont les `.zshenv` et `.zshrc` exécutent les vôtres (depuis votre `ZDOTDIR`, sinon `$HOME`), puis fixent `PROMPT="(mm) $PROMPT"` ; `ZDOTDIR` reprend ensuite votre valeur | `.zshenv` et `.zshrc` s'exécutent comme d'habitude | | |
| 122 | +| `fish` | `-C` enveloppe votre fonction `fish_prompt` après la lecture de votre configuration | `config.fish` s'exécute comme d'habitude | | |
| 123 | +| `sh`, `dash`, `ash`, `ksh`, `mksh` | `PS1="(mm) $ "` dans l'environnement | un fichier désigné par `$ENV` qui fixe `PS1` remplace le préfixe | | |
| 124 | +| `cmd` | `PROMPT="(mm) $P$G"` dans l'environnement | — | | |
| 125 | +| tout autre shell | rien : pas de préfixe | — | | |
| 126 | + | |
| 127 | +Les fichiers de mm sont écrits dans un dossier temporaire (`mm-shell-*`), supprimé quand le shell se termine ; s'ils ne peuvent pas être écrits, le shell démarre sans le préfixe. Un thème qui recalcule le prompt avant chaque ligne (powerlevel10k, par exemple) remplace le préfixe : testez plutôt `MM_SHELL` dans sa configuration. | |
| 94 | 128 | |
| 95 | 129 | Les touches : |
| 96 | 130 | |
| @@ -88,9 +88,43 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t | |||
| 88 | 88 | ||
| 89 | ## Touches du mode fenêtre | 89 | ## Touches du mode fenêtre |
| 90 | 90 | ||
| 91 | -Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. | 91 | +Dans `mm -tui` les mêmes commandes existent, plus `/shell` ; `/abort` au repos affiche `⚠️ No generation in progress.` comme notice, et le sélecteur ouvert par `/` les liste. |
| 92 | 92 | ||
| 93 | -`/quit` et `/abort` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas). | 93 | +`/quit`, `/abort` et `/shell` prennent effet **immédiatement**, même pendant un tour. Toutes les autres commandes et toutes les questions attendent la fin du tour, parce qu'elles ont besoin de l'historique que le tour est encore en train d'écrire. Voir [Sortir d'un tour qui ne s'arrête pas](#sortir-dun-tour-qui-ne-sarrête-pas). |
| 94 | + | ||
| 95 | +### `/shell` | ||
| 96 | + | ||
| 97 | +Mode fenêtre uniquement. Ne prend pas d'argument : `/shell <texte>` est envoyé au modèle. | ||
| 98 | + | ||
| 99 | +| Étape | Comportement | | ||
| 100 | +|-------|--------------| | ||
| 101 | +| Départ | La fenêtre rend le terminal (`Suspend` de tcell : écran normal, mode ligne, souris et collage désactivés), affiche `mm: <shell> in <dossier> — type exit to come back to the conversation.` et lance le shell, avec le terminal comme entrée et sortie. | | ||
| 102 | +| Shell | `$SHELL`, sinon `/bin/sh` ; sous Windows `%COMSPEC%`, sinon `cmd.exe`. Lancé dans le dossier de travail de la fenêtre, avec l'environnement de mm plus `MM_SHELL=1`. | | ||
| 103 | +| Prompt | Commence par `(mm) `, devant le prompt que définit votre propre configuration (voir ci-dessous). | | ||
| 104 | +| `Ctrl-C`, `Ctrl-\` | Atteignent le shell et ce qu'il exécute ; `mm` les ignore pendant que le shell tourne. | | ||
| 105 | +| Un tour en cours | Continue, sans être annulé. Ce qu'il diffuse s'affiche au retour. Une permission qu'il demande attend le retour de la fenêtre. | | ||
| 106 | +| Retour | Quand le shell se termine : la fenêtre est entièrement redessinée, et une notice dit comment il s'est terminé. | | ||
| 107 | + | ||
| 108 | +| Notice | Quand | | ||
| 109 | +|--------|-------| | ||
| 110 | +| `🐚 Back from the shell.` | Le shell s'est terminé avec le statut 0. | | ||
| 111 | +| `🐚 Back from the shell (exit status N).` | Le shell s'est terminé avec le statut N (`exit 3`, ou le statut de la dernière commande). | | ||
| 112 | +| `⚠️ /shell: <erreur>` | Le shell n'a pas pu démarrer (par exemple `exec: "/bin/fish": no such file or directory`), ou le terminal n'a pas pu être suspendu ou repris. | | ||
| 113 | + | ||
| 114 | +L'historique n'est pas modifié et le modèle n'est pas appelé. | ||
| 115 | + | ||
| 116 | +Comment le préfixe `(mm) ` est ajouté, selon le shell (reconnu au nom du programme) : | ||
| 117 | + | ||
| 118 | +| Shell | Comment | Votre configuration | | ||
| 119 | +|-------|---------|---------------------| | ||
| 120 | +| `bash` | `--rcfile` avec un fichier de mm qui exécute `~/.bashrc`, puis fixe `PS1="(mm) $PS1"` | `~/.bashrc` s'exécute comme d'habitude | | ||
| 121 | +| `zsh` | `ZDOTDIR` pointe vers un dossier de mm dont les `.zshenv` et `.zshrc` exécutent les vôtres (depuis votre `ZDOTDIR`, sinon `$HOME`), puis fixent `PROMPT="(mm) $PROMPT"` ; `ZDOTDIR` reprend ensuite votre valeur | `.zshenv` et `.zshrc` s'exécutent comme d'habitude | | ||
| 122 | +| `fish` | `-C` enveloppe votre fonction `fish_prompt` après la lecture de votre configuration | `config.fish` s'exécute comme d'habitude | | ||
| 123 | +| `sh`, `dash`, `ash`, `ksh`, `mksh` | `PS1="(mm) $ "` dans l'environnement | un fichier désigné par `$ENV` qui fixe `PS1` remplace le préfixe | | ||
| 124 | +| `cmd` | `PROMPT="(mm) $P$G"` dans l'environnement | — | | ||
| 125 | +| tout autre shell | rien : pas de préfixe | — | | ||
| 126 | + | ||
| 127 | +Les fichiers de mm sont écrits dans un dossier temporaire (`mm-shell-*`), supprimé quand le shell se termine ; s'ils ne peuvent pas être écrits, le shell démarre sans le préfixe. Un thème qui recalcule le prompt avant chaque ligne (powerlevel10k, par exemple) remplace le préfixe : testez plutôt `MM_SHELL` dans sa configuration. | ||
| 94 | 128 | ||
| 95 | Les touches : | 129 | Les touches : |
| 96 | 130 | ||
modified
internal/command/command.go +4 -0 | @@ -44,6 +44,7 @@ const ( | ||
| 44 | 44 | Unload = "unload" |
| 45 | 45 | Memory = "memory" |
| 46 | 46 | Docs = "docs" |
| 47 | + Shell = "shell" | |
| 47 | 48 | ) |
| 48 | 49 | |
| 49 | 50 | // Command is one slash command: what it is called, what it does, who offers |
| @@ -77,6 +78,9 @@ var table = []Command{ | ||
| 77 | 78 | {Name: Help, Description: "List the commands", Fronts: Terminal | Window}, |
| 78 | 79 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, |
| 79 | 80 | {Name: Save, Description: "Save the last answer to a file, never overwriting one (/save notes.md 2: the answer before)", Fronts: Terminal | Window, TakesArg: true, Hint: "<file> [n]"}, |
| 81 | + // Window only: the window owns the whole terminal, /shell hands it back | |
| 82 | + // for a while (ticket 122). | |
| 83 | + {Name: Shell, Description: "Open your shell in this terminal; exit it to come back to the conversation", Fronts: Window}, | |
| 80 | 84 | } |
| 81 | 85 | |
| 82 | 86 | // All returns the whole table, in display order: the fixed commands, /unload |
| @@ -44,6 +44,7 @@ const ( | |||
| 44 | Unload = "unload" | 44 | Unload = "unload" |
| 45 | Memory = "memory" | 45 | Memory = "memory" |
| 46 | Docs = "docs" | 46 | Docs = "docs" |
| 47 | + Shell = "shell" | ||
| 47 | ) | 48 | ) |
| 48 | 49 | ||
| 49 | // Command is one slash command: what it is called, what it does, who offers | 50 | // Command is one slash command: what it is called, what it does, who offers |
| @@ -77,6 +78,9 @@ var table = []Command{ | |||
| 77 | {Name: Help, Description: "List the commands", Fronts: Terminal | Window}, | 78 | {Name: Help, Description: "List the commands", Fronts: Terminal | Window}, |
| 78 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, | 79 | {Name: Copy, Description: "Copy the last answer to the clipboard (/copy 2: the one before; /copy all: the whole conversation)", Fronts: Terminal | Window, TakesArg: true}, |
| 79 | {Name: Save, Description: "Save the last answer to a file, never overwriting one (/save notes.md 2: the answer before)", Fronts: Terminal | Window, TakesArg: true, Hint: "<file> [n]"}, | 80 | {Name: Save, Description: "Save the last answer to a file, never overwriting one (/save notes.md 2: the answer before)", Fronts: Terminal | Window, TakesArg: true, Hint: "<file> [n]"}, |
| 81 | + // Window only: the window owns the whole terminal, /shell hands it back | ||
| 82 | + // for a while (ticket 122). | ||
| 83 | + {Name: Shell, Description: "Open your shell in this terminal; exit it to come back to the conversation", Fronts: Window}, | ||
| 80 | } | 84 | } |
| 81 | 85 | ||
| 82 | // All returns the whole table, in display order: the fixed commands, /unload | 86 | // All returns the whole table, in display order: the fixed commands, /unload |
modified
internal/command/command_test.go +5 -4 | @@ -23,11 +23,12 @@ func TestForFiltersByFront(t *testing.T) { | ||
| 23 | 23 | if got := names(For(Terminal)); got != want { |
| 24 | 24 | t.Errorf("Terminal commands = %q, want %q", got, want) |
| 25 | 25 | } |
| 26 | - if got := names(For(Window)); got != want { | |
| 27 | - t.Errorf("Window commands = %q, want %q", got, want) | |
| 26 | + // /shell is the window's own: it hands back the terminal the window owns. | |
| 27 | + if got := names(For(Window)); got != want+" shell" { | |
| 28 | + t.Errorf("Window commands = %q, want %q", got, want+" shell") | |
| 28 | 29 | } |
| 29 | - if len(All()) != 9 { | |
| 30 | - t.Errorf("All() has %d commands, want 9", len(All())) | |
| 30 | + if len(All()) != 10 { | |
| 31 | + t.Errorf("All() has %d commands, want 10", len(All())) | |
| 31 | 32 | } |
| 32 | 33 | } |
| 33 | 34 | |
| @@ -23,11 +23,12 @@ func TestForFiltersByFront(t *testing.T) { | |||
| 23 | if got := names(For(Terminal)); got != want { | 23 | if got := names(For(Terminal)); got != want { |
| 24 | t.Errorf("Terminal commands = %q, want %q", got, want) | 24 | t.Errorf("Terminal commands = %q, want %q", got, want) |
| 25 | } | 25 | } |
| 26 | - if got := names(For(Window)); got != want { | 26 | + // /shell is the window's own: it hands back the terminal the window owns. |
| 27 | - t.Errorf("Window commands = %q, want %q", got, want) | 27 | + if got := names(For(Window)); got != want+" shell" { |
| 28 | + t.Errorf("Window commands = %q, want %q", got, want+" shell") | ||
| 28 | } | 29 | } |
| 29 | - if len(All()) != 9 { | 30 | + if len(All()) != 10 { |
| 30 | - t.Errorf("All() has %d commands, want 9", len(All())) | 31 | + t.Errorf("All() has %d commands, want 10", len(All())) |
| 31 | } | 32 | } |
| 32 | } | 33 | } |
| 33 | 34 | ||
modified
internal/tui/commands.go +4 -1 | @@ -39,7 +39,8 @@ func (r *runner) submit(p Prompt) { | ||
| 39 | 39 | |
| 40 | 40 | // control runs the commands that must never wait for the turn in progress, |
| 41 | 41 | // and reports whether it ran one. Everything else needs the session the turn |
| 42 | -// is still writing to, so it keeps queueing. | |
| 42 | +// is still writing to, so it keeps queueing. /shell touches no session: it | |
| 43 | +// runs at once, and the turn goes on under it instead of being cancelled. | |
| 43 | 44 | func (r *runner) control(p Prompt) bool { |
| 44 | 45 | cmd, _, ok := command.Parse(p.Typed) |
| 45 | 46 | if !ok { |
| @@ -50,6 +51,8 @@ func (r *runner) control(p Prompt) bool { | ||
| 50 | 51 | r.quitNow() |
| 51 | 52 | case command.Abort: |
| 52 | 53 | r.abort() |
| 54 | + case command.Shell: | |
| 55 | + r.shell() | |
| 53 | 56 | default: |
| 54 | 57 | return false |
| 55 | 58 | } |
| @@ -39,7 +39,8 @@ func (r *runner) submit(p Prompt) { | |||
| 39 | 39 | ||
| 40 | // control runs the commands that must never wait for the turn in progress, | 40 | // control runs the commands that must never wait for the turn in progress, |
| 41 | // and reports whether it ran one. Everything else needs the session the turn | 41 | // and reports whether it ran one. Everything else needs the session the turn |
| 42 | -// is still writing to, so it keeps queueing. | 42 | +// is still writing to, so it keeps queueing. /shell touches no session: it |
| 43 | +// runs at once, and the turn goes on under it instead of being cancelled. | ||
| 43 | func (r *runner) control(p Prompt) bool { | 44 | func (r *runner) control(p Prompt) bool { |
| 44 | cmd, _, ok := command.Parse(p.Typed) | 45 | cmd, _, ok := command.Parse(p.Typed) |
| 45 | if !ok { | 46 | if !ok { |
| @@ -50,6 +51,8 @@ func (r *runner) control(p Prompt) bool { | |||
| 50 | r.quitNow() | 51 | r.quitNow() |
| 51 | case command.Abort: | 52 | case command.Abort: |
| 52 | r.abort() | 53 | r.abort() |
| 54 | + case command.Shell: | ||
| 55 | + r.shell() | ||
| 53 | default: | 56 | default: |
| 54 | return false | 57 | return false |
| 55 | } | 58 | } |
added
internal/tui/shell.go +123 -0 | new file mode 100644 | ||
| @@ -0,0 +1,123 @@ | ||
| 1 | +// /shell (ticket 122): the window steps aside, the user's shell gets the | |
| 2 | +// terminal, and `exit` brings the conversation back as it was. Not a pop-up | |
| 3 | +// terminal drawn inside the window: that needs a pseudo-terminal and a VT | |
| 4 | +// emulator, works on Unix only, and draws full-screen programs worse than | |
| 5 | +// the terminal itself. Suspending is what tcell offers for "run a | |
| 6 | +// sub-shell", and it works on every platform mm ships for. | |
| 7 | + | |
| 8 | +package tui | |
| 9 | + | |
| 10 | +import ( | |
| 11 | + "errors" | |
| 12 | + "fmt" | |
| 13 | + "os" | |
| 14 | + "os/exec" | |
| 15 | + "os/signal" | |
| 16 | + "path/filepath" | |
| 17 | + "runtime" | |
| 18 | + "syscall" | |
| 19 | + | |
| 20 | + "github.com/gdamore/tcell/v2" | |
| 21 | +) | |
| 22 | + | |
| 23 | +// shellCommand is the program /shell starts: $SHELL, else /bin/sh; on | |
| 24 | +// Windows %COMSPEC%, else cmd.exe. getenv is os.Getenv outside tests. | |
| 25 | +// | |
| 26 | +// shellCommand("linux", os.Getenv) // "/bin/zsh" when SHELL=/bin/zsh | |
| 27 | +// shellCommand("windows", os.Getenv) // "C:\Windows\system32\cmd.exe" | |
| 28 | +func shellCommand(goos string, getenv func(string) string) string { | |
| 29 | + if goos == "windows" { | |
| 30 | + if c := getenv("COMSPEC"); c != "" { | |
| 31 | + return c | |
| 32 | + } | |
| 33 | + return "cmd.exe" | |
| 34 | + } | |
| 35 | + if s := getenv("SHELL"); s != "" { | |
| 36 | + return s | |
| 37 | + } | |
| 38 | + return "/bin/sh" | |
| 39 | +} | |
| 40 | + | |
| 41 | +// runShell runs the user's shell in dir on the real terminal, and returns | |
| 42 | +// when it exits. The window must be suspended first. | |
| 43 | +// | |
| 44 | +// Ctrl-C and Ctrl-\ typed in the shell can reach mm too: the terminal | |
| 45 | +// sends them to its whole foreground process group, and only some shells | |
| 46 | +// (bash, zsh) move to a group of their own. The window has no signal | |
| 47 | +// handler, so the default one would end mm under the user's shell. They are | |
| 48 | +// caught, and dropped, while the shell runs; a caught signal is reset to its | |
| 49 | +// default in the child, so the shell and what it runs still get them. | |
| 50 | +func runShell(dir string) error { | |
| 51 | + caught := make(chan os.Signal, 8) | |
| 52 | + signal.Notify(caught, os.Interrupt, syscall.SIGQUIT) | |
| 53 | + defer signal.Stop(caught) | |
| 54 | + | |
| 55 | + shell := shellCommand(runtime.GOOS, os.Getenv) | |
| 56 | + l, cleanup := prepareLaunch(shell) | |
| 57 | + defer cleanup() | |
| 58 | + fmt.Printf("mm: %s in %s — type exit to come back to the conversation.\n", shell, dir) | |
| 59 | + cmd := exec.Command(shell, l.args...) | |
| 60 | + cmd.Dir = dir | |
| 61 | + cmd.Env = append(os.Environ(), l.env...) | |
| 62 | + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr | |
| 63 | + return cmd.Run() | |
| 64 | +} | |
| 65 | + | |
| 66 | +// prepareLaunch writes the rc files promptLaunch asks for in a new | |
| 67 | +// temporary directory, removed by cleanup once the shell has exited. When | |
| 68 | +// they cannot be written, the shell starts as it is, with MM_SHELL=1 only: | |
| 69 | +// a prompt without its prefix is no reason to refuse the shell. | |
| 70 | +func prepareLaunch(shell string) (launch, func()) { | |
| 71 | + plain := launch{env: []string{"MM_SHELL=1"}} | |
| 72 | + tmp, err := os.MkdirTemp("", "mm-shell-") | |
| 73 | + if err != nil { | |
| 74 | + return plain, func() {} | |
| 75 | + } | |
| 76 | + cleanup := func() { _ = os.RemoveAll(tmp) } | |
| 77 | + l := promptLaunch(shell, tmp, os.Getenv) | |
| 78 | + for name, content := range l.files { | |
| 79 | + if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0o600); err != nil { | |
| 80 | + cleanup() | |
| 81 | + return plain, func() {} | |
| 82 | + } | |
| 83 | + } | |
| 84 | + return l, cleanup | |
| 85 | +} | |
| 86 | + | |
| 87 | +// shell is /shell: suspend the window, run the shell, resume, and say how | |
| 88 | +// it ended. A turn in progress goes on meanwhile — its text is recorded and | |
| 89 | +// shows up on the way back; a permission it asks for waits for the window. | |
| 90 | +func (r *runner) shell() { | |
| 91 | + if r.suspend == nil { | |
| 92 | + r.t.Error("⚠️ /shell: this window has no terminal to hand over.") | |
| 93 | + return | |
| 94 | + } | |
| 95 | + err := r.suspend(func() error { return r.runShell(r.opts.Cwd) }) | |
| 96 | + var exit *exec.ExitError | |
| 97 | + switch { | |
| 98 | + case err == nil: | |
| 99 | + r.t.Notice("🐚 Back from the shell.") | |
| 100 | + case errors.As(err, &exit): | |
| 101 | + // `exit 1`, or the last command failing: the user's business. | |
| 102 | + r.t.Notice(fmt.Sprintf("🐚 Back from the shell (exit status %d).", exit.ExitCode())) | |
| 103 | + default: | |
| 104 | + r.t.Error("⚠️ /shell: " + err.Error()) | |
| 105 | + } | |
| 106 | +} | |
| 107 | + | |
| 108 | +// suspendScreen is the runner's suspend on a real screen: the terminal back | |
| 109 | +// in its normal mode for fn, then the whole window redrawn — tcell only | |
| 110 | +// redraws the cells it thinks changed, and the shell wrote over all of them. | |
| 111 | +func suspendScreen(screen tcell.Screen) func(func() error) error { | |
| 112 | + return func(fn func() error) error { | |
| 113 | + if err := screen.Suspend(); err != nil { | |
| 114 | + return err | |
| 115 | + } | |
| 116 | + err := fn() | |
| 117 | + if rerr := screen.Resume(); rerr != nil && err == nil { | |
| 118 | + err = rerr | |
| 119 | + } | |
| 120 | + screen.Sync() | |
| 121 | + return err | |
| 122 | + } | |
| 123 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | +// /shell (ticket 122): the window steps aside, the user's shell gets the | ||
| 2 | +// terminal, and `exit` brings the conversation back as it was. Not a pop-up | ||
| 3 | +// terminal drawn inside the window: that needs a pseudo-terminal and a VT | ||
| 4 | +// emulator, works on Unix only, and draws full-screen programs worse than | ||
| 5 | +// the terminal itself. Suspending is what tcell offers for "run a | ||
| 6 | +// sub-shell", and it works on every platform mm ships for. | ||
| 7 | + | ||
| 8 | +package tui | ||
| 9 | + | ||
| 10 | +import ( | ||
| 11 | + "errors" | ||
| 12 | + "fmt" | ||
| 13 | + "os" | ||
| 14 | + "os/exec" | ||
| 15 | + "os/signal" | ||
| 16 | + "path/filepath" | ||
| 17 | + "runtime" | ||
| 18 | + "syscall" | ||
| 19 | + | ||
| 20 | + "github.com/gdamore/tcell/v2" | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | +// shellCommand is the program /shell starts: $SHELL, else /bin/sh; on | ||
| 24 | +// Windows %COMSPEC%, else cmd.exe. getenv is os.Getenv outside tests. | ||
| 25 | +// | ||
| 26 | +// shellCommand("linux", os.Getenv) // "/bin/zsh" when SHELL=/bin/zsh | ||
| 27 | +// shellCommand("windows", os.Getenv) // "C:\Windows\system32\cmd.exe" | ||
| 28 | +func shellCommand(goos string, getenv func(string) string) string { | ||
| 29 | + if goos == "windows" { | ||
| 30 | + if c := getenv("COMSPEC"); c != "" { | ||
| 31 | + return c | ||
| 32 | + } | ||
| 33 | + return "cmd.exe" | ||
| 34 | + } | ||
| 35 | + if s := getenv("SHELL"); s != "" { | ||
| 36 | + return s | ||
| 37 | + } | ||
| 38 | + return "/bin/sh" | ||
| 39 | +} | ||
| 40 | + | ||
| 41 | +// runShell runs the user's shell in dir on the real terminal, and returns | ||
| 42 | +// when it exits. The window must be suspended first. | ||
| 43 | +// | ||
| 44 | +// Ctrl-C and Ctrl-\ typed in the shell can reach mm too: the terminal | ||
| 45 | +// sends them to its whole foreground process group, and only some shells | ||
| 46 | +// (bash, zsh) move to a group of their own. The window has no signal | ||
| 47 | +// handler, so the default one would end mm under the user's shell. They are | ||
| 48 | +// caught, and dropped, while the shell runs; a caught signal is reset to its | ||
| 49 | +// default in the child, so the shell and what it runs still get them. | ||
| 50 | +func runShell(dir string) error { | ||
| 51 | + caught := make(chan os.Signal, 8) | ||
| 52 | + signal.Notify(caught, os.Interrupt, syscall.SIGQUIT) | ||
| 53 | + defer signal.Stop(caught) | ||
| 54 | + | ||
| 55 | + shell := shellCommand(runtime.GOOS, os.Getenv) | ||
| 56 | + l, cleanup := prepareLaunch(shell) | ||
| 57 | + defer cleanup() | ||
| 58 | + fmt.Printf("mm: %s in %s — type exit to come back to the conversation.\n", shell, dir) | ||
| 59 | + cmd := exec.Command(shell, l.args...) | ||
| 60 | + cmd.Dir = dir | ||
| 61 | + cmd.Env = append(os.Environ(), l.env...) | ||
| 62 | + cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr | ||
| 63 | + return cmd.Run() | ||
| 64 | +} | ||
| 65 | + | ||
| 66 | +// prepareLaunch writes the rc files promptLaunch asks for in a new | ||
| 67 | +// temporary directory, removed by cleanup once the shell has exited. When | ||
| 68 | +// they cannot be written, the shell starts as it is, with MM_SHELL=1 only: | ||
| 69 | +// a prompt without its prefix is no reason to refuse the shell. | ||
| 70 | +func prepareLaunch(shell string) (launch, func()) { | ||
| 71 | + plain := launch{env: []string{"MM_SHELL=1"}} | ||
| 72 | + tmp, err := os.MkdirTemp("", "mm-shell-") | ||
| 73 | + if err != nil { | ||
| 74 | + return plain, func() {} | ||
| 75 | + } | ||
| 76 | + cleanup := func() { _ = os.RemoveAll(tmp) } | ||
| 77 | + l := promptLaunch(shell, tmp, os.Getenv) | ||
| 78 | + for name, content := range l.files { | ||
| 79 | + if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0o600); err != nil { | ||
| 80 | + cleanup() | ||
| 81 | + return plain, func() {} | ||
| 82 | + } | ||
| 83 | + } | ||
| 84 | + return l, cleanup | ||
| 85 | +} | ||
| 86 | + | ||
| 87 | +// shell is /shell: suspend the window, run the shell, resume, and say how | ||
| 88 | +// it ended. A turn in progress goes on meanwhile — its text is recorded and | ||
| 89 | +// shows up on the way back; a permission it asks for waits for the window. | ||
| 90 | +func (r *runner) shell() { | ||
| 91 | + if r.suspend == nil { | ||
| 92 | + r.t.Error("⚠️ /shell: this window has no terminal to hand over.") | ||
| 93 | + return | ||
| 94 | + } | ||
| 95 | + err := r.suspend(func() error { return r.runShell(r.opts.Cwd) }) | ||
| 96 | + var exit *exec.ExitError | ||
| 97 | + switch { | ||
| 98 | + case err == nil: | ||
| 99 | + r.t.Notice("🐚 Back from the shell.") | ||
| 100 | + case errors.As(err, &exit): | ||
| 101 | + // `exit 1`, or the last command failing: the user's business. | ||
| 102 | + r.t.Notice(fmt.Sprintf("🐚 Back from the shell (exit status %d).", exit.ExitCode())) | ||
| 103 | + default: | ||
| 104 | + r.t.Error("⚠️ /shell: " + err.Error()) | ||
| 105 | + } | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// suspendScreen is the runner's suspend on a real screen: the terminal back | ||
| 109 | +// in its normal mode for fn, then the whole window redrawn — tcell only | ||
| 110 | +// redraws the cells it thinks changed, and the shell wrote over all of them. | ||
| 111 | +func suspendScreen(screen tcell.Screen) func(func() error) error { | ||
| 112 | + return func(fn func() error) error { | ||
| 113 | + if err := screen.Suspend(); err != nil { | ||
| 114 | + return err | ||
| 115 | + } | ||
| 116 | + err := fn() | ||
| 117 | + if rerr := screen.Resume(); rerr != nil && err == nil { | ||
| 118 | + err = rerr | ||
| 119 | + } | ||
| 120 | + screen.Sync() | ||
| 121 | + return err | ||
| 122 | + } | ||
| 123 | +} | ||
added
internal/tui/shell_test.go +241 -0 | new file mode 100644 | ||
| @@ -0,0 +1,241 @@ | ||
| 1 | +package tui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "errors" | |
| 5 | + "os" | |
| 6 | + "os/exec" | |
| 7 | + "path/filepath" | |
| 8 | + "strings" | |
| 9 | + "testing" | |
| 10 | + | |
| 11 | + "github.com/gdamore/tcell/v2" | |
| 12 | +) | |
| 13 | + | |
| 14 | +// The shell is the user's own: $SHELL on Unix, %COMSPEC% on Windows, and a | |
| 15 | +// shell that always exists when the variable is not set. | |
| 16 | +func TestShellCommand(t *testing.T) { | |
| 17 | + env := func(vars map[string]string) func(string) string { | |
| 18 | + return func(k string) string { return vars[k] } | |
| 19 | + } | |
| 20 | + cases := []struct { | |
| 21 | + goos string | |
| 22 | + vars map[string]string | |
| 23 | + want string | |
| 24 | + }{ | |
| 25 | + {"linux", map[string]string{"SHELL": "/bin/zsh"}, "/bin/zsh"}, | |
| 26 | + {"darwin", nil, "/bin/sh"}, | |
| 27 | + {"windows", map[string]string{"COMSPEC": `C:\Windows\system32\cmd.exe`, "SHELL": "/bin/zsh"}, `C:\Windows\system32\cmd.exe`}, | |
| 28 | + {"windows", nil, "cmd.exe"}, | |
| 29 | + } | |
| 30 | + for _, c := range cases { | |
| 31 | + if got := shellCommand(c.goos, env(c.vars)); got != c.want { | |
| 32 | + t.Errorf("shellCommand(%s, %v) = %q, want %q", c.goos, c.vars, got, c.want) | |
| 33 | + } | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +// fakeShell makes r's /shell record where it ran, inside a suspended window, | |
| 38 | +// and end with err. | |
| 39 | +func fakeShell(r *runner, err error) (suspended *int, ranIn *string) { | |
| 40 | + suspended, ranIn = new(int), new(string) | |
| 41 | + r.suspend = func(fn func() error) error { | |
| 42 | + *suspended++ | |
| 43 | + return fn() | |
| 44 | + } | |
| 45 | + r.runShell = func(dir string) error { | |
| 46 | + *ranIn = dir | |
| 47 | + return err | |
| 48 | + } | |
| 49 | + return suspended, ranIn | |
| 50 | +} | |
| 51 | + | |
| 52 | +// /shell suspends the window, runs the shell in the window's directory, and | |
| 53 | +// says so on the way back; the history does not change. | |
| 54 | +func TestRunnerShell(t *testing.T) { | |
| 55 | + r, tr := newTestRunner(t, &fakeModel{}) | |
| 56 | + suspended, ranIn := fakeShell(r, nil) | |
| 57 | + r.submit(promptOf("/shell")) | |
| 58 | + if *suspended != 1 || *ranIn != r.opts.Cwd { | |
| 59 | + t.Errorf("suspended %d time(s), shell ran in %q (want 1, %q)", *suspended, *ranIn, r.opts.Cwd) | |
| 60 | + } | |
| 61 | + if n := notices(tr); !strings.Contains(n, "🐚 Back from the shell.") { | |
| 62 | + t.Errorf("notices:\n%s", n) | |
| 63 | + } | |
| 64 | + if len(r.messages) != 1 { | |
| 65 | + t.Errorf("the history changed: %d messages", len(r.messages)) | |
| 66 | + } | |
| 67 | +} | |
| 68 | + | |
| 69 | +// A shell that exits non-zero is the user's business, said as a notice; one | |
| 70 | +// that cannot start is an error. | |
| 71 | +func TestRunnerShellEndings(t *testing.T) { | |
| 72 | + sh, err := exec.LookPath("sh") | |
| 73 | + if err != nil { | |
| 74 | + t.Skip("no sh to produce an exit status") | |
| 75 | + } | |
| 76 | + exit := exec.Command(sh, "-c", "exit 3").Run() | |
| 77 | + | |
| 78 | + r, tr := newTestRunner(t, &fakeModel{}) | |
| 79 | + fakeShell(r, exit) | |
| 80 | + r.submit(promptOf("/shell")) | |
| 81 | + if n := notices(tr); !strings.Contains(n, "🐚 Back from the shell (exit status 3).") { | |
| 82 | + t.Errorf("notices:\n%s", n) | |
| 83 | + } | |
| 84 | + | |
| 85 | + r, tr = newTestRunner(t, &fakeModel{}) | |
| 86 | + fakeShell(r, errors.New(`exec: "nosuchshell": executable file not found in $PATH`)) | |
| 87 | + r.submit(promptOf("/shell")) | |
| 88 | + entries := tr.Entries() | |
| 89 | + if last := entries[len(entries)-1]; last.Kind != KindError || !strings.Contains(last.Text, "⚠️ /shell: exec: \"nosuchshell\"") { | |
| 90 | + t.Errorf("last entry: %+v", last) | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +// Without a screen to suspend, /shell says it cannot rather than run a shell | |
| 95 | +// on a terminal the window still owns. | |
| 96 | +func TestRunnerShellWithoutScreen(t *testing.T) { | |
| 97 | + r, tr := newTestRunner(t, &fakeModel{}) | |
| 98 | + ran := false | |
| 99 | + r.runShell = func(string) error { ran = true; return nil } | |
| 100 | + r.submit(promptOf("/shell")) | |
| 101 | + if ran || !strings.Contains(notices(tr), "no terminal to hand over") { | |
| 102 | + t.Errorf("ran=%v, notices:\n%s", ran, notices(tr)) | |
| 103 | + } | |
| 104 | +} | |
| 105 | + | |
| 106 | +// /shell during a turn runs at once and leaves the turn alone: the shell | |
| 107 | +// touches no session, so there is nothing to wait for and nothing to cancel. | |
| 108 | +func TestRunnerShellDuringATurn(t *testing.T) { | |
| 109 | + r, tr := newTestRunner(t, &fakeModel{blockFirst: true, answer: "done"}) | |
| 110 | + r.submit(promptOf("a long question")) | |
| 111 | + if !r.isBusy() { | |
| 112 | + t.Fatal("the turn did not start") | |
| 113 | + } | |
| 114 | + suspended, _ := fakeShell(r, nil) | |
| 115 | + r.submit(promptOf("/shell")) | |
| 116 | + if *suspended != 1 { | |
| 117 | + t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended) | |
| 118 | + } | |
| 119 | + if r.cancelled.Load() || r.pending.Typed != "" { | |
| 120 | + t.Errorf("the turn was cancelled (%v) or /shell queued (%q)", r.cancelled.Load(), r.pending.Typed) | |
| 121 | + } | |
| 122 | + if !strings.Contains(notices(tr), "🐚 Back from the shell.") { | |
| 123 | + t.Errorf("notices:\n%s", notices(tr)) | |
| 124 | + } | |
| 125 | + r.cancelTurn() | |
| 126 | + waitIdle(t, r) | |
| 127 | +} | |
| 128 | + | |
| 129 | +// suspendScreen runs fn between Suspend and Resume and returns its error. | |
| 130 | +func TestSuspendScreenRunsFn(t *testing.T) { | |
| 131 | + screen := tcell.NewSimulationScreen("") | |
| 132 | + if err := screen.Init(); err != nil { | |
| 133 | + t.Fatal(err) | |
| 134 | + } | |
| 135 | + defer screen.Fini() | |
| 136 | + boom := errors.New("boom") | |
| 137 | + ran := false | |
| 138 | + if err := suspendScreen(screen)(func() error { ran = true; return boom }); !ran || !errors.Is(err, boom) { | |
| 139 | + t.Errorf("ran=%v err=%v", ran, err) | |
| 140 | + } | |
| 141 | +} | |
| 142 | + | |
| 143 | +// Each known shell gets its own way to a "(mm) " prompt; every shell gets | |
| 144 | +// MM_SHELL=1; an unknown one runs as it is. | |
| 145 | +func TestPromptLaunch(t *testing.T) { | |
| 146 | + env := func(vars map[string]string) func(string) string { | |
| 147 | + return func(k string) string { return vars[k] } | |
| 148 | + } | |
| 149 | + home := env(map[string]string{"HOME": "/home/me"}) | |
| 150 | + | |
| 151 | + bash := promptLaunch("/usr/bin/bash", "/tmp/x", home) | |
| 152 | + if strings.Join(bash.args, " ") != "--rcfile /tmp/x/bashrc" || !strings.Contains(bash.files["bashrc"], `. "$HOME/.bashrc"`) || !strings.Contains(bash.files["bashrc"], `PS1="(mm) $PS1"`) { | |
| 153 | + t.Errorf("bash: %+v", bash) | |
| 154 | + } | |
| 155 | + zsh := promptLaunch("/bin/zsh", "/tmp/x", home) | |
| 156 | + if strings.Join(zsh.env, " ") != "MM_SHELL=1 ZDOTDIR=/tmp/x MM_REAL_ZDOTDIR=/home/me" || !strings.Contains(zsh.files[".zshrc"], `PROMPT="(mm) $PROMPT"`) || zsh.files[".zshenv"] == "" { | |
| 157 | + t.Errorf("zsh: %+v", zsh) | |
| 158 | + } | |
| 159 | + if z := promptLaunch("zsh", "/tmp/x", env(map[string]string{"HOME": "/home/me", "ZDOTDIR": "/home/me/.config/zsh"})); !strings.Contains(strings.Join(z.env, " "), "MM_REAL_ZDOTDIR=/home/me/.config/zsh") { | |
| 160 | + t.Errorf("zsh with ZDOTDIR: %+v", z.env) | |
| 161 | + } | |
| 162 | + if fish := promptLaunch("/opt/homebrew/bin/fish", "/tmp/x", home); len(fish.args) != 2 || fish.args[0] != "-C" || !strings.Contains(fish.args[1], `echo -n "(mm) "`) { | |
| 163 | + t.Errorf("fish: %+v", fish) | |
| 164 | + } | |
| 165 | + if sh := promptLaunch("/bin/sh", "/tmp/x", home); strings.Join(sh.env, " ") != "MM_SHELL=1 PS1=(mm) $ " { | |
| 166 | + t.Errorf("sh: %+v", sh.env) | |
| 167 | + } | |
| 168 | + if cmd := promptLaunch(`C:\Windows\System32\CMD.EXE`, `C:\tmp`, home); strings.Join(cmd.env, " ") != "MM_SHELL=1 PROMPT=(mm) $P$G" { | |
| 169 | + t.Errorf("cmd: %+v", cmd.env) | |
| 170 | + } | |
| 171 | + if nu := promptLaunch("/usr/bin/nu", "/tmp/x", home); len(nu.args) != 0 || len(nu.files) != 0 || strings.Join(nu.env, " ") != "MM_SHELL=1" { | |
| 172 | + t.Errorf("unknown shell: %+v", nu) | |
| 173 | + } | |
| 174 | +} | |
| 175 | + | |
| 176 | +// The real shells, where they are installed: the user's rc file still runs, | |
| 177 | +// and the prompt it set comes out with "(mm) " in front. | |
| 178 | +func TestPromptLaunchInRealShells(t *testing.T) { | |
| 179 | + home := t.TempDir() | |
| 180 | + t.Setenv("HOME", home) | |
| 181 | + t.Setenv("ZDOTDIR", "") | |
| 182 | + for _, file := range []string{".bashrc", ".zshrc"} { | |
| 183 | + if err := os.WriteFile(filepath.Join(home, file), []byte("PS1='me$ '\nRC_RAN=yes\n"), 0o644); err != nil { | |
| 184 | + t.Fatal(err) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + for _, shell := range []string{"bash", "zsh", "dash"} { | |
| 188 | + path, err := exec.LookPath(shell) | |
| 189 | + if err != nil { | |
| 190 | + t.Logf("%s not installed: skipped", shell) | |
| 191 | + continue | |
| 192 | + } | |
| 193 | + l, cleanup := prepareLaunch(path) | |
| 194 | + cmd := exec.Command(path, append(l.args, "-i")...) | |
| 195 | + cmd.Env = append(os.Environ(), l.env...) | |
| 196 | + cmd.Stdin = strings.NewReader(`echo "[$PS1][$RC_RAN][$MM_SHELL]"` + "\nexit\n") | |
| 197 | + out, err := cmd.Output() | |
| 198 | + cleanup() | |
| 199 | + if err != nil { | |
| 200 | + t.Errorf("%s: %v", shell, err) | |
| 201 | + continue | |
| 202 | + } | |
| 203 | + want := "[(mm) me$ ][yes][1]" | |
| 204 | + if shell == "dash" { | |
| 205 | + want = "[(mm) $ ][][1]" // no rc file without $ENV: the prefix and a plain prompt | |
| 206 | + } | |
| 207 | + if !strings.Contains(string(out), want) { | |
| 208 | + t.Errorf("%s printed %q, want %q", shell, out, want) | |
| 209 | + } | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +// fish, where it is installed: the prompt function the user's config.fish | |
| 214 | +// defines still runs, behind "(mm) ". | |
| 215 | +func TestPromptLaunchInFish(t *testing.T) { | |
| 216 | + path, err := exec.LookPath("fish") | |
| 217 | + if err != nil { | |
| 218 | + t.Skip("fish not installed") | |
| 219 | + } | |
| 220 | + home := t.TempDir() | |
| 221 | + t.Setenv("HOME", home) | |
| 222 | + t.Setenv("XDG_CONFIG_HOME", "") | |
| 223 | + conf := filepath.Join(home, ".config", "fish") | |
| 224 | + if err := os.MkdirAll(conf, 0o755); err != nil { | |
| 225 | + t.Fatal(err) | |
| 226 | + } | |
| 227 | + if err := os.WriteFile(filepath.Join(conf, "config.fish"), []byte("function fish_prompt; echo -n 'me> '; end\n"), 0o644); err != nil { | |
| 228 | + t.Fatal(err) | |
| 229 | + } | |
| 230 | + l, cleanup := prepareLaunch(path) | |
| 231 | + defer cleanup() | |
| 232 | + cmd := exec.Command(path, append(l.args, "-c", `fish_prompt; echo "[$MM_SHELL]"`)...) | |
| 233 | + cmd.Env = append(os.Environ(), l.env...) | |
| 234 | + out, err := cmd.Output() | |
| 235 | + if err != nil { | |
| 236 | + t.Fatalf("fish: %v", err) | |
| 237 | + } | |
| 238 | + if want := "(mm) me> [1]"; !strings.Contains(string(out), want) { | |
| 239 | + t.Errorf("fish printed %q, want %q", out, want) | |
| 240 | + } | |
| 241 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,241 @@ | |||
| 1 | +package tui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "errors" | ||
| 5 | + "os" | ||
| 6 | + "os/exec" | ||
| 7 | + "path/filepath" | ||
| 8 | + "strings" | ||
| 9 | + "testing" | ||
| 10 | + | ||
| 11 | + "github.com/gdamore/tcell/v2" | ||
| 12 | +) | ||
| 13 | + | ||
| 14 | +// The shell is the user's own: $SHELL on Unix, %COMSPEC% on Windows, and a | ||
| 15 | +// shell that always exists when the variable is not set. | ||
| 16 | +func TestShellCommand(t *testing.T) { | ||
| 17 | + env := func(vars map[string]string) func(string) string { | ||
| 18 | + return func(k string) string { return vars[k] } | ||
| 19 | + } | ||
| 20 | + cases := []struct { | ||
| 21 | + goos string | ||
| 22 | + vars map[string]string | ||
| 23 | + want string | ||
| 24 | + }{ | ||
| 25 | + {"linux", map[string]string{"SHELL": "/bin/zsh"}, "/bin/zsh"}, | ||
| 26 | + {"darwin", nil, "/bin/sh"}, | ||
| 27 | + {"windows", map[string]string{"COMSPEC": `C:\Windows\system32\cmd.exe`, "SHELL": "/bin/zsh"}, `C:\Windows\system32\cmd.exe`}, | ||
| 28 | + {"windows", nil, "cmd.exe"}, | ||
| 29 | + } | ||
| 30 | + for _, c := range cases { | ||
| 31 | + if got := shellCommand(c.goos, env(c.vars)); got != c.want { | ||
| 32 | + t.Errorf("shellCommand(%s, %v) = %q, want %q", c.goos, c.vars, got, c.want) | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +// fakeShell makes r's /shell record where it ran, inside a suspended window, | ||
| 38 | +// and end with err. | ||
| 39 | +func fakeShell(r *runner, err error) (suspended *int, ranIn *string) { | ||
| 40 | + suspended, ranIn = new(int), new(string) | ||
| 41 | + r.suspend = func(fn func() error) error { | ||
| 42 | + *suspended++ | ||
| 43 | + return fn() | ||
| 44 | + } | ||
| 45 | + r.runShell = func(dir string) error { | ||
| 46 | + *ranIn = dir | ||
| 47 | + return err | ||
| 48 | + } | ||
| 49 | + return suspended, ranIn | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +// /shell suspends the window, runs the shell in the window's directory, and | ||
| 53 | +// says so on the way back; the history does not change. | ||
| 54 | +func TestRunnerShell(t *testing.T) { | ||
| 55 | + r, tr := newTestRunner(t, &fakeModel{}) | ||
| 56 | + suspended, ranIn := fakeShell(r, nil) | ||
| 57 | + r.submit(promptOf("/shell")) | ||
| 58 | + if *suspended != 1 || *ranIn != r.opts.Cwd { | ||
| 59 | + t.Errorf("suspended %d time(s), shell ran in %q (want 1, %q)", *suspended, *ranIn, r.opts.Cwd) | ||
| 60 | + } | ||
| 61 | + if n := notices(tr); !strings.Contains(n, "🐚 Back from the shell.") { | ||
| 62 | + t.Errorf("notices:\n%s", n) | ||
| 63 | + } | ||
| 64 | + if len(r.messages) != 1 { | ||
| 65 | + t.Errorf("the history changed: %d messages", len(r.messages)) | ||
| 66 | + } | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +// A shell that exits non-zero is the user's business, said as a notice; one | ||
| 70 | +// that cannot start is an error. | ||
| 71 | +func TestRunnerShellEndings(t *testing.T) { | ||
| 72 | + sh, err := exec.LookPath("sh") | ||
| 73 | + if err != nil { | ||
| 74 | + t.Skip("no sh to produce an exit status") | ||
| 75 | + } | ||
| 76 | + exit := exec.Command(sh, "-c", "exit 3").Run() | ||
| 77 | + | ||
| 78 | + r, tr := newTestRunner(t, &fakeModel{}) | ||
| 79 | + fakeShell(r, exit) | ||
| 80 | + r.submit(promptOf("/shell")) | ||
| 81 | + if n := notices(tr); !strings.Contains(n, "🐚 Back from the shell (exit status 3).") { | ||
| 82 | + t.Errorf("notices:\n%s", n) | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + r, tr = newTestRunner(t, &fakeModel{}) | ||
| 86 | + fakeShell(r, errors.New(`exec: "nosuchshell": executable file not found in $PATH`)) | ||
| 87 | + r.submit(promptOf("/shell")) | ||
| 88 | + entries := tr.Entries() | ||
| 89 | + if last := entries[len(entries)-1]; last.Kind != KindError || !strings.Contains(last.Text, "⚠️ /shell: exec: \"nosuchshell\"") { | ||
| 90 | + t.Errorf("last entry: %+v", last) | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// Without a screen to suspend, /shell says it cannot rather than run a shell | ||
| 95 | +// on a terminal the window still owns. | ||
| 96 | +func TestRunnerShellWithoutScreen(t *testing.T) { | ||
| 97 | + r, tr := newTestRunner(t, &fakeModel{}) | ||
| 98 | + ran := false | ||
| 99 | + r.runShell = func(string) error { ran = true; return nil } | ||
| 100 | + r.submit(promptOf("/shell")) | ||
| 101 | + if ran || !strings.Contains(notices(tr), "no terminal to hand over") { | ||
| 102 | + t.Errorf("ran=%v, notices:\n%s", ran, notices(tr)) | ||
| 103 | + } | ||
| 104 | +} | ||
| 105 | + | ||
| 106 | +// /shell during a turn runs at once and leaves the turn alone: the shell | ||
| 107 | +// touches no session, so there is nothing to wait for and nothing to cancel. | ||
| 108 | +func TestRunnerShellDuringATurn(t *testing.T) { | ||
| 109 | + r, tr := newTestRunner(t, &fakeModel{blockFirst: true, answer: "done"}) | ||
| 110 | + r.submit(promptOf("a long question")) | ||
| 111 | + if !r.isBusy() { | ||
| 112 | + t.Fatal("the turn did not start") | ||
| 113 | + } | ||
| 114 | + suspended, _ := fakeShell(r, nil) | ||
| 115 | + r.submit(promptOf("/shell")) | ||
| 116 | + if *suspended != 1 { | ||
| 117 | + t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended) | ||
| 118 | + } | ||
| 119 | + if r.cancelled.Load() || r.pending.Typed != "" { | ||
| 120 | + t.Errorf("the turn was cancelled (%v) or /shell queued (%q)", r.cancelled.Load(), r.pending.Typed) | ||
| 121 | + } | ||
| 122 | + if !strings.Contains(notices(tr), "🐚 Back from the shell.") { | ||
| 123 | + t.Errorf("notices:\n%s", notices(tr)) | ||
| 124 | + } | ||
| 125 | + r.cancelTurn() | ||
| 126 | + waitIdle(t, r) | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +// suspendScreen runs fn between Suspend and Resume and returns its error. | ||
| 130 | +func TestSuspendScreenRunsFn(t *testing.T) { | ||
| 131 | + screen := tcell.NewSimulationScreen("") | ||
| 132 | + if err := screen.Init(); err != nil { | ||
| 133 | + t.Fatal(err) | ||
| 134 | + } | ||
| 135 | + defer screen.Fini() | ||
| 136 | + boom := errors.New("boom") | ||
| 137 | + ran := false | ||
| 138 | + if err := suspendScreen(screen)(func() error { ran = true; return boom }); !ran || !errors.Is(err, boom) { | ||
| 139 | + t.Errorf("ran=%v err=%v", ran, err) | ||
| 140 | + } | ||
| 141 | +} | ||
| 142 | + | ||
| 143 | +// Each known shell gets its own way to a "(mm) " prompt; every shell gets | ||
| 144 | +// MM_SHELL=1; an unknown one runs as it is. | ||
| 145 | +func TestPromptLaunch(t *testing.T) { | ||
| 146 | + env := func(vars map[string]string) func(string) string { | ||
| 147 | + return func(k string) string { return vars[k] } | ||
| 148 | + } | ||
| 149 | + home := env(map[string]string{"HOME": "/home/me"}) | ||
| 150 | + | ||
| 151 | + bash := promptLaunch("/usr/bin/bash", "/tmp/x", home) | ||
| 152 | + if strings.Join(bash.args, " ") != "--rcfile /tmp/x/bashrc" || !strings.Contains(bash.files["bashrc"], `. "$HOME/.bashrc"`) || !strings.Contains(bash.files["bashrc"], `PS1="(mm) $PS1"`) { | ||
| 153 | + t.Errorf("bash: %+v", bash) | ||
| 154 | + } | ||
| 155 | + zsh := promptLaunch("/bin/zsh", "/tmp/x", home) | ||
| 156 | + if strings.Join(zsh.env, " ") != "MM_SHELL=1 ZDOTDIR=/tmp/x MM_REAL_ZDOTDIR=/home/me" || !strings.Contains(zsh.files[".zshrc"], `PROMPT="(mm) $PROMPT"`) || zsh.files[".zshenv"] == "" { | ||
| 157 | + t.Errorf("zsh: %+v", zsh) | ||
| 158 | + } | ||
| 159 | + if z := promptLaunch("zsh", "/tmp/x", env(map[string]string{"HOME": "/home/me", "ZDOTDIR": "/home/me/.config/zsh"})); !strings.Contains(strings.Join(z.env, " "), "MM_REAL_ZDOTDIR=/home/me/.config/zsh") { | ||
| 160 | + t.Errorf("zsh with ZDOTDIR: %+v", z.env) | ||
| 161 | + } | ||
| 162 | + if fish := promptLaunch("/opt/homebrew/bin/fish", "/tmp/x", home); len(fish.args) != 2 || fish.args[0] != "-C" || !strings.Contains(fish.args[1], `echo -n "(mm) "`) { | ||
| 163 | + t.Errorf("fish: %+v", fish) | ||
| 164 | + } | ||
| 165 | + if sh := promptLaunch("/bin/sh", "/tmp/x", home); strings.Join(sh.env, " ") != "MM_SHELL=1 PS1=(mm) $ " { | ||
| 166 | + t.Errorf("sh: %+v", sh.env) | ||
| 167 | + } | ||
| 168 | + if cmd := promptLaunch(`C:\Windows\System32\CMD.EXE`, `C:\tmp`, home); strings.Join(cmd.env, " ") != "MM_SHELL=1 PROMPT=(mm) $P$G" { | ||
| 169 | + t.Errorf("cmd: %+v", cmd.env) | ||
| 170 | + } | ||
| 171 | + if nu := promptLaunch("/usr/bin/nu", "/tmp/x", home); len(nu.args) != 0 || len(nu.files) != 0 || strings.Join(nu.env, " ") != "MM_SHELL=1" { | ||
| 172 | + t.Errorf("unknown shell: %+v", nu) | ||
| 173 | + } | ||
| 174 | +} | ||
| 175 | + | ||
| 176 | +// The real shells, where they are installed: the user's rc file still runs, | ||
| 177 | +// and the prompt it set comes out with "(mm) " in front. | ||
| 178 | +func TestPromptLaunchInRealShells(t *testing.T) { | ||
| 179 | + home := t.TempDir() | ||
| 180 | + t.Setenv("HOME", home) | ||
| 181 | + t.Setenv("ZDOTDIR", "") | ||
| 182 | + for _, file := range []string{".bashrc", ".zshrc"} { | ||
| 183 | + if err := os.WriteFile(filepath.Join(home, file), []byte("PS1='me$ '\nRC_RAN=yes\n"), 0o644); err != nil { | ||
| 184 | + t.Fatal(err) | ||
| 185 | + } | ||
| 186 | + } | ||
| 187 | + for _, shell := range []string{"bash", "zsh", "dash"} { | ||
| 188 | + path, err := exec.LookPath(shell) | ||
| 189 | + if err != nil { | ||
| 190 | + t.Logf("%s not installed: skipped", shell) | ||
| 191 | + continue | ||
| 192 | + } | ||
| 193 | + l, cleanup := prepareLaunch(path) | ||
| 194 | + cmd := exec.Command(path, append(l.args, "-i")...) | ||
| 195 | + cmd.Env = append(os.Environ(), l.env...) | ||
| 196 | + cmd.Stdin = strings.NewReader(`echo "[$PS1][$RC_RAN][$MM_SHELL]"` + "\nexit\n") | ||
| 197 | + out, err := cmd.Output() | ||
| 198 | + cleanup() | ||
| 199 | + if err != nil { | ||
| 200 | + t.Errorf("%s: %v", shell, err) | ||
| 201 | + continue | ||
| 202 | + } | ||
| 203 | + want := "[(mm) me$ ][yes][1]" | ||
| 204 | + if shell == "dash" { | ||
| 205 | + want = "[(mm) $ ][][1]" // no rc file without $ENV: the prefix and a plain prompt | ||
| 206 | + } | ||
| 207 | + if !strings.Contains(string(out), want) { | ||
| 208 | + t.Errorf("%s printed %q, want %q", shell, out, want) | ||
| 209 | + } | ||
| 210 | + } | ||
| 211 | +} | ||
| 212 | + | ||
| 213 | +// fish, where it is installed: the prompt function the user's config.fish | ||
| 214 | +// defines still runs, behind "(mm) ". | ||
| 215 | +func TestPromptLaunchInFish(t *testing.T) { | ||
| 216 | + path, err := exec.LookPath("fish") | ||
| 217 | + if err != nil { | ||
| 218 | + t.Skip("fish not installed") | ||
| 219 | + } | ||
| 220 | + home := t.TempDir() | ||
| 221 | + t.Setenv("HOME", home) | ||
| 222 | + t.Setenv("XDG_CONFIG_HOME", "") | ||
| 223 | + conf := filepath.Join(home, ".config", "fish") | ||
| 224 | + if err := os.MkdirAll(conf, 0o755); err != nil { | ||
| 225 | + t.Fatal(err) | ||
| 226 | + } | ||
| 227 | + if err := os.WriteFile(filepath.Join(conf, "config.fish"), []byte("function fish_prompt; echo -n 'me> '; end\n"), 0o644); err != nil { | ||
| 228 | + t.Fatal(err) | ||
| 229 | + } | ||
| 230 | + l, cleanup := prepareLaunch(path) | ||
| 231 | + defer cleanup() | ||
| 232 | + cmd := exec.Command(path, append(l.args, "-c", `fish_prompt; echo "[$MM_SHELL]"`)...) | ||
| 233 | + cmd.Env = append(os.Environ(), l.env...) | ||
| 234 | + out, err := cmd.Output() | ||
| 235 | + if err != nil { | ||
| 236 | + t.Fatalf("fish: %v", err) | ||
| 237 | + } | ||
| 238 | + if want := "(mm) me> [1]"; !strings.Contains(string(out), want) { | ||
| 239 | + t.Errorf("fish printed %q, want %q", out, want) | ||
| 240 | + } | ||
| 241 | +} | ||
added
internal/tui/shellprompt.go +78 -0 | new file mode 100644 | ||
| @@ -0,0 +1,78 @@ | ||
| 1 | +// The prompt of the /shell shell: "(mm) " in front of the user's own, so a | |
| 2 | +// shell started from the window does not pass for the one the window was | |
| 3 | +// started from — `exit` there closes the terminal tab instead. | |
| 4 | +// | |
| 5 | +// Setting PS1 is not enough: an interactive shell reads its rc file, and the | |
| 6 | +// rc file sets the prompt. So each known shell gets a small rc file of mm's, | |
| 7 | +// in a temporary directory, that runs the user's own first and then adds the | |
| 8 | +// prefix. A theme that rebuilds the prompt before each line (powerlevel10k) | |
| 9 | +// drops it again; MM_SHELL=1, set for every shell, is there for those, and | |
| 10 | +// for anyone who wants their own marker. | |
| 11 | + | |
| 12 | +package tui | |
| 13 | + | |
| 14 | +import ( | |
| 15 | + "path/filepath" | |
| 16 | + "strings" | |
| 17 | +) | |
| 18 | + | |
| 19 | +// promptPrefix goes in front of the user's prompt. | |
| 20 | +const promptPrefix = "(mm) " | |
| 21 | + | |
| 22 | +// launch is how to start a shell with the prefixed prompt. | |
| 23 | +type launch struct { | |
| 24 | + args []string // given to the shell after its name | |
| 25 | + env []string // KEY=value, added to mm's environment | |
| 26 | + files map[string]string // file name → content, written in the temporary directory first | |
| 27 | +} | |
| 28 | + | |
| 29 | +// promptLaunch says how to start shell (a path or a name, as shellCommand | |
| 30 | +// returns it) so its prompt starts with "(mm) ". tmp is the temporary | |
| 31 | +// directory the files go in; getenv is os.Getenv outside tests. A shell it | |
| 32 | +// does not know runs as it is, with MM_SHELL=1 only. | |
| 33 | +// | |
| 34 | +// l := promptLaunch("/bin/bash", "/tmp/mm-shell-1", os.Getenv) | |
| 35 | +// // l.args == ["--rcfile", "/tmp/mm-shell-1/bashrc"] | |
| 36 | +// // l.files["bashrc"] runs ~/.bashrc, then PS1="(mm) $PS1" | |
| 37 | +func promptLaunch(shell, tmp string, getenv func(string) string) launch { | |
| 38 | + l := launch{env: []string{"MM_SHELL=1"}} | |
| 39 | + name := strings.TrimSuffix(strings.ToLower(filepath.Base(strings.ReplaceAll(shell, `\`, "/"))), ".exe") | |
| 40 | + switch name { | |
| 41 | + case "bash": | |
| 42 | + // --rcfile replaces ~/.bashrc for an interactive non-login bash: run it ourselves. | |
| 43 | + l.args = []string{"--rcfile", filepath.Join(tmp, "bashrc")} | |
| 44 | + l.files = map[string]string{"bashrc": `[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" | |
| 45 | +PS1="` + promptPrefix + `$PS1" | |
| 46 | +`} | |
| 47 | + case "zsh": | |
| 48 | + // zsh reads its rc files from $ZDOTDIR: point it at ours, and let | |
| 49 | + // ours run the user's from where they were. | |
| 50 | + real := getenv("ZDOTDIR") | |
| 51 | + if real == "" { | |
| 52 | + real = getenv("HOME") | |
| 53 | + } | |
| 54 | + l.env = append(l.env, "ZDOTDIR="+tmp, "MM_REAL_ZDOTDIR="+real) | |
| 55 | + l.files = map[string]string{ | |
| 56 | + ".zshenv": `_mm_zdotdir="$ZDOTDIR" | |
| 57 | +ZDOTDIR="$MM_REAL_ZDOTDIR" | |
| 58 | +[[ -f "$ZDOTDIR/.zshenv" ]] && source "$ZDOTDIR/.zshenv" | |
| 59 | +MM_REAL_ZDOTDIR="$ZDOTDIR" | |
| 60 | +ZDOTDIR="$_mm_zdotdir" | |
| 61 | +unset _mm_zdotdir | |
| 62 | +`, | |
| 63 | + ".zshrc": `ZDOTDIR="$MM_REAL_ZDOTDIR" | |
| 64 | +unset MM_REAL_ZDOTDIR | |
| 65 | +[[ -f "$ZDOTDIR/.zshrc" ]] && source "$ZDOTDIR/.zshrc" | |
| 66 | +PROMPT="` + promptPrefix + `$PROMPT" | |
| 67 | +`, | |
| 68 | + } | |
| 69 | + case "fish": | |
| 70 | + // -C runs after the user's config: wrap the prompt function it defined. | |
| 71 | + l.args = []string{"-C", `functions -c fish_prompt _mm_fish_prompt; function fish_prompt; echo -n "` + promptPrefix + `"; _mm_fish_prompt; end`} | |
| 72 | + case "sh", "dash", "ash", "ksh", "mksh": | |
| 73 | + l.env = append(l.env, "PS1="+promptPrefix+"$ ") | |
| 74 | + case "cmd": | |
| 75 | + l.env = append(l.env, "PROMPT="+promptPrefix+"$P$G") | |
| 76 | + } | |
| 77 | + return l | |
| 78 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,78 @@ | |||
| 1 | +// The prompt of the /shell shell: "(mm) " in front of the user's own, so a | ||
| 2 | +// shell started from the window does not pass for the one the window was | ||
| 3 | +// started from — `exit` there closes the terminal tab instead. | ||
| 4 | +// | ||
| 5 | +// Setting PS1 is not enough: an interactive shell reads its rc file, and the | ||
| 6 | +// rc file sets the prompt. So each known shell gets a small rc file of mm's, | ||
| 7 | +// in a temporary directory, that runs the user's own first and then adds the | ||
| 8 | +// prefix. A theme that rebuilds the prompt before each line (powerlevel10k) | ||
| 9 | +// drops it again; MM_SHELL=1, set for every shell, is there for those, and | ||
| 10 | +// for anyone who wants their own marker. | ||
| 11 | + | ||
| 12 | +package tui | ||
| 13 | + | ||
| 14 | +import ( | ||
| 15 | + "path/filepath" | ||
| 16 | + "strings" | ||
| 17 | +) | ||
| 18 | + | ||
| 19 | +// promptPrefix goes in front of the user's prompt. | ||
| 20 | +const promptPrefix = "(mm) " | ||
| 21 | + | ||
| 22 | +// launch is how to start a shell with the prefixed prompt. | ||
| 23 | +type launch struct { | ||
| 24 | + args []string // given to the shell after its name | ||
| 25 | + env []string // KEY=value, added to mm's environment | ||
| 26 | + files map[string]string // file name → content, written in the temporary directory first | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +// promptLaunch says how to start shell (a path or a name, as shellCommand | ||
| 30 | +// returns it) so its prompt starts with "(mm) ". tmp is the temporary | ||
| 31 | +// directory the files go in; getenv is os.Getenv outside tests. A shell it | ||
| 32 | +// does not know runs as it is, with MM_SHELL=1 only. | ||
| 33 | +// | ||
| 34 | +// l := promptLaunch("/bin/bash", "/tmp/mm-shell-1", os.Getenv) | ||
| 35 | +// // l.args == ["--rcfile", "/tmp/mm-shell-1/bashrc"] | ||
| 36 | +// // l.files["bashrc"] runs ~/.bashrc, then PS1="(mm) $PS1" | ||
| 37 | +func promptLaunch(shell, tmp string, getenv func(string) string) launch { | ||
| 38 | + l := launch{env: []string{"MM_SHELL=1"}} | ||
| 39 | + name := strings.TrimSuffix(strings.ToLower(filepath.Base(strings.ReplaceAll(shell, `\`, "/"))), ".exe") | ||
| 40 | + switch name { | ||
| 41 | + case "bash": | ||
| 42 | + // --rcfile replaces ~/.bashrc for an interactive non-login bash: run it ourselves. | ||
| 43 | + l.args = []string{"--rcfile", filepath.Join(tmp, "bashrc")} | ||
| 44 | + l.files = map[string]string{"bashrc": `[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc" | ||
| 45 | +PS1="` + promptPrefix + `$PS1" | ||
| 46 | +`} | ||
| 47 | + case "zsh": | ||
| 48 | + // zsh reads its rc files from $ZDOTDIR: point it at ours, and let | ||
| 49 | + // ours run the user's from where they were. | ||
| 50 | + real := getenv("ZDOTDIR") | ||
| 51 | + if real == "" { | ||
| 52 | + real = getenv("HOME") | ||
| 53 | + } | ||
| 54 | + l.env = append(l.env, "ZDOTDIR="+tmp, "MM_REAL_ZDOTDIR="+real) | ||
| 55 | + l.files = map[string]string{ | ||
| 56 | + ".zshenv": `_mm_zdotdir="$ZDOTDIR" | ||
| 57 | +ZDOTDIR="$MM_REAL_ZDOTDIR" | ||
| 58 | +[[ -f "$ZDOTDIR/.zshenv" ]] && source "$ZDOTDIR/.zshenv" | ||
| 59 | +MM_REAL_ZDOTDIR="$ZDOTDIR" | ||
| 60 | +ZDOTDIR="$_mm_zdotdir" | ||
| 61 | +unset _mm_zdotdir | ||
| 62 | +`, | ||
| 63 | + ".zshrc": `ZDOTDIR="$MM_REAL_ZDOTDIR" | ||
| 64 | +unset MM_REAL_ZDOTDIR | ||
| 65 | +[[ -f "$ZDOTDIR/.zshrc" ]] && source "$ZDOTDIR/.zshrc" | ||
| 66 | +PROMPT="` + promptPrefix + `$PROMPT" | ||
| 67 | +`, | ||
| 68 | + } | ||
| 69 | + case "fish": | ||
| 70 | + // -C runs after the user's config: wrap the prompt function it defined. | ||
| 71 | + l.args = []string{"-C", `functions -c fish_prompt _mm_fish_prompt; function fish_prompt; echo -n "` + promptPrefix + `"; _mm_fish_prompt; end`} | ||
| 72 | + case "sh", "dash", "ash", "ksh", "mksh": | ||
| 73 | + l.env = append(l.env, "PS1="+promptPrefix+"$ ") | ||
| 74 | + case "cmd": | ||
| 75 | + l.env = append(l.env, "PROMPT="+promptPrefix+"$P$G") | ||
| 76 | + } | ||
| 77 | + return l | ||
| 78 | +} | ||
modified
internal/tui/tui.go +1 -0 | @@ -64,6 +64,7 @@ func newApp(screen tcell.Screen, opts Options) *app { | ||
| 64 | 64 | t := &Transcript{} |
| 65 | 65 | r := newRunner(opts, t, wake) |
| 66 | 66 | r.clip = clipboard.New(screen.SetClipboard) |
| 67 | + r.suspend = suspendScreen(screen) | |
| 67 | 68 | v := newView(screen, t, opts.AgentName, hooks{ |
| 68 | 69 | send: r.submit, |
| 69 | 70 | cancel: r.cancelTurn, |
| @@ -64,6 +64,7 @@ func newApp(screen tcell.Screen, opts Options) *app { | |||
| 64 | t := &Transcript{} | 64 | t := &Transcript{} |
| 65 | r := newRunner(opts, t, wake) | 65 | r := newRunner(opts, t, wake) |
| 66 | r.clip = clipboard.New(screen.SetClipboard) | 66 | r.clip = clipboard.New(screen.SetClipboard) |
| 67 | + r.suspend = suspendScreen(screen) | ||
| 67 | v := newView(screen, t, opts.AgentName, hooks{ | 68 | v := newView(screen, t, opts.AgentName, hooks{ |
| 68 | send: r.submit, | 69 | send: r.submit, |
| 69 | cancel: r.cancelTurn, | 70 | cancel: r.cancelTurn, |
modified
internal/tui/turn.go +6 -0 | @@ -105,6 +105,11 @@ type runner struct { | ||
| 105 | 105 | wake func() |
| 106 | 106 | keep *store.Keeper |
| 107 | 107 | clip Copier // set by newApp; nil means /copy reports that it cannot |
| 108 | + // suspend hands the terminal to fn and takes it back (set by newApp; | |
| 109 | + // nil means /shell reports that it cannot); runShell is what /shell runs | |
| 110 | + // meanwhile. Both are fields so a test runs neither a shell nor a screen. | |
| 111 | + suspend func(fn func() error) error | |
| 112 | + runShell func(dir string) error | |
| 108 | 113 | |
| 109 | 114 | messages []*ai.Message |
| 110 | 115 | busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>" |
| @@ -146,6 +151,7 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner { | ||
| 146 | 151 | keep: store.NewKeeper(opts.Cwd), |
| 147 | 152 | messages: session.Fresh(agentsmd.System(opts.System, opts.Cwd)), |
| 148 | 153 | results: make(chan outcome, 1), |
| 154 | + runShell: runShell, | |
| 149 | 155 | } |
| 150 | 156 | r.busy.Store("") |
| 151 | 157 | return r |
| @@ -105,6 +105,11 @@ type runner struct { | |||
| 105 | wake func() | 105 | wake func() |
| 106 | keep *store.Keeper | 106 | keep *store.Keeper |
| 107 | clip Copier // set by newApp; nil means /copy reports that it cannot | 107 | clip Copier // set by newApp; nil means /copy reports that it cannot |
| 108 | + // suspend hands the terminal to fn and takes it back (set by newApp; | ||
| 109 | + // nil means /shell reports that it cannot); runShell is what /shell runs | ||
| 110 | + // meanwhile. Both are fields so a test runs neither a shell nor a screen. | ||
| 111 | + suspend func(fn func() error) error | ||
| 112 | + runShell func(dir string) error | ||
| 108 | 113 | ||
| 109 | messages []*ai.Message | 114 | messages []*ai.Message |
| 110 | busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>" | 115 | busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>" |
| @@ -146,6 +151,7 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner { | |||
| 146 | keep: store.NewKeeper(opts.Cwd), | 151 | keep: store.NewKeeper(opts.Cwd), |
| 147 | messages: session.Fresh(agentsmd.System(opts.System, opts.Cwd)), | 152 | messages: session.Fresh(agentsmd.System(opts.System, opts.Cwd)), |
| 148 | results: make(chan outcome, 1), | 153 | results: make(chan outcome, 1), |
| 154 | + runShell: runShell, | ||
| 149 | } | 155 | } |
| 150 | r.busy.Store("") | 156 | r.busy.Store("") |
| 151 | return r | 157 | return r |
modified
internal/tui/view_test.go +2 -2 | @@ -163,8 +163,8 @@ func TestPasteNeverSends(t *testing.T) { | ||
| 163 | 163 | func TestPicker(t *testing.T) { |
| 164 | 164 | hs := newHarness(t, 70, 20) |
| 165 | 165 | hs.typeText("/") |
| 166 | - if got := len(hs.v.choices()); got != 9 { | |
| 167 | - t.Fatalf("%d choices on '/', want 9", got) | |
| 166 | + if got := len(hs.v.choices()); got != 10 { | |
| 167 | + t.Fatalf("%d choices on '/', want 10", got) | |
| 168 | 168 | } |
| 169 | 169 | if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") { |
| 170 | 170 | t.Errorf("picker not drawn:\n%s", text) |
| @@ -163,8 +163,8 @@ func TestPasteNeverSends(t *testing.T) { | |||
| 163 | func TestPicker(t *testing.T) { | 163 | func TestPicker(t *testing.T) { |
| 164 | hs := newHarness(t, 70, 20) | 164 | hs := newHarness(t, 70, 20) |
| 165 | hs.typeText("/") | 165 | hs.typeText("/") |
| 166 | - if got := len(hs.v.choices()); got != 9 { | 166 | + if got := len(hs.v.choices()); got != 10 { |
| 167 | - t.Fatalf("%d choices on '/', want 9", got) | 167 | + t.Fatalf("%d choices on '/', want 10", got) |
| 168 | } | 168 | } |
| 169 | if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") { | 169 | if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") { |
| 170 | t.Errorf("picker not drawn:\n%s", text) | 170 | t.Errorf("picker not drawn:\n%s", text) |