@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
@@ -0,0 +1,262 @@
1
+ /** Session-owned prompt index: every user prompt of the selected session, plus its recall cursor.
2
+ *
3
+ * The index replaces a plain bounded recall buffer. Each durable entry carries the sequence it came
4
+ * from, so an entry evicted by the budgets stays recoverable: the loaded window refills anything the
5
+ * transcript still holds, and `session/page` refetches anything older than the window. Budgets
6
+ * therefore bound memory without deciding reachability — which is what a base-200 buffer got wrong.
7
+ *
8
+ * Locally submitted slash commands never become durable records, so they are retained too, marked as
9
+ * non-durable. A durable echo of a locally recorded prompt upgrades that entry instead of adding a
10
+ * second copy, keeping the refill boundary (the oldest durable sequence) exact.
11
+ */
12
+ import { type Reasoning } from './history.ts';
13
+ import { Transcript } from './transcript.ts';
14
+ import type { HistorySearch } from './types.ts';
15
+ import type { ObjectValue } from '../transport/wire.ts';
16
+ /** A durable user prompt as the transcript reports it, before retention. */
17
+ export interface PromptRecord {
18
+ seq: number;
19
+ text: string;
20
+ }
21
+ /** One retained prompt: its text, plus the durable sequence when the host also recorded it. */
22
+ export interface PromptEntry extends PromptRecord {
23
+ durable: boolean;
24
+ }
25
+ /** How many prompts one session may retain, and how much text they may occupy. */
26
+ export interface PromptLimits {
27
+ maxEntries: number;
28
+ maxBytes: number;
29
+ }
30
+ /** Default balance: a long session's whole prompt list, since prompts are far smaller than history. */
31
+ export declare const DEFAULT_PROMPT_LIMITS: PromptLimits;
32
+ /** Flatten one transcript prompt into the single line the composer recalls. */
33
+ export declare function promptText(value: string): string;
34
+ /** The selected session's composer: its draft, the caret in it, and a draft a dialog parked aside. */
35
+ export interface ComposerState {
36
+ draft: string;
37
+ cursor: number;
38
+ parked: string;
39
+ }
40
+ /** How the selected session's record is being read: which window, where, and what is expanded.
41
+ *
42
+ * Everything here changes how the record renders, which is why it is session state rather than a
43
+ * transient panel flag; the record's content stays in `Transcript`. `window` is a strong reference
44
+ * released by `closeWindow`, and the layout cache keyed by it is a weak one.
45
+ */
46
+ export interface ViewState {
47
+ /** Detached record shown instead of the live transcript while reading jumped-to history. */
48
+ window?: Transcript;
49
+ scroll: number;
50
+ /** Reading protection: reclamation pauses until the reader returns to the live end. */
51
+ pinned: boolean;
52
+ /** Message sequences whose reasoning is expanded beyond the default fold. */
53
+ folds: ReadonlySet<number>;
54
+ /** Fold mode for the live attempt's completed reasoning. */
55
+ liveReasoning: Reasoning;
56
+ }
57
+ /** Composer-adjacent `@` reference menu: the highlighted row and the draft that dismissed it. */
58
+ export interface ReferenceState {
59
+ index: number;
60
+ dismissed?: string;
61
+ }
62
+ /** Model dialog step: the catalog plus the provider or model being inspected. */
63
+ export interface ModelState {
64
+ catalog: ObjectValue;
65
+ provider?: string;
66
+ model?: ObjectValue;
67
+ }
68
+ /** Panels the reader opened for the selected session.
69
+ *
70
+ * Only visibility and query text: every panel's rows come from the record, so closing one loses
71
+ * nothing and a session switch may clear all of it. The row cursor inside a panel is not here — it
72
+ * is focus, held by `Picker` and reset through its `key`.
73
+ */
74
+ export interface PanelState {
75
+ thoughts: boolean;
76
+ queue: boolean;
77
+ model?: ModelState;
78
+ history?: {
79
+ query: string;
80
+ contentSearch: boolean;
81
+ matches?: HistorySearch;
82
+ };
83
+ search?: {
84
+ query: string;
85
+ items: ObjectValue[];
86
+ hasMore: boolean;
87
+ };
88
+ }
89
+ /** Keyboard state of one pending question's options. */
90
+ export interface OptionState {
91
+ key: string;
92
+ cursor: number;
93
+ selected: string[];
94
+ custom: boolean;
95
+ }
96
+ /** Local answer state for host waterfalls: question answers collected so far, and the keyboard
97
+ * selection of an approval or of a question's options. The waterfall itself stays in
98
+ * `SessionController.interactions`, which is derived per session on every publish.
99
+ */
100
+ export interface InteractionState {
101
+ answers: Record<string, ObjectValue[]>;
102
+ option?: OptionState;
103
+ approval?: {
104
+ eventId: string;
105
+ index: number;
106
+ };
107
+ }
108
+ /** Client-owned state of the selected session, reset whenever another session is opened.
109
+ *
110
+ * It holds the record, the prompt index, the composer, the reading view and the local interaction
111
+ * state because all five belong to one session and none of them is owned by the host beyond what the
112
+ * record mirrors; everything derivable from `Telemetry` or `CostLedger` stays out (see the design's
113
+ * §5.7.6). The record is referenced here and nowhere else, so "the selected session" has one entry.
114
+ */
115
+ export declare class SessionInfo {
116
+ sessionId: string;
117
+ /** The selected session's record. Replaced — never mutated in place — when another session opens. */
118
+ record: Transcript;
119
+ readonly prompts: PromptIndex;
120
+ readonly composer: ComposerState;
121
+ readonly view: ViewState;
122
+ readonly interaction: InteractionState;
123
+ readonly reference: ReferenceState;
124
+ readonly panels: PanelState;
125
+ constructor(sessionId?: string);
126
+ /** Forget everything a previous session owned, keeping this instance identity-stable for `State`. */
127
+ reset(sessionId?: string): void;
128
+ /** Release the detached history window, if the reader has one open. */
129
+ closeWindow(): void;
130
+ }
131
+ /** Seq-ordered prompts with a recall cursor, owned by the selected session.
132
+ *
133
+ * `append` folds only prompts newer than the last fold, so streaming stays O(new records) and never
134
+ * rebuilds a projection just to notice a prompt. `oldest` reports the durable boundary, and
135
+ * `missingBefore` reports what the loaded window can refill before a page request is spent.
136
+ */
137
+ export declare class PromptIndex {
138
+ private readonly limits;
139
+ private entries;
140
+ private bytes;
141
+ private newestSeq;
142
+ private complete;
143
+ private shed;
144
+ private position;
145
+ private draft;
146
+ constructor(limits?: PromptLimits);
147
+ /** Retained entry count, so a caller can tell an empty index from a parked cursor. */
148
+ get length(): number;
149
+ /** Whether recall is parked on the oldest retained entry. */
150
+ get atOldest(): boolean;
151
+ /** Newest durable sequence already folded, so the next fold scans only what arrived after it. */
152
+ get through(): number;
153
+ /** Oldest durable sequence still retained; the window refills only prompts older than this. */
154
+ get oldest(): number | undefined;
155
+ /** Retained entries in session order, oldest first. */
156
+ get items(): readonly PromptEntry[];
157
+ /** Retained durable prompts only, in session order: what a cache may hand to another index. */
158
+ get durableItems(): PromptRecord[];
159
+ /** Whether a backfill has walked the host history to its beginning for this session. */
160
+ get exhausted(): boolean;
161
+ /** Whether a budget eviction dropped a retained prefix, so the index is no longer contiguous with
162
+ * the session's first prompt and must not claim to be exhaustive. */
163
+ get trimmed(): boolean;
164
+ /** Record that the host holds no prompts older than the ones retained.
165
+ *
166
+ * An index that shed a prefix cannot claim this: the dropped prompts are older than anything the
167
+ * live window holds, so the lazy backward step could not recover them.
168
+ */
169
+ markComplete(): void;
170
+ /** Forget one session's prompts and cursor. */
171
+ reset(): void;
172
+ /** Fold one transcript scan: its prompts, plus the watermark it covered.
173
+ *
174
+ * The watermark advances even when the scan found no prompt, so a turn of assistant and tool
175
+ * records is scanned once rather than again on every following frame.
176
+ * @param value - Prompts newer than `through`, and the newest sequence the scan covered.
177
+ */
178
+ fold(value: {
179
+ prompts: readonly PromptRecord[];
180
+ through: number;
181
+ }): void;
182
+ /** Fold prompts whose own sequences are already the watermark; entries beyond the budgets drop the
183
+ * oldest, which stays reloadable.
184
+ * @param values - Prompts in session order, oldest first.
185
+ */
186
+ append(values: readonly PromptRecord[]): void;
187
+ /** Remember a locally submitted command that never becomes a durable record.
188
+ * @param value - Submitted command text; consecutive repeats coalesce.
189
+ */
190
+ record(value: string): void;
191
+ /** Add refilled or paged prompts in front; an active cursor shifts so its entry stays selected.
192
+ * @param values - Older durable prompts in session order, oldest first.
193
+ * @returns How many entries were actually retained.
194
+ */
195
+ prepend(values: readonly PromptRecord[]): number;
196
+ /** Recall older/newer input, restoring the original unsent draft at the end.
197
+ * @param direction - Negative for older input, positive for newer input.
198
+ * @param current - Current composer content before beginning recall.
199
+ * @returns Recalled input, or the original draft when returning to the newest position.
200
+ */
201
+ move(direction: -1 | 1, current: string): string;
202
+ /** Leave recall navigation when the composer is edited or otherwise replaced. */
203
+ resetCursor(): void;
204
+ /** Trim a bulk backfill down to the budgets.
205
+ *
206
+ * `prepend` deliberately never evicts, because a lazy backward step must not drop what it just
207
+ * recovered. A full-history backfill is different: the excess it added is the oldest prefix, which
208
+ * stays reloadable through the same lazy path, so it can be settled to the budgets once. */
209
+ settle(): void;
210
+ /** Whether one entry fits the per-entry budget the composer can recall. */
211
+ private retainable;
212
+ /** Retain one durable prompt; a durable echo upgrades a local entry instead of duplicating it. */
213
+ private push;
214
+ /** Drop the oldest entries until both budgets hold; a dropped prefix stays reloadable. */
215
+ private trim;
216
+ }
217
+ /** Bytes of prompt text the process keeps across sessions before evicting the least recently used. */
218
+ export declare const DEFAULT_PROMPT_CACHE_BYTES: number;
219
+ /** Process-lifetime cache of one session's folded prompts, so re-opening a session and re-reading
220
+ * its history are not the same cost.
221
+ *
222
+ * Two producers write it: the cost scan, which already reads every page of every session, and the
223
+ * open-time backfill. Only a complete entry lets an open skip the walk; a partial one is written as
224
+ * incomplete and ignored when read, because the part it is missing is the oldest prefix — the part
225
+ * that would still have to be fetched. Bytes are bounded across sessions by least-recently-used
226
+ * eviction, keeping at least the newest session so one huge history is still cached.
227
+ */
228
+ export declare class PromptCache {
229
+ private readonly maxBytes;
230
+ private entries;
231
+ private bytes;
232
+ constructor(maxBytes?: number);
233
+ /** Store one session's prompts, replacing whatever it held.
234
+ * @param sessionId - Host session identity.
235
+ * @param value - Prompts in session order, and whether the host held no older ones.
236
+ */
237
+ put(sessionId: string, value: {
238
+ prompts: readonly PromptRecord[];
239
+ complete: boolean;
240
+ }): void;
241
+ /** Fold one scanned page in front of a session's entry.
242
+ *
243
+ * Pages arrive newest first, so each call's prompts are older than everything already cached; the
244
+ * entry stays incomplete until the scan reports it reached the beginning.
245
+ * @param sessionId - Host session identity.
246
+ * @param prompts - One page's prompts, oldest first within the page.
247
+ * @param complete - Whether this call is the scan's final one for that session.
248
+ */
249
+ observe(sessionId: string, prompts: readonly PromptRecord[], complete: boolean): void;
250
+ /** Read one session's prompts, marking the entry most recently used.
251
+ * @param sessionId - Host session identity.
252
+ * @returns The cached prompts, or undefined when nothing is cached.
253
+ */
254
+ get(sessionId: string): {
255
+ prompts: PromptRecord[];
256
+ complete: boolean;
257
+ } | undefined;
258
+ /** Forget one session, for example after the host rewrote its history. */
259
+ drop(sessionId: string): void;
260
+ /** Evict least-recently-used sessions until the byte budget holds. */
261
+ private evict;
262
+ }
@@ -0,0 +1,326 @@
1
+ /** Session-owned prompt index: every user prompt of the selected session, plus its recall cursor.
2
+ *
3
+ * The index replaces a plain bounded recall buffer. Each durable entry carries the sequence it came
4
+ * from, so an entry evicted by the budgets stays recoverable: the loaded window refills anything the
5
+ * transcript still holds, and `session/page` refetches anything older than the window. Budgets
6
+ * therefore bound memory without deciding reachability — which is what a base-200 buffer got wrong.
7
+ *
8
+ * Locally submitted slash commands never become durable records, so they are retained too, marked as
9
+ * non-durable. A durable echo of a locally recorded prompt upgrades that entry instead of adding a
10
+ * second copy, keeping the refill boundary (the oldest durable sequence) exact.
11
+ */
12
+ import { releaseHistoryLayout } from "./history.js";
13
+ import { Transcript } from "./transcript.js";
14
+ /** Default balance: a long session's whole prompt list, since prompts are far smaller than history. */
15
+ export const DEFAULT_PROMPT_LIMITS = { maxEntries: 2000, maxBytes: 512 * 1024 };
16
+ /** Longest single prompt worth recalling; a larger one is skipped rather than truncated in place. */
17
+ const MAX_ENTRY_CHARS = 128 * 1024;
18
+ /** Flatten one transcript prompt into the single line the composer recalls. */
19
+ export function promptText(value) { return value.replace(/\r?\n/g, ' ').trim(); }
20
+ /** Client-owned state of the selected session, reset whenever another session is opened.
21
+ *
22
+ * It holds the record, the prompt index, the composer, the reading view and the local interaction
23
+ * state because all five belong to one session and none of them is owned by the host beyond what the
24
+ * record mirrors; everything derivable from `Telemetry` or `CostLedger` stays out (see the design's
25
+ * §5.7.6). The record is referenced here and nowhere else, so "the selected session" has one entry.
26
+ */
27
+ export class SessionInfo {
28
+ sessionId;
29
+ /** The selected session's record. Replaced — never mutated in place — when another session opens. */
30
+ record = new Transcript();
31
+ prompts = new PromptIndex();
32
+ composer = { draft: '', cursor: 0, parked: '' };
33
+ view = { scroll: 0, pinned: false, folds: new Set(), liveReasoning: 'row' };
34
+ interaction = { answers: {} };
35
+ reference = { index: 0 };
36
+ panels = { thoughts: false, queue: false };
37
+ constructor(sessionId = '') {
38
+ this.sessionId = sessionId;
39
+ }
40
+ /** Forget everything a previous session owned, keeping this instance identity-stable for `State`. */
41
+ reset(sessionId = '') {
42
+ this.sessionId = sessionId;
43
+ this.closeWindow();
44
+ releaseHistoryLayout(this.record);
45
+ this.record.dispose();
46
+ this.record = new Transcript();
47
+ this.prompts.reset();
48
+ this.composer.draft = '';
49
+ this.composer.cursor = 0;
50
+ this.composer.parked = '';
51
+ this.view.scroll = 0;
52
+ this.view.pinned = false;
53
+ this.view.folds = new Set();
54
+ this.view.liveReasoning = 'row';
55
+ this.interaction.answers = {};
56
+ this.interaction.option = undefined;
57
+ this.interaction.approval = undefined;
58
+ this.reference.index = 0;
59
+ this.reference.dismissed = undefined;
60
+ this.panels.thoughts = false;
61
+ this.panels.queue = false;
62
+ this.panels.model = undefined;
63
+ this.panels.history = undefined;
64
+ this.panels.search = undefined;
65
+ }
66
+ /** Release the detached history window, if the reader has one open. */
67
+ closeWindow() {
68
+ const window = this.view.window;
69
+ if (!window)
70
+ return;
71
+ releaseHistoryLayout(window);
72
+ window.dispose();
73
+ this.view.window = undefined;
74
+ }
75
+ }
76
+ /** Seq-ordered prompts with a recall cursor, owned by the selected session.
77
+ *
78
+ * `append` folds only prompts newer than the last fold, so streaming stays O(new records) and never
79
+ * rebuilds a projection just to notice a prompt. `oldest` reports the durable boundary, and
80
+ * `missingBefore` reports what the loaded window can refill before a page request is spent.
81
+ */
82
+ export class PromptIndex {
83
+ limits;
84
+ entries = [];
85
+ bytes = 0;
86
+ newestSeq = -1;
87
+ complete = false;
88
+ shed = false;
89
+ position;
90
+ draft = '';
91
+ constructor(limits = DEFAULT_PROMPT_LIMITS) {
92
+ this.limits = limits;
93
+ }
94
+ /** Retained entry count, so a caller can tell an empty index from a parked cursor. */
95
+ get length() { return this.entries.length; }
96
+ /** Whether recall is parked on the oldest retained entry. */
97
+ get atOldest() { return this.position === 0; }
98
+ /** Newest durable sequence already folded, so the next fold scans only what arrived after it. */
99
+ get through() { return this.newestSeq; }
100
+ /** Oldest durable sequence still retained; the window refills only prompts older than this. */
101
+ get oldest() {
102
+ for (const entry of this.entries)
103
+ if (entry.durable)
104
+ return entry.seq;
105
+ return undefined;
106
+ }
107
+ /** Retained entries in session order, oldest first. */
108
+ get items() { return this.entries; }
109
+ /** Retained durable prompts only, in session order: what a cache may hand to another index. */
110
+ get durableItems() {
111
+ return this.entries.filter(entry => entry.durable).map(entry => ({ seq: entry.seq, text: entry.text }));
112
+ }
113
+ /** Whether a backfill has walked the host history to its beginning for this session. */
114
+ get exhausted() { return this.complete; }
115
+ /** Whether a budget eviction dropped a retained prefix, so the index is no longer contiguous with
116
+ * the session's first prompt and must not claim to be exhaustive. */
117
+ get trimmed() { return this.shed; }
118
+ /** Record that the host holds no prompts older than the ones retained.
119
+ *
120
+ * An index that shed a prefix cannot claim this: the dropped prompts are older than anything the
121
+ * live window holds, so the lazy backward step could not recover them.
122
+ */
123
+ markComplete() { if (!this.shed)
124
+ this.complete = true; }
125
+ /** Forget one session's prompts and cursor. */
126
+ reset() {
127
+ this.entries = [];
128
+ this.bytes = 0;
129
+ this.newestSeq = -1;
130
+ this.complete = false;
131
+ this.shed = false;
132
+ this.position = undefined;
133
+ this.draft = '';
134
+ }
135
+ /** Fold one transcript scan: its prompts, plus the watermark it covered.
136
+ *
137
+ * The watermark advances even when the scan found no prompt, so a turn of assistant and tool
138
+ * records is scanned once rather than again on every following frame.
139
+ * @param value - Prompts newer than `through`, and the newest sequence the scan covered.
140
+ */
141
+ fold(value) {
142
+ for (const prompt of value.prompts)
143
+ this.push(prompt);
144
+ this.newestSeq = Math.max(this.newestSeq, value.through, value.prompts.reduce((max, prompt) => Math.max(max, prompt.seq), -1));
145
+ }
146
+ /** Fold prompts whose own sequences are already the watermark; entries beyond the budgets drop the
147
+ * oldest, which stays reloadable.
148
+ * @param values - Prompts in session order, oldest first.
149
+ */
150
+ append(values) {
151
+ this.fold({ prompts: values, through: values.reduce((max, value) => Math.max(max, value.seq), -1) });
152
+ }
153
+ /** Remember a locally submitted command that never becomes a durable record.
154
+ * @param value - Submitted command text; consecutive repeats coalesce.
155
+ */
156
+ record(value) {
157
+ const entry = { seq: this.newestSeq, text: promptText(value), durable: false };
158
+ if (!this.retainable(entry) || this.entries.at(-1)?.text === entry.text)
159
+ return;
160
+ this.entries.push(entry);
161
+ this.bytes += entry.text.length * 2;
162
+ this.trim();
163
+ }
164
+ /** Add refilled or paged prompts in front; an active cursor shifts so its entry stays selected.
165
+ * @param values - Older durable prompts in session order, oldest first.
166
+ * @returns How many entries were actually retained.
167
+ */
168
+ prepend(values) {
169
+ const older = values.map(value => ({ seq: value.seq, text: promptText(value.text), durable: true }))
170
+ .filter(value => this.retainable(value));
171
+ if (!older.length)
172
+ return 0;
173
+ this.entries = [...older, ...this.entries];
174
+ this.bytes += older.reduce((sum, value) => sum + value.text.length * 2, 0);
175
+ if (this.position !== undefined)
176
+ this.position += older.length;
177
+ return older.length;
178
+ }
179
+ /** Recall older/newer input, restoring the original unsent draft at the end.
180
+ * @param direction - Negative for older input, positive for newer input.
181
+ * @param current - Current composer content before beginning recall.
182
+ * @returns Recalled input, or the original draft when returning to the newest position.
183
+ */
184
+ move(direction, current) {
185
+ if (!this.entries.length)
186
+ return current;
187
+ if (this.position === undefined) {
188
+ if (direction > 0)
189
+ return current;
190
+ this.position = this.entries.length;
191
+ this.draft = current;
192
+ }
193
+ this.position = Math.max(0, Math.min(this.entries.length, this.position + direction));
194
+ if (this.position === this.entries.length) {
195
+ const draft = this.draft;
196
+ this.resetCursor();
197
+ return draft;
198
+ }
199
+ return this.entries[this.position].text;
200
+ }
201
+ /** Leave recall navigation when the composer is edited or otherwise replaced. */
202
+ resetCursor() { this.position = undefined; this.draft = ''; }
203
+ /** Trim a bulk backfill down to the budgets.
204
+ *
205
+ * `prepend` deliberately never evicts, because a lazy backward step must not drop what it just
206
+ * recovered. A full-history backfill is different: the excess it added is the oldest prefix, which
207
+ * stays reloadable through the same lazy path, so it can be settled to the budgets once. */
208
+ settle() { this.trim(); }
209
+ /** Whether one entry fits the per-entry budget the composer can recall. */
210
+ retainable(value) {
211
+ return value.text !== '' && value.text.length <= MAX_ENTRY_CHARS;
212
+ }
213
+ /** Retain one durable prompt; a durable echo upgrades a local entry instead of duplicating it. */
214
+ push(value) {
215
+ const entry = { seq: value.seq, text: promptText(value.text), durable: true };
216
+ if (!this.retainable(entry))
217
+ return;
218
+ const last = this.entries.at(-1);
219
+ if (last && last.text === entry.text) {
220
+ last.seq = entry.seq;
221
+ last.durable = true;
222
+ return;
223
+ }
224
+ this.entries.push(entry);
225
+ this.bytes += entry.text.length * 2;
226
+ this.trim();
227
+ }
228
+ /** Drop the oldest entries until both budgets hold; a dropped prefix stays reloadable. */
229
+ trim() {
230
+ while (this.entries.length > this.limits.maxEntries || this.bytes > this.limits.maxBytes) {
231
+ this.bytes -= this.entries.shift().text.length * 2;
232
+ this.shed = true;
233
+ }
234
+ }
235
+ }
236
+ /** Bytes of prompt text the process keeps across sessions before evicting the least recently used. */
237
+ export const DEFAULT_PROMPT_CACHE_BYTES = 4 * 1024 * 1024;
238
+ /** Process-lifetime cache of one session's folded prompts, so re-opening a session and re-reading
239
+ * its history are not the same cost.
240
+ *
241
+ * Two producers write it: the cost scan, which already reads every page of every session, and the
242
+ * open-time backfill. Only a complete entry lets an open skip the walk; a partial one is written as
243
+ * incomplete and ignored when read, because the part it is missing is the oldest prefix — the part
244
+ * that would still have to be fetched. Bytes are bounded across sessions by least-recently-used
245
+ * eviction, keeping at least the newest session so one huge history is still cached.
246
+ */
247
+ export class PromptCache {
248
+ maxBytes;
249
+ entries = new Map();
250
+ bytes = 0;
251
+ constructor(maxBytes = DEFAULT_PROMPT_CACHE_BYTES) {
252
+ this.maxBytes = maxBytes;
253
+ }
254
+ /** Store one session's prompts, replacing whatever it held.
255
+ * @param sessionId - Host session identity.
256
+ * @param value - Prompts in session order, and whether the host held no older ones.
257
+ */
258
+ put(sessionId, value) {
259
+ const all = value.prompts.filter(prompt => prompt.text !== '')
260
+ .map(prompt => ({ seq: prompt.seq, text: prompt.text }));
261
+ let prompts = all, complete = value.complete;
262
+ let bytes = all.reduce((sum, prompt) => sum + prompt.text.length * 2, 0);
263
+ // One session can hold more prompt text than the whole budget. Keep its newest prompts that fit
264
+ // and record the entry as incomplete, so a later open still fetches the older part instead of
265
+ // trusting a list with a hole at the front.
266
+ if (bytes > this.maxBytes) {
267
+ let start = all.length, kept = 0;
268
+ for (let index = all.length - 1; index >= 0; index--) {
269
+ const size = all[index].text.length * 2;
270
+ if (kept + size > this.maxBytes)
271
+ break;
272
+ kept += size;
273
+ start = index;
274
+ }
275
+ prompts = all.slice(start);
276
+ bytes = kept;
277
+ complete = false;
278
+ }
279
+ this.drop(sessionId);
280
+ const entry = { prompts, complete, bytes };
281
+ this.entries.set(sessionId, entry);
282
+ this.bytes += entry.bytes;
283
+ this.evict();
284
+ }
285
+ /** Fold one scanned page in front of a session's entry.
286
+ *
287
+ * Pages arrive newest first, so each call's prompts are older than everything already cached; the
288
+ * entry stays incomplete until the scan reports it reached the beginning.
289
+ * @param sessionId - Host session identity.
290
+ * @param prompts - One page's prompts, oldest first within the page.
291
+ * @param complete - Whether this call is the scan's final one for that session.
292
+ */
293
+ observe(sessionId, prompts, complete) {
294
+ const current = this.entries.get(sessionId)?.prompts ?? [];
295
+ this.put(sessionId, { prompts: [...prompts, ...current], complete });
296
+ }
297
+ /** Read one session's prompts, marking the entry most recently used.
298
+ * @param sessionId - Host session identity.
299
+ * @returns The cached prompts, or undefined when nothing is cached.
300
+ */
301
+ get(sessionId) {
302
+ const entry = this.entries.get(sessionId);
303
+ if (!entry)
304
+ return undefined;
305
+ this.entries.delete(sessionId);
306
+ this.entries.set(sessionId, entry);
307
+ return { prompts: entry.prompts, complete: entry.complete };
308
+ }
309
+ /** Forget one session, for example after the host rewrote its history. */
310
+ drop(sessionId) {
311
+ const entry = this.entries.get(sessionId);
312
+ if (!entry)
313
+ return;
314
+ this.bytes -= entry.bytes;
315
+ this.entries.delete(sessionId);
316
+ }
317
+ /** Evict least-recently-used sessions until the byte budget holds. */
318
+ evict() {
319
+ for (const [sessionId, entry] of this.entries) {
320
+ if (this.bytes <= this.maxBytes || this.entries.size === 1)
321
+ return;
322
+ this.bytes -= entry.bytes;
323
+ this.entries.delete(sessionId);
324
+ }
325
+ }
326
+ }
@@ -9,15 +9,23 @@ export declare function navigationCommand(value: string): {
9
9
  export declare function sessionLabel(session: ObjectValue): string;
10
10
  /** Match an exact ID or name before a unique ID prefix; never choose an ambiguous target. */
11
11
  export declare function resolveTarget(items: ObjectValue[], query: string, id: string, names: (item: ObjectValue) => string[]): ObjectValue;
12
- /** Activity a session summary reports on its own, without loading that session's history. */
13
- export type SessionState = 'running' | 'idle' | 'blank';
12
+ /** User-visible activity of one session, most actionable first.
13
+ *
14
+ * `needs` is the only state that asks the user to do something, and it outranks the host's running
15
+ * flag because a turn waiting on an answer is running only in the mechanical sense. `blank` stays a
16
+ * marker for a session that never sent a turn, but it is not a status and never enters a rollup.
17
+ */
18
+ export type SessionState = 'needs' | 'running' | 'idle' | 'blank';
14
19
  /** Classify one session from the host's list summary; nothing is inferred from silence.
15
20
  * @param session - Session summary from `session/list`.
16
- * @returns Running while its agent works, blank before its first turn, otherwise idle.
21
+ * @param pending - Whether this client holds an unanswered interaction for that session.
22
+ * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
17
23
  */
18
- export declare function sessionState(session: ObjectValue): SessionState;
19
- /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
24
+ export declare function sessionState(session: ObjectValue, pending?: boolean): SessionState;
25
+ /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
20
26
  export declare const SESSION_MARKERS: Record<SessionState, string>;
27
+ /** One word per state, so every screen names the same state the same way. */
28
+ export declare const STATE_LABELS: Record<SessionState, string>;
21
29
  /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
22
30
  * @param time - Epoch milliseconds of the last activity, when the summary reported one.
23
31
  * @param now - Current epoch milliseconds.
@@ -27,11 +35,57 @@ export declare function activityAge(time: number | undefined, now: number): stri
27
35
  /** Status cell for one session row: its state marker and the age of its last activity.
28
36
  * @param session - Session summary from `session/list`.
29
37
  * @param now - Current epoch milliseconds.
38
+ * @param pending - Whether this client holds an unanswered interaction for that session.
30
39
  * @returns Marker with an optional age, without a trailing space when unknown.
31
40
  */
32
- export declare function sessionStatus(session: ObjectValue, now: number): string;
41
+ export declare function sessionStatus(session: ObjectValue, now: number, pending?: boolean): string;
42
+ /** States a workspace rollup reports, most actionable first. */
43
+ export declare const ROLLUP_STATES: readonly ["needs", "running", "idle"];
44
+ /** One state a workspace rollup reports. */
45
+ export type RollupState = (typeof ROLLUP_STATES)[number];
46
+ /** One counted state of a workspace rollup. */
47
+ export interface RollupCount {
48
+ state: RollupState;
49
+ count: number;
50
+ }
51
+ /** How much room a rollup has for words. */
52
+ export type RollupStyle = 'words' | 'badges';
33
53
  /** Count the sessions of one workspace by the state each reports.
54
+ *
55
+ * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
56
+ * absence of activity, and listing it beside real work only makes the rollup harder to read.
34
57
  * @param sessions - Sessions whose `sessionIds` belong to the workspace.
35
- * @returns One `marker count` cell per state that occurs, running first, or an empty string.
58
+ * @param pending - Session IDs this client holds an unanswered interaction for.
59
+ * @returns One count per state that occurs, most actionable first, or an empty list.
60
+ */
61
+ export declare function workspaceCounts(sessions: readonly ObjectValue[], pending?: ReadonlySet<string>): RollupCount[];
62
+ /** Render one rollup as separately coloured cells.
63
+ *
64
+ * Each cell after the first carries the separator that joins it to the previous one, so a caller can
65
+ * colour the cells independently without losing the text {@link workspaceStatus} would produce.
66
+ * @param counts - Counts from {@link workspaceCounts}.
67
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
68
+ * @returns The cells in the order given, with their separators.
69
+ */
70
+ export declare function workspaceSegments(counts: readonly RollupCount[], style?: RollupStyle): {
71
+ state: RollupState;
72
+ text: string;
73
+ }[];
74
+ /** Render one rollup as plain text, the same way every screen and test reads it.
75
+ * @param counts - Counts from {@link workspaceCounts}.
76
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
77
+ * @returns The joined cell text, empty when nothing was counted.
78
+ */
79
+ export declare function workspaceStatus(counts: readonly RollupCount[], style?: RollupStyle): string;
80
+ /** Marker key for the compact rollup, which has no room for the words. */
81
+ export declare const ROLLUP_LEGEND: string;
82
+ /** Secondary path text for one workspace row.
83
+ *
84
+ * The title is usually the last path segment, so repeating it wastes the row; the parent directory
85
+ * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
86
+ * path, because dropping it would hide where the workspace actually lives.
87
+ * @param path - Registered host directory.
88
+ * @param title - Workspace title as the row already shows it.
89
+ * @returns The path to show beside the row, or an empty string when nothing is left.
36
90
  */
37
- export declare function workspaceStatus(sessions: readonly ObjectValue[]): string;
91
+ export declare function workspaceDetail(path: string, title: string): string;