@itookit/dsht 0.3.3 → 0.3.4

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.
@@ -0,0 +1,78 @@
1
+ /** Drop the render measurements React's development build appends to the global performance timeline.
2
+ *
3
+ * `react-reconciler` emits one `performance.measure()` entry per rendered component when it resolves
4
+ * to its development build, and Node keeps every entry for the life of the process because
5
+ * `performance.clearMeasures()` is the only way to release them. Each entry also carries a
6
+ * `detail.devtools.properties` payload describing the component's props, which is where the memory
7
+ * actually goes: roughly 1.2 KB per render once the duplicated property names are counted.
8
+ *
9
+ * The production build emits no measurements at all, so `src/cli/index.ts` selects it and this
10
+ * module is a safety net for `DSHT_REACT_DEV=1`, where the development build is deliberate.
11
+ */
12
+ /** Zero-width space React prefixes to a component's own measure name; see `ReactFiberPerformanceTrack` in react-reconciler. */
13
+ const COMPONENT_PREFIX = '\u200b';
14
+ /** Measure names React passes as an explicit update trigger, or as the label for an errored or recovered boundary. */
15
+ const REACT_NAMES = [
16
+ 'Update', 'Cascading Update', 'Update Blocked', 'Update Suspended', 'Mount', 'Unmount',
17
+ 'Reconnect', 'Disconnect', 'Recovered', 'Errored',
18
+ ];
19
+ /** Constructors `performance` uses for invalid input, which a measurement library cannot survive. */
20
+ const PROGRAMMING_ERRORS = ['TypeError', 'RangeError', 'SyntaxError'];
21
+ /** The global performance timeline, when the runtime exposes one.
22
+ * @returns The timeline, or undefined on a runtime without `performance` or `getEntriesByType`.
23
+ */
24
+ function timeline() {
25
+ const candidate = globalThis.performance;
26
+ return typeof candidate?.getEntriesByType === 'function' ? candidate : undefined;
27
+ }
28
+ /** Measure names the current timeline holds that React created.
29
+ *
30
+ * A name only counts when it is exactly one of `REACT_NAMES` or carries React's zero-width prefix,
31
+ * so entries an application or a library created under its own name are never selected.
32
+ * @param performance - Timeline to read.
33
+ * @returns Names React created, without duplicates.
34
+ */
35
+ export function reactMeasureNames(performance) {
36
+ const names = new Set();
37
+ for (const entry of performance.getEntriesByType('measure')) {
38
+ const name = entry.name;
39
+ if (name.startsWith(COMPONENT_PREFIX))
40
+ names.add(name);
41
+ else if (REACT_NAMES.includes(name))
42
+ names.add(name);
43
+ }
44
+ return [...names];
45
+ }
46
+ /** Remove React's render measurements, and only those, from the global performance timeline.
47
+ *
48
+ * Intended to run on the memory-sampling interval: the development build keeps appending, so one
49
+ * pass only bounds the total instead of ending it. A failure is not worth propagating, because this
50
+ * is a bounded diagnostic and a measurement library that rejects input should not stop the client.
51
+ * @returns How many distinct measure names were cleared.
52
+ */
53
+ export function clearReactMeasures() {
54
+ const performance = timeline();
55
+ if (performance === undefined)
56
+ return 0;
57
+ const names = reactMeasureNames(performance);
58
+ for (const name of names) {
59
+ try {
60
+ performance.clearMeasures(name);
61
+ }
62
+ catch (error) {
63
+ if (!PROGRAMMING_ERRORS.includes(error?.name ?? ''))
64
+ throw error;
65
+ }
66
+ }
67
+ return names.length;
68
+ }
69
+ /** How many measure entries the timeline currently holds.
70
+ *
71
+ * Recorded next to the heap counters so a memory log shows whether retained growth tracks the
72
+ * render count. Entries cleared by `clearReactMeasures` leave the count at zero.
73
+ * @returns Entry count, or undefined on a runtime without a readable timeline.
74
+ */
75
+ export function measureCount() {
76
+ const performance = timeline();
77
+ return performance?.getEntriesByType('measure').length;
78
+ }
@@ -46,6 +46,15 @@ export declare class SessionController {
46
46
  * @returns Retained question and approval frames for that session.
47
47
  */
48
48
  pendingFor(state: State): ObjectValue[];
49
+ /** Unanswered interactions by session, so a list can show who is waiting without opening them.
50
+ *
51
+ * The host delivers approval and question waterfalls for every session on one stream, and this
52
+ * client already retains them to answer later, so the counts are a fact of this generation rather
53
+ * than a new subscription. They live only as long as the connection: a reconnect clears the map
54
+ * until the host replays the pending waterfalls.
55
+ * @returns One count per session holding at least one unanswered interaction.
56
+ */
57
+ pendingCounts(): ReadonlyMap<string, number>;
49
58
  /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
50
59
  * @param frame - One decoded waterfall frame.
51
60
  * @returns Whether this domain retained the frame for an answer.
@@ -168,6 +177,13 @@ export declare class SessionController {
168
177
  * @param allowed - Whether the request is approved once.
169
178
  */
170
179
  approve(allowed: boolean): Promise<void>;
180
+ /** Dismiss the whole selected-session question set without answering it.
181
+ *
182
+ * The Web client's close button settles the same waterfall the same way — reject with
183
+ * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
184
+ * batch is answered as one request, so dismissals also discard partial local answers.
185
+ */
186
+ dismissQuestion(): Promise<void>;
171
187
  /** Add a page before the retained window using its fixed opening cut.
172
188
  * @param signal - Cancels local paging without interrupting the remote agent.
173
189
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -95,6 +95,23 @@ export class SessionController {
95
95
  pendingFor(state) {
96
96
  return [...this.interactions.values()].filter(frame => frame.agentId === state.sessionId);
97
97
  }
98
+ /** Unanswered interactions by session, so a list can show who is waiting without opening them.
99
+ *
100
+ * The host delivers approval and question waterfalls for every session on one stream, and this
101
+ * client already retains them to answer later, so the counts are a fact of this generation rather
102
+ * than a new subscription. They live only as long as the connection: a reconnect clears the map
103
+ * until the host replays the pending waterfalls.
104
+ * @returns One count per session holding at least one unanswered interaction.
105
+ */
106
+ pendingCounts() {
107
+ const counts = new Map();
108
+ for (const frame of this.interactions.values()) {
109
+ if (typeof frame.agentId !== 'string')
110
+ continue;
111
+ counts.set(frame.agentId, (counts.get(frame.agentId) ?? 0) + 1);
112
+ }
113
+ return counts;
114
+ }
98
115
  /** Retain a recognized host waterfall; unknown events stay with the connection to delegate.
99
116
  * @param frame - One decoded waterfall frame.
100
117
  * @returns Whether this domain retained the frame for an answer.
@@ -459,6 +476,22 @@ export class SessionController {
459
476
  throw new Error('No pending approval');
460
477
  await this.answer(allowed ? 'allowed-once' : 'rejected');
461
478
  }
479
+ /** Dismiss the whole selected-session question set without answering it.
480
+ *
481
+ * The Web client's close button settles the same waterfall the same way — reject with
482
+ * `ASK_CANCELLED` — so the host records a user cancellation rather than an answer. A question
483
+ * batch is answered as one request, so dismissals also discard partial local answers.
484
+ */
485
+ async dismissQuestion() {
486
+ const pending = this.store.state.pending[0];
487
+ if (pending?.event !== 'user-questions/request')
488
+ throw new Error('No pending question');
489
+ await this.reply(pending, { kind: 'rejected', error: {
490
+ name: 'UserQuestionError', message: 'the user cancelled ask_user_question', code: 'ASK_CANCELLED',
491
+ } });
492
+ this.interactions.delete(string(pending.eventId));
493
+ this.store.update({});
494
+ }
462
495
  /** Add a page before the retained window using its fixed opening cut.
463
496
  * @param signal - Cancels local paging without interrupting the remote agent.
464
497
  * @param transcript - Transcript to extend; defaults to the live one.
@@ -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;
@@ -36,15 +36,20 @@ export function resolveTarget(items, query, id, names) {
36
36
  }
37
37
  /** Classify one session from the host's list summary; nothing is inferred from silence.
38
38
  * @param session - Session summary from `session/list`.
39
- * @returns Running while its agent works, blank before its first turn, otherwise idle.
39
+ * @param pending - Whether this client holds an unanswered interaction for that session.
40
+ * @returns Needs-you while an answer is owed, running while its agent works, otherwise idle or blank.
40
41
  */
41
- export function sessionState(session) {
42
+ export function sessionState(session, pending = false) {
43
+ if (pending)
44
+ return 'needs';
42
45
  if (session.running === true)
43
46
  return 'running';
44
47
  return session.blank === true ? 'blank' : 'idle';
45
48
  }
46
- /** Leading marker per state: a working clock, a filled idle dot, and an empty unused circle. */
47
- export const SESSION_MARKERS = { running: '◐', idle: '●', blank: '○' };
49
+ /** Leading marker per state: a question mark, a working clock, a filled dot, and an unused circle. */
50
+ export const SESSION_MARKERS = { needs: '?', running: '◐', idle: '●', blank: '○' };
51
+ /** One word per state, so every screen names the same state the same way. */
52
+ export const STATE_LABELS = { needs: 'needs you', running: 'working', idle: 'ready', blank: 'empty' };
48
53
  /** Coarse age of a session's last activity, so the column stays steady between list refreshes.
49
54
  * @param time - Epoch milliseconds of the last activity, when the summary reported one.
50
55
  * @param now - Current epoch milliseconds.
@@ -65,20 +70,74 @@ export function activityAge(time, now) {
65
70
  /** Status cell for one session row: its state marker and the age of its last activity.
66
71
  * @param session - Session summary from `session/list`.
67
72
  * @param now - Current epoch milliseconds.
73
+ * @param pending - Whether this client holds an unanswered interaction for that session.
68
74
  * @returns Marker with an optional age, without a trailing space when unknown.
69
75
  */
70
- export function sessionStatus(session, now) {
76
+ export function sessionStatus(session, now, pending = false) {
71
77
  const age = activityAge(typeof session.updatedAt === 'number' ? session.updatedAt : undefined, now);
72
- return `${SESSION_MARKERS[sessionState(session)]}${age === '' ? '' : ` ${age}`}`;
78
+ return `${SESSION_MARKERS[sessionState(session, pending)]}${age === '' ? '' : ` ${age}`}`;
73
79
  }
80
+ /** States a workspace rollup reports, most actionable first. */
81
+ export const ROLLUP_STATES = ['needs', 'running', 'idle'];
74
82
  /** Count the sessions of one workspace by the state each reports.
83
+ *
84
+ * Blank sessions are counted by neither a badge nor a word: a session that never sent a turn is the
85
+ * absence of activity, and listing it beside real work only makes the rollup harder to read.
75
86
  * @param sessions - Sessions whose `sessionIds` belong to the workspace.
76
- * @returns One `marker count` cell per state that occurs, running first, or an empty string.
87
+ * @param pending - Session IDs this client holds an unanswered interaction for.
88
+ * @returns One count per state that occurs, most actionable first, or an empty list.
77
89
  */
78
- export function workspaceStatus(sessions) {
79
- const counts = { running: 0, idle: 0, blank: 0 };
80
- for (const session of sessions)
81
- counts[sessionState(session)] += 1;
82
- return ['running', 'idle', 'blank'].filter(state => counts[state] > 0)
83
- .map(state => `${SESSION_MARKERS[state]} ${counts[state]}`).join(' ');
90
+ export function workspaceCounts(sessions, pending = new Set()) {
91
+ const counts = { needs: 0, running: 0, idle: 0 };
92
+ for (const session of sessions) {
93
+ const id = session.sessionId;
94
+ const state = sessionState(session, typeof id === 'string' && pending.has(id));
95
+ if (state !== 'blank')
96
+ counts[state] += 1;
97
+ }
98
+ return ROLLUP_STATES.filter(state => counts[state] > 0).map(state => ({ state, count: counts[state] }));
99
+ }
100
+ /** Render one rollup as separately coloured cells.
101
+ *
102
+ * Each cell after the first carries the separator that joins it to the previous one, so a caller can
103
+ * colour the cells independently without losing the text {@link workspaceStatus} would produce.
104
+ * @param counts - Counts from {@link workspaceCounts}.
105
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
106
+ * @returns The cells in the order given, with their separators.
107
+ */
108
+ export function workspaceSegments(counts, style = 'words') {
109
+ const separator = style === 'words' ? ' · ' : ' ';
110
+ return counts.map(({ state, count }, index) => ({ state,
111
+ text: `${index === 0 ? '' : separator}${style === 'words' ? `${SESSION_MARKERS[state]} ${count} ${STATE_LABELS[state]}` : `${SESSION_MARKERS[state]}${count}`}` }));
112
+ }
113
+ /** Render one rollup as plain text, the same way every screen and test reads it.
114
+ * @param counts - Counts from {@link workspaceCounts}.
115
+ * @param style - `words` spells each state out; `badges` keeps only the marker and the count.
116
+ * @returns The joined cell text, empty when nothing was counted.
117
+ */
118
+ export function workspaceStatus(counts, style = 'words') {
119
+ return workspaceSegments(counts, style).map(segment => segment.text).join('');
120
+ }
121
+ /** Marker key for the compact rollup, which has no room for the words. */
122
+ export const ROLLUP_LEGEND = `${SESSION_MARKERS.idle} ${STATE_LABELS.idle} · ${SESSION_MARKERS.running} ${STATE_LABELS.running} · ${SESSION_MARKERS.needs} ${STATE_LABELS.needs}`;
123
+ /** Secondary path text for one workspace row.
124
+ *
125
+ * The title is usually the last path segment, so repeating it wastes the row; the parent directory
126
+ * is what distinguishes two checkouts. A title that does not name the last segment keeps the full
127
+ * path, because dropping it would hide where the workspace actually lives.
128
+ * @param path - Registered host directory.
129
+ * @param title - Workspace title as the row already shows it.
130
+ * @returns The path to show beside the row, or an empty string when nothing is left.
131
+ */
132
+ export function workspaceDetail(path, title) {
133
+ if (!path)
134
+ return '';
135
+ const trimmed = path.replace(/\/+$/u, '');
136
+ const cut = trimmed.lastIndexOf('/');
137
+ const base = cut < 0 ? trimmed : trimmed.slice(cut + 1);
138
+ if (base !== title)
139
+ return path;
140
+ if (cut > 0)
141
+ return trimmed.slice(0, cut);
142
+ return cut === 0 ? '/' : '';
84
143
  }
package/dist/ui/app.js CHANGED
@@ -19,7 +19,7 @@ import { CopyMode } from "./copy-mode.js";
19
19
  import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
20
20
  import { COMMAND_HINTS, completeCommand as completeDraft, suggestedCommands } from "./commands/registry.js";
21
21
  import { classifySubmission } from "./commands/parse.js";
22
- import { sessionLabel, sessionStatus, workspaceStatus } from "../session/navigation.js";
22
+ import { ROLLUP_LEGEND, sessionLabel, sessionStatus, workspaceCounts, workspaceDetail, workspaceSegments, workspaceStatus } from "../session/navigation.js";
23
23
  import { array, errorText, object, safeText, string } from "../transport/wire.js";
24
24
  /** Transient notices expire; interactive panels remain open until dismissed. */
25
25
  const PANEL_LIFETIME_MS = 10_000;
@@ -266,7 +266,6 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
266
266
  if (key.ctrl && draft.current)
267
267
  setInput('');
268
268
  if (key.escape) {
269
- setOptionState({ ...choiceState, custom: false });
270
269
  setApprovalSelection(undefined);
271
270
  setRemoval(undefined);
272
271
  setModels(undefined);
@@ -277,6 +276,18 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
277
276
  setHelp(false);
278
277
  setCostExpanded(false);
279
278
  setStatusExpanded(false);
279
+ // A question batch is one request, so Esc leaves the whole set the way the Web client's
280
+ // close button does. The free-text row keeps its own step back: its first Esc returns to
281
+ // the options, and only the next one dismisses. Approval keeps every choice explicit.
282
+ if (pending?.event === 'user-questions/request' && !choiceState.custom) {
283
+ if (controller.state.online && !controller.state.busy && controller.state.pending[0]?.eventId === eventId) {
284
+ setOptionState(undefined);
285
+ setAnswers(previous => { const rest = { ...previous }; delete rest[eventId]; return rest; });
286
+ operate(() => controller.dismissQuestion());
287
+ }
288
+ return;
289
+ }
290
+ setOptionState({ ...choiceState, custom: false });
280
291
  }
281
292
  return;
282
293
  }
@@ -621,17 +632,44 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
621
632
  if (accepted)
622
633
  setInput('');
623
634
  };
635
+ const width = Math.max(10, (stdout.columns ?? 80) - 2);
636
+ // The composer never trades the conversation away for input. Its window grows with the body and
637
+ // is clamped so a few conversation rows always survive; width never buys height, so landscape and
638
+ // a wide desktop only wrap less instead of showing more input rows. The budget subtracts the one
639
+ // row the frame, the header, the status bar and a transient notice/suggestion line may each take.
640
+ const bodyRows = Math.max(4, (stdout.rows ?? 30) - 4 - statusBarRows);
641
+ const composerRows = Math.max(1, Math.min(Math.min(5, Math.max(2, Math.floor(bodyRows / 3))), bodyRows - 7));
642
+ const draftWidth = Math.max(1, width - 6);
624
643
  // Session activity is a list-level fact, so both pickers read it without loading any history.
625
644
  const listAge = Date.now();
645
+ // Unanswered interactions are held per session already; a list only has to read the counts.
646
+ const pendingCounts = controller.pendingCounts();
647
+ const isPending = (session) => (pendingCounts.get(string(session.sessionId)) ?? 0) > 0;
626
648
  const sessionsOf = (workspace) => {
627
649
  const ids = new Set(array(workspace.sessionIds).map(string));
628
650
  return state.sessions.filter(session => ids.has(string(session.sessionId)));
629
651
  };
652
+ // `width` counts only the frame's outer padding, so the list subtracts the composer border and
653
+ // padding it renders inside; measured against the wider value a long row would still wrap.
654
+ const pickerWidth = Math.max(16, width - 4);
655
+ // Words explain themselves. Badges fit a narrow terminal but need the marker key beside them.
656
+ const rollupStyle = pickerWidth >= 80 ? 'words' : 'badges';
657
+ // Needs-you is the one state that asks for action, so it outranks working; ready stays quiet.
658
+ const stateColor = { needs: theme.status.critical, running: theme.status.working, idle: theme.colors.muted };
630
659
  const choices = state.screen === 'workspaces' ? [
631
- ...state.workspaces.map(workspace => ({ key: string(workspace.workspaceId),
632
- label: `${[workspaceStatus(sessionsOf(workspace)), string(workspace.title), string(workspace.path)].filter(Boolean).join(' ')}`,
633
- remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
634
- action: () => controller.pickWorkspace(string(workspace.workspaceId)) })),
660
+ ...state.workspaces.map(workspace => {
661
+ const counts = workspaceCounts(sessionsOf(workspace), new Set(pendingCounts.keys()));
662
+ const name = string(workspace.title);
663
+ const title = name || string(workspace.path);
664
+ // A path only earns a column when words are affordable and the title does not already say it.
665
+ const detail = rollupStyle === 'words' && name ? workspaceDetail(string(workspace.path), name) : '';
666
+ return { key: string(workspace.workspaceId),
667
+ label: [workspaceStatus(counts, rollupStyle), title].filter(Boolean).join(' '), title,
668
+ cells: workspaceSegments(counts, rollupStyle).map(segment => ({ text: segment.text, color: stateColor[segment.state] })),
669
+ ...(detail ? { detail } : {}),
670
+ remove: () => setRemoval({ kind: 'workspace', id: string(workspace.workspaceId), name: string(workspace.title), path: string(workspace.path) }),
671
+ action: () => controller.pickWorkspace(string(workspace.workspaceId)) };
672
+ }),
635
673
  { key: '@all', label: 'All sessions', action: () => operate(() => controller.switchSession('all')) },
636
674
  // The directory this client runs in is the one case where no path has to be typed, and offering
637
675
  // it only while the host has not registered it keeps the row from repeating itself.
@@ -642,12 +680,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
642
680
  ] : [
643
681
  ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.createSession()) }] : []),
644
682
  ...controller.visibleSessions.map(session => ({ key: string(session.sessionId),
645
- label: `${sessionStatus(session, listAge)} ${sessionLabel(session)} ${session.sessionId}`,
683
+ label: `${sessionStatus(session, listAge, isPending(session))} ${sessionLabel(session)} ${session.sessionId}`,
646
684
  remove: () => operate(() => requestRemoval('session', string(session.sessionId))),
647
685
  action: () => { setScroll(0); operate(() => controller.selectSession(string(session.sessionId))); } })),
648
686
  { key: '@back', label: '← Workspaces', action: () => operate(() => controller.showPicker('workspaces')) },
649
687
  ];
650
- const width = Math.max(10, (stdout.columns ?? 80) - 2);
651
688
  const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
652
689
  const { length, first } = layout;
653
690
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
@@ -783,8 +820,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
783
820
  : state.screen === 'chat' && position > 0 ? 'history'
784
821
  : undefined;
785
822
  const statusFrozen = pauseReason !== undefined;
786
- return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
823
+ return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(ChatHeader, { title: headerTitle, mode: controller.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [historyLoading && _jsxs(Text, { dimColor: true, children: [historyLoading, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.error && _jsx(Text, { color: theme.colors.error, children: state.error }), state.screen === 'chat' && _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { await controller.removeTarget(removal); setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && !state.busy && state.online, canSelect: () => !draft.current && !controller.state.busy && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { await controller.selectModel(provider, model, effort); setModels(undefined); }), onOpen: (provider, model) => setModels({ catalog: models.catalog, provider, model }), onBack: () => setModels({ catalog: models.catalog }), onClose: () => setModels(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchResults(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, width: pickerWidth,
824
+ // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
825
+ // terminal too narrow for the whole key gets the badges alone rather than half a legend.
826
+ legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && !state.busy && !input, canSelect: () => !draft.current && controller.state.online && !controller.state.busy }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy }), state.screen === 'chat' && historyQuery !== undefined && _jsx(HistoryDialog, { identity: `history:${historyQuery}`, contentSearch: contentSearch, matches: historyMatches, query: historyQuery, messages: layout.messages, width: width, enabled: !input && !state.busy, canSelect: () => !draft.current && !controller.state.busy, onJump: seq => operate(() => jumpHistory(seq)), onClose: () => { setHistoryQuery(undefined); setHistoryMatches(undefined); } }), pending && _jsxs(Box, { flexShrink: 0, flexDirection: "column", children: [_jsx(Text, { bold: true, color: theme.colors.context, children: question ? `Question ${answered.length + 1}/${questions.length}${question.header ? ` · ${safeText(string(question.header))}` : ''}` : 'Approval required' }), _jsx(Text, { children: safeText(question ? string(question.question) : JSON.stringify(pending.request, null, 2)) }), question?.detail && _jsx(Text, { children: safeText(string(question.detail)) }), pending.event === 'approval/request' && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [['Allow once', 'Deny', 'Stop turn'].map((label, index) => _jsxs(Text, { color: approvalIndex === index ? theme.accent : undefined, children: [approvalIndex === index ? '❯ ' : ' ', index + 1, ". ", label] }, label)), _jsx(Text, { dimColor: true, children: "\u2191 \u2193 / 1\u20133 select \u00B7 Enter confirm" })] }), options.length > 0 && _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [[...options, { label: 'Other answer — type below' }].map((option, index) => ({ option, index }))
787
827
  .slice(optionStart, optionStart + optionPageSize).map(({ option, index }) => _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Text, { color: index === optionCursor ? theme.accent : undefined, wrap: "truncate-end", children: [index === optionCursor ? '❯ ' : ' ', index + 1, ". ", question?.multiSelect === true && index < options.length ? choiceState.selected.includes(string(option.label)) ? '[x] ' : '[ ] ' : '', safeText(string(option.label))] }), option.description && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [' ', safeText(string(option.description))] })] }, index)), _jsx(Text, { dimColor: true, children: choiceState.custom ? 'Type your answer below · Esc returns to options' : question?.multiSelect === true
788
- ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm' : '↑ ↓ / 1–9 select · Enter confirm' })] }), _jsx(Text, { dimColor: true, children: question ? 'Choose "Other answer" to type · the draft is kept while this is open'
789
- : 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsxs(Box, { flexShrink: 0, children: [_jsx(Text, { color: answerPending ? theme.colors.muted : theme.accent, children: "\u276F " }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' })] }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
828
+ ? '↑ ↓ move · Space / 1–9 toggle · Enter confirm · Esc dismisses' : '↑ ↓ / 1–9 select · Enter confirm · Esc dismisses' })] }), _jsx(Text, { dimColor: true, children: question ? options.length > 0
829
+ ? 'Choose "Other answer" to type · the draft is kept while this is open'
830
+ : 'Esc dismisses the question · the draft is kept while this is open'
831
+ : 'Esc keeps this pending · the draft is kept while this is open' })] })] }) }), !queueOpen && !pending && state.screen === 'chat' && queued.length > 0 && _jsx(QueuedPreview, { queued: queued, width: width }), _jsx(TextInput, { value: input, onChange: setInput, onCursorChange: setCursor, onSubmit: () => { void submit(draft.current); }, reservedKeys: approvalKeysActive ? ['1', '2', '3'] : queueOpen && !pending ? ['d'] : questionKeysActive ? ['1', '2', '3', '4', '5', '6', '7', '8', '9', ...(question?.multiSelect === true ? [' '] : [])] : !removal && !models && !searchResults && (state.screen === 'workspaces' || state.screen === 'sessions') ? ['d'] : undefined, width: draftWidth, maxRows: composerRows, promptColor: answerPending ? theme.colors.muted : theme.accent, focus: state.online && !state.busy && !copyMode && !answerPending, placeholder: state.screen === 'path' ? 'Absolute directory path on host' : 'Message, @host-file, or /help' }), referenceOpen && _jsx(ReferenceMenu, { matches: matches, index: referenceIndex })] }), commandSuggestions && _jsx(Text, { dimColor: true, children: commandSuggestions.join(' ') }), help && _jsx(HelpPanel, { page: currentHelpPage, pages: helpPages, pageSize: helpPageSize }), costExpanded && _jsx(CostPanel, { controller: controller }), _jsx(Frozen, { frozen: statusFrozen, identity: `${width}:${state.sessionId}:${statusExpanded}:${statusScroll}:${pauseReason ?? ''}`, children: _jsx(StatusBar, { controller: controller, width: width, expanded: statusExpanded, scroll: statusScroll, pageSize: statusViewRows, onScroll: setStatusScroll, onOverflow: setStatusOverflow, onRows: setStatusBarRows, pauseReason: pauseReason, revision: state.version }) })] })] }) }) }) });
790
832
  }
@@ -264,14 +264,17 @@ export const StatusBar = memo(function StatusBar({ controller, expanded = false,
264
264
  : phase.kind === 'tool' ? `${phase.name ?? 'tool'} ${phaseText(now - phase.startedAt)}`
265
265
  : `${phase.kind === 'thinking' ? 'think' : 'write'} ${phaseText(now - phase.startedAt)}`;
266
266
  // The state token reports a fact and never guesses: a paused clock is named, offline and errors
267
- // take the token over, and an unknown phase simply leaves the phase group empty.
267
+ // take the token over, and an unknown phase simply leaves the phase group empty. An answer this
268
+ // client still owes outranks the paused reason, because that reason is only why the clock stopped.
268
269
  const stateToken = !state.online
269
270
  ? { text: '! Offline', color: theme.status.offline }
270
271
  : state.controlError || state.modelError
271
272
  ? { text: '⚠ Error', color: theme.status.warning }
272
- : pauseReason !== undefined
273
- ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
274
- : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
273
+ : state.pending.length > 0
274
+ ? { text: '? Needs you', color: theme.status.critical }
275
+ : pauseReason !== undefined
276
+ ? { text: `⏸ ${pauseReason}${clock}`, color: theme.colors.muted }
277
+ : running ? { text: `◐${clock}`, color: theme.status.working } : { text: '● Ready', color: theme.status.ready };
275
278
  // One marker covers both scopes, because either an unpriceable record or a scan that has not
276
279
  // covered every session makes the pair inexact as a reading.
277
280
  const inexact = coverage !== 'complete';
@@ -334,9 +337,11 @@ const StatusDetails = memo(function StatusDetails({ controller, theme, width, no
334
337
  const turns = count(numeric(record(view.values.sessionStats).turns));
335
338
  const duration = since === undefined ? 'unknown duration' : elapsedTime(now - since);
336
339
  const detail = [
337
- { key: 'activity', color: running ? theme.colors.context : theme.colors.muted, text: running
338
- ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
339
- : '● Ready · Ctrl+C exit' },
340
+ { key: 'activity', color: state.pending.length > 0 ? theme.status.critical : running ? theme.colors.context : theme.colors.muted, text: state.pending.length > 0
341
+ ? '? Needs you · answer the request above to continue'
342
+ : running
343
+ ? `◐ Working · ${duration}${state.transcript.activeTurnStartedAt === undefined ? ' (observed)' : ''} · Ctrl+C Stop`
344
+ : '● Ready · Ctrl+C exit' },
340
345
  { key: 'host', text: `${safeText(controller.base)} · ${safeText(state.status)}${!state.online ? ' · offline, last known status' : ''}` },
341
346
  ...state.sessionId
342
347
  ? [{ key: 'session', text: `Session ${safeText(state.sessionId)}${controller.sessionMode ? ` · ${safeText(controller.sessionMode)}` : ''}` }] : [],
@@ -63,15 +63,19 @@ export declare function SearchResultsDialog({ query, items, hasMore, width, enab
63
63
  onClose(): void;
64
64
  }): import("react").JSX.Element;
65
65
  /** Title, current selection and confirm action for a picker screen.
66
- * @param props - Screen title, list identity and navigation state.
66
+ * @param props - Screen title, list identity, navigation state, usable width and marker key.
67
67
  * @returns The picker screen.
68
68
  */
69
- export declare function PickerScreen({ title, identity, choices, enabled, canSelect }: {
69
+ export declare function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }: {
70
70
  title: string;
71
71
  identity: string;
72
72
  choices: Choice[];
73
73
  enabled: boolean;
74
74
  canSelect(): boolean;
75
+ /** Columns the list may occupy inside the composer frame. */
76
+ width?: number;
77
+ /** Marker key shown above the list, for a rollup too compact to spell its states out. */
78
+ legend?: string;
75
79
  }): import("react").JSX.Element;
76
80
  /** Newest-first reasoning summaries with the preceding user prompt.
77
81
  * @param props - Loaded entries and lazy paging action.
@@ -64,11 +64,11 @@ export function SearchResultsDialog({ query, items, hasMore, width, enabled, can
64
64
  action: () => onOpen(string(item.sessionId)) })), { key: 'close', label: '← Back', action: onClose }], enabled: enabled, canSelect: canSelect })] });
65
65
  }
66
66
  /** Title, current selection and confirm action for a picker screen.
67
- * @param props - Screen title, list identity and navigation state.
67
+ * @param props - Screen title, list identity, navigation state, usable width and marker key.
68
68
  * @returns The picker screen.
69
69
  */
70
- export function PickerScreen({ title, identity, choices, enabled, canSelect }) {
71
- return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: title }), _jsx(Picker, { choices: choices, enabled: enabled, canSelect: canSelect }, identity)] });
70
+ export function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }) {
71
+ return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: title }), legend !== undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: legend }), _jsx(Picker, { choices: choices, enabled: enabled, canSelect: canSelect, width: width }, identity)] });
72
72
  }
73
73
  /** Newest-first reasoning summaries with the preceding user prompt.
74
74
  * @param props - Loaded entries and lazy paging action.