🛟 Updated.
a7ddac6 parent: dfcd2ed modified
.tickets/issues/0075-add-gronk-provider.yaml +2 -2 | @@ -6,9 +6,9 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-25T04:15:36.796Z | |
| 9 | +updatedAt: 2026-10-25T21:58:54.735Z | |
| 10 | 10 | labels: [] |
| 11 | -milestone: v1.0.1 | |
| 11 | +milestone: v2.0.0 | |
| 12 | 12 | epic: Providers |
| 13 | 13 | body: "" |
| 14 | 14 | tasks: [] |
| @@ -6,9 +6,9 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-06T03:58:37.611Z | 8 | createdAt: 2026-10-06T03:58:37.611Z |
| 9 | -updatedAt: 2026-10-25T04:15:36.796Z | 9 | +updatedAt: 2026-10-25T21:58:54.735Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | -milestone: v1.0.1 | 11 | +milestone: v2.0.0 |
| 12 | epic: Providers | 12 | 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 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 155 |
| 3 | 3 | title: When adding a message don't cancel the running generation, use something like /btw |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-10-25T10:00:14.109Z |
| 9 | -updatedAt: 2026-10-25T10:00:27.072Z | |
| 9 | +updatedAt: 2026-10-25T22:04:44.137Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | - enhancement |
| 13 | + - To be tested | |
| 13 | 14 | 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: 155 | 2 | id: 155 |
| 3 | title: When adding a message don't cancel the running generation, use something like /btw | 3 | title: When adding a message don't cancel the running generation, use something like /btw |
| 4 | -state: open | 4 | +state: closed |
| 5 | author: | 5 | author: |
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-10-25T10:00:14.109Z | 8 | createdAt: 2026-10-25T10:00:14.109Z |
| 9 | -updatedAt: 2026-10-25T10:00:27.072Z | 9 | +updatedAt: 2026-10-25T22:04:44.137Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | - enhancement | 12 | - enhancement |
| 13 | + - To be tested | ||
| 13 | milestone: v1.0.1 | 14 | 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 % | ||
| 12 | 12 | maxTurns: 10 # room for the loop detector to stop the turn first (6 calls) |
| 13 | 13 | watchdogTimeout: 3s # the "silent" scenario stalls quickly |
| 14 | 14 | firstTokenTimeout: 3s |
| 15 | -skillsDir: "" | |
| 15 | +skillsDir: no-skills # no skill: the README files are not procedures | |
| 16 | 16 | agentsMd: |
| 17 | 17 | enabled: false |
| 18 | 18 | 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 quickly | 13 | watchdogTimeout: 3s # the "silent" scenario stalls quickly |
| 14 | firstTokenTimeout: 3s | 14 | firstTokenTimeout: 3s |
| 15 | -skillsDir: "" | 15 | +skillsDir: no-skills # no skill: the README files are not procedures |
| 16 | agentsMd: | 16 | agentsMd: |
| 17 | enabled: false | 17 | 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 | ||
| 16 | 16 | |
| 17 | 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 | 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 | |
| @@ -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 @@ | ||
| 30 | 30 | | `authenticate` | Returns an empty response. | |
| 31 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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: | ||
| 41 | 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 | 42 | | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Cancel the ask; `🛑 Ask aborted — nothing kept.` | |
| 43 | 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 | 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 | 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 | 85 | ## Keyboard shortcuts |
| 83 | 86 | |
| @@ -130,9 +133,9 @@ The keys: | ||
| 130 | 133 | |
| 131 | 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 | 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 | 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 | 140 | | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open (`/`, `@` or `$`). | |
| 138 | 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. | |
| @@ -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 shortcuts | 85 | ## 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 | ||
| 16 | 16 | |
| 17 | 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 | 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 | |
| @@ -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 @@ | ||
| 30 | 30 | | `authenticate` | Renvoie une réponse vide. | |
| 31 | 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 | 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 | 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 | 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 | 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`. | |
| @@ -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 : | ||
| 41 | 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 | 42 | | `/abort`, `Esc`, `Ctrl+C`, `session/cancel` | Annulent la demande ; `🛑 Ask aborted — nothing kept.` | |
| 43 | 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 | 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 | 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 | 85 | ## Raccourcis clavier |
| 83 | 86 | |
| @@ -130,9 +133,9 @@ Les touches : | ||
| 130 | 133 | |
| 131 | 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 | 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 | 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 | 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 | 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. | |
| @@ -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 clavier | 85 | ## 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 | ||
| 90 | 90 | } |
| 91 | 91 | |
| 92 | 92 | 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 | 97 | for { |
| 95 | 98 | fmt.Print("\n> ") |
| 96 | 99 | |
| 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() | |
| 99 | 104 | var ok bool |
| 100 | 105 | |
| 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 | + } | |
| 107 | 115 | // If generating, we let the generation loop handle the SIGINT. |
| 108 | 116 | // 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 | + } | |
| 112 | 121 | } |
| 113 | 122 | } |
| 114 | 123 | |
| @@ -129,9 +138,9 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 129 | 138 | // /resume — are only reachable here, between two generations: Genkit |
| 130 | 139 | // holds its own copy of the conversation while it runs, and a |
| 131 | 140 | // 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. | |
| 135 | 144 | // /ask-bob and its family: another agent answers, and its answer |
| 136 | 145 | // joins the history for the next question. Between two generations |
| 137 | 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 | 150 | continue |
| 142 | 151 | } |
| 143 | 152 | 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 | 154 | if quit { |
| 146 | 155 | fmt.Println("Goodbye.") |
| 147 | 156 | return |
| @@ -218,7 +227,11 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 218 | 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 | 235 | generationRunning := true |
| 223 | 236 | for generationRunning { |
| 224 | 237 | select { |
| @@ -311,46 +324,36 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | ||
| 311 | 324 | case req := <-prompt.requestCh(): |
| 312 | 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 | 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 | 334 | if prompt.answer(nextInput) { |
| 318 | 335 | continue |
| 319 | 336 | } |
| 320 | - prompt.drop() | |
| 321 | - spinner.Stop() // le spinner tourne : on lui reprend la ligne | |
| 322 | 337 | switch { |
| 323 | 338 | case command.Is(nextInput, command.Abort): |
| 339 | + prompt.drop() | |
| 340 | + spinner.Stop() // le spinner tourne : on lui reprend la ligne | |
| 324 | 341 | cancelGen() |
| 325 | 342 | fmt.Println("🛑 Aborting...") |
| 326 | 343 | case command.Is(nextInput, command.Quit): |
| 344 | + prompt.drop() | |
| 345 | + spinner.Stop() | |
| 327 | 346 | cancelGen() |
| 328 | 347 | fmt.Println("Goodbye.") |
| 329 | 348 | return |
| 330 | 349 | 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) | |
| 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 | } |
| @@ -90,25 +90,34 @@ func Run(ctx context.Context, e *engine.Engine, system string, tools []ai.ToolRe | |||
| 90 | } | 90 | } |
| 91 | 91 | ||
| 92 | generating := false | 92 | 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 string | 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() | ||
| 99 | var ok bool | 104 | 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 | - return | 110 | + 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 | - return | 119 | + 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: Genkit | 138 | // /resume — are only reachable here, between two generations: Genkit |
| 130 | // holds its own copy of the conversation while it runs, and a | 139 | // 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. Typed | 140 | // 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 | 141 | + // DURING a generation, such a command waits in the queue (ticket 155) |
| 133 | - // received" branch below, which cancels the turn and re-injects it | 142 | + // 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 answer | 144 | // /ask-bob and its family: another agent answers, and its answer |
| 136 | // joins the history for the next question. Between two generations | 145 | // 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 | continue | 150 | continue |
| 142 | } | 151 | } |
| 143 | var quit bool | 152 | 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 | return | 156 | 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 := true | 235 | 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 | continue | 335 | 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 | return | 348 | 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 := <-resultCh | 355 | + 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 { | ||
| 22 | 22 | sigCh <-chan os.Signal |
| 23 | 23 | inputCh <-chan string |
| 24 | 24 | 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 | 28 | // 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 | 42 | done <- result{exchange, err} |
| 42 | 43 | }() |
| 43 | 44 | |
| 45 | + typing := a.inputCh // nil once stdin closes: see Run's waiting room | |
| 44 | 46 | for { |
| 45 | 47 | select { |
| 46 | 48 | case res := <-done: |
| @@ -51,7 +53,11 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai. | ||
| 51 | 53 | cancel() |
| 52 | 54 | case req := <-a.prompt.requestCh(): |
| 53 | 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 | 61 | if a.prompt.answer(input) { |
| 56 | 62 | continue |
| 57 | 63 | } |
| @@ -65,7 +71,9 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai. | ||
| 65 | 71 | quit = true |
| 66 | 72 | cancel() |
| 67 | 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 | } |
| @@ -22,6 +22,7 @@ type asking struct { | |||
| 22 | sigCh <-chan os.Signal | 22 | sigCh <-chan os.Signal |
| 23 | inputCh <-chan string | 23 | inputCh <-chan string |
| 24 | prompt *linePrompt // the asked agent's calls may need the user's answer | 24 | 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 the | 28 | // 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 | continue | 62 | continue |
| 57 | } | 63 | } |
| @@ -65,7 +71,9 @@ func (a asking) run(ctx context.Context, req command.AskRequest, messages []*ai. | |||
| 65 | quit = true | 71 | 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 ( | ||
| 5 | 5 | "errors" |
| 6 | 6 | "os" |
| 7 | 7 | "testing" |
| 8 | + "time" | |
| 8 | 9 | |
| 9 | 10 | "mm/internal/command" |
| 10 | 11 | "mm/internal/config" |
| @@ -19,6 +20,7 @@ type fakeRunner struct { | ||
| 19 | 20 | answer string |
| 20 | 21 | fail error |
| 21 | 22 | block bool |
| 23 | + wait chan struct{} // answer once the test closes it | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 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 | 28 | <-ctx.Done() |
| 27 | 29 | return nil, ctx.Err() |
| 28 | 30 | } |
| 31 | + if f.wait != nil { | |
| 32 | + <-f.wait | |
| 33 | + } | |
| 29 | 34 | if f.fail != nil { |
| 30 | 35 | return nil, f.fail |
| 31 | 36 | } |
| @@ -41,7 +46,7 @@ func lineAsk(t *testing.T, r fakeRunner) (asking, chan os.Signal, chan string) { | ||
| 41 | 46 | config.Cfg.Sessions.Enabled = false |
| 42 | 47 | config.Cfg.MMConfigs = map[string]config.AgentRef{"bob": {Path: "/nowhere/agent.bob.yaml"}} |
| 43 | 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, in | |
| 49 | + 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 | 52 | var askHistory = []*ai.Message{ai.NewSystemTextMessage("sys")} |
| @@ -81,3 +86,37 @@ func TestLineAskStops(t *testing.T) { | ||
| 81 | 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 | +} | |
| @@ -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 string | 20 | answer string |
| 20 | fail error | 21 | fail error |
| 21 | block bool | 22 | 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.fail | 35 | 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 = false | 46 | 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, in | 49 | + 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 { | ||
| 134 | 134 | } |
| 135 | 135 | |
| 136 | 136 | // 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 | 138 | func (p *linePrompt) drop() { |
| 139 | 139 | if p == nil || p.pending == nil { |
| 140 | 140 | 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 being | 136 | // 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 | return | 140 | 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 ( | ||
| 17 | 17 | ) |
| 18 | 18 | |
| 19 | 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 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. | |
| 22 | 23 | // |
| 23 | 24 | // The two control commands are the exception, and it is the whole point of |
| 24 | 25 | // ticket 26: /quit and /abort are about the turn in progress, so queueing |
| @@ -30,8 +31,8 @@ func (r *runner) submit(p Prompt) { | ||
| 30 | 31 | return |
| 31 | 32 | } |
| 32 | 33 | 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))) | |
| 35 | 36 | return |
| 36 | 37 | } |
| 37 | 38 | r.dispatch(p) |
| @@ -39,8 +40,8 @@ func (r *runner) submit(p Prompt) { | ||
| 39 | 40 | |
| 40 | 41 | // control runs the commands that must never wait for the turn in progress, |
| 41 | 42 | // 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. | |
| 44 | 45 | func (r *runner) control(p Prompt) bool { |
| 45 | 46 | cmd, _, ok := command.Parse(p.Typed) |
| 46 | 47 | if !ok { |
| @@ -127,7 +128,7 @@ func (r *runner) unload(model string) { | ||
| 127 | 128 | |
| 128 | 129 | // help lists the window's commands and keys. |
| 129 | 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 | 134 | // 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 has | 20 | +// 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 of | 24 | // 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 queueing | 25 | // ticket 26: /quit and /abort are about the turn in progress, so queueing |
| @@ -30,8 +31,8 @@ func (r *runner) submit(p Prompt) { | |||
| 30 | return | 31 | return |
| 31 | } | 32 | } |
| 32 | if r.isBusy() { | 33 | if r.isBusy() { |
| 33 | - r.pending = p | 34 | + 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 | return | 36 | 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 turn | 42 | // 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 | +// 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 { | ||
| 97 | 97 | switch { |
| 98 | 98 | case v.ask != nil: |
| 99 | 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 | 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 | 104 | case !v.onInput: |
| 103 | 105 | return " ↑↓ PgUp PgDn scroll · Home End · Tab back to the box " |
| 104 | 106 | 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) { | ||
| 116 | 116 | if *suspended != 1 { |
| 117 | 117 | t.Errorf("the shell waited for the turn: suspended %d time(s)", *suspended) |
| 118 | 118 | } |
| 119 | - if r.cancelled.Load() || r.pending.Typed != "" { | |
| 120 | - t.Errorf("the turn was cancelled (%v) or /shell queued (%q)", r.cancelled.Load(), r.pending.Typed) | |
| 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) | |
| 121 | 121 | } |
| 122 | 122 | if !strings.Contains(notices(tr), "🐚 Back from the shell.") { |
| 123 | 123 | 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() { | ||
| 136 | 136 | a.v.syncForm(a.r.currentForm()) |
| 137 | 137 | a.v.version = a.r.opts.Version |
| 138 | 138 | a.v.struggle = a.r.struggles.badge() |
| 139 | + a.v.queued = a.r.queued() | |
| 139 | 140 | a.v.draw() |
| 140 | 141 | } |
| 141 | 142 | |
| @@ -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.Version | 137 | 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 { | ||
| 83 | 83 | resp *ai.ModelResponse |
| 84 | 84 | history []*ai.Message |
| 85 | 85 | 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. | |
| 88 | 88 | cancelled bool |
| 89 | 89 | line string |
| 90 | 90 | changed bool |
| @@ -120,7 +120,9 @@ type runner struct { | ||
| 120 | 120 | // way out rather than cancelling a context that is already cut. |
| 121 | 121 | cancelled atomic.Bool |
| 122 | 122 | 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 | |
| 124 | 126 | // asked is the question of the turn in progress, as the model reads it: |
| 125 | 127 | // not in messages until the turn settles, yet the first one ↑ recalls. |
| 126 | 128 | // A turn that fails before answering drops its question from the |
| @@ -290,12 +292,22 @@ func (r *runner) settle() { | ||
| 290 | 292 | r.busy.Store("") |
| 291 | 293 | r.cancel = nil |
| 292 | 294 | 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:] | |
| 295 | 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 | 311 | // apply folds one outcome into the session and the transcript. |
| 300 | 312 | func (r *runner) apply(res outcome) { |
| 301 | 313 | if res.kind == "ask" { |
| @@ -83,8 +83,8 @@ type outcome struct { | |||
| 83 | resp *ai.ModelResponse | 83 | resp *ai.ModelResponse |
| 84 | history []*ai.Message | 84 | history []*ai.Message |
| 85 | err error | 85 | err error |
| 86 | - // cancelled says the turn's context was cut — by Esc, Ctrl-C or a new | 86 | + // 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 bool | 88 | cancelled bool |
| 89 | line string | 89 | line string |
| 90 | changed bool | 90 | 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.Bool | 121 | cancelled atomic.Bool |
| 122 | results chan outcome | 122 | 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 | ||
| 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 the | 128 | // 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 = nil | 293 | 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 { | ||
| 33 | 33 | // background process holds open, an engine that stopped answering). |
| 34 | 34 | deaf bool |
| 35 | 35 | 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 | 39 | fail error |
| 37 | 40 | summary string |
| 38 | 41 | tokens int |
| @@ -46,6 +49,9 @@ func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Mess | ||
| 46 | 49 | <-m.release |
| 47 | 50 | return nil, nil, errors.New("too late") |
| 48 | 51 | } |
| 52 | + if m.calls.Load() == 1 && m.hold != nil { | |
| 53 | + <-m.hold | |
| 54 | + } | |
| 49 | 55 | if m.calls.Load() == 1 && m.blockFirst { |
| 50 | 56 | <-ctx.Done() |
| 51 | 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 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"} | |
| 166 | 172 | r, tr := newTestRunner(t, m) |
| 167 | 173 | r.submit(promptOf("first")) |
| 168 | 174 | 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) | |
| 171 | 177 | } |
| 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)) | |
| 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 | 207 | waitIdle(t, r) |
| 177 | 208 | if !strings.Contains(notices(tr), "🛑 Generation aborted.") { |
| 178 | 209 | t.Errorf("notices:\n%s", notices(tr)) |
| @@ -184,8 +215,25 @@ func TestRunnerCancelAndPending(t *testing.T) { | ||
| 184 | 215 | if got := tr.Entries(); got[len(got)-2].Kind != KindAgent || got[len(got)-2].Text != "done" { |
| 185 | 216 | t.Errorf("answer not shown: %+v", got) |
| 186 | 217 | } |
| 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)) | |
| 189 | 237 | } |
| 190 | 238 | } |
| 191 | 239 | |
| @@ -506,8 +554,8 @@ func TestQuitLeavesATurnThatWillNotSettle(t *testing.T) { | ||
| 506 | 554 | if !r.quit.Load() { |
| 507 | 555 | t.Fatal("/quit during a stuck turn did not ask the window to close") |
| 508 | 556 | } |
| 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) | |
| 511 | 559 | } |
| 512 | 560 | } |
| 513 | 561 | |
| @@ -515,8 +563,8 @@ func TestAbortDuringAStuckTurnSaysSoInsteadOfQueueing(t *testing.T) { | ||
| 515 | 563 | r, tr, _ := deafRunner(t) |
| 516 | 564 | |
| 517 | 565 | 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) | |
| 520 | 568 | } |
| 521 | 569 | if got := tr.Plain(); !strings.Contains(got, "Aborting") { |
| 522 | 570 | 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 bool | 34 | deaf bool |
| 35 | release chan struct{} // closed by the test to let a deaf call return | 35 | 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 error | 39 | fail error |
| 37 | summary string | 40 | summary string |
| 38 | tokens int | 41 | tokens int |
| @@ -46,6 +49,9 @@ func (m *fakeModel) generate(ctx context.Context) (*ai.ModelResponse, []*ai.Mess | |||
| 46 | <-m.release | 49 | <-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 cancelled | 168 | +// 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 do | 565 | 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 { | ||
| 90 | 90 | // struggle is the badge in front of the counters (ticket 153): the |
| 91 | 91 | // worst struggle of the turn, zero when the model is doing fine. |
| 92 | 92 | 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 | |
| 96 | 98 | |
| 97 | 99 | // input is what has been typed but not sent, as lines. A long paste is |
| 98 | 100 | // 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): the | 90 | // 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 badge | 92 | 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 *permission | 94 | + queued int |
| 95 | - form *formView // the ask_user form being answered, nil when none | 95 | + 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 is | 99 | // 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) { | ||
| 270 | 270 | t.Errorf("status row %q", last) |
| 271 | 271 | } |
| 272 | 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 | 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 | 282 | // 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 when | 282 | // Following: the view sticks to the end while text arrives, lets go when |