@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.
- package/README.i18n.yaml +2 -2
- package/README.md +9 -9
- package/README.zh.md +6 -6
- package/dist/cli/dsht.d.ts +2 -0
- package/dist/cli/dsht.js +125 -0
- package/dist/cli/index.js +16 -123
- package/dist/controller/controller.d.ts +8 -0
- package/dist/controller/controller.js +12 -0
- package/dist/controller/perf-measures.d.ts +34 -0
- package/dist/controller/perf-measures.js +78 -0
- package/dist/session/controller.d.ts +16 -0
- package/dist/session/controller.js +33 -0
- package/dist/session/navigation.d.ts +62 -8
- package/dist/session/navigation.js +72 -13
- package/dist/ui/app.js +53 -11
- package/dist/ui/chat/status.js +12 -7
- package/dist/ui/dialogs/index.d.ts +6 -2
- package/dist/ui/dialogs/index.js +3 -3
- package/dist/ui/dialogs/picker.d.ts +21 -3
- package/dist/ui/dialogs/picker.js +37 -5
- package/dist/ui/input/input.d.ts +18 -3
- package/dist/ui/input/input.js +61 -22
- package/dist/ui/input/viewport.d.ts +96 -0
- package/dist/ui/input/viewport.js +173 -0
- package/package.json +3 -3
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
79
|
-
const counts = {
|
|
80
|
-
for (const session of sessions)
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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 =>
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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,
|
|
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 ?
|
|
789
|
-
|
|
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
|
}
|
package/dist/ui/chat/status.js
CHANGED
|
@@ -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
|
-
:
|
|
273
|
-
? { text:
|
|
274
|
-
:
|
|
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:
|
|
338
|
-
?
|
|
339
|
-
:
|
|
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
|
|
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.
|
package/dist/ui/dialogs/index.js
CHANGED
|
@@ -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
|
|
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.
|