zames_pro 2.74.0 → 2.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +24 -0
  3. package/dist/attachments.js +6 -1
  4. package/dist/config.js +7 -7
  5. package/dist/i18n/common.js +4 -0
  6. package/dist/i18n/help.js +8 -0
  7. package/dist/index.js +9 -0
  8. package/dist/input/suggest.js +19 -4
  9. package/dist/theme.js +9 -0
  10. package/dist/tui/ConfigMenu.js +44 -11
  11. package/dist/tui/FullScreenApp.js +37 -9
  12. package/dist/tui/InputLine.js +52 -19
  13. package/dist/tui/SelectList.js +6 -2
  14. package/dist/tui/StatusBar.js +66 -36
  15. package/dist/tui/TuiApp.js +3 -2
  16. package/dist/tui/edit.js +21 -1
  17. package/dist/tui/keys.js +33 -0
  18. package/dist/tui/store.js +1 -1
  19. package/dist/tui/useMouseWheel.js +80 -29
  20. package/dist/tui/widgets.js +6 -1
  21. package/package.json +2 -1
  22. package/scripts/inline-status-driver.mts +31 -0
  23. package/scripts/inline-status.tsx +33 -0
  24. package/scripts/mouse-churn.mts +34 -0
  25. package/scripts/qwen-models-smoke.mts +117 -0
  26. package/scripts/tui-comp-shot.mts +63 -0
  27. package/scripts/tui-components.tsx +62 -0
  28. package/scripts/tui-config-driver.mts +82 -0
  29. package/scripts/tui-config-probe.tsx +74 -0
  30. package/scripts/tui-edit-driver.mts +100 -0
  31. package/scripts/tui-edit-probe.tsx +38 -0
  32. package/scripts/tui-editor-probe.tsx +24 -0
  33. package/scripts/tui-input-driver.mts +54 -0
  34. package/scripts/tui-input-shot.tsx +59 -0
  35. package/scripts/tui-log-driver.mts +35 -0
  36. package/scripts/tui-log-shot.tsx +48 -0
  37. package/scripts/tui-longpaste-driver.mts +56 -0
  38. package/scripts/tui-longpaste.tsx +35 -0
  39. package/scripts/tui-manyselect-shot.mts +34 -0
  40. package/scripts/tui-manyselect.tsx +17 -0
  41. package/scripts/tui-mouse-churn-probe.tsx +28 -0
  42. package/scripts/tui-paste-driver.mts +88 -0
  43. package/scripts/tui-paste-kinds.tsx +76 -0
  44. package/scripts/tui-pastetest.mts +38 -0
  45. package/scripts/tui-repro.tsx +64 -0
  46. package/scripts/tui-resize.mts +70 -0
  47. package/scripts/tui-scroll-driver.mts +53 -0
  48. package/scripts/tui-scroll-probe.tsx +46 -0
  49. package/scripts/tui-shot.mts +106 -0
  50. package/scripts/tui-status-driver.mts +24 -0
  51. package/scripts/tui-status-shot.tsx +27 -0
  52. package/scripts/tui-submit.tsx +57 -0
  53. package/scripts/tui-wheel-driver.mts +61 -0
  54. package/scripts/tui-wheel-probe.tsx +48 -0
  55. package/scripts/tui-widgets-driver.mts +20 -0
  56. package/scripts/tui-widgets.tsx +44 -0
package/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.75.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - **tui:** show a one-time hint about mouse selection
15
+ - **tui:** Home/End jump to the top/bottom of the dialog
16
+ - **tui:** mouse wheel scrolls the dialog by default
17
+
18
+ ### Fixed
19
+
20
+ - **tui:** the status row keeps its height (inline caret on the input row)
21
+ - **tui:** the status bar never wraps to a second line
22
+ - **tui:** the caret lands correctly in a CJK buffer
23
+ - **tui:** responsive dividers and wrapping-aware menu window
24
+ - **tui:** log scroll reads the LIVE offset (no stale closure)
25
+ - **tui:** Ctrl+Delete works (kitty CSI-u) and deletes left at end of line
26
+ - **tui:** the mouse wheel no longer scrolls the input history (for good)
27
+ - **attachments:** keep the items array identity across reset()
28
+ - **tui:** the config menu now redraws on change and fits the terminal
29
+ - **tui:** reserve the real number of rows for a wrapping suggest popup
30
+ - **tui:** exact badge width (T0.7) and honor NO_COLOR (T0.11)
31
+ - **tui:** deliver the FULL pasted text to the agent, not the [Pasted lines#N] marker
32
+ - **tui:** separate SELECT/SCROLL mouse modes and stop the header wrapping the panel
33
+
10
34
  ## [2.74.0] - 2026-10-13
11
35
 
12
36
  ### Added
package/README.md CHANGED
@@ -255,6 +255,18 @@ The prompt is a small line editor with persistent history:
255
255
  Claude Code's bash mode). The same sandbox guard as the `Bash` tool applies,
256
256
  so a direct command cannot leave the project either.
257
257
 
258
+ Full-screen mode adds dialog navigation, so a long answer can be read and
259
+ selected without leaving the terminal:
260
+
261
+ - mouse wheel — scroll the dialog (never the input history; history is arrow
262
+ keys only). Selecting text needs `Shift`+drag, the standard TUI convention.
263
+ - `PgUp` / `PgDn` — scroll the dialog half a screen.
264
+ - `Home` / `End` — jump to the very top / bottom of the dialog (only while the
265
+ input line is empty; with text typed they move the caret as usual).
266
+ - To copy a LARGE block, use `/export` (writes the whole session as Markdown)
267
+ or the transcript file (`/transcript` shows the path) — the terminal's own
268
+ drag-select cannot reach past one screen.
269
+
258
270
  Set `NO_COLOR=1` to disable colors (a calm default palette is used otherwise).
259
271
 
260
272
  ### Images and files
@@ -823,6 +835,18 @@ zames
823
835
  Claude Code). Действует та же песочница, что и для инструмента `Bash`, так
824
836
  что прямая команда тоже не может выйти за пределы проекта.
825
837
 
838
+ В полноэкранном режиме добавляется навигация по диалогу, чтобы длинный ответ
839
+ можно было прочитать и выделить не покидая терминал:
840
+
841
+ - колесо мыши — скролл диалога (историю ввода колесо НЕ листает; история —
842
+ только стрелки). Выделение текста — `Shift`+протяжка, стандарт TUI.
843
+ - `PgUp` / `PgDn` — листать диалог на пол-экрана.
844
+ - `Home` / `End` — прыжок в самый верх / низ диалога (только когда строка ввода
845
+ пуста; при набранном тексте они двигают курсор, как обычно).
846
+ - Чтобы скопировать БОЛЬШОЙ кусок, используйте `/export` (пишет всю сессию в
847
+ Markdown) или файл транскрипта (`/transcript` покажет путь) — собственным
848
+ выделением терминала за пределы одного экрана не выделить.
849
+
826
850
  Установите `NO_COLOR=1`, чтобы отключить цвета (иначе используется спокойная
827
851
  палитра по умолчанию).
828
852
 
@@ -141,7 +141,12 @@ export class AttachmentStore {
141
141
  imageCount = 0;
142
142
  fileCount = 0;
143
143
  reset() {
144
- this.items = [];
144
+ // Empty IN PLACE: the Ink tree captures `this.items` by reference at mount
145
+ // (InputLine's `attachments` prop). Reassigning to a new array left the
146
+ // tree holding the OLD one, so after the first submit every new attachment
147
+ // was appended to an array the UI no longer read — `onSubmit` got `[]` and
148
+ // the pasted image/file was silently dropped.
149
+ this.items.length = 0;
145
150
  this.imageCount = 0;
146
151
  this.fileCount = 0;
147
152
  }
package/dist/config.js CHANGED
@@ -101,13 +101,13 @@ export const DEFAULTS = {
101
101
  // layout, which keeps the scrollback but is subject to reflow artifacts.
102
102
  alternateScreen: true,
103
103
  // In-app MOUSE-WHEEL scrolling of the log. ON by default: the operator
104
- // wants the wheel to scroll the DIALOG. That needs terminal mouse
105
- // REPORTING (1000/1006), which in turn means drag-select needs Shift (a
106
- // terminal convention: Shift+drag bypasses the app). With this OFF the
107
- // app does not enable reporting, and in the alternate screen most
108
- // terminals then turn the wheel into Up/Down ARROW keys (DECSET 1007),
109
- // which the editor maps to HISTORY — the exact bug reported. So ON is the
110
- // only setting where the wheel behaves.
104
+ // asked that the wheel scroll the DIALOG (and it must NEVER scroll the
105
+ // input history — history is arrow keys only). That needs terminal mouse
106
+ // REPORTING (1000/1006) plus alternate-scroll OFF (1007l), so the wheel
107
+ // arrives as SGR events we consume and never as Up/Down arrows. The
108
+ // trade-off is that the terminal no longer does its own drag-select, so
109
+ // selecting text needs SHIFT+DRAG (the standard TUI convention). Set to
110
+ // false to give the terminal the mouse back (native select, wheel inert).
111
111
  mouseScroll: true,
112
112
  },
113
113
  // Off by default: a full tsc/eslint run after every edit is slow; opt in.
@@ -64,6 +64,10 @@ export const commonMessages = {
64
64
  en: 'Working directory: {v}',
65
65
  },
66
66
  'msg.transcript': { ru: 'Транскрипт: {v}', en: 'Transcript: {v}' },
67
+ 'msg.mouse_hint': {
68
+ ru: 'Мышь: колесо листает диалог (история — только стрелки), выделение текста — Shift+протяжка; PgUp/PgDn листают, Home/End — в начало/конец.',
69
+ en: 'Mouse: the wheel scrolls the dialog (history is arrow keys only), select text with Shift+drag; PgUp/PgDn scroll, Home/End jump to top/bottom.',
70
+ },
67
71
  'msg.new_chat': { ru: 'Создаю новый чат...', en: 'Creating a new chat...' },
68
72
  'msg.new_chat_ok': { ru: 'Новый чат.', en: 'New chat.' },
69
73
  'msg.resuming': {
package/dist/i18n/help.js CHANGED
@@ -97,6 +97,14 @@ export const helpMessages = {
97
97
  ru: 'Esc, Ctrl+C прервать текущую генерацию',
98
98
  en: 'Esc, Ctrl+C abort current generation',
99
99
  },
100
+ 'help.key.scroll': {
101
+ ru: 'PgUp / PgDn листать диалог (Home/End — в начало/конец) [full-screen]',
102
+ en: 'PgUp / PgDn scroll the dialog (Home/End — top/bottom) [full-screen]',
103
+ },
104
+ 'help.key.wheel': {
105
+ ru: 'колесо мыши скролл диалога; выделение — Shift+протяжка',
106
+ en: 'mouse wheel scroll the dialog; select text with Shift+drag',
107
+ },
100
108
  'help.key.bang': {
101
109
  ru: '!команда выполнить shell-команду напрямую (мимо модели)',
102
110
  en: '!command run a shell command directly (bypass the model)',
package/dist/index.js CHANGED
@@ -437,6 +437,8 @@ ${theme.bold(t('help.while_working'))}
437
437
  ${t('help.key.attach')}
438
438
  ${t('help.key.esc')}
439
439
  ${t('help.key.bang')}
440
+ ${t('help.key.scroll')}
441
+ ${t('help.key.wheel')}
440
442
 
441
443
  ${theme.bold(t('help.commands'))}
442
444
 
@@ -1596,6 +1598,13 @@ async function main() {
1596
1598
  };
1597
1599
  try {
1598
1600
  ed.start();
1601
+ // One-time hint so the operator knows how selection works: with wheel
1602
+ // scrolling on (the default) the terminal no longer selects natively, so
1603
+ // a drag needs Shift. Printed ONCE, above the input, in full-screen TUI
1604
+ // only (the legacy editor keeps native selection).
1605
+ if (config.ui.tui && config.ui.alternateScreen) {
1606
+ ed.printAbove(theme.dim(t('msg.mouse_hint')));
1607
+ }
1599
1608
  }
1600
1609
  catch (e) {
1601
1610
  // A TUI mount failure must not kill the agent. We surface it and let the
@@ -3,7 +3,7 @@
3
3
  // editor state: given the buffer text and the command list these decide which
4
4
  // commands to show, the visible window, and the Tab completion. Split out
5
5
  // (C3) so the fuzzy match can be unit-tested without driving the editor.
6
- import { SUGGEST_PAGE, isSubsequence } from './layout.js';
6
+ import { SUGGEST_PAGE, isSubsequence, visLen, } from './layout.js';
7
7
  /**
8
8
  * The commands matching the current buffer. Shown only when the line starts
9
9
  * with «/» and is a single short word. The match is fuzzy: an exact prefix
@@ -62,13 +62,28 @@ export function suggestWindow(sugg, offset) {
62
62
  * (0 when no list). Full-screen mode uses this to RESERVE space above the
63
63
  * input panel so opening the list does not shift the layout (the operator saw
64
64
  * the whole screen jump when `/chats` or `/resume` hints appeared).
65
+ *
66
+ * With `cols > 0` a row whose label+description is WIDER than the terminal is
67
+ * counted as the multiple rows it actually wraps to; counting one row per
68
+ * command under-reserved the panel and the caret drifted on a narrow terminal
69
+ * (the command list carries descriptions up to ~60 chars).
65
70
  */
66
- export function suggestRowCount(buf, commands) {
71
+ export function suggestRowCount(buf, commands, cols = 0) {
67
72
  const sugg = matchSlashCommands(buf, commands);
68
73
  if (!sugg.length)
69
74
  return 0;
70
- const { moreCount } = suggestWindow(sugg, 0);
71
- return Math.min(sugg.length, SUGGEST_PAGE) + (moreCount > 0 ? 1 : 0);
75
+ const { shown, moreCount } = suggestWindow(sugg, 0);
76
+ const width = Math.max(1, cols);
77
+ let rows = 0;
78
+ for (const c of shown) {
79
+ const label = c.hint ? c.name + ' ' + c.hint : c.name;
80
+ // 3 = ' ▸ ' prefix, 2 = the gap before the description.
81
+ const textWidth = 3 + visLen(label) + 2 + visLen(c.description);
82
+ rows += cols > 0 ? Math.max(1, Math.ceil(textWidth / width)) : 1;
83
+ }
84
+ if (moreCount > 0)
85
+ rows += 1;
86
+ return rows;
72
87
  }
73
88
  /**
74
89
  * Tab completion. With an explicit selection, insert that command. With a
package/dist/theme.js CHANGED
@@ -1,5 +1,14 @@
1
1
  import chalk from 'chalk';
2
2
  import { contentWidth } from './width.js';
3
+ // Respect NO_COLOR (https://no-color.org): when the operator sets it (any
4
+ // non-empty value) drop ALL SGR codes so the output can be piped into a file
5
+ // or a log viewer without escape noise. chalk 5 only auto-detects TTY/CI, it
6
+ // does NOT read NO_COLOR, so the level is set explicitly here — the one place
7
+ // the palette is built, so every consumer (theme, spinner, Ink Text colors
8
+ // that go through chalk) is covered. chalk.level is the documented override.
9
+ if (process.env.NO_COLOR) {
10
+ chalk.level = 0;
11
+ }
3
12
  // A calm palette for a dark terminal.
4
13
  // Muted, slightly "faded" tones without high saturation
5
14
  // (in the spirit of Tokyo Night / Nord). The goal is comfortable reading of
@@ -47,6 +47,12 @@ export function ConfigMenuApp(opts) {
47
47
  const [editBuf, setEditBuf] = useState('');
48
48
  const [pendingReset, setPendingReset] = useState(null);
49
49
  const [message, setMessage] = useState(null);
50
+ // `get`/`set` mutate the LIVE config object in place, so React has no state
51
+ // change to react to and the row kept the OLD value on screen after a
52
+ // toggle/cycle (the operator saw «ru» while the config already held «en»).
53
+ // Bump this counter after every mutation to force a re-render.
54
+ const [version, setVersion] = useState(0);
55
+ const bump = () => setVersion((v) => v + 1);
50
56
  const visible = useMemo(() => {
51
57
  const q = filterQuery.trim().toLowerCase();
52
58
  if (!q)
@@ -78,6 +84,7 @@ export function ConfigMenuApp(opts) {
78
84
  }
79
85
  try {
80
86
  set(f, raw);
87
+ bump();
81
88
  setMessage(t('cfg.menu.saved', {
82
89
  v: f.path,
83
90
  value: displayValue(f, get(f.path), t),
@@ -93,6 +100,7 @@ export function ConfigMenuApp(opts) {
93
100
  return;
94
101
  if (f.type === 'boolean') {
95
102
  set(f, get(f.path) === true ? 'false' : 'true');
103
+ bump();
96
104
  setMessage(null);
97
105
  return;
98
106
  }
@@ -112,6 +120,7 @@ export function ConfigMenuApp(opts) {
112
120
  const base = idx < 0 ? 0 : idx;
113
121
  const next = f.values[(base + dir + f.values.length) % f.values.length];
114
122
  set(f, next);
123
+ bump();
115
124
  setMessage(null);
116
125
  };
117
126
  /** ←/→ acts on the focused row: cycle an enum, toggle a boolean. */
@@ -123,6 +132,7 @@ export function ConfigMenuApp(opts) {
123
132
  cycleEnum(f, dir);
124
133
  else if (f.type === 'boolean') {
125
134
  set(f, get(f.path) === true ? 'false' : 'true');
135
+ bump();
126
136
  setMessage(null);
127
137
  }
128
138
  };
@@ -229,6 +239,7 @@ export function ConfigMenuApp(opts) {
229
239
  return;
230
240
  if (pendingReset === f.path) {
231
241
  reset(f);
242
+ bump();
232
243
  setMessage(t('cfg.reset', { v: f.path }));
233
244
  setPendingReset(null);
234
245
  }
@@ -239,29 +250,51 @@ export function ConfigMenuApp(opts) {
239
250
  return;
240
251
  }
241
252
  });
242
- // Windowed list: show a slice around the cursor, accounting for group headers.
253
+ // Windowed list: show a slice around the cursor. The window is measured in
254
+ // RENDERED ROWS, not items: each new group adds a header row, AND a label
255
+ // long enough to wrap takes more than one row. Counting one row per item
256
+ // let the block grow taller than the terminal and the top border/title
257
+ // scrolled off ("cut off at the top") on a narrow terminal.
243
258
  const termRows = rows || 24;
244
- const maxItemRows = Math.max(3, termRows - 6);
259
+ const maxRows = Math.max(3, termRows - 8);
260
+ const innerCols = Math.max(1, (columns || 80) - 4);
245
261
  const shown = useMemo(() => {
246
- // Walk back from the cursor until the window would overflow.
247
- let start = Math.min(cursor, Math.max(0, visible.length - 1));
248
262
  const groupStart = (i) => i === 0 || visible[i].groupKey !== visible[i - 1].groupKey;
249
- const rowsFor = (from) => {
263
+ // Rendered height of one item: 1 base row + wrapped extra rows. The label
264
+ // plus the value cell plus the path hint is approximated by the raw label
265
+ // length; over-estimating is safe (it only shortens the window).
266
+ const itemRows = (i) => {
267
+ const f = visible[i];
268
+ const label = f.labelKey ? t(f.labelKey) : f.path;
269
+ return Math.max(1, Math.ceil((label.length + 12) / innerCols));
270
+ };
271
+ const rowsIn = (a, b) => {
250
272
  let r = 0;
251
- for (let i = from; i <= cursor; i++) {
252
- if (i === from || groupStart(i))
273
+ for (let i = a; i < b; i++) {
274
+ if (groupStart(i))
253
275
  r++;
254
- r++;
276
+ r += itemRows(i);
255
277
  }
256
278
  return r;
257
279
  };
258
- while (start > 0 && rowsFor(start - 1) <= maxItemRows)
280
+ if (!visible.length)
281
+ return { start: 0, end: 0 };
282
+ let start = cursor;
283
+ let end = cursor + 1;
284
+ // Fill downward first, then upward, so the cursor stays visible and the
285
+ // window uses the whole budget.
286
+ while (end < visible.length && rowsIn(start, end + 1) <= maxRows)
287
+ end++;
288
+ while (start > 0 && rowsIn(start - 1, end) <= maxRows)
259
289
  start--;
260
- return { start, end: Math.min(visible.length, start + maxItemRows) };
261
- }, [cursor, visible, maxItemRows]);
290
+ return { start, end };
291
+ }, [cursor, visible, maxRows, innerCols, t]);
262
292
  const usable = Math.max(1, (columns || 80) - 1);
263
293
  void visCols;
264
294
  void usable;
295
+ // Referenced so the value-cell recompute below depends on the mutation
296
+ // counter (a pure `get()` call has no reactive dependency of its own).
297
+ void version;
265
298
  const items = [];
266
299
  let lastGroup = '';
267
300
  for (let i = shown.start; i < shown.end; i++) {
@@ -18,7 +18,7 @@ import { InputLine } from './InputLine.js';
18
18
  import { contentWidth } from '../width.js';
19
19
  import { translate } from '../i18n.js';
20
20
  import { suggestRowCount } from '../input/suggest.js';
21
- import { inputBoxRows } from '../input/layout.js';
21
+ import { inputBoxRows, truncateToWidth, visLen } from '../input/layout.js';
22
22
  import { useMouseWheel } from './useMouseWheel.js';
23
23
  /** The header bar: title, version, working directory. */
24
24
  function Header({ store }) {
@@ -28,9 +28,22 @@ function Header({ store }) {
28
28
  .filter(Boolean)
29
29
  .join(' ');
30
30
  const left = '❯ ' + state.title + (state.version ? ' v' + state.version : '');
31
- const dir = state.workdir ? ' ' + state.workdir : '';
32
- const gap = Math.max(1, cols - left.length - dir.length - right.length - 2);
33
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: dir }), _jsx(Text, { children: ' '.repeat(gap) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
31
+ const rawDir = state.workdir ? ' ' + state.workdir : '';
32
+ // The header MUST stay exactly ONE text row: FullScreenApp reserves
33
+ // HEADER_ROWS = 2 (title + divider) and the whole panel height is computed
34
+ // from it. A long working directory used to WRAP the title row at a narrow
35
+ // terminal (or a deep path), adding a third row the layout did not know
36
+ // about — Ink then overflowed the fixed `height={termRows}` column and the
37
+ // TOP input row was clipped (the operator saw the second line overprint the
38
+ // first). Truncate the directory (then the title) so the row always fits.
39
+ const gap = Math.max(1, cols - visLen(left) - visLen(rawDir) - visLen(right) - 2);
40
+ let dir = rawDir;
41
+ const budget = cols - visLen(left) - visLen(right) - 2;
42
+ if (visLen(dir) > budget)
43
+ dir = budget > 0 ? truncateToWidth(dir, budget) : '';
44
+ // If even title + provider exceed the width, drop the directory entirely.
45
+ const finalDir = gap >= 1 ? dir : '';
46
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "#c8b06a", bold: true, children: left }), _jsx(Text, { color: "#7fc4f0", children: finalDir }), _jsx(Text, { children: ' '.repeat(Math.max(1, cols - visLen(left) - visLen(finalDir) - visLen(right) - 2)) }), right ? _jsx(Text, { color: "#6fd0b0", children: right }) : null] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(cols) })] }));
34
47
  }
35
48
  export function FullScreenApp({ store, inputProps, }) {
36
49
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
@@ -49,7 +62,7 @@ export function FullScreenApp({ store, inputProps, }) {
49
62
  const INPUT_BOX_ROWS = inputBoxRows(state.prompt, state.edit.buf, state.edit.cursor, cols);
50
63
  // The suggestion popup renders ABOVE the input box; reserve its rows so
51
64
  // opening the list does not push the panel / shift the whole layout.
52
- const suggestRows = suggestRowCount(state.edit.buf, state.commands);
65
+ const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
53
66
  const panelRows = HEADER_ROWS + STATUS_ROWS + INPUT_BOX_ROWS + suggestRows;
54
67
  const logHeight = Math.max(1, termRows - panelRows);
55
68
  // Window the log BY RENDERED ROW: a long wrapped answer can be taller than
@@ -61,21 +74,36 @@ export function FullScreenApp({ store, inputProps, }) {
61
74
  const maxScroll = Math.max(0, total - logHeight);
62
75
  const scroll = Math.max(0, Math.min(state.scroll, maxScroll));
63
76
  const visible = windowByRows(all, logHeight, scroll, cols);
64
- // Scroll the log with PgUp/PgDn.
77
+ // Scroll the log with PgUp/PgDn; Home/End jump to the very top/bottom. The
78
+ // input line OWNS Home/End while its buffer is non-empty (they move the
79
+ // caret), so the jump only applies to an EMPTY buffer — which is exactly
80
+ // when the operator is navigating the log to select a large block.
81
+ //
82
+ // `useInput` binds the callback ONCE, so a `scroll` captured in the closure
83
+ // goes STALE after the first scroll (PgUp/PgDn/Home/End then compute from an
84
+ // old offset and jump wrong). Read the live value from the store instead.
65
85
  useInput((_input, key) => {
66
86
  const step = Math.max(1, Math.floor(logHeight / 2));
87
+ const cur = store.getSnapshot().scroll;
67
88
  if (key.pageUp) {
68
- store.set({ scroll: Math.min(maxScroll, scroll + step) });
89
+ store.set({ scroll: Math.min(maxScroll, cur + step) });
69
90
  }
70
91
  else if (key.pageDown) {
71
- store.set({ scroll: Math.max(0, scroll - step) });
92
+ store.set({ scroll: Math.max(0, cur - step) });
93
+ }
94
+ else if (state.edit.buf.length === 0 && (key.home || key.end)) {
95
+ // Both the bare and the Ctrl-modified forms land here (Ink sets
96
+ // key.end / key.home for either), so Ctrl+End / Ctrl+Home are a
97
+ // reliable fallback on terminals that swallow the bare keys.
98
+ store.set({ scroll: key.home ? maxScroll : 0 });
72
99
  }
73
100
  });
74
101
  // Scroll the log with the MOUSE WHEEL. Ink does not parse mouse events, so
75
102
  // useMouseWheel reads the raw SGR sequences (see the hook). One wheel notch
76
103
  // scrolls ~3 rows, like most terminals.
77
104
  useMouseWheel((delta) => {
78
- const next = scroll + delta * 3;
105
+ const cur = store.getSnapshot().scroll;
106
+ const next = cur + delta * 3;
79
107
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
80
108
  }, state.mouseScroll);
81
109
  return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", height: logHeight, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: "#5b616e", children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
@@ -8,12 +8,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  // reading key events from Ink's useInput, bracketed paste from usePaste,
9
9
  // calling the operator callbacks (submit / abort / attach), and painting the
10
10
  // visual rows via the shared layoutInput.
11
- import { useCallback, useMemo, useRef, useState } from 'react';
12
- import { Box, Text, useInput, usePaste, useCursor, useWindowSize } from 'ink';
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
+ import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
13
13
  import { translate } from '../i18n.js';
14
- import { layoutInput, visLen, charWidth, SUGGEST_PAGE, } from '../input/layout.js';
14
+ import { layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
15
15
  import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveRight, moveHome, moveEnd, wordLeft, wordRight, deleteWordLeft, deleteWordRight, killToLineEnd, moveVisualRows, pushUndo, undoEdit, historyUp, historyDown, searchStart, searchNext, searchAccept, searchCancel, searchInput, searchBackspace, recordHistory, afterSubmit, } from './edit.js';
16
- import { dispatchKey } from './keys.js';
16
+ import { dispatchKey, parseCsiU } from './keys.js';
17
17
  import { useSyncExternalStore } from 'react';
18
18
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
19
19
  /** Render the visual rows of the buffer with the prompt on the first row. */
@@ -69,9 +69,13 @@ export function InputLine(props) {
69
69
  const withHist = recordHistory(s, s.buf);
70
70
  const next = afterSubmit(withHist);
71
71
  setSt(next);
72
- props.onPastesChange(next.pastes);
73
72
  props.onHistoryChange(withHist.history);
73
+ // onSubmit MUST run BEFORE the paste list is cleared: the Ink adapter
74
+ // (TuiEditor.handleSubmit) expands `[Pasted lines#N]` markers by reading
75
+ // the store's paste list, so clearing it first sent the literal marker to
76
+ // the agent instead of the pasted text.
74
77
  props.onSubmit(s.buf, props.attachments.slice());
78
+ props.onPastesChange(next.pastes);
75
79
  }, [props]);
76
80
  const handlePaste = useCallback(async (text) => {
77
81
  const trimmed = String(text).trim();
@@ -224,6 +228,11 @@ export function InputLine(props) {
224
228
  doSubmit,
225
229
  tryClipboard,
226
230
  ]);
231
+ // The raw-stdin CSI-u handler is bound ONCE (its effect depends only on
232
+ // stdin), so it reads the latest `runAction` through a ref — otherwise the
233
+ // effect would re-bind on every render and could miss keystrokes.
234
+ const runActionRef = useRef(runAction);
235
+ runActionRef.current = runAction;
227
236
  useInput((input, key) => {
228
237
  const s = stRef.current;
229
238
  if (props.locked) {
@@ -248,6 +257,37 @@ export function InputLine(props) {
248
257
  usePaste((text) => {
249
258
  void handlePaste(text);
250
259
  });
260
+ // RAW STDIN fallback for the KITTY CSI-u forms Ink DROPS. Ink hides the raw
261
+ // `sequence` for these (it yields `input=''`), so the modified
262
+ // Delete/Backspace bindings never fired (the operator's "Ctrl+Delete does
263
+ // nothing"). The legacy LineEditor parsed the bytes itself; we read the same
264
+ // raw stream here. Sequences split across reads are carried over.
265
+ const { stdin: rawStdin, isRawModeSupported } = useStdin();
266
+ useEffect(() => {
267
+ if (!isRawModeSupported)
268
+ return;
269
+ let carry = '';
270
+ const onRaw = (data) => {
271
+ const s = typeof data === 'string' ? data : data.toString('utf-8');
272
+ const buf = carry + s;
273
+ // Hold a trailing INCOMPLETE CSI-u sequence for the next read.
274
+ const m = /\u001b\[[0-9;:]*$/.exec(buf);
275
+ carry = m ? m[0] : '';
276
+ const scan = carry ? buf.slice(0, buf.length - carry.length) : buf;
277
+ // Find every complete CSI-u sequence and dispatch it.
278
+ const re = /\u001b\[[0-9;:]*u/g;
279
+ let hit;
280
+ while ((hit = re.exec(scan)) !== null) {
281
+ const action = parseCsiU(hit[0]);
282
+ if (action)
283
+ runActionRef.current(action);
284
+ }
285
+ };
286
+ rawStdin.on('data', onRaw);
287
+ return () => {
288
+ rawStdin.removeListener('data', onRaw);
289
+ };
290
+ }, [rawStdin, isRawModeSupported]);
251
291
  // Position the real terminal cursor on the input cell. `y` is the row within
252
292
  // the live Ink output BELOW the log. Panel layout (top to bottom):
253
293
  // row 0 = divider
@@ -255,13 +295,15 @@ export function InputLine(props) {
255
295
  // row 2 = input box TOP border
256
296
  // rows 3.. = input rows
257
297
  const cursorRow = lay.cursorRow;
258
- const rowText = lay.rows[cursorRow]?.text ?? '';
259
298
  const cursorX =
260
299
  // The input rows sit inside the rounded box, indented by 1 column for the
261
- // left border and 1 for the box's inner padding.
262
- 2 +
263
- visLen(lay.rows[cursorRow]?.prefix ?? '') +
264
- colWidth(rowText, lay.cursorCol - visLen(lay.rows[cursorRow]?.prefix ?? ''));
300
+ // left border and 1 for the box's inner padding. `layoutInput().cursorCol`
301
+ // is ALREADY a column count that includes the prompt prefix (wide chars
302
+ // counted as 2), so the terminal column is just 2 + cursorCol. The old
303
+ // code re-measured `rowText` up to `cursorCol` code points, which
304
+ // DOUBLE-counted wide chars: a CJK buffer put the caret at the line end
305
+ // instead of under the cursor.
306
+ 2 + lay.cursorCol;
265
307
  setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
266
308
  const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
267
309
  const t = translate(props.locale);
@@ -274,12 +316,3 @@ export function InputLine(props) {
274
316
  ' ' +
275
317
  t('editor.page_hint') }))] })), _jsx(Box, { borderStyle: "round", borderColor: props.locked ? '#5b616e' : '#c8b06a', paddingLeft: 1, paddingRight: 1, flexDirection: "column", children: _jsx(InputRows, { prompt: props.prompt, buf: st.buf, cols: innerCols }) })] }));
276
318
  }
277
- /** Visible width of the first `col` code points of `text` (wide chars = 2). */
278
- function colWidth(text, col) {
279
- const chars = Array.from(text);
280
- let w = 0;
281
- for (let i = 0; i < col && i < chars.length; i++) {
282
- w += charWidth(chars[i].codePointAt(0));
283
- }
284
- return Math.max(0, w);
285
- }
@@ -7,10 +7,14 @@ import { Box, Text, useInput, useApp, useWindowSize } from 'ink';
7
7
  import { UI, HintBar } from './widgets.js';
8
8
  export function SelectList({ title, items, initial, onSelect, }) {
9
9
  const { exit } = useApp();
10
- const { rows } = useWindowSize();
10
+ const { rows, columns } = useWindowSize();
11
11
  const start = Math.max(0, items.findIndex((it) => it.value === initial));
12
12
  const [cursor, setCursor] = useState(start);
13
13
  const maxRows = Math.max(3, (rows || 24) - 7);
14
+ // The divider must fit the box: the border (2) + paddingX (2) leave
15
+ // `cols - 6` usable columns. A hardcoded 40-char rule WRAPPED to a second
16
+ // row on a narrow terminal and left a ragged edge on a wide one.
17
+ const innerCols = Math.max(1, (columns || 80) - 6);
14
18
  const win = useMemo(() => {
15
19
  let top = Math.max(0, cursor - maxRows + 1);
16
20
  if (cursor < top)
@@ -38,7 +42,7 @@ export function SelectList({ title, items, initial, onSelect, }) {
38
42
  finish(items[cursor]?.value ?? null);
39
43
  }
40
44
  });
41
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(40) }), items.slice(win.top, win.end).map((it, i) => {
45
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: UI.border, paddingX: 1, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: UI.title, bold: true, children: '◆ ' + title }), _jsx(Text, { color: UI.dim, children: items.length })] }), _jsx(Text, { color: "#3a3f4b", children: '─'.repeat(innerCols) }), items.slice(win.top, win.end).map((it, i) => {
42
46
  const idx = win.top + i;
43
47
  const sel = idx === cursor;
44
48
  return (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, children: sel ? '❯ ' : ' ' }), _jsx(Text, { color: sel ? UI.accent : undefined, bold: sel, children: it.label }), it.detail ? _jsx(Text, { color: UI.dim, children: ' ' + it.detail }) : null] }, it.value));