@itookit/dsht 0.3.3 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +10 -10
  3. package/README.zh.md +7 -7
  4. package/dist/cli/dsht.d.ts +2 -0
  5. package/dist/cli/dsht.js +125 -0
  6. package/dist/cli/index.js +16 -123
  7. package/dist/controller/controller.d.ts +115 -0
  8. package/dist/controller/controller.js +126 -1
  9. package/dist/controller/perf-measures.d.ts +34 -0
  10. package/dist/controller/perf-measures.js +78 -0
  11. package/dist/cost/controller.d.ts +5 -0
  12. package/dist/cost/controller.js +2 -1
  13. package/dist/cost/scanner.d.ts +4 -2
  14. package/dist/cost/scanner.js +6 -3
  15. package/dist/session/controller.d.ts +157 -1
  16. package/dist/session/controller.js +395 -29
  17. package/dist/session/index.d.ts +2 -0
  18. package/dist/session/index.js +1 -0
  19. package/dist/session/info.d.ts +262 -0
  20. package/dist/session/info.js +326 -0
  21. package/dist/session/navigation.d.ts +62 -8
  22. package/dist/session/navigation.js +72 -13
  23. package/dist/session/transcript.d.ts +37 -1
  24. package/dist/session/transcript.js +73 -0
  25. package/dist/state.d.ts +3 -2
  26. package/dist/state.js +2 -2
  27. package/dist/ui/app.js +230 -178
  28. package/dist/ui/chat/status.js +15 -10
  29. package/dist/ui/dialogs/index.d.ts +9 -8
  30. package/dist/ui/dialogs/index.js +3 -3
  31. package/dist/ui/dialogs/picker.d.ts +21 -3
  32. package/dist/ui/dialogs/picker.js +37 -5
  33. package/dist/ui/input/input.d.ts +18 -3
  34. package/dist/ui/input/input.js +61 -22
  35. package/dist/ui/input/viewport.d.ts +96 -0
  36. package/dist/ui/input/viewport.js +173 -0
  37. package/dsht-m.png +0 -0
  38. package/package.json +3 -3
  39. package/dist/ui/input/history.d.ts +0 -19
  40. package/dist/ui/input/history.js +0 -43
@@ -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 { Transcript, toolLine } from "./transcript.js";
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
- historyPinned = false;
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.store.state.transcript.activeTurnStartedAt ?? this.connection.observedAt(this.store.state.sessionId);
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.historyPinned; }
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.store.state.transcript.ready = false;
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. */
@@ -95,6 +104,23 @@ export class SessionController {
95
104
  pendingFor(state) {
96
105
  return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
97
106
  }
107
+ /** Unanswered interactions by session, so a list can show who is waiting without opening them.
108
+ *
109
+ * The host delivers approval and question waterfalls for every session on one stream, and this
110
+ * client already retains them to answer later, so the counts are a fact of this generation rather
111
+ * than a new subscription. They live only as long as the connection: a reconnect clears the map
112
+ * until the host replays the pending waterfalls.
113
+ * @returns One count per session holding at least one unanswered interaction.
114
+ */
115
+ pendingCounts() {
116
+ const counts = new Map();
117
+ for (const frame of this.interactions.values()) {
118
+ if (typeof frame.agentId !== 'string')
119
+ continue;
120
+ counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
121
+ }
122
+ return counts;
123
+ }
98
124
  /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
99
125
  * @param frame - One decoded waterfall frame.
100
126
  * @returns Whether this domain retained the frame for an answer.
@@ -164,7 +190,7 @@ export class SessionController {
164
190
  * @param pinned - Whether the main transcript is actively being read away from its tail.
165
191
  */
166
192
  pinHistory(pinned) {
167
- this.historyPinned = pinned;
193
+ this.info.view.pinned = pinned;
168
194
  if (!pinned && this.reclaimHistory())
169
195
  this.store.update({});
170
196
  }
@@ -237,7 +263,7 @@ export class SessionController {
237
263
  this.follow?.cancel();
238
264
  this.follow = undefined;
239
265
  this.releaseTranscript();
240
- this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, transcript: new Transcript(), screen: 'sessions' });
266
+ this.store.update({ workspaceId, sessionId: undefined, showAllSessions: false, screen: 'sessions' });
241
267
  }
242
268
  /** Open a workspace picker, or resolve a workspace by ID, exact title/path, or unique ID prefix.
243
269
  * @param query - Workspace target, if any.
@@ -296,13 +322,13 @@ export class SessionController {
296
322
  const selection = this.store.selection();
297
323
  this.follow?.cancel();
298
324
  this.follow = undefined;
299
- this.releaseTranscript();
300
- const transcript = new Transcript();
325
+ this.info.reset(sessionId);
326
+ const transcript = this.info.record;
301
327
  const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
302
328
  const workspaceId = workspace ? string(workspace.workspaceId)
303
329
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
304
330
  this.connection.observe(sessionId);
305
- this.store.update({ sessionId, workspaceId, showAllSessions: false, transcript, screen: 'chat', status: 'Loading session…' });
331
+ this.store.update({ sessionId, workspaceId, showAllSessions: false, screen: 'chat', status: 'Loading session…' });
306
332
  this.follow = this.host.require().subscribe('session/follow', {
307
333
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
308
334
  }, {
@@ -311,11 +337,12 @@ export class SessionController {
311
337
  return;
312
338
  try {
313
339
  transcript.accept(value);
340
+ this.prompts.fold(transcript.promptsSince(this.prompts.through));
314
341
  this.reclaimHistory();
315
342
  const frame = object(value);
316
343
  if (frame.type === 'snapshot')
317
344
  this.connection.telemetryView().snapshot(sessionId, frame.projections);
318
- this.store.update({ transcript, status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
345
+ this.store.update({ status: this.stoppingSession === sessionId ? this.store.state.status : transcript.hasLiveContent ? 'Responding…' : 'Connected' });
319
346
  }
320
347
  catch (error) {
321
348
  this.connection.fail(new Error(errorText(error)));
@@ -328,16 +355,17 @@ export class SessionController {
328
355
  this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
329
356
  },
330
357
  });
358
+ this.backfillPrompts(sessionId, selection);
331
359
  }
332
360
  /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
333
361
  * @param signal - Cancels waiting without closing the session.
334
362
  */
335
363
  async waitForHistory(signal) {
336
- const transcript = this.store.state.transcript;
364
+ const transcript = this.info.record;
337
365
  const deadline = Date.now() + this.host.require().timeoutMs;
338
366
  while (!transcript.ready) {
339
367
  signal.throwIfAborted();
340
- if (!this.store.state.online || this.store.state.transcript !== transcript)
368
+ if (!this.store.state.online || this.info.record !== transcript)
341
369
  throw new Error('Session changed while loading history');
342
370
  if (Date.now() >= deadline)
343
371
  throw new Error('Session snapshot timed out');
@@ -380,7 +408,7 @@ export class SessionController {
380
408
  * @returns The host's successful command result text.
381
409
  */
382
410
  async command(line, signal) {
383
- if (!this.store.state.transcript.ready)
411
+ if (!this.info.record.ready)
384
412
  throw new Error('Wait for the session snapshot before running commands');
385
413
  const execution = await this.host.require().call('commands/execute', {
386
414
  agentId: this.sessionId, line, submittedAttachments: [],
@@ -393,6 +421,11 @@ export class SessionController {
393
421
  }
394
422
  if (result.kind === 'error')
395
423
  throw new Error(string(result.text));
424
+ // Compaction rewrites the host log, so a cached prompt list for this session may describe
425
+ // records that no longer exist. The live index keeps what the reader already sees; the cache is
426
+ // dropped so the next open reads the rewritten history.
427
+ if (line.trim().split(/\s/, 1)[0] === '/compact')
428
+ this.promptCache.drop(this.sessionId);
396
429
  return result.text === undefined ? 'Command completed.' : string(result.text);
397
430
  }
398
431
  /** Remove one host-owned pending input; an already claimed item reports a host error.
@@ -415,7 +448,7 @@ export class SessionController {
415
448
  * @returns Absolute saved filename.
416
449
  */
417
450
  async exportHtml(path, signal) {
418
- return saveTranscriptHtml(this.store.state.transcript, this.sessionId, path, signal);
451
+ return saveTranscriptHtml(this.info.record, this.sessionId, path, signal);
419
452
  }
420
453
  /** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain.
421
454
  * @param text - Composed prompt text.
@@ -424,7 +457,7 @@ export class SessionController {
424
457
  if (this.store.state.pending.length)
425
458
  throw new Error('Answer the pending question or approval first');
426
459
  this.stoppingSession = undefined;
427
- if (!this.store.state.transcript.ready)
460
+ if (!this.info.record.ready)
428
461
  throw new Error('Wait for the session snapshot before sending');
429
462
  const admission = this.host.require().call('session/prompt', { request: {
430
463
  sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
@@ -459,14 +492,34 @@ export class SessionController {
459
492
  throw new Error('No pending approval');
460
493
  await this.answer(allowed ? 'allowed-once' : 'rejected');
461
494
  }
495
+ /** Dismiss the whole selected-session question set without answering it.
496
+ *
497
+ * The Web client's close button settles the same waterfall the same way — reject with
498
+ * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
499
+ * batch is answered as one request, so dismissals also discard partial local answers.
500
+ */
501
+ async dismissQuestion() {
502
+ const pending = this.store.state.pending[0];
503
+ if (pending?.event !== 'user-questions/request')
504
+ throw new Error('No pending question');
505
+ await this.reply(pending, { kind: 'rejected', error: {
506
+ name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
507
+ } });
508
+ this.interactions.delete(string(pending.eventId));
509
+ this.store.update({});
510
+ }
462
511
  /** Add a page before the retained window using its fixed opening cut.
512
+ *
513
+ * Reclamation is deliberately not pinned here. Every caller that needs the fetched page to
514
+ * survive is already covered by the view: scrolling back sets a scroll position, `/think` opens a
515
+ * panel, and a recall page folds its prompts into the index before this resolves. A pin set here
516
+ * had no clearing edge, so after one recall page the history budget silently stopped applying for
517
+ * the rest of the session while every following page kept adding records.
463
518
  * @param signal - Cancels local paging without interrupting the remote agent.
464
519
  * @param transcript - Transcript to extend; defaults to the live one.
465
520
  */
466
- async older(signal, transcript = this.store.state.transcript) {
521
+ async older(signal, transcript = this.info.record) {
467
522
  const selection = this.store.selection();
468
- if (transcript === this.store.state.transcript)
469
- this.historyPinned = true;
470
523
  if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
471
524
  return;
472
525
  const result = await this.host.require().call('session/page', { request: {
@@ -478,13 +531,326 @@ export class SessionController {
478
531
  transcript.addPage(result);
479
532
  this.store.update({});
480
533
  }
534
+ /** Recall one step through the session's prompt index; navigation never touches the network.
535
+ * @param direction - Negative for older input, positive for newer input.
536
+ * @param current - Composer content before recall began, restored at the newest position.
537
+ * @returns The recalled prompt, or the unsent draft.
538
+ */
539
+ recall(direction, current) { return this.prompts.move(direction, current); }
540
+ /** Composer draft, caret and parked draft, as the selected session holds them. */
541
+ get composer() { return this.info.composer; }
542
+ /** Replace the composer text and caret, publishing only when either actually changed. */
543
+ setComposer(draft, cursor = draft.length) {
544
+ const composer = this.info.composer;
545
+ if (composer.draft === draft && composer.cursor === cursor)
546
+ return;
547
+ composer.draft = draft;
548
+ composer.cursor = cursor;
549
+ this.store.update({});
550
+ }
551
+ /** Move the caret without changing the text. */
552
+ setComposerCursor(cursor) {
553
+ const composer = this.info.composer;
554
+ if (composer.cursor === cursor)
555
+ return;
556
+ composer.cursor = cursor;
557
+ this.store.update({});
558
+ }
559
+ /** Move a non-empty draft aside while a dialog owns the keyboard. */
560
+ parkComposer() {
561
+ const composer = this.info.composer;
562
+ if (composer.draft === '')
563
+ return;
564
+ composer.parked = composer.draft;
565
+ composer.draft = '';
566
+ composer.cursor = 0;
567
+ this.store.update({});
568
+ }
569
+ /** Give a parked draft back once no dialog needs the keyboard. */
570
+ restoreComposer() {
571
+ const composer = this.info.composer;
572
+ if (composer.parked === '')
573
+ return;
574
+ composer.draft = composer.parked;
575
+ composer.cursor = composer.parked.length;
576
+ composer.parked = '';
577
+ this.store.update({});
578
+ }
579
+ /** How the selected session's record is being read right now. */
580
+ get view() { return this.info.view; }
581
+ /** Show a detached history window, releasing the one it replaces.
582
+ * @param window - Record to display, or undefined to return to the live transcript.
583
+ */
584
+ setViewWindow(window) {
585
+ if (this.info.view.window === window)
586
+ return;
587
+ this.info.closeWindow();
588
+ this.info.view.window = window;
589
+ this.store.update({});
590
+ }
591
+ /** Move the reader's position inside the displayed record.
592
+ * @param scroll - Rows scrolled back from the live end.
593
+ */
594
+ setScroll(scroll) {
595
+ if (this.info.view.scroll === scroll)
596
+ return;
597
+ this.info.view.scroll = scroll;
598
+ this.store.update({});
599
+ }
600
+ /** Replace the set of expanded reasoning blocks, keyed by message sequence.
601
+ * @param folds - Sequences to expand beyond the default fold.
602
+ */
603
+ setFolds(folds) {
604
+ if (this.info.view.folds === folds)
605
+ return;
606
+ this.info.view.folds = folds;
607
+ this.store.update({});
608
+ }
609
+ /** Set the fold mode of the live attempt's completed reasoning.
610
+ * @param reasoning - `row` to fold, `full` to keep the streamed text.
611
+ */
612
+ setLiveReasoning(reasoning) {
613
+ if (this.info.view.liveReasoning === reasoning)
614
+ return;
615
+ this.info.view.liveReasoning = reasoning;
616
+ this.store.update({});
617
+ }
618
+ /** Local answer state for the selected session's pending waterfalls. */
619
+ get interaction() { return this.info.interaction; }
620
+ /** Replace the partly collected answers, keyed by the waterfall event id.
621
+ * @param answers - Answers collected so far, by event id.
622
+ */
623
+ setAnswers(answers) {
624
+ this.info.interaction.answers = answers;
625
+ this.store.update({});
626
+ }
627
+ /** Replace the pending question's option keyboard state.
628
+ * @param option - Highlighted option, toggled labels and free-text mode; undefined clears it.
629
+ */
630
+ setOption(option) {
631
+ if (this.info.interaction.option === option)
632
+ return;
633
+ this.info.interaction.option = option;
634
+ this.store.update({});
635
+ }
636
+ /** Replace the pending approval's selected row.
637
+ * @param approval - Selected approval row; undefined clears the highlight.
638
+ */
639
+ setApproval(approval) {
640
+ if (this.info.interaction.approval === approval)
641
+ return;
642
+ this.info.interaction.approval = approval;
643
+ this.store.update({});
644
+ }
645
+ /** Composer-adjacent `@` reference menu state. */
646
+ get reference() { return this.info.reference; }
647
+ /** Highlight one row of the open reference menu.
648
+ * @param index - Row index into the current matches.
649
+ */
650
+ setReferenceIndex(index) {
651
+ if (this.info.reference.index === index)
652
+ return;
653
+ this.info.reference.index = index;
654
+ this.store.update({});
655
+ }
656
+ /** Remember the draft that dismissed the reference menu, so it does not reopen while it stands.
657
+ * @param draft - Composer text at dismissal, or undefined to allow the menu again.
658
+ */
659
+ setReferenceDismissed(draft) {
660
+ if (this.info.reference.dismissed === draft)
661
+ return;
662
+ this.info.reference.dismissed = draft;
663
+ this.store.update({});
664
+ }
665
+ /** Panels the selected session has open. */
666
+ get panels() { return this.info.panels; }
667
+ /** Show or hide the reasoning panel.
668
+ * @param open - Whether `/think` is open.
669
+ */
670
+ openThoughts(open) {
671
+ if (this.info.panels.thoughts === open)
672
+ return;
673
+ this.info.panels.thoughts = open;
674
+ this.store.update({});
675
+ }
676
+ /** Show or hide the pending-input panel.
677
+ * @param open - Whether `/queue` is open.
678
+ */
679
+ openQueue(open) {
680
+ if (this.info.panels.queue === open)
681
+ return;
682
+ this.info.panels.queue = open;
683
+ this.store.update({});
684
+ }
685
+ /** Show the model dialog at one step, or close it.
686
+ * @param model - Catalog plus the provider or model being inspected; undefined closes the dialog.
687
+ */
688
+ setModelPanel(model) {
689
+ if (this.info.panels.model === model)
690
+ return;
691
+ this.info.panels.model = model;
692
+ this.store.update({});
693
+ }
694
+ /** Show the history or content-search dialog, or close it.
695
+ * @param history - Query, content-search mode and matches; undefined closes the dialog.
696
+ */
697
+ setHistoryPanel(history) {
698
+ if (this.info.panels.history === history)
699
+ return;
700
+ this.info.panels.history = history;
701
+ this.store.update({});
702
+ }
703
+ /** Show the host session-search results, or close them.
704
+ * @param search - Query, results and truncation flag; undefined closes the dialog.
705
+ */
706
+ setSearchPanel(search) {
707
+ if (this.info.panels.search === search)
708
+ return;
709
+ this.info.panels.search = search;
710
+ this.store.update({});
711
+ }
712
+ /** Remember a locally submitted command, which never becomes a durable session record. */
713
+ recordRecall(value) { this.prompts.record(value); }
714
+ /** Leave recall navigation because the composer was edited or replaced. */
715
+ resetRecall() { this.prompts.resetCursor(); }
716
+ /** Whether recall is parked on the oldest prompt it retains in memory. */
717
+ get recallAtOldest() { return this.prompts.atOldest; }
718
+ /** How many prompts the session retains for recall. */
719
+ get recallLength() { return this.prompts.length; }
720
+ /** Whether an older prompt is reachable at all: shed from the window, or still on the host. */
721
+ get recallHasOlder() {
722
+ const transcript = this.info.record;
723
+ const oldest = this.prompts.oldest;
724
+ const refillable = oldest !== undefined && transcript.beforeSeq !== undefined && oldest > transcript.beforeSeq;
725
+ return refillable || (!this.prompts.exhausted && transcript.hasMore);
726
+ }
727
+ /** Recover older prompts from the loaded window before spending a page request.
728
+ *
729
+ * The transcript window can still hold prompts the index's budgets evicted, so a backward step
730
+ * refills from memory first and only then asks the caller to page. That is what keeps recall
731
+ * complete across submissions, where the previous buffer lost them at the eviction boundary.
732
+ * @returns Whether any older prompt was recovered.
733
+ */
734
+ refillRecall() {
735
+ const oldest = this.prompts.oldest;
736
+ if (oldest === undefined)
737
+ return false;
738
+ return this.prompts.prepend(this.info.record.promptsBefore(oldest)) > 0;
739
+ }
740
+ /** Seed recall from a complete cached entry, so an open that follows a scan costs no request.
741
+ * @param sessionId - Session being opened.
742
+ * @returns Whether the cache covered this session.
743
+ */
744
+ adoptCachedPrompts(sessionId) {
745
+ const cached = this.promptCache.get(sessionId);
746
+ if (!cached?.complete)
747
+ return false;
748
+ const oldest = this.prompts.oldest;
749
+ const older = oldest === undefined ? cached.prompts : cached.prompts.filter(prompt => prompt.seq < oldest);
750
+ this.prompts.prepend(older);
751
+ // Same budget as the walk it replaces: `settle` may shed the oldest prefix, and `markComplete`
752
+ // then refuses, so the lazy backward step stays available for whatever was shed.
753
+ this.prompts.settle();
754
+ this.prompts.markComplete();
755
+ this.store.update({});
756
+ return true;
757
+ }
758
+ /** Fold one history page the cost scan already read into the prompt cache.
759
+ *
760
+ * The scan reads every session's whole history on connect, so this is where two readers stop
761
+ * paying twice: it hands over pages it already has, and an open that follows reads the cache.
762
+ * @param sessionId - Session the page belongs to.
763
+ * @param records - Raw records of one scanned page.
764
+ */
765
+ rememberScanPage(sessionId, records) {
766
+ this.promptCache.observe(sessionId, recordPrompts(records), false);
767
+ }
768
+ /** Report that the scanned session's history was read to its beginning.
769
+ * @param sessionId - Session the scan finished.
770
+ */
771
+ rememberScanDone(sessionId) {
772
+ this.promptCache.observe(sessionId, [], true);
773
+ }
774
+ /** Fold every prompt the host still holds into the recall index, in the background.
775
+ *
776
+ * Session start delivers only the newest window, so without this the arrows could reach older
777
+ * prompts but not show them without paging first. Each page is parsed into a temporary transcript
778
+ * and only its prompts are kept, so the live record, its memory window and the row cache never
779
+ * grow. The walk is bounded and the next selection cancels it; anything past the bound is still
780
+ * reachable through the lazy backward step.
781
+ * @param sessionId - Session being opened.
782
+ * @param selection - Selector generation that must still be current.
783
+ */
784
+ backfillPrompts(sessionId, selection) {
785
+ this.promptBackfill?.abort();
786
+ const abort = new AbortController();
787
+ this.promptBackfill = abort;
788
+ void (async () => {
789
+ try {
790
+ while (!this.info.record.ready) {
791
+ abort.signal.throwIfAborted();
792
+ await delay(20, undefined, { signal: abort.signal });
793
+ }
794
+ // A cost scan or an earlier open may already have this session's prompts cached.
795
+ if (this.adoptCachedPrompts(sessionId))
796
+ return;
797
+ const throughSeq = this.info.record.readThrough;
798
+ let beforeSeq = this.info.record.beforeSeq;
799
+ let hasMore = this.info.record.hasMore;
800
+ for (let page = 0; hasMore && beforeSeq !== undefined && page < PROMPT_BACKFILL_PAGES; page++) {
801
+ abort.signal.throwIfAborted();
802
+ if (selection !== this.store.selection())
803
+ return;
804
+ const result = object(await this.host.require().call('session/page', { request: {
805
+ address: { kind: 'session', sessionId }, throughSeq, beforeSeq, maxMessages: 80,
806
+ } }, abort.signal));
807
+ abort.signal.throwIfAborted();
808
+ if (selection !== this.store.selection())
809
+ return;
810
+ const temporary = new Transcript();
811
+ try {
812
+ temporary.accept({ type: 'snapshot', cursor: throughSeq, assistantStream: { revision: 0 }, records: result.records, hasMore: result.hasMore });
813
+ const next = temporary.beforeSeq;
814
+ if (temporary.hasMore && (next === undefined || next >= beforeSeq))
815
+ throw new Error('Host history page did not advance');
816
+ this.prompts.prepend(temporary.promptsSince(-1).prompts);
817
+ beforeSeq = next;
818
+ hasMore = temporary.hasMore;
819
+ }
820
+ finally {
821
+ temporary.dispose();
822
+ }
823
+ // A scan that finished while this walk ran already established the same list.
824
+ if (this.adoptCachedPrompts(sessionId))
825
+ return;
826
+ }
827
+ this.prompts.settle();
828
+ // Only a walk that ended because the host said "no more" makes the index exhaustive; one
829
+ // stopped by the page bound leaves the lazy backward step in charge of the rest.
830
+ if (!hasMore)
831
+ this.prompts.markComplete();
832
+ // Cache only what the walk established: advertising a capped or shed list would let a later
833
+ // open skip a fetch it still needs.
834
+ if (!hasMore)
835
+ this.promptCache.put(sessionId, { prompts: this.prompts.durableItems, complete: this.prompts.exhausted });
836
+ this.store.update({});
837
+ }
838
+ catch {
839
+ // A cancelled, disconnected or unavailable history leaves the lazy backward step in charge.
840
+ }
841
+ finally {
842
+ if (this.promptBackfill === abort)
843
+ this.promptBackfill = undefined;
844
+ }
845
+ })();
846
+ }
481
847
  /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
482
848
  * @param query - Literal, case-insensitive text including folded reasoning.
483
849
  * @param signal - Cancels HTTP and processing without cancelling the agent.
484
850
  * @returns Newest-first bounded summaries and an explicit truncation flag.
485
851
  */
486
852
  async searchHistory(query, signal) {
487
- const source = this.store.state.transcript;
853
+ const source = this.info.record;
488
854
  if (!source.ready)
489
855
  throw new Error('Wait for the session snapshot');
490
856
  const sessionId = this.sessionId;
@@ -546,7 +912,7 @@ export class SessionController {
546
912
  * @returns A caller-owned historical window that must be disposed when closed.
547
913
  */
548
914
  async historyAt(target, signal) {
549
- const source = this.store.state.transcript;
915
+ const source = this.info.record;
550
916
  const selection = this.store.selection();
551
917
  const page = object(await this.host.require().call('session/page', { request: {
552
918
  address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
@@ -572,14 +938,14 @@ export class SessionController {
572
938
  * @param signal - Cancels local paging without interrupting the remote agent.
573
939
  */
574
940
  async historyThrough(target, signal) {
575
- const transcript = this.store.state.transcript;
941
+ const transcript = this.info.record;
576
942
  if (!transcript.ready)
577
943
  throw new Error('Wait for the session snapshot');
578
944
  while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
579
945
  signal.throwIfAborted();
580
946
  const before = transcript.beforeSeq;
581
947
  await this.older(signal);
582
- if (this.store.state.transcript !== transcript)
948
+ if (this.info.record !== transcript)
583
949
  throw new Error('Session changed while loading history');
584
950
  if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
585
951
  throw new Error('Host history page did not advance');
@@ -590,18 +956,18 @@ export class SessionController {
590
956
  * @returns Number of removed records.
591
957
  */
592
958
  reclaimHistory() {
593
- if (this.historyPinned || !this.store.state.online)
959
+ if (this.info.view.pinned || !this.store.state.online)
594
960
  return 0;
595
- const removed = this.store.state.transcript.trimHistory(this.historyLimits);
961
+ const removed = this.info.record.trimHistory(this.historyLimits);
596
962
  if (removed)
597
- releaseHistoryLayout(this.store.state.transcript);
963
+ releaseHistoryLayout(this.info.record);
598
964
  return removed;
599
965
  }
600
966
  /** Release the selected transcript and its layout caches. */
601
967
  releaseTranscript() {
602
- releaseHistoryLayout(this.store.state.transcript);
603
- this.store.state.transcript.dispose();
604
- this.historyPinned = false;
968
+ this.promptBackfill?.abort();
969
+ this.promptBackfill = undefined;
970
+ this.info.reset();
605
971
  }
606
972
  /** @returns The selected session identity, or a `Select a session first` failure. */
607
973
  get sessionId() {
@@ -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';
@@ -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";