@itookit/dsht 0.3.2 → 0.3.4
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.
- package/README.i18n.yaml +2 -2
- package/README.md +21 -20
- package/README.zh.md +21 -20
- package/dist/cli/dsht.d.ts +2 -0
- package/dist/cli/dsht.js +125 -0
- package/dist/cli/index.js +16 -126
- package/dist/controller/controller.d.ts +18 -1
- package/dist/controller/controller.js +24 -2
- package/dist/controller/perf-measures.d.ts +34 -0
- package/dist/controller/perf-measures.js +78 -0
- package/dist/cost/config.d.ts +17 -0
- package/dist/cost/config.js +68 -0
- package/dist/cost/controller.d.ts +9 -2
- package/dist/cost/controller.js +10 -2
- package/dist/cost/index.d.ts +5 -4
- package/dist/cost/index.js +4 -3
- package/dist/cost/ledger-files.d.ts +4 -8
- package/dist/cost/ledger-files.js +46 -71
- package/dist/cost/ledger.d.ts +33 -20
- package/dist/cost/ledger.js +78 -68
- package/dist/cost/pricing.d.ts +55 -17
- package/dist/cost/pricing.js +126 -44
- package/dist/cost/records.js +18 -5
- package/dist/cost/types.d.ts +34 -18
- package/dist/cost/types.js +4 -0
- package/dist/session/controller.d.ts +16 -0
- package/dist/session/controller.js +33 -0
- package/dist/session/navigation.d.ts +80 -0
- package/dist/session/navigation.js +107 -0
- package/dist/session/transcript.d.ts +48 -6
- package/dist/session/transcript.js +117 -14
- package/dist/storage/files.d.ts +8 -0
- package/dist/storage/files.js +17 -0
- package/dist/storage/heap-snapshot.d.ts +19 -0
- package/dist/storage/heap-snapshot.js +29 -0
- package/dist/storage/index.d.ts +2 -1
- package/dist/storage/index.js +2 -1
- package/dist/ui/app.js +116 -10
- package/dist/ui/chat/history-view.d.ts +5 -1
- package/dist/ui/chat/history-view.js +9 -4
- package/dist/ui/chat/status.d.ts +16 -4
- package/dist/ui/chat/status.js +112 -32
- package/dist/ui/commands/parse.d.ts +3 -0
- package/dist/ui/commands/parse.js +5 -0
- package/dist/ui/commands/registry.js +1 -0
- package/dist/ui/dialogs/cost.js +3 -3
- package/dist/ui/dialogs/index.d.ts +6 -2
- package/dist/ui/dialogs/index.js +3 -3
- package/dist/ui/dialogs/picker.d.ts +21 -3
- package/dist/ui/dialogs/picker.js +37 -5
- package/dist/ui/input/input.d.ts +18 -3
- package/dist/ui/input/input.js +61 -22
- package/dist/ui/input/viewport.d.ts +96 -0
- package/dist/ui/input/viewport.js +173 -0
- package/package.json +3 -3
|
@@ -63,15 +63,19 @@ export declare function SearchResultsDialog({ query, items, hasMore, width, enab
|
|
|
63
63
|
onClose(): void;
|
|
64
64
|
}): import("react").JSX.Element;
|
|
65
65
|
/** Title, current selection and confirm action for a picker screen.
|
|
66
|
-
* @param props - Screen title, list identity and
|
|
66
|
+
* @param props - Screen title, list identity, navigation state, usable width and marker key.
|
|
67
67
|
* @returns The picker screen.
|
|
68
68
|
*/
|
|
69
|
-
export declare function PickerScreen({ title, identity, choices, enabled, canSelect }: {
|
|
69
|
+
export declare function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }: {
|
|
70
70
|
title: string;
|
|
71
71
|
identity: string;
|
|
72
72
|
choices: Choice[];
|
|
73
73
|
enabled: boolean;
|
|
74
74
|
canSelect(): boolean;
|
|
75
|
+
/** Columns the list may occupy inside the composer frame. */
|
|
76
|
+
width?: number;
|
|
77
|
+
/** Marker key shown above the list, for a rollup too compact to spell its states out. */
|
|
78
|
+
legend?: string;
|
|
75
79
|
}): import("react").JSX.Element;
|
|
76
80
|
/** Newest-first reasoning summaries with the preceding user prompt.
|
|
77
81
|
* @param props - Loaded entries and lazy paging action.
|
package/dist/ui/dialogs/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/ui/input/input.d.ts
CHANGED
|
@@ -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
|
|
15
|
-
/** Controlled composer with local cursor
|
|
16
|
-
|
|
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;
|
package/dist/ui/input/input.js
CHANGED
|
@@ -1,31 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return _jsx(Text, { dimColor: true, children: placeholder });
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Multi-row composer geometry: wrapping, tab stops, block folding and the cursor window.
|
|
2
|
+
*
|
|
3
|
+
* The draft keeps exactly the bytes the user typed or pasted; nothing here mutates it. Display rows
|
|
4
|
+
* and folded blocks are derived on every render, so an edit never has to rebase a stored span.
|
|
5
|
+
*/
|
|
6
|
+
import stringWidth from 'string-width';
|
|
7
|
+
/** Cells a tab advances to; every stop is a multiple of this width. */
|
|
8
|
+
export const TAB_WIDTH = 4;
|
|
9
|
+
const segments = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
10
|
+
/** Spaces a tab adds to reach the next stop after the given column.
|
|
11
|
+
* @param column - Cells already used on the row.
|
|
12
|
+
* @param width - Tab stop width in cells.
|
|
13
|
+
* @returns Cell count for one tab, always at least one.
|
|
14
|
+
*/
|
|
15
|
+
export function tabStop(column, width = TAB_WIDTH) { return width - (column % width); }
|
|
16
|
+
/** Byte length of draft text, used for the folded-block size label.
|
|
17
|
+
* @param text - Source text.
|
|
18
|
+
* @returns UTF-8 byte length.
|
|
19
|
+
*/
|
|
20
|
+
export function byteLength(text) { return Buffer.byteLength(text, 'utf8'); }
|
|
21
|
+
/** Compact size label for a folded block.
|
|
22
|
+
* @param bytes - UTF-8 byte length.
|
|
23
|
+
* @returns Bytes, kilobytes or megabytes with one decimal.
|
|
24
|
+
*/
|
|
25
|
+
export function formatBytes(bytes) {
|
|
26
|
+
if (bytes < 1024)
|
|
27
|
+
return `${bytes} B`;
|
|
28
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
29
|
+
}
|
|
30
|
+
/** Offsets where each newline-separated line begins.
|
|
31
|
+
* @param text - Source text.
|
|
32
|
+
* @returns One offset per logical line, always at least the offset zero.
|
|
33
|
+
*/
|
|
34
|
+
function lineStarts(text) {
|
|
35
|
+
const starts = [0];
|
|
36
|
+
for (let index = 0; index < text.length;) {
|
|
37
|
+
const next = text.indexOf('\n', index);
|
|
38
|
+
if (next === -1)
|
|
39
|
+
break;
|
|
40
|
+
starts.push(next + 1);
|
|
41
|
+
index = next + 1;
|
|
42
|
+
}
|
|
43
|
+
return starts;
|
|
44
|
+
}
|
|
45
|
+
/** Wrap a draft into display rows, expanding tabs and hard-breaking long segments.
|
|
46
|
+
*
|
|
47
|
+
* A newline belongs to the end of the row it closes, so a cursor parked on it renders at that row's
|
|
48
|
+
* end and a cursor just after it renders at the next row's start.
|
|
49
|
+
* @param text - Source text, including newlines and tabs.
|
|
50
|
+
* @param width - Row width in cells.
|
|
51
|
+
* @param tabWidth - Tab stop width in cells.
|
|
52
|
+
* @returns One entry per rendered row, in order.
|
|
53
|
+
*/
|
|
54
|
+
export function wrapDraft(text, width, tabWidth = TAB_WIDTH) {
|
|
55
|
+
const limit = Math.max(1, Math.floor(width));
|
|
56
|
+
const rows = [];
|
|
57
|
+
let row = { text: '', start: 0, end: 0, map: [0] };
|
|
58
|
+
let column = 0;
|
|
59
|
+
let offset = 0;
|
|
60
|
+
const close = (end) => { row.end = end; rows.push(row); };
|
|
61
|
+
const open = (start) => { row = { text: '', start, end: start, map: [0] }; column = 0; };
|
|
62
|
+
for (const { segment } of segments.segment(text)) {
|
|
63
|
+
const length = segment.length;
|
|
64
|
+
if (segment === '\n') {
|
|
65
|
+
row.map.push(row.text.length);
|
|
66
|
+
close(offset + length);
|
|
67
|
+
open(offset + length);
|
|
68
|
+
offset += length;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const display = segment === '\t' ? ' '.repeat(tabStop(column, tabWidth)) : segment;
|
|
72
|
+
const cells = segment === '\t' ? display.length : stringWidth(segment);
|
|
73
|
+
if (column > 0 && column + cells > limit) {
|
|
74
|
+
close(offset);
|
|
75
|
+
open(offset);
|
|
76
|
+
}
|
|
77
|
+
row.text += display;
|
|
78
|
+
column += cells;
|
|
79
|
+
offset += length;
|
|
80
|
+
row.map.push(row.text.length);
|
|
81
|
+
}
|
|
82
|
+
close(offset);
|
|
83
|
+
return rows;
|
|
84
|
+
}
|
|
85
|
+
/** Locate the cursor among rendered rows.
|
|
86
|
+
* @param rows - Rendered rows from `wrapDraft`.
|
|
87
|
+
* @param cursor - Source offset of the cursor.
|
|
88
|
+
* @returns Row index, display index within that row, and its cell column.
|
|
89
|
+
*/
|
|
90
|
+
export function cursorPlace(rows, cursor) {
|
|
91
|
+
for (let row = 0; row < rows.length; row++) {
|
|
92
|
+
const current = rows[row];
|
|
93
|
+
if (cursor >= current.start && cursor < current.end) {
|
|
94
|
+
const index = current.map[cursor - current.start] ?? current.text.length;
|
|
95
|
+
return { row, index, column: stringWidth(current.text.slice(0, index)) };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const row = rows.length - 1;
|
|
99
|
+
const last = rows[row];
|
|
100
|
+
return { row, index: last.text.length, column: stringWidth(last.text) };
|
|
101
|
+
}
|
|
102
|
+
/** Choose the visible slice of rows that keeps the cursor on screen.
|
|
103
|
+
* @param count - Total rendered row count.
|
|
104
|
+
* @param cursorRow - Row index the cursor occupies.
|
|
105
|
+
* @param maxRows - Content rows the composer may show.
|
|
106
|
+
* @returns Half-open row range to render.
|
|
107
|
+
*/
|
|
108
|
+
export function windowRows(count, cursorRow, maxRows) {
|
|
109
|
+
const size = Math.max(1, Math.floor(maxRows));
|
|
110
|
+
if (count <= size)
|
|
111
|
+
return { start: 0, end: count };
|
|
112
|
+
const start = Math.min(Math.max(0, cursorRow - size + 1), count - size);
|
|
113
|
+
return { start, end: start + size };
|
|
114
|
+
}
|
|
115
|
+
/** Shift a wrapped slice back to the source offsets it was cut from.
|
|
116
|
+
* @param rows - Rows wrapped from a slice.
|
|
117
|
+
* @param base - Source offset the slice started at.
|
|
118
|
+
* @returns The same rows with absolute offsets.
|
|
119
|
+
*/
|
|
120
|
+
function rebase(rows, base) {
|
|
121
|
+
return base === 0 ? rows : rows.map(row => ({ ...row, start: row.start + base, end: row.end + base }));
|
|
122
|
+
}
|
|
123
|
+
/** Summary row for a folded interior.
|
|
124
|
+
* @param text - Whole draft.
|
|
125
|
+
* @param starts - Logical line starts.
|
|
126
|
+
* @param from - First folded source offset.
|
|
127
|
+
* @param to - One past the last folded source offset.
|
|
128
|
+
* @returns The row that replaces the interior.
|
|
129
|
+
*/
|
|
130
|
+
function summary(text, starts, from, to) {
|
|
131
|
+
return { text: '', start: from, end: to, map: [],
|
|
132
|
+
fold: { from, to, lines: starts.length - 2, bytes: byteLength(text.slice(from, to)) } };
|
|
133
|
+
}
|
|
134
|
+
/** Wrap and fold a draft for display.
|
|
135
|
+
*
|
|
136
|
+
* A block is folded only when the draft has interior lines to hide and its whole wrapped height
|
|
137
|
+
* exceeds the composer window. The decision reads the unfolded height, so folding can never feed
|
|
138
|
+
* back into itself and oscillate. When the logical line count alone already exceeds the window the
|
|
139
|
+
* interior is never wrapped, which keeps a very large paste cheap to re-plan on every keystroke.
|
|
140
|
+
* @param text - Source text, including newlines and tabs.
|
|
141
|
+
* @param width - Row width in cells.
|
|
142
|
+
* @param maxRows - Content rows the composer may show.
|
|
143
|
+
* @param tabWidth - Tab stop width in cells.
|
|
144
|
+
* @returns Rows to render and the source range of every folded block.
|
|
145
|
+
*/
|
|
146
|
+
export function planDraft(text, width, maxRows, tabWidth = TAB_WIDTH) {
|
|
147
|
+
const starts = lineStarts(text);
|
|
148
|
+
const size = Math.max(1, Math.floor(maxRows));
|
|
149
|
+
if (starts.length >= 3 && starts.length > size) {
|
|
150
|
+
const from = starts[1];
|
|
151
|
+
const to = starts[starts.length - 1];
|
|
152
|
+
// The head slice ends with the newline that closes its last line, so the empty row that
|
|
153
|
+
// newline opens belongs to the block being folded, not to the head.
|
|
154
|
+
const head = wrapDraft(text.slice(0, from), width, tabWidth);
|
|
155
|
+
head.pop();
|
|
156
|
+
return { rows: [...head, summary(text, starts, from, to),
|
|
157
|
+
...rebase(wrapDraft(text.slice(to), width, tabWidth), to)], regions: [{ start: from, end: to }] };
|
|
158
|
+
}
|
|
159
|
+
const rows = wrapDraft(text, width, tabWidth);
|
|
160
|
+
if (starts.length < 3 || rows.length <= size)
|
|
161
|
+
return { rows, regions: [] };
|
|
162
|
+
const from = starts[1];
|
|
163
|
+
const to = starts[starts.length - 1];
|
|
164
|
+
return { rows: [...rows.filter(row => row.start < from), summary(text, starts, from, to), ...rows.filter(row => row.start >= to)],
|
|
165
|
+
regions: [{ start: from, end: to }] };
|
|
166
|
+
}
|
|
167
|
+
/** Display label for a folded block.
|
|
168
|
+
* @param fold - Folded-block facts.
|
|
169
|
+
* @returns A single-line summary such as `[84 lines · 6.1 KB]`.
|
|
170
|
+
*/
|
|
171
|
+
export function foldLabel(fold) {
|
|
172
|
+
return `[${fold.lines} lines · ${formatBytes(fold.bytes)}]`;
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itookit/dsht",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
|
|
6
6
|
"engines": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"start": "tsx src/cli/index.
|
|
29
|
-
"start:profile": "NODE_OPTIONS=\"--expose-gc --heapsnapshot-signal=SIGUSR2\" tsx src/cli/index.
|
|
28
|
+
"start": "tsx src/cli/index.ts",
|
|
29
|
+
"start:profile": "node -e \"require('node:fs').mkdirSync('.diagnostics',{recursive:true})\" && NODE_OPTIONS=\"--expose-gc --heapsnapshot-signal=SIGUSR2 --diagnostic-dir=.diagnostics\" tsx src/cli/index.ts",
|
|
30
30
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
31
31
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.tests.json",
|
|
32
32
|
"test": "tsx --test tests/*/*.test.ts tests/*/*.test.tsx",
|