@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
@@ -36,15 +36,20 @@ export function resolveTarget(items, query, id, names) {
36
36
  }
37
37
  /** Classify one session from the host's list summary; nothing is inferred from silence.
38
38
  * @param session - Session summary from `session/list`.
39
- * @returns Running while its agent works, blank before its first turn, otherwise idle.
39
+ * @param pending - Whether this client holds an unanswered interaction for that session.
40
+ * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
40
41
  */
41
- export function sessionState(session) {
42
+ export function sessionState(session, pending = false) {
43
+ if (pending)
44
+ return 'needs';
42
45
  if (session.running === true)
43
46
  return 'running';
44
47
  return session.blank === true ? 'blank' : 'idle';
45
48
  }
46
- /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
47
- export const SESSION_MARKERS = { running: '◐', idle: '●', blank: '○' };
49
+ /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
50
+ export const SESSION_MARKERS = { needs: '?', running: '◐', idle: '●', blank: '○' };
51
+ /** One word per state, so every screen names the same state the same way. */
52
+ export const STATE_LABELS = { needs: 'needs you', running: 'working', idle: 'ready', blank: 'empty' };
48
53
  /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
49
54
  * @param time - Epoch milliseconds of the last activity, when the summary reported one.
50
55
  * @param now - Current epoch milliseconds.
@@ -65,20 +70,74 @@ export function activityAge(time, now) {
65
70
  /** Status cell for one session row: its state marker and the age of its last activity.
66
71
  * @param session - Session summary from `session/list`.
67
72
  * @param now - Current epoch milliseconds.
73
+ * @param pending - Whether this client holds an unanswered interaction for that session.
68
74
  * @returns Marker with an optional age, without a trailing space when unknown.
69
75
  */
70
- export function sessionStatus(session, now) {
76
+ export function sessionStatus(session, now, pending = false) {
71
77
  const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
72
- return `${SESSION_MARKERS[sessionState(session)]}${age === '' ? '' : ` ${age}`}`;
78
+ return `${SESSION_MARKERS[sessionState(session, pending)]}${age === '' ? '' : ` ${age}`}`;
73
79
  }
80
+ /** States a workspace rollup reports, most actionable first. */
81
+ export const ROLLUP_STATES = ['needs', 'running', 'idle'];
74
82
  /** Count the sessions of one workspace by the state each reports.
83
+ *
84
+ * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
85
+ * absence of activity, and listing it beside real work only makes the rollup harder to read.
75
86
  * @param sessions - Sessions whose `sessionIds` belong to the workspace.
76
- * @returns One `marker count` cell per state that occurs, running first, or an empty string.
87
+ * @param pending - Session IDs this client holds an unanswered interaction for.
88
+ * @returns One count per state that occurs, most actionable first, or an empty list.
77
89
  */
78
- export function workspaceStatus(sessions) {
79
- const counts = { running: 0, idle: 0, blank: 0 };
80
- for (const session of sessions)
81
- counts[sessionState(session)] += 1;
82
- return ['running', 'idle', 'blank'].filter(state => counts[state] > 0)
83
- .map(state => `${SESSION_MARKERS[state]} ${counts[state]}`).join(' ');
90
+ export function workspaceCounts(sessions, pending = new Set()) {
91
+ const counts = { needs: 0, running: 0, idle: 0 };
92
+ for (const session of sessions) {
93
+ const id = session.sessionId;
94
+ const state = sessionState(session, typeof id === 'string' && pending.has(id));
95
+ if (state !== 'blank')
96
+ counts[state] += 1;
97
+ }
98
+ return ROLLUP_STATES.filter(state => counts[state] > 0).map(state => ({ state, count: counts[state] }));
99
+ }
100
+ /** Render one rollup as separately coloured cells.
101
+ *
102
+ * Each cell after the first carries the separator that joins it to the previous one, so a caller can
103
+ * colour the cells independently without losing the text {@link workspaceStatus} would produce.
104
+ * @param counts - Counts from {@link workspaceCounts}.
105
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
106
+ * @returns The cells in the order given, with their separators.
107
+ */
108
+ export function workspaceSegments(counts, style = 'words') {
109
+ const separator = style === 'words' ? ' · ' : ' ';
110
+ return counts.map(({ state, count }, index) => ({ state,
111
+ text: `${index === 0 ? '' : separator}${style === 'words' ? `${SESSION_MARKERS[state]} ${count} ${STATE_LABELS[state]}` : `${SESSION_MARKERS[state]}${count}`}` }));
112
+ }
113
+ /** Render one rollup as plain text, the same way every screen and test reads it.
114
+ * @param counts - Counts from {@link workspaceCounts}.
115
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
116
+ * @returns The joined cell text, empty when nothing was counted.
117
+ */
118
+ export function workspaceStatus(counts, style = 'words') {
119
+ return workspaceSegments(counts, style).map(segment => segment.text).join('');
120
+ }
121
+ /** Marker key for the compact rollup, which has no room for the words. */
122
+ export const ROLLUP_LEGEND = `${SESSION_MARKERS.idle} ${STATE_LABELS.idle} · ${SESSION_MARKERS.running} ${STATE_LABELS.running} · ${SESSION_MARKERS.needs} ${STATE_LABELS.needs}`;
123
+ /** Secondary path text for one workspace row.
124
+ *
125
+ * The title is usually the last path segment, so repeating it wastes the row; the parent directory
126
+ * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
127
+ * path, because dropping it would hide where the workspace actually lives.
128
+ * @param path - Registered host directory.
129
+ * @param title - Workspace title as the row already shows it.
130
+ * @returns The path to show beside the row, or an empty string when nothing is left.
131
+ */
132
+ export function workspaceDetail(path, title) {
133
+ if (!path)
134
+ return '';
135
+ const trimmed = path.replace(/\/+$/u, '');
136
+ const cut = trimmed.lastIndexOf('/');
137
+ const base = cut < 0 ? trimmed : trimmed.slice(cut + 1);
138
+ if (base !== title)
139
+ return path;
140
+ if (cut > 0)
141
+ return trimmed.slice(0, cut);
142
+ return cut === 0 ? '/' : '';
84
143
  }
@@ -1,5 +1,6 @@
1
1
  import type { HistoryLimits } from './memory.ts';
2
- import { type Json } from '../transport/wire.ts';
2
+ import type { PromptRecord } from './info.ts';
3
+ import { type Json, type ObjectValue } from '../transport/wire.ts';
3
4
  interface ToolSummary {
4
5
  name: string;
5
6
  operation?: string;
@@ -19,6 +20,21 @@ export declare function toolLine(text: string, width: number): string;
19
20
  * @returns Blocks joined by newlines.
20
21
  */
21
22
  export declare function contentText(content: Json | undefined, tools?: ReadonlyMap<string, ToolSummary>, width?: number, reasoning?: 'row' | 'full'): string;
23
+ /** One durable `user/message` event as a recallable prompt.
24
+ *
25
+ * Injected context and every other record type return undefined. This is the single place the rule
26
+ * lives, so a prompt parsed straight from a wire record (the cost scan's pages) is identical to one
27
+ * the transcript folded into its own window.
28
+ * @param seq - Durable sequence of the record.
29
+ * @param event - Decoded wire event, when the record carried one.
30
+ * @returns The prompt text, or undefined when the record is not a user prompt.
31
+ */
32
+ export declare function eventPrompt(seq: number, event: ObjectValue | undefined): PromptRecord | undefined;
33
+ /** User prompts in one raw history page, oldest first.
34
+ * @param records - One HTTP history page's records.
35
+ * @returns Prompts with their durable sequences, in page order.
36
+ */
37
+ export declare function recordPrompts(records: unknown): PromptRecord[];
22
38
  /** Semantic content stays separate from terminal rows, styles, and fold state. */
23
39
  export interface MessagePart {
24
40
  kind: 'text' | 'reasoning' | 'tool' | 'success' | 'error';
@@ -144,6 +160,26 @@ export declare class Transcript {
144
160
  get thoughts(): ThoughtEntry[];
145
161
  /** Latest loaded user prompt summary, sharing the durable thought index. */
146
162
  get latestPrompt(): string;
163
+ /** User prompts newer than `afterSeq`, oldest first, with the newest sequence scanned.
164
+ *
165
+ * Recall folds its tail this way instead of projecting messages, so a stream frame never rebuilds
166
+ * the row layout at a second width just to notice a new prompt. The watermark is returned even when
167
+ * the scanned records contributed no prompt, because an assistant-only turn must not make the next
168
+ * frame rescan it.
169
+ * @param afterSeq - Newest sequence already folded into the caller's index.
170
+ * @returns Prompts with an increasing sequence, and the watermark the caller should adopt.
171
+ */
172
+ promptsSince(afterSeq: number): {
173
+ prompts: PromptRecord[];
174
+ through: number;
175
+ };
176
+ /** User prompts strictly older than `beforeSeq`, oldest first, from the retained window.
177
+ * @param beforeSeq - Sequence the caller's index has already reached.
178
+ * @returns Loaded prompts that can refill an evicted prefix without a page request.
179
+ */
180
+ promptsBefore(beforeSeq: number): PromptRecord[];
181
+ /** One `user/message` record as a prompt; injected context and other records return undefined. */
182
+ private userPrompt;
147
183
  /** Number of semantic records and unfinished legacy chunks held by the client. */
148
184
  get retainedRecordCount(): number;
149
185
  /** Earliest loaded record, used with the opening cursor for backward paging. */
@@ -59,6 +59,40 @@ export function contentText(content, tools, width = 100, reasoning = 'full') {
59
59
  }
60
60
  /** Event types that contribute a displayed message; other retained events only affect live state. */
61
61
  const DISPLAY_EVENTS = new Set(['user/message', 'assistant/message', 'tool/result']);
62
+ /** One durable `user/message` event as a recallable prompt.
63
+ *
64
+ * Injected context and every other record type return undefined. This is the single place the rule
65
+ * lives, so a prompt parsed straight from a wire record (the cost scan's pages) is identical to one
66
+ * the transcript folded into its own window.
67
+ * @param seq - Durable sequence of the record.
68
+ * @param event - Decoded wire event, when the record carried one.
69
+ * @returns The prompt text, or undefined when the record is not a user prompt.
70
+ */
71
+ export function eventPrompt(seq, event) {
72
+ if (!event || event.type !== 'user/message' || event.surfaceOp !== 'append')
73
+ return undefined;
74
+ const data = object(event.data);
75
+ if (data.source && object(data.source).kind !== 'user')
76
+ return undefined;
77
+ return { seq, text: contentText(data.content) };
78
+ }
79
+ /** User prompts in one raw history page, oldest first.
80
+ * @param records - One HTTP history page's records.
81
+ * @returns Prompts with their durable sequences, in page order.
82
+ */
83
+ export function recordPrompts(records) {
84
+ const prompts = [];
85
+ for (const raw of array(records)) {
86
+ const event = object(object(raw).event);
87
+ const seq = event.seq;
88
+ if (typeof seq !== 'number' || !Number.isSafeInteger(seq))
89
+ continue;
90
+ const prompt = eventPrompt(seq, event);
91
+ if (prompt)
92
+ prompts.push(prompt);
93
+ }
94
+ return prompts;
95
+ }
62
96
  /** Opening snapshots replace all state; durable events are deduplicated by sequence. */
63
97
  export class Transcript {
64
98
  sizes = new Map();
@@ -397,6 +431,45 @@ export class Transcript {
397
431
  }
398
432
  /** Latest loaded user prompt summary, sharing the durable thought index. */
399
433
  get latestPrompt() { void this.thoughts; return this.thoughtIndex.prompt; }
434
+ /** User prompts newer than `afterSeq`, oldest first, with the newest sequence scanned.
435
+ *
436
+ * Recall folds its tail this way instead of projecting messages, so a stream frame never rebuilds
437
+ * the row layout at a second width just to notice a new prompt. The watermark is returned even when
438
+ * the scanned records contributed no prompt, because an assistant-only turn must not make the next
439
+ * frame rescan it.
440
+ * @param afterSeq - Newest sequence already folded into the caller's index.
441
+ * @returns Prompts with an increasing sequence, and the watermark the caller should adopt.
442
+ */
443
+ promptsSince(afterSeq) {
444
+ const keys = this.sortedKeys();
445
+ let start = keys.length;
446
+ while (start > 0 && keys[start - 1] > afterSeq)
447
+ start--;
448
+ const prompts = [];
449
+ for (let index = start; index < keys.length; index++) {
450
+ const prompt = this.userPrompt(keys[index]);
451
+ if (prompt)
452
+ prompts.push(prompt);
453
+ }
454
+ return { prompts, through: Math.max(afterSeq, keys.at(-1) ?? afterSeq) };
455
+ }
456
+ /** User prompts strictly older than `beforeSeq`, oldest first, from the retained window.
457
+ * @param beforeSeq - Sequence the caller's index has already reached.
458
+ * @returns Loaded prompts that can refill an evicted prefix without a page request.
459
+ */
460
+ promptsBefore(beforeSeq) {
461
+ const prompts = [];
462
+ for (const seq of this.sortedKeys()) {
463
+ if (seq >= beforeSeq)
464
+ break;
465
+ const prompt = this.userPrompt(seq);
466
+ if (prompt)
467
+ prompts.push(prompt);
468
+ }
469
+ return prompts;
470
+ }
471
+ /** One `user/message` record as a prompt; injected context and other records return undefined. */
472
+ userPrompt(seq) { return eventPrompt(seq, this.events.get(seq)); }
400
473
  /** Number of semantic records and unfinished legacy chunks held by the client. */
401
474
  get retainedRecordCount() { return this.events.size; }
402
475
  /** Earliest loaded record, used with the opening cursor for backward paging. */
package/dist/state.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Application state shared by the controller facade and every domain controller. */
2
- import { Transcript } from './session/transcript.ts';
2
+ import { SessionInfo } from './session/info.ts';
3
3
  import type { HistorySearch, RemovalTarget } from './session/types.ts';
4
4
  import type { ObjectValue } from './transport/wire.ts';
5
5
  /** State shared by the picker and conversation view. */
@@ -21,7 +21,8 @@ export interface State {
21
21
  presetError?: string;
22
22
  presets?: ObjectValue[];
23
23
  defaultModel?: ObjectValue;
24
- transcript: Transcript;
24
+ /** Record, prompt index, composer, view and interaction state of the selected session. */
25
+ session: SessionInfo;
25
26
  }
26
27
  /** The state contract every domain controller writes through. */
27
28
  export interface ControllerStore {
package/dist/state.js CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Application state shared by the controller facade and every domain controller. */
2
- import { Transcript } from "./session/transcript.js";
2
+ import { SessionInfo } from "./session/info.js";
3
3
  /** Build the initial state before any connection exists.
4
4
  * @returns A fresh state whose transcript is empty and disconnected.
5
5
  */
6
6
  export function initialState() {
7
7
  return { version: 0, online: false, busy: false, screen: 'workspaces', status: 'Connecting…',
8
- error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], transcript: new Transcript() };
8
+ error: '', workspaces: [], sessions: [], showAllSessions: false, pending: [], session: new SessionInfo() };
9
9
  }