bots-garden/mini-mepublic Fork
⑂ a7ddac6
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-25T23:11:31+01:00 Browse files
a7ddac6 parent: dfcd2ed
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-25T04:15:36.796Z
9+updatedAt: 2026-10-25T21:58:54.735Z
1010 labels: []
11-milestone: v1.0.1
11+milestone: v2.0.0
1212 epic: Providers
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-25T04:15:36.796Z9+updatedAt: 2026-10-25T21:58:54.735Z
10 labels: []10 labels: []
11-milestone: v1.0.111+milestone: v2.0.0
12 epic: Providers12 epic: Providers
13 body: ""13 body: ""
14 tasks: []14 tasks: []
modified .tickets/issues/0155-when-adding-a-message-don-t-cancel-the-running-g.yaml +95 -5
@@ -1,16 +1,106 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 155
33 title: When adding a message don't cancel the running generation, use something like /btw
4-state: open
4+state: closed
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-10-25T10:00:14.109Z
9-updatedAt: 2026-10-25T10:00:27.072Z
9+updatedAt: 2026-10-25T22:04:44.137Z
1010 labels:
1111 - priority::urgent
1212 - enhancement
13+ - To be tested
1314 milestone: v1.0.1
14-body: ""
15-tasks: []
16-comments: []
15+body: |
16+ ## Need
17+
18+ Typing a message while a turn runs cancelled the turn: the answer being written was cut, and the new message ran instead. Most of the time the user wants to add something ("and the tests too"), not to throw away the work in progress.
19+
20+ ## Decision (2026-10-25)
21+
22+ - A message typed during a generation is **queued**: the turn goes on to its end, then the queued messages run as the next questions, in the order they were typed.
23+ - No `/btw` command (rejected: injecting the text into the running turn, and a side question answered aside).
24+ - Stopping stays explicit: Esc, Ctrl-C, `/abort` (window), `/abort`, Ctrl+C (line mode), `session/cancel` (editor). What is queued runs straight after — typing the correction then pressing Esc is the old behaviour.
25+ - `/quit`, `/abort` and `/shell` (window) still run at once (ticket 26).
26+
27+ ## What was done
28+
29+ - Window (`internal/tui`): `runner.pending` is a queue (`[]Prompt`); `submit` queues during a job and shows `⏳ Queued (N) — runs once this turn ends · Esc stops the turn`; `settle` → `runQueued` dispatches until a job starts (an instant command such as `/help` lets the next entry run); the rule reads `⠋ thinking · N queued — Esc stops it · Enter queues more`; `/help` says "Enter sends (during a turn: queues)".
30+ - Line mode (`internal/agent`): new `inbox` (queue.go); the waiting room queues every line but `/abort`, `/quit` and a permission answer; a blank line is ignored; the main loop pops the queue before reading stdin and echoes the line after `>`. A line typed during a line-mode `/ask` is queued too (it was dropped with "still answering"). The end of stdin during a turn no longer spins on the closed channel: the turn ends, the queue runs, mm exits.
31+ - Editor (ACP): no code change — `turnMu` already makes a second `session/prompt` wait instead of cancelling. Documented; whether the editor queues or cancels on its side is the editor's choice.
32+
33+ ## Verified
34+
35+ - Tests: tui `TestRunnerQueuesAQuestionTypedDuringATurn`, `TestStoppingTheTurnRunsTheQueue`, `TestTheQueueRunsInOrderThroughInstantCommands`, rule label in `TestDrawFrame`; agent `TestInboxKeepsTheOrderAndDropsBlankLines`, `TestLineAskQueuesWhatIsTypedMeanwhile`, `TestLineAskSurvivesTheEndOfStdin`. `go vet ./...` and `go test ./...` green.
36+ - Live, with `demos/struggle-signals` (fake server): `printf 'first\nsecond\n' | mm -config agent.yaml` → `⏳ Queued (1)`, two answers, no abort. Window through a pty with `FAKE=silent`: `Queued (1)`, `1 queued` in the rule, no `Generation aborted`, the first turn ran until the watchdog, then the second started.
37+ - Not verified: Zed.
38+
39+ ## Docs
40+
41+ EN + FR: `reference/slash-commands.md` (text during a generation, `/ask` table, window `Enter` / `Esc` rows), `how-to/use-the-window-mode.md` (step 4), `reference/acp.md` (`session/cancel`).
42+tasks:
43+ - id: 1
44+ title: "Window: queue instead of cancel; notice and rule show the queue"
45+ state: closed
46+ priority: none
47+ author:
48+ name: k33g
49+ email: ph.charriere@gmail.com
50+ createdAt: 2026-10-25T22:00:00.000Z
51+ - id: 2
52+ title: "Line mode: inbox queue in the waiting room and in /ask; blank lines ignored; end of stdin handled"
53+ state: closed
54+ priority: none
55+ author:
56+ name: k33g
57+ email: ph.charriere@gmail.com
58+ createdAt: 2026-10-25T22:00:00.000Z
59+ - id: 3
60+ title: "ACP: check that a second session/prompt waits (turnMu) and document it"
61+ state: closed
62+ priority: none
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-25T22:00:00.000Z
67+ - id: 4
68+ title: Tests (tui, agent) + live check (pipe, window through a pty)
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-10-25T22:00:00.000Z
75+ - id: 5
76+ title: Docs EN + FR
77+ state: closed
78+ priority: none
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-25T22:00:00.000Z
83+ - id: 6
84+ title: Check in Zed what the editor does with a message typed during a turn
85+ state: closed
86+ priority: none
87+ author:
88+ name: k33g
89+ email: ph.charriere@gmail.com
90+ createdAt: 2026-10-25T22:00:00.000Z
91+ updatedAt: 2026-10-25T22:04:44.133Z
92+ closedAt: 2026-10-25T22:04:44.133Z
93+comments:
94+ - id: 1
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-10-25T22:30:00.000Z
99+ body: |
100+ Done and closed.
101+
102+ - Window and line mode: a message typed during a turn is queued (`⏳ Queued (N) — runs once this turn ends`), never cancels the turn; the queue runs in order once the turn has settled. Esc / Ctrl-C / `/abort` still stop the turn, and the queue runs right after.
103+ - Line-mode `/ask` queues too; a stdin closed during a turn (end of a pipe) is handled.
104+ - ACP: no change needed, a second `session/prompt` already waits (`turnMu`).
105+ - Verified: `go vet ./...` + `go test ./...` green; live with `demos/struggle-signals` (pipe in line mode, window through a pty).
106+ - Task 6 (check in Zed) stays open.
@@ -1,16 +1,106 @@
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: 1552 id: 155
3 title: When adding a message don't cancel the running generation, use something like /btw3 title: When adding a message don't cancel the running generation, use something like /btw
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-25T10:00:14.109Z8 createdAt: 2026-10-25T10:00:14.109Z
9-updatedAt: 2026-10-25T10:00:27.072Z9+updatedAt: 2026-10-25T22:04:44.137Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 - enhancement12 - enhancement
13+ - To be tested
13 milestone: v1.0.114 milestone: v1.0.1
14-body: ""15+body: |
15-tasks: []16+ ## Need
16-comments: []17+
18+ Typing a message while a turn runs cancelled the turn: the answer being written was cut, and the new message ran instead. Most of the time the user wants to add something ("and the tests too"), not to throw away the work in progress.
19+
20+ ## Decision (2026-10-25)
21+
22+ - A message typed during a generation is **queued**: the turn goes on to its end, then the queued messages run as the next questions, in the order they were typed.
23+ - No `/btw` command (rejected: injecting the text into the running turn, and a side question answered aside).
24+ - Stopping stays explicit: Esc, Ctrl-C, `/abort` (window), `/abort`, Ctrl+C (line mode), `session/cancel` (editor). What is queued runs straight after — typing the correction then pressing Esc is the old behaviour.
25+ - `/quit`, `/abort` and `/shell` (window) still run at once (ticket 26).
26+
27+ ## What was done
28+
29+ - Window (`internal/tui`): `runner.pending` is a queue (`[]Prompt`); `submit` queues during a job and shows `⏳ Queued (N) — runs once this turn ends · Esc stops the turn`; `settle` → `runQueued` dispatches until a job starts (an instant command such as `/help` lets the next entry run); the rule reads `⠋ thinking · N queued — Esc stops it · Enter queues more`; `/help` says "Enter sends (during a turn: queues)".
30+ - Line mode (`internal/agent`): new `inbox` (queue.go); the waiting room queues every line but `/abort`, `/quit` and a permission answer; a blank line is ignored; the main loop pops the queue before reading stdin and echoes the line after `>`. A line typed during a line-mode `/ask` is queued too (it was dropped with "still answering"). The end of stdin during a turn no longer spins on the closed channel: the turn ends, the queue runs, mm exits.
31+ - Editor (ACP): no code change — `turnMu` already makes a second `session/prompt` wait instead of cancelling. Documented; whether the editor queues or cancels on its side is the editor's choice.
32+
33+ ## Verified
34+
35+ - Tests: tui `TestRunnerQueuesAQuestionTypedDuringATurn`, `TestStoppingTheTurnRunsTheQueue`, `TestTheQueueRunsInOrderThroughInstantCommands`, rule label in `TestDrawFrame`; agent `TestInboxKeepsTheOrderAndDropsBlankLines`, `TestLineAskQueuesWhatIsTypedMeanwhile`, `TestLineAskSurvivesTheEndOfStdin`. `go vet ./...` and `go test ./...` green.
36+ - Live, with `demos/struggle-signals` (fake server): `printf 'first\nsecond\n' | mm -config agent.yaml` → `⏳ Queued (1)`, two answers, no abort. Window through a pty with `FAKE=silent`: `Queued (1)`, `1 queued` in the rule, no `Generation aborted`, the first turn ran until the watchdog, then the second started.
37+ - Not verified: Zed.
38+
39+ ## Docs
40+
41+ EN + FR: `reference/slash-commands.md` (text during a generation, `/ask` table, window `Enter` / `Esc` rows), `how-to/use-the-window-mode.md` (step 4), `reference/acp.md` (`session/cancel`).
42+tasks:
43+ - id: 1
44+ title: "Window: queue instead of cancel; notice and rule show the queue"
45+ state: closed
46+ priority: none
47+ author:
48+ name: k33g
49+ email: ph.charriere@gmail.com
50+ createdAt: 2026-10-25T22:00:00.000Z
51+ - id: 2
52+ title: "Line mode: inbox queue in the waiting room and in /ask; blank lines ignored; end of stdin handled"
53+ state: closed
54+ priority: none
55+ author:
56+ name: k33g
57+ email: ph.charriere@gmail.com
58+ createdAt: 2026-10-25T22:00:00.000Z
59+ - id: 3
60+ title: "ACP: check that a second session/prompt waits (turnMu) and document it"
61+ state: closed
62+ priority: none
63+ author:
64+ name: k33g
65+ email: ph.charriere@gmail.com
66+ createdAt: 2026-10-25T22:00:00.000Z
67+ - id: 4
68+ title: Tests (tui, agent) + live check (pipe, window through a pty)
69+ state: closed
70+ priority: none
71+ author:
72+ name: k33g
73+ email: ph.charriere@gmail.com
74+ createdAt: 2026-10-25T22:00:00.000Z
75+ - id: 5
76+ title: Docs EN + FR
77+ state: closed
78+ priority: none
79+ author:
80+ name: k33g
81+ email: ph.charriere@gmail.com
82+ createdAt: 2026-10-25T22:00:00.000Z
83+ - id: 6
84+ title: Check in Zed what the editor does with a message typed during a turn
85+ state: closed
86+ priority: none
87+ author:
88+ name: k33g
89+ email: ph.charriere@gmail.com
90+ createdAt: 2026-10-25T22:00:00.000Z
91+ updatedAt: 2026-10-25T22:04:44.133Z
92+ closedAt: 2026-10-25T22:04:44.133Z
93+comments:
94+ - id: 1
95+ author:
96+ name: k33g
97+ email: ph.charriere@gmail.com
98+ createdAt: 2026-10-25T22:30:00.000Z
99+ body: |
100+ Done and closed.
101+
102+ - Window and line mode: a message typed during a turn is queued (`⏳ Queued (N) — runs once this turn ends`), never cancels the turn; the queue runs in order once the turn has settled. Esc / Ctrl-C / `/abort` still stop the turn, and the queue runs right after.
103+ - Line-mode `/ask` queues too; a stdin closed during a turn (end of a pipe) is handled.
104+ - ACP: no change needed, a second `session/prompt` already waits (`turnMu`).
105+ - Verified: `go vet ./...` + `go test ./...` green; live with `demos/struggle-signals` (pipe in line mode, window through a pty).
106+ - Task 6 (check in Zed) stays open.
modified demos/struggle-signals/agent.yaml +1 -1
@@ -12,7 +12,7 @@ contextWindow: 1000 # the "context" scenario reports 900 tokens: 90 %
1212 maxTurns: 10 # room for the loop detector to stop the turn first (6 calls)
1313 watchdogTimeout: 3s # the "silent" scenario stalls quickly
1414 firstTokenTimeout: 3s
15-skillsDir: ""
15+skillsDir: no-skills # no skill: the README files are not procedures
1616 agentsMd:
1717 enabled: false
1818 sessions:
@@ -12,7 +12,7 @@ contextWindow: 1000 # the "context" scenario reports 900 tokens: 90 %
12 maxTurns: 10 # room for the loop detector to stop the turn first (6 calls)12 maxTurns: 10 # room for the loop detector to stop the turn first (6 calls)
13 watchdogTimeout: 3s # the "silent" scenario stalls quickly13 watchdogTimeout: 3s # the "silent" scenario stalls quickly
14 firstTokenTimeout: 3s14 firstTokenTimeout: 3s
15-skillsDir: ""15+skillsDir: no-skills # no skill: the README files are not procedures
16 agentsMd:16 agentsMd:
17 enabled: false17 enabled: false
18 sessions:18 sessions:
modified docs/en/how-to/use-the-window-mode.md +1 -1
@@ -16,7 +16,7 @@ This guide shows how to run mini-me as a single full-screen window — the conve
1616
1717 3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.
1818
19-4. Press **Esc** to stop a running turn. Typing a new question and pressing Enter while a turn runs also stops it; the new question runs once the old one has settled.
19+4. To add something while a turn runs, type it and press **Enter**: the turn is not stopped, the text waits (`⏳ Queued (1)`, and the rule reads `thinking · 1 queued`) and runs as the next question once the answer is written. Press **Esc** to stop a running turn; whatever is queued runs straight after — type the correction, then press Esc, to redirect the model at once.
2020
2121 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.
2222
@@ -16,7 +16,7 @@ This guide shows how to run mini-me as a single full-screen window — the conve
16 16
17 3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.17 3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.
18 18
19-4. Press **Esc** to stop a running turn. Typing a new question and pressing Enter while a turn runs also stops it; the new question runs once the old one has settled.19+4. To add something while a turn runs, type it and press **Enter**: the turn is not stopped, the text waits (`⏳ Queued (1)`, and the rule reads `thinking · 1 queued`) and runs as the next question once the answer is written. Press **Esc** to stop a running turn; whatever is queued runs straight after — type the correction, then press Esc, to redirect the model at once.
20 20
21 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.21 5. Type `/` at the start of the box to open the command picker: ↑/↓ choose, **Tab** completes, **Enter** sends a complete command. `/help` (or **F1**) lists the commands and keys in the conversation.
22 22
modified docs/en/reference/acp.md +1 -1
@@ -30,7 +30,7 @@
3030 | `authenticate` | Returns an empty response. |
3131 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. |
3232 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33-| `session/cancel` | Cancels the turn in flight for that session. |
33+| `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. |
3434 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
3535 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |
3636 | `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
@@ -30,7 +30,7 @@
30 | `authenticate` | Returns an empty response. |30 | `authenticate` | Returns an empty response. |
31 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. |31 | `session/new` | Creates a session with a time-sortable id (`20260916-101530-4f2a9c1e`) and the request's `cwd`, a fresh history holding the system prompt, and an empty allow-always set. `mcpServers` are counted in the log and ignored. Once the response is written, sends an `available_commands_update` notification listing the slash commands (see below). With `sessions.enabled`, writes `<cwd>/<sessions.dir>/<id>.json` at once and rewrites it after every turn, `/new` and `/compact`. |
32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |32 | `session/prompt` | Runs one generation on the session's history, or executes a slash command without consulting the model. Turns are serialised process-wide. |
33-| `session/cancel` | Cancels the turn in flight for that session. |33+| `session/cancel` | Cancels the turn in flight for that session. A `session/prompt` that arrives during a turn never cancels it: it waits for the turn to end, then runs. Whether a message typed during a turn is sent at once, queued, or preceded by a `session/cancel` is the editor's choice. |
34 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |34 | `session/load` | With `sessions.enabled`: reads `<cwd>/<sessions.dir>/<sessionId>.json`, replaces its system prompt by the current one, registers the session (empty allow-always set), sends the replay (below) then an `available_commands_update`, and answers `{}`. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
35 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |35 | `session/list` | With `sessions.enabled`: lists the sessions stored in `<cwd>/<sessions.dir>`, `cwd` being the request's, else `mm`'s own directory. Most recently updated first; a session without any question is left out. Each entry: `sessionId`, `cwd` (the one listed), `title` (the first question, one line; absent when empty), `updatedAt` (RFC 3339, UTC). One page: no `nextCursor`, `cursor` ignored. Sessions written by the line mode or the window of the same project are listed too. Without `sessions.enabled`: `method not found`. |
36 | `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |36 | `session/resume` | With `sessions.enabled`: like `session/load` without the replay — reads the file, replaces its system prompt by the current one, registers the session, sends an `available_commands_update`, and answers `{}`. `mcpServers` are ignored. Unknown id or unreadable file: `invalid params` carrying the `sessionId` and the error. Without `sessions.enabled`: `method not found`. |
modified docs/en/reference/slash-commands.md +7 -4
@@ -41,7 +41,7 @@ While it runs:
4141 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
4242 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |
4343 | `/quit` | Cancels the ask and exits. |
44-| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. |
44+| Any other line (line mode) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`; the line runs once the ask has ended, like a line typed during a generation (below). |
4545
4646 When it ends:
4747
@@ -77,7 +77,10 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
7777
7878 | Input | Behaviour |
7979 |-------|-----------|
80-| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. |
80+| A non-command line, or a command other than `/abort` and `/quit` | Does not stop the generation. Prints `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`, `N` being the number of lines waiting. Once the turn has ended, the queued lines run in the order they were typed, each one echoed after the `>` prompt as if it had been typed there. |
81+| An empty line | Ignored. |
82+| `/abort`, `Ctrl+C` | Stop the generation; the queued lines then run. |
83+| End of input (the end of a pipe) | The generation goes on; the queued lines run, then `mm` exits. |
8184
8285 ## Keyboard shortcuts
8386
@@ -130,9 +133,9 @@ The keys:
130133
131134 | Key | Action |
132135 |-----|--------|
133-| `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. |
136+| `Enter` | Sends the box (nothing on an empty box). During a turn, the text does not stop it: it is queued (`⏳ Queued (N) — runs once this turn ends · Esc stops the turn`, the rule reads `<state> · N queued`) and the queued entries run in order once the turn has settled; `/quit`, `/abort` and `/shell` run at once. With the picker open on an incomplete name, completes it instead. |
134137 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
135-| `Esc` | Cancels the running turn (same as `/abort`); when no turn runs and the box is not empty, clears the box instead; closes the picker; rejects a pending permission. |
138+| `Esc` | Cancels the running turn (same as `/abort`) — the queued entries then run; when no turn runs and the box is not empty, clears the box instead; closes the picker; rejects a pending permission. |
136139 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
137140 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). |
138141 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows. From the first row, `↑` recalls the previous question (sessions on: this session, then the earlier ones, newest first); from the last row during a recall, `↓` the next one, then the text typed before. With nothing to recall, they scroll the conversation from the first/last row. On the conversation: scroll one line. In the picker: move the choice. |
@@ -41,7 +41,7 @@ While it runs:
41 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |41 | `<name>`'s tool calls | Shown as tool calls titled `<name> · <tool>: <args>`. Permission requests go through this front end: none in the line mode (allowed), `permissions` in the window (`a`/`s`/`r`, "for this session" keyed on `<name>/<tool>`), the editor's dialog over ACP. |
42 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |42 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` |
43 | `/quit` | Cancels the ask and exits. |43 | `/quit` | Cancels the ask and exits. |
44-| Any other line (line mode) | `⚠️ <name> is still answering — /abort to stop it.`; the line is dropped. |44+| Any other line (line mode) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`; the line runs once the ask has ended, like a line typed during a generation (below). |
45 45
46 When it ends:46 When it ends:
47 47
@@ -77,7 +77,10 @@ Details and examples: [Point the model at a file with `@path`](../how-to/attach-
77 77
78 | Input | Behaviour |78 | Input | Behaviour |
79 |-------|-----------|79 |-------|-----------|
80-| A non-command line | Prints `🔄 New command received. Cancelling current task...`, cancels the current generation, keeps its partial history, then submits the new line as the next question. |80+| A non-command line, or a command other than `/abort` and `/quit` | Does not stop the generation. Prints `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`, `N` being the number of lines waiting. Once the turn has ended, the queued lines run in the order they were typed, each one echoed after the `>` prompt as if it had been typed there. |
81+| An empty line | Ignored. |
82+| `/abort`, `Ctrl+C` | Stop the generation; the queued lines then run. |
83+| End of input (the end of a pipe) | The generation goes on; the queued lines run, then `mm` exits. |
81 84
82 ## Keyboard shortcuts85 ## Keyboard shortcuts
83 86
@@ -130,9 +133,9 @@ The keys:
130 133
131 | Key | Action |134 | Key | Action |
132 |-----|--------|135 |-----|--------|
133-| `Enter` | Sends the box (nothing on an empty box). With the picker open on an incomplete name, completes it instead. |136+| `Enter` | Sends the box (nothing on an empty box). During a turn, the text does not stop it: it is queued (`⏳ Queued (N) — runs once this turn ends · Esc stops the turn`, the rule reads `<state> · N queued`) and the queued entries run in order once the turn has settled; `/quit`, `/abort` and `/shell` run at once. With the picker open on an incomplete name, completes it instead. |
134 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |137 | `Ctrl-J`, `Alt-Enter` | Inserts a newline in the box. |
135-| `Esc` | Cancels the running turn (same as `/abort`); when no turn runs and the box is not empty, clears the box instead; closes the picker; rejects a pending permission. |138+| `Esc` | Cancels the running turn (same as `/abort`) — the queued entries then run; when no turn runs and the box is not empty, clears the box instead; closes the picker; rejects a pending permission. |
136 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |139 | `Ctrl-C` | Cancels the running turn; quits when idle — and quits too when the turn has already been asked to stop and has not. |
137 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). |140 | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). |
138 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows. From the first row, `↑` recalls the previous question (sessions on: this session, then the earlier ones, newest first); from the last row during a recall, `↓` the next one, then the text typed before. With nothing to recall, they scroll the conversation from the first/last row. On the conversation: scroll one line. In the picker: move the choice. |141 | `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows. From the first row, `↑` recalls the previous question (sessions on: this session, then the earlier ones, newest first); from the last row during a recall, `↓` the next one, then the text typed before. With nothing to recall, they scroll the conversation from the first/last row. On the conversation: scroll one line. In the picker: move the choice. |
modified docs/fr/how-to/use-the-window-mode.md +1 -1
@@ -16,7 +16,7 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
1616
1717 3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.
1818
19-4. Appuyez sur **Échap** pour arrêter un tour en cours. Taper une nouvelle question et appuyer sur Entrée pendant un tour l'arrête aussi ; la nouvelle question part une fois l'ancien tour rangé.
19+4. Pour ajouter quelque chose pendant un tour, tapez-le et appuyez sur **Entrée** : le tour n'est pas arrêté, le texte attend (`⏳ Queued (1)`, et la règle affiche `thinking · 1 queued`) et part comme question suivante une fois la réponse écrite. Appuyez sur **Échap** pour arrêter un tour en cours ; ce qui attend part juste après — tapez la correction, puis Échap, pour réorienter le modèle tout de suite.
2020
2121 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.
2222
@@ -16,7 +16,7 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran
16 16
17 3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.17 3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.
18 18
19-4. Appuyez sur **Échap** pour arrêter un tour en cours. Taper une nouvelle question et appuyer sur Entrée pendant un tour l'arrête aussi ; la nouvelle question part une fois l'ancien tour rangé.19+4. Pour ajouter quelque chose pendant un tour, tapez-le et appuyez sur **Entrée** : le tour n'est pas arrêté, le texte attend (`⏳ Queued (1)`, et la règle affiche `thinking · 1 queued`) et part comme question suivante une fois la réponse écrite. Appuyez sur **Échap** pour arrêter un tour en cours ; ce qui attend part juste après — tapez la correction, puis Échap, pour réorienter le modèle tout de suite.
20 20
21 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.21 5. Tapez `/` en début de zone pour ouvrir le sélecteur de commandes : ↑/↓ choisissent, **Tab** complète, **Entrée** envoie une commande complète. `/help` (ou **F1**) liste les commandes et les touches dans la conversation.
22 22
modified docs/fr/reference/acp.md +1 -1
@@ -30,7 +30,7 @@
3030 | `authenticate` | Renvoie une réponse vide. |
3131 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. |
3232 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33-| `session/cancel` | Annule le tour en cours pour cette session. |
33+| `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. |
3434 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
3535 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |
3636 | `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
@@ -30,7 +30,7 @@
30 | `authenticate` | Renvoie une réponse vide. |30 | `authenticate` | Renvoie une réponse vide. |
31 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. |31 | `session/new` | Crée une session avec un identifiant triable par date (`20260916-101530-4f2a9c1e`) et le `cwd` de la requête, un historique neuf contenant le prompt système et un ensemble « toujours autoriser » vide. Les `mcpServers` sont comptés dans le journal et ignorés. Une fois la réponse écrite, envoie une notification `available_commands_update` listant les commandes slash (voir plus bas). Avec `sessions.enabled`, écrit `<cwd>/<sessions.dir>/<id>.json` immédiatement et le réécrit après chaque tour, `/new` et `/compact`. |
32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |32 | `session/prompt` | Exécute une génération sur l'historique de la session, ou exécute une commande slash sans consulter le modèle. Les tours sont sérialisés à l'échelle du processus. |
33-| `session/cancel` | Annule le tour en cours pour cette session. |33+| `session/cancel` | Annule le tour en cours pour cette session. Un `session/prompt` qui arrive pendant un tour ne l'annule jamais : il attend la fin du tour, puis s'exécute. Qu'un message tapé pendant un tour soit envoyé tout de suite, mis en attente ou précédé d'un `session/cancel`, c'est le choix de l'éditeur. |
34 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |34 | `session/load` | Avec `sessions.enabled` : lit `<cwd>/<sessions.dir>/<sessionId>.json`, remplace son prompt système par l'actuel, enregistre la session (ensemble « toujours autoriser » vide), envoie le rejeu (ci-dessous) puis un `available_commands_update`, et répond `{}`. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
35 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |35 | `session/list` | Avec `sessions.enabled` : liste les sessions stockées dans `<cwd>/<sessions.dir>`, `cwd` étant celui de la requête, sinon le dossier de `mm`. Les plus récemment modifiées d'abord ; une session sans aucune question est omise. Chaque entrée : `sessionId`, `cwd` (celui listé), `title` (la première question, sur une ligne ; absent quand elle est vide), `updatedAt` (RFC 3339, UTC). Une seule page : pas de `nextCursor`, `cursor` ignoré. Les sessions écrites par le mode ligne ou la fenêtre dans le même projet sont listées aussi. Sans `sessions.enabled` : `method not found`. |
36 | `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |36 | `session/resume` | Avec `sessions.enabled` : comme `session/load` sans le rejeu — lit le fichier, remplace son prompt système par l'actuel, enregistre la session, envoie un `available_commands_update` et répond `{}`. Les `mcpServers` sont ignorés. Identifiant inconnu ou fichier illisible : `invalid params` portant le `sessionId` et l'erreur. Sans `sessions.enabled` : `method not found`. |
modified docs/fr/reference/slash-commands.md +7 -4
@@ -41,7 +41,7 @@ Pendant l'exécution :
4141 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
4242 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |
4343 | `/quit` | Annule la demande et quitte. |
44-| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. |
44+| Toute autre ligne (mode ligne) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn` ; la ligne part une fois la demande terminée, comme une ligne tapée pendant une génération (plus bas). |
4545
4646 À la fin :
4747
@@ -77,7 +77,10 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
7777
7878 | Saisie | Comportement |
7979 |--------|--------------|
80-| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. |
80+| Une ligne qui n'est pas une commande, ou une commande autre que `/abort` et `/quit` | N'arrête pas la génération. Affiche `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`, `N` étant le nombre de lignes en attente. Une fois le tour terminé, les lignes en attente partent dans l'ordre où elles ont été tapées, chacune réaffichée après l'invite `>` comme si elle y avait été tapée. |
81+| Une ligne vide | Ignorée. |
82+| `/abort`, `Ctrl+C` | Arrêtent la génération ; les lignes en attente partent ensuite. |
83+| Fin de l'entrée (la fin d'un pipe) | La génération continue ; les lignes en attente partent, puis `mm` se termine. |
8184
8285 ## Raccourcis clavier
8386
@@ -130,9 +133,9 @@ Les touches :
130133
131134 | Touche | Action |
132135 |--------|--------|
133-| `Entrée` | Envoie la zone (rien sur une zone vide). Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |
136+| `Entrée` | Envoie la zone (rien sur une zone vide). Pendant un tour, le texte ne l'arrête pas : il est mis en attente (`⏳ Queued (N) — runs once this turn ends · Esc stops the turn`, la règle affiche `<état> · N queued`) et les entrées en attente partent dans l'ordre une fois le tour rangé ; `/quit`, `/abort` et `/shell` s'exécutent tout de suite. Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |
134137 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
135-| `Échap` | Annule le tour en cours (comme `/abort`) ; quand aucun tour ne tourne et que la zone n'est pas vide, vide la zone à la place ; ferme le sélecteur ; refuse une permission en attente. |
138+| `Échap` | Annule le tour en cours (comme `/abort`) — les entrées en attente partent ensuite ; quand aucun tour ne tourne et que la zone n'est pas vide, vide la zone à la place ; ferme le sélecteur ; refuse une permission en attente. |
136139 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
137140 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert (`/`, `@` ou `$`). |
138141 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées. Depuis la première rangée, `↑` rappelle la question précédente (sessions activées : cette session, puis les précédentes, de la plus récente à la plus ancienne) ; depuis la dernière rangée pendant un rappel, `↓` la suivante, puis le texte tapé avant. Sans rien à rappeler, ils font défiler la conversation depuis la première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
@@ -41,7 +41,7 @@ Pendant l'exécution :
41 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |41 | Appels d'outils de `<nom>` | Affichés comme des appels d'outils intitulés `<nom> · <outil>: <arguments>`. Les demandes de permission passent par cette façade : aucune en mode ligne (autorisé), `permissions` en mode fenêtre (`a`/`s`/`r`, « pour cette session » indexé sur `<nom>/<outil>`), la boîte de dialogue de l'éditeur en ACP. |
42 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |42 | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` |
43 | `/quit` | Annule la demande et quitte. |43 | `/quit` | Annule la demande et quitte. |
44-| Toute autre ligne (mode ligne) | `⚠️ <nom> is still answering — /abort to stop it.` ; la ligne est ignorée. |44+| Toute autre ligne (mode ligne) | `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn` ; la ligne part une fois la demande terminée, comme une ligne tapée pendant une génération (plus bas). |
45 45
46 À la fin :46 À la fin :
47 47
@@ -77,7 +77,10 @@ Détails et exemples : [Désigner un fichier au modèle avec `@chemin`](../how-t
77 77
78 | Saisie | Comportement |78 | Saisie | Comportement |
79 |--------|--------------|79 |--------|--------------|
80-| Une ligne qui n'est pas une commande | Affiche `🔄 New command received. Cancelling current task...`, annule la génération en cours, conserve son historique partiel, puis soumet la nouvelle ligne comme question suivante. |80+| Une ligne qui n'est pas une commande, ou une commande autre que `/abort` et `/quit` | N'arrête pas la génération. Affiche `⏳ Queued (N) — runs once this turn ends · /abort or Ctrl+C stops the turn`, `N` étant le nombre de lignes en attente. Une fois le tour terminé, les lignes en attente partent dans l'ordre où elles ont été tapées, chacune réaffichée après l'invite `>` comme si elle y avait été tapée. |
81+| Une ligne vide | Ignorée. |
82+| `/abort`, `Ctrl+C` | Arrêtent la génération ; les lignes en attente partent ensuite. |
83+| Fin de l'entrée (la fin d'un pipe) | La génération continue ; les lignes en attente partent, puis `mm` se termine. |
81 84
82 ## Raccourcis clavier85 ## Raccourcis clavier
83 86
@@ -130,9 +133,9 @@ Les touches :
130 133
131 | Touche | Action |134 | Touche | Action |
132 |--------|--------|135 |--------|--------|
133-| `Entrée` | Envoie la zone (rien sur une zone vide). Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |136+| `Entrée` | Envoie la zone (rien sur une zone vide). Pendant un tour, le texte ne l'arrête pas : il est mis en attente (`⏳ Queued (N) — runs once this turn ends · Esc stops the turn`, la règle affiche `<état> · N queued`) et les entrées en attente partent dans l'ordre une fois le tour rangé ; `/quit`, `/abort` et `/shell` s'exécutent tout de suite. Avec le sélecteur ouvert sur un nom incomplet, le complète à la place. |
134 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |137 | `Ctrl-J`, `Alt-Entrée` | Insère un saut de ligne dans la zone. |
135-| `Échap` | Annule le tour en cours (comme `/abort`) ; quand aucun tour ne tourne et que la zone n'est pas vide, vide la zone à la place ; ferme le sélecteur ; refuse une permission en attente. |138+| `Échap` | Annule le tour en cours (comme `/abort`) — les entrées en attente partent ensuite ; quand aucun tour ne tourne et que la zone n'est pas vide, vide la zone à la place ; ferme le sélecteur ; refuse une permission en attente. |
136 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |139 | `Ctrl-C` | Annule le tour en cours ; quitte au repos — et quitte aussi lorsque le tour a déjà reçu l'ordre de s'arrêter et ne l'a pas fait. |
137 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert (`/`, `@` ou `$`). |140 | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert (`/`, `@` ou `$`). |
138 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées. Depuis la première rangée, `↑` rappelle la question précédente (sessions activées : cette session, puis les précédentes, de la plus récente à la plus ancienne) ; depuis la dernière rangée pendant un rappel, `↓` la suivante, puis le texte tapé avant. Sans rien à rappeler, ils font défiler la conversation depuis la première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |141 | `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées. Depuis la première rangée, `↑` rappelle la question précédente (sessions activées : cette session, puis les précédentes, de la plus récente à la plus ancienne) ; depuis la dernière rangée pendant un rappel, `↓` la suivante, puis le texte tapé avant. Sans rien à rappeler, ils font défiler la conversation depuis la première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. |
modified internal/agent/agent.go +45 -42
@@ -90,25 +90,34 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
9090 }
9191
9292 generating := false
93+ // queue holds what was typed during a generation (ticket 155): it runs
94+ // here, in order, before anything new is read.
95+ queue := &inbox{}
9396
9497 for {
9598 fmt.Print("\n> ")
9699
97- // 1. Wait for the next user input OR a quit signal.
98- var input string
100+ // 1. Take the next queued line, else wait for the next user input OR
101+ // a quit signal. A queued line is echoed after the prompt, so the
102+ // transcript reads as if it had been typed there.
103+ input, queued := queue.pop()
99104 var ok bool
100105
101- select {
102- case <-sigCh:
103- if !generating {
104- fmt.Println("\nGoodbye.")
105- return
106- }
106+ if queued {
107+ fmt.Println(input)
108+ } else {
109+ select {
110+ case <-sigCh:
111+ if !generating {
112+ fmt.Println("\nGoodbye.")
113+ return
114+ }
107115 // If generating, we let the generation loop handle the SIGINT.
108116 // We fall through to wait for the next input (which will be the prompt).
109- case input, ok = <-inputCh:
110- if !ok {
111- return
117+ case input, ok = <-inputCh:
118+ if !ok {
119+ return
120+ }
112121 }
113122 }
114123
@@ -129,9 +138,9 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
129138 // /resume — are only reachable here, between two generations: Genkit
130139 // holds its own copy of the conversation while it runs, and a
131140 // tool_call/tool_result pair in flight must not move under it. Typed
132- // DURING a generation, such a command goes through the "new command
133- // received" branch below, which cancels the turn and re-injects it
134- // here — so it always applies to the finished history.
141+ // DURING a generation, such a command waits in the queue (ticket 155)
142+ // and comes back here once the turn has settled — so it always
143+ // applies to the finished history.
135144 // /ask-bob and its family: another agent answers, and its answer
136145 // joins the history for the next question. Between two generations
137146 // too, for the same reason as the commands below.
@@ -141,7 +150,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
141150 continue
142151 }
143152 var quit bool
144- messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh, prompt: prompt}.run(ctx, req, messages)
153+ messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh, prompt: prompt, queue: queue}.run(ctx, req, messages)
145154 if quit {
146155 fmt.Println("Goodbye.")
147156 return
@@ -218,7 +227,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
218227 resultCh <- genResult{resp: resp, history: history, err: err}
219228 }()
220229
221- // 4. The "Waiting Room": Listen for completion, interruption, OR new command.
230+ // 4. The "Waiting Room": Listen for completion, interruption, OR new
231+ // command. typing goes nil when stdin closes (the end of a pipe), so
232+ // the closed channel is not read in a loop; the main loop sees the
233+ // end of the input once the queue is empty.
234+ typing := inputCh
222235 generationRunning := true
223236 for generationRunning {
224237 select {
@@ -311,46 +324,36 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
311324 case req := <-prompt.requestCh():
312325 prompt.show(req)
313326
314- case nextInput := <-inputCh:
327+ case nextInput, open := <-typing:
328+ if !open {
329+ typing = nil
330+ continue
331+ }
315332 // A line typed while a tool waits for permission is its answer;
316- // anything else stops the turn, question included.
333+ // /abort and /quit stop the turn; anything else waits for it.
317334 if prompt.answer(nextInput) {
318335 continue
319336 }
320- prompt.drop()
321- spinner.Stop() // le spinner tourne : on lui reprend la ligne
322337 switch {
323338 case command.Is(nextInput, command.Abort):
339+ prompt.drop()
340+ spinner.Stop() // le spinner tourne : on lui reprend la ligne
324341 cancelGen()
325342 fmt.Println("🛑 Aborting...")
326343 case command.Is(nextInput, command.Quit):
344+ prompt.drop()
345+ spinner.Stop()
327346 cancelGen()
328347 fmt.Println("Goodbye.")
329348 return
330349 default:
331- // A new message arrived! Cancel current task and re-inject the message.
332- fmt.Println("🔄 New command received. Cancelling current task...")
333- cancelGen()
334-
335- // Wait for current goroutine to clean up before re-injecting.
336- res := <-resultCh
337-
338- // Same principle as above: the commands already run stay in
339- // the history, otherwise the next question starts again
340- // without knowing what was done.
341- asked := messages
342- messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
343- save(keep, messages)
344- if res.err != nil && genCtx.Err() != context.Canceled {
345- explained := e.Explain(res.err)
346- fmt.Println("[error: " + explained + "]")
347- record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
350+ // Ticket 155: a new message no longer cancels the turn —
351+ // the answer being written is often the one it builds on.
352+ // It waits in the queue and runs once the turn has settled.
353+ if notice := queue.push(nextInput); notice != "" {
354+ spinner.Stop()
355+ fmt.Println("\n" + notice)
348356 }
349- generationRunning = false
350- generating = false
351-
352- // Re-inject the command into the main loop.
353- go func(cmd string) { inputCh <- cmd }(nextInput)
354357 }
355358 }
356359 }
@@ -90,25 +90,34 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
90 }90 }
91 91
92 generating := false92 generating := false
93+ // queue holds what was typed during a generation (ticket 155): it runs
94+ // here, in order, before anything new is read.
95+ queue := &inbox{}
93 96
94 for {97 for {
95 fmt.Print("\n> ")98 fmt.Print("\n> ")
96 99
97- // 1. Wait for the next user input OR a quit signal.100+ // 1. Take the next queued line, else wait for the next user input OR
98- var input string101+ // a quit signal. A queued line is echoed after the prompt, so the
102+ // transcript reads as if it had been typed there.
103+ input, queued := queue.pop()
99 var ok bool104 var ok bool
100 105
101- select {106+ if queued {
102- case <-sigCh:107+ fmt.Println(input)
103- if !generating {108+ } else {
104- fmt.Println("\nGoodbye.")109+ select {
105- return110+ case <-sigCh:
106- }111+ if !generating {
112+ fmt.Println("\nGoodbye.")
113+ return
114+ }
107 // If generating, we let the generation loop handle the SIGINT.115 // If generating, we let the generation loop handle the SIGINT.
108 // We fall through to wait for the next input (which will be the prompt).116 // We fall through to wait for the next input (which will be the prompt).
109- case input, ok = <-inputCh:117+ case input, ok = <-inputCh:
110- if !ok {118+ if !ok {
111- return119+ return
120+ }
112 }121 }
113 }122 }
114 123
@@ -129,9 +138,9 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
129 // /resume — are only reachable here, between two generations: Genkit138 // /resume — are only reachable here, between two generations: Genkit
130 // holds its own copy of the conversation while it runs, and a139 // holds its own copy of the conversation while it runs, and a
131 // tool_call/tool_result pair in flight must not move under it. Typed140 // tool_call/tool_result pair in flight must not move under it. Typed
132- // DURING a generation, such a command goes through the "new command141+ // DURING a generation, such a command waits in the queue (ticket 155)
133- // received" branch below, which cancels the turn and re-injects it142+ // and comes back here once the turn has settled — so it always
134- // here — so it always applies to the finished history.143+ // applies to the finished history.
135 // /ask-bob and its family: another agent answers, and its answer144 // /ask-bob and its family: another agent answers, and its answer
136 // joins the history for the next question. Between two generations145 // joins the history for the next question. Between two generations
137 // too, for the same reason as the commands below.146 // too, for the same reason as the commands below.
@@ -141,7 +150,7 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
141 continue150 continue
142 }151 }
143 var quit bool152 var quit bool
144- messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh, prompt: prompt}.run(ctx, req, messages)153+ messages, quit = asking{pool: pool, cwd: cwd, keep: keep, sigCh: sigCh, inputCh: inputCh, prompt: prompt, queue: queue}.run(ctx, req, messages)
145 if quit {154 if quit {
146 fmt.Println("Goodbye.")155 fmt.Println("Goodbye.")
147 return156 return
@@ -218,7 +227,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
218 resultCh <- genResult{resp: resp, history: history, err: err}227 resultCh <- genResult{resp: resp, history: history, err: err}
219 }()228 }()
220 229
221- // 4. The "Waiting Room": Listen for completion, interruption, OR new command.230+ // 4. The "Waiting Room": Listen for completion, interruption, OR new
231+ // command. typing goes nil when stdin closes (the end of a pipe), so
232+ // the closed channel is not read in a loop; the main loop sees the
233+ // end of the input once the queue is empty.
234+ typing := inputCh
222 generationRunning := true235 generationRunning := true
223 for generationRunning {236 for generationRunning {
224 select {237 select {
@@ -311,46 +324,36 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe
311 case req := <-prompt.requestCh():324 case req := <-prompt.requestCh():
312 prompt.show(req)325 prompt.show(req)
313 326
314- case nextInput := <-inputCh:327+ case nextInput, open := <-typing:
328+ if !open {
329+ typing = nil
330+ continue
331+ }
315 // A line typed while a tool waits for permission is its answer;332 // A line typed while a tool waits for permission is its answer;
316- // anything else stops the turn, question included.333+ // /abort and /quit stop the turn; anything else waits for it.
317 if prompt.answer(nextInput) {334 if prompt.answer(nextInput) {
318 continue335 continue
319 }336 }
320- prompt.drop()
321- spinner.Stop() // le spinner tourne : on lui reprend la ligne
322 switch {337 switch {
323 case command.Is(nextInput, command.Abort):338 case command.Is(nextInput, command.Abort):
339+ prompt.drop()
340+ spinner.Stop() // le spinner tourne : on lui reprend la ligne
324 cancelGen()341 cancelGen()
325 fmt.Println("🛑 Aborting...")342 fmt.Println("🛑 Aborting...")
326 case command.Is(nextInput, command.Quit):343 case command.Is(nextInput, command.Quit):
344+ prompt.drop()
345+ spinner.Stop()
327 cancelGen()346 cancelGen()
328 fmt.Println("Goodbye.")347 fmt.Println("Goodbye.")
329 return348 return
330 default:349 default:
331- // A new message arrived! Cancel current task and re-inject the message.350+ // Ticket 155: a new message no longer cancels the turn —
332- fmt.Println("🔄 New command received. Cancelling current task...")351+ // the answer being written is often the one it builds on.
333- cancelGen()352+ // It waits in the queue and runs once the turn has settled.
334-353+ if notice := queue.push(nextInput); notice != "" {
335- // Wait for current goroutine to clean up before re-injecting.354+ spinner.Stop()
336- res := <-resultCh355+ fmt.Println("\n" + notice)
337-
338- // Same principle as above: the commands already run stay in
339- // the history, otherwise the next question starts again
340- // without knowing what was done.
341- asked := messages
342- messages = session.AfterTurn(messages, before, res.resp, res.history, res.err)
343- save(keep, messages)
344- if res.err != nil && genCtx.Err() != context.Canceled {
345- explained := e.Explain(res.err)
346- fmt.Println("[error: " + explained + "]")
347- record(keep, messages, store.NewFailure(asked, store.FailGeneration, explained, res.err))
348 }356 }
349- generationRunning = false
350- generating = false
351-
352- // Re-inject the command into the main loop.
353- go func(cmd string) { inputCh <- cmd }(nextInput)
354 }357 }
355 }358 }
356 }359 }
modified internal/agent/ask.go +10 -2
@@ -22,6 +22,7 @@ type asking struct {
2222 sigCh <-chan os.Signal
2323 inputCh <-chan string
2424 prompt *linePrompt // the asked agent's calls may need the user's answer
25+ queue *inbox // what is typed meanwhile waits here (ticket 155)
2526 }
2627
2728 // run asks req.Agent, streaming its answer and tool calls, and returns the
@@ -41,6 +42,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
4142 done <- result{exchange, err}
4243 }()
4344
45+ typing := a.inputCh // nil once stdin closes: see Run's waiting room
4446 for {
4547 select {
4648 case res := <-done:
@@ -51,7 +53,11 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
5153 cancel()
5254 case req := <-a.prompt.requestCh():
5355 a.prompt.show(req)
54- case input := <-a.inputCh:
56+ case input, open := <-typing:
57+ if !open {
58+ typing = nil
59+ continue
60+ }
5561 if a.prompt.answer(input) {
5662 continue
5763 }
@@ -65,7 +71,9 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
6571 quit = true
6672 cancel()
6773 default:
68- fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent)
74+ if notice := a.queue.push(input); notice != "" {
75+ fmt.Println(notice)
76+ }
6977 }
7078 }
7179 }
@@ -22,6 +22,7 @@ type asking struct {
22 sigCh <-chan os.Signal22 sigCh <-chan os.Signal
23 inputCh <-chan string23 inputCh <-chan string
24 prompt *linePrompt // the asked agent's calls may need the user's answer24 prompt *linePrompt // the asked agent's calls may need the user's answer
25+ queue *inbox // what is typed meanwhile waits here (ticket 155)
25 }26 }
26 27
27 // run asks req.Agent, streaming its answer and tool calls, and returns the28 // run asks req.Agent, streaming its answer and tool calls, and returns the
@@ -41,6 +42,7 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
41 done <- result{exchange, err}42 done <- result{exchange, err}
42 }()43 }()
43 44
45+ typing := a.inputCh // nil once stdin closes: see Run's waiting room
44 for {46 for {
45 select {47 select {
46 case res := <-done:48 case res := <-done:
@@ -51,7 +53,11 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
51 cancel()53 cancel()
52 case req := <-a.prompt.requestCh():54 case req := <-a.prompt.requestCh():
53 a.prompt.show(req)55 a.prompt.show(req)
54- case input := <-a.inputCh:56+ case input, open := <-typing:
57+ if !open {
58+ typing = nil
59+ continue
60+ }
55 if a.prompt.answer(input) {61 if a.prompt.answer(input) {
56 continue62 continue
57 }63 }
@@ -65,7 +71,9 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai.
65 quit = true71 quit = true
66 cancel()72 cancel()
67 default:73 default:
68- fmt.Printf("⚠️ %s is still answering — /abort to stop it.\n", req.Agent)74+ if notice := a.queue.push(input); notice != "" {
75+ fmt.Println(notice)
76+ }
69 }77 }
70 }78 }
71 }79 }
modified internal/agent/ask_test.go +40 -1
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "os"
77 "testing"
8+ "time"
89
910 "mm/internal/command"
1011 "mm/internal/config"
@@ -19,6 +20,7 @@ type fakeRunner struct {
1920 answer string
2021 fail error
2122 block bool
23+ wait chan struct{} // answer once the test closes it
2224 }
2325
2426 func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
@@ -26,6 +28,9 @@ func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Mes
2628 <-ctx.Done()
2729 return nil, ctx.Err()
2830 }
31+ if f.wait != nil {
32+ <-f.wait
33+ }
2934 if f.fail != nil {
3035 return nil, f.fail
3136 }
@@ -41,7 +46,7 @@ func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) {
4146 config.Cfg.Sessions.Enabled = false
4247 config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": {Path: "/nowhere/agent.bob.yaml"}}
4348 sig, in := make(chan os.Signal, 1), make(chan string, 1)
44- return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in
49+ return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in, queue: &inbox{}}, sig, in
4550 }
4651
4752 var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")}
@@ -81,3 +86,37 @@ func TestLineAskStops(t *testing.T) {
8186 t.Errorf("failure: %d messages", len(got))
8287 }
8388 }
89+
90+// A question typed while bob answers waits for the main loop (ticket 155)
91+// instead of being refused; the ask goes on and keeps its exchange.
92+func TestLineAskQueuesWhatIsTypedMeanwhile(t *testing.T) {
93+ release := make(chan struct{})
94+ a, _, in := lineAsk(t, fakeRunner{answer: "four", wait: release})
95+ in <- "and 3+3?"
96+ go func() {
97+ for len(in) > 0 {
98+ time.Sleep(time.Millisecond)
99+ }
100+ time.Sleep(5 * time.Millisecond) // the line is read before bob answers
101+ close(release)
102+ }()
103+ got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory)
104+ if quit || len(got) != 3 {
105+ t.Errorf("run = %d messages, quit %v", len(got), quit)
106+ }
107+ if line, ok := a.queue.pop(); !ok || line != "and 3+3?" {
108+ t.Errorf("queue = %q, %v", line, ok)
109+ }
110+}
111+
112+// stdin closing during an ask (the end of a pipe) does not spin on the
113+// closed channel nor stop the ask.
114+func TestLineAskSurvivesTheEndOfStdin(t *testing.T) {
115+ release := make(chan struct{})
116+ a, _, in := lineAsk(t, fakeRunner{answer: "four", wait: release})
117+ close(in)
118+ time.AfterFunc(10*time.Millisecond, func() { close(release) })
119+ if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory); len(got) != 3 {
120+ t.Errorf("run = %d messages", len(got))
121+ }
122+}
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "os"6 "os"
7 "testing"7 "testing"
8+ "time"
8 9
9 "mm/internal/command"10 "mm/internal/command"
10 "mm/internal/config"11 "mm/internal/config"
@@ -19,6 +20,7 @@ type fakeRunner struct {
19 answer string20 answer string
20 fail error21 fail error
21 block bool22 block bool
23+ wait chan struct{} // answer once the test closes it
22 }24 }
23 25
24 func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {26 func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Message, _ string, sink ui.Sink) ([]*ai.Message, error) {
@@ -26,6 +28,9 @@ func (f fakeRunner) Run(ctx context.Context, req command.AskRequest, _ []*ai.Mes
26 <-ctx.Done()28 <-ctx.Done()
27 return nil, ctx.Err()29 return nil, ctx.Err()
28 }30 }
31+ if f.wait != nil {
32+ <-f.wait
33+ }
29 if f.fail != nil {34 if f.fail != nil {
30 return nil, f.fail35 return nil, f.fail
31 }36 }
@@ -41,7 +46,7 @@ func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) {
41 config.Cfg.Sessions.Enabled = false46 config.Cfg.Sessions.Enabled = false
42 config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": {Path: "/nowhere/agent.bob.yaml"}}47 config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": {Path: "/nowhere/agent.bob.yaml"}}
43 sig, in := make(chan os.Signal, 1), make(chan string, 1)48 sig, in := make(chan os.Signal, 1), make(chan string, 1)
44- return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in}, sig, in49+ return asking{pool: r, cwd: t.TempDir(), keep: store.NewKeeper(t.TempDir()), sigCh: sig, inputCh: in, queue: &inbox{}}, sig, in
45 }50 }
46 51
47 var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")}52 var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")}
@@ -81,3 +86,37 @@ func TestLineAskStops(t *testing.T) {
81 t.Errorf("failure: %d messages", len(got))86 t.Errorf("failure: %d messages", len(got))
82 }87 }
83 }88 }
89+
90+// A question typed while bob answers waits for the main loop (ticket 155)
91+// instead of being refused; the ask goes on and keeps its exchange.
92+func TestLineAskQueuesWhatIsTypedMeanwhile(t *testing.T) {
93+ release := make(chan struct{})
94+ a, _, in := lineAsk(t, fakeRunner{answer: "four", wait: release})
95+ in <- "and 3+3?"
96+ go func() {
97+ for len(in) > 0 {
98+ time.Sleep(time.Millisecond)
99+ }
100+ time.Sleep(5 * time.Millisecond) // the line is read before bob answers
101+ close(release)
102+ }()
103+ got, quit := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory)
104+ if quit || len(got) != 3 {
105+ t.Errorf("run = %d messages, quit %v", len(got), quit)
106+ }
107+ if line, ok := a.queue.pop(); !ok || line != "and 3+3?" {
108+ t.Errorf("queue = %q, %v", line, ok)
109+ }
110+}
111+
112+// stdin closing during an ask (the end of a pipe) does not spin on the
113+// closed channel nor stop the ask.
114+func TestLineAskSurvivesTheEndOfStdin(t *testing.T) {
115+ release := make(chan struct{})
116+ a, _, in := lineAsk(t, fakeRunner{answer: "four", wait: release})
117+ close(in)
118+ time.AfterFunc(10*time.Millisecond, func() { close(release) })
119+ if got, _ := a.run(context.Background(), command.AskRequest{Agent: "bob", Prompt: "2+2?"}, askHistory); len(got) != 3 {
120+ t.Errorf("run = %d messages", len(got))
121+ }
122+}
modified internal/agent/prompt.go +1 -1
@@ -134,7 +134,7 @@ func (p *linePrompt) answer(line string) bool {
134134 }
135135
136136 // drop abandons the pending request, if any: the turn it belongs to is being
137-// stopped (Ctrl+C, /abort, a new question, the turn ended).
137+// stopped (Ctrl+C, /abort, /quit, the turn ended).
138138 func (p *linePrompt) drop() {
139139 if p == nil || p.pending == nil {
140140 return
@@ -134,7 +134,7 @@ func (p *linePrompt) answer(line string) bool {
134 }134 }
135 135
136 // drop abandons the pending request, if any: the turn it belongs to is being136 // drop abandons the pending request, if any: the turn it belongs to is being
137-// stopped (Ctrl+C, /abort, a new question, the turn ended).137+// stopped (Ctrl+C, /abort, /quit, the turn ended).
138 func (p *linePrompt) drop() {138 func (p *linePrompt) drop() {
139 if p == nil || p.pending == nil {139 if p == nil || p.pending == nil {
140 return140 return
added internal/agent/queue.go +38 -0
new file mode 100644
@@ -0,0 +1,38 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// inbox holds the lines typed while a generation or an /ask runs (ticket
9+// 155). They used to cancel the turn in progress; they now wait, and the
10+// main loop runs them in order once the turn has settled, as if they had
11+// been typed then. /abort, /quit, Ctrl+C and a permission answer are not
12+// queued: the waiting rooms handle them first.
13+//
14+// var q inbox
15+// fmt.Println(q.push("and the tests?")) // ⏳ Queued (1) — runs once this turn ends …
16+// line, ok := q.pop() // "and the tests?", true
17+type inbox struct{ lines []string }
18+
19+// push queues a line typed during a job and returns the notice to print. A
20+// blank line is somebody pressing Enter, not a question: it is dropped and
21+// the notice is "".
22+func (q *inbox) push(line string) string {
23+ if strings.TrimSpace(line) == "" {
24+ return ""
25+ }
26+ q.lines = append(q.lines, line)
27+ return fmt.Sprintf("⏳ Queued (%d) — runs once this turn ends · /abort or Ctrl+C stops the turn", len(q.lines))
28+}
29+
30+// pop takes the oldest queued line; false when nothing waits.
31+func (q *inbox) pop() (string, bool) {
32+ if len(q.lines) == 0 {
33+ return "", false
34+ }
35+ line := q.lines[0]
36+ q.lines = q.lines[1:]
37+ return line, true
38+}
new file mode 100644
@@ -0,0 +1,38 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "strings"
6+)
7+
8+// inbox holds the lines typed while a generation or an /ask runs (ticket
9+// 155). They used to cancel the turn in progress; they now wait, and the
10+// main loop runs them in order once the turn has settled, as if they had
11+// been typed then. /abort, /quit, Ctrl+C and a permission answer are not
12+// queued: the waiting rooms handle them first.
13+//
14+// var q inbox
15+// fmt.Println(q.push("and the tests?")) // ⏳ Queued (1) — runs once this turn ends …
16+// line, ok := q.pop() // "and the tests?", true
17+type inbox struct{ lines []string }
18+
19+// push queues a line typed during a job and returns the notice to print. A
20+// blank line is somebody pressing Enter, not a question: it is dropped and
21+// the notice is "".
22+func (q *inbox) push(line string) string {
23+ if strings.TrimSpace(line) == "" {
24+ return ""
25+ }
26+ q.lines = append(q.lines, line)
27+ return fmt.Sprintf("⏳ Queued (%d) — runs once this turn ends · /abort or Ctrl+C stops the turn", len(q.lines))
28+}
29+
30+// pop takes the oldest queued line; false when nothing waits.
31+func (q *inbox) pop() (string, bool) {
32+ if len(q.lines) == 0 {
33+ return "", false
34+ }
35+ line := q.lines[0]
36+ q.lines = q.lines[1:]
37+ return line, true
38+}
added internal/agent/queue_test.go +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// Lines typed during a job come back oldest first, each notice counting
9+// what waits; a blank line is not a question and is not queued.
10+func TestInboxKeepsTheOrderAndDropsBlankLines(t *testing.T) {
11+ var q inbox
12+ if n := q.push("first"); !strings.HasPrefix(n, "⏳ Queued (1) — runs once this turn ends") {
13+ t.Errorf("notice %q", n)
14+ }
15+ if n := q.push(" "); n != "" {
16+ t.Errorf("a blank line was queued: %q", n)
17+ }
18+ if n := q.push("/compact"); !strings.HasPrefix(n, "⏳ Queued (2)") {
19+ t.Errorf("notice %q", n)
20+ }
21+ for _, want := range []string{"first", "/compact"} {
22+ if got, ok := q.pop(); !ok || got != want {
23+ t.Errorf("pop = %q, %v, want %q", got, ok, want)
24+ }
25+ }
26+ if got, ok := q.pop(); ok {
27+ t.Errorf("pop on an empty queue = %q", got)
28+ }
29+}
new file mode 100644
@@ -0,0 +1,29 @@
1+package agent
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+// Lines typed during a job come back oldest first, each notice counting
9+// what waits; a blank line is not a question and is not queued.
10+func TestInboxKeepsTheOrderAndDropsBlankLines(t *testing.T) {
11+ var q inbox
12+ if n := q.push("first"); !strings.HasPrefix(n, "⏳ Queued (1) — runs once this turn ends") {
13+ t.Errorf("notice %q", n)
14+ }
15+ if n := q.push(" "); n != "" {
16+ t.Errorf("a blank line was queued: %q", n)
17+ }
18+ if n := q.push("/compact"); !strings.HasPrefix(n, "⏳ Queued (2)") {
19+ t.Errorf("notice %q", n)
20+ }
21+ for _, want := range []string{"first", "/compact"} {
22+ if got, ok := q.pop(); !ok || got != want {
23+ t.Errorf("pop = %q, %v, want %q", got, ok, want)
24+ }
25+ }
26+ if got, ok := q.pop(); ok {
27+ t.Errorf("pop on an empty queue = %q", got)
28+ }
29+}
modified internal/tui/commands.go +8 -7
@@ -17,8 +17,9 @@ import (
1717 )
1818
1919 // submit is what Enter does with the box: a slash command or a question.
20-// During a turn it cancels the turn and keeps the text for when the turn has
21-// settled — the REPL's "new command received" rule.
20+// During a turn the text waits in a queue and runs once the turn has settled
21+// (ticket 155): a question typed meanwhile no longer cuts the answer being
22+// written. Esc stops the turn; what is queued then runs at once.
2223 //
2324 // The two control commands are the exception, and it is the whole point of
2425 // ticket 26: /quit and /abort are about the turn in progress, so queueing
@@ -30,8 +31,8 @@ func (r *runner) submit(p Prompt) {
3031 return
3132 }
3233 if r.isBusy() {
33- r.pending = p
34- r.cancelTurn()
34+ r.pending = append(r.pending, p)
35+ r.t.Notice(fmt.Sprintf("⏳ Queued (%d) — runs once this turn ends · Esc stops the turn", len(r.pending)))
3536 return
3637 }
3738 r.dispatch(p)
@@ -39,8 +40,8 @@ func (r *runner) submit(p Prompt) {
3940
4041 // control runs the commands that must never wait for the turn in progress,
4142 // and reports whether it ran one. Everything else needs the session the turn
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+// is still writing to, so it waits in the queue. /shell touches no session:
44+// it runs at once, and the turn goes on under it.
4445 func (r *runner) control(p Prompt) bool {
4546 cmd, _, ok := command.Parse(p.Typed)
4647 if !ok {
@@ -127,7 +128,7 @@ func (r *runner) unload(model string) {
127128
128129 // help lists the window's commands and keys.
129130 func (r *runner) help() {
130- r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn (idle: clears the box) · ↑/↓ recall earlier questions (sessions on) · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
131+ r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends (during a turn: queues) · Ctrl-J new line · Esc cancels the turn (idle: clears the box) · ↑/↓ recall earlier questions (sessions on) · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
131132 }
132133
133134 // quitNow ends the loop on its next turn.
@@ -17,8 +17,9 @@ import (
17 )17 )
18 18
19 // submit is what Enter does with the box: a slash command or a question.19 // submit is what Enter does with the box: a slash command or a question.
20-// During a turn it cancels the turn and keeps the text for when the turn has20+// During a turn the text waits in a queue and runs once the turn has settled
21-// settled — the REPL's "new command received" rule.21+// (ticket 155): a question typed meanwhile no longer cuts the answer being
22+// written. Esc stops the turn; what is queued then runs at once.
22 //23 //
23 // The two control commands are the exception, and it is the whole point of24 // The two control commands are the exception, and it is the whole point of
24 // ticket 26: /quit and /abort are about the turn in progress, so queueing25 // ticket 26: /quit and /abort are about the turn in progress, so queueing
@@ -30,8 +31,8 @@ func (r *runner) submit(p Prompt) {
30 return31 return
31 }32 }
32 if r.isBusy() {33 if r.isBusy() {
33- r.pending = p34+ r.pending = append(r.pending, p)
34- r.cancelTurn()35+ r.t.Notice(fmt.Sprintf("⏳ Queued (%d) — runs once this turn ends · Esc stops the turn", len(r.pending)))
35 return36 return
36 }37 }
37 r.dispatch(p)38 r.dispatch(p)
@@ -39,8 +40,8 @@ func (r *runner) submit(p Prompt) {
39 40
40 // control runs the commands that must never wait for the turn in progress,41 // 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 turn42 // and reports whether it ran one. Everything else needs the session the turn
42-// is still writing to, so it keeps queueing. /shell touches no session: it43+// is still writing to, so it waits in the queue. /shell touches no session:
43-// runs at once, and the turn goes on under it instead of being cancelled.44+// it runs at once, and the turn goes on under it.
44 func (r *runner) control(p Prompt) bool {45 func (r *runner) control(p Prompt) bool {
45 cmd, _, ok := command.Parse(p.Typed)46 cmd, _, ok := command.Parse(p.Typed)
46 if !ok {47 if !ok {
@@ -127,7 +128,7 @@ func (r *runner) unload(model string) {
127 128
128 // help lists the window's commands and keys.129 // help lists the window's commands and keys.
129 func (r *runner) help() {130 func (r *runner) help() {
130- r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends · Ctrl-J new line · Esc cancels the turn (idle: clears the box) · ↑/↓ recall earlier questions (sessions on) · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")131+ r.t.Notice(command.Usage(command.Window) + "\n\nEnter sends (during a turn: queues) · Ctrl-J new line · Esc cancels the turn (idle: clears the box) · ↑/↓ recall earlier questions (sessions on) · Ctrl-C cancels then quits (twice to leave a turn that will not stop) · Ctrl-L clears · Ctrl-Y copies the last answer · F2 mouse on/off (off: select text with the mouse as usual) · PgUp/PgDn scroll · Tab moves the focus · @path attaches a file")
131 }132 }
132 133
133 // quitNow ends the loop on its next turn.134 // quitNow ends the loop on its next turn.
modified internal/tui/draw.go +3 -1
@@ -97,8 +97,10 @@ func (v *View) ruleLabel() string {
9797 switch {
9898 case v.ask != nil:
9999 return " a allow once · s allow for this session · r / Esc reject · n never "
100+ case v.busy != "" && v.queued > 0:
101+ return fmt.Sprintf(" %c %s · %d queued — Esc stops it · Enter queues more ", v.spinner(), v.busy, v.queued)
100102 case v.busy != "":
101- return fmt.Sprintf(" %c %s — Esc stops it · Enter sends the next question ", v.spinner(), v.busy)
103+ return fmt.Sprintf(" %c %s — Esc stops it · Enter queues a question ", v.spinner(), v.busy)
102104 case !v.onInput:
103105 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
104106 default:
@@ -97,8 +97,10 @@ func (v *View) ruleLabel() string {
97 switch {97 switch {
98 case v.ask != nil:98 case v.ask != nil:
99 return " a allow once · s allow for this session · r / Esc reject · n never "99 return " a allow once · s allow for this session · r / Esc reject · n never "
100+ case v.busy != "" && v.queued > 0:
101+ return fmt.Sprintf(" %c %s · %d queued — Esc stops it · Enter queues more ", v.spinner(), v.busy, v.queued)
100 case v.busy != "":102 case v.busy != "":
101- return fmt.Sprintf(" %c %s — Esc stops it · Enter sends the next question ", v.spinner(), v.busy)103+ return fmt.Sprintf(" %c %s — Esc stops it · Enter queues a question ", v.spinner(), v.busy)
102 case !v.onInput:104 case !v.onInput:
103 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "105 return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box "
104 default:106 default:
modified internal/tui/shell_test.go +2 -2
@@ -116,8 +116,8 @@ func TestRunnerShellDuringATurn(t *testing.T) {
116116 if *suspended != 1 {
117117 t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended)
118118 }
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)
119+ if r.cancelled.Load() || r.queued() != 0 {
120+ t.Errorf("the turn was cancelled (%v) or /shell queued (%+v)", r.cancelled.Load(), r.pending)
121121 }
122122 if !strings.Contains(notices(tr), "🐚 Back from the shell.") {
123123 t.Errorf("notices:\n%s", notices(tr))
@@ -116,8 +116,8 @@ func TestRunnerShellDuringATurn(t *testing.T) {
116 if *suspended != 1 {116 if *suspended != 1 {
117 t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended)117 t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended)
118 }118 }
119- if r.cancelled.Load() || r.pending.Typed != "" {119+ if r.cancelled.Load() || r.queued() != 0 {
120- t.Errorf("the turn was cancelled (%v) or /shell queued (%q)", r.cancelled.Load(), r.pending.Typed)120+ t.Errorf("the turn was cancelled (%v) or /shell queued (%+v)", r.cancelled.Load(), r.pending)
121 }121 }
122 if !strings.Contains(notices(tr), "🐚 Back from the shell.") {122 if !strings.Contains(notices(tr), "🐚 Back from the shell.") {
123 t.Errorf("notices:\n%s", notices(tr))123 t.Errorf("notices:\n%s", notices(tr))
modified internal/tui/tui.go +1 -0
@@ -136,6 +136,7 @@ func (a *app) frame() {
136136 a.v.syncForm(a.r.currentForm())
137137 a.v.version = a.r.opts.Version
138138 a.v.struggle = a.r.struggles.badge()
139+ a.v.queued = a.r.queued()
139140 a.v.draw()
140141 }
141142
@@ -136,6 +136,7 @@ func (a *app) frame() {
136 a.v.syncForm(a.r.currentForm())136 a.v.syncForm(a.r.currentForm())
137 a.v.version = a.r.opts.Version137 a.v.version = a.r.opts.Version
138 a.v.struggle = a.r.struggles.badge()138 a.v.struggle = a.r.struggles.badge()
139+ a.v.queued = a.r.queued()
139 a.v.draw()140 a.v.draw()
140 }141 }
141 142
modified internal/tui/turn.go +17 -5
@@ -83,8 +83,8 @@ type outcome struct {
8383 resp *ai.ModelResponse
8484 history []*ai.Message
8585 err error
86- // cancelled says the turn's context was cut — by Esc, Ctrl-C or a new
87- // question — which is not a failure to report as one.
86+ // cancelled says the turn's context was cut — by Esc, Ctrl-C or /abort —
87+ // which is not a failure to report as one.
8888 cancelled bool
8989 line string
9090 changed bool
@@ -120,7 +120,9 @@ type runner struct {
120120 // way out rather than cancelling a context that is already cut.
121121 cancelled atomic.Bool
122122 results chan outcome
123- pending Prompt // input to run once the cancelled turn has settled
123+ // pending is what was sent during a job, oldest first: each entry runs
124+ // once the one before it has settled (ticket 155).
125+ pending []Prompt
124126 // asked is the question of the turn in progress, as the model reads it:
125127 // not in messages until the turn settles, yet the first one ↑ recalls.
126128 // A turn that fails before answering drops its question from the
@@ -290,12 +292,22 @@ func (r *runner) settle() {
290292 r.busy.Store("")
291293 r.cancel = nil
292294 r.cancelled.Store(false)
293- if p := r.pending; p.Typed != "" {
294- r.pending = Prompt{}
295+ r.runQueued()
296+}
297+
298+// runQueued dispatches the queue until a job starts: a command that answers
299+// at once (/help, /sessions…) lets the next entry run straight after it.
300+func (r *runner) runQueued() {
301+ for !r.isBusy() && len(r.pending) > 0 {
302+ p := r.pending[0]
303+ r.pending = r.pending[1:]
295304 r.dispatch(p)
296305 }
297306 }
298307
308+// queued is how many entries wait for the job in progress, for the rule.
309+func (r *runner) queued() int { return len(r.pending) }
310+
299311 // apply folds one outcome into the session and the transcript.
300312 func (r *runner) apply(res outcome) {
301313 if res.kind == "ask" {
@@ -83,8 +83,8 @@ type outcome struct {
83 resp *ai.ModelResponse83 resp *ai.ModelResponse
84 history []*ai.Message84 history []*ai.Message
85 err error85 err error
86- // cancelled says the turn's context was cut — by Esc, Ctrl-C or a new86+ // cancelled says the turn's context was cut — by Esc, Ctrl-C or /abort —
87- // question — which is not a failure to report as one.87+ // which is not a failure to report as one.
88 cancelled bool88 cancelled bool
89 line string89 line string
90 changed bool90 changed bool
@@ -120,7 +120,9 @@ type runner struct {
120 // way out rather than cancelling a context that is already cut.120 // way out rather than cancelling a context that is already cut.
121 cancelled atomic.Bool121 cancelled atomic.Bool
122 results chan outcome122 results chan outcome
123- pending Prompt // input to run once the cancelled turn has settled123+ // pending is what was sent during a job, oldest first: each entry runs
124+ // once the one before it has settled (ticket 155).
125+ pending []Prompt
124 // asked is the question of the turn in progress, as the model reads it:126 // asked is the question of the turn in progress, as the model reads it:
125 // not in messages until the turn settles, yet the first one ↑ recalls.127 // not in messages until the turn settles, yet the first one ↑ recalls.
126 // A turn that fails before answering drops its question from the128 // A turn that fails before answering drops its question from the
@@ -290,12 +292,22 @@ func (r *runner) settle() {
290 r.busy.Store("")292 r.busy.Store("")
291 r.cancel = nil293 r.cancel = nil
292 r.cancelled.Store(false)294 r.cancelled.Store(false)
293- if p := r.pending; p.Typed != "" {295+ r.runQueued()
294- r.pending = Prompt{}296+}
297+
298+// runQueued dispatches the queue until a job starts: a command that answers
299+// at once (/help, /sessions…) lets the next entry run straight after it.
300+func (r *runner) runQueued() {
301+ for !r.isBusy() && len(r.pending) > 0 {
302+ p := r.pending[0]
303+ r.pending = r.pending[1:]
295 r.dispatch(p)304 r.dispatch(p)
296 }305 }
297 }306 }
298 307
308+// queued is how many entries wait for the job in progress, for the rule.
309+func (r *runner) queued() int { return len(r.pending) }
310+
299 // apply folds one outcome into the session and the transcript.311 // apply folds one outcome into the session and the transcript.
300 func (r *runner) apply(res outcome) {312 func (r *runner) apply(res outcome) {
301 if res.kind == "ask" {313 if res.kind == "ask" {
modified internal/tui/turn_test.go +63 -15
@@ -33,6 +33,9 @@ type fakeModel struct {
3333 // background process holds open, an engine that stopped answering).
3434 deaf bool
3535 release chan struct{} // closed by the test to let a deaf call return
36+ // hold makes the first call wait until the test closes it, then answer:
37+ // a turn that is still writing when the next question is typed.
38+ hold chan struct{}
3639 fail error
3740 summary string
3841 tokens int
@@ -46,6 +49,9 @@ func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Mess
4649 <-m.release
4750 return nil, nil, errors.New("too late")
4851 }
52+ if m.calls.Load() == 1 && m.hold != nil {
53+ <-m.hold
54+ }
4955 if m.calls.Load() == 1 && m.blockFirst {
5056 <-ctx.Done()
5157 return nil, nil, ctx.Err()
@@ -159,20 +165,45 @@ func TestRunnerFailure(t *testing.T) {
159165 }
160166 }
161167
162-// Enter during a turn cancels it and runs the new text once the cancelled
163-// turn has settled; the cancelled turn is reported as aborted, not failed.
164-func TestRunnerCancelAndPending(t *testing.T) {
165- m := &fakeModel{blockFirst: true, answer: "done"}
168+// Enter during a turn queues the text (ticket 155): the turn writes its
169+// answer to the end, then the queued question runs.
170+func TestRunnerQueuesAQuestionTypedDuringATurn(t *testing.T) {
171+ m := &fakeModel{hold: make(chan struct{}), answer: "done"}
166172 r, tr := newTestRunner(t, m)
167173 r.submit(promptOf("first"))
168174 r.submit(promptOf("second"))
169- if r.pending.Typed != "second" {
170- t.Fatalf("pending %q", r.pending.Typed)
175+ if r.cancelled.Load() || r.queued() != 1 || r.pending[0].Typed != "second" {
176+ t.Fatalf("cancelled %v, queue %+v", r.cancelled.Load(), r.pending)
171177 }
172- waitIdle(t, r) // settles "first" (aborted) and dispatches "second"
173- if !r.isBusy() && r.pending.Typed != "" {
174- t.Fatalf("second not dispatched: busy %v pending %q", r.isBusy(), r.pending.Typed)
178+ if !strings.Contains(notices(tr), "⏳ Queued (1) — runs once this turn ends") {
179+ t.Errorf("notices:\n%s", notices(tr))
175180 }
181+ close(m.hold)
182+ waitIdle(t, r) // settles "first" and dispatches "second"
183+ waitIdle(t, r)
184+ if strings.Contains(notices(tr), "aborted") {
185+ t.Errorf("the first turn was aborted:\n%s", notices(tr))
186+ }
187+ // system, first, done, second, done
188+ if m.calls.Load() != 2 || len(r.messages) != 5 || r.messages[1].Text() != "first" || r.messages[3].Text() != "second" {
189+ t.Errorf("calls %d, history %d", m.calls.Load(), len(r.messages))
190+ }
191+ if r.queued() != 0 || r.cancelTurn() {
192+ t.Error("the queue or the turn is still there once idle")
193+ }
194+}
195+
196+// Esc still stops the turn; what was queued then runs at once — typing the
197+// correction and pressing Esc is the old "new question cuts the turn".
198+func TestStoppingTheTurnRunsTheQueue(t *testing.T) {
199+ m := &fakeModel{blockFirst: true, answer: "done"}
200+ r, tr := newTestRunner(t, m)
201+ r.submit(promptOf("first"))
202+ r.submit(promptOf("second"))
203+ if !r.cancelTurn() {
204+ t.Fatal("nothing to cancel")
205+ }
206+ waitIdle(t, r)
176207 waitIdle(t, r)
177208 if !strings.Contains(notices(tr), "🛑 Generation aborted.") {
178209 t.Errorf("notices:\n%s", notices(tr))
@@ -184,8 +215,25 @@ func TestRunnerCancelAndPending(t *testing.T) {
184215 if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" {
185216 t.Errorf("answer not shown: %+v", got)
186217 }
187- if r.cancelTurn() {
188- t.Error("cancelTurn reported a turn while idle")
218+}
219+
220+// The queue runs in order, and a command that answers at once does not hold
221+// back the entry behind it.
222+func TestTheQueueRunsInOrderThroughInstantCommands(t *testing.T) {
223+ m := &fakeModel{hold: make(chan struct{}), answer: "done"}
224+ r, tr := newTestRunner(t, m)
225+ r.submit(promptOf("first"))
226+ r.submit(promptOf("/help"))
227+ r.submit(promptOf("third"))
228+ if r.queued() != 2 || !strings.Contains(notices(tr), "⏳ Queued (2)") {
229+ t.Fatalf("queue %+v, notices:\n%s", r.pending, notices(tr))
230+ }
231+ close(m.hold)
232+ waitIdle(t, r) // first settles, /help answers, third starts
233+ waitIdle(t, r)
234+ help := strings.Index(notices(tr), "Ctrl-J new line")
235+ if help < 0 || len(r.messages) != 5 || r.messages[3].Text() != "third" {
236+ t.Errorf("help at %d, history %d", help, len(r.messages))
189237 }
190238 }
191239
@@ -506,8 +554,8 @@ func TestQuitLeavesATurnThatWillNotSettle(t *testing.T) {
506554 if !r.quit.Load() {
507555 t.Fatal("/quit during a stuck turn did not ask the window to close")
508556 }
509- if r.pending.Typed != "" {
510- t.Fatalf("/quit was queued behind the turn instead of running: %q", r.pending.Typed)
557+ if r.queued() != 0 {
558+ t.Fatalf("/quit was queued behind the turn instead of running: %+v", r.pending)
511559 }
512560 }
513561
@@ -515,8 +563,8 @@ func TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing(t *testing.T) {
515563 r, tr, _ := deafRunner(t)
516564
517565 r.submit(Prompt{Typed: "/abort"}) // the cancellation it can still do
518- if r.pending.Typed != "" {
519- t.Fatalf("/abort was queued behind the turn: %q", r.pending.Typed)
566+ if r.queued() != 0 {
567+ t.Fatalf("/abort was queued behind the turn: %+v", r.pending)
520568 }
521569 if got := tr.Plain(); !strings.Contains(got, "Aborting") {
522570 t.Fatalf("the first /abort said nothing about aborting:\n%s", got)
@@ -33,6 +33,9 @@ type fakeModel struct {
33 // background process holds open, an engine that stopped answering).33 // background process holds open, an engine that stopped answering).
34 deaf bool34 deaf bool
35 release chan struct{} // closed by the test to let a deaf call return35 release chan struct{} // closed by the test to let a deaf call return
36+ // hold makes the first call wait until the test closes it, then answer:
37+ // a turn that is still writing when the next question is typed.
38+ hold chan struct{}
36 fail error39 fail error
37 summary string40 summary string
38 tokens int41 tokens int
@@ -46,6 +49,9 @@ func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Mess
46 <-m.release49 <-m.release
47 return nil, nil, errors.New("too late")50 return nil, nil, errors.New("too late")
48 }51 }
52+ if m.calls.Load() == 1 && m.hold != nil {
53+ <-m.hold
54+ }
49 if m.calls.Load() == 1 && m.blockFirst {55 if m.calls.Load() == 1 && m.blockFirst {
50 <-ctx.Done()56 <-ctx.Done()
51 return nil, nil, ctx.Err()57 return nil, nil, ctx.Err()
@@ -159,20 +165,45 @@ func TestRunnerFailure(t *testing.T) {
159 }165 }
160 }166 }
161 167
162-// Enter during a turn cancels it and runs the new text once the cancelled168+// Enter during a turn queues the text (ticket 155): the turn writes its
163-// turn has settled; the cancelled turn is reported as aborted, not failed.169+// answer to the end, then the queued question runs.
164-func TestRunnerCancelAndPending(t *testing.T) {170+func TestRunnerQueuesAQuestionTypedDuringATurn(t *testing.T) {
165- m := &fakeModel{blockFirst: true, answer: "done"}171+ m := &fakeModel{hold: make(chan struct{}), answer: "done"}
166 r, tr := newTestRunner(t, m)172 r, tr := newTestRunner(t, m)
167 r.submit(promptOf("first"))173 r.submit(promptOf("first"))
168 r.submit(promptOf("second"))174 r.submit(promptOf("second"))
169- if r.pending.Typed != "second" {175+ if r.cancelled.Load() || r.queued() != 1 || r.pending[0].Typed != "second" {
170- t.Fatalf("pending %q", r.pending.Typed)176+ t.Fatalf("cancelled %v, queue %+v", r.cancelled.Load(), r.pending)
171 }177 }
172- waitIdle(t, r) // settles "first" (aborted) and dispatches "second"178+ if !strings.Contains(notices(tr), "⏳ Queued (1) — runs once this turn ends") {
173- if !r.isBusy() && r.pending.Typed != "" {179+ t.Errorf("notices:\n%s", notices(tr))
174- t.Fatalf("second not dispatched: busy %v pending %q", r.isBusy(), r.pending.Typed)
175 }180 }
181+ close(m.hold)
182+ waitIdle(t, r) // settles "first" and dispatches "second"
183+ waitIdle(t, r)
184+ if strings.Contains(notices(tr), "aborted") {
185+ t.Errorf("the first turn was aborted:\n%s", notices(tr))
186+ }
187+ // system, first, done, second, done
188+ if m.calls.Load() != 2 || len(r.messages) != 5 || r.messages[1].Text() != "first" || r.messages[3].Text() != "second" {
189+ t.Errorf("calls %d, history %d", m.calls.Load(), len(r.messages))
190+ }
191+ if r.queued() != 0 || r.cancelTurn() {
192+ t.Error("the queue or the turn is still there once idle")
193+ }
194+}
195+
196+// Esc still stops the turn; what was queued then runs at once — typing the
197+// correction and pressing Esc is the old "new question cuts the turn".
198+func TestStoppingTheTurnRunsTheQueue(t *testing.T) {
199+ m := &fakeModel{blockFirst: true, answer: "done"}
200+ r, tr := newTestRunner(t, m)
201+ r.submit(promptOf("first"))
202+ r.submit(promptOf("second"))
203+ if !r.cancelTurn() {
204+ t.Fatal("nothing to cancel")
205+ }
206+ waitIdle(t, r)
176 waitIdle(t, r)207 waitIdle(t, r)
177 if !strings.Contains(notices(tr), "🛑 Generation aborted.") {208 if !strings.Contains(notices(tr), "🛑 Generation aborted.") {
178 t.Errorf("notices:\n%s", notices(tr))209 t.Errorf("notices:\n%s", notices(tr))
@@ -184,8 +215,25 @@ func TestRunnerCancelAndPending(t *testing.T) {
184 if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" {215 if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" {
185 t.Errorf("answer not shown: %+v", got)216 t.Errorf("answer not shown: %+v", got)
186 }217 }
187- if r.cancelTurn() {218+}
188- t.Error("cancelTurn reported a turn while idle")219+
220+// The queue runs in order, and a command that answers at once does not hold
221+// back the entry behind it.
222+func TestTheQueueRunsInOrderThroughInstantCommands(t *testing.T) {
223+ m := &fakeModel{hold: make(chan struct{}), answer: "done"}
224+ r, tr := newTestRunner(t, m)
225+ r.submit(promptOf("first"))
226+ r.submit(promptOf("/help"))
227+ r.submit(promptOf("third"))
228+ if r.queued() != 2 || !strings.Contains(notices(tr), "⏳ Queued (2)") {
229+ t.Fatalf("queue %+v, notices:\n%s", r.pending, notices(tr))
230+ }
231+ close(m.hold)
232+ waitIdle(t, r) // first settles, /help answers, third starts
233+ waitIdle(t, r)
234+ help := strings.Index(notices(tr), "Ctrl-J new line")
235+ if help < 0 || len(r.messages) != 5 || r.messages[3].Text() != "third" {
236+ t.Errorf("help at %d, history %d", help, len(r.messages))
189 }237 }
190 }238 }
191 239
@@ -506,8 +554,8 @@ func TestQuitLeavesATurnThatWillNotSettle(t *testing.T) {
506 if !r.quit.Load() {554 if !r.quit.Load() {
507 t.Fatal("/quit during a stuck turn did not ask the window to close")555 t.Fatal("/quit during a stuck turn did not ask the window to close")
508 }556 }
509- if r.pending.Typed != "" {557+ if r.queued() != 0 {
510- t.Fatalf("/quit was queued behind the turn instead of running: %q", r.pending.Typed)558+ t.Fatalf("/quit was queued behind the turn instead of running: %+v", r.pending)
511 }559 }
512 }560 }
513 561
@@ -515,8 +563,8 @@ func TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing(t *testing.T) {
515 r, tr, _ := deafRunner(t)563 r, tr, _ := deafRunner(t)
516 564
517 r.submit(Prompt{Typed: "/abort"}) // the cancellation it can still do565 r.submit(Prompt{Typed: "/abort"}) // the cancellation it can still do
518- if r.pending.Typed != "" {566+ if r.queued() != 0 {
519- t.Fatalf("/abort was queued behind the turn: %q", r.pending.Typed)567+ t.Fatalf("/abort was queued behind the turn: %+v", r.pending)
520 }568 }
521 if got := tr.Plain(); !strings.Contains(got, "Aborting") {569 if got := tr.Plain(); !strings.Contains(got, "Aborting") {
522 t.Fatalf("the first /abort said nothing about aborting:\n%s", got)570 t.Fatalf("the first /abort said nothing about aborting:\n%s", got)
modified internal/tui/view.go +5 -3
@@ -90,9 +90,11 @@ type View struct {
9090 // struggle is the badge in front of the counters (ticket 153): the
9191 // worst struggle of the turn, zero when the model is doing fine.
9292 struggle badge
93- busy string // "", "thinking", "compressing", "asking <agent>"
94- ask *permission
95- form *formView // the ask_user form being answered, nil when none
93+ // queued is how many entries wait for the job in progress (ticket 155).
94+ queued int
95+ busy string // "", "thinking", "compressing", "asking <agent>"
96+ ask *permission
97+ form *formView // the ask_user form being answered, nil when none
9698
9799 // input is what has been typed but not sent, as lines. A long paste is
98100 // one placeholder rune in there; pastes holds the text it stands for.
@@ -90,9 +90,11 @@ type View struct {
90 // struggle is the badge in front of the counters (ticket 153): the90 // struggle is the badge in front of the counters (ticket 153): the
91 // worst struggle of the turn, zero when the model is doing fine.91 // worst struggle of the turn, zero when the model is doing fine.
92 struggle badge92 struggle badge
93- busy string // "", "thinking", "compressing", "asking <agent>"93+ // queued is how many entries wait for the job in progress (ticket 155).
94- ask *permission94+ queued int
95- form *formView // the ask_user form being answered, nil when none95+ busy string // "", "thinking", "compressing", "asking <agent>"
96+ ask *permission
97+ form *formView // the ask_user form being answered, nil when none
96 98
97 // input is what has been typed but not sent, as lines. A long paste is99 // input is what has been typed but not sent, as lines. A long paste is
98 // one placeholder rune in there; pastes holds the text it stands for.100 // one placeholder rune in there; pastes holds the text it stands for.
modified internal/tui/view_test.go +5 -1
@@ -270,9 +270,13 @@ func TestDrawFrame(t *testing.T) {
270270 t.Errorf("status row %q", last)
271271 }
272272 hs.v.busy = "thinking"
273- if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it") {
273+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it · Enter") {
274274 t.Errorf("busy rule %q", rule)
275275 }
276+ hs.v.queued = 2
277+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking · 2 queued — Esc stops") {
278+ t.Errorf("busy rule with a queue %q", rule)
279+ }
276280 }
277281
278282 // Following: the view sticks to the end while text arrives, lets go when
@@ -270,9 +270,13 @@ func TestDrawFrame(t *testing.T) {
270 t.Errorf("status row %q", last)270 t.Errorf("status row %q", last)
271 }271 }
272 hs.v.busy = "thinking"272 hs.v.busy = "thinking"
273- if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it") {273+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking — Esc stops it · Enter") {
274 t.Errorf("busy rule %q", rule)274 t.Errorf("busy rule %q", rule)
275 }275 }
276+ hs.v.queued = 2
277+ if rule := hs.rows()[9]; !strings.Contains(rule, "⠋ thinking · 2 queued — Esc stops") {
278+ t.Errorf("busy rule with a queue %q", rule)
279+ }
276 }280 }
277 281
278 // Following: the view sticks to the end while text arrives, lets go when282 // Following: the view sticks to the end while text arrives, lets go when