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

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

Download ZIP

🛟 Updated.

k33g committed 2026-09-18T17:25:36+02:00 Browse files
49d3dce parent: 099bf1b
modified .tickets/issues/0020-editbox-in-tui-mode-word-text-wrap.yaml +56 -3
@@ -6,9 +6,62 @@ author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-17T13:26:49.530Z
9-updatedAt: 2026-09-17T13:35:27.249Z
9+updatedAt: 2026-09-18T15:00:00.000Z
1010 labels: []
1111 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
1467 comments: []
@@ -6,9 +6,62 @@ author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-17T13:26:49.530Z8 createdAt: 2026-09-17T13:26:49.530Z
9-updatedAt: 2026-09-17T13:35:27.249Z9+updatedAt: 2026-09-18T15:00:00.000Z
10 labels: []10 labels: []
11 milestone: v0.0.211 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 @@
11 # Managed by IssueSpec. Hand edits are welcome; keep the schema valid.
22 id: 21
33 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
55 author:
66 name: k33g
77 email: ph.charriere@gmail.com
88 createdAt: 2026-09-17T13:31:10.696Z
9-updatedAt: 2026-09-18T14:20:00.000Z
9+updatedAt: 2026-09-18T15:12:38.136Z
1010 labels:
1111 - priority::urgent
1212 milestone: v0.0.2
@@ -52,7 +52,7 @@ tasks:
5252 updatedAt: 2026-09-18T14:20:00.000Z
5353 closedAt: 2026-09-18T14:20:00.000Z
5454 - 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
5656 state: closed
5757 priority: none
5858 author:
@@ -72,7 +72,7 @@ tasks:
7272 updatedAt: 2026-09-18T14:20:00.000Z
7373 closedAt: 2026-09-18T14:20:00.000Z
7474 - 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)
7676 state: closed
7777 priority: none
7878 author:
@@ -83,10 +83,12 @@ tasks:
8383 closedAt: 2026-09-18T14:20:00.000Z
8484 - id: 5
8585 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
8787 priority: medium
8888 author:
8989 name: k33g
9090 email: ph.charriere@gmail.com
9191 createdAt: 2026-09-18T14:20:00.000Z
92+ updatedAt: 2026-09-18T15:12:35.052Z
93+ closedAt: 2026-09-18T15:12:35.052Z
9294 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: 212 id: 21
3 title: When there is an error durring the completion, we should log it somewhere to help Claude to debug and improve3 title: When there is an error durring the completion, we should log it somewhere to help Claude to debug and improve
4-state: open4+state: closed
5 author:5 author:
6 name: k33g6 name: k33g
7 email: ph.charriere@gmail.com7 email: ph.charriere@gmail.com
8 createdAt: 2026-09-17T13:31:10.696Z8 createdAt: 2026-09-17T13:31:10.696Z
9-updatedAt: 2026-09-18T14:20:00.000Z9+updatedAt: 2026-09-18T15:12:38.136Z
10 labels:10 labels:
11 - priority::urgent11 - priority::urgent
12 milestone: v0.0.212 milestone: v0.0.2
@@ -52,7 +52,7 @@ tasks:
52 updatedAt: 2026-09-18T14:20:00.000Z52 updatedAt: 2026-09-18T14:20:00.000Z
53 closedAt: 2026-09-18T14:20:00.000Z53 closedAt: 2026-09-18T14:20:00.000Z
54 - id: 254 - 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: closed56 state: closed
57 priority: none57 priority: none
58 author:58 author:
@@ -72,7 +72,7 @@ tasks:
72 updatedAt: 2026-09-18T14:20:00.000Z72 updatedAt: 2026-09-18T14:20:00.000Z
73 closedAt: 2026-09-18T14:20:00.000Z73 closedAt: 2026-09-18T14:20:00.000Z
74 - id: 474 - 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: closed76 state: closed
77 priority: none77 priority: none
78 author:78 author:
@@ -83,10 +83,12 @@ tasks:
83 closedAt: 2026-09-18T14:20:00.000Z83 closedAt: 2026-09-18T14:20:00.000Z
84 - id: 584 - 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: open86+ state: closed
87 priority: medium87 priority: medium
88 author:88 author:
89 name: k33g89 name: k33g
90 email: ph.charriere@gmail.com90 email: ph.charriere@gmail.com
91 createdAt: 2026-09-18T14:20:00.000Z91 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
1212
1313 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.
1414
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).
1616
1717 3. Watch the answer stream into the conversation. Each tool call is one framed line — `‣ bash ls -1 ✓ done 0.03s` — followed by its output, folded to `previewLines` lines; the `+`/`-` lines of a file edit are coloured.
1818
@@ -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
5050 | `Esc` | Cancels the running turn; closes the picker; rejects a pending permission. |
5151 | `Ctrl-C` | Cancels the running turn; quits when idle. |
5252 | `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. |
5454 | `PgUp` `PgDn` | Scroll one page. |
5555 | `Home` `End` | In the box: start/end of the line. On the conversation: top / bottom (bottom resumes following). |
5656 | `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
1212
1313 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.
1414
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).
1616
1717 3. Regardez la réponse arriver dans la conversation. Chaque appel d'outil tient sur une ligne encadrée — `‣ bash ls -1 ✓ done 0.03s` — suivie de sa sortie, repliée à `previewLines` lignes ; les lignes `+`/`-` d'une modification de fichier sont colorées.
1818
@@ -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 `⚠️
5050 | `Échap` | Annule le tour en cours ; ferme le sélecteur ; refuse une permission en attente. |
5151 | `Ctrl-C` | Annule le tour en cours ; quitte au repos. |
5252 | `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. |
5454 | `PgUp` `PgDn` | Défilent d'une page. |
5555 | `Début` `Fin` | Dans la zone : début/fin de ligne. Sur la conversation : haut / bas (le bas reprend le suivi). |
5656 | `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 {
9999 }
100100 }
101101
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.
105107 func (v *View) drawInput(p painter) {
106108 height := p.height()
107109 if height < 1 {
108110 return
109111 }
110112 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)
112117 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) {
115120 break
116121 }
117- v.drawInputLine(p, row, v.input[at])
122+ v.drawInputRow(p, row, rows[at])
118123 }
119124 if v.onInput {
120- p.showCursor(2+v.cursorX(), v.cursor-from)
125+ p.showCursor(2+min(cx, width-1), cur-top)
121126 } else {
122127 v.screen.HideCursor()
123128 }
124129 }
125130
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
127132 // 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])
130136 x, limit := 2, p.width()
131137 put := func(text string, style tcell.Style) {
132138 for _, r := range text {
@@ -151,23 +157,6 @@ func (v *View) drawInputLine(p painter, row int, line string) {
151157 }
152158 }
153159
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-
171160 // drawPermission replaces the box with the question a tool is waiting on.
172161 func (v *View) drawPermission(p painter) {
173162 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 one102+// 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 drawn103+// (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 return110 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 + row118+ at := top + row
114- if at >= len(v.input) {119+ if at >= len(rows) {
115 break120 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 as131+// 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 an132 // 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 {
161161 case tcell.KeyEnd:
162162 v.column = len(v.runes())
163163 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
169165 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)
175167 default:
176168 return false
177169 }
@@ -286,5 +278,3 @@ func (v *View) runes() []rune {
286278 }
287279 return []rune(v.input[v.cursor])
288280 }
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 false168 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) {
465465 }
466466 }
467467 }
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+}