@agi-fans/dsh-tui 0.1.1 → 0.3.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 (43) 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-queue.d.ts → command-export.d.ts} +2 -2
  5. package/lib/command-export.js +39 -0
  6. package/lib/command-export.js.map +1 -0
  7. package/lib/{command-transcript.d.ts → command-permission.d.ts} +2 -2
  8. package/lib/{command-mode.js → command-permission.js} +28 -21
  9. package/lib/command-permission.js.map +1 -0
  10. package/lib/command-session.js +1 -1
  11. package/lib/command-session.js.map +1 -1
  12. package/lib/{command-mode.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 +14 -0
  16. package/lib/event-views.d.ts +32 -8
  17. package/lib/event-views.js +249 -45
  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/provider-local.d.ts +4 -1
  23. package/lib/provider-local.js +171 -43
  24. package/lib/provider-local.js.map +1 -1
  25. package/lib/renderer.d.ts +2 -0
  26. package/lib/renderer.js +48 -25
  27. package/lib/renderer.js.map +1 -1
  28. package/lib/runner.js +28 -0
  29. package/lib/runner.js.map +1 -1
  30. package/lib/session-controller.d.ts +27 -3
  31. package/lib/session-controller.js +206 -8
  32. package/lib/session-controller.js.map +1 -1
  33. package/lib/welcome-tips.js +2 -2
  34. package/lib/welcome-tips.js.map +1 -1
  35. package/package.json +13 -12
  36. package/lib/command-mode.js.map +0 -1
  37. package/lib/command-queue.js +0 -62
  38. package/lib/command-queue.js.map +0 -1
  39. package/lib/command-transcript.js +0 -85
  40. package/lib/command-transcript.js.map +0 -1
  41. package/lib/pwd.d.ts +0 -13
  42. package/lib/pwd.js +0 -14
  43. package/lib/pwd.js.map +0 -1
@@ -21,12 +21,19 @@ 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
+ todos: [],
33
+ compactCommandId: undefined,
34
+ nextTurnInbox: [],
35
+ nextStepInbox: [],
36
+ };
30
37
  }
31
38
  /** Extract plain text from text blocks, ignoring other block kinds. */
32
39
  function contentToText(content) {
@@ -66,8 +73,11 @@ function streamingBlock(state, turn, step) {
66
73
  return undefined;
67
74
  }
68
75
  /** 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();
76
+ function editableBlocks(state, mutable) {
77
+ return mutable ? state.blocks : state.blocks.slice();
78
+ }
79
+ function settleAssistant(state, turn, step, text, reasoning, mutable) {
80
+ const blocks = editableBlocks(state, mutable);
71
81
  const last = blocks[blocks.length - 1];
72
82
  if (last?.kind === 'assistant' && last.streaming && last.turn === turn && last.step === step) {
73
83
  blocks[blocks.length - 1] = { kind: 'assistant', turn, step, text, reasoning, streaming: false };
@@ -84,23 +94,41 @@ function settleAssistant(state, turn, step, text, reasoning) {
84
94
  * @returns the next state.
85
95
  */
86
96
  export function applyEvent(state, event, presentation) {
97
+ return foldEvent(state, event, presentation, false);
98
+ }
99
+ /**
100
+ * Replay one immutable event log without repeatedly copying its growing block
101
+ * array. The mutable array is private to this fold and becomes readonly when
102
+ * the completed state escapes.
103
+ */
104
+ export function replayEvents(events, presentations) {
105
+ let state = initialTranscript();
106
+ const indexes = { toolByCallId: new Map() };
107
+ for (const event of events)
108
+ state = foldEvent(state, event, presentations?.get(event.seq), true, indexes);
109
+ return state;
110
+ }
111
+ function foldEvent(state, event, presentation, mutable, indexes) {
87
112
  switch (event.type) {
88
113
  case 'turn/start':
89
- return { ...state, status: 'running', turn: event.data.turn };
114
+ return { ...state, status: 'running', turn: event.data.turn, todos: [], compactCommandId: undefined };
90
115
  case 'turn/end': {
91
- const blocks = state.blocks.slice();
92
- const last = blocks[blocks.length - 1];
116
+ const last = state.blocks[state.blocks.length - 1];
117
+ const reason = event.data.reason;
118
+ const changesBlocks = (last?.kind === 'assistant' && last.streaming)
119
+ || reason.kind === 'error'
120
+ || reason.kind === 'aborted';
121
+ const blocks = changesBlocks ? editableBlocks(state, mutable) : state.blocks;
93
122
  if (last?.kind === 'assistant' && last.streaming) {
94
123
  blocks[blocks.length - 1] = { ...last, streaming: false };
95
124
  }
96
- const reason = event.data.reason;
97
125
  if (reason.kind === 'error') {
98
126
  blocks.push({ kind: 'notice', level: 'error', text: 'error: ' + reason.error.code + ': ' + reason.error.message });
99
127
  }
100
128
  else if (reason.kind === 'aborted') {
101
129
  blocks.push({ kind: 'notice', level: 'info', text: 'interrupted' });
102
130
  }
103
- return { ...state, blocks, status: 'idle' };
131
+ return { ...state, blocks, status: 'idle', compactCommandId: undefined };
104
132
  }
105
133
  case 'user/message': {
106
134
  // Synthetic plugin injections (system-prompt runtime context, skill
@@ -111,12 +139,14 @@ export function applyEvent(state, event, presentation) {
111
139
  const text = contentToText(event.data.content);
112
140
  if (text === '')
113
141
  return state;
114
- return { ...state, blocks: [...state.blocks, { kind: 'user', text }] };
142
+ const blocks = editableBlocks(state, mutable);
143
+ blocks.push({ kind: 'user', text });
144
+ return { ...state, blocks };
115
145
  }
116
146
  case 'assistant/chunk': {
117
147
  const { turn, step, chunk } = event.data;
118
148
  if (chunk.type === 'text-delta') {
119
- const blocks = state.blocks.slice();
149
+ const blocks = editableBlocks(state, mutable);
120
150
  const last = streamingBlock(state, turn, step);
121
151
  if (last !== undefined) {
122
152
  const idx = blocks.length - 1;
@@ -130,7 +160,7 @@ export function applyEvent(state, event, presentation) {
130
160
  return { ...state, blocks };
131
161
  }
132
162
  if (chunk.type === 'reasoning-delta') {
133
- const blocks = state.blocks.slice();
163
+ const blocks = editableBlocks(state, mutable);
134
164
  const last = streamingBlock(state, turn, step);
135
165
  if (last !== undefined) {
136
166
  const idx = blocks.length - 1;
@@ -144,8 +174,10 @@ export function applyEvent(state, event, presentation) {
144
174
  return { ...state, blocks };
145
175
  }
146
176
  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);
177
+ const blocks = editableBlocks(state, mutable);
178
+ const index = indexes === undefined
179
+ ? blocks.findIndex(block => block.kind === 'tool' && block.callId === chunk.id)
180
+ : indexes.toolByCallId.get(chunk.id) ?? -1;
149
181
  const existing = blocks[index];
150
182
  if (existing?.kind === 'tool') {
151
183
  blocks[index] = {
@@ -160,6 +192,7 @@ export function applyEvent(state, event, presentation) {
160
192
  kind: 'tool', callId: chunk.id, name: chunk.name ?? 'tool',
161
193
  args: chunk.argumentsDelta, status: 'running', output: '', partial: true,
162
194
  });
195
+ indexes?.toolByCallId.set(chunk.id, blocks.length - 1);
163
196
  }
164
197
  return { ...state, blocks };
165
198
  }
@@ -167,7 +200,7 @@ export function applyEvent(state, event, presentation) {
167
200
  }
168
201
  case 'assistant/message': {
169
202
  const { turn, step, message } = event.data;
170
- return settleAssistant(state, turn, step, contentToText(message.content), contentToReasoning(message.content));
203
+ return settleAssistant(state, turn, step, contentToText(message.content), contentToReasoning(message.content), mutable);
171
204
  }
172
205
  case 'tool/call': {
173
206
  const block = {
@@ -179,16 +212,43 @@ export function applyEvent(state, event, presentation) {
179
212
  output: '',
180
213
  ...(presentation === undefined ? {} : { presentation }),
181
214
  };
182
- const blocks = state.blocks.slice();
183
- const partial = blocks.findIndex(item => item.kind === 'tool' && item.callId === event.data.callId);
215
+ const blocks = editableBlocks(state, mutable);
216
+ const partial = indexes === undefined
217
+ ? blocks.findIndex(item => item.kind === 'tool' && item.callId === event.data.callId)
218
+ : indexes.toolByCallId.get(event.data.callId) ?? -1;
184
219
  if (partial >= 0)
185
220
  blocks[partial] = block;
186
- else
221
+ else {
187
222
  blocks.push(block);
223
+ indexes?.toolByCallId.set(event.data.callId, blocks.length - 1);
224
+ }
188
225
  return { ...state, blocks };
189
226
  }
190
227
  case 'tool/result':
191
- return applyToolResult(state, event.data.message, event.data.error, presentation);
228
+ return applyToolResult(state, event.data.message, event.data.error, presentation, mutable, indexes);
229
+ case 'todo/write':
230
+ return { ...state, todos: event.data.todos.map(todo => ({ ...todo })) };
231
+ case 'command/run':
232
+ if (event.data.name !== 'compact')
233
+ return state;
234
+ return {
235
+ ...state,
236
+ status: 'compacting',
237
+ compactCommandId: event.data.commandId,
238
+ };
239
+ case 'command/done':
240
+ if (state.compactCommandId !== event.data.commandId)
241
+ return state;
242
+ return { ...state, status: 'idle', compactCommandId: undefined };
243
+ case 'agent/inbox/spliced': {
244
+ const key = event.data.target === 'next-turn' ? 'nextTurnInbox' : 'nextStepInbox';
245
+ return {
246
+ ...state,
247
+ [key]: state[key].toSpliced(event.data.start, event.data.removedCount ?? 0, ...event.data.inserted),
248
+ };
249
+ }
250
+ case 'session/end-seed':
251
+ return { ...state, nextTurnInbox: [], nextStepInbox: [] };
192
252
  // Log-only vocabulary (boundaries, usage, compaction, approvals, ...):
193
253
  // nothing to display; the recognized core events above own the surface.
194
254
  default:
@@ -196,27 +256,38 @@ export function applyEvent(state, event, presentation) {
196
256
  }
197
257
  }
198
258
  /** Fold one tool result into its tool block. */
199
- function applyToolResult(state, message, error, presentation) {
259
+ function applyToolResult(state, message, error, presentation, mutable, indexes) {
200
260
  // A tool-result message carries exactly one tool-result block; the call
201
261
  // identity and outcome live on that inner block.
202
262
  const inner = message.content[0];
203
263
  if (inner?.type !== 'tool-result')
204
264
  return state;
205
- const blocks = state.blocks.slice();
265
+ const blocks = editableBlocks(state, mutable);
266
+ const indexed = indexes?.toolByCallId.get(inner.toolCallId);
267
+ if (indexed !== undefined) {
268
+ const block = blocks[indexed];
269
+ if (block?.kind === 'tool') {
270
+ blocks[indexed] = settleTool(block, inner, error, presentation);
271
+ return { ...state, blocks };
272
+ }
273
+ }
206
274
  for (let i = blocks.length - 1; i >= 0; i -= 1) {
207
275
  const block = blocks[i];
208
276
  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
- };
277
+ blocks[i] = settleTool(block, inner, error, presentation);
215
278
  return { ...state, blocks };
216
279
  }
217
280
  }
218
281
  return state;
219
282
  }
283
+ function settleTool(block, result, error, presentation) {
284
+ return {
285
+ ...block,
286
+ status: error !== undefined || result.isError === true ? 'error' : 'ok',
287
+ output: contentToText(result.content),
288
+ ...(presentation === undefined ? {} : { presentation }),
289
+ };
290
+ }
220
291
  /** Collapsed tool-output preview height (OMP `DEFAULT_TERMINAL_PREVIEW_LINES`). */
221
292
  export const TOOL_COLLAPSED_LINES = 10;
222
293
  /** OMP AssistantMessage uses one horizontal cell of padding and no vertical padding. */
@@ -307,8 +378,6 @@ export function blockLines(block, theme, width, spinnerFrame = 0, toolsExpanded
307
378
  return toolBlockLines(block, theme, width, spinnerFrame, toolsExpanded);
308
379
  if (block.kind === 'toolCatalog')
309
380
  return renderToolsPanel(block.tools, theme, width, toolsExpanded);
310
- if (block.kind === 'hotkeyCatalog')
311
- return renderHotkeysPanel(block.bindings, theme, width);
312
381
  if (block.kind === 'commandOutput')
313
382
  return renderCommandOutput(block.command, block.text, theme, width);
314
383
  if (block.framed !== true) {
@@ -337,23 +406,52 @@ function fitFrame(lines, width) {
337
406
  });
338
407
  }
339
408
  /**
340
- * Scroll changes only the viewport. Cache the expensive Markdown/tool fold by
341
- * immutable TranscriptState identity so wheel frames slice already-rendered
342
- * rows instead of formatting the complete session again.
409
+ * Scroll, activity, inbox, and composer changes do not alter transcript blocks.
410
+ * Cache the expensive Markdown/tool fold by immutable block-array identity so
411
+ * those frames slice already-rendered rows instead of formatting the complete
412
+ * session again. Spinner frames matter only while a visible tool block runs.
343
413
  */
344
414
  const transcriptBodyCache = new WeakMap();
415
+ /** Settled immutable blocks keep their expensive Markdown/tool layout. */
416
+ const blockLinesCache = new WeakMap();
417
+ function cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded) {
418
+ const animatedSpinnerFrame = block.kind === 'tool' && block.status === 'running' ? spinnerFrame : -1;
419
+ const cached = blockLinesCache.get(block);
420
+ if (cached !== undefined
421
+ && cached.width === options.width
422
+ && cached.colors === options.colors
423
+ && cached.trueColor === trueColor
424
+ && cached.themeName === themeName
425
+ && cached.spinnerFrame === animatedSpinnerFrame
426
+ && cached.expanded === expanded)
427
+ return cached.lines;
428
+ const lines = blockLines(block, theme, options.width, spinnerFrame, expanded);
429
+ blockLinesCache.set(block, {
430
+ width: options.width,
431
+ colors: options.colors,
432
+ trueColor,
433
+ themeName,
434
+ spinnerFrame: animatedSpinnerFrame,
435
+ expanded,
436
+ lines,
437
+ });
438
+ return lines;
439
+ }
345
440
  function renderTranscriptBody(state, options, theme, spinnerFrame) {
346
441
  const toolsExpanded = options.toolsExpanded === true;
347
442
  const expandedTools = [...(options.expandedTools ?? [])].sort().join('\0');
348
443
  const themeName = options.themeName ?? 'dark';
349
444
  const trueColor = options.trueColor === true;
350
- const cached = transcriptBodyCache.get(state);
445
+ const animatedSpinnerFrame = state.blocks.some(block => block.kind === 'tool' && block.status === 'running')
446
+ ? spinnerFrame
447
+ : -1;
448
+ const cached = transcriptBodyCache.get(state.blocks);
351
449
  if (cached !== undefined
352
450
  && cached.width === options.width
353
451
  && cached.colors === options.colors
354
452
  && cached.trueColor === trueColor
355
453
  && cached.themeName === themeName
356
- && cached.spinnerFrame === spinnerFrame
454
+ && cached.spinnerFrame === animatedSpinnerFrame
357
455
  && cached.toolsExpanded === toolsExpanded
358
456
  && cached.expandedTools === expandedTools) {
359
457
  return { lines: cached.lines, blockStarts: cached.blockStarts };
@@ -374,15 +472,15 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
374
472
  }
375
473
  blockStarts.push(lines.length);
376
474
  const expanded = toolsExpanded || (block.kind === 'tool' && options.expandedTools?.has(block.callId) === true);
377
- lines.push(...blockLines(block, theme, options.width, spinnerFrame, expanded));
475
+ lines.push(...cachedBlockLines(block, options, theme, themeName, trueColor, spinnerFrame, expanded));
378
476
  previous = block;
379
477
  }
380
- transcriptBodyCache.set(state, {
478
+ transcriptBodyCache.set(state.blocks, {
381
479
  width: options.width,
382
480
  colors: options.colors,
383
481
  trueColor,
384
482
  themeName,
385
- spinnerFrame,
483
+ spinnerFrame: animatedSpinnerFrame,
386
484
  toolsExpanded,
387
485
  expandedTools,
388
486
  lines,
@@ -393,8 +491,6 @@ function renderTranscriptBody(state, options, theme, spinnerFrame) {
393
491
  function commandSurfaceName(block) {
394
492
  if (block?.kind === 'toolCatalog')
395
493
  return 'tools';
396
- if (block?.kind === 'hotkeyCatalog')
397
- return 'hotkeys';
398
494
  if (block?.kind === 'commandOutput')
399
495
  return block.command;
400
496
  return undefined;
@@ -508,6 +604,103 @@ function paintImageMarkerSlice(fullText, slice, sourceStart, theme) {
508
604
  output += fullText.slice(cursor, sourceEnd);
509
605
  return output;
510
606
  }
607
+ /** Maximum number of queued composer submissions kept visible above the editor. */
608
+ export const QUEUED_SUBMISSION_PREVIEW = 3;
609
+ /** Maximum number of Todo items kept visible above the composer. */
610
+ export const TODO_PREVIEW = 5;
611
+ function todoPreviewStart(todos) {
612
+ if (todos.length <= TODO_PREVIEW)
613
+ return 0;
614
+ const active = todos.findIndex(todo => todo.status === 'in_progress');
615
+ if (active >= 0)
616
+ return active;
617
+ const pending = todos.findIndex(todo => todo.status === 'pending');
618
+ return pending >= 0 ? pending : Math.max(0, todos.length - TODO_PREVIEW);
619
+ }
620
+ /** Compact, unframed Todo tree placed above queued messages. */
621
+ export function renderTodos(todos, theme, width) {
622
+ if (todos.length === 0 || width <= 0)
623
+ return [];
624
+ const completed = todos.filter(todo => todo.status === 'completed').length;
625
+ const header = ' ' + theme.bold(theme.fg('accent', 'Todos'))
626
+ + theme.fg('dim', ` · ${completed}/${todos.length}`);
627
+ const start = todoPreviewStart(todos);
628
+ const end = Math.min(todos.length, start + TODO_PREVIEW);
629
+ const rows = [
630
+ ...(start === 0 ? [] : [`… ${start} earlier`]),
631
+ ...todos.slice(start, end),
632
+ ...(end === todos.length ? [] : [`… ${todos.length - end} more`]),
633
+ ];
634
+ const lines = [header, ...rows.map((row, index) => {
635
+ const branch = ' ' + theme.fg('dim', index === rows.length - 1 ? '└─' : '├─') + ' ';
636
+ if (typeof row === 'string')
637
+ return branch + theme.fg('dim', row);
638
+ const todo = row;
639
+ if (todo.status === 'completed') {
640
+ return branch + theme.fg('success', '☑ ' + theme.strikethrough(todo.content));
641
+ }
642
+ if (todo.status === 'in_progress') {
643
+ return branch + theme.fg('accent', '☐ ' + todo.content);
644
+ }
645
+ return branch + theme.fg('dim', '☐ ' + todo.content);
646
+ })];
647
+ return lines.map(line => truncateToWidth(line, width));
648
+ }
649
+ function queuedSubmissionLabel(submission) {
650
+ const text = submission.text.replaceAll('\r\n', '\n').replaceAll('\r', '\n')
651
+ .replaceAll('\n', ' ↵ ')
652
+ .replace(/\s+/gu, ' ')
653
+ .trim();
654
+ if (text !== '')
655
+ return text;
656
+ const count = submission.images.length;
657
+ return count === 0 ? '(empty message)' : `${count} image${count === 1 ? '' : 's'}`;
658
+ }
659
+ function queuedMessageLabel(message) {
660
+ const text = contentToText(message.content).replaceAll('\r\n', '\n').replaceAll('\r', '\n')
661
+ .replaceAll('\n', ' ↵ ')
662
+ .replace(/\s+/gu, ' ')
663
+ .trim();
664
+ if (text !== '')
665
+ return text;
666
+ const count = message.content.filter(block => block.type === 'image').length;
667
+ return count === 0 ? '(empty message)' : `${count} image${count === 1 ? '' : 's'}`;
668
+ }
669
+ /** Compact, unframed pending-message view placed immediately above the composer. */
670
+ export function renderQueuedSubmissions(submissions, theme, width, inbox = []) {
671
+ const labels = [
672
+ ...inbox.filter(message => message.source.kind === 'user').map(queuedMessageLabel),
673
+ ...submissions.map(queuedSubmissionLabel),
674
+ ];
675
+ if (labels.length === 0 || width <= 0)
676
+ return [];
677
+ const start = Math.max(0, labels.length - QUEUED_SUBMISSION_PREVIEW);
678
+ const hidden = start;
679
+ const queueLabel = theme.bold(theme.fg('accent', 'Queued'));
680
+ const rail = ' ' + theme.fg('border', '│') + ' ';
681
+ const alignAction = (left, action, preserveAction = true) => {
682
+ const leftWidth = visibleWidth(left);
683
+ const actionWidth = visibleWidth(action);
684
+ if (leftWidth + actionWidth + 2 <= width) {
685
+ return left + ' '.repeat(width - leftWidth - actionWidth) + action;
686
+ }
687
+ if (!preserveAction || actionWidth + 4 >= width)
688
+ return truncateToWidth(left, width);
689
+ const paintedLeft = truncateToWidth(left, width - actionWidth - 2);
690
+ return paintedLeft + ' ' + action;
691
+ };
692
+ if (labels.length === 1) {
693
+ return [alignAction(rail + queueLabel + theme.fg('dim', ' · ') + theme.fg('text', labels[0] ?? ''), theme.fg('dim', '↑ edit'))];
694
+ }
695
+ const summary = ` · ${labels.length}${hidden === 0 ? '' : ` · ${hidden} earlier`}`;
696
+ const visibleLabels = labels.slice(start);
697
+ const lines = [
698
+ alignAction(rail + queueLabel + theme.fg('dim', summary), theme.fg('dim', '↑ edit latest'), false),
699
+ ...visibleLabels.map((label, index) => rail + theme.fg(index === visibleLabels.length - 1 ? 'accent' : 'dim', String(start + index + 1))
700
+ + ' ' + theme.fg('text', label)),
701
+ ];
702
+ return lines.map(line => truncateToWidth(line, width));
703
+ }
511
704
  /**
512
705
  * Compose the welcome card, transcript, working row, and rounded editor into
513
706
  * one frame — the oh-my-pi surface.
@@ -578,7 +771,11 @@ export function renderView(state, options) {
578
771
  transcriptStart = body.length;
579
772
  body.push(...transcript.lines);
580
773
  }
581
- const working = state.status === 'running' ? renderWorking(theme, spinnerFrame, undefined, width) : [];
774
+ const working = state.status === 'running'
775
+ ? renderWorking(theme, spinnerFrame, undefined, width)
776
+ : state.status === 'compacting'
777
+ ? renderWorking(theme, spinnerFrame, 'Compacting', width)
778
+ : [];
582
779
  const statusBar = resolveStatusBarConfig(options.statusBar, options.statusPreset);
583
780
  const inlineHint = slashInlineHint(options.input, options.inputCursor, options.commands);
584
781
  const statusFooter = renderStatusFooter({
@@ -596,7 +793,7 @@ export function renderView(state, options) {
596
793
  input: options.input,
597
794
  inputCursor: options.inputCursor,
598
795
  status: ' ' + theme.fg('accent', '🐳') + ' ',
599
- border: state.status === 'running' ? 'accent' : 'border',
796
+ border: state.status === 'idle' ? 'border' : 'accent',
600
797
  ...(options.inputImages === undefined || options.inputImages === 0
601
798
  ? {}
602
799
  : { paintInput: (text, start) => paintImageMarkerSlice(options.input, text, start, theme) }),
@@ -617,6 +814,10 @@ export function renderView(state, options) {
617
814
  const editor = promptSelector === undefined && settings === undefined && copySelector === undefined && search === undefined
618
815
  ? renderEditor(editorOpts, theme)
619
816
  : undefined;
817
+ const queuedSubmissions = editor === undefined
818
+ ? []
819
+ : renderQueuedSubmissions(options.queuedSubmissions ?? [], theme, width, state.nextTurnInbox);
820
+ const todos = editor === undefined ? [] : renderTodos(state.todos, theme, width);
620
821
  const autocomplete = promptSelector !== undefined || settings !== undefined || copySelector !== undefined || search !== undefined
621
822
  || options.autocomplete === undefined
622
823
  ? []
@@ -624,7 +825,7 @@ export function renderView(state, options) {
624
825
  const inputLines = promptSelector?.lines ?? settings?.lines ?? copySelector?.lines ?? search?.lines
625
826
  ?? (editor === undefined ? [] : editor.lines);
626
827
  const spacer = 1;
627
- const reserved = inputLines.length + working.length + spacer + autocomplete.length + statusFooter.length;
828
+ const reserved = inputLines.length + working.length + todos.length + queuedSubmissions.length + spacer + autocomplete.length + statusFooter.length;
628
829
  const budget = Math.max(0, height - reserved);
629
830
  const focusStart = options.focusBlock === undefined
630
831
  ? undefined
@@ -637,10 +838,12 @@ export function renderView(state, options) {
637
838
  const lines = [...visible];
638
839
  if (visible.length > 0)
639
840
  lines.push('');
640
- const bottomRows = working.length + inputLines.length + autocomplete.length + statusFooter.length;
841
+ const bottomRows = working.length + todos.length + queuedSubmissions.length + inputLines.length + autocomplete.length + statusFooter.length;
641
842
  const fill = Math.max(0, height - lines.length - bottomRows);
642
843
  lines.push(...Array.from({ length: fill }, () => ''));
643
844
  lines.push(...working);
845
+ lines.push(...todos);
846
+ lines.push(...queuedSubmissions);
644
847
  const editorStart = lines.length;
645
848
  lines.push(...inputLines);
646
849
  lines.push(...autocomplete);
@@ -653,8 +856,9 @@ export function renderView(state, options) {
653
856
  row: editorStart + caret.row,
654
857
  column: Math.min(caret.column, width),
655
858
  },
656
- cursorVisible: promptSelector?.cursorVisible
657
- ?? (settings === undefined && copySelector === undefined),
859
+ cursorVisible: state.status === 'compacting'
860
+ ? false
861
+ : promptSelector?.cursorVisible ?? (settings === undefined && copySelector === undefined),
658
862
  ...(promptSelector?.editor !== undefined
659
863
  ? { editor: { start: editorStart + promptSelector.editor.start, rows: promptSelector.editor.rows } }
660
864
  : editor === undefined ? {} : { editor: { start: editorStart, rows: editor.lines.length } }),