@itookit/dsht 0.3.4 → 0.3.8
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 +4 -3
- package/README.zh.md +4 -3
- package/dist/cli/dsht.js +5 -2
- package/dist/controller/controller.d.ts +113 -1
- package/dist/controller/controller.js +145 -2
- package/dist/cost/controller.d.ts +5 -0
- package/dist/cost/controller.js +2 -1
- package/dist/cost/scanner.d.ts +4 -2
- package/dist/cost/scanner.js +6 -3
- package/dist/session/controller.d.ts +150 -1
- package/dist/session/controller.js +388 -29
- package/dist/session/history.d.ts +21 -1
- package/dist/session/history.js +15 -0
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/info.d.ts +262 -0
- package/dist/session/info.js +326 -0
- package/dist/session/transcript.d.ts +37 -1
- package/dist/session/transcript.js +73 -0
- package/dist/shell/controller.d.ts +67 -0
- package/dist/shell/controller.js +126 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +28 -0
- package/dist/shell/runner.js +108 -0
- package/dist/state.d.ts +3 -2
- package/dist/state.js +2 -2
- package/dist/ui/app.js +206 -177
- package/dist/ui/chat/history-view.js +1 -1
- package/dist/ui/chat/shell-view.d.ts +34 -0
- package/dist/ui/chat/shell-view.js +111 -0
- package/dist/ui/chat/status.js +4 -4
- package/dist/ui/commands/parse.d.ts +5 -0
- package/dist/ui/commands/parse.js +9 -0
- package/dist/ui/dialogs/index.d.ts +3 -6
- package/dist/ui/theme/index.d.ts +5 -0
- package/dist/ui/theme/index.js +2 -1
- package/dsht-m.png +0 -0
- package/package.json +1 -1
- package/dist/ui/input/history.d.ts +0 -19
- package/dist/ui/input/history.js +0 -43
|
@@ -7,11 +7,14 @@ import { saveTranscriptHtml } from "./export-html.js";
|
|
|
7
7
|
import { releaseHistoryLayout } from "./history.js";
|
|
8
8
|
import { resolveTarget, sessionLabel } from "./navigation.js";
|
|
9
9
|
import { fileReferences } from "./references.js";
|
|
10
|
-
import {
|
|
10
|
+
import { PromptCache } from "./info.js";
|
|
11
|
+
import { recordPrompts, Transcript, toolLine } from "./transcript.js";
|
|
11
12
|
/** Built-in preset identifiers and the labels the web session header shows. */
|
|
12
13
|
const BUILT_IN_MODES = new Map([['standard', 'Standard mode'], ['ptc', 'PTC mode'], ['minimal', 'Minimal mode'], ['cordis', 'Creator mode']]);
|
|
13
14
|
/** Bounded match count for one history search. */
|
|
14
15
|
const SEARCH_MATCH_LIMIT = 200;
|
|
16
|
+
/** Bounded page count for the prompt backfill that runs once per opened session. */
|
|
17
|
+
const PROMPT_BACKFILL_PAGES = 200;
|
|
15
18
|
/** Owns the selected session: its follow stream, transcript, history window and interactions. */
|
|
16
19
|
export class SessionController {
|
|
17
20
|
store;
|
|
@@ -20,7 +23,13 @@ export class SessionController {
|
|
|
20
23
|
historyLimits;
|
|
21
24
|
follow;
|
|
22
25
|
interactions = new Map();
|
|
23
|
-
|
|
26
|
+
/** Cancels the background prompt backfill of the previous selection. */
|
|
27
|
+
promptBackfill;
|
|
28
|
+
/** Prompts of sessions this process has already read, so re-opening one costs no page request. */
|
|
29
|
+
promptCache = new PromptCache();
|
|
30
|
+
/** Prompt index, composer and reading view of the selected session; the instance `State.session` exposes. */
|
|
31
|
+
get info() { return this.store.state.session; }
|
|
32
|
+
get prompts() { return this.info.prompts; }
|
|
24
33
|
stoppingSession;
|
|
25
34
|
interruptTask;
|
|
26
35
|
admission;
|
|
@@ -61,7 +70,7 @@ export class SessionController {
|
|
|
61
70
|
get workingSince() {
|
|
62
71
|
if (!this.running || !this.store.state.sessionId)
|
|
63
72
|
return undefined;
|
|
64
|
-
return this.
|
|
73
|
+
return this.info.record.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
|
|
65
74
|
}
|
|
66
75
|
/** Present only sessions explicitly accounted to the selected workspace. */
|
|
67
76
|
get visibleSessions() {
|
|
@@ -75,13 +84,13 @@ export class SessionController {
|
|
|
75
84
|
/** Whether a turn, cancellation or prompt admission is still in flight. */
|
|
76
85
|
get active() { return this.interruptTask !== undefined || this.running || this.admission !== undefined; }
|
|
77
86
|
/** Whether reading protects the loaded window, suspending history reclamation. */
|
|
78
|
-
get pinned() { return this.
|
|
87
|
+
get pinned() { return this.info.view.pinned; }
|
|
79
88
|
/** Drop generation-scoped state before a new connection generation begins. */
|
|
80
89
|
beginGeneration() { this.stoppingSession = undefined; }
|
|
81
90
|
/** Invalidate in-flight work and drop transient interactions when a generation ends. */
|
|
82
91
|
endGeneration() {
|
|
83
92
|
this.store.bumpSelection();
|
|
84
|
-
this.
|
|
93
|
+
this.info.record.ready = false;
|
|
85
94
|
this.interactions.clear();
|
|
86
95
|
}
|
|
87
96
|
/** Wait for an in-flight cancellation so shutdown leaves nothing running. */
|
|
@@ -181,7 +190,7 @@ export class SessionController {
|
|
|
181
190
|
* @param pinned - Whether the main transcript is actively being read away from its tail.
|
|
182
191
|
*/
|
|
183
192
|
pinHistory(pinned) {
|
|
184
|
-
this.
|
|
193
|
+
this.info.view.pinned = pinned;
|
|
185
194
|
if (!pinned && this.reclaimHistory())
|
|
186
195
|
this.store.update({});
|
|
187
196
|
}
|
|
@@ -254,7 +263,7 @@ export class SessionController {
|
|
|
254
263
|
this.follow?.cancel();
|
|
255
264
|
this.follow = undefined;
|
|
256
265
|
this.releaseTranscript();
|
|
257
|
-
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false,
|
|
266
|
+
this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
|
|
258
267
|
}
|
|
259
268
|
/** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
|
|
260
269
|
* @param query - Workspace target, if any.
|
|
@@ -288,6 +297,32 @@ export class SessionController {
|
|
|
288
297
|
}
|
|
289
298
|
/** Prompt for a host path without starting a local agent. */
|
|
290
299
|
enterPath() { this.store.update({ screen: 'path' }); }
|
|
300
|
+
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
301
|
+
*
|
|
302
|
+
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
303
|
+
* path segments so `/srv/app-old` cannot match `/srv/app`. A remote host's paths usually differ
|
|
304
|
+
* from the client's, in which case nothing matches and the picker stays exactly as before.
|
|
305
|
+
* @param directory - Directory this client was started in.
|
|
306
|
+
* @returns The adopted workspace's id, or undefined when none matches.
|
|
307
|
+
*/
|
|
308
|
+
adoptLocalWorkspace(directory) {
|
|
309
|
+
const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
310
|
+
const target = slashed(directory);
|
|
311
|
+
if (target === '')
|
|
312
|
+
return undefined;
|
|
313
|
+
let best;
|
|
314
|
+
for (const workspace of this.store.state.workspaces) {
|
|
315
|
+
const path = slashed(string(workspace.path));
|
|
316
|
+
if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
|
|
317
|
+
continue;
|
|
318
|
+
if (best === undefined || path.length > best.length)
|
|
319
|
+
best = { id: string(workspace.workspaceId), length: path.length };
|
|
320
|
+
}
|
|
321
|
+
if (best === undefined)
|
|
322
|
+
return undefined;
|
|
323
|
+
this.pickWorkspace(best.id);
|
|
324
|
+
return best.id;
|
|
325
|
+
}
|
|
291
326
|
/** Register a host directory and move to its session picker.
|
|
292
327
|
* @param path - Absolute directory path on the host.
|
|
293
328
|
*/
|
|
@@ -313,13 +348,13 @@ export class SessionController {
|
|
|
313
348
|
const selection = this.store.selection();
|
|
314
349
|
this.follow?.cancel();
|
|
315
350
|
this.follow = undefined;
|
|
316
|
-
this.
|
|
317
|
-
const transcript =
|
|
351
|
+
this.info.reset(sessionId);
|
|
352
|
+
const transcript = this.info.record;
|
|
318
353
|
const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
|
|
319
354
|
const workspaceId = workspace ? string(workspace.workspaceId)
|
|
320
355
|
: this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
|
|
321
356
|
this.connection.observe(sessionId);
|
|
322
|
-
this.store.update({ sessionId, workspaceId, showAllSessions: false,
|
|
357
|
+
this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
|
|
323
358
|
this.follow = this.host.require().subscribe('session/follow', {
|
|
324
359
|
request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
|
|
325
360
|
}, {
|
|
@@ -328,11 +363,12 @@ export class SessionController {
|
|
|
328
363
|
return;
|
|
329
364
|
try {
|
|
330
365
|
transcript.accept(value);
|
|
366
|
+
this.prompts.fold(transcript.promptsSince(this.prompts.through));
|
|
331
367
|
this.reclaimHistory();
|
|
332
368
|
const frame = object(value);
|
|
333
369
|
if (frame.type === 'snapshot')
|
|
334
370
|
this.connection.telemetryView().snapshot(sessionId, frame.projections);
|
|
335
|
-
this.store.update({
|
|
371
|
+
this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
|
|
336
372
|
}
|
|
337
373
|
catch (error) {
|
|
338
374
|
this.connection.fail(new Error(errorText(error)));
|
|
@@ -345,16 +381,17 @@ export class SessionController {
|
|
|
345
381
|
this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
|
|
346
382
|
},
|
|
347
383
|
});
|
|
384
|
+
this.backfillPrompts(sessionId, selection);
|
|
348
385
|
}
|
|
349
386
|
/** Wait for the selected follow snapshot, failing on disconnect or cancellation.
|
|
350
387
|
* @param signal - Cancels waiting without closing the session.
|
|
351
388
|
*/
|
|
352
389
|
async waitForHistory(signal) {
|
|
353
|
-
const transcript = this.
|
|
390
|
+
const transcript = this.info.record;
|
|
354
391
|
const deadline = Date.now() + this.host.require().timeoutMs;
|
|
355
392
|
while (!transcript.ready) {
|
|
356
393
|
signal.throwIfAborted();
|
|
357
|
-
if (!this.store.state.online || this.
|
|
394
|
+
if (!this.store.state.online || this.info.record !== transcript)
|
|
358
395
|
throw new Error('Session changed while loading history');
|
|
359
396
|
if (Date.now() >= deadline)
|
|
360
397
|
throw new Error('Session snapshot timed out');
|
|
@@ -397,7 +434,7 @@ export class SessionController {
|
|
|
397
434
|
* @returns The host's successful command result text.
|
|
398
435
|
*/
|
|
399
436
|
async command(line, signal) {
|
|
400
|
-
if (!this.
|
|
437
|
+
if (!this.info.record.ready)
|
|
401
438
|
throw new Error('Wait for the session snapshot before running commands');
|
|
402
439
|
const execution = await this.host.require().call('commands/execute', {
|
|
403
440
|
agentId: this.sessionId, line, submittedAttachments: [],
|
|
@@ -410,6 +447,11 @@ export class SessionController {
|
|
|
410
447
|
}
|
|
411
448
|
if (result.kind === 'error')
|
|
412
449
|
throw new Error(string(result.text));
|
|
450
|
+
// Compaction rewrites the host log, so a cached prompt list for this session may describe
|
|
451
|
+
// records that no longer exist. The live index keeps what the reader already sees; the cache is
|
|
452
|
+
// dropped so the next open reads the rewritten history.
|
|
453
|
+
if (line.trim().split(/\s/, 1)[0] === '/compact')
|
|
454
|
+
this.promptCache.drop(this.sessionId);
|
|
413
455
|
return result.text === undefined ? 'Command completed.' : string(result.text);
|
|
414
456
|
}
|
|
415
457
|
/** Remove one host-owned pending input; an already claimed item reports a host error.
|
|
@@ -432,7 +474,7 @@ export class SessionController {
|
|
|
432
474
|
* @returns Absolute saved filename.
|
|
433
475
|
*/
|
|
434
476
|
async exportHtml(path, signal) {
|
|
435
|
-
return saveTranscriptHtml(this.
|
|
477
|
+
return saveTranscriptHtml(this.info.record, this.sessionId, path, signal);
|
|
436
478
|
}
|
|
437
479
|
/** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain.
|
|
438
480
|
* @param text - Composed prompt text.
|
|
@@ -441,7 +483,7 @@ export class SessionController {
|
|
|
441
483
|
if (this.store.state.pending.length)
|
|
442
484
|
throw new Error('Answer the pending question or approval first');
|
|
443
485
|
this.stoppingSession = undefined;
|
|
444
|
-
if (!this.
|
|
486
|
+
if (!this.info.record.ready)
|
|
445
487
|
throw new Error('Wait for the session snapshot before sending');
|
|
446
488
|
const admission = this.host.require().call('session/prompt', { request: {
|
|
447
489
|
sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
|
|
@@ -493,13 +535,17 @@ export class SessionController {
|
|
|
493
535
|
this.store.update({});
|
|
494
536
|
}
|
|
495
537
|
/** Add a page before the retained window using its fixed opening cut.
|
|
538
|
+
*
|
|
539
|
+
* Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
|
|
540
|
+
* survive is already covered by the view: scrolling back sets a scroll position, `/think` opens a
|
|
541
|
+
* panel, and a recall page folds its prompts into the index before this resolves. A pin set here
|
|
542
|
+
* had no clearing edge, so after one recall page the history budget silently stopped applying for
|
|
543
|
+
* the rest of the session while every following page kept adding records.
|
|
496
544
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
497
545
|
* @param transcript - Transcript to extend; defaults to the live one.
|
|
498
546
|
*/
|
|
499
|
-
async older(signal, transcript = this.
|
|
547
|
+
async older(signal, transcript = this.info.record) {
|
|
500
548
|
const selection = this.store.selection();
|
|
501
|
-
if (transcript === this.store.state.transcript)
|
|
502
|
-
this.historyPinned = true;
|
|
503
549
|
if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
|
|
504
550
|
return;
|
|
505
551
|
const result = await this.host.require().call('session/page', { request: {
|
|
@@ -511,13 +557,326 @@ export class SessionController {
|
|
|
511
557
|
transcript.addPage(result);
|
|
512
558
|
this.store.update({});
|
|
513
559
|
}
|
|
560
|
+
/** Recall one step through the session's prompt index; navigation never touches the network.
|
|
561
|
+
* @param direction - Negative for older input, positive for newer input.
|
|
562
|
+
* @param current - Composer content before recall began, restored at the newest position.
|
|
563
|
+
* @returns The recalled prompt, or the unsent draft.
|
|
564
|
+
*/
|
|
565
|
+
recall(direction, current) { return this.prompts.move(direction, current); }
|
|
566
|
+
/** Composer draft, caret and parked draft, as the selected session holds them. */
|
|
567
|
+
get composer() { return this.info.composer; }
|
|
568
|
+
/** Replace the composer text and caret, publishing only when either actually changed. */
|
|
569
|
+
setComposer(draft, cursor = draft.length) {
|
|
570
|
+
const composer = this.info.composer;
|
|
571
|
+
if (composer.draft === draft && composer.cursor === cursor)
|
|
572
|
+
return;
|
|
573
|
+
composer.draft = draft;
|
|
574
|
+
composer.cursor = cursor;
|
|
575
|
+
this.store.update({});
|
|
576
|
+
}
|
|
577
|
+
/** Move the caret without changing the text. */
|
|
578
|
+
setComposerCursor(cursor) {
|
|
579
|
+
const composer = this.info.composer;
|
|
580
|
+
if (composer.cursor === cursor)
|
|
581
|
+
return;
|
|
582
|
+
composer.cursor = cursor;
|
|
583
|
+
this.store.update({});
|
|
584
|
+
}
|
|
585
|
+
/** Move a non-empty draft aside while a dialog owns the keyboard. */
|
|
586
|
+
parkComposer() {
|
|
587
|
+
const composer = this.info.composer;
|
|
588
|
+
if (composer.draft === '')
|
|
589
|
+
return;
|
|
590
|
+
composer.parked = composer.draft;
|
|
591
|
+
composer.draft = '';
|
|
592
|
+
composer.cursor = 0;
|
|
593
|
+
this.store.update({});
|
|
594
|
+
}
|
|
595
|
+
/** Give a parked draft back once no dialog needs the keyboard. */
|
|
596
|
+
restoreComposer() {
|
|
597
|
+
const composer = this.info.composer;
|
|
598
|
+
if (composer.parked === '')
|
|
599
|
+
return;
|
|
600
|
+
composer.draft = composer.parked;
|
|
601
|
+
composer.cursor = composer.parked.length;
|
|
602
|
+
composer.parked = '';
|
|
603
|
+
this.store.update({});
|
|
604
|
+
}
|
|
605
|
+
/** How the selected session's record is being read right now. */
|
|
606
|
+
get view() { return this.info.view; }
|
|
607
|
+
/** Show a detached history window, releasing the one it replaces.
|
|
608
|
+
* @param window - Record to display, or undefined to return to the live transcript.
|
|
609
|
+
*/
|
|
610
|
+
setViewWindow(window) {
|
|
611
|
+
if (this.info.view.window === window)
|
|
612
|
+
return;
|
|
613
|
+
this.info.closeWindow();
|
|
614
|
+
this.info.view.window = window;
|
|
615
|
+
this.store.update({});
|
|
616
|
+
}
|
|
617
|
+
/** Move the reader's position inside the displayed record.
|
|
618
|
+
* @param scroll - Rows scrolled back from the live end.
|
|
619
|
+
*/
|
|
620
|
+
setScroll(scroll) {
|
|
621
|
+
if (this.info.view.scroll === scroll)
|
|
622
|
+
return;
|
|
623
|
+
this.info.view.scroll = scroll;
|
|
624
|
+
this.store.update({});
|
|
625
|
+
}
|
|
626
|
+
/** Replace the set of expanded reasoning blocks, keyed by message sequence.
|
|
627
|
+
* @param folds - Sequences to expand beyond the default fold.
|
|
628
|
+
*/
|
|
629
|
+
setFolds(folds) {
|
|
630
|
+
if (this.info.view.folds === folds)
|
|
631
|
+
return;
|
|
632
|
+
this.info.view.folds = folds;
|
|
633
|
+
this.store.update({});
|
|
634
|
+
}
|
|
635
|
+
/** Set the fold mode of the live attempt's completed reasoning.
|
|
636
|
+
* @param reasoning - `row` to fold, `full` to keep the streamed text.
|
|
637
|
+
*/
|
|
638
|
+
setLiveReasoning(reasoning) {
|
|
639
|
+
if (this.info.view.liveReasoning === reasoning)
|
|
640
|
+
return;
|
|
641
|
+
this.info.view.liveReasoning = reasoning;
|
|
642
|
+
this.store.update({});
|
|
643
|
+
}
|
|
644
|
+
/** Local answer state for the selected session's pending waterfalls. */
|
|
645
|
+
get interaction() { return this.info.interaction; }
|
|
646
|
+
/** Replace the partly collected answers, keyed by the waterfall event id.
|
|
647
|
+
* @param answers - Answers collected so far, by event id.
|
|
648
|
+
*/
|
|
649
|
+
setAnswers(answers) {
|
|
650
|
+
this.info.interaction.answers = answers;
|
|
651
|
+
this.store.update({});
|
|
652
|
+
}
|
|
653
|
+
/** Replace the pending question's option keyboard state.
|
|
654
|
+
* @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
|
|
655
|
+
*/
|
|
656
|
+
setOption(option) {
|
|
657
|
+
if (this.info.interaction.option === option)
|
|
658
|
+
return;
|
|
659
|
+
this.info.interaction.option = option;
|
|
660
|
+
this.store.update({});
|
|
661
|
+
}
|
|
662
|
+
/** Replace the pending approval's selected row.
|
|
663
|
+
* @param approval - Selected approval row; undefined clears the highlight.
|
|
664
|
+
*/
|
|
665
|
+
setApproval(approval) {
|
|
666
|
+
if (this.info.interaction.approval === approval)
|
|
667
|
+
return;
|
|
668
|
+
this.info.interaction.approval = approval;
|
|
669
|
+
this.store.update({});
|
|
670
|
+
}
|
|
671
|
+
/** Composer-adjacent `@` reference menu state. */
|
|
672
|
+
get reference() { return this.info.reference; }
|
|
673
|
+
/** Highlight one row of the open reference menu.
|
|
674
|
+
* @param index - Row index into the current matches.
|
|
675
|
+
*/
|
|
676
|
+
setReferenceIndex(index) {
|
|
677
|
+
if (this.info.reference.index === index)
|
|
678
|
+
return;
|
|
679
|
+
this.info.reference.index = index;
|
|
680
|
+
this.store.update({});
|
|
681
|
+
}
|
|
682
|
+
/** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
|
|
683
|
+
* @param draft - Composer text at dismissal, or undefined to allow the menu again.
|
|
684
|
+
*/
|
|
685
|
+
setReferenceDismissed(draft) {
|
|
686
|
+
if (this.info.reference.dismissed === draft)
|
|
687
|
+
return;
|
|
688
|
+
this.info.reference.dismissed = draft;
|
|
689
|
+
this.store.update({});
|
|
690
|
+
}
|
|
691
|
+
/** Panels the selected session has open. */
|
|
692
|
+
get panels() { return this.info.panels; }
|
|
693
|
+
/** Show or hide the reasoning panel.
|
|
694
|
+
* @param open - Whether `/think` is open.
|
|
695
|
+
*/
|
|
696
|
+
openThoughts(open) {
|
|
697
|
+
if (this.info.panels.thoughts === open)
|
|
698
|
+
return;
|
|
699
|
+
this.info.panels.thoughts = open;
|
|
700
|
+
this.store.update({});
|
|
701
|
+
}
|
|
702
|
+
/** Show or hide the pending-input panel.
|
|
703
|
+
* @param open - Whether `/queue` is open.
|
|
704
|
+
*/
|
|
705
|
+
openQueue(open) {
|
|
706
|
+
if (this.info.panels.queue === open)
|
|
707
|
+
return;
|
|
708
|
+
this.info.panels.queue = open;
|
|
709
|
+
this.store.update({});
|
|
710
|
+
}
|
|
711
|
+
/** Show the model dialog at one step, or close it.
|
|
712
|
+
* @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
|
|
713
|
+
*/
|
|
714
|
+
setModelPanel(model) {
|
|
715
|
+
if (this.info.panels.model === model)
|
|
716
|
+
return;
|
|
717
|
+
this.info.panels.model = model;
|
|
718
|
+
this.store.update({});
|
|
719
|
+
}
|
|
720
|
+
/** Show the history or content-search dialog, or close it.
|
|
721
|
+
* @param history - Query, content-search mode and matches; undefined closes the dialog.
|
|
722
|
+
*/
|
|
723
|
+
setHistoryPanel(history) {
|
|
724
|
+
if (this.info.panels.history === history)
|
|
725
|
+
return;
|
|
726
|
+
this.info.panels.history = history;
|
|
727
|
+
this.store.update({});
|
|
728
|
+
}
|
|
729
|
+
/** Show the host session-search results, or close them.
|
|
730
|
+
* @param search - Query, results and truncation flag; undefined closes the dialog.
|
|
731
|
+
*/
|
|
732
|
+
setSearchPanel(search) {
|
|
733
|
+
if (this.info.panels.search === search)
|
|
734
|
+
return;
|
|
735
|
+
this.info.panels.search = search;
|
|
736
|
+
this.store.update({});
|
|
737
|
+
}
|
|
738
|
+
/** Remember a locally submitted command, which never becomes a durable session record. */
|
|
739
|
+
recordRecall(value) { this.prompts.record(value); }
|
|
740
|
+
/** Leave recall navigation because the composer was edited or replaced. */
|
|
741
|
+
resetRecall() { this.prompts.resetCursor(); }
|
|
742
|
+
/** Whether recall is parked on the oldest prompt it retains in memory. */
|
|
743
|
+
get recallAtOldest() { return this.prompts.atOldest; }
|
|
744
|
+
/** How many prompts the session retains for recall. */
|
|
745
|
+
get recallLength() { return this.prompts.length; }
|
|
746
|
+
/** Whether an older prompt is reachable at all: shed from the window, or still on the host. */
|
|
747
|
+
get recallHasOlder() {
|
|
748
|
+
const transcript = this.info.record;
|
|
749
|
+
const oldest = this.prompts.oldest;
|
|
750
|
+
const refillable = oldest !== undefined && transcript.beforeSeq !== undefined && oldest > transcript.beforeSeq;
|
|
751
|
+
return refillable || (!this.prompts.exhausted && transcript.hasMore);
|
|
752
|
+
}
|
|
753
|
+
/** Recover older prompts from the loaded window before spending a page request.
|
|
754
|
+
*
|
|
755
|
+
* The transcript window can still hold prompts the index's budgets evicted, so a backward step
|
|
756
|
+
* refills from memory first and only then asks the caller to page. That is what keeps recall
|
|
757
|
+
* complete across submissions, where the previous buffer lost them at the eviction boundary.
|
|
758
|
+
* @returns Whether any older prompt was recovered.
|
|
759
|
+
*/
|
|
760
|
+
refillRecall() {
|
|
761
|
+
const oldest = this.prompts.oldest;
|
|
762
|
+
if (oldest === undefined)
|
|
763
|
+
return false;
|
|
764
|
+
return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
|
|
765
|
+
}
|
|
766
|
+
/** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
|
|
767
|
+
* @param sessionId - Session being opened.
|
|
768
|
+
* @returns Whether the cache covered this session.
|
|
769
|
+
*/
|
|
770
|
+
adoptCachedPrompts(sessionId) {
|
|
771
|
+
const cached = this.promptCache.get(sessionId);
|
|
772
|
+
if (!cached?.complete)
|
|
773
|
+
return false;
|
|
774
|
+
const oldest = this.prompts.oldest;
|
|
775
|
+
const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
|
|
776
|
+
this.prompts.prepend(older);
|
|
777
|
+
// Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
|
|
778
|
+
// then refuses, so the lazy backward step stays available for whatever was shed.
|
|
779
|
+
this.prompts.settle();
|
|
780
|
+
this.prompts.markComplete();
|
|
781
|
+
this.store.update({});
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
/** Fold one history page the cost scan already read into the prompt cache.
|
|
785
|
+
*
|
|
786
|
+
* The scan reads every session's whole history on connect, so this is where two readers stop
|
|
787
|
+
* paying twice: it hands over pages it already has, and an open that follows reads the cache.
|
|
788
|
+
* @param sessionId - Session the page belongs to.
|
|
789
|
+
* @param records - Raw records of one scanned page.
|
|
790
|
+
*/
|
|
791
|
+
rememberScanPage(sessionId, records) {
|
|
792
|
+
this.promptCache.observe(sessionId, recordPrompts(records), false);
|
|
793
|
+
}
|
|
794
|
+
/** Report that the scanned session's history was read to its beginning.
|
|
795
|
+
* @param sessionId - Session the scan finished.
|
|
796
|
+
*/
|
|
797
|
+
rememberScanDone(sessionId) {
|
|
798
|
+
this.promptCache.observe(sessionId, [], true);
|
|
799
|
+
}
|
|
800
|
+
/** Fold every prompt the host still holds into the recall index, in the background.
|
|
801
|
+
*
|
|
802
|
+
* Session start delivers only the newest window, so without this the arrows could reach older
|
|
803
|
+
* prompts but not show them without paging first. Each page is parsed into a temporary transcript
|
|
804
|
+
* and only its prompts are kept, so the live record, its memory window and the row cache never
|
|
805
|
+
* grow. The walk is bounded and the next selection cancels it; anything past the bound is still
|
|
806
|
+
* reachable through the lazy backward step.
|
|
807
|
+
* @param sessionId - Session being opened.
|
|
808
|
+
* @param selection - Selector generation that must still be current.
|
|
809
|
+
*/
|
|
810
|
+
backfillPrompts(sessionId, selection) {
|
|
811
|
+
this.promptBackfill?.abort();
|
|
812
|
+
const abort = new AbortController();
|
|
813
|
+
this.promptBackfill = abort;
|
|
814
|
+
void (async () => {
|
|
815
|
+
try {
|
|
816
|
+
while (!this.info.record.ready) {
|
|
817
|
+
abort.signal.throwIfAborted();
|
|
818
|
+
await delay(20, undefined, { signal: abort.signal });
|
|
819
|
+
}
|
|
820
|
+
// A cost scan or an earlier open may already have this session's prompts cached.
|
|
821
|
+
if (this.adoptCachedPrompts(sessionId))
|
|
822
|
+
return;
|
|
823
|
+
const throughSeq = this.info.record.readThrough;
|
|
824
|
+
let beforeSeq = this.info.record.beforeSeq;
|
|
825
|
+
let hasMore = this.info.record.hasMore;
|
|
826
|
+
for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
|
|
827
|
+
abort.signal.throwIfAborted();
|
|
828
|
+
if (selection !== this.store.selection())
|
|
829
|
+
return;
|
|
830
|
+
const result = object(await this.host.require().call('session/page', { request: {
|
|
831
|
+
address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
|
|
832
|
+
} }, abort.signal));
|
|
833
|
+
abort.signal.throwIfAborted();
|
|
834
|
+
if (selection !== this.store.selection())
|
|
835
|
+
return;
|
|
836
|
+
const temporary = new Transcript();
|
|
837
|
+
try {
|
|
838
|
+
temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
|
|
839
|
+
const next = temporary.beforeSeq;
|
|
840
|
+
if (temporary.hasMore && (next === undefined || next >= beforeSeq))
|
|
841
|
+
throw new Error('Host history page did not advance');
|
|
842
|
+
this.prompts.prepend(temporary.promptsSince(-1).prompts);
|
|
843
|
+
beforeSeq = next;
|
|
844
|
+
hasMore = temporary.hasMore;
|
|
845
|
+
}
|
|
846
|
+
finally {
|
|
847
|
+
temporary.dispose();
|
|
848
|
+
}
|
|
849
|
+
// A scan that finished while this walk ran already established the same list.
|
|
850
|
+
if (this.adoptCachedPrompts(sessionId))
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
this.prompts.settle();
|
|
854
|
+
// Only a walk that ended because the host said "no more" makes the index exhaustive; one
|
|
855
|
+
// stopped by the page bound leaves the lazy backward step in charge of the rest.
|
|
856
|
+
if (!hasMore)
|
|
857
|
+
this.prompts.markComplete();
|
|
858
|
+
// Cache only what the walk established: advertising a capped or shed list would let a later
|
|
859
|
+
// open skip a fetch it still needs.
|
|
860
|
+
if (!hasMore)
|
|
861
|
+
this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
|
|
862
|
+
this.store.update({});
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
|
|
866
|
+
}
|
|
867
|
+
finally {
|
|
868
|
+
if (this.promptBackfill === abort)
|
|
869
|
+
this.promptBackfill = undefined;
|
|
870
|
+
}
|
|
871
|
+
})();
|
|
872
|
+
}
|
|
514
873
|
/** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
|
|
515
874
|
* @param query - Literal, case-insensitive text including folded reasoning.
|
|
516
875
|
* @param signal - Cancels HTTP and processing without cancelling the agent.
|
|
517
876
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
518
877
|
*/
|
|
519
878
|
async searchHistory(query, signal) {
|
|
520
|
-
const source = this.
|
|
879
|
+
const source = this.info.record;
|
|
521
880
|
if (!source.ready)
|
|
522
881
|
throw new Error('Wait for the session snapshot');
|
|
523
882
|
const sessionId = this.sessionId;
|
|
@@ -579,7 +938,7 @@ export class SessionController {
|
|
|
579
938
|
* @returns A caller-owned historical window that must be disposed when closed.
|
|
580
939
|
*/
|
|
581
940
|
async historyAt(target, signal) {
|
|
582
|
-
const source = this.
|
|
941
|
+
const source = this.info.record;
|
|
583
942
|
const selection = this.store.selection();
|
|
584
943
|
const page = object(await this.host.require().call('session/page', { request: {
|
|
585
944
|
address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
|
|
@@ -605,14 +964,14 @@ export class SessionController {
|
|
|
605
964
|
* @param signal - Cancels local paging without interrupting the remote agent.
|
|
606
965
|
*/
|
|
607
966
|
async historyThrough(target, signal) {
|
|
608
|
-
const transcript = this.
|
|
967
|
+
const transcript = this.info.record;
|
|
609
968
|
if (!transcript.ready)
|
|
610
969
|
throw new Error('Wait for the session snapshot');
|
|
611
970
|
while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
|
|
612
971
|
signal.throwIfAborted();
|
|
613
972
|
const before = transcript.beforeSeq;
|
|
614
973
|
await this.older(signal);
|
|
615
|
-
if (this.
|
|
974
|
+
if (this.info.record !== transcript)
|
|
616
975
|
throw new Error('Session changed while loading history');
|
|
617
976
|
if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
|
|
618
977
|
throw new Error('Host history page did not advance');
|
|
@@ -623,18 +982,18 @@ export class SessionController {
|
|
|
623
982
|
* @returns Number of removed records.
|
|
624
983
|
*/
|
|
625
984
|
reclaimHistory() {
|
|
626
|
-
if (this.
|
|
985
|
+
if (this.info.view.pinned || !this.store.state.online)
|
|
627
986
|
return 0;
|
|
628
|
-
const removed = this.
|
|
987
|
+
const removed = this.info.record.trimHistory(this.historyLimits);
|
|
629
988
|
if (removed)
|
|
630
|
-
releaseHistoryLayout(this.
|
|
989
|
+
releaseHistoryLayout(this.info.record);
|
|
631
990
|
return removed;
|
|
632
991
|
}
|
|
633
992
|
/** Release the selected transcript and its layout caches. */
|
|
634
993
|
releaseTranscript() {
|
|
635
|
-
|
|
636
|
-
this.
|
|
637
|
-
this.
|
|
994
|
+
this.promptBackfill?.abort();
|
|
995
|
+
this.promptBackfill = undefined;
|
|
996
|
+
this.info.reset();
|
|
638
997
|
}
|
|
639
998
|
/** @returns The selected session identity, or a `Select a session first` failure. */
|
|
640
999
|
get sessionId() {
|
|
@@ -3,7 +3,7 @@ import { type MarkdownSpan } from './markdown.ts';
|
|
|
3
3
|
/** Default fold mode; individual sequence overrides are view state, never stored content. */
|
|
4
4
|
export type Reasoning = 'row' | 'full';
|
|
5
5
|
/** Color semantics are independent of the selected terminal palette. */
|
|
6
|
-
export type RowKind = MessagePart['kind'] | 'user' | 'assistant' | 'context' | 'muted';
|
|
6
|
+
export type RowKind = MessagePart['kind'] | 'user' | 'assistant' | 'context' | 'muted' | 'shell';
|
|
7
7
|
/** One visible terminal row; no remote ANSI is allowed into its text. */
|
|
8
8
|
export interface HistoryRow {
|
|
9
9
|
text: string;
|
|
@@ -11,7 +11,27 @@ export interface HistoryRow {
|
|
|
11
11
|
bold?: boolean;
|
|
12
12
|
seq?: number;
|
|
13
13
|
spans?: MarkdownSpan[];
|
|
14
|
+
/** Draw the row on the theme's local-command bar, used by `!` commands. */
|
|
15
|
+
highlight?: boolean;
|
|
14
16
|
}
|
|
17
|
+
/** Wrap text into rows with the per-kind trimming rule.
|
|
18
|
+
* @param text - Text to wrap.
|
|
19
|
+
* @param width - Available terminal columns.
|
|
20
|
+
* @param kind - Part kind, which decides whether wrapped lines are trimmed.
|
|
21
|
+
* @param seq - Durable sequence, for committed parts.
|
|
22
|
+
* @returns The wrapped rows.
|
|
23
|
+
*/
|
|
24
|
+
/** Wrap plain local text into terminal rows, preserving its own whitespace.
|
|
25
|
+
*
|
|
26
|
+
* Command output is aligned by spaces and indented by stack traces, so this never collapses runs of
|
|
27
|
+
* whitespace the way tool summaries do; only the terminal width decides where a row breaks.
|
|
28
|
+
* @param text - Raw text, possibly containing newlines.
|
|
29
|
+
* @param width - Available terminal columns.
|
|
30
|
+
* @param kind - Row kind used for coloring.
|
|
31
|
+
* @param highlight - Whether the rows are a local command line drawn on the command bar.
|
|
32
|
+
* @returns One row per wrapped terminal line; empty text yields one empty row.
|
|
33
|
+
*/
|
|
34
|
+
export declare function plainRows(text: string, width: number, kind: RowKind, highlight?: boolean): HistoryRow[];
|
|
15
35
|
/** Drop all terminal rows and layout metadata for an evicted or inactive transcript.
|
|
16
36
|
* @param transcript - Transcript whose previously returned layout is no longer used.
|
|
17
37
|
*/
|
package/dist/session/history.js
CHANGED
|
@@ -140,6 +140,21 @@ const liveRows = new WeakMap();
|
|
|
140
140
|
* @param seq - Durable sequence, for committed parts.
|
|
141
141
|
* @returns The wrapped rows.
|
|
142
142
|
*/
|
|
143
|
+
/** Wrap plain local text into terminal rows, preserving its own whitespace.
|
|
144
|
+
*
|
|
145
|
+
* Command output is aligned by spaces and indented by stack traces, so this never collapses runs of
|
|
146
|
+
* whitespace the way tool summaries do; only the terminal width decides where a row breaks.
|
|
147
|
+
* @param text - Raw text, possibly containing newlines.
|
|
148
|
+
* @param width - Available terminal columns.
|
|
149
|
+
* @param kind - Row kind used for coloring.
|
|
150
|
+
* @param highlight - Whether the rows are a local command line drawn on the command bar.
|
|
151
|
+
* @returns One row per wrapped terminal line; empty text yields one empty row.
|
|
152
|
+
*/
|
|
153
|
+
export function plainRows(text, width, kind, highlight = false) {
|
|
154
|
+
const columns = Math.max(1, width);
|
|
155
|
+
const wrapped = wrapAnsi(text === '' ? ' ' : text, columns, { hard: true, trim: false });
|
|
156
|
+
return wrapped.split('\n').map(line => ({ text: line, kind, ...(highlight ? { highlight: true } : {}) }));
|
|
157
|
+
}
|
|
143
158
|
function wrapRows(text, width, kind, seq) {
|
|
144
159
|
return wrapAnsi(text, width, { hard: true, trim: !['tool', 'success', 'error'].includes(kind) })
|
|
145
160
|
.split('\n').map(text => ({ text, kind, seq }));
|
package/dist/session/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { Telemetry } from './telemetry.ts';
|
|
|
9
9
|
export type { QueuedInput } from './telemetry.ts';
|
|
10
10
|
export { DEFAULT_HISTORY_LIMITS, historyLimits } from './memory.ts';
|
|
11
11
|
export type { HistoryLimits } from './memory.ts';
|
|
12
|
+
export { DEFAULT_PROMPT_LIMITS, promptText, PromptIndex, SessionInfo } from './info.ts';
|
|
13
|
+
export type { ComposerState, InteractionState, ModelState, OptionState, PanelState, PromptEntry, PromptLimits, PromptRecord, ReferenceState, ViewState } from './info.ts';
|
|
12
14
|
export { navigationCommand, resolveTarget, sessionLabel } from './navigation.ts';
|
|
13
15
|
export { activeReference, fileMention, fileReferences } from './references.ts';
|
|
14
16
|
export type { FileReference } from './references.ts';
|
package/dist/session/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { historyLayout, layoutStats, releaseHistoryLayout } from "./history.js";
|
|
|
5
5
|
export { markdownCacheStats } from "./markdown.js";
|
|
6
6
|
export { Telemetry } from "./telemetry.js";
|
|
7
7
|
export { DEFAULT_HISTORY_LIMITS, historyLimits } from "./memory.js";
|
|
8
|
+
export { DEFAULT_PROMPT_LIMITS, promptText, PromptIndex, SessionInfo } from "./info.js";
|
|
8
9
|
export { navigationCommand, resolveTarget, sessionLabel } from "./navigation.js";
|
|
9
10
|
export { activeReference, fileMention, fileReferences } from "./references.js";
|
|
10
11
|
export { saveSessionLog } from "./export.js";
|