🛟 Updated.
49d3dce parent: 099bf1b modified
.tickets/issues/0020-editbox-in-tui-mode-word-text-wrap.yaml +56 -3 | @@ -6,9 +6,62 @@ author: | ||
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-17T13:26:49.530Z |
| 9 | -updatedAt: 2026-09-17T13:35:27.249Z | |
| 9 | +updatedAt: 2026-09-18T15:00:00.000Z | |
| 10 | 10 | labels: [] |
| 11 | 11 | milestone: v0.0.2 |
| 12 | -body: "" | |
| 13 | -tasks: [] | |
| 12 | +body: | | |
| 13 | + ## Problem | |
| 14 | + | |
| 15 | + In the window mode (`mm -tui`) the input box showed one typed line per row and clipped it with `…` at the right edge: past the box's width the text being typed was invisible. | |
| 16 | + | |
| 17 | + ## Implementation note (Claude, 2026-09-18, branch `fix/word-wrap`) | |
| 18 | + | |
| 19 | + `internal/tui/inputwrap.go`: the typed text stays as logical lines (`v.input`, one per Ctrl-J); the screen shows *rows*. `inputRows(width)` lays every logical line out at the box's width in cells — a paste token is one rune but a whole label — breaking after the last space that fits, mid-word only when a word is longer than a row; a space never triggers a break (it hangs past the edge, invisible, so a row never starts with a space). `cursorRow` places the cursor on the row that holds it (a column sitting exactly on a soft break belongs to the next row), `moveRow` is ↑/↓ between rows keeping the x offset (false on the first/last row, so the conversation scrolls as before). `drawInput` shows the rows and scrolls the 3-row box so the cursor's row is visible. `drawInputLine` → `drawInputRow`; the old `cursorX` and `clampColumn` are gone (`cursorX` now returns the offset in the row). | |
| 20 | + | |
| 21 | + Kept: the box's height (3 rows, `inputHeight`), Home/End on the logical line, the `…` clipping for a paste label wider than a row, the picker (reads `v.input[0]`). | |
| 22 | + | |
| 23 | + Tested: `TestInputWordWrap` on a 22-column simulation screen — wrap at the space, cursor position (`SimulationScreen.GetCursor`), ↑/↓ between rows and out of the box, a 26-letter word cut at the edge, a 4-row input scrolled to the cursor's row. `go test ./...` green. | |
| 24 | + | |
| 25 | + Docs EN+FR: `how-to/use-the-window-mode.md` (step 2), `reference/slash-commands.md` (↑/↓ row). | |
| 26 | + | |
| 27 | + Not done: a box that grows with its content (kept at 3 rows on purpose — Turbo's choice, recorded in `.memory/summary.md`); wide (CJK) runes still count one cell, as in the rest of the window. | |
| 28 | +tasks: | |
| 29 | + - id: 1 | |
| 30 | + title: "Word wrap of the typed text at the box's width, mid-word only for words longer than a row; paste labels measured in cells" | |
| 31 | + state: closed | |
| 32 | + priority: none | |
| 33 | + author: | |
| 34 | + name: k33g | |
| 35 | + email: ph.charriere@gmail.com | |
| 36 | + createdAt: 2026-09-18T15:00:00.000Z | |
| 37 | + updatedAt: 2026-09-18T15:00:00.000Z | |
| 38 | + closedAt: 2026-09-18T15:00:00.000Z | |
| 39 | + - id: 2 | |
| 40 | + title: "Cursor on its row; ↑/↓ move between rows; the box scrolls to the cursor's row" | |
| 41 | + state: closed | |
| 42 | + priority: none | |
| 43 | + author: | |
| 44 | + name: k33g | |
| 45 | + email: ph.charriere@gmail.com | |
| 46 | + createdAt: 2026-09-18T15:00:00.000Z | |
| 47 | + updatedAt: 2026-09-18T15:00:00.000Z | |
| 48 | + closedAt: 2026-09-18T15:00:00.000Z | |
| 49 | + - id: 3 | |
| 50 | + title: "Tests (simulation screen) and docs EN+FR" | |
| 51 | + state: closed | |
| 52 | + priority: none | |
| 53 | + author: | |
| 54 | + name: k33g | |
| 55 | + email: ph.charriere@gmail.com | |
| 56 | + createdAt: 2026-09-18T15:00:00.000Z | |
| 57 | + updatedAt: 2026-09-18T15:00:00.000Z | |
| 58 | + closedAt: 2026-09-18T15:00:00.000Z | |
| 59 | + - id: 4 | |
| 60 | + title: "Verify on macOS with the installed binary: type a line longer than the window, check the wrap, ↑/↓ and Backspace across the soft break" | |
| 61 | + state: open | |
| 62 | + priority: medium | |
| 63 | + author: | |
| 64 | + name: k33g | |
| 65 | + email: ph.charriere@gmail.com | |
| 66 | + createdAt: 2026-09-18T15:00:00.000Z | |
| 14 | 67 | comments: [] |
| @@ -6,9 +6,62 @@ author: | |||
| 6 | name: k33g | 6 | name: k33g |
| 7 | email: ph.charriere@gmail.com | 7 | email: ph.charriere@gmail.com |
| 8 | createdAt: 2026-09-17T13:26:49.530Z | 8 | createdAt: 2026-09-17T13:26:49.530Z |
| 9 | -updatedAt: 2026-09-17T13:35:27.249Z | 9 | +updatedAt: 2026-09-18T15:00:00.000Z |
| 10 | labels: [] | 10 | labels: [] |
| 11 | milestone: v0.0.2 | 11 | milestone: v0.0.2 |
| 12 | -body: "" | 12 | +body: | |
| 13 | -tasks: [] | 13 | + ## Problem |
| 14 | + | ||
| 15 | + In the window mode (`mm -tui`) the input box showed one typed line per row and clipped it with `…` at the right edge: past the box's width the text being typed was invisible. | ||
| 16 | + | ||
| 17 | + ## Implementation note (Claude, 2026-09-18, branch `fix/word-wrap`) | ||
| 18 | + | ||
| 19 | + `internal/tui/inputwrap.go`: the typed text stays as logical lines (`v.input`, one per Ctrl-J); the screen shows *rows*. `inputRows(width)` lays every logical line out at the box's width in cells — a paste token is one rune but a whole label — breaking after the last space that fits, mid-word only when a word is longer than a row; a space never triggers a break (it hangs past the edge, invisible, so a row never starts with a space). `cursorRow` places the cursor on the row that holds it (a column sitting exactly on a soft break belongs to the next row), `moveRow` is ↑/↓ between rows keeping the x offset (false on the first/last row, so the conversation scrolls as before). `drawInput` shows the rows and scrolls the 3-row box so the cursor's row is visible. `drawInputLine` → `drawInputRow`; the old `cursorX` and `clampColumn` are gone (`cursorX` now returns the offset in the row). | ||
| 20 | + | ||
| 21 | + Kept: the box's height (3 rows, `inputHeight`), Home/End on the logical line, the `…` clipping for a paste label wider than a row, the picker (reads `v.input[0]`). | ||
| 22 | + | ||
| 23 | + Tested: `TestInputWordWrap` on a 22-column simulation screen — wrap at the space, cursor position (`SimulationScreen.GetCursor`), ↑/↓ between rows and out of the box, a 26-letter word cut at the edge, a 4-row input scrolled to the cursor's row. `go test ./...` green. | ||
| 24 | + | ||
| 25 | + Docs EN+FR: `how-to/use-the-window-mode.md` (step 2), `reference/slash-commands.md` (↑/↓ row). | ||
| 26 | + | ||
| 27 | + Not done: a box that grows with its content (kept at 3 rows on purpose — Turbo's choice, recorded in `.memory/summary.md`); wide (CJK) runes still count one cell, as in the rest of the window. | ||
| 28 | +tasks: | ||
| 29 | + - id: 1 | ||
| 30 | + title: "Word wrap of the typed text at the box's width, mid-word only for words longer than a row; paste labels measured in cells" | ||
| 31 | + state: closed | ||
| 32 | + priority: none | ||
| 33 | + author: | ||
| 34 | + name: k33g | ||
| 35 | + email: ph.charriere@gmail.com | ||
| 36 | + createdAt: 2026-09-18T15:00:00.000Z | ||
| 37 | + updatedAt: 2026-09-18T15:00:00.000Z | ||
| 38 | + closedAt: 2026-09-18T15:00:00.000Z | ||
| 39 | + - id: 2 | ||
| 40 | + title: "Cursor on its row; ↑/↓ move between rows; the box scrolls to the cursor's row" | ||
| 41 | + state: closed | ||
| 42 | + priority: none | ||
| 43 | + author: | ||
| 44 | + name: k33g | ||
| 45 | + email: ph.charriere@gmail.com | ||
| 46 | + createdAt: 2026-09-18T15:00:00.000Z | ||
| 47 | + updatedAt: 2026-09-18T15:00:00.000Z | ||
| 48 | + closedAt: 2026-09-18T15:00:00.000Z | ||
| 49 | + - id: 3 | ||
| 50 | + title: "Tests (simulation screen) and docs EN+FR" | ||
| 51 | + state: closed | ||
| 52 | + priority: none | ||
| 53 | + author: | ||
| 54 | + name: k33g | ||
| 55 | + email: ph.charriere@gmail.com | ||
| 56 | + createdAt: 2026-09-18T15:00:00.000Z | ||
| 57 | + updatedAt: 2026-09-18T15:00:00.000Z | ||
| 58 | + closedAt: 2026-09-18T15:00:00.000Z | ||
| 59 | + - id: 4 | ||
| 60 | + title: "Verify on macOS with the installed binary: type a line longer than the window, check the wrap, ↑/↓ and Backspace across the soft break" | ||
| 61 | + state: open | ||
| 62 | + priority: medium | ||
| 63 | + author: | ||
| 64 | + name: k33g | ||
| 65 | + email: ph.charriere@gmail.com | ||
| 66 | + createdAt: 2026-09-18T15:00:00.000Z | ||
| 14 | comments: [] | 67 | comments: [] |
modified
.tickets/issues/0021-when-there-is-an-error-durring-the-completion-we.yaml +7 -5 | @@ -1,12 +1,12 @@ | ||
| 1 | 1 | # Managed by IssueSpec. Hand edits are welcome; keep the schema valid. |
| 2 | 2 | id: 21 |
| 3 | 3 | title: When there is an error durring the completion, we should log it somewhere to help Claude to debug and improve |
| 4 | -state: open | |
| 4 | +state: closed | |
| 5 | 5 | author: |
| 6 | 6 | name: k33g |
| 7 | 7 | email: ph.charriere@gmail.com |
| 8 | 8 | createdAt: 2026-09-17T13:31:10.696Z |
| 9 | -updatedAt: 2026-09-18T14:20:00.000Z | |
| 9 | +updatedAt: 2026-09-18T15:12:38.136Z | |
| 10 | 10 | labels: |
| 11 | 11 | - priority::urgent |
| 12 | 12 | milestone: v0.0.2 |
| @@ -52,7 +52,7 @@ tasks: | ||
| 52 | 52 | updatedAt: 2026-09-18T14:20:00.000Z |
| 53 | 53 | closedAt: 2026-09-18T14:20:00.000Z |
| 54 | 54 | - id: 2 |
| 55 | - title: "Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors" | |
| 55 | + title: Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors | |
| 56 | 56 | state: closed |
| 57 | 57 | priority: none |
| 58 | 58 | author: |
| @@ -72,7 +72,7 @@ tasks: | ||
| 72 | 72 | updatedAt: 2026-09-18T14:20:00.000Z |
| 73 | 73 | closedAt: 2026-09-18T14:20:00.000Z |
| 74 | 74 | - id: 4 |
| 75 | - title: "/sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration)" | |
| 75 | + title: /sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration) | |
| 76 | 76 | state: closed |
| 77 | 77 | priority: none |
| 78 | 78 | author: |
| @@ -83,10 +83,12 @@ tasks: | ||
| 83 | 83 | closedAt: 2026-09-18T14:20:00.000Z |
| 84 | 84 | - id: 5 |
| 85 | 85 | title: "Verify on macOS with the installed binary (`./install.sh`): trigger a red error in `mm -tui`, then `jq .errors` on the session file and `/resume <id>`" |
| 86 | - state: open | |
| 86 | + state: closed | |
| 87 | 87 | priority: medium |
| 88 | 88 | author: |
| 89 | 89 | name: k33g |
| 90 | 90 | email: ph.charriere@gmail.com |
| 91 | 91 | createdAt: 2026-09-18T14:20:00.000Z |
| 92 | + updatedAt: 2026-09-18T15:12:35.052Z | |
| 93 | + closedAt: 2026-09-18T15:12:35.052Z | |
| 92 | 94 | comments: [] |
| @@ -1,12 +1,12 @@ | |||
| 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: 21 | 2 | id: 21 |
| 3 | title: When there is an error durring the completion, we should log it somewhere to help Claude to debug and improve | 3 | title: When there is an error durring the completion, we should log it somewhere to help Claude to debug and improve |
| 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-09-17T13:31:10.696Z | 8 | createdAt: 2026-09-17T13:31:10.696Z |
| 9 | -updatedAt: 2026-09-18T14:20:00.000Z | 9 | +updatedAt: 2026-09-18T15:12:38.136Z |
| 10 | labels: | 10 | labels: |
| 11 | - priority::urgent | 11 | - priority::urgent |
| 12 | milestone: v0.0.2 | 12 | milestone: v0.0.2 |
| @@ -52,7 +52,7 @@ tasks: | |||
| 52 | updatedAt: 2026-09-18T14:20:00.000Z | 52 | updatedAt: 2026-09-18T14:20:00.000Z |
| 53 | closedAt: 2026-09-18T14:20:00.000Z | 53 | closedAt: 2026-09-18T14:20:00.000Z |
| 54 | - id: 2 | 54 | - id: 2 |
| 55 | - title: "Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors" | 55 | + title: Record failed generations, failed compactions and failed /resume in the window mode, the REPL and over ACP; cancelled turns are not errors |
| 56 | state: closed | 56 | state: closed |
| 57 | priority: none | 57 | priority: none |
| 58 | author: | 58 | author: |
| @@ -72,7 +72,7 @@ tasks: | |||
| 72 | updatedAt: 2026-09-18T14:20:00.000Z | 72 | updatedAt: 2026-09-18T14:20:00.000Z |
| 73 | closedAt: 2026-09-18T14:20:00.000Z | 73 | closedAt: 2026-09-18T14:20:00.000Z |
| 74 | - id: 4 | 74 | - id: 4 |
| 75 | - title: "/sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration)" | 75 | + title: /sessions listing and /resume report show ` · ⚠️ N error(s)`; docs EN+FR (explanation, how-to, slash-commands, configuration) |
| 76 | state: closed | 76 | state: closed |
| 77 | priority: none | 77 | priority: none |
| 78 | author: | 78 | author: |
| @@ -83,10 +83,12 @@ tasks: | |||
| 83 | closedAt: 2026-09-18T14:20:00.000Z | 83 | closedAt: 2026-09-18T14:20:00.000Z |
| 84 | - id: 5 | 84 | - id: 5 |
| 85 | title: "Verify on macOS with the installed binary (`./install.sh`): trigger a red error in `mm -tui`, then `jq .errors` on the session file and `/resume <id>`" | 85 | title: "Verify on macOS with the installed binary (`./install.sh`): trigger a red error in `mm -tui`, then `jq .errors` on the session file and `/resume <id>`" |
| 86 | - state: open | 86 | + state: closed |
| 87 | priority: medium | 87 | priority: medium |
| 88 | author: | 88 | author: |
| 89 | name: k33g | 89 | name: k33g |
| 90 | email: ph.charriere@gmail.com | 90 | email: ph.charriere@gmail.com |
| 91 | createdAt: 2026-09-18T14:20:00.000Z | 91 | createdAt: 2026-09-18T14:20:00.000Z |
| 92 | + updatedAt: 2026-09-18T15:12:35.052Z | ||
| 93 | + closedAt: 2026-09-18T15:12:35.052Z | ||
| 92 | comments: [] | 94 | comments: [] |
modified
docs/en/how-to/use-the-window-mode.md +1 -1 | @@ -12,7 +12,7 @@ This guide shows how to run mini-me as a single full-screen window — the conve | ||
| 12 | 12 | |
| 13 | 13 | Or set `tui: true` in the YAML and start `mm` as usual. The banner that the line mode prints is shown as the first line of the conversation. |
| 14 | 14 | |
| 15 | -2. Type your question in the box at the bottom and press **Enter**. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below). | |
| 15 | +2. Type your question in the box at the bottom and press **Enter**. A long line wraps at word boundaries inside the box (a word longer than a row is cut at the edge); the box keeps its three rows and scrolls them so the cursor's row stays visible, and ↑/↓ move the cursor from row to row. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below). | |
| 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 | |
| @@ -12,7 +12,7 @@ This guide shows how to run mini-me as a single full-screen window — the conve | |||
| 12 | 12 | ||
| 13 | Or set `tui: true` in the YAML and start `mm` as usual. The banner that the line mode prints is shown as the first line of the conversation. | 13 | Or set `tui: true` in the YAML and start `mm` as usual. The banner that the line mode prints is shown as the first line of the conversation. |
| 14 | 14 | ||
| 15 | -2. Type your question in the box at the bottom and press **Enter**. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below). | 15 | +2. Type your question in the box at the bottom and press **Enter**. A long line wraps at word boundaries inside the box (a word longer than a row is cut at the edge); the box keeps its three rows and scrolls them so the cursor's row stays visible, and ↑/↓ move the cursor from row to row. For a second paragraph, press **Ctrl-J** (or Alt-Enter where your terminal reports it) instead of Enter. A pasted block is inserted whole, never sent; a long one is collapsed into a `[Pasted #1 · …]` token (see below). |
| 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 | ||
modified
docs/en/reference/slash-commands.md +1 -1 | @@ -50,7 +50,7 @@ In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No gener | ||
| 50 | 50 | | `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. | |
| 51 | 51 | | `Ctrl-C` | Cancels the running turn; quits when idle. | |
| 52 | 52 | | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. | |
| 53 | -| `↑` `↓` | In the box: move between its lines, and scroll the conversation from its first/last line. On the conversation: scroll one line. In the picker: move the choice. | | |
| 53 | +| `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. | | |
| 54 | 54 | | `PgUp` `PgDn` | Scroll one page. | |
| 55 | 55 | | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). | |
| 56 | 56 | | `Ctrl-L` | Clears the displayed conversation; the history is kept. | |
| @@ -50,7 +50,7 @@ In `mm -tui` the same commands exist; `/abort` when idle prints `⚠️ No gener | |||
| 50 | | `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. | | 50 | | `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. | |
| 51 | | `Ctrl-C` | Cancels the running turn; quits when idle. | | 51 | | `Ctrl-C` | Cancels the running turn; quits when idle. | |
| 52 | | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. | | 52 | | `Tab` | Moves the focus between the box and the conversation; completes the picker's choice when it is open. | |
| 53 | -| `↑` `↓` | In the box: move between its lines, and scroll the conversation from its first/last line. On the conversation: scroll one line. In the picker: move the choice. | | 53 | +| `↑` `↓` | In the box: move between its rows — a long line wraps at spaces into several rows — and scroll the conversation from its first/last row. On the conversation: scroll one line. In the picker: move the choice. | |
| 54 | | `PgUp` `PgDn` | Scroll one page. | | 54 | | `PgUp` `PgDn` | Scroll one page. | |
| 55 | | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). | | 55 | | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). | |
| 56 | | `Ctrl-L` | Clears the displayed conversation; the history is kept. | | 56 | | `Ctrl-L` | Clears the displayed conversation; the history is kept. | |
modified
docs/fr/how-to/use-the-window-mode.md +1 -1 | @@ -12,7 +12,7 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran | ||
| 12 | 12 | |
| 13 | 13 | Ou mettez `tui: true` dans le YAML et lancez `mm` comme d'habitude. La bannière que le mode ligne affiche apparaît comme première ligne de la conversation. |
| 14 | 14 | |
| 15 | -2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas). | |
| 15 | +2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Une ligne longue passe à la ligne entre les mots dans la zone (un mot plus long qu'une rangée est coupé au bord) ; la zone garde ses trois rangées et les fait défiler pour que celle du curseur reste visible, et ↑/↓ déplacent le curseur de rangée en rangée. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas). | |
| 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 | |
| @@ -12,7 +12,7 @@ Ce guide montre comment lancer mini-me dans une fenêtre unique en plein écran | |||
| 12 | 12 | ||
| 13 | Ou mettez `tui: true` dans le YAML et lancez `mm` comme d'habitude. La bannière que le mode ligne affiche apparaît comme première ligne de la conversation. | 13 | Ou mettez `tui: true` dans le YAML et lancez `mm` comme d'habitude. La bannière que le mode ligne affiche apparaît comme première ligne de la conversation. |
| 14 | 14 | ||
| 15 | -2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas). | 15 | +2. Tapez votre question dans la zone du bas et appuyez sur **Entrée**. Une ligne longue passe à la ligne entre les mots dans la zone (un mot plus long qu'une rangée est coupé au bord) ; la zone garde ses trois rangées et les fait défiler pour que celle du curseur reste visible, et ↑/↓ déplacent le curseur de rangée en rangée. Pour un second paragraphe, appuyez sur **Ctrl-J** (ou Alt-Entrée quand votre terminal le transmet) au lieu d'Entrée. Un bloc collé est inséré en entier, jamais envoyé ; un bloc long est replié en un jeton `[Pasted #1 · …]` (voir plus bas). |
| 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 | ||
modified
docs/fr/reference/slash-commands.md +1 -1 | @@ -50,7 +50,7 @@ Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ | ||
| 50 | 50 | | `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. | |
| 51 | 51 | | `Ctrl-C` | Annule le tour en cours ; quitte au repos. | |
| 52 | 52 | | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. | |
| 53 | -| `↑` `↓` | Dans la zone : passent d'une ligne à l'autre, et font défiler la conversation depuis sa première/dernière ligne. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. | | |
| 53 | +| `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. | | |
| 54 | 54 | | `PgUp` `PgDn` | Défilent d'une page. | |
| 55 | 55 | | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). | |
| 56 | 56 | | `Ctrl-L` | Efface la conversation affichée ; l'historique est conservé. | |
| @@ -50,7 +50,7 @@ Dans `mm -tui` les mêmes commandes existent ; `/abort` au repos affiche `⚠️ | |||
| 50 | | `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. | | 50 | | `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. | |
| 51 | | `Ctrl-C` | Annule le tour en cours ; quitte au repos. | | 51 | | `Ctrl-C` | Annule le tour en cours ; quitte au repos. | |
| 52 | | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. | | 52 | | `Tab` | Déplace le focus entre la zone et la conversation ; complète le choix du sélecteur quand il est ouvert. | |
| 53 | -| `↑` `↓` | Dans la zone : passent d'une ligne à l'autre, et font défiler la conversation depuis sa première/dernière ligne. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. | | 53 | +| `↑` `↓` | Dans la zone : passent d'une rangée à l'autre — une ligne longue est repliée aux espaces sur plusieurs rangées — et font défiler la conversation depuis sa première/dernière rangée. Sur la conversation : défilent d'une ligne. Dans le sélecteur : déplacent le choix. | |
| 54 | | `PgUp` `PgDn` | Défilent d'une page. | | 54 | | `PgUp` `PgDn` | Défilent d'une page. | |
| 55 | | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). | | 55 | | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). | |
| 56 | | `Ctrl-L` | Efface la conversation affichée ; l'historique est conservé. | | 56 | | `Ctrl-L` | Efface la conversation affichée ; l'historique est conservé. | |
modified
internal/tui/draw.go +17 -28 | @@ -99,34 +99,40 @@ func (v *View) ruleLabel() string { | ||
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | 101 | |
| 102 | -// drawInput paints what has been typed and the cursor. A paste token is one | |
| 103 | -// rune in the input but a whole label on the screen, so lines are drawn | |
| 104 | -// rune by rune and the cursor column is measured the same way. | |
| 102 | +// drawInput paints what has been typed, word-wrapped to the box's width | |
| 103 | +// (inputwrap.go), and the cursor. The box shows as many rows as it has and | |
| 104 | +// scrolls so the cursor's row is always one of them. A paste token is one | |
| 105 | +// rune in the input but a whole label on the screen, so rows are drawn rune | |
| 106 | +// by rune and the cursor column is measured the same way. | |
| 105 | 107 | func (v *View) drawInput(p painter) { |
| 106 | 108 | height := p.height() |
| 107 | 109 | if height < 1 { |
| 108 | 110 | return |
| 109 | 111 | } |
| 110 | 112 | p.text(0, 0, ">", stPrompt) |
| 111 | - from := max(v.cursor-height+1, 0) | |
| 113 | + width := max(p.width()-2, 1) | |
| 114 | + rows := v.inputRows(width) | |
| 115 | + cur, cx := v.cursorRow(rows) | |
| 116 | + top := max(cur-height+1, 0) | |
| 112 | 117 | for row := 0; row < height; row++ { |
| 113 | - at := from + row | |
| 114 | - if at >= len(v.input) { | |
| 118 | + at := top + row | |
| 119 | + if at >= len(rows) { | |
| 115 | 120 | break |
| 116 | 121 | } |
| 117 | - v.drawInputLine(p, row, v.input[at]) | |
| 122 | + v.drawInputRow(p, row, rows[at]) | |
| 118 | 123 | } |
| 119 | 124 | if v.onInput { |
| 120 | - p.showCursor(2+v.cursorX(), v.cursor-from) | |
| 125 | + p.showCursor(2+min(cx, width-1), cur-top) | |
| 121 | 126 | } else { |
| 122 | 127 | v.screen.HideCursor() |
| 123 | 128 | } |
| 124 | 129 | } |
| 125 | 130 | |
| 126 | -// drawInputLine paints one line of the box from column 2: plain runes as | |
| 131 | +// drawInputRow paints one row of the box from column 2: plain runes as | |
| 127 | 132 | // they are, paste tokens as their label in the token style, clipped with an |
| 128 | -// ellipsis at the right edge. | |
| 129 | -func (v *View) drawInputLine(p painter, row int, line string) { | |
| 133 | +// ellipsis at the right edge (a label wider than the row). | |
| 134 | +func (v *View) drawInputRow(p painter, row int, r inputRow) { | |
| 135 | + line := string([]rune(v.input[r.line])[r.from:r.to]) | |
| 130 | 136 | x, limit := 2, p.width() |
| 131 | 137 | put := func(text string, style tcell.Style) { |
| 132 | 138 | for _, r := range text { |
| @@ -151,23 +157,6 @@ func (v *View) drawInputLine(p painter, row int, line string) { | ||
| 151 | 157 | } |
| 152 | 158 | } |
| 153 | 159 | |
| 154 | -// cursorX is the screen column of the cursor within its line: one cell per | |
| 155 | -// rune, a label's width per paste token. | |
| 156 | -func (v *View) cursorX() int { | |
| 157 | - x := 0 | |
| 158 | - for i, r := range v.runes() { | |
| 159 | - if i >= v.column { | |
| 160 | - break | |
| 161 | - } | |
| 162 | - if label, ok := v.pastes.labelOf(r); ok { | |
| 163 | - x += len([]rune(label)) | |
| 164 | - continue | |
| 165 | - } | |
| 166 | - x++ | |
| 167 | - } | |
| 168 | - return x | |
| 169 | -} | |
| 170 | - | |
| 171 | 160 | // drawPermission replaces the box with the question a tool is waiting on. |
| 172 | 161 | func (v *View) drawPermission(p painter) { |
| 173 | 162 | v.screen.HideCursor() |
| @@ -99,34 +99,40 @@ func (v *View) ruleLabel() string { | |||
| 99 | } | 99 | } |
| 100 | } | 100 | } |
| 101 | 101 | ||
| 102 | -// drawInput paints what has been typed and the cursor. A paste token is one | 102 | +// drawInput paints what has been typed, word-wrapped to the box's width |
| 103 | -// rune in the input but a whole label on the screen, so lines are drawn | 103 | +// (inputwrap.go), and the cursor. The box shows as many rows as it has and |
| 104 | -// rune by rune and the cursor column is measured the same way. | 104 | +// scrolls so the cursor's row is always one of them. A paste token is one |
| 105 | +// rune in the input but a whole label on the screen, so rows are drawn rune | ||
| 106 | +// by rune and the cursor column is measured the same way. | ||
| 105 | func (v *View) drawInput(p painter) { | 107 | func (v *View) drawInput(p painter) { |
| 106 | height := p.height() | 108 | height := p.height() |
| 107 | if height < 1 { | 109 | if height < 1 { |
| 108 | return | 110 | return |
| 109 | } | 111 | } |
| 110 | p.text(0, 0, ">", stPrompt) | 112 | p.text(0, 0, ">", stPrompt) |
| 111 | - from := max(v.cursor-height+1, 0) | 113 | + width := max(p.width()-2, 1) |
| 114 | + rows := v.inputRows(width) | ||
| 115 | + cur, cx := v.cursorRow(rows) | ||
| 116 | + top := max(cur-height+1, 0) | ||
| 112 | for row := 0; row < height; row++ { | 117 | for row := 0; row < height; row++ { |
| 113 | - at := from + row | 118 | + at := top + row |
| 114 | - if at >= len(v.input) { | 119 | + if at >= len(rows) { |
| 115 | break | 120 | break |
| 116 | } | 121 | } |
| 117 | - v.drawInputLine(p, row, v.input[at]) | 122 | + v.drawInputRow(p, row, rows[at]) |
| 118 | } | 123 | } |
| 119 | if v.onInput { | 124 | if v.onInput { |
| 120 | - p.showCursor(2+v.cursorX(), v.cursor-from) | 125 | + p.showCursor(2+min(cx, width-1), cur-top) |
| 121 | } else { | 126 | } else { |
| 122 | v.screen.HideCursor() | 127 | v.screen.HideCursor() |
| 123 | } | 128 | } |
| 124 | } | 129 | } |
| 125 | 130 | ||
| 126 | -// drawInputLine paints one line of the box from column 2: plain runes as | 131 | +// drawInputRow paints one row of the box from column 2: plain runes as |
| 127 | // they are, paste tokens as their label in the token style, clipped with an | 132 | // they are, paste tokens as their label in the token style, clipped with an |
| 128 | -// ellipsis at the right edge. | 133 | +// ellipsis at the right edge (a label wider than the row). |
| 129 | -func (v *View) drawInputLine(p painter, row int, line string) { | 134 | +func (v *View) drawInputRow(p painter, row int, r inputRow) { |
| 135 | + line := string([]rune(v.input[r.line])[r.from:r.to]) | ||
| 130 | x, limit := 2, p.width() | 136 | x, limit := 2, p.width() |
| 131 | put := func(text string, style tcell.Style) { | 137 | put := func(text string, style tcell.Style) { |
| 132 | for _, r := range text { | 138 | for _, r := range text { |
| @@ -151,23 +157,6 @@ func (v *View) drawInputLine(p painter, row int, line string) { | |||
| 151 | } | 157 | } |
| 152 | } | 158 | } |
| 153 | 159 | ||
| 154 | -// cursorX is the screen column of the cursor within its line: one cell per | ||
| 155 | -// rune, a label's width per paste token. | ||
| 156 | -func (v *View) cursorX() int { | ||
| 157 | - x := 0 | ||
| 158 | - for i, r := range v.runes() { | ||
| 159 | - if i >= v.column { | ||
| 160 | - break | ||
| 161 | - } | ||
| 162 | - if label, ok := v.pastes.labelOf(r); ok { | ||
| 163 | - x += len([]rune(label)) | ||
| 164 | - continue | ||
| 165 | - } | ||
| 166 | - x++ | ||
| 167 | - } | ||
| 168 | - return x | ||
| 169 | -} | ||
| 170 | - | ||
| 171 | // drawPermission replaces the box with the question a tool is waiting on. | 160 | // drawPermission replaces the box with the question a tool is waiting on. |
| 172 | func (v *View) drawPermission(p painter) { | 161 | func (v *View) drawPermission(p painter) { |
| 173 | v.screen.HideCursor() | 162 | v.screen.HideCursor() |
modified
internal/tui/events.go +2 -12 | @@ -161,17 +161,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool { | ||
| 161 | 161 | case tcell.KeyEnd: |
| 162 | 162 | v.column = len(v.runes()) |
| 163 | 163 | case tcell.KeyUp: |
| 164 | - if v.cursor == 0 { | |
| 165 | - return false // scroll the conversation instead | |
| 166 | - } | |
| 167 | - v.cursor-- | |
| 168 | - v.clampColumn() | |
| 164 | + return v.moveRow(-1) // false on the first row: scroll the conversation instead | |
| 169 | 165 | case tcell.KeyDown: |
| 170 | - if v.cursor >= len(v.input)-1 { | |
| 171 | - return false | |
| 172 | - } | |
| 173 | - v.cursor++ | |
| 174 | - v.clampColumn() | |
| 166 | + return v.moveRow(1) | |
| 175 | 167 | default: |
| 176 | 168 | return false |
| 177 | 169 | } |
| @@ -286,5 +278,3 @@ func (v *View) runes() []rune { | ||
| 286 | 278 | } |
| 287 | 279 | return []rune(v.input[v.cursor]) |
| 288 | 280 | } |
| 289 | - | |
| 290 | -func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) } | |
| @@ -161,17 +161,9 @@ func (v *View) handleInputKey(ev *tcell.EventKey) bool { | |||
| 161 | case tcell.KeyEnd: | 161 | case tcell.KeyEnd: |
| 162 | v.column = len(v.runes()) | 162 | v.column = len(v.runes()) |
| 163 | case tcell.KeyUp: | 163 | case tcell.KeyUp: |
| 164 | - if v.cursor == 0 { | 164 | + return v.moveRow(-1) // false on the first row: scroll the conversation instead |
| 165 | - return false // scroll the conversation instead | ||
| 166 | - } | ||
| 167 | - v.cursor-- | ||
| 168 | - v.clampColumn() | ||
| 169 | case tcell.KeyDown: | 165 | case tcell.KeyDown: |
| 170 | - if v.cursor >= len(v.input)-1 { | 166 | + return v.moveRow(1) |
| 171 | - return false | ||
| 172 | - } | ||
| 173 | - v.cursor++ | ||
| 174 | - v.clampColumn() | ||
| 175 | default: | 167 | default: |
| 176 | return false | 168 | return false |
| 177 | } | 169 | } |
| @@ -286,5 +278,3 @@ func (v *View) runes() []rune { | |||
| 286 | } | 278 | } |
| 287 | return []rune(v.input[v.cursor]) | 279 | return []rune(v.input[v.cursor]) |
| 288 | } | 280 | } |
| 289 | - | ||
| 290 | -func (v *View) clampColumn() { v.column = min(v.column, len(v.runes())) } | ||
added
internal/tui/inputwrap.go +135 -0 | new file mode 100644 | ||
| @@ -0,0 +1,135 @@ | ||
| 1 | +package tui | |
| 2 | + | |
| 3 | +// Word wrap in the box. What is typed is kept as logical lines (v.input, one | |
| 4 | +// per Ctrl-J); the screen shows rows. A logical line wider than the box is | |
| 5 | +// laid out on several rows, broken after a space where there is one and | |
| 6 | +// mid-word when a word is longer than the row — the transcript's rule | |
| 7 | +// (wrap.go), redone here because a paste token is one rune in the text and | |
| 8 | +// a whole label on the screen, so the width is counted in cells, not runes. | |
| 9 | +// The cursor lives in the logical text (line, column) and is placed on the | |
| 10 | +// row that holds it at draw time; ↑/↓ move between rows. | |
| 11 | + | |
| 12 | +// inputRow is one screen row of the box: the runes [from, to) of logical | |
| 13 | +// line line. A row that ends at a soft break keeps the space it broke after, | |
| 14 | +// so the column count of the text is never off by one; the space is simply | |
| 15 | +// not drawn when it falls past the edge. | |
| 16 | +type inputRow struct { | |
| 17 | + line int | |
| 18 | + from, to int | |
| 19 | +} | |
| 20 | + | |
| 21 | +// inputWidth is how many cells a row of the box may use: the prompt "> " | |
| 22 | +// takes the first two. | |
| 23 | +func (v *View) inputWidth() int { | |
| 24 | + w, _ := v.size() | |
| 25 | + return max(w-2, 1) | |
| 26 | +} | |
| 27 | + | |
| 28 | +// cellWidth is what one rune of the input takes on the screen: a cell, or | |
| 29 | +// a paste label's width. | |
| 30 | +func (v *View) cellWidth(r rune) int { | |
| 31 | + if label, ok := v.pastes.labelOf(r); ok { | |
| 32 | + return len([]rune(label)) | |
| 33 | + } | |
| 34 | + return 1 | |
| 35 | +} | |
| 36 | + | |
| 37 | +// inputRows lays the typed lines out at width cells. Every logical line | |
| 38 | +// yields at least one row — an empty line is an empty row, not nothing. | |
| 39 | +func (v *View) inputRows(width int) []inputRow { | |
| 40 | + width = max(width, 1) | |
| 41 | + var rows []inputRow | |
| 42 | + for i, line := range v.input { | |
| 43 | + rows = append(rows, v.wrapInputLine(i, []rune(line), width)...) | |
| 44 | + } | |
| 45 | + return rows | |
| 46 | +} | |
| 47 | + | |
| 48 | +// wrapInputLine breaks one logical line into rows. A space never triggers a | |
| 49 | +// break: it hangs past the edge, invisible, so a row never starts with the | |
| 50 | +// space the previous one broke after. | |
| 51 | +func (v *View) wrapInputLine(line int, runes []rune, width int) []inputRow { | |
| 52 | + var rows []inputRow | |
| 53 | + from, x, lastSpace := 0, 0, -1 | |
| 54 | + for i, r := range runes { | |
| 55 | + w := v.cellWidth(r) | |
| 56 | + if r == ' ' { | |
| 57 | + lastSpace = i | |
| 58 | + x += w | |
| 59 | + continue | |
| 60 | + } | |
| 61 | + if x+w > width && i > from { | |
| 62 | + to := i | |
| 63 | + if lastSpace >= from { | |
| 64 | + to = lastSpace + 1 | |
| 65 | + } | |
| 66 | + rows = append(rows, inputRow{line: line, from: from, to: to}) | |
| 67 | + from, x, lastSpace = to, 0, -1 | |
| 68 | + for j := from; j < i; j++ { | |
| 69 | + x += v.cellWidth(runes[j]) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + x += w | |
| 73 | + } | |
| 74 | + return append(rows, inputRow{line: line, from: from, to: len(runes)}) | |
| 75 | +} | |
| 76 | + | |
| 77 | +// cursorRow finds the row holding the cursor and the cursor's x offset in | |
| 78 | +// it. A column sitting exactly on a soft break belongs to the next row, so | |
| 79 | +// typing at the edge shows the cursor where the next rune will land. | |
| 80 | +func (v *View) cursorRow(rows []inputRow) (row, x int) { | |
| 81 | + for i, r := range rows { | |
| 82 | + if r.line != v.cursor { | |
| 83 | + continue | |
| 84 | + } | |
| 85 | + last := i+1 >= len(rows) || rows[i+1].line != r.line | |
| 86 | + if v.column < r.to || (v.column == r.to && last) || v.column < r.from { | |
| 87 | + return i, v.cellsBetween(r.from, min(v.column, r.to)) | |
| 88 | + } | |
| 89 | + } | |
| 90 | + return max(len(rows)-1, 0), 0 | |
| 91 | +} | |
| 92 | + | |
| 93 | +// cellsBetween is the width of the cursor line's runes [from, to). | |
| 94 | +func (v *View) cellsBetween(from, to int) int { | |
| 95 | + x := 0 | |
| 96 | + for _, r := range v.runes()[from:to] { | |
| 97 | + x += v.cellWidth(r) | |
| 98 | + } | |
| 99 | + return x | |
| 100 | +} | |
| 101 | + | |
| 102 | +// cursorX is the cursor's x offset within its row. | |
| 103 | +func (v *View) cursorX() int { | |
| 104 | + _, x := v.cursorRow(v.inputRows(v.inputWidth())) | |
| 105 | + return x | |
| 106 | +} | |
| 107 | + | |
| 108 | +// moveRow moves the cursor to the row delta away (−1 up, +1 down), keeping | |
| 109 | +// its x offset as far as the target row goes, and reports whether there was | |
| 110 | +// such a row — false at the box's first or last row, where ↑/↓ scroll the | |
| 111 | +// conversation instead. | |
| 112 | +func (v *View) moveRow(delta int) bool { | |
| 113 | + rows := v.inputRows(v.inputWidth()) | |
| 114 | + at, x := v.cursorRow(rows) | |
| 115 | + target := at + delta | |
| 116 | + if target < 0 || target >= len(rows) { | |
| 117 | + return false | |
| 118 | + } | |
| 119 | + row := rows[target] | |
| 120 | + v.cursor = row.line | |
| 121 | + // The last column of a row that soft-wraps is the one before its break: | |
| 122 | + // one further and the cursor would show at the start of the next row. | |
| 123 | + limit := row.to | |
| 124 | + if target+1 < len(rows) && rows[target+1].line == row.line && row.to > row.from { | |
| 125 | + limit = row.to - 1 | |
| 126 | + } | |
| 127 | + runes := v.runes() | |
| 128 | + col, width := row.from, 0 | |
| 129 | + for col < limit && width+v.cellWidth(runes[col]) <= x { | |
| 130 | + width += v.cellWidth(runes[col]) | |
| 131 | + col++ | |
| 132 | + } | |
| 133 | + v.column = col | |
| 134 | + return true | |
| 135 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,135 @@ | |||
| 1 | +package tui | ||
| 2 | + | ||
| 3 | +// Word wrap in the box. What is typed is kept as logical lines (v.input, one | ||
| 4 | +// per Ctrl-J); the screen shows rows. A logical line wider than the box is | ||
| 5 | +// laid out on several rows, broken after a space where there is one and | ||
| 6 | +// mid-word when a word is longer than the row — the transcript's rule | ||
| 7 | +// (wrap.go), redone here because a paste token is one rune in the text and | ||
| 8 | +// a whole label on the screen, so the width is counted in cells, not runes. | ||
| 9 | +// The cursor lives in the logical text (line, column) and is placed on the | ||
| 10 | +// row that holds it at draw time; ↑/↓ move between rows. | ||
| 11 | + | ||
| 12 | +// inputRow is one screen row of the box: the runes [from, to) of logical | ||
| 13 | +// line line. A row that ends at a soft break keeps the space it broke after, | ||
| 14 | +// so the column count of the text is never off by one; the space is simply | ||
| 15 | +// not drawn when it falls past the edge. | ||
| 16 | +type inputRow struct { | ||
| 17 | + line int | ||
| 18 | + from, to int | ||
| 19 | +} | ||
| 20 | + | ||
| 21 | +// inputWidth is how many cells a row of the box may use: the prompt "> " | ||
| 22 | +// takes the first two. | ||
| 23 | +func (v *View) inputWidth() int { | ||
| 24 | + w, _ := v.size() | ||
| 25 | + return max(w-2, 1) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +// cellWidth is what one rune of the input takes on the screen: a cell, or | ||
| 29 | +// a paste label's width. | ||
| 30 | +func (v *View) cellWidth(r rune) int { | ||
| 31 | + if label, ok := v.pastes.labelOf(r); ok { | ||
| 32 | + return len([]rune(label)) | ||
| 33 | + } | ||
| 34 | + return 1 | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +// inputRows lays the typed lines out at width cells. Every logical line | ||
| 38 | +// yields at least one row — an empty line is an empty row, not nothing. | ||
| 39 | +func (v *View) inputRows(width int) []inputRow { | ||
| 40 | + width = max(width, 1) | ||
| 41 | + var rows []inputRow | ||
| 42 | + for i, line := range v.input { | ||
| 43 | + rows = append(rows, v.wrapInputLine(i, []rune(line), width)...) | ||
| 44 | + } | ||
| 45 | + return rows | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +// wrapInputLine breaks one logical line into rows. A space never triggers a | ||
| 49 | +// break: it hangs past the edge, invisible, so a row never starts with the | ||
| 50 | +// space the previous one broke after. | ||
| 51 | +func (v *View) wrapInputLine(line int, runes []rune, width int) []inputRow { | ||
| 52 | + var rows []inputRow | ||
| 53 | + from, x, lastSpace := 0, 0, -1 | ||
| 54 | + for i, r := range runes { | ||
| 55 | + w := v.cellWidth(r) | ||
| 56 | + if r == ' ' { | ||
| 57 | + lastSpace = i | ||
| 58 | + x += w | ||
| 59 | + continue | ||
| 60 | + } | ||
| 61 | + if x+w > width && i > from { | ||
| 62 | + to := i | ||
| 63 | + if lastSpace >= from { | ||
| 64 | + to = lastSpace + 1 | ||
| 65 | + } | ||
| 66 | + rows = append(rows, inputRow{line: line, from: from, to: to}) | ||
| 67 | + from, x, lastSpace = to, 0, -1 | ||
| 68 | + for j := from; j < i; j++ { | ||
| 69 | + x += v.cellWidth(runes[j]) | ||
| 70 | + } | ||
| 71 | + } | ||
| 72 | + x += w | ||
| 73 | + } | ||
| 74 | + return append(rows, inputRow{line: line, from: from, to: len(runes)}) | ||
| 75 | +} | ||
| 76 | + | ||
| 77 | +// cursorRow finds the row holding the cursor and the cursor's x offset in | ||
| 78 | +// it. A column sitting exactly on a soft break belongs to the next row, so | ||
| 79 | +// typing at the edge shows the cursor where the next rune will land. | ||
| 80 | +func (v *View) cursorRow(rows []inputRow) (row, x int) { | ||
| 81 | + for i, r := range rows { | ||
| 82 | + if r.line != v.cursor { | ||
| 83 | + continue | ||
| 84 | + } | ||
| 85 | + last := i+1 >= len(rows) || rows[i+1].line != r.line | ||
| 86 | + if v.column < r.to || (v.column == r.to && last) || v.column < r.from { | ||
| 87 | + return i, v.cellsBetween(r.from, min(v.column, r.to)) | ||
| 88 | + } | ||
| 89 | + } | ||
| 90 | + return max(len(rows)-1, 0), 0 | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// cellsBetween is the width of the cursor line's runes [from, to). | ||
| 94 | +func (v *View) cellsBetween(from, to int) int { | ||
| 95 | + x := 0 | ||
| 96 | + for _, r := range v.runes()[from:to] { | ||
| 97 | + x += v.cellWidth(r) | ||
| 98 | + } | ||
| 99 | + return x | ||
| 100 | +} | ||
| 101 | + | ||
| 102 | +// cursorX is the cursor's x offset within its row. | ||
| 103 | +func (v *View) cursorX() int { | ||
| 104 | + _, x := v.cursorRow(v.inputRows(v.inputWidth())) | ||
| 105 | + return x | ||
| 106 | +} | ||
| 107 | + | ||
| 108 | +// moveRow moves the cursor to the row delta away (−1 up, +1 down), keeping | ||
| 109 | +// its x offset as far as the target row goes, and reports whether there was | ||
| 110 | +// such a row — false at the box's first or last row, where ↑/↓ scroll the | ||
| 111 | +// conversation instead. | ||
| 112 | +func (v *View) moveRow(delta int) bool { | ||
| 113 | + rows := v.inputRows(v.inputWidth()) | ||
| 114 | + at, x := v.cursorRow(rows) | ||
| 115 | + target := at + delta | ||
| 116 | + if target < 0 || target >= len(rows) { | ||
| 117 | + return false | ||
| 118 | + } | ||
| 119 | + row := rows[target] | ||
| 120 | + v.cursor = row.line | ||
| 121 | + // The last column of a row that soft-wraps is the one before its break: | ||
| 122 | + // one further and the cursor would show at the start of the next row. | ||
| 123 | + limit := row.to | ||
| 124 | + if target+1 < len(rows) && rows[target+1].line == row.line && row.to > row.from { | ||
| 125 | + limit = row.to - 1 | ||
| 126 | + } | ||
| 127 | + runes := v.runes() | ||
| 128 | + col, width := row.from, 0 | ||
| 129 | + for col < limit && width+v.cellWidth(runes[col]) <= x { | ||
| 130 | + width += v.cellWidth(runes[col]) | ||
| 131 | + col++ | ||
| 132 | + } | ||
| 133 | + v.column = col | ||
| 134 | + return true | ||
| 135 | +} | ||
modified
internal/tui/view_test.go +50 -0 | @@ -465,3 +465,53 @@ func TestShouldCollapse(t *testing.T) { | ||
| 465 | 465 | } |
| 466 | 466 | } |
| 467 | 467 | } |
| 468 | + | |
| 469 | +// The box wraps a long line at word boundaries — mid-word only when a word is | |
| 470 | +// longer than a row — keeps the cursor on the row that holds it, moves it | |
| 471 | +// between rows with ↑/↓ (and lets ↑ on the first row scroll the conversation), | |
| 472 | +// and scrolls its rows so the cursor's is always visible. | |
| 473 | +func TestInputWordWrap(t *testing.T) { | |
| 474 | + hs := newHarness(t, 22, 12) // box rows 8–10, 20 cells of text each | |
| 475 | + hs.typeText("hello wonderful world again") | |
| 476 | + rows := hs.rows() | |
| 477 | + if rows[8] != "> hello wonderful" || rows[9] != " world again" { | |
| 478 | + t.Fatalf("wrapped rows %q", rows[8:10]) | |
| 479 | + } | |
| 480 | + if x, y, _ := hs.screen.GetCursor(); x != 2+len("world again") || y != 9 { | |
| 481 | + t.Errorf("cursor at (%d,%d), want (%d,9)", x, y, 2+len("world again")) | |
| 482 | + } | |
| 483 | + | |
| 484 | + hs.key(tcell.KeyUp, tcell.ModNone) | |
| 485 | + if hs.v.cursor != 0 || hs.v.column != len("world again") { | |
| 486 | + t.Errorf("↑ onto the first row: line %d col %d", hs.v.cursor, hs.v.column) | |
| 487 | + } | |
| 488 | + hs.v.t.AddUser(strings.Repeat("filler\n", 12)) // something to scroll | |
| 489 | + hs.rows() // laid out, following the end | |
| 490 | + hs.key(tcell.KeyUp, tcell.ModNone) // first row: the conversation scrolls, the cursor stays | |
| 491 | + if hs.v.cursor != 0 || hs.v.follow { | |
| 492 | + t.Errorf("↑ on the first row: line %d, follow %v", hs.v.cursor, hs.v.follow) | |
| 493 | + } | |
| 494 | + hs.key(tcell.KeyDown, tcell.ModNone) | |
| 495 | + if hs.v.cursor != 0 || hs.v.column != len("hello wonderful world again") { | |
| 496 | + t.Errorf("↓ back onto the last row: line %d col %d", hs.v.cursor, hs.v.column) | |
| 497 | + } | |
| 498 | + hs.key(tcell.KeyHome, tcell.ModNone) | |
| 499 | + hs.key(tcell.KeyDown, tcell.ModNone) | |
| 500 | + if hs.v.column != len("hello wonderful ") { | |
| 501 | + t.Errorf("↓ from column 0 lands at column %d, want the start of the second row", hs.v.column) | |
| 502 | + } | |
| 503 | + | |
| 504 | + hs.v.setInput("abcdefghijklmnopqrstuvwxyz") | |
| 505 | + if rows := hs.rows(); rows[8] != "> abcdefghijklmnopqrst" || rows[9] != " uvwxyz" { | |
| 506 | + t.Errorf("a word longer than the row is cut at the edge: %q", rows[8:10]) | |
| 507 | + } | |
| 508 | + | |
| 509 | + hs.v.setInput("aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm") | |
| 510 | + rows = hs.rows() | |
| 511 | + if rows[8] != "> eeee ffff gggg hhhh" || rows[9] != " iiii jjjj kkkk llll" || rows[10] != " mmmm" { | |
| 512 | + t.Errorf("the box scrolls to the cursor's row: %q", rows[8:11]) | |
| 513 | + } | |
| 514 | + if _, y, _ := hs.screen.GetCursor(); y != 10 { | |
| 515 | + t.Errorf("cursor row %d, want the box's last row (10)", y) | |
| 516 | + } | |
| 517 | +} | |
| @@ -465,3 +465,53 @@ func TestShouldCollapse(t *testing.T) { | |||
| 465 | } | 465 | } |
| 466 | } | 466 | } |
| 467 | } | 467 | } |
| 468 | + | ||
| 469 | +// The box wraps a long line at word boundaries — mid-word only when a word is | ||
| 470 | +// longer than a row — keeps the cursor on the row that holds it, moves it | ||
| 471 | +// between rows with ↑/↓ (and lets ↑ on the first row scroll the conversation), | ||
| 472 | +// and scrolls its rows so the cursor's is always visible. | ||
| 473 | +func TestInputWordWrap(t *testing.T) { | ||
| 474 | + hs := newHarness(t, 22, 12) // box rows 8–10, 20 cells of text each | ||
| 475 | + hs.typeText("hello wonderful world again") | ||
| 476 | + rows := hs.rows() | ||
| 477 | + if rows[8] != "> hello wonderful" || rows[9] != " world again" { | ||
| 478 | + t.Fatalf("wrapped rows %q", rows[8:10]) | ||
| 479 | + } | ||
| 480 | + if x, y, _ := hs.screen.GetCursor(); x != 2+len("world again") || y != 9 { | ||
| 481 | + t.Errorf("cursor at (%d,%d), want (%d,9)", x, y, 2+len("world again")) | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + hs.key(tcell.KeyUp, tcell.ModNone) | ||
| 485 | + if hs.v.cursor != 0 || hs.v.column != len("world again") { | ||
| 486 | + t.Errorf("↑ onto the first row: line %d col %d", hs.v.cursor, hs.v.column) | ||
| 487 | + } | ||
| 488 | + hs.v.t.AddUser(strings.Repeat("filler\n", 12)) // something to scroll | ||
| 489 | + hs.rows() // laid out, following the end | ||
| 490 | + hs.key(tcell.KeyUp, tcell.ModNone) // first row: the conversation scrolls, the cursor stays | ||
| 491 | + if hs.v.cursor != 0 || hs.v.follow { | ||
| 492 | + t.Errorf("↑ on the first row: line %d, follow %v", hs.v.cursor, hs.v.follow) | ||
| 493 | + } | ||
| 494 | + hs.key(tcell.KeyDown, tcell.ModNone) | ||
| 495 | + if hs.v.cursor != 0 || hs.v.column != len("hello wonderful world again") { | ||
| 496 | + t.Errorf("↓ back onto the last row: line %d col %d", hs.v.cursor, hs.v.column) | ||
| 497 | + } | ||
| 498 | + hs.key(tcell.KeyHome, tcell.ModNone) | ||
| 499 | + hs.key(tcell.KeyDown, tcell.ModNone) | ||
| 500 | + if hs.v.column != len("hello wonderful ") { | ||
| 501 | + t.Errorf("↓ from column 0 lands at column %d, want the start of the second row", hs.v.column) | ||
| 502 | + } | ||
| 503 | + | ||
| 504 | + hs.v.setInput("abcdefghijklmnopqrstuvwxyz") | ||
| 505 | + if rows := hs.rows(); rows[8] != "> abcdefghijklmnopqrst" || rows[9] != " uvwxyz" { | ||
| 506 | + t.Errorf("a word longer than the row is cut at the edge: %q", rows[8:10]) | ||
| 507 | + } | ||
| 508 | + | ||
| 509 | + hs.v.setInput("aaaa bbbb cccc dddd eeee ffff gggg hhhh iiii jjjj kkkk llll mmmm") | ||
| 510 | + rows = hs.rows() | ||
| 511 | + if rows[8] != "> eeee ffff gggg hhhh" || rows[9] != " iiii jjjj kkkk llll" || rows[10] != " mmmm" { | ||
| 512 | + t.Errorf("the box scrolls to the cursor's row: %q", rows[8:11]) | ||
| 513 | + } | ||
| 514 | + if _, y, _ := hs.screen.GetCursor(); y != 10 { | ||
| 515 | + t.Errorf("cursor row %d, want the box's last row (10)", y) | ||
| 516 | + } | ||
| 517 | +} | ||