@agi-fans/dsh-tui 0.1.0 → 0.2.0
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.md +2 -2
- package/lib/autocomplete.js +0 -2
- package/lib/autocomplete.js.map +1 -1
- package/lib/command-auth.d.ts +20 -0
- package/lib/command-auth.js +225 -0
- package/lib/command-auth.js.map +1 -0
- package/lib/{command-queue.d.ts → command-export.d.ts} +2 -2
- package/lib/command-export.js +39 -0
- package/lib/command-export.js.map +1 -0
- package/lib/command-model.js +23 -12
- package/lib/command-model.js.map +1 -1
- package/lib/{command-transcript.d.ts → command-steer.d.ts} +2 -2
- package/lib/command-steer.js +18 -0
- package/lib/command-steer.js.map +1 -0
- package/lib/definition.d.ts +25 -1
- package/lib/event-views.d.ts +23 -8
- package/lib/event-views.js +207 -47
- package/lib/event-views.js.map +1 -1
- package/lib/hotkeys.d.ts +2 -5
- package/lib/hotkeys.js +4 -14
- package/lib/hotkeys.js.map +1 -1
- package/lib/prompt-selector.d.ts +2 -0
- package/lib/prompt-selector.js +35 -13
- package/lib/prompt-selector.js.map +1 -1
- package/lib/provider-local.d.ts +6 -3
- package/lib/provider-local.js +180 -44
- package/lib/provider-local.js.map +1 -1
- package/lib/renderer.js +11 -1
- package/lib/renderer.js.map +1 -1
- package/lib/runner.js +32 -4
- package/lib/runner.js.map +1 -1
- package/lib/session-controller.d.ts +27 -3
- package/lib/session-controller.js +200 -7
- package/lib/session-controller.js.map +1 -1
- package/lib/welcome-tips.js +2 -1
- package/lib/welcome-tips.js.map +1 -1
- package/package.json +12 -7
- package/lib/command-queue.js +0 -62
- package/lib/command-queue.js.map +0 -1
- package/lib/command-transcript.js +0 -85
- package/lib/command-transcript.js.map +0 -1
- package/lib/pwd.d.ts +0 -13
- package/lib/pwd.js +0 -14
- package/lib/pwd.js.map +0 -1
package/lib/event-views.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a terminal.
|
|
9
9
|
* @module @agi-fans/dsh-tui
|
|
10
10
|
*/
|
|
11
|
-
import type { CallId } from '@deepseek-ai/dsh-llm';
|
|
11
|
+
import type { CallId, UserMessage } from '@deepseek-ai/dsh-llm';
|
|
12
12
|
import type { SessionEvent } from '@deepseek-ai/dsh-session';
|
|
13
13
|
import type { AutocompleteItem, SlashCommand } from './autocomplete.ts';
|
|
14
14
|
import { type HistorySearchState } from './history-search.ts';
|
|
@@ -18,10 +18,9 @@ import { type SettingsState } from './settings-list.ts';
|
|
|
18
18
|
import { type PromptSelectorState } from './prompt-selector.ts';
|
|
19
19
|
import { type StatusBarConfig, type StatusPreset } from './status-config.ts';
|
|
20
20
|
import { type Theme, type ThemeName } from './theme.ts';
|
|
21
|
-
import type { TuiRecentSession, TuiSessionControls, TuiSessionStats } from './definition.ts';
|
|
21
|
+
import type { TuiRecentSession, TuiSessionControls, TuiSessionStats, TuiSubmission } from './definition.ts';
|
|
22
22
|
import { type TuiToolPresentation } from './tool-renderers.ts';
|
|
23
23
|
import { type ToolInfo } from './tools-list.ts';
|
|
24
|
-
import { type HotkeyBindings } from './hotkeys.ts';
|
|
25
24
|
import type { WelcomeTip } from './welcome-tips.ts';
|
|
26
25
|
/** Display state of one tool invocation. */
|
|
27
26
|
export type ToolBlockStatus = 'running' | 'ok' | 'error';
|
|
@@ -48,9 +47,6 @@ export type Block = {
|
|
|
48
47
|
} | {
|
|
49
48
|
kind: 'toolCatalog';
|
|
50
49
|
tools: readonly ToolInfo[];
|
|
51
|
-
} | {
|
|
52
|
-
kind: 'hotkeyCatalog';
|
|
53
|
-
bindings: HotkeyBindings;
|
|
54
50
|
} | {
|
|
55
51
|
kind: 'commandOutput';
|
|
56
52
|
command: string;
|
|
@@ -59,9 +55,10 @@ export type Block = {
|
|
|
59
55
|
kind: 'notice';
|
|
60
56
|
level: 'info' | 'error';
|
|
61
57
|
text: string;
|
|
58
|
+
framed?: boolean;
|
|
62
59
|
};
|
|
63
|
-
/** Live session
|
|
64
|
-
export type SessionStatus = 'idle' | 'running';
|
|
60
|
+
/** Live session activity controlling the composer and activity row. */
|
|
61
|
+
export type SessionStatus = 'idle' | 'running' | 'compacting';
|
|
65
62
|
/** Mutable-free transcript state produced by applyEvent. */
|
|
66
63
|
export interface TranscriptState {
|
|
67
64
|
/** Ordered display blocks (user, assistant, tool). */
|
|
@@ -70,6 +67,12 @@ export interface TranscriptState {
|
|
|
70
67
|
status: SessionStatus;
|
|
71
68
|
/** The most recent turn number. */
|
|
72
69
|
turn: number;
|
|
70
|
+
/** Lifecycle id of a manual compact command currently owning the UI. */
|
|
71
|
+
compactCommandId: string | undefined;
|
|
72
|
+
/** Durable follow-up turns waiting in the Harness-owned agent inbox. */
|
|
73
|
+
nextTurnInbox: UserMessage[];
|
|
74
|
+
/** Durable steering/context waiting for a later step (kept for splice fidelity). */
|
|
75
|
+
nextStepInbox: UserMessage[];
|
|
73
76
|
}
|
|
74
77
|
/** Empty starting state. */
|
|
75
78
|
export declare function initialTranscript(): TranscriptState;
|
|
@@ -80,6 +83,12 @@ export declare function initialTranscript(): TranscriptState;
|
|
|
80
83
|
* @returns the next state.
|
|
81
84
|
*/
|
|
82
85
|
export declare function applyEvent(state: TranscriptState, event: SessionEvent, presentation?: TuiToolPresentation): TranscriptState;
|
|
86
|
+
/**
|
|
87
|
+
* Replay one immutable event log without repeatedly copying its growing block
|
|
88
|
+
* array. The mutable array is private to this fold and becomes readonly when
|
|
89
|
+
* the completed state escapes.
|
|
90
|
+
*/
|
|
91
|
+
export declare function replayEvents(events: readonly SessionEvent[], presentations?: ReadonlyMap<number, TuiToolPresentation>): TranscriptState;
|
|
83
92
|
/** View options: terminal geometry and live input state. */
|
|
84
93
|
export interface ViewOptions {
|
|
85
94
|
/** Terminal width in columns. */
|
|
@@ -96,6 +105,8 @@ export interface ViewOptions {
|
|
|
96
105
|
inputCursor: number;
|
|
97
106
|
/** Number of client-owned image drafts represented by input markers. */
|
|
98
107
|
inputImages?: number;
|
|
108
|
+
/** Composer submissions accepted while the active turn is still running. */
|
|
109
|
+
queuedSubmissions?: readonly TuiSubmission[];
|
|
99
110
|
/** Whether to emit color SGR sequences. */
|
|
100
111
|
colors: boolean;
|
|
101
112
|
/** Working directory shown in the footer. */
|
|
@@ -178,6 +189,10 @@ export declare const TRANSCRIPT_WHEEL_SCROLL = 3;
|
|
|
178
189
|
export declare function windowTranscript(body: readonly string[], budget: number, start: number, theme: Theme): TranscriptScroll & {
|
|
179
190
|
lines: string[];
|
|
180
191
|
};
|
|
192
|
+
/** Maximum number of queued composer submissions kept visible above the editor. */
|
|
193
|
+
export declare const QUEUED_SUBMISSION_PREVIEW = 3;
|
|
194
|
+
/** Compact, unframed pending-message view placed immediately above the composer. */
|
|
195
|
+
export declare function renderQueuedSubmissions(submissions: readonly TuiSubmission[], theme: Theme, width: number, inbox?: readonly UserMessage[]): string[];
|
|
181
196
|
/**
|
|
182
197
|
* Compose the welcome card, transcript, working row, and rounded editor into
|
|
183
198
|
* one frame — the oh-my-pi surface.
|
package/lib/event-views.js
CHANGED
|
@@ -21,12 +21,18 @@ import { createTheme, SPINNER, SYMBOL } from "./theme.js";
|
|
|
21
21
|
import { padToWidth, truncateToWidth, visibleWidth, wrapText } from "./width.js";
|
|
22
22
|
import { renderTool } from "./tool-renderers.js";
|
|
23
23
|
import { renderToolsPanel } from "./tools-list.js";
|
|
24
|
-
import { renderHotkeysPanel } from "./hotkeys.js";
|
|
25
24
|
import { renderCommandOutput, renderCommandSeparator } from "./command-output.js";
|
|
26
25
|
import { renderPathMentionRows } from "./path-mentions.js";
|
|
27
26
|
/** Empty starting state. */
|
|
28
27
|
export function initialTranscript() {
|
|
29
|
-
return {
|
|
28
|
+
return {
|
|
29
|
+
blocks: [],
|
|
30
|
+
status: 'idle',
|
|
31
|
+
turn: 0,
|
|
32
|
+
compactCommandId: undefined,
|
|
33
|
+
nextTurnInbox: [],
|
|
34
|
+
nextStepInbox: [],
|
|
35
|
+
};
|
|
30
36
|
}
|
|
31
37
|
/** Extract plain text from text blocks, ignoring other block kinds. */
|
|
32
38
|
function contentToText(content) {
|
|
@@ -66,8 +72,11 @@ function streamingBlock(state, turn, step) {
|
|
|
66
72
|
return undefined;
|
|
67
73
|
}
|
|
68
74
|
/** Replace the trailing streaming block with a settled one, or append. */
|
|
69
|
-
function
|
|
70
|
-
|
|
75
|
+
function editableBlocks(state, mutable) {
|
|
76
|
+
return mutable ? state.blocks : state.blocks.slice();
|
|
77
|
+
}
|
|
78
|
+
function settleAssistant(state, turn, step, text, reasoning, mutable) {
|
|
79
|
+
const blocks = editableBlocks(state, mutable);
|
|
71
80
|
const last = blocks[blocks.length - 1];
|
|
72
81
|
if (last?.kind === 'assistant' && last.streaming && last.turn === turn && last.step === step) {
|
|
73
82
|
blocks[blocks.length - 1] = { kind: 'assistant', turn, step, text, reasoning, streaming: false };
|
|
@@ -84,23 +93,41 @@ function settleAssistant(state, turn, step, text, reasoning) {
|
|
|
84
93
|
* @returns the next state.
|
|
85
94
|
*/
|
|
86
95
|
export function applyEvent(state, event, presentation) {
|
|
96
|
+
return foldEvent(state, event, presentation, false);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Replay one immutable event log without repeatedly copying its growing block
|
|
100
|
+
* array. The mutable array is private to this fold and becomes readonly when
|
|
101
|
+
* the completed state escapes.
|
|
102
|
+
*/
|
|
103
|
+
export function replayEvents(events, presentations) {
|
|
104
|
+
let state = initialTranscript();
|
|
105
|
+
const indexes = { toolByCallId: new Map() };
|
|
106
|
+
for (const event of events)
|
|
107
|
+
state = foldEvent(state, event, presentations?.get(event.seq), true, indexes);
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
function foldEvent(state, event, presentation, mutable, indexes) {
|
|
87
111
|
switch (event.type) {
|
|
88
112
|
case 'turn/start':
|
|
89
|
-
return { ...state, status: 'running', turn: event.data.turn };
|
|
113
|
+
return { ...state, status: 'running', turn: event.data.turn, compactCommandId: undefined };
|
|
90
114
|
case 'turn/end': {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
115
|
+
const last = state.blocks[state.blocks.length - 1];
|
|
116
|
+
const reason = event.data.reason;
|
|
117
|
+
const changesBlocks = (last?.kind === 'assistant' && last.streaming)
|
|
118
|
+
|| reason.kind === 'error'
|
|
119
|
+
|| reason.kind === 'aborted';
|
|
120
|
+
const blocks = changesBlocks ? editableBlocks(state, mutable) : state.blocks;
|
|
93
121
|
if (last?.kind === 'assistant' && last.streaming) {
|
|
94
122
|
blocks[blocks.length - 1] = { ...last, streaming: false };
|
|
95
123
|
}
|
|
96
|
-
const reason = event.data.reason;
|
|
97
124
|
if (reason.kind === 'error') {
|
|
98
125
|
blocks.push({ kind: 'notice', level: 'error', text: 'error: ' + reason.error.code + ': ' + reason.error.message });
|
|
99
126
|
}
|
|
100
127
|
else if (reason.kind === 'aborted') {
|
|
101
128
|
blocks.push({ kind: 'notice', level: 'info', text: 'interrupted' });
|
|
102
129
|
}
|
|
103
|
-
return { ...state, blocks, status: 'idle' };
|
|
130
|
+
return { ...state, blocks, status: 'idle', compactCommandId: undefined };
|
|
104
131
|
}
|
|
105
132
|
case 'user/message': {
|
|
106
133
|
// Synthetic plugin injections (system-prompt runtime context, skill
|
|
@@ -111,12 +138,14 @@ export function applyEvent(state, event, presentation) {
|
|
|
111
138
|
const text = contentToText(event.data.content);
|
|
112
139
|
if (text === '')
|
|
113
140
|
return state;
|
|
114
|
-
|
|
141
|
+
const blocks = editableBlocks(state, mutable);
|
|
142
|
+
blocks.push({ kind: 'user', text });
|
|
143
|
+
return { ...state, blocks };
|
|
115
144
|
}
|
|
116
145
|
case 'assistant/chunk': {
|
|
117
146
|
const { turn, step, chunk } = event.data;
|
|
118
147
|
if (chunk.type === 'text-delta') {
|
|
119
|
-
const blocks = state
|
|
148
|
+
const blocks = editableBlocks(state, mutable);
|
|
120
149
|
const last = streamingBlock(state, turn, step);
|
|
121
150
|
if (last !== undefined) {
|
|
122
151
|
const idx = blocks.length - 1;
|
|
@@ -130,7 +159,7 @@ export function applyEvent(state, event, presentation) {
|
|
|
130
159
|
return { ...state, blocks };
|
|
131
160
|
}
|
|
132
161
|
if (chunk.type === 'reasoning-delta') {
|
|
133
|
-
const blocks = state
|
|
162
|
+
const blocks = editableBlocks(state, mutable);
|
|
134
163
|
const last = streamingBlock(state, turn, step);
|
|
135
164
|
if (last !== undefined) {
|
|
136
165
|
const idx = blocks.length - 1;
|
|
@@ -144,8 +173,10 @@ export function applyEvent(state, event, presentation) {
|
|
|
144
173
|
return { ...state, blocks };
|
|
145
174
|
}
|
|
146
175
|
if (chunk.type === 'tool-call-delta') {
|
|
147
|
-
const blocks = state
|
|
148
|
-
const index =
|
|
176
|
+
const blocks = editableBlocks(state, mutable);
|
|
177
|
+
const index = indexes === undefined
|
|
178
|
+
? blocks.findIndex(block => block.kind === 'tool' && block.callId === chunk.id)
|
|
179
|
+
: indexes.toolByCallId.get(chunk.id) ?? -1;
|
|
149
180
|
const existing = blocks[index];
|
|
150
181
|
if (existing?.kind === 'tool') {
|
|
151
182
|
blocks[index] = {
|
|
@@ -160,6 +191,7 @@ export function applyEvent(state, event, presentation) {
|
|
|
160
191
|
kind: 'tool', callId: chunk.id, name: chunk.name ?? 'tool',
|
|
161
192
|
args: chunk.argumentsDelta, status: 'running', output: '', partial: true,
|
|
162
193
|
});
|
|
194
|
+
indexes?.toolByCallId.set(chunk.id, blocks.length - 1);
|
|
163
195
|
}
|
|
164
196
|
return { ...state, blocks };
|
|
165
197
|
}
|
|
@@ -167,7 +199,7 @@ export function applyEvent(state, event, presentation) {
|
|
|
167
199
|
}
|
|
168
200
|
case 'assistant/message': {
|
|
169
201
|
const { turn, step, message } = event.data;
|
|
170
|
-
return settleAssistant(state, turn, step, contentToText(message.content), contentToReasoning(message.content));
|
|
202
|
+
return settleAssistant(state, turn, step, contentToText(message.content), contentToReasoning(message.content), mutable);
|
|
171
203
|
}
|
|
172
204
|
case 'tool/call': {
|
|
173
205
|
const block = {
|
|
@@ -179,16 +211,41 @@ export function applyEvent(state, event, presentation) {
|
|
|
179
211
|
output: '',
|
|
180
212
|
...(presentation === undefined ? {} : { presentation }),
|
|
181
213
|
};
|
|
182
|
-
const blocks = state
|
|
183
|
-
const partial =
|
|
214
|
+
const blocks = editableBlocks(state, mutable);
|
|
215
|
+
const partial = indexes === undefined
|
|
216
|
+
? blocks.findIndex(item => item.kind === 'tool' && item.callId === event.data.callId)
|
|
217
|
+
: indexes.toolByCallId.get(event.data.callId) ?? -1;
|
|
184
218
|
if (partial >= 0)
|
|
185
219
|
blocks[partial] = block;
|
|
186
|
-
else
|
|
220
|
+
else {
|
|
187
221
|
blocks.push(block);
|
|
222
|
+
indexes?.toolByCallId.set(event.data.callId, blocks.length - 1);
|
|
223
|
+
}
|
|
188
224
|
return { ...state, blocks };
|
|
189
225
|
}
|
|
190
226
|
case 'tool/result':
|
|
191
|
-
return applyToolResult(state, event.data.message, event.data.error, presentation);
|
|
227
|
+
return applyToolResult(state, event.data.message, event.data.error, presentation, mutable, indexes);
|
|
228
|
+
case 'command/run':
|
|
229
|
+
if (event.data.name !== 'compact')
|
|
230
|
+
return state;
|
|
231
|
+
return {
|
|
232
|
+
...state,
|
|
233
|
+
status: 'compacting',
|
|
234
|
+
compactCommandId: event.data.commandId,
|
|
235
|
+
};
|
|
236
|
+
case 'command/done':
|
|
237
|
+
if (state.compactCommandId !== event.data.commandId)
|
|
238
|
+
return state;
|
|
239
|
+
return { ...state, status: 'idle', compactCommandId: undefined };
|
|
240
|
+
case 'agent/inbox/spliced': {
|
|
241
|
+
const key = event.data.target === 'next-turn' ? 'nextTurnInbox' : 'nextStepInbox';
|
|
242
|
+
return {
|
|
243
|
+
...state,
|
|
244
|
+
[key]: state[key].toSpliced(event.data.start, event.data.removedCount ?? 0, ...event.data.inserted),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
case 'session/end-seed':
|
|
248
|
+
return { ...state, nextTurnInbox: [], nextStepInbox: [] };
|
|
192
249
|
// Log-only vocabulary (boundaries, usage, compaction, approvals, ...):
|
|
193
250
|
// nothing to display; the recognized core events above own the surface.
|
|
194
251
|
default:
|
|
@@ -196,27 +253,38 @@ export function applyEvent(state, event, presentation) {
|
|
|
196
253
|
}
|
|
197
254
|
}
|
|
198
255
|
/** Fold one tool result into its tool block. */
|
|
199
|
-
function applyToolResult(state, message, error, presentation) {
|
|
256
|
+
function applyToolResult(state, message, error, presentation, mutable, indexes) {
|
|
200
257
|
// A tool-result message carries exactly one tool-result block; the call
|
|
201
258
|
// identity and outcome live on that inner block.
|
|
202
259
|
const inner = message.content[0];
|
|
203
260
|
if (inner?.type !== 'tool-result')
|
|
204
261
|
return state;
|
|
205
|
-
const blocks = state
|
|
262
|
+
const blocks = editableBlocks(state, mutable);
|
|
263
|
+
const indexed = indexes?.toolByCallId.get(inner.toolCallId);
|
|
264
|
+
if (indexed !== undefined) {
|
|
265
|
+
const block = blocks[indexed];
|
|
266
|
+
if (block?.kind === 'tool') {
|
|
267
|
+
blocks[indexed] = settleTool(block, inner, error, presentation);
|
|
268
|
+
return { ...state, blocks };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
206
271
|
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
|
207
272
|
const block = blocks[i];
|
|
208
273
|
if (block?.kind === 'tool' && block.callId === inner.toolCallId) {
|
|
209
|
-
blocks[i] =
|
|
210
|
-
...block,
|
|
211
|
-
status: error !== undefined || inner.isError === true ? 'error' : 'ok',
|
|
212
|
-
output: contentToText(inner.content),
|
|
213
|
-
...(presentation === undefined ? {} : { presentation }),
|
|
214
|
-
};
|
|
274
|
+
blocks[i] = settleTool(block, inner, error, presentation);
|
|
215
275
|
return { ...state, blocks };
|
|
216
276
|
}
|
|
217
277
|
}
|
|
218
278
|
return state;
|
|
219
279
|
}
|
|
280
|
+
function settleTool(block, result, error, presentation) {
|
|
281
|
+
return {
|
|
282
|
+
...block,
|
|
283
|
+
status: error !== undefined || result.isError === true ? 'error' : 'ok',
|
|
284
|
+
output: contentToText(result.content),
|
|
285
|
+
...(presentation === undefined ? {} : { presentation }),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
220
288
|
/** Collapsed tool-output preview height (OMP `DEFAULT_TERMINAL_PREVIEW_LINES`). */
|
|
221
289
|
export const TOOL_COLLAPSED_LINES = 10;
|
|
222
290
|
/** OMP AssistantMessage uses one horizontal cell of padding and no vertical padding. */
|
|
@@ -307,14 +375,13 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
|
|
|
307
375
|
return toolBlockLines(block, theme, width, spinnerFrame, toolsExpanded);
|
|
308
376
|
if (block.kind === 'toolCatalog')
|
|
309
377
|
return renderToolsPanel(block.tools, theme, width, toolsExpanded);
|
|
310
|
-
if (block.kind === 'hotkeyCatalog')
|
|
311
|
-
return renderHotkeysPanel(block.bindings, theme, width);
|
|
312
378
|
if (block.kind === 'commandOutput')
|
|
313
379
|
return renderCommandOutput(block.command, block.text, theme, width);
|
|
314
|
-
if (block.
|
|
380
|
+
if (block.framed !== true) {
|
|
315
381
|
const prefix = ' ';
|
|
316
382
|
const continuation = ' '.repeat(visibleWidth(prefix));
|
|
317
|
-
|
|
383
|
+
const tone = block.level === 'error' ? 'error' : 'dim';
|
|
384
|
+
return wrapText(block.text, Math.max(1, width - visibleWidth(prefix))).map((line, index) => truncateToWidth((index === 0 ? prefix : continuation) + theme.fg(tone, line), width));
|
|
318
385
|
}
|
|
319
386
|
const state = block.level === 'error' ? 'error' : 'info';
|
|
320
387
|
const paint = (text) => (block.level === 'error' ? theme.fg('error', text) : theme.fg('dim', text));
|
|
@@ -336,23 +403,52 @@ function fitFrame(lines, width) {
|
|
|
336
403
|
});
|
|
337
404
|
}
|
|
338
405
|
/**
|
|
339
|
-
* Scroll
|
|
340
|
-
*
|
|
341
|
-
* rows instead of formatting the complete
|
|
406
|
+
* Scroll, activity, inbox, and composer changes do not alter transcript blocks.
|
|
407
|
+
* Cache the expensive Markdown/tool fold by immutable block-array identity so
|
|
408
|
+
* those frames slice already-rendered rows instead of formatting the complete
|
|
409
|
+
* session again. Spinner frames matter only while a visible tool block runs.
|
|
342
410
|
*/
|
|
343
411
|
const transcriptBodyCache = new WeakMap();
|
|
412
|
+
/** Settled immutable blocks keep their expensive Markdown/tool layout. */
|
|
413
|
+
const blockLinesCache = new WeakMap();
|
|
414
|
+
function cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded) {
|
|
415
|
+
const animatedSpinnerFrame = block.kind === 'tool' && block.status === 'running' ? spinnerFrame : -1;
|
|
416
|
+
const cached = blockLinesCache.get(block);
|
|
417
|
+
if (cached !== undefined
|
|
418
|
+
&& cached.width === options.width
|
|
419
|
+
&& cached.colors === options.colors
|
|
420
|
+
&& cached.trueColor === trueColor
|
|
421
|
+
&& cached.themeName === themeName
|
|
422
|
+
&& cached.spinnerFrame === animatedSpinnerFrame
|
|
423
|
+
&& cached.expanded === expanded)
|
|
424
|
+
return cached.lines;
|
|
425
|
+
const lines = blockLines(block, theme, options.width, spinnerFrame, expanded);
|
|
426
|
+
blockLinesCache.set(block, {
|
|
427
|
+
width: options.width,
|
|
428
|
+
colors: options.colors,
|
|
429
|
+
trueColor,
|
|
430
|
+
themeName,
|
|
431
|
+
spinnerFrame: animatedSpinnerFrame,
|
|
432
|
+
expanded,
|
|
433
|
+
lines,
|
|
434
|
+
});
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
344
437
|
function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
345
438
|
const toolsExpanded = options.toolsExpanded === true;
|
|
346
439
|
const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
|
|
347
440
|
const themeName = options.themeName ?? 'dark';
|
|
348
441
|
const trueColor = options.trueColor === true;
|
|
349
|
-
const
|
|
442
|
+
const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
|
|
443
|
+
? spinnerFrame
|
|
444
|
+
: -1;
|
|
445
|
+
const cached = transcriptBodyCache.get(state.blocks);
|
|
350
446
|
if (cached !== undefined
|
|
351
447
|
&& cached.width === options.width
|
|
352
448
|
&& cached.colors === options.colors
|
|
353
449
|
&& cached.trueColor === trueColor
|
|
354
450
|
&& cached.themeName === themeName
|
|
355
|
-
&& cached.spinnerFrame ===
|
|
451
|
+
&& cached.spinnerFrame === animatedSpinnerFrame
|
|
356
452
|
&& cached.toolsExpanded === toolsExpanded
|
|
357
453
|
&& cached.expandedTools === expandedTools) {
|
|
358
454
|
return { lines: cached.lines, blockStarts: cached.blockStarts };
|
|
@@ -373,15 +469,15 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
|
373
469
|
}
|
|
374
470
|
blockStarts.push(lines.length);
|
|
375
471
|
const expanded = toolsExpanded || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
|
|
376
|
-
lines.push(...
|
|
472
|
+
lines.push(...cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded));
|
|
377
473
|
previous = block;
|
|
378
474
|
}
|
|
379
|
-
transcriptBodyCache.set(state, {
|
|
475
|
+
transcriptBodyCache.set(state.blocks, {
|
|
380
476
|
width: options.width,
|
|
381
477
|
colors: options.colors,
|
|
382
478
|
trueColor,
|
|
383
479
|
themeName,
|
|
384
|
-
spinnerFrame,
|
|
480
|
+
spinnerFrame: animatedSpinnerFrame,
|
|
385
481
|
toolsExpanded,
|
|
386
482
|
expandedTools,
|
|
387
483
|
lines,
|
|
@@ -392,8 +488,6 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
|
|
|
392
488
|
function commandSurfaceName(block) {
|
|
393
489
|
if (block?.kind === 'toolCatalog')
|
|
394
490
|
return 'tools';
|
|
395
|
-
if (block?.kind === 'hotkeyCatalog')
|
|
396
|
-
return 'hotkeys';
|
|
397
491
|
if (block?.kind === 'commandOutput')
|
|
398
492
|
return block.command;
|
|
399
493
|
return undefined;
|
|
@@ -507,6 +601,63 @@ function paintImageMarkerSlice(fullText, slice, sourceStart, theme) {
|
|
|
507
601
|
output += fullText.slice(cursor, sourceEnd);
|
|
508
602
|
return output;
|
|
509
603
|
}
|
|
604
|
+
/** Maximum number of queued composer submissions kept visible above the editor. */
|
|
605
|
+
export const QUEUED_SUBMISSION_PREVIEW = 3;
|
|
606
|
+
function queuedSubmissionLabel(submission) {
|
|
607
|
+
const text = submission.text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
|
|
608
|
+
.replaceAll('\n', ' ↵ ')
|
|
609
|
+
.replace(/\s+/gu, ' ')
|
|
610
|
+
.trim();
|
|
611
|
+
if (text !== '')
|
|
612
|
+
return text;
|
|
613
|
+
const count = submission.images.length;
|
|
614
|
+
return count === 0 ? '(empty message)' : `${count} image${count === 1 ? '' : 's'}`;
|
|
615
|
+
}
|
|
616
|
+
function queuedMessageLabel(message) {
|
|
617
|
+
const text = contentToText(message.content).replaceAll('\r\n', '\n').replaceAll('\r', '\n')
|
|
618
|
+
.replaceAll('\n', ' ↵ ')
|
|
619
|
+
.replace(/\s+/gu, ' ')
|
|
620
|
+
.trim();
|
|
621
|
+
if (text !== '')
|
|
622
|
+
return text;
|
|
623
|
+
const count = message.content.filter(block => block.type === 'image').length;
|
|
624
|
+
return count === 0 ? '(empty message)' : `${count} image${count === 1 ? '' : 's'}`;
|
|
625
|
+
}
|
|
626
|
+
/** Compact, unframed pending-message view placed immediately above the composer. */
|
|
627
|
+
export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
|
|
628
|
+
const labels = [
|
|
629
|
+
...inbox.filter(message => message.source.kind === 'user').map(queuedMessageLabel),
|
|
630
|
+
...submissions.map(queuedSubmissionLabel),
|
|
631
|
+
];
|
|
632
|
+
if (labels.length === 0 || width <= 0)
|
|
633
|
+
return [];
|
|
634
|
+
const start = Math.max(0, labels.length - QUEUED_SUBMISSION_PREVIEW);
|
|
635
|
+
const hidden = start;
|
|
636
|
+
const queueLabel = theme.bold(theme.fg('accent', 'Queued'));
|
|
637
|
+
const rail = ' ' + theme.fg('border', '│') + ' ';
|
|
638
|
+
const alignAction = (left, action, preserveAction = true) => {
|
|
639
|
+
const leftWidth = visibleWidth(left);
|
|
640
|
+
const actionWidth = visibleWidth(action);
|
|
641
|
+
if (leftWidth + actionWidth + 2 <= width) {
|
|
642
|
+
return left + ' '.repeat(width - leftWidth - actionWidth) + action;
|
|
643
|
+
}
|
|
644
|
+
if (!preserveAction || actionWidth + 4 >= width)
|
|
645
|
+
return truncateToWidth(left, width);
|
|
646
|
+
const paintedLeft = truncateToWidth(left, width - actionWidth - 2);
|
|
647
|
+
return paintedLeft + ' ' + action;
|
|
648
|
+
};
|
|
649
|
+
if (labels.length === 1) {
|
|
650
|
+
return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
|
|
651
|
+
}
|
|
652
|
+
const summary = ` · ${labels.length}${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
|
|
653
|
+
const visibleLabels = labels.slice(start);
|
|
654
|
+
const lines = [
|
|
655
|
+
alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
|
|
656
|
+
...visibleLabels.map((label, index) => rail + theme.fg(index === visibleLabels.length - 1 ? 'accent' : 'dim', String(start + index + 1))
|
|
657
|
+
+ ' ' + theme.fg('text', label)),
|
|
658
|
+
];
|
|
659
|
+
return lines.map(line => truncateToWidth(line, width));
|
|
660
|
+
}
|
|
510
661
|
/**
|
|
511
662
|
* Compose the welcome card, transcript, working row, and rounded editor into
|
|
512
663
|
* one frame — the oh-my-pi surface.
|
|
@@ -577,7 +728,11 @@ export function renderView(state, options) {
|
|
|
577
728
|
transcriptStart = body.length;
|
|
578
729
|
body.push(...transcript.lines);
|
|
579
730
|
}
|
|
580
|
-
const working = state.status === 'running'
|
|
731
|
+
const working = state.status === 'running'
|
|
732
|
+
? renderWorking(theme, spinnerFrame, undefined, width)
|
|
733
|
+
: state.status === 'compacting'
|
|
734
|
+
? renderWorking(theme, spinnerFrame, 'Compacting', width)
|
|
735
|
+
: [];
|
|
581
736
|
const statusBar = resolveStatusBarConfig(options.statusBar, options.statusPreset);
|
|
582
737
|
const inlineHint = slashInlineHint(options.input, options.inputCursor, options.commands);
|
|
583
738
|
const statusFooter = renderStatusFooter({
|
|
@@ -595,7 +750,7 @@ export function renderView(state, options) {
|
|
|
595
750
|
input: options.input,
|
|
596
751
|
inputCursor: options.inputCursor,
|
|
597
752
|
status: ' ' + theme.fg('accent', '🐳') + ' ',
|
|
598
|
-
border: state.status === '
|
|
753
|
+
border: state.status === 'idle' ? 'border' : 'accent',
|
|
599
754
|
...(options.inputImages === undefined || options.inputImages === 0
|
|
600
755
|
? {}
|
|
601
756
|
: { paintInput: (text, start) => paintImageMarkerSlice(options.input, text, start, theme) }),
|
|
@@ -616,6 +771,9 @@ export function renderView(state, options) {
|
|
|
616
771
|
const editor = promptSelector === undefined && settings === undefined && copySelector === undefined && search === undefined
|
|
617
772
|
? renderEditor(editorOpts, theme)
|
|
618
773
|
: undefined;
|
|
774
|
+
const queuedSubmissions = editor === undefined
|
|
775
|
+
? []
|
|
776
|
+
: renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox);
|
|
619
777
|
const autocomplete = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined
|
|
620
778
|
|| options.autocomplete === undefined
|
|
621
779
|
? []
|
|
@@ -623,7 +781,7 @@ export function renderView(state, options) {
|
|
|
623
781
|
const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
|
|
624
782
|
?? (editor === undefined ? [] : editor.lines);
|
|
625
783
|
const spacer = 1;
|
|
626
|
-
const reserved = inputLines.length + working.length + spacer + autocomplete.length + statusFooter.length;
|
|
784
|
+
const reserved = inputLines.length + working.length + queuedSubmissions.length + spacer + autocomplete.length + statusFooter.length;
|
|
627
785
|
const budget = Math.max(0, height - reserved);
|
|
628
786
|
const focusStart = options.focusBlock === undefined
|
|
629
787
|
? undefined
|
|
@@ -636,10 +794,11 @@ export function renderView(state, options) {
|
|
|
636
794
|
const lines = [...visible];
|
|
637
795
|
if (visible.length > 0)
|
|
638
796
|
lines.push('');
|
|
639
|
-
const bottomRows = working.length + inputLines.length + autocomplete.length + statusFooter.length;
|
|
797
|
+
const bottomRows = working.length + queuedSubmissions.length + inputLines.length + autocomplete.length + statusFooter.length;
|
|
640
798
|
const fill = Math.max(0, height - lines.length - bottomRows);
|
|
641
799
|
lines.push(...Array.from({ length: fill }, () => ''));
|
|
642
800
|
lines.push(...working);
|
|
801
|
+
lines.push(...queuedSubmissions);
|
|
643
802
|
const editorStart = lines.length;
|
|
644
803
|
lines.push(...inputLines);
|
|
645
804
|
lines.push(...autocomplete);
|
|
@@ -652,8 +811,9 @@ export function renderView(state, options) {
|
|
|
652
811
|
row: editorStart + caret.row,
|
|
653
812
|
column: Math.min(caret.column, width),
|
|
654
813
|
},
|
|
655
|
-
cursorVisible:
|
|
656
|
-
|
|
814
|
+
cursorVisible: state.status === 'compacting'
|
|
815
|
+
? false
|
|
816
|
+
: promptSelector?.cursorVisible ?? (settings === undefined && copySelector === undefined),
|
|
657
817
|
...(promptSelector?.editor !== undefined
|
|
658
818
|
? { editor: { start: editorStart + promptSelector.editor.start, rows: promptSelector.editor.rows } }
|
|
659
819
|
: editor === undefined ? {} : { editor: { start: editorStart, rows: editor.lines.length } }),
|