@itookit/dsht 0.3.4 → 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.
@@ -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. */
@@ -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.historyPinned = pinned;
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, transcript: new Transcript(), screen: 'sessions' });
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.
@@ -313,13 +322,13 @@ export class SessionController {
313
322
  const selection = this.store.selection();
314
323
  this.follow?.cancel();
315
324
  this.follow = undefined;
316
- this.releaseTranscript();
317
- const transcript = new Transcript();
325
+ this.info.reset(sessionId);
326
+ const transcript = this.info.record;
318
327
  const workspace = this.store.state.workspaces.find(item => array(item.sessionIds).includes(sessionId));
319
328
  const workspaceId = workspace ? string(workspace.workspaceId)
320
329
  : this.store.state.sessions.some(item => item.sessionId === sessionId) ? undefined : this.store.state.workspaceId;
321
330
  this.connection.observe(sessionId);
322
- 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…' });
323
332
  this.follow = this.host.require().subscribe('session/follow', {
324
333
  request: { address: { kind: 'session', sessionId }, maxMessages: 80, assistantStream: true },
325
334
  }, {
@@ -328,11 +337,12 @@ export class SessionController {
328
337
  return;
329
338
  try {
330
339
  transcript.accept(value);
340
+ this.prompts.fold(transcript.promptsSince(this.prompts.through));
331
341
  this.reclaimHistory();
332
342
  const frame = object(value);
333
343
  if (frame.type === 'snapshot')
334
344
  this.connection.telemetryView().snapshot(sessionId, frame.projections);
335
- 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' });
336
346
  }
337
347
  catch (error) {
338
348
  this.connection.fail(new Error(errorText(error)));
@@ -345,16 +355,17 @@ export class SessionController {
345
355
  this.store.update({ status: 'Session disconnected', error: errorText(error ?? 'Session stream ended') });
346
356
  },
347
357
  });
358
+ this.backfillPrompts(sessionId, selection);
348
359
  }
349
360
  /** Wait for the selected follow snapshot, failing on disconnect or cancellation.
350
361
  * @param signal - Cancels waiting without closing the session.
351
362
  */
352
363
  async waitForHistory(signal) {
353
- const transcript = this.store.state.transcript;
364
+ const transcript = this.info.record;
354
365
  const deadline = Date.now() + this.host.require().timeoutMs;
355
366
  while (!transcript.ready) {
356
367
  signal.throwIfAborted();
357
- if (!this.store.state.online || this.store.state.transcript !== transcript)
368
+ if (!this.store.state.online || this.info.record !== transcript)
358
369
  throw new Error('Session changed while loading history');
359
370
  if (Date.now() >= deadline)
360
371
  throw new Error('Session snapshot timed out');
@@ -397,7 +408,7 @@ export class SessionController {
397
408
  * @returns The host's successful command result text.
398
409
  */
399
410
  async command(line, signal) {
400
- if (!this.store.state.transcript.ready)
411
+ if (!this.info.record.ready)
401
412
  throw new Error('Wait for the session snapshot before running commands');
402
413
  const execution = await this.host.require().call('commands/execute', {
403
414
  agentId: this.sessionId, line, submittedAttachments: [],
@@ -410,6 +421,11 @@ export class SessionController {
410
421
  }
411
422
  if (result.kind === 'error')
412
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);
413
429
  return result.text === undefined ? 'Command completed.' : string(result.text);
414
430
  }
415
431
  /** Remove one host-owned pending input; an already claimed item reports a host error.
@@ -432,7 +448,7 @@ export class SessionController {
432
448
  * @returns Absolute saved filename.
433
449
  */
434
450
  async exportHtml(path, signal) {
435
- return saveTranscriptHtml(this.store.state.transcript, this.sessionId, path, signal);
451
+ return saveTranscriptHtml(this.info.record, this.sessionId, path, signal);
436
452
  }
437
453
  /** Admit text once as steering while running, or a new turn while idle; a lost response can leave delivery uncertain.
438
454
  * @param text - Composed prompt text.
@@ -441,7 +457,7 @@ export class SessionController {
441
457
  if (this.store.state.pending.length)
442
458
  throw new Error('Answer the pending question or approval first');
443
459
  this.stoppingSession = undefined;
444
- if (!this.store.state.transcript.ready)
460
+ if (!this.info.record.ready)
445
461
  throw new Error('Wait for the session snapshot before sending');
446
462
  const admission = this.host.require().call('session/prompt', { request: {
447
463
  sessionId: this.sessionId, requestId: randomUUID(), mode: this.running ? 'steer' : 'queue',
@@ -493,13 +509,17 @@ export class SessionController {
493
509
  this.store.update({});
494
510
  }
495
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.
496
518
  * @param signal - Cancels local paging without interrupting the remote agent.
497
519
  * @param transcript - Transcript to extend; defaults to the live one.
498
520
  */
499
- async older(signal, transcript = this.store.state.transcript) {
521
+ async older(signal, transcript = this.info.record) {
500
522
  const selection = this.store.selection();
501
- if (transcript === this.store.state.transcript)
502
- this.historyPinned = true;
503
523
  if (!transcript.ready || !transcript.hasMore || transcript.beforeSeq === undefined)
504
524
  return;
505
525
  const result = await this.host.require().call('session/page', { request: {
@@ -511,13 +531,326 @@ export class SessionController {
511
531
  transcript.addPage(result);
512
532
  this.store.update({});
513
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
+ }
514
847
  /** Search one page at a time, preserving only the first 200 matches and releasing temporary content.
515
848
  * @param query - Literal, case-insensitive text including folded reasoning.
516
849
  * @param signal - Cancels HTTP and processing without cancelling the agent.
517
850
  * @returns Newest-first bounded summaries and an explicit truncation flag.
518
851
  */
519
852
  async searchHistory(query, signal) {
520
- const source = this.store.state.transcript;
853
+ const source = this.info.record;
521
854
  if (!source.ready)
522
855
  throw new Error('Wait for the session snapshot');
523
856
  const sessionId = this.sessionId;
@@ -579,7 +912,7 @@ export class SessionController {
579
912
  * @returns A caller-owned historical window that must be disposed when closed.
580
913
  */
581
914
  async historyAt(target, signal) {
582
- const source = this.store.state.transcript;
915
+ const source = this.info.record;
583
916
  const selection = this.store.selection();
584
917
  const page = object(await this.host.require().call('session/page', { request: {
585
918
  address: { kind: 'session', sessionId: this.sessionId }, throughSeq: source.readThrough,
@@ -605,14 +938,14 @@ export class SessionController {
605
938
  * @param signal - Cancels local paging without interrupting the remote agent.
606
939
  */
607
940
  async historyThrough(target, signal) {
608
- const transcript = this.store.state.transcript;
941
+ const transcript = this.info.record;
609
942
  if (!transcript.ready)
610
943
  throw new Error('Wait for the session snapshot');
611
944
  while (transcript.hasMore && (target === 'first' || transcript.beforeSeq !== undefined && transcript.beforeSeq > target)) {
612
945
  signal.throwIfAborted();
613
946
  const before = transcript.beforeSeq;
614
947
  await this.older(signal);
615
- if (this.store.state.transcript !== transcript)
948
+ if (this.info.record !== transcript)
616
949
  throw new Error('Session changed while loading history');
617
950
  if (transcript.hasMore && (before === undefined || transcript.beforeSeq === undefined || transcript.beforeSeq >= before)) {
618
951
  throw new Error('Host history page did not advance');
@@ -623,18 +956,18 @@ export class SessionController {
623
956
  * @returns Number of removed records.
624
957
  */
625
958
  reclaimHistory() {
626
- if (this.historyPinned || !this.store.state.online)
959
+ if (this.info.view.pinned || !this.store.state.online)
627
960
  return 0;
628
- const removed = this.store.state.transcript.trimHistory(this.historyLimits);
961
+ const removed = this.info.record.trimHistory(this.historyLimits);
629
962
  if (removed)
630
- releaseHistoryLayout(this.store.state.transcript);
963
+ releaseHistoryLayout(this.info.record);
631
964
  return removed;
632
965
  }
633
966
  /** Release the selected transcript and its layout caches. */
634
967
  releaseTranscript() {
635
- releaseHistoryLayout(this.store.state.transcript);
636
- this.store.state.transcript.dispose();
637
- this.historyPinned = false;
968
+ this.promptBackfill?.abort();
969
+ this.promptBackfill = undefined;
970
+ this.info.reset();
638
971
  }
639
972
  /** @returns The selected session identity, or a `Select a session first` failure. */
640
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";