@itookit/dsht 0.3.3 → 0.3.7

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 (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -10
  3. package/README.zh.md +7 -7
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -123
  7. package/dist/controller/controller.d.ts +115 -0
  8. package/dist/controller/controller.js +126 -1
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/controller.d.ts +5 -0
  12. package/dist/cost/controller.js +2 -1
  13. package/dist/cost/scanner.d.ts +4 -2
  14. package/dist/cost/scanner.js +6 -3
  15. package/dist/session/controller.d.ts +157 -1
  16. package/dist/session/controller.js +395 -29
  17. package/dist/session/index.d.ts +2 -0
  18. package/dist/session/index.js +1 -0
  19. package/dist/session/info.d.ts +262 -0
  20. package/dist/session/info.js +326 -0
  21. package/dist/session/navigation.d.ts +62 -8
  22. package/dist/session/navigation.js +72 -13
  23. package/dist/session/transcript.d.ts +37 -1
  24. package/dist/session/transcript.js +73 -0
  25. package/dist/state.d.ts +3 -2
  26. package/dist/state.js +2 -2
  27. package/dist/ui/app.js +230 -178
  28. package/dist/ui/chat/status.js +15 -10
  29. package/dist/ui/dialogs/index.d.ts +9 -8
  30. package/dist/ui/dialogs/index.js +3 -3
  31. package/dist/ui/dialogs/picker.d.ts +21 -3
  32. package/dist/ui/dialogs/picker.js +37 -5
  33. package/dist/ui/input/input.d.ts +18 -3
  34. package/dist/ui/input/input.js +61 -22
  35. package/dist/ui/input/viewport.d.ts +96 -0
  36. package/dist/ui/input/viewport.js +173 -0
  37. package/dsht-m.png +0 -0
  38. package/package.json +3 -3
  39. package/dist/ui/input/history.d.ts +0 -19
  40. package/dist/ui/input/history.js +0 -43
@@ -236,7 +236,7 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
236
236
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
237
237
  const view = controller.telemetry.view(state.sessionId);
238
238
  const costs = controller.costs;
239
- const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
239
+ const sessionCost = controller.sessionCostText;
240
240
  const todayCost = costs ? costText(costs.today()) : '?';
241
241
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
242
242
  const coverage = costs?.coverage ?? 'complete';
@@ -258,20 +258,23 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
258
258
  const billed = buckets[0] === undefined || buckets[2] === undefined ? undefined : buckets[0] + buckets[2] + (buckets[3] ?? 0);
259
259
  const hit = cacheHitText(buckets[2], billed);
260
260
  const compactCount = (value) => value === undefined ? '?' : compactNumber.format(value);
261
- const phase = state.transcript.livePhase;
261
+ const phase = state.session.record.livePhase;
262
262
  const clock = running && since !== undefined ? ` ${clockText(now - since)}` : '';
263
263
  const phaseLabel = phase === undefined ? undefined
264
264
  : phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
265
265
  : `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
266
266
  // The state token reports a fact and never guesses: a paused clock is named, offline and errors
267
- // take the token over, and an unknown phase simply leaves the phase group empty.
267
+ // take the token over, and an unknown phase simply leaves the phase group empty. An answer this
268
+ // client still owes outranks the paused reason, because that reason is only why the clock stopped.
268
269
  const stateToken = !state.online
269
270
  ? { text: '! Offline', color: theme.status.offline }
270
271
  : state.controlError || state.modelError
271
272
  ? { text: '⚠ Error', color: theme.status.warning }
272
- : pauseReason !== undefined
273
- ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
274
- : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
273
+ : state.pending.length > 0
274
+ ? { text: '? Needs you', color: theme.status.critical }
275
+ : pauseReason !== undefined
276
+ ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
277
+ : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
275
278
  // One marker covers both scopes, because either an unpriceable record or a scan that has not
276
279
  // covered every session makes the pair inexact as a reading.
277
280
  const inexact = coverage !== 'complete';
@@ -322,7 +325,7 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
322
325
  const workspace = state.workspaces.find(item => item.workspaceId === state.workspaceId);
323
326
  const view = controller.telemetry.view(state.sessionId);
324
327
  const costs = controller.costs;
325
- const sessionCost = costs?.hasSession(state.sessionId) ? costText(costs.total(state.sessionId)) : '?';
328
+ const sessionCost = controller.sessionCostText;
326
329
  const todayCost = costs ? costText(costs.today()) : '?';
327
330
  // `*` belongs to costText alone; incomplete coverage is a separate degradation, reported by `!`.
328
331
  const coverage = costs?.coverage ?? 'complete';
@@ -334,9 +337,11 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
334
337
  const turns = count(numeric(record(view.values.sessionStats).turns));
335
338
  const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
336
339
  const detail = [
337
- { key: 'activity', color: running ? theme.colors.context : theme.colors.muted, text: running
338
- ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
339
- : '● Ready · Ctrl+C exit' },
340
+ { key: 'activity', color: state.pending.length > 0 ? theme.status.critical : running ? theme.colors.context : theme.colors.muted, text: state.pending.length > 0
341
+ ? '? Needs you · answer the request above to continue'
342
+ : running
343
+ ? `◐ Working · ${duration}${state.session.record.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
344
+ : '● Ready · Ctrl+C exit' },
340
345
  { key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
341
346
  ...state.sessionId
342
347
  ? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
@@ -27,12 +27,9 @@ export declare function RemovalDialog({ removal, enabled, canSelect, onCancel, o
27
27
  onCancel(): void;
28
28
  onConfirm(): void;
29
29
  }): import("react").JSX.Element;
30
- /** Two-step model and reasoning-effort selector. */
31
- export interface ModelState {
32
- catalog: ObjectValue;
33
- provider?: string;
34
- model?: ObjectValue;
35
- }
30
+ import type { ModelState } from '../../session/index.ts';
31
+ /** Two-step model and reasoning-effort selector; the state shape is shared with the session. */
32
+ export type { ModelState };
36
33
  /** Model routes and adapter-owned reasoning efforts.
37
34
  * @param props - Catalog, current step and its selection actions.
38
35
  * @returns The model or effort picker.
@@ -63,15 +60,19 @@ export declare function SearchResultsDialog({ query, items, hasMore, width, enab
63
60
  onClose(): void;
64
61
  }): import("react").JSX.Element;
65
62
  /** Title, current selection and confirm action for a picker screen.
66
- * @param props - Screen title, list identity and navigation state.
63
+ * @param props - Screen title, list identity, navigation state, usable width and marker key.
67
64
  * @returns The picker screen.
68
65
  */
69
- export declare function PickerScreen({ title, identity, choices, enabled, canSelect }: {
66
+ export declare function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }: {
70
67
  title: string;
71
68
  identity: string;
72
69
  choices: Choice[];
73
70
  enabled: boolean;
74
71
  canSelect(): boolean;
72
+ /** Columns the list may occupy inside the composer frame. */
73
+ width?: number;
74
+ /** Marker key shown above the list, for a rollup too compact to spell its states out. */
75
+ legend?: string;
75
76
  }): import("react").JSX.Element;
76
77
  /** Newest-first reasoning summaries with the preceding user prompt.
77
78
  * @param props - Loaded entries and lazy paging action.
@@ -64,11 +64,11 @@ export function SearchResultsDialog({ query, items, hasMore, width, enabled, can
64
64
  action: () => onOpen(string(item.sessionId)) })), { key: 'close', label: '← Back', action: onClose }], enabled: enabled, canSelect: canSelect })] });
65
65
  }
66
66
  /** Title, current selection and confirm action for a picker screen.
67
- * @param props - Screen title, list identity and navigation state.
67
+ * @param props - Screen title, list identity, navigation state, usable width and marker key.
68
68
  * @returns The picker screen.
69
69
  */
70
- export function PickerScreen({ title, identity, choices, enabled, canSelect }) {
71
- return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: title }), _jsx(Picker, { choices: choices, enabled: enabled, canSelect: canSelect }, identity)] });
70
+ export function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }) {
71
+ return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: title }), legend !== undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: legend }), _jsx(Picker, { choices: choices, enabled: enabled, canSelect: canSelect, width: width }, identity)] });
72
72
  }
73
73
  /** Newest-first reasoning summaries with the preceding user prompt.
74
74
  * @param props - Loaded entries and lazy paging action.
@@ -1,18 +1,36 @@
1
- /** One selectable row; a `remove` action adds the archive/delete affordance. */
1
+ /** One cell of a table row, optionally coloured by the state it reports. */
2
+ export interface ChoiceCell {
3
+ text: string;
4
+ color?: string;
5
+ bold?: boolean;
6
+ }
7
+ /** One selectable row; a `remove` action adds the archive/delete affordance.
8
+ *
9
+ * A row with `title`/`cells`/`detail` is laid out as a table so the state and path columns line up
10
+ * across rows; a row with only `label` keeps the plain single-text form every dialog uses.
11
+ */
2
12
  export interface Choice {
3
13
  key: string;
4
14
  label: string;
15
+ /** Leading table column; the row falls back to `label` when this is absent. */
16
+ title?: string;
17
+ /** Cells after the title, each carrying its own colour. */
18
+ cells?: readonly ChoiceCell[];
19
+ /** Trailing column, right-aligned and truncated from its start so a path keeps its tail. */
20
+ detail?: string;
5
21
  action(): void;
6
22
  remove?(): void;
7
23
  }
8
24
  /** Keyboard-driven list with paging and optional removal.
9
- * @param props - Choices, availability, page size and hint text.
25
+ * @param props - Choices, availability, page size, hint text and the usable column count.
10
26
  * @returns The rendered list rows and its navigation hint.
11
27
  */
12
- export declare function Picker({ choices, enabled, canSelect, pageSize, hint }: {
28
+ export declare function Picker({ choices, enabled, canSelect, pageSize, hint, width }: {
13
29
  choices: Choice[];
14
30
  enabled: boolean;
15
31
  canSelect(): boolean;
16
32
  pageSize?: number;
17
33
  hint?: string;
34
+ /** Columns this list may occupy inside its frame; defaults to the composer's inner width. */
35
+ width?: number;
18
36
  }): import("react").JSX.Element;
@@ -1,20 +1,26 @@
1
1
  import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  /** Shared list selector used by every picker and dialog. */
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Box, Text, useInput, useStdin } from 'ink';
4
+ import { Box, Text, useInput, useStdin, useStdout } from 'ink';
5
+ import stringWidth from 'string-width';
5
6
  import { safeText } from "../../transport/wire.js";
6
7
  import { useCopyMode } from "../copy-mode.js";
7
8
  import { useTheme } from "../theme/index.js";
9
+ /** Display width of one cell list, so wide characters count as two columns. */
10
+ function cellsWidth(cells) {
11
+ return (cells ?? []).reduce((sum, cell) => sum + stringWidth(cell.text), 0);
12
+ }
8
13
  /** Keyboard-driven list with paging and optional removal.
9
- * @param props - Choices, availability, page size and hint text.
14
+ * @param props - Choices, availability, page size, hint text and the usable column count.
10
15
  * @returns The rendered list rows and its navigation hint.
11
16
  */
12
- export function Picker({ choices, enabled, canSelect, pageSize = 12, hint }) {
17
+ export function Picker({ choices, enabled, canSelect, pageSize = 12, hint, width }) {
13
18
  const theme = useTheme();
14
19
  const copyMode = useCopyMode();
20
+ const { internal_eventEmitter } = useStdin();
21
+ const { stdout } = useStdout();
15
22
  const [selected, setSelected] = useState(0);
16
23
  const current = Math.min(selected, choices.length - 1);
17
- const { internal_eventEmitter } = useStdin();
18
24
  const rawKey = useRef('');
19
25
  useEffect(() => {
20
26
  const remember = (raw) => { rawKey.current = raw; };
@@ -34,5 +40,31 @@ export function Picker({ choices, enabled, canSelect, pageSize = 12, hint }) {
34
40
  choices[current]?.action();
35
41
  }, { isActive: enabled && !copyMode });
36
42
  const start = Math.max(0, current - Math.max(0, pageSize - 1));
37
- return _jsxs(Box, { flexDirection: "column", children: [choices.slice(start, start + pageSize).map((choice, index) => _jsxs(Text, { color: start + index === current ? theme.accent : undefined, children: [start + index === current ? '❯ ' : ' ', safeText(choice.label)] }, choice.key)), _jsx(Text, { dimColor: true, children: hint ?? `↑ ↓ select · Enter open${choices.some(choice => choice.remove) ? ' · d/Delete remove / archive' : ''} · Ctrl+C stop / exit` })] });
43
+ const rows = choices.slice(start, start + pageSize);
44
+ // The picker sits inside the composer frame, whose border and padding take six columns that Ink
45
+ // never gives to the content, so a row measured against the terminal width would still wrap.
46
+ const columns = width ?? Math.max(16, (stdout.columns ?? 80) - 6);
47
+ // Only a row carrying a table column is measured: an entry such as `+ Add workspace` keeps the
48
+ // plain full-width form and must not widen the columns the workspace rows share.
49
+ const tableRows = rows.filter(choice => choice.title !== undefined || choice.cells !== undefined || choice.detail !== undefined);
50
+ const table = tableRows.length > 0;
51
+ // Two columns go to the cursor and two separate the title from the cells that follow it. A trailing
52
+ // path column keeps a readable minimum, so the title yields to it rather than pushing it off screen;
53
+ // when no row has a path, the title column may use everything the cells do not need.
54
+ const wantsDetail = tableRows.some(choice => choice.detail !== undefined);
55
+ const titleWidth = 4 + Math.max(0, ...tableRows.map(choice => stringWidth(choice.title ?? choice.label)));
56
+ const cellWidth = Math.max(0, ...tableRows.map(choice => cellsWidth(choice.cells)));
57
+ const available = Math.max(0, columns - (wantsDetail ? 12 : 0));
58
+ const titleColumn = table ? Math.min(titleWidth, Math.max(Math.min(available, 8), available - cellWidth)) : 0;
59
+ const cellColumn = table ? Math.min(cellWidth, Math.max(0, available - titleColumn)) : 0;
60
+ const detailColumn = table ? Math.max(0, columns - titleColumn - cellColumn) : 0;
61
+ const detailShown = detailColumn >= 8 && wantsDetail;
62
+ return _jsxs(Box, { flexDirection: "column", children: [rows.map((choice, index) => {
63
+ const isCurrent = start + index === current;
64
+ const cursor = isCurrent ? '❯ ' : ' ';
65
+ const isTableRow = choice.title !== undefined || choice.cells !== undefined || choice.detail !== undefined;
66
+ if (!table || !isTableRow)
67
+ return _jsxs(Text, { color: isCurrent ? theme.accent : undefined, wrap: "truncate-end", children: [cursor, safeText(choice.label)] }, choice.key);
68
+ return _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: titleColumn, flexShrink: 0, children: _jsxs(Text, { color: isCurrent ? theme.accent : undefined, wrap: "truncate-end", children: [cursor, safeText(choice.title ?? choice.label)] }) }), _jsx(Box, { width: cellColumn, flexShrink: 0, children: _jsx(Text, { wrap: "truncate-end", children: choice.cells?.map((cell, position) => _jsx(Text, { color: cell.color, bold: cell.bold, children: safeText(cell.text) }, position)) }) }), detailShown && choice.detail !== undefined && _jsx(Box, { width: detailColumn, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { dimColor: true, wrap: "truncate-start", children: safeText(choice.detail) }) })] }, choice.key);
69
+ }), _jsx(Text, { dimColor: true, children: hint ?? `↑ ↓ select · Enter open${choices.some(choice => choice.remove) ? ' · d/Delete remove / archive' : ''} · Ctrl+C stop / exit` })] });
38
70
  }
@@ -1,3 +1,4 @@
1
+ import { type FoldRegion } from './viewport.ts';
1
2
  import { type Key } from 'ink';
2
3
  /** Editor offsets are UTF-16 positions at grapheme boundaries; killed text stays local. */
3
4
  export interface EditState {
@@ -9,11 +10,17 @@ export interface EditState {
9
10
  * @param state - Current text, cursor, and most recently killed text.
10
11
  * @param input - Decoded text or control-key letter.
11
12
  * @param key - Ink's VT/terminal key flags.
13
+ * @param regions - Source ranges rendered as a single folded row.
12
14
  * @returns The next editor state, without sending a message or exiting.
13
15
  */
14
- export declare function editInput(state: EditState, input: string, key: Partial<Key>): EditState;
15
- /** Controlled composer with local cursor and kill buffer; Enter submission belongs to the caller. */
16
- export declare function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder, reservedKeys }: {
16
+ export declare function editInput(state: EditState, input: string, key: Partial<Key>, regions?: readonly FoldRegion[]): EditState;
17
+ /** Controlled composer with local cursor, kill buffer and a cursor-following row window.
18
+ *
19
+ * Enter submission belongs to the caller. A draft taller than `maxRows` scrolls inside the composer
20
+ * so the conversation above it keeps its space; a multiline block taller than the window folds to a
21
+ * single summary row whose text is still sent in full.
22
+ */
23
+ export declare function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder, reservedKeys, width, maxRows, prompt, promptColor }: {
17
24
  value: string;
18
25
  onChange(value: string): void;
19
26
  onCursorChange(cursor: number): void;
@@ -22,4 +29,12 @@ export declare function TextInput({ value, onChange, onCursorChange, onSubmit, f
22
29
  placeholder: string;
23
30
  /** Keys owned by the surrounding picker while the composer is empty. */
24
31
  reservedKeys?: readonly string[];
32
+ /** Cells available to the draft, excluding the prompt and the composer border. */
33
+ width: number;
34
+ /** Content rows shown before the composer scrolls. */
35
+ maxRows: number;
36
+ /** Prompt drawn on the first visible row. */
37
+ prompt?: string;
38
+ /** Prompt colour, which dims while an answer dialog parks the draft. */
39
+ promptColor?: string;
25
40
  }): import("react").JSX.Element;
@@ -1,31 +1,46 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- /** Single-line terminal editing with explicit cursor ownership and Unicode grapheme movement. */
3
- import { useEffect, useRef, useState } from 'react';
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /** Terminal editing with explicit cursor ownership, Unicode movement and a bounded multi-row window. */
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { isMouseReport } from "./mouse.js";
5
- import { Text, useInput, useStdin } from 'ink';
5
+ import { cursorPlace, foldLabel, planDraft, windowRows } from "./viewport.js";
6
+ import { Box, Text, useInput, useStdin } from 'ink';
6
7
  const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
8
+ /** A cursor position strictly inside a folded block is pulled to the nearer edge, so movement
9
+ * never parks inside a block that renders as one summary row. */
10
+ function snap(position, regions, direction) {
11
+ for (const region of regions) {
12
+ if (position > region.start && position < region.end)
13
+ return direction < 0 ? region.start : region.end;
14
+ }
15
+ return position;
16
+ }
7
17
  /** Apply one Ink-decoded terminal key; application commands remain owned by the parent.
8
18
  * @param state - Current text, cursor, and most recently killed text.
9
19
  * @param input - Decoded text or control-key letter.
10
20
  * @param key - Ink's VT/terminal key flags.
21
+ * @param regions - Source ranges rendered as a single folded row.
11
22
  * @returns The next editor state, without sending a message or exiting.
12
23
  */
13
- export function editInput(state, input, key) {
24
+ export function editInput(state, input, key, regions = []) {
14
25
  const { text, cursor, killed } = state;
15
26
  if (key.eventType === 'release' || key.return || key.tab || key.escape || key.upArrow || key.downArrow || key.pageUp || key.pageDown)
16
27
  return state;
17
- const previous = () => segments.segment(text).containing(cursor - 1)?.index ?? 0;
28
+ const previous = () => snap(segments.segment(text).containing(cursor - 1)?.index ?? 0, regions, -1);
18
29
  const next = () => {
19
30
  const part = segments.segment(text).containing(cursor);
20
- return part ? part.index + part.segment.length : text.length;
31
+ return snap(part ? part.index + part.segment.length : text.length, regions, 1);
21
32
  };
22
- const wordStart = () => text.slice(0, cursor).replace(/\s+$/u, '').replace(/\S+$/u, '').length;
23
- const wordEnd = () => cursor + (/^\s*\S+/u.exec(text.slice(cursor))?.[0].length ?? text.length - cursor);
33
+ const wordStart = () => snap(text.slice(0, cursor).replace(/\s+$/u, '').replace(/\S+$/u, '').length, regions, -1);
34
+ const wordEnd = () => snap(cursor + (/^\s*\S+/u.exec(text.slice(cursor))?.[0].length ?? text.length - cursor), regions, 1);
24
35
  const move = (position) => position === cursor ? state : ({ ...state, cursor: position });
25
36
  const remove = (start, end, kill = false) => ({
26
37
  text: text.slice(0, start) + text.slice(end), cursor: start,
27
38
  killed: kill && start !== end ? text.slice(start, end) : killed,
28
39
  });
40
+ // A folded block is one object: crossing it is one step, and backspace or delete at its edge
41
+ // removes the whole block rather than one character of hidden text.
42
+ const trailingFold = regions.find(region => region.end === cursor);
43
+ const leadingFold = regions.find(region => region.start === cursor);
29
44
  if (key.home || key.ctrl && input === 'a')
30
45
  return move(0);
31
46
  if (key.end || key.ctrl && input === 'e')
@@ -47,20 +62,26 @@ export function editInput(state, input, key) {
47
62
  if (key.meta && input === 'd')
48
63
  return remove(cursor, wordEnd(), true);
49
64
  if (key.backspace || key.ctrl && input === 'h')
50
- return remove(previous(), cursor);
65
+ return trailingFold ? remove(trailingFold.start, trailingFold.end) : remove(previous(), cursor);
51
66
  if (key.delete || key.ctrl && input === 'd')
52
- return remove(cursor, next());
67
+ return leadingFold ? remove(leadingFold.start, leadingFold.end) : remove(cursor, next());
53
68
  if (key.ctrl && input === 'y')
54
69
  return { ...state, text: text.slice(0, cursor) + killed + text.slice(cursor), cursor: cursor + killed.length };
55
70
  if (key.ctrl || key.meta || key.super || key.hyper)
56
71
  return state;
57
- const inserted = input.replace(/[\r\n\t]+/g, ' ').replace(/[\u0000-\u001f\u007f-\u009f]/g, '');
72
+ // A paste keeps its line breaks and tabs; the display row is where they are turned into layout.
73
+ const inserted = input.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
58
74
  if (!inserted)
59
75
  return state;
60
76
  return { ...state, text: text.slice(0, cursor) + inserted + text.slice(cursor), cursor: cursor + inserted.length };
61
77
  }
62
- /** Controlled composer with local cursor and kill buffer; Enter submission belongs to the caller. */
63
- export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder, reservedKeys }) {
78
+ /** Controlled composer with local cursor, kill buffer and a cursor-following row window.
79
+ *
80
+ * Enter submission belongs to the caller. A draft taller than `maxRows` scrolls inside the composer
81
+ * so the conversation above it keeps its space; a multiline block taller than the window folds to a
82
+ * single summary row whose text is still sent in full.
83
+ */
84
+ export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, placeholder, reservedKeys, width, maxRows, prompt = '❯ ', promptColor }) {
64
85
  const { internal_eventEmitter } = useStdin();
65
86
  const rawKey = useRef('');
66
87
  useEffect(() => {
@@ -74,6 +95,12 @@ export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, pl
74
95
  const [, redraw] = useState(0);
75
96
  if (current.current.text !== value)
76
97
  current.current = { ...current.current, text: value, cursor: value.length };
98
+ const { text, cursor } = current.current;
99
+ const plan = useMemo(() => planDraft(text, width, maxRows), [text, width, maxRows]);
100
+ // Input callbacks run before Ink refreshes the controlled field, so the handler reads the
101
+ // freshest regions through a ref instead of the closure it was created with.
102
+ const regions = useRef(plan.regions);
103
+ regions.current = plan.regions;
77
104
  useInput((input, key) => {
78
105
  if (key.eventType === 'release' || isMouseReport(rawKey.current))
79
106
  return;
@@ -86,7 +113,7 @@ export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, pl
86
113
  const before = current.current;
87
114
  const backspace = rawKey.current === '\x7f' || rawKey.current === '\x1b\x7f'
88
115
  || /^\x1b\[127(?:;[\d:]+)?u$/.test(rawKey.current);
89
- const after = editInput(before, input, key.delete && backspace ? { ...key, delete: false, backspace: true } : key);
116
+ const after = editInput(before, input, key.delete && backspace ? { ...key, delete: false, backspace: true } : key, regions.current);
90
117
  current.current = after;
91
118
  if (after.text !== before.text)
92
119
  onChange(after.text);
@@ -95,11 +122,23 @@ export function TextInput({ value, onChange, onCursorChange, onSubmit, focus, pl
95
122
  if (after.text === before.text && after.cursor !== before.cursor)
96
123
  redraw(value => value + 1);
97
124
  }, { isActive: focus });
98
- const { text, cursor } = current.current;
99
- const character = segments.segment(text).containing(cursor)?.segment ?? ' ';
100
- if (!text && !focus)
101
- return _jsx(Text, { dimColor: true, children: placeholder });
102
- if (!text)
103
- return _jsxs(Text, { children: [_jsx(Text, { inverse: true, children: placeholder[0] ?? ' ' }), _jsx(Text, { dimColor: true, children: placeholder.slice(1) })] });
104
- return _jsxs(Text, { children: [text.slice(0, cursor), _jsx(Text, { inverse: focus, children: character }), text.slice(cursor + character.length)] });
125
+ if (!text) {
126
+ if (!focus)
127
+ return _jsxs(Text, { dimColor: true, children: [prompt, placeholder] });
128
+ return _jsxs(Text, { children: [_jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { inverse: true, children: placeholder[0] ?? ' ' }), _jsx(Text, { dimColor: true, children: placeholder.slice(1) })] });
129
+ }
130
+ const place = cursorPlace(plan.rows, cursor);
131
+ const window = windowRows(plan.rows.length, place.row, maxRows);
132
+ return _jsx(Box, { flexDirection: "column", flexShrink: 0, children: plan.rows.slice(window.start, window.end).map((row, offset) => {
133
+ const index = window.start + offset;
134
+ const lead = index === window.start ? prompt : ' '.repeat(prompt.length);
135
+ const key = `${row.start}:${index}`;
136
+ if (row.fold)
137
+ return _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: promptColor, children: lead }), foldLabel(row.fold)] }, key);
138
+ if (index !== place.row)
139
+ return _jsxs(Text, { children: [_jsx(Text, { color: promptColor, children: lead }), row.text] }, key);
140
+ const at = Math.min(place.index, row.text.length);
141
+ const end = row.map[cursor - row.start + 1] ?? row.text.length;
142
+ return _jsxs(Text, { children: [_jsx(Text, { color: promptColor, children: lead }), row.text.slice(0, at), _jsx(Text, { inverse: focus, children: row.text.slice(at, end) || ' ' }), row.text.slice(end)] }, key);
143
+ }) });
105
144
  }
@@ -0,0 +1,96 @@
1
+ /** Cells a tab advances to; every stop is a multiple of this width. */
2
+ export declare const TAB_WIDTH = 4;
3
+ /** Source range of one block that renders as a summary row instead of its text. */
4
+ export interface FoldRegion {
5
+ start: number;
6
+ end: number;
7
+ }
8
+ /** One rendered draft row and the source range it came from. */
9
+ export interface DraftRow {
10
+ /** Display text; tabs are already expanded to spaces. */
11
+ text: string;
12
+ /** First source offset covered by this row. */
13
+ start: number;
14
+ /** One past the last source offset covered by this row. */
15
+ end: number;
16
+ /** `map[k]` is the index in `text` of source offset `start + k`. */
17
+ map: number[];
18
+ /** Present when this row summarizes a folded block rather than showing its text. */
19
+ fold?: {
20
+ from: number;
21
+ to: number;
22
+ lines: number;
23
+ bytes: number;
24
+ };
25
+ }
26
+ /** Where the cursor lands among the rendered rows. */
27
+ export interface CursorPlace {
28
+ row: number;
29
+ index: number;
30
+ column: number;
31
+ }
32
+ /** Rendered rows plus the blocks the editor must treat as single objects. */
33
+ export interface DraftPlan {
34
+ rows: DraftRow[];
35
+ regions: FoldRegion[];
36
+ }
37
+ /** Spaces a tab adds to reach the next stop after the given column.
38
+ * @param column - Cells already used on the row.
39
+ * @param width - Tab stop width in cells.
40
+ * @returns Cell count for one tab, always at least one.
41
+ */
42
+ export declare function tabStop(column: number, width?: number): number;
43
+ /** Byte length of draft text, used for the folded-block size label.
44
+ * @param text - Source text.
45
+ * @returns UTF-8 byte length.
46
+ */
47
+ export declare function byteLength(text: string): number;
48
+ /** Compact size label for a folded block.
49
+ * @param bytes - UTF-8 byte length.
50
+ * @returns Bytes, kilobytes or megabytes with one decimal.
51
+ */
52
+ export declare function formatBytes(bytes: number): string;
53
+ /** Wrap a draft into display rows, expanding tabs and hard-breaking long segments.
54
+ *
55
+ * A newline belongs to the end of the row it closes, so a cursor parked on it renders at that row's
56
+ * end and a cursor just after it renders at the next row's start.
57
+ * @param text - Source text, including newlines and tabs.
58
+ * @param width - Row width in cells.
59
+ * @param tabWidth - Tab stop width in cells.
60
+ * @returns One entry per rendered row, in order.
61
+ */
62
+ export declare function wrapDraft(text: string, width: number, tabWidth?: number): DraftRow[];
63
+ /** Locate the cursor among rendered rows.
64
+ * @param rows - Rendered rows from `wrapDraft`.
65
+ * @param cursor - Source offset of the cursor.
66
+ * @returns Row index, display index within that row, and its cell column.
67
+ */
68
+ export declare function cursorPlace(rows: readonly DraftRow[], cursor: number): CursorPlace;
69
+ /** Choose the visible slice of rows that keeps the cursor on screen.
70
+ * @param count - Total rendered row count.
71
+ * @param cursorRow - Row index the cursor occupies.
72
+ * @param maxRows - Content rows the composer may show.
73
+ * @returns Half-open row range to render.
74
+ */
75
+ export declare function windowRows(count: number, cursorRow: number, maxRows: number): {
76
+ start: number;
77
+ end: number;
78
+ };
79
+ /** Wrap and fold a draft for display.
80
+ *
81
+ * A block is folded only when the draft has interior lines to hide and its whole wrapped height
82
+ * exceeds the composer window. The decision reads the unfolded height, so folding can never feed
83
+ * back into itself and oscillate. When the logical line count alone already exceeds the window the
84
+ * interior is never wrapped, which keeps a very large paste cheap to re-plan on every keystroke.
85
+ * @param text - Source text, including newlines and tabs.
86
+ * @param width - Row width in cells.
87
+ * @param maxRows - Content rows the composer may show.
88
+ * @param tabWidth - Tab stop width in cells.
89
+ * @returns Rows to render and the source range of every folded block.
90
+ */
91
+ export declare function planDraft(text: string, width: number, maxRows: number, tabWidth?: number): DraftPlan;
92
+ /** Display label for a folded block.
93
+ * @param fold - Folded-block facts.
94
+ * @returns A single-line summary such as `[84 lines · 6.1 KB]`.
95
+ */
96
+ export declare function foldLabel(fold: NonNullable<DraftRow['fold']>): string;