bots-garden/mini-mepublic Fork
⑂ 4478b61
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-10-22T19:34:50+02:00 Browse files
4478b61 parent: cc53fa7
modified .tickets/issues/0061-llmman-provider-download-the-model-automatically.yaml +2 -2
@@ -6,9 +6,9 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-01T05:48:44.919Z
9-updatedAt: 2026-10-21T11:45:30.797Z
9+updatedAt: 2026-10-22T17:11:46.417Z
1010 labels: []
11-milestone: v1.0.0
11+milestone: v1.0.1
1212 epic: Providers (LLM Engine)
1313 body: ""
1414 tasks: []
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-01T05:48:44.919Z8 createdAt: 2026-10-01T05:48:44.919Z
9-updatedAt: 2026-10-21T11:45:30.797Z9+updatedAt: 2026-10-22T17:11:46.417Z
10 labels: []10 labels: []
11-milestone: v1.0.011+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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-02T05:32:33.878Z
9-updatedAt: 2026-10-20T07:50:44.919Z
9+updatedAt: 2026-10-22T17:07:15.939Z
1010 labels:
1111 - priority::urgent
1212 - kind::feature
13-milestone: v1.0.0
1413 epic: SDK
1514 body: |
1615 ## Proposal: what "Mini Me as an SDK" could look like
@@ -6,11 +6,10 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-02T05:32:33.878Z8 createdAt: 2026-10-02T05:32:33.878Z
9-updatedAt: 2026-10-20T07:50:44.919Z9+updatedAt: 2026-10-22T17:07:15.939Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 - kind::feature12 - kind::feature
13-milestone: v1.0.0
14 epic: SDK13 epic: SDK
15 body: |14 body: |
16 ## Proposal: what "Mini Me as an SDK" could look like15 ## 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-06T03:58:37.611Z
9-updatedAt: 2026-10-21T11:49:01.282Z
9+updatedAt: 2026-10-22T17:11:08.086Z
1010 labels: []
11-milestone: v1.0.0
11+milestone: v1.0.1
1212 epic: Providers (LLM Engine)
1313 body: ""
1414 tasks: []
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-06T03:58:37.611Z8 createdAt: 2026-10-06T03:58:37.611Z
9-updatedAt: 2026-10-21T11:49:01.282Z9+updatedAt: 2026-10-22T17:11:08.086Z
10 labels: []10 labels: []
11-milestone: v1.0.011+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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-20T07:51:18.410Z
9-updatedAt: 2026-10-20T07:52:09.908Z
9+updatedAt: 2026-10-22T17:07:42.565Z
1010 labels: []
11-milestone: v1.0.0
11+epic: SDK
1212 related:
1313 - 71
1414 body: |
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-20T07:51:18.410Z8 createdAt: 2026-10-20T07:51:18.410Z
9-updatedAt: 2026-10-20T07:52:09.908Z9+updatedAt: 2026-10-22T17:07:42.565Z
10 labels: []10 labels: []
11-milestone: v1.0.011+epic: SDK
12 related:12 related:
13 - 7113 - 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-21T04:27:19.433Z
9-updatedAt: 2026-10-21T04:41:59.368Z
9+updatedAt: 2026-10-22T17:10:36.153Z
1010 labels: []
11-milestone: v1.0.0
1211 body: |
1312 the Claude skill will produce a descriptive doc about the model and a config file (`agent.yaml`)
1413 tasks: []
@@ -6,9 +6,8 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-21T04:27:19.433Z8 createdAt: 2026-10-21T04:27:19.433Z
9-updatedAt: 2026-10-21T04:41:59.368Z9+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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-21T13:00:00.000Z
9-updatedAt: 2026-10-21T13:00:00.000Z
9+updatedAt: 2026-10-22T17:09:28.961Z
1010 labels: []
11-milestone: v1.0.0
1211 epic: Mini Me as a service
1312 related:
1413 - 71
@@ -117,7 +116,7 @@ body: |
117116 - Go library (ticket 71) first, or a protocol (MCP / HTTP) first?
118117 tasks:
119118 - 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)
121120 state: open
122121 priority: none
123122 author:
@@ -153,7 +152,7 @@ tasks:
153152 createdAt: 2026-10-21T13:00:00.000Z
154153 updatedAt: 2026-10-21T13:00:00.000Z
155154 - 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
157156 state: open
158157 priority: none
159158 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: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-21T13:00:00.000Z8 createdAt: 2026-10-21T13:00:00.000Z
9-updatedAt: 2026-10-21T13:00:00.000Z9+updatedAt: 2026-10-22T17:09:28.961Z
10 labels: []10 labels: []
11-milestone: v1.0.0
12 epic: Mini Me as a service11 epic: Mini Me as a service
13 related:12 related:
14 - 7113 - 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: 1118 - 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: open120 state: open
122 priority: none121 priority: none
123 author:122 author:
@@ -153,7 +152,7 @@ tasks:
153 createdAt: 2026-10-21T13:00:00.000Z152 createdAt: 2026-10-21T13:00:00.000Z
154 updatedAt: 2026-10-21T13:00:00.000Z153 updatedAt: 2026-10-21T13:00:00.000Z
155 - id: 5154 - 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: open156 state: open
158 priority: none157 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-21T14:00:00.000Z
9-updatedAt: 2026-10-21T14:00:00.000Z
9+updatedAt: 2026-10-22T17:08:57.704Z
1010 labels:
1111 - kind::feature
12-milestone: v1.0.0
1312 epic: Mini Me as a service
1413 related:
1514 - 113
@@ -130,7 +129,7 @@ body: |
130129 - Sessions: stateless only, or save / continue sessions server-side?
131130 tasks:
132131 - 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)
134133 state: open
135134 priority: none
136135 author:
@@ -184,7 +183,7 @@ tasks:
184183 createdAt: 2026-10-21T14:00:00.000Z
185184 updatedAt: 2026-10-21T14:00:00.000Z
186185 - 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)
188187 state: open
189188 priority: none
190189 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: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-21T14:00:00.000Z8 createdAt: 2026-10-21T14:00:00.000Z
9-updatedAt: 2026-10-21T14:00:00.000Z9+updatedAt: 2026-10-22T17:08:57.704Z
10 labels:10 labels:
11 - kind::feature11 - kind::feature
12-milestone: v1.0.0
13 epic: Mini Me as a service12 epic: Mini Me as a service
14 related:13 related:
15 - 11314 - 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: 1131 - 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: open133 state: open
135 priority: none134 priority: none
136 author:135 author:
@@ -184,7 +183,7 @@ tasks:
184 createdAt: 2026-10-21T14:00:00.000Z183 createdAt: 2026-10-21T14:00:00.000Z
185 updatedAt: 2026-10-21T14:00:00.000Z184 updatedAt: 2026-10-21T14:00:00.000Z
186 - id: 7185 - 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: open187 state: open
189 priority: none188 priority: none
190 author:189 author:
modified .tickets/issues/0122-open-a-shell-window.yaml +33 -4
@@ -1,14 +1,43 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 122
33 title: Open a shell window
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-22T03:58:02.231Z
9-updatedAt: 2026-10-22T03:58:02.231Z
9+updatedAt: 2026-10-22T23:30:00.000Z
1010 labels: []
1111 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
1316 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: 1222 id: 122
3 title: Open a shell window3 title: Open a shell window
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-22T03:58:02.231Z8 createdAt: 2026-10-22T03:58:02.231Z
9-updatedAt: 2026-10-22T03:58:02.231Z9+updatedAt: 2026-10-22T23:30:00.000Z
10 labels: []10 labels: []
11 milestone: v1.0.011 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:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-22T06:06:35.493Z
9-updatedAt: 2026-10-22T06:06:35.493Z
9+updatedAt: 2026-10-22T17:08:19.090Z
1010 labels: []
11-milestone: v1.0.0
11+milestone: v1.0.1
1212 body: ""
1313 tasks: []
1414 comments: []
@@ -6,9 +6,9 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-10-22T06:06:35.493Z8 createdAt: 2026-10-22T06:06:35.493Z
9-updatedAt: 2026-10-22T06:06:35.493Z9+updatedAt: 2026-10-22T17:08:19.090Z
10 labels: []10 labels: []
11-milestone: v1.0.011+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
6565 mm -tui
6666 ```
6767
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).
6969
7070 ### 3. Inside an editor (ACP)
7171
@@ -65,7 +65,7 @@ Type a question at the `>` prompt. Each command the model runs is shown with its
65 mm -tui65 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)
3939 - [Start a new session without restarting](how-to/start-a-new-session.md)
4040 - [Point the model at a file with `@path`](how-to/attach-a-file.md)
4141 - [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
4343 - [Navigate and refactor code with a language server (`lsp`)](how-to/navigate-code-with-lsp.md)
4444 - [Run a single prompt and exit (`-p`/`--prompt`)](how-to/run-a-single-prompt.md)
4545 - [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 5345 - [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
5959 - **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.
6060 - **Printing directly from the ACP façade.** Impossible: the specification forbids anything but protocol messages on stdout.
6161 - **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.
6263
6364 ## How it relates to the rest
6465
@@ -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 rest64 ## 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.
8181
8282 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.
8383
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+
8494 ## See also
8595
8696 - [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 also94 ## See also
85 95
86 - [Command line](../reference/cli.md) — the `-tui` flag and the window-mode specifics96 - [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-
8888
8989 ## Window mode keys
9090
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).
9292
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.
94128
95129 The keys:
96130
@@ -88,9 +88,43 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
88 88
89 ## Window mode keys89 ## 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:
3939 - [Démarrer une nouvelle session sans relancer](how-to/start-a-new-session.md)
4040 - [Désigner un fichier au modèle avec `@chemin`](how-to/attach-a-file.md)
4141 - [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
4343 - [Naviguer et refactorer le code avec un serveur de langage (`lsp`)](how-to/navigate-code-with-lsp.md)
4444 - [Exécuter une invite unique et quitter (`-p`/`--prompt`)](how-to/executer-une-invite-unique.md)
4545 - [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 5345 - [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
5959 - **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.
6060 - **Afficher directement depuis la façade ACP.** Impossible : la spécification interdit tout ce qui n'est pas message de protocole sur stdout.
6161 - **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.
6263
6364 ## Liens avec le reste
6465
@@ -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 reste64 ## 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
8181
8282 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.
8383
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+
8494 ## Voir aussi
8595
8696 - [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 aussi94 ## Voir aussi
85 95
86 - [Ligne de commande](../reference/cli.md) — l'option `-tui` et les spécificités du mode fenêtre96 - [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
8888
8989 ## Touches du mode fenêtre
9090
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.
9292
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.
94128
95129 Les touches :
96130
@@ -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être89 ## 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 (
4444 Unload = "unload"
4545 Memory = "memory"
4646 Docs = "docs"
47+ Shell = "shell"
4748 )
4849
4950 // Command is one slash command: what it is called, what it does, who offers
@@ -77,6 +78,9 @@ var table = []Command{
7778 {Name: Help, Description: "List the commands", Fronts: Terminal | Window},
7879 {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},
7980 {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},
8084 }
8185
8286 // 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 offers50 // 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, /unload86 // 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) {
2323 if got := names(For(Terminal)); got != want {
2424 t.Errorf("Terminal commands = %q, want %q", got, want)
2525 }
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")
2829 }
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()))
3132 }
3233 }
3334
@@ -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) {
3939
4040 // control runs the commands that must never wait for the turn in progress,
4141 // 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.
4344 func (r *runner) control(p Prompt) bool {
4445 cmd, _, ok := command.Parse(p.Typed)
4546 if !ok {
@@ -50,6 +51,8 @@ func (r *runner) control(p Prompt) bool {
5051 r.quitNow()
5152 case command.Abort:
5253 r.abort()
54+ case command.Shell:
55+ r.shell()
5356 default:
5457 return false
5558 }
@@ -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 turn41 // 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 false57 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 {
6464 t := &Transcript{}
6565 r := newRunner(opts, t, wake)
6666 r.clip = clipboard.New(screen.SetClipboard)
67+ r.suspend = suspendScreen(screen)
6768 v := newView(screen, t, opts.AgentName, hooks{
6869 send: r.submit,
6970 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 {
105105 wake func()
106106 keep *store.Keeper
107107 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
108113
109114 messages []*ai.Message
110115 busy atomic.Value // string: "", "thinking", "compressing", "asking <agent>"
@@ -146,6 +151,7 @@ func newRunner(opts Options, t *Transcript, wake func()) *runner {
146151 keep: store.NewKeeper(opts.Cwd),
147152 messages: session.Fresh(agentsmd.System(opts.System, opts.Cwd)),
148153 results: make(chan outcome, 1),
154+ runShell: runShell,
149155 }
150156 r.busy.Store("")
151157 return r
@@ -105,6 +105,11 @@ type runner struct {
105 wake func()105 wake func()
106 keep *store.Keeper106 keep *store.Keeper
107 clip Copier // set by newApp; nil means /copy reports that it cannot107 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.Message114 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 r157 return r
modified internal/tui/view_test.go +2 -2
@@ -163,8 +163,8 @@ func TestPasteNeverSends(t *testing.T) {
163163 func TestPicker(t *testing.T) {
164164 hs := newHarness(t, 70, 20)
165165 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)
168168 }
169169 if text := hs.screenText(); !strings.Contains(text, "/resume") || !strings.Contains(text, "Continue a stored session") {
170170 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)