@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
@@ -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/dsht-m.png CHANGED
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itookit/dsht",
3
- "version": "0.3.3",
3
+ "version": "0.3.7",
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.tsx",
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.tsx",
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",
@@ -1,19 +0,0 @@
1
- /** Bounded, process-local recall history; never stores transcripts or writes prompt text to disk. */
2
- export declare class InputHistory {
3
- private entries;
4
- private bytes;
5
- private position;
6
- private draft;
7
- /** Remember submitted input, coalescing consecutive duplicates and enforcing both budgets.
8
- * @param value - Submitted command or prompt, excluding interaction answers.
9
- */
10
- record(value: string): void;
11
- /** Leave recall navigation when the composer is edited or otherwise replaced. */
12
- reset(): void;
13
- /** Recall older/newer input, restoring the original unsent draft at the end.
14
- * @param direction - Negative for older input, positive for newer input.
15
- * @param current - Current composer content before beginning recall.
16
- * @returns Recalled input, or the original draft when returning to the newest position.
17
- */
18
- move(direction: -1 | 1, current: string): string;
19
- }
@@ -1,43 +0,0 @@
1
- /** Bounded, process-local recall history; never stores transcripts or writes prompt text to disk. */
2
- export class InputHistory {
3
- entries = [];
4
- bytes = 0;
5
- position;
6
- draft = '';
7
- /** Remember submitted input, coalescing consecutive duplicates and enforcing both budgets.
8
- * @param value - Submitted command or prompt, excluding interaction answers.
9
- */
10
- record(value) {
11
- this.reset();
12
- if (!value || this.entries.at(-1) === value || value.length * 2 > 256 * 1024)
13
- return;
14
- this.entries.push(value);
15
- this.bytes += value.length * 2;
16
- while (this.entries.length > 200 || this.bytes > 256 * 1024)
17
- this.bytes -= this.entries.shift().length * 2;
18
- }
19
- /** Leave recall navigation when the composer is edited or otherwise replaced. */
20
- reset() { this.position = undefined; this.draft = ''; }
21
- /** Recall older/newer input, restoring the original unsent draft at the end.
22
- * @param direction - Negative for older input, positive for newer input.
23
- * @param current - Current composer content before beginning recall.
24
- * @returns Recalled input, or the original draft when returning to the newest position.
25
- */
26
- move(direction, current) {
27
- if (!this.entries.length)
28
- return current;
29
- if (this.position === undefined) {
30
- if (direction > 0)
31
- return current;
32
- this.position = this.entries.length;
33
- this.draft = current;
34
- }
35
- this.position = Math.max(0, Math.min(this.entries.length, this.position + direction));
36
- if (this.position === this.entries.length) {
37
- const draft = this.draft;
38
- this.reset();
39
- return draft;
40
- }
41
- return this.entries[this.position];
42
- }
43
- }