@itookit/dsht 0.3.0 → 0.3.3

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 (134) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +39 -23
  3. package/README.zh.md +40 -24
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +42 -29
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +269 -0
  12. package/dist/controller/controller.js +372 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/config.d.ts +17 -0
  18. package/dist/cost/config.js +68 -0
  19. package/dist/cost/controller.d.ts +41 -0
  20. package/dist/cost/controller.js +115 -0
  21. package/dist/cost/index.d.ts +10 -0
  22. package/dist/cost/index.js +8 -0
  23. package/dist/cost/ledger-files.d.ts +16 -0
  24. package/dist/cost/ledger-files.js +103 -0
  25. package/dist/cost/ledger.d.ts +78 -0
  26. package/dist/cost/ledger.js +146 -0
  27. package/dist/cost/pricing.d.ts +86 -0
  28. package/dist/cost/pricing.js +223 -0
  29. package/dist/cost/records.d.ts +17 -0
  30. package/dist/cost/records.js +79 -0
  31. package/dist/cost/scanner.d.ts +22 -0
  32. package/dist/cost/scanner.js +95 -0
  33. package/dist/cost/types.d.ts +85 -0
  34. package/dist/cost/types.js +7 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +2 -0
  37. package/dist/session/connection-view.d.ts +18 -0
  38. package/dist/session/connection-view.js +1 -0
  39. package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
  40. package/dist/session/controller.js +616 -0
  41. package/dist/session/export-html.d.ts +9 -0
  42. package/dist/session/export-html.js +39 -0
  43. package/dist/{export.d.ts → session/export.d.ts} +1 -1
  44. package/dist/{export.js → session/export.js} +6 -20
  45. package/dist/{history.d.ts → session/history.d.ts} +17 -0
  46. package/dist/{history.js → session/history.js} +163 -2
  47. package/dist/session/index.d.ts +17 -0
  48. package/dist/session/index.js +10 -0
  49. package/dist/session/markdown.d.ts +39 -0
  50. package/dist/session/markdown.js +255 -0
  51. package/dist/session/math.d.ts +11 -0
  52. package/dist/session/math.js +82 -0
  53. package/dist/session/navigation.d.ts +37 -0
  54. package/dist/session/navigation.js +84 -0
  55. package/dist/{references.js → session/references.js} +1 -1
  56. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
  57. package/dist/{telemetry.js → session/telemetry.js} +1 -1
  58. package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
  59. package/dist/{transcript.js → session/transcript.js} +141 -20
  60. package/dist/session/types.d.ts +18 -0
  61. package/dist/session/types.js +2 -0
  62. package/dist/state.d.ts +41 -0
  63. package/dist/state.js +9 -0
  64. package/dist/storage/directories.d.ts +14 -0
  65. package/dist/storage/directories.js +24 -0
  66. package/dist/storage/files.d.ts +51 -0
  67. package/dist/storage/files.js +152 -0
  68. package/dist/storage/heap-snapshot.d.ts +19 -0
  69. package/dist/storage/heap-snapshot.js +29 -0
  70. package/dist/storage/index.d.ts +4 -0
  71. package/dist/storage/index.js +4 -0
  72. package/dist/transport/auth.js +65 -0
  73. package/dist/transport/host.d.ts +13 -0
  74. package/dist/transport/host.js +1 -0
  75. package/dist/ui/app.d.ts +11 -0
  76. package/dist/ui/app.js +790 -0
  77. package/dist/ui/chat/header.d.ts +11 -0
  78. package/dist/ui/chat/header.js +14 -0
  79. package/dist/ui/chat/history-view.d.ts +12 -0
  80. package/dist/ui/chat/history-view.js +17 -0
  81. package/dist/ui/chat/status.d.ts +91 -0
  82. package/dist/ui/chat/status.js +386 -0
  83. package/dist/ui/chat/viewport.d.ts +17 -0
  84. package/dist/ui/chat/viewport.js +14 -0
  85. package/dist/ui/commands/parse.d.ts +99 -0
  86. package/dist/ui/commands/parse.js +126 -0
  87. package/dist/ui/commands/registry.d.ts +33 -0
  88. package/dist/ui/commands/registry.js +73 -0
  89. package/dist/ui/copy-mode.d.ts +4 -0
  90. package/dist/ui/copy-mode.js +6 -0
  91. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  92. package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
  93. package/dist/ui/dialogs/index.d.ts +120 -0
  94. package/dist/ui/dialogs/index.js +113 -0
  95. package/dist/ui/dialogs/picker.d.ts +18 -0
  96. package/dist/ui/dialogs/picker.js +38 -0
  97. package/dist/ui/frozen.d.ts +8 -0
  98. package/dist/ui/frozen.js +7 -0
  99. package/dist/ui/input/references.d.ts +12 -0
  100. package/dist/ui/input/references.js +15 -0
  101. package/dist/ui/mount.d.ts +6 -0
  102. package/dist/ui/mount.js +11 -0
  103. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  104. package/package.json +19 -13
  105. package/dist/app.d.ts +0 -21
  106. package/dist/app.js +0 -805
  107. package/dist/auth.js +0 -108
  108. package/dist/controller.js +0 -961
  109. package/dist/cost.d.ts +0 -119
  110. package/dist/cost.js +0 -313
  111. package/dist/history-view.d.ts +0 -8
  112. package/dist/history-view.js +0 -12
  113. package/dist/navigation.d.ts +0 -11
  114. package/dist/navigation.js +0 -36
  115. package/dist/status.d.ts +0 -28
  116. package/dist/status.js +0 -157
  117. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  118. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  119. /package/dist/{memory.js → session/memory.js} +0 -0
  120. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  121. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  122. /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
  123. /package/dist/{client.js → transport/client.js} +0 -0
  124. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  125. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  126. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  127. /package/dist/{wire.js → transport/wire.js} +0 -0
  128. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  129. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  130. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  131. /package/dist/{input.js → ui/input/input.js} +0 -0
  132. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  133. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  134. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -0,0 +1,82 @@
1
+ /** MathJax compiles TeX locally; terminal output keeps explicit grouping when Unicode cannot express it. */
2
+ import { mathjax } from '@mathjax/src/js/mathjax.js';
3
+ import { TeX } from '@mathjax/src/js/input/tex.js';
4
+ import { liteAdaptor } from '@mathjax/src/js/adaptors/liteAdaptor.js';
5
+ import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js';
6
+ import { STATE } from '@mathjax/src/js/core/MathItem.js';
7
+ import { TextNode } from '@mathjax/src/js/core/MmlTree/MmlNode.js';
8
+ import { SerializedMmlVisitor } from '@mathjax/src/js/core/MmlTree/SerializedMmlVisitor.js';
9
+ import '@mathjax/src/js/input/tex/base/BaseConfiguration.js';
10
+ import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js';
11
+ import { safeText } from "../transport/wire.js";
12
+ RegisterHTMLHandler(liteAdaptor());
13
+ const superscripts = Object.fromEntries([...('0123456789+-=()ni')].map((c, i) => [c, [...'⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿⁱ'][i]]));
14
+ const subscripts = Object.fromEntries([...'0123456789+-=()aehijklmnoprstuvx'].map((c, i) => [c, [...'₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ'][i]]));
15
+ function script(text, sub) {
16
+ const alphabet = sub ? subscripts : superscripts;
17
+ return [...text].every(c => alphabet[c]) ? [...text].map(c => alphabet[c]).join('') : `${sub ? '_' : '^'}(${text})`;
18
+ }
19
+ function readable(node) {
20
+ if (node instanceof TextNode)
21
+ return node.getText();
22
+ const children = node.childNodes.map(readable);
23
+ const [a = '', b = '', c = ''] = children;
24
+ let base = node.childNodes[0];
25
+ while (base && ['TeXAtom', 'mrow', 'inferredMrow', 'mstyle'].includes(base.kind) && base.childNodes.length === 1)
26
+ base = base.childNodes[0];
27
+ const grouped = base && !base.isToken && base.childNodes.length > 1 ? `(${a})` : a;
28
+ switch (node.kind) {
29
+ case 'mfrac': return `(${a})/(${b})`;
30
+ case 'msqrt': return `√(${children.join('')})`;
31
+ case 'mroot': return `root(${b}, ${a})`;
32
+ case 'msup': return `${grouped}${script(b, false)}`;
33
+ case 'msub': return `${grouped}${script(b, true)}`;
34
+ case 'msubsup': return `${grouped}${script(b, true)}${script(c, false)}`;
35
+ case 'munder': return `${a}_(${b})`;
36
+ case 'mover': return `${a}^(${b})`;
37
+ case 'munderover': return `${a}_(${b})^(${c})`;
38
+ case 'mtable': return `[${children.join('; ')}]`;
39
+ case 'mtr':
40
+ case 'mlabeledtr': return children.join(', ');
41
+ case 'mspace': return ' ';
42
+ case 'mphantom': return '';
43
+ case 'math':
44
+ case 'mrow':
45
+ case 'inferredMrow':
46
+ case 'TeXAtom':
47
+ case 'mstyle':
48
+ case 'mi':
49
+ case 'mn':
50
+ case 'mo':
51
+ case 'mtext':
52
+ case 'mtd': return children.join('');
53
+ default: throw new Error(`Unsupported terminal math node: ${node.kind}`);
54
+ }
55
+ }
56
+ /** Convert one TeX expression, without extension autoloads, network access or shared macro state.
57
+ * @param source - TeX without delimiters.
58
+ * @param display - Block rather than inline mathematics.
59
+ * @returns Unicode approximation and MathJax-generated MathML, or undefined for invalid TeX.
60
+ */
61
+ export function renderMath(source, display) {
62
+ if (source.length > 16 * 1024)
63
+ return undefined;
64
+ try {
65
+ const tex = new TeX({ packages: ['base', 'ams'], maxBuffer: 16 * 1024,
66
+ formatError(_jax, error) { throw error; } });
67
+ const document = mathjax.document('', { InputJax: tex });
68
+ const node = document.convert(source, { display, end: STATE.CONVERT });
69
+ const html = new SerializedMmlVisitor().visitTree(node);
70
+ let text;
71
+ try {
72
+ text = readable(node);
73
+ }
74
+ catch {
75
+ text = source; /* Valid MathML with no faithful terminal notation retains its TeX. */
76
+ }
77
+ return { text: safeText(text), html };
78
+ }
79
+ catch {
80
+ return undefined; /* Incomplete or invalid model-authored TeX remains visible as source. */
81
+ }
82
+ }
@@ -0,0 +1,37 @@
1
+ /** Shared display names and unambiguous slash-command target resolution. */
2
+ import { type ObjectValue } from '../transport/wire.ts';
3
+ /** Parse workspace and resume navigation, including their long aliases. */
4
+ export declare function navigationCommand(value: string): {
5
+ kind: 'workspace' | 'session';
6
+ query?: string;
7
+ } | undefined;
8
+ /** Resolve the host's title projection, falling back to the session ID. */
9
+ export declare function sessionLabel(session: ObjectValue): string;
10
+ /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
11
+ export declare function resolveTarget(items: ObjectValue[], query: string, id: string, names: (item: ObjectValue) => string[]): ObjectValue;
12
+ /** Activity a session summary reports on its own, without loading that session's history. */
13
+ export type SessionState = 'running' | 'idle' | 'blank';
14
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
15
+ * @param session - Session summary from `session/list`.
16
+ * @returns Running while its agent works, blank before its first turn, otherwise idle.
17
+ */
18
+ export declare function sessionState(session: ObjectValue): SessionState;
19
+ /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
20
+ export declare const SESSION_MARKERS: Record<SessionState, string>;
21
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
22
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
23
+ * @param now - Current epoch milliseconds.
24
+ * @returns `now`, minutes, hours or days.
25
+ */
26
+ export declare function activityAge(time: number | undefined, now: number): string;
27
+ /** Status cell for one session row: its state marker and the age of its last activity.
28
+ * @param session - Session summary from `session/list`.
29
+ * @param now - Current epoch milliseconds.
30
+ * @returns Marker with an optional age, without a trailing space when unknown.
31
+ */
32
+ export declare function sessionStatus(session: ObjectValue, now: number): string;
33
+ /** Count the sessions of one workspace by the state each reports.
34
+ * @param sessions - Sessions whose `sessionIds` belong to the workspace.
35
+ * @returns One `marker count` cell per state that occurs, running first, or an empty string.
36
+ */
37
+ export declare function workspaceStatus(sessions: readonly ObjectValue[]): string;
@@ -0,0 +1,84 @@
1
+ /** Shared display names and unambiguous slash-command target resolution. */
2
+ import { safeText, string } from "../transport/wire.js";
3
+ /** Parse workspace and resume navigation, including their long aliases. */
4
+ export function navigationCommand(value) {
5
+ const match = /^\/(ws|workspace|workspaces|resume|session|sessions)(?:\s+(.+))?$/.exec(value);
6
+ if (!match)
7
+ return undefined;
8
+ return { kind: match[1] === 'ws' || match[1].startsWith('workspace') ? 'workspace' : 'session', query: match[2] };
9
+ }
10
+ /** Resolve the host's title projection, falling back to the session ID. */
11
+ export function sessionLabel(session) {
12
+ const projections = session.projections;
13
+ if (projections && typeof projections === 'object' && !Array.isArray(projections)) {
14
+ const values = projections.values;
15
+ const title = values && typeof values === 'object' && !Array.isArray(values) ? values.title : undefined;
16
+ if (typeof title === 'string' && safeText(title).trim())
17
+ return safeText(title).trim();
18
+ if (title && typeof title === 'object' && !Array.isArray(title) && typeof title.title === 'string' && safeText(title.title).trim())
19
+ return safeText(title.title).trim();
20
+ }
21
+ return string(session.sessionId);
22
+ }
23
+ /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
24
+ export function resolveTarget(items, query, id, names) {
25
+ const target = query.replace(/^(["'])(.*)\1$/, '$2');
26
+ const exactId = items.find(item => item[id] === target);
27
+ if (exactId)
28
+ return exactId;
29
+ const exactNames = items.filter(item => names(item).includes(target));
30
+ const matches = exactNames.length ? exactNames : items.filter(item => string(item[id]).startsWith(target));
31
+ if (matches.length === 1)
32
+ return matches[0];
33
+ if (matches.length > 1)
34
+ throw new Error(`Ambiguous target: ${target}. Use a full ID.`);
35
+ throw new Error(`Target not found: ${target}`);
36
+ }
37
+ /** Classify one session from the host's list summary; nothing is inferred from silence.
38
+ * @param session - Session summary from `session/list`.
39
+ * @returns Running while its agent works, blank before its first turn, otherwise idle.
40
+ */
41
+ export function sessionState(session) {
42
+ if (session.running === true)
43
+ return 'running';
44
+ return session.blank === true ? 'blank' : 'idle';
45
+ }
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: '○' };
48
+ /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
49
+ * @param time - Epoch milliseconds of the last activity, when the summary reported one.
50
+ * @param now - Current epoch milliseconds.
51
+ * @returns `now`, minutes, hours or days.
52
+ */
53
+ export function activityAge(time, now) {
54
+ if (time === undefined || !Number.isFinite(time))
55
+ return '';
56
+ const seconds = Math.max(0, Math.floor((now - time) / 1000));
57
+ if (seconds < 60)
58
+ return 'now';
59
+ const minutes = Math.floor(seconds / 60);
60
+ if (minutes < 60)
61
+ return `${minutes}m`;
62
+ const hours = Math.floor(minutes / 60);
63
+ return hours < 24 ? `${hours}h` : `${Math.floor(hours / 24)}d`;
64
+ }
65
+ /** Status cell for one session row: its state marker and the age of its last activity.
66
+ * @param session - Session summary from `session/list`.
67
+ * @param now - Current epoch milliseconds.
68
+ * @returns Marker with an optional age, without a trailing space when unknown.
69
+ */
70
+ export function sessionStatus(session, now) {
71
+ const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
72
+ return `${SESSION_MARKERS[sessionState(session)]}${age === '' ? '' : ` ${age}`}`;
73
+ }
74
+ /** Count the sessions of one workspace by the state each reports.
75
+ * @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.
77
+ */
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(' ');
84
+ }
@@ -1,5 +1,5 @@
1
1
  /** Independent adapter for Harness path-only @ references; no file bytes are read. */
2
- import { array, object, string } from "./wire.js";
2
+ import { array, object, string } from "../transport/wire.js";
3
3
  /** Find the unfinished reference at the end of the draft; email addresses do not trigger it.
4
4
  * @param text - Complete composer draft.
5
5
  * @returns The replaceable token and host query, or undefined outside a reference.
@@ -1,5 +1,5 @@
1
1
  /** Host projection values with per-key watermarks; snapshots never roll back newer updates. */
2
- import { type ObjectValue } from './wire.ts';
2
+ import { type ObjectValue } from '../transport/wire.ts';
3
3
  /** One host-owned pending input, removable only while its occurrence remains queued. */
4
4
  export interface QueuedInput {
5
5
  id: string;
@@ -1,5 +1,5 @@
1
1
  /** Host projection values with per-key watermarks; snapshots never roll back newer updates. */
2
- import { array, object, string } from "./wire.js";
2
+ import { array, object, string } from "../transport/wire.js";
3
3
  function queuedInputs(value) {
4
4
  return array(value).map(value => {
5
5
  const item = object(value);
@@ -1,5 +1,5 @@
1
1
  import type { HistoryLimits } from './memory.ts';
2
- import { type Json } from './wire.ts';
2
+ import { type Json } from '../transport/wire.ts';
3
3
  interface ToolSummary {
4
4
  name: string;
5
5
  operation?: string;
@@ -25,6 +25,17 @@ export interface MessagePart {
25
25
  text: string;
26
26
  /** Closed live reasoning can fold before the assistant message is committed. */
27
27
  closed?: boolean;
28
+ /** Stable identity of a live part, so its rows can be wrapped incrementally as it grows. */
29
+ key?: string;
30
+ }
31
+ /** The stripe of work the live attempt last moved into; the status bar times it until a newer one. */
32
+ export interface LivePhase {
33
+ /** Which kind of work the newest event named. */
34
+ kind: 'thinking' | 'tool' | 'output';
35
+ /** Tool name when `kind` is `tool`, so the bar can name what is running. */
36
+ name?: string;
37
+ /** Epoch milliseconds when this phase began. */
38
+ startedAt: number;
28
39
  }
29
40
  /** A displayed message retains the durable sequence for stable reconciliation. */
30
41
  export interface Message {
@@ -60,7 +71,14 @@ export declare class Transcript {
60
71
  private closedBlocks;
61
72
  private oldestSeq;
62
73
  private turnMarker;
74
+ /** Pending-tool lookup memo, keyed by the revision that produced it. */
75
+ private runningCache;
63
76
  private attempt;
77
+ private phase;
78
+ /** Whether the newest delta still extends `phase`; attempt boundaries close it without dropping it. */
79
+ private phaseOpen;
80
+ /** Set when an added record can change which call the open turn is still waiting on. */
81
+ private pendingDirty;
64
82
  private nextIndex;
65
83
  private revision;
66
84
  private legacyStream;
@@ -97,6 +115,29 @@ export declare class Transcript {
97
115
  private dropProjections;
98
116
  private storeEvent;
99
117
  private deleteEvent;
118
+ /** The stripe of work the live attempt is in: the newest event the bar can time.
119
+ *
120
+ * Its age answers "what is it doing, and for how long" without inferring anything from silence,
121
+ * so the answer outlives the stream that supplied it: a tool keeps its age while it runs and after
122
+ * it answers, until the next stripe starts or the turn closes. Only the turn's own boundaries
123
+ * withdraw the phase, because everything inside a turn is still work the bar should be timing.
124
+ * @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
125
+ */
126
+ get livePhase(): LivePhase | undefined;
127
+ /** The tool the open turn asked for that has not answered yet, oldest first.
128
+ * @returns Call id, tool name and the moment the request was recorded, or undefined when none is in flight.
129
+ */
130
+ get runningTool(): {
131
+ id: string;
132
+ name: string;
133
+ startedAt: number;
134
+ } | undefined;
135
+ /** Find the open turn's unanswered tool call, which is the work a bar can still be waiting on.
136
+ *
137
+ * The host's `tool/call` event is not retained — only user-visible content is — so this reads the
138
+ * tool-call blocks a retained assistant message carries and the results that answer them.
139
+ */
140
+ private findPendingTool;
100
141
  /** Start of the open durable turn, when its timestamp is present in the retained window. */
101
142
  get activeTurnStartedAt(): number | undefined;
102
143
  /** Cached prompt/reasoning summaries, oldest first; stream deltas never invalidate this index. */
@@ -144,6 +185,29 @@ export declare class Transcript {
144
185
  */
145
186
  private foldLegacy;
146
187
  private validatePackedTiming;
188
+ /** Drop the live attempt's blocks; the phase they named stays until something newer replaces it. */
189
+ private clearBlocks;
190
+ /** Withdraw the phase at a turn boundary, where no work is running under it any more. */
191
+ private clearPhase;
192
+ /** Move the phase to the call the open turn is still waiting on, which is work in progress.
193
+ *
194
+ * A tool runs after the assistant stream that asked for it has ended, so the live block no longer
195
+ * covers it; the call the host committed but did not answer is what keeps a long command visible
196
+ * instead of showing an idle bar. While deltas are still arriving they are the newer evidence, and
197
+ * a turn with nothing unanswered keeps the phase it has rather than dropping back to silence.
198
+ */
199
+ private settlePhase;
200
+ /** Record the stripe a delta moved the attempt into; the status bar shows its age.
201
+ *
202
+ * A delta that continues the open stripe keeps the original start, because the phase is one
203
+ * stretch of work rather than one chunk. A stripe that returns after the attempt closed it — a
204
+ * second `think` in a later step — starts over, and the call id is what distinguishes a repeated
205
+ * tool from the call that already ran.
206
+ * @param kind - Stripe the delta belongs to.
207
+ * @param name - Tool name, when the stripe is a tool call.
208
+ * @param id - Tool call id, when the stripe is a tool call.
209
+ */
210
+ private notePhase;
147
211
  private chunk;
148
212
  }
149
213
  export {};
@@ -1,6 +1,6 @@
1
1
  /** Human transcript projection from durable events and ephemeral assistant chunks. */
2
2
  import sliceAnsi from 'slice-ansi';
3
- import { array, object, safeText, string } from "./wire.js";
3
+ import { array, object, safeText, string } from "../transport/wire.js";
4
4
  function toolSummary(block) {
5
5
  let args = {};
6
6
  if (typeof block.arguments === 'string' && block.arguments.trimStart().startsWith('{')) {
@@ -76,7 +76,14 @@ export class Transcript {
76
76
  closedBlocks = new Set();
77
77
  oldestSeq;
78
78
  turnMarker;
79
+ /** Pending-tool lookup memo, keyed by the revision that produced it. */
80
+ runningCache;
79
81
  attempt;
82
+ phase;
83
+ /** Whether the newest delta still extends `phase`; attempt boundaries close it without dropping it. */
84
+ phaseOpen = false;
85
+ /** Set when an added record can change which call the open turn is still waiting on. */
86
+ pendingDirty = false;
80
87
  nextIndex = 0;
81
88
  revision = 0;
82
89
  legacyStream = false;
@@ -114,7 +121,8 @@ export class Transcript {
114
121
  this.closedBlocks.clear();
115
122
  this.oldestSeq = undefined;
116
123
  this.turnMarker = undefined;
117
- this.blocks.clear();
124
+ this.clearBlocks();
125
+ this.clearPhase();
118
126
  this.keysDirty = true;
119
127
  this.projectionRevision++;
120
128
  this.legacyDirty = true;
@@ -157,8 +165,7 @@ export class Transcript {
157
165
  throw new Error('Assistant stream revision gap');
158
166
  this.revision = number(live.revision);
159
167
  if (live.type === 'start') {
160
- this.blocks.clear();
161
- this.closedBlocks.clear();
168
+ this.clearBlocks();
162
169
  this.attempt = string(live.attemptId);
163
170
  this.nextIndex = 0;
164
171
  }
@@ -172,7 +179,8 @@ export class Transcript {
172
179
  }
173
180
  else if (live.type === 'end') {
174
181
  this.attempt = undefined;
175
- this.blocks.clear();
182
+ this.clearBlocks();
183
+ this.settlePhase();
176
184
  }
177
185
  else
178
186
  throw new Error('Unknown assistant stream frame');
@@ -181,6 +189,10 @@ export class Transcript {
181
189
  }
182
190
  default: throw new Error('Unknown session follow frame');
183
191
  }
192
+ if (this.pendingDirty) {
193
+ this.pendingDirty = false;
194
+ this.settlePhase();
195
+ }
184
196
  }
185
197
  /** Add an older page without replacing the live tail. */
186
198
  addPage(value) {
@@ -190,6 +202,10 @@ export class Transcript {
190
202
  const page = object(value);
191
203
  this.addRecords(array(page.records));
192
204
  this.hasMore = page.hasMore === true;
205
+ if (this.pendingDirty) {
206
+ this.pendingDirty = false;
207
+ this.settlePhase();
208
+ }
193
209
  }
194
210
  /** Durable read cutoff includes followed records that were not present in the opening snapshot. */
195
211
  get readThrough() { return Math.max(this.cursor, this.throughSeq); }
@@ -257,8 +273,8 @@ export class Transcript {
257
273
  this.events.clear();
258
274
  this.sizes.clear();
259
275
  this.storedBytes = 0;
260
- this.blocks.clear();
261
- this.closedBlocks.clear();
276
+ this.clearBlocks();
277
+ this.clearPhase();
262
278
  this.transientSeqs.clear();
263
279
  this.sortedSeqs = [];
264
280
  this.displayed = [];
@@ -296,6 +312,58 @@ export class Transcript {
296
312
  this.sizes.delete(seq);
297
313
  this.events.delete(seq);
298
314
  }
315
+ /** The stripe of work the live attempt is in: the newest event the bar can time.
316
+ *
317
+ * Its age answers "what is it doing, and for how long" without inferring anything from silence,
318
+ * so the answer outlives the stream that supplied it: a tool keeps its age while it runs and after
319
+ * it answers, until the next stripe starts or the turn closes. Only the turn's own boundaries
320
+ * withdraw the phase, because everything inside a turn is still work the bar should be timing.
321
+ * @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
322
+ */
323
+ get livePhase() {
324
+ const phase = this.phase;
325
+ return phase === undefined ? undefined
326
+ : { kind: phase.kind, ...(phase.name === undefined ? {} : { name: phase.name }), startedAt: phase.startedAt };
327
+ }
328
+ /** The tool the open turn asked for that has not answered yet, oldest first.
329
+ * @returns Call id, tool name and the moment the request was recorded, or undefined when none is in flight.
330
+ */
331
+ get runningTool() {
332
+ if (this.runningCache?.version === this.version)
333
+ return this.runningCache.value;
334
+ const value = this.findPendingTool();
335
+ this.runningCache = { version: this.version, value };
336
+ return value;
337
+ }
338
+ /** Find the open turn's unanswered tool call, which is the work a bar can still be waiting on.
339
+ *
340
+ * The host's `tool/call` event is not retained — only user-visible content is — so this reads the
341
+ * tool-call blocks a retained assistant message carries and the results that answer them.
342
+ */
343
+ findPendingTool() {
344
+ const marker = this.turnMarker;
345
+ // A turn that ended cannot have a tool in flight, and `turn/end` clears the marker's start.
346
+ if (marker?.start === undefined)
347
+ return undefined;
348
+ const calls = [];
349
+ const answered = new Set();
350
+ for (const seq of this.sortedKeys()) {
351
+ if (seq <= marker.seq)
352
+ continue;
353
+ const event = this.events.get(seq);
354
+ if (event.type !== 'assistant/message' && event.type !== 'tool/result')
355
+ continue;
356
+ const startedAt = typeof event.time === 'number' ? event.time : marker.start;
357
+ for (const block of array(object(object(event.data).message).content).map(object)) {
358
+ if (block.type === 'tool-call')
359
+ calls.push({ id: string(block.id), name: string(block.name ?? 'tool'), startedAt });
360
+ else if (block.type === 'tool-result')
361
+ answered.add(string(block.toolCallId));
362
+ }
363
+ }
364
+ const pending = calls.find(call => !answered.has(call.id));
365
+ return pending === undefined ? undefined : { id: pending.id, name: pending.name, startedAt: pending.startedAt };
366
+ }
299
367
  /** Start of the open durable turn, when its timestamp is present in the retained window. */
300
368
  get activeTurnStartedAt() {
301
369
  return this.turnMarker?.start;
@@ -449,6 +517,7 @@ export class Transcript {
449
517
  * @returns Ordered blocks without terminal style escapes.
450
518
  */
451
519
  liveParts(width) {
520
+ const attempt = this.liveAttemptKey ?? 'live';
452
521
  return [...this.blocks].sort(([a], [b]) => a - b).map(([index, block]) => {
453
522
  const closed = this.closedBlocks.has(index);
454
523
  const cached = this.liveProjection.get(block);
@@ -457,6 +526,7 @@ export class Transcript {
457
526
  const part = {
458
527
  kind: block.type === 'reasoning' ? 'reasoning' : block.type === 'tool-call' ? 'tool' : 'text',
459
528
  text: cached?.width === width ? cached.part.text : contentText([block], undefined, width), closed,
529
+ key: `${attempt}:${index}`,
460
530
  };
461
531
  this.liveProjection.set(block, { width, part });
462
532
  return part;
@@ -478,9 +548,16 @@ export class Transcript {
478
548
  this.throughSeq = Math.max(this.throughSeq, seq);
479
549
  if ((event.type === 'turn/start' || event.type === 'turn/end') && seq >= (this.turnMarker?.seq ?? -1)) {
480
550
  this.turnMarker = { seq, start: event.type === 'turn/start' && typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : undefined };
551
+ // A turn is the span a phase belongs to: the closed one has nothing left running and the
552
+ // opening one has not moved yet, so neither may show the other's last event.
553
+ this.clearPhase();
481
554
  }
482
555
  const current = this.events.get(seq);
483
556
  this.storeEvent(seq, retainedEvent(event));
557
+ // Only these records can answer "which call is the open turn still waiting on".
558
+ if (event.type === 'assistant/message' || event.type === 'tool/result'
559
+ || event.type === 'turn/start' || event.type === 'turn/end')
560
+ this.pendingDirty = true;
484
561
  if (!DISPLAY_EVENTS.has(string(event.type)))
485
562
  this.transientSeqs.add(seq);
486
563
  else
@@ -520,8 +597,7 @@ export class Transcript {
520
597
  foldLegacy(added) {
521
598
  let events;
522
599
  if (this.legacyDirty) {
523
- this.blocks.clear();
524
- this.closedBlocks.clear();
600
+ this.clearBlocks();
525
601
  this.legacyPosition = '';
526
602
  this.legacySeq = -1;
527
603
  this.legacyDirty = false;
@@ -533,17 +609,14 @@ export class Transcript {
533
609
  for (const event of events) {
534
610
  this.legacySeq = Math.max(this.legacySeq, number(event.seq));
535
611
  if (['step/start', 'step/end', 'turn/end', 'assistant/message', 'assistant/attempt'].includes(string(event.type))) {
536
- this.blocks.clear();
537
- this.closedBlocks.clear();
612
+ this.clearBlocks();
538
613
  this.legacyPosition = '';
539
614
  }
540
615
  else if (event.type === 'assistant/chunk' || string(event.type).startsWith('chunkrow/')) {
541
616
  const data = object(event.data);
542
617
  const nextPosition = `${number(data.turn)}:${number(data.step)}`;
543
- if (this.legacyPosition !== nextPosition) {
544
- this.blocks.clear();
545
- this.closedBlocks.clear();
546
- }
618
+ if (this.legacyPosition !== nextPosition)
619
+ this.clearBlocks();
547
620
  this.legacyPosition = nextPosition;
548
621
  switch (event.type) {
549
622
  case 'assistant/chunk':
@@ -572,6 +645,48 @@ export class Transcript {
572
645
  if (count === 0 || array(data.dt).length !== count - 1)
573
646
  throw new Error('Invalid packed history member count');
574
647
  }
648
+ /** Drop the live attempt's blocks; the phase they named stays until something newer replaces it. */
649
+ clearBlocks() { this.blocks.clear(); this.closedBlocks.clear(); this.phaseOpen = false; }
650
+ /** Withdraw the phase at a turn boundary, where no work is running under it any more. */
651
+ clearPhase() { this.phase = undefined; this.phaseOpen = false; }
652
+ /** Move the phase to the call the open turn is still waiting on, which is work in progress.
653
+ *
654
+ * A tool runs after the assistant stream that asked for it has ended, so the live block no longer
655
+ * covers it; the call the host committed but did not answer is what keeps a long command visible
656
+ * instead of showing an idle bar. While deltas are still arriving they are the newer evidence, and
657
+ * a turn with nothing unanswered keeps the phase it has rather than dropping back to silence.
658
+ */
659
+ settlePhase() {
660
+ if (this.phaseOpen)
661
+ return;
662
+ const tool = this.runningTool;
663
+ if (tool === undefined)
664
+ return;
665
+ const key = toolPhaseKey(tool.id, tool.name);
666
+ if (this.phase?.key === key)
667
+ return;
668
+ this.phase = { key, kind: 'tool', name: tool.name, startedAt: tool.startedAt };
669
+ }
670
+ /** Record the stripe a delta moved the attempt into; the status bar shows its age.
671
+ *
672
+ * A delta that continues the open stripe keeps the original start, because the phase is one
673
+ * stretch of work rather than one chunk. A stripe that returns after the attempt closed it — a
674
+ * second `think` in a later step — starts over, and the call id is what distinguishes a repeated
675
+ * tool from the call that already ran.
676
+ * @param kind - Stripe the delta belongs to.
677
+ * @param name - Tool name, when the stripe is a tool call.
678
+ * @param id - Tool call id, when the stripe is a tool call.
679
+ */
680
+ notePhase(kind, name, id) {
681
+ const key = kind === 'tool' ? toolPhaseKey(id ?? '', name) : kind;
682
+ if (this.phase?.key === key && this.phaseOpen) {
683
+ if (name !== undefined && this.phase.name !== name)
684
+ this.phase = { key, kind, name, startedAt: this.phase.startedAt };
685
+ return;
686
+ }
687
+ this.phase = { key, kind, ...(name === undefined ? {} : { name }), startedAt: Date.now() };
688
+ this.phaseOpen = true;
689
+ }
575
690
  chunk(chunk) {
576
691
  if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
577
692
  for (const [index, block] of this.blocks)
@@ -583,13 +698,15 @@ export class Transcript {
583
698
  const block = this.blocks.get(index);
584
699
  this.blocks.set(index, { type: chunk.type === 'text-delta' ? 'text' : 'reasoning',
585
700
  text: (block ? string(block.text) : '') + string(chunk.text) });
701
+ this.notePhase(chunk.type === 'text-delta' ? 'output' : 'thinking');
586
702
  }
587
703
  else if (chunk.type === 'tool-call-delta') {
588
704
  const index = number(chunk.index);
589
705
  const block = this.blocks.get(index);
590
- this.blocks.set(index, { type: 'tool-call', id: string(chunk.id),
591
- name: typeof chunk.name === 'string' ? chunk.name : block?.name ?? '',
592
- arguments: '' });
706
+ const name = typeof chunk.name === 'string' ? chunk.name : typeof block?.name === 'string' ? block.name : '';
707
+ const id = typeof chunk.id === 'string' ? chunk.id : typeof block?.id === 'string' ? block.id : '';
708
+ this.blocks.set(index, { type: 'tool-call', id, name, arguments: '' });
709
+ this.notePhase('tool', name === '' ? undefined : name, id);
593
710
  }
594
711
  else if (chunk.type === 'block-end') {
595
712
  this.blocks.set(number(chunk.index), object(chunk.block));
@@ -602,12 +719,16 @@ function number(value) {
602
719
  throw new Error('Invalid sequence number');
603
720
  return value;
604
721
  }
722
+ /** Identity of one tool call, so two calls of the same tool do not share one phase. */
723
+ function toolPhaseKey(id, name) {
724
+ return `tool:${id === '' ? name ?? '' : id}`;
725
+ }
605
726
  /** Keep only user-visible content in durable client memory; the host owns raw tool results. */
606
727
  function retainedEvent(event) {
607
728
  if (!DISPLAY_EVENTS.has(string(event.type)))
608
729
  return event;
609
730
  if (event.surfaceOp !== 'append')
610
- return { seq: event.seq, type: event.type };
731
+ return { seq: event.seq, type: event.type, ...(typeof event.time === 'number' ? { time: event.time } : {}) };
611
732
  const data = object(event.data);
612
733
  const clean = (value) => {
613
734
  const block = object(value);
@@ -617,7 +738,7 @@ function retainedEvent(event) {
617
738
  return { type: 'tool-result', toolCallId: block.toolCallId, isError: block.isError === true };
618
739
  return block;
619
740
  };
620
- return { seq: event.seq, type: event.type, surfaceOp: 'append', data: event.type === 'user/message'
741
+ return { seq: event.seq, type: event.type, ...(typeof event.time === 'number' ? { time: event.time } : {}), surfaceOp: 'append', data: event.type === 'user/message'
621
742
  ? { content: data.content, ...(data.source ? { source: data.source } : {}) }
622
743
  : { message: { content: array(object(data.message).content).filter(block => event.type !== 'tool/result' || object(block).type === 'tool-result').map(clean) } } };
623
744
  }
@@ -0,0 +1,18 @@
1
+ /** Session-domain result types shared by the controller facade and the terminal UI. */
2
+ /** Resolved navigation-removal identity; empty marks a fresh blank, idle session eligible for immediate archival. */
3
+ export interface RemovalTarget {
4
+ kind: 'workspace' | 'session';
5
+ id: string;
6
+ name: string;
7
+ path?: string;
8
+ empty?: boolean;
9
+ }
10
+ /** Bounded search results contain navigation summaries, never complete message bodies. */
11
+ export interface HistorySearch {
12
+ items: {
13
+ seq: number;
14
+ role: string;
15
+ preview: string;
16
+ }[];
17
+ truncated: boolean;
18
+ }
@@ -0,0 +1,2 @@
1
+ /** Session-domain result types shared by the controller facade and the terminal UI. */
2
+ export {};