@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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/lib/autocomplete.js +0 -2
  3. package/lib/autocomplete.js.map +1 -1
  4. package/lib/command-auth.d.ts +20 -0
  5. package/lib/command-auth.js +225 -0
  6. package/lib/command-auth.js.map +1 -0
  7. package/lib/{command-queue.d.ts → command-export.d.ts} +2 -2
  8. package/lib/command-export.js +39 -0
  9. package/lib/command-export.js.map +1 -0
  10. package/lib/command-model.js +23 -12
  11. package/lib/command-model.js.map +1 -1
  12. package/lib/{command-transcript.d.ts → command-steer.d.ts} +2 -2
  13. package/lib/command-steer.js +18 -0
  14. package/lib/command-steer.js.map +1 -0
  15. package/lib/definition.d.ts +25 -1
  16. package/lib/event-views.d.ts +23 -8
  17. package/lib/event-views.js +207 -47
  18. package/lib/event-views.js.map +1 -1
  19. package/lib/hotkeys.d.ts +2 -5
  20. package/lib/hotkeys.js +4 -14
  21. package/lib/hotkeys.js.map +1 -1
  22. package/lib/prompt-selector.d.ts +2 -0
  23. package/lib/prompt-selector.js +35 -13
  24. package/lib/prompt-selector.js.map +1 -1
  25. package/lib/provider-local.d.ts +6 -3
  26. package/lib/provider-local.js +180 -44
  27. package/lib/provider-local.js.map +1 -1
  28. package/lib/renderer.js +11 -1
  29. package/lib/renderer.js.map +1 -1
  30. package/lib/runner.js +32 -4
  31. package/lib/runner.js.map +1 -1
  32. package/lib/session-controller.d.ts +27 -3
  33. package/lib/session-controller.js +200 -7
  34. package/lib/session-controller.js.map +1 -1
  35. package/lib/welcome-tips.js +2 -1
  36. package/lib/welcome-tips.js.map +1 -1
  37. package/package.json +12 -7
  38. package/lib/command-queue.js +0 -62
  39. package/lib/command-queue.js.map +0 -1
  40. package/lib/command-transcript.js +0 -85
  41. package/lib/command-transcript.js.map +0 -1
  42. package/lib/pwd.d.ts +0 -13
  43. package/lib/pwd.js +0 -14
  44. package/lib/pwd.js.map +0 -1
@@ -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 status shown on the status line. */
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.
@@ -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 { blocks: [], status: 'idle', turn: 0 };
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 settleAssistant(state, turn, step, text, reasoning) {
70
- const blocks = state.blocks.slice();
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 blocks = state.blocks.slice();
92
- const last = blocks[blocks.length - 1];
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
- return { ...state, blocks: [...state.blocks, { kind: 'user', text }] };
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.blocks.slice();
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.blocks.slice();
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.blocks.slice();
148
- const index = blocks.findIndex(block => block.kind === 'tool' && block.callId === chunk.id);
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.blocks.slice();
183
- const partial = blocks.findIndex(item => item.kind === 'tool' && item.callId === event.data.callId);
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.blocks.slice();
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.level === 'info' && !block.text.includes('\n')) {
380
+ if (block.framed !== true) {
315
381
  const prefix = ' ';
316
382
  const continuation = ' '.repeat(visibleWidth(prefix));
317
- return wrapText(block.text, Math.max(1, width - visibleWidth(prefix))).map((line, index) => truncateToWidth((index === 0 ? prefix : continuation) + theme.fg('dim', line), width));
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 changes only the viewport. Cache the expensive Markdown/tool fold by
340
- * immutable TranscriptState identity so wheel frames slice already-rendered
341
- * rows instead of formatting the complete session again.
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 cached = transcriptBodyCache.get(state);
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 === 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(...blockLines(block, theme, options.width, spinnerFrame, expanded));
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' ? renderWorking(theme, spinnerFrame, undefined, width) : [];
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 === 'running' ? 'accent' : 'border',
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: promptSelector?.cursorVisible
656
- ?? (settings === undefined && copySelector === undefined),
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 } }),