@itookit/dsht 0.3.2 → 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.
- package/README.i18n.yaml +2 -2
- package/README.md +16 -15
- package/README.zh.md +18 -17
- package/dist/cli/index.js +9 -12
- package/dist/controller/controller.d.ts +10 -1
- package/dist/controller/controller.js +12 -2
- 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/navigation.d.ts +26 -0
- package/dist/session/navigation.js +48 -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 +68 -4
- 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 +100 -25
- 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/package.json +2 -2
|
@@ -28,9 +28,9 @@ export interface MessagePart {
|
|
|
28
28
|
/** Stable identity of a live part, so its rows can be wrapped incrementally as it grows. */
|
|
29
29
|
key?: string;
|
|
30
30
|
}
|
|
31
|
-
/** The stripe of
|
|
31
|
+
/** The stripe of work the live attempt last moved into; the status bar times it until a newer one. */
|
|
32
32
|
export interface LivePhase {
|
|
33
|
-
/** Which kind of
|
|
33
|
+
/** Which kind of work the newest event named. */
|
|
34
34
|
kind: 'thinking' | 'tool' | 'output';
|
|
35
35
|
/** Tool name when `kind` is `tool`, so the bar can name what is running. */
|
|
36
36
|
name?: string;
|
|
@@ -71,8 +71,14 @@ export declare class Transcript {
|
|
|
71
71
|
private closedBlocks;
|
|
72
72
|
private oldestSeq;
|
|
73
73
|
private turnMarker;
|
|
74
|
+
/** Pending-tool lookup memo, keyed by the revision that produced it. */
|
|
75
|
+
private runningCache;
|
|
74
76
|
private attempt;
|
|
75
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;
|
|
76
82
|
private nextIndex;
|
|
77
83
|
private revision;
|
|
78
84
|
private legacyStream;
|
|
@@ -109,12 +115,29 @@ export declare class Transcript {
|
|
|
109
115
|
private dropProjections;
|
|
110
116
|
private storeEvent;
|
|
111
117
|
private deleteEvent;
|
|
112
|
-
/** The stripe of work the live attempt is in: the
|
|
118
|
+
/** The stripe of work the live attempt is in: the newest event the bar can time.
|
|
113
119
|
*
|
|
114
|
-
* Its age answers "what is it doing, and for how long" without inferring anything from silence
|
|
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.
|
|
115
124
|
* @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
|
|
116
125
|
*/
|
|
117
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;
|
|
118
141
|
/** Start of the open durable turn, when its timestamp is present in the retained window. */
|
|
119
142
|
get activeTurnStartedAt(): number | undefined;
|
|
120
143
|
/** Cached prompt/reasoning summaries, oldest first; stream deltas never invalidate this index. */
|
|
@@ -162,9 +185,28 @@ export declare class Transcript {
|
|
|
162
185
|
*/
|
|
163
186
|
private foldLegacy;
|
|
164
187
|
private validatePackedTiming;
|
|
165
|
-
/** Drop the live attempt's blocks
|
|
188
|
+
/** Drop the live attempt's blocks; the phase they named stays until something newer replaces it. */
|
|
166
189
|
private clearBlocks;
|
|
167
|
-
/**
|
|
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
|
+
*/
|
|
168
210
|
private notePhase;
|
|
169
211
|
private chunk;
|
|
170
212
|
}
|
|
@@ -76,8 +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;
|
|
80
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;
|
|
81
87
|
nextIndex = 0;
|
|
82
88
|
revision = 0;
|
|
83
89
|
legacyStream = false;
|
|
@@ -116,6 +122,7 @@ export class Transcript {
|
|
|
116
122
|
this.oldestSeq = undefined;
|
|
117
123
|
this.turnMarker = undefined;
|
|
118
124
|
this.clearBlocks();
|
|
125
|
+
this.clearPhase();
|
|
119
126
|
this.keysDirty = true;
|
|
120
127
|
this.projectionRevision++;
|
|
121
128
|
this.legacyDirty = true;
|
|
@@ -173,6 +180,7 @@ export class Transcript {
|
|
|
173
180
|
else if (live.type === 'end') {
|
|
174
181
|
this.attempt = undefined;
|
|
175
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); }
|
|
@@ -258,6 +274,7 @@ export class Transcript {
|
|
|
258
274
|
this.sizes.clear();
|
|
259
275
|
this.storedBytes = 0;
|
|
260
276
|
this.clearBlocks();
|
|
277
|
+
this.clearPhase();
|
|
261
278
|
this.transientSeqs.clear();
|
|
262
279
|
this.sortedSeqs = [];
|
|
263
280
|
this.displayed = [];
|
|
@@ -295,16 +312,57 @@ export class Transcript {
|
|
|
295
312
|
this.sizes.delete(seq);
|
|
296
313
|
this.events.delete(seq);
|
|
297
314
|
}
|
|
298
|
-
/** The stripe of work the live attempt is in: the
|
|
315
|
+
/** The stripe of work the live attempt is in: the newest event the bar can time.
|
|
299
316
|
*
|
|
300
|
-
* Its age answers "what is it doing, and for how long" without inferring anything from silence
|
|
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.
|
|
301
321
|
* @returns Phase kind, the tool name when the phase is a tool call, and when the phase began.
|
|
302
322
|
*/
|
|
303
323
|
get livePhase() {
|
|
304
|
-
|
|
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)
|
|
305
347
|
return undefined;
|
|
306
|
-
const
|
|
307
|
-
|
|
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 };
|
|
308
366
|
}
|
|
309
367
|
/** Start of the open durable turn, when its timestamp is present in the retained window. */
|
|
310
368
|
get activeTurnStartedAt() {
|
|
@@ -490,9 +548,16 @@ export class Transcript {
|
|
|
490
548
|
this.throughSeq = Math.max(this.throughSeq, seq);
|
|
491
549
|
if ((event.type === 'turn/start' || event.type === 'turn/end') && seq >= (this.turnMarker?.seq ?? -1)) {
|
|
492
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();
|
|
493
554
|
}
|
|
494
555
|
const current = this.events.get(seq);
|
|
495
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;
|
|
496
561
|
if (!DISPLAY_EVENTS.has(string(event.type)))
|
|
497
562
|
this.transientSeqs.add(seq);
|
|
498
563
|
else
|
|
@@ -580,14 +645,47 @@ export class Transcript {
|
|
|
580
645
|
if (count === 0 || array(data.dt).length !== count - 1)
|
|
581
646
|
throw new Error('Invalid packed history member count');
|
|
582
647
|
}
|
|
583
|
-
/** Drop the live attempt's blocks
|
|
584
|
-
clearBlocks() { this.blocks.clear(); this.closedBlocks.clear(); this.
|
|
585
|
-
/**
|
|
586
|
-
|
|
587
|
-
|
|
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);
|
|
588
666
|
if (this.phase?.key === key)
|
|
589
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
|
+
}
|
|
590
687
|
this.phase = { key, kind, ...(name === undefined ? {} : { name }), startedAt: Date.now() };
|
|
688
|
+
this.phaseOpen = true;
|
|
591
689
|
}
|
|
592
690
|
chunk(chunk) {
|
|
593
691
|
if (chunk.type === 'text-delta' || chunk.type === 'tool-call-delta') {
|
|
@@ -606,8 +704,9 @@ export class Transcript {
|
|
|
606
704
|
const index = number(chunk.index);
|
|
607
705
|
const block = this.blocks.get(index);
|
|
608
706
|
const name = typeof chunk.name === 'string' ? chunk.name : typeof block?.name === 'string' ? block.name : '';
|
|
609
|
-
|
|
610
|
-
this.
|
|
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);
|
|
611
710
|
}
|
|
612
711
|
else if (chunk.type === 'block-end') {
|
|
613
712
|
this.blocks.set(number(chunk.index), object(chunk.block));
|
|
@@ -620,12 +719,16 @@ function number(value) {
|
|
|
620
719
|
throw new Error('Invalid sequence number');
|
|
621
720
|
return value;
|
|
622
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
|
+
}
|
|
623
726
|
/** Keep only user-visible content in durable client memory; the host owns raw tool results. */
|
|
624
727
|
function retainedEvent(event) {
|
|
625
728
|
if (!DISPLAY_EVENTS.has(string(event.type)))
|
|
626
729
|
return event;
|
|
627
730
|
if (event.surfaceOp !== 'append')
|
|
628
|
-
return { seq: event.seq, type: event.type };
|
|
731
|
+
return { seq: event.seq, type: event.type, ...(typeof event.time === 'number' ? { time: event.time } : {}) };
|
|
629
732
|
const data = object(event.data);
|
|
630
733
|
const clean = (value) => {
|
|
631
734
|
const block = object(value);
|
|
@@ -635,7 +738,7 @@ function retainedEvent(event) {
|
|
|
635
738
|
return { type: 'tool-result', toolCallId: block.toolCallId, isError: block.isError === true };
|
|
636
739
|
return block;
|
|
637
740
|
};
|
|
638
|
-
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'
|
|
639
742
|
? { content: data.content, ...(data.source ? { source: data.source } : {}) }
|
|
640
743
|
: { message: { content: array(object(data.message).content).filter(block => event.type !== 'tool/result' || object(block).type === 'tool-result').map(clean) } } };
|
|
641
744
|
}
|
package/dist/storage/files.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ export declare function readText(path: string): Promise<string | undefined>;
|
|
|
9
9
|
* @returns File contents, or undefined when the file does not exist.
|
|
10
10
|
*/
|
|
11
11
|
export declare function readPrivateFile(path: string, label: string): Promise<string | undefined>;
|
|
12
|
+
/** Move one private file within its directory, ignoring a source that is already gone.
|
|
13
|
+
*
|
|
14
|
+
* Used to set an unreadable file aside under a new name without deleting the bytes. A missing source
|
|
15
|
+
* is not an error: the caller listed the directory earlier and another process may have removed it.
|
|
16
|
+
* @param from - Existing path.
|
|
17
|
+
* @param to - Destination path, replaced when it already exists.
|
|
18
|
+
*/
|
|
19
|
+
export declare function renameFile(from: string, to: string): Promise<void>;
|
|
12
20
|
/** Replace a file atomically with owner-only contents, leaving no partial file behind.
|
|
13
21
|
* @param path - Destination path.
|
|
14
22
|
* @param contents - Complete file contents.
|
package/dist/storage/files.js
CHANGED
|
@@ -43,6 +43,23 @@ export async function readPrivateFile(path, label) {
|
|
|
43
43
|
await handle.close();
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
|
+
/** Move one private file within its directory, ignoring a source that is already gone.
|
|
47
|
+
*
|
|
48
|
+
* Used to set an unreadable file aside under a new name without deleting the bytes. A missing source
|
|
49
|
+
* is not an error: the caller listed the directory earlier and another process may have removed it.
|
|
50
|
+
* @param from - Existing path.
|
|
51
|
+
* @param to - Destination path, replaced when it already exists.
|
|
52
|
+
*/
|
|
53
|
+
export async function renameFile(from, to) {
|
|
54
|
+
try {
|
|
55
|
+
await rename(from, to);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (isMissing(error))
|
|
59
|
+
return;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
46
63
|
/** Replace a file atomically with owner-only contents, leaving no partial file behind.
|
|
47
64
|
* @param path - Destination path.
|
|
48
65
|
* @param contents - Complete file contents.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Build a snapshot filename from a sampling-point tag and a time.
|
|
2
|
+
*
|
|
3
|
+
* The tag is a user label such as `after-stress`; everything that could steer the write outside the
|
|
4
|
+
* destination directory is folded into a dash, so a tag can never become a path.
|
|
5
|
+
* @param tag - Sampling-point label.
|
|
6
|
+
* @param time - Epoch milliseconds that keeps repeated snapshots distinct.
|
|
7
|
+
* @returns A `.heapsnapshot` filename that names one file in one directory.
|
|
8
|
+
*/
|
|
9
|
+
export declare function heapSnapshotName(tag: string, time: number): string;
|
|
10
|
+
/** Write a V8 heap snapshot into one directory.
|
|
11
|
+
*
|
|
12
|
+
* V8 serializes the whole heap synchronously, so the client pauses while the file is written; the
|
|
13
|
+
* result is the artifact DevTools records, meant for offline leak analysis rather than display.
|
|
14
|
+
* @param directory - Destination directory.
|
|
15
|
+
* @param tag - Sampling-point label naming the file.
|
|
16
|
+
* @param write - Snapshot writer; injectable so tests do not have to serialize a heap.
|
|
17
|
+
* @returns Absolute path of the written snapshot.
|
|
18
|
+
*/
|
|
19
|
+
export declare function writeHeapSnapshot(directory: string, tag?: string, write?: (file: string) => string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** V8 heap snapshots written to the working directory so memory growth can be inspected offline. */
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { writeHeapSnapshot as writeV8HeapSnapshot } from 'node:v8';
|
|
4
|
+
/** Longest sampling-point tag kept in a snapshot filename. */
|
|
5
|
+
const TAG_LIMIT = 48;
|
|
6
|
+
/** Build a snapshot filename from a sampling-point tag and a time.
|
|
7
|
+
*
|
|
8
|
+
* The tag is a user label such as `after-stress`; everything that could steer the write outside the
|
|
9
|
+
* destination directory is folded into a dash, so a tag can never become a path.
|
|
10
|
+
* @param tag - Sampling-point label.
|
|
11
|
+
* @param time - Epoch milliseconds that keeps repeated snapshots distinct.
|
|
12
|
+
* @returns A `.heapsnapshot` filename that names one file in one directory.
|
|
13
|
+
*/
|
|
14
|
+
export function heapSnapshotName(tag, time) {
|
|
15
|
+
const safe = tag.trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, TAG_LIMIT).replace(/[.-]+$/, '');
|
|
16
|
+
return `${safe || 'snapshot'}-${time}.heapsnapshot`;
|
|
17
|
+
}
|
|
18
|
+
/** Write a V8 heap snapshot into one directory.
|
|
19
|
+
*
|
|
20
|
+
* V8 serializes the whole heap synchronously, so the client pauses while the file is written; the
|
|
21
|
+
* result is the artifact DevTools records, meant for offline leak analysis rather than display.
|
|
22
|
+
* @param directory - Destination directory.
|
|
23
|
+
* @param tag - Sampling-point label naming the file.
|
|
24
|
+
* @param write - Snapshot writer; injectable so tests do not have to serialize a heap.
|
|
25
|
+
* @returns Absolute path of the written snapshot.
|
|
26
|
+
*/
|
|
27
|
+
export function writeHeapSnapshot(directory, tag = 'snapshot', write = writeV8HeapSnapshot) {
|
|
28
|
+
return write(join(directory, heapSnapshotName(tag, Date.now())));
|
|
29
|
+
}
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/** Storage domain: every filesystem operation the client performs. */
|
|
2
|
-
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, writeExclusiveStream, writePrivateFile } from './files.ts';
|
|
2
|
+
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from './files.ts';
|
|
3
3
|
export { ensureDirectory, ensurePrivateDirectory, listEntries } from './directories.ts';
|
|
4
|
+
export { heapSnapshotName, writeHeapSnapshot } from './heap-snapshot.ts';
|
package/dist/storage/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
/** Storage domain: every filesystem operation the client performs. */
|
|
2
|
-
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, writeExclusiveStream, writePrivateFile } from "./files.js";
|
|
2
|
+
export { appendPrivateFile, createPrivateFile, readPrivateFile, readText, removeFile, renameFile, writeExclusiveStream, writePrivateFile } from "./files.js";
|
|
3
3
|
export { ensureDirectory, ensurePrivateDirectory, listEntries } from "./directories.js";
|
|
4
|
+
export { heapSnapshotName, writeHeapSnapshot } from "./heap-snapshot.js";
|
package/dist/ui/app.js
CHANGED
|
@@ -19,7 +19,7 @@ import { CopyMode } from "./copy-mode.js";
|
|
|
19
19
|
import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
|
|
20
20
|
import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
|
|
21
21
|
import { classifySubmission } from "./commands/parse.js";
|
|
22
|
-
import { sessionLabel } from "../session/navigation.js";
|
|
22
|
+
import { sessionLabel, sessionStatus, workspaceStatus } from "../session/navigation.js";
|
|
23
23
|
import { array, errorText, object, safeText, string } from "../transport/wire.js";
|
|
24
24
|
/** Transient notices expire; interactive panels remain open until dismissed. */
|
|
25
25
|
const PANEL_LIFETIME_MS = 10_000;
|
|
@@ -125,6 +125,14 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
125
125
|
const timer = setTimeout(() => setNotice(undefined), panelLifetimeMs);
|
|
126
126
|
return () => clearTimeout(timer);
|
|
127
127
|
}, [notice, panelLifetimeMs, copyMode]);
|
|
128
|
+
// `/history` is a lookup rather than a panel to read, so it expires with the notice lifetime;
|
|
129
|
+
// Esc closes it sooner, and its own content search stays open until the reader leaves it.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
if (copyMode || contentSearch || historyQuery === undefined)
|
|
132
|
+
return;
|
|
133
|
+
const timer = setTimeout(() => { setHistoryQuery(undefined); setHistoryMatches(undefined); }, panelLifetimeMs);
|
|
134
|
+
return () => clearTimeout(timer);
|
|
135
|
+
}, [historyQuery, contentSearch, panelLifetimeMs, copyMode]);
|
|
128
136
|
const pending = state.pending[0];
|
|
129
137
|
const queued = controller.telemetry.pending(state.sessionId).filter(item => item.placement !== 'context');
|
|
130
138
|
useEffect(() => { setQueueOpen(false); }, [state.sessionId, pending?.eventId]);
|
|
@@ -185,6 +193,31 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
185
193
|
// leave the arrows with the history, and Ctrl+P/N reach it from every surface.
|
|
186
194
|
const recallBlocked = !!(removal || models || thoughtList || historyQuery !== undefined || searchResults || (statusExpanded && statusOverflow));
|
|
187
195
|
const questionKeysActive = !!question && options.length > 0 && !choiceState.custom && !copyMode && !panelBlocksKeys;
|
|
196
|
+
// A dialog that demands an answer owns the keyboard until it is settled: the draft being written is
|
|
197
|
+
// parked rather than typed into, and comes back when the last one closes. A question switches to
|
|
198
|
+
// the composer itself for a free-text or "Other" answer, so it keeps the keys then.
|
|
199
|
+
const answerPending = !!pending && (pending.event === 'approval/request' || options.length > 0 && !choiceState.custom);
|
|
200
|
+
const parkedDraft = useRef('');
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (answerPending) {
|
|
203
|
+
if (draft.current === '')
|
|
204
|
+
return;
|
|
205
|
+
parkedDraft.current = draft.current;
|
|
206
|
+
draft.current = '';
|
|
207
|
+
updateInput('');
|
|
208
|
+
setCursor(0);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (pending || parkedDraft.current === '')
|
|
212
|
+
return;
|
|
213
|
+
// Restoring writes the states directly: the draft was neither recalled from history nor a new
|
|
214
|
+
// message, so it must not move a reader's scroll position.
|
|
215
|
+
const restore = parkedDraft.current;
|
|
216
|
+
parkedDraft.current = '';
|
|
217
|
+
draft.current = restore;
|
|
218
|
+
updateInput(restore);
|
|
219
|
+
setCursor(restore.length);
|
|
220
|
+
}, [answerPending, pending]);
|
|
188
221
|
const approvalKeysActive = pending?.event === 'approval/request' && !copyMode && !panelBlocksKeys;
|
|
189
222
|
const approvalIndex = approvalSelection?.eventId === eventId ? approvalSelection.index : -1;
|
|
190
223
|
const answerQuestion = async (selected, custom) => {
|
|
@@ -222,6 +255,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
222
255
|
}
|
|
223
256
|
if ((key.escape || key.ctrl && _value === 'c') && historyAbort.current) {
|
|
224
257
|
historyAbort.current.abort();
|
|
258
|
+
// Esc also leaves a history list that was already on screen when the load was aborted.
|
|
259
|
+
if (key.escape && historyQuery !== undefined) {
|
|
260
|
+
setHistoryQuery(undefined);
|
|
261
|
+
setHistoryMatches(undefined);
|
|
262
|
+
}
|
|
225
263
|
return;
|
|
226
264
|
}
|
|
227
265
|
if ((key.escape || key.ctrl && _value === 'c') && controller.state.pending.length) {
|
|
@@ -335,6 +373,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
335
373
|
void controller.interrupt(true);
|
|
336
374
|
return;
|
|
337
375
|
}
|
|
376
|
+
// The typed host path is a screen of its own, so Esc has to leave it: the picker behind it is
|
|
377
|
+
// disabled while a draft exists, so a leftover path would leave no way back at all.
|
|
378
|
+
if (key.escape && state.screen === 'path') {
|
|
379
|
+
setInput('');
|
|
380
|
+
operate(() => controller.showPicker('workspaces'));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
338
383
|
if (key.ctrl && _value === 'c') {
|
|
339
384
|
// A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
|
|
340
385
|
if (input !== '') {
|
|
@@ -555,6 +600,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
555
600
|
case 'exportHtml':
|
|
556
601
|
await historyOperation(async (signal) => { setNotice(`Saved loaded conversation: ${await controller.exportHtml(submission.destination, signal)}`); }, 'Exporting loaded conversation…');
|
|
557
602
|
return;
|
|
603
|
+
case 'coredump':
|
|
604
|
+
// V8 serializes the heap synchronously, so the client stalls until the file is written;
|
|
605
|
+
// the path is reported afterwards so the snapshot can be opened in DevTools.
|
|
606
|
+
await historyOperation(async () => {
|
|
607
|
+
setNotice(`Heap snapshot saved: ${controller.heapSnapshot(submission.tag)}`);
|
|
608
|
+
}, 'Writing heap snapshot…');
|
|
609
|
+
return;
|
|
558
610
|
case 'answer':
|
|
559
611
|
await answerQuestion(question.multiSelect === true ? choiceState.selected : [], submission.text);
|
|
560
612
|
return;
|
|
@@ -569,17 +621,28 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
569
621
|
if (accepted)
|
|
570
622
|
setInput('');
|
|
571
623
|
};
|
|
624
|
+
// Session activity is a list-level fact, so both pickers read it without loading any history.
|
|
625
|
+
const listAge = Date.now();
|
|
626
|
+
const sessionsOf = (workspace) => {
|
|
627
|
+
const ids = new Set(array(workspace.sessionIds).map(string));
|
|
628
|
+
return state.sessions.filter(session => ids.has(string(session.sessionId)));
|
|
629
|
+
};
|
|
572
630
|
const choices = state.screen === 'workspaces' ? [
|
|
573
631
|
...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
|
|
574
|
-
label: `${string(workspace.title)
|
|
632
|
+
label: `${[workspaceStatus(sessionsOf(workspace)), string(workspace.title), string(workspace.path)].filter(Boolean).join(' ')}`,
|
|
575
633
|
remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
|
|
576
634
|
action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
|
|
577
635
|
{ key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
|
|
636
|
+
// The directory this client runs in is the one case where no path has to be typed, and offering
|
|
637
|
+
// it only while the host has not registered it keeps the row from repeating itself.
|
|
638
|
+
...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
|
|
639
|
+
: [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
|
|
640
|
+
action: () => operate(() => controller.createWorkspace(controller.localDirectory)) }]),
|
|
578
641
|
{ key: '@new', label: '+ Add workspace (host directory)', action: () => controller.enterPath() },
|
|
579
642
|
] : [
|
|
580
643
|
...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
|
|
581
644
|
...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
|
|
582
|
-
label: `${session
|
|
645
|
+
label: `${sessionStatus(session, listAge)} ${sessionLabel(session)} ${session.sessionId}`,
|
|
583
646
|
remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
|
|
584
647
|
action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
|
|
585
648
|
{ key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
|
|
@@ -722,5 +785,6 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
722
785
|
const statusFrozen = pauseReason !== undefined;
|
|
723
786
|
return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
|
|
724
787
|
.slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index + 1, ". ", question?.multiSelect === true && index < options.length ? choiceState.selected.includes(string(option.label)) ? '[x] ' : '[ ] ' : '', safeText(string(option.label))] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(string(option.description))] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
|
|
725
|
-
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? '
|
|
788
|
+
? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Choose "Other answer" to type · the draft is kept while this is open'
|
|
789
|
+
: 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: answerPending ? theme.colors.muted : theme.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
|
|
726
790
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { HistoryRow } from '../../session/history.ts';
|
|
2
2
|
/** Render a viewport with role, reasoning, tool and result colors from the selected theme.
|
|
3
|
+
*
|
|
4
|
+
* Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
|
|
5
|
+
* node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
|
|
6
|
+
* An empty row renders as a single space because Ink gives an empty text node no height.
|
|
3
7
|
* @param rows - Already wrapped, terminal-safe visible rows.
|
|
4
|
-
* @returns Colored
|
|
8
|
+
* @returns Colored rows with unchanged row geometry and plain-text role markers.
|
|
5
9
|
*/
|
|
6
10
|
export declare function HistoryViewport({ rows }: {
|
|
7
11
|
rows: HistoryRow[];
|
|
@@ -1,12 +1,17 @@
|
|
|
1
|
-
import { jsx as _jsx
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
/** Only visible semantic rows become React nodes; ANSI colors are applied after remote text sanitation. */
|
|
3
|
-
import { Text } from 'ink';
|
|
3
|
+
import { Box, Text } from 'ink';
|
|
4
4
|
import { useTheme } from "../theme/index.js";
|
|
5
5
|
/** Render a viewport with role, reasoning, tool and result colors from the selected theme.
|
|
6
|
+
*
|
|
7
|
+
* Rows are sibling text nodes rather than lines inside one text node: Ink measures and caches text per
|
|
8
|
+
* node, so a parent holding the whole viewport is re-measured and retained whenever one row changes.
|
|
9
|
+
* An empty row renders as a single space because Ink gives an empty text node no height.
|
|
6
10
|
* @param rows - Already wrapped, terminal-safe visible rows.
|
|
7
|
-
* @returns Colored
|
|
11
|
+
* @returns Colored rows with unchanged row geometry and plain-text role markers.
|
|
8
12
|
*/
|
|
9
13
|
export function HistoryViewport({ rows }) {
|
|
10
14
|
const theme = useTheme();
|
|
11
|
-
return _jsx(
|
|
15
|
+
return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { color: theme.colors[row.kind], bold: row.bold, children: row.spans?.length ? row.spans.map((span, position) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, position))
|
|
16
|
+
: row.text === '' ? ' ' : row.text }, index)) });
|
|
12
17
|
}
|