@esso0428/pi-subagents 0.17.1 → 0.17.3

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/src/index.ts CHANGED
@@ -12,10 +12,10 @@
12
12
 
13
13
  import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
14
14
  import { join } from "node:path";
15
- import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
- import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
15
+ import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSelectListTheme, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
+ import { Container, Key, matchesKey, SelectList, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
- import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
18
+ import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory, readAgentHistoryResult } from "./agent-history.js";
19
19
  import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
20
20
  import { AgentManager } from "./agent-manager.js";
21
21
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
@@ -171,8 +171,8 @@ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "ma
171
171
  * surfaces (or "" if the run produced nothing). `record.result` is bounded to
172
172
  * the run's own turns, so this is never a stale earlier answer (#144).
173
173
  */
174
- function partialOutputSuffix(record: AgentRecord): string {
175
- const partial = record.result?.trim();
174
+ function partialOutputSuffix(record: AgentRecord, fallback?: string): string {
175
+ const partial = record.result?.trim() || fallback?.trim();
176
176
  return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
177
177
  }
178
178
 
@@ -450,7 +450,12 @@ export default function (pi: ExtensionAPI) {
450
450
  // Persist final record for cross-extension history reconstruction
451
451
  pi.appendEntry("subagents:record", {
452
452
  id: record.id, type: record.type, description: record.description,
453
- status: record.status, result: record.result, error: record.error,
453
+ status: record.status,
454
+ // Durable transcripts are the source of truth for full output. Avoid
455
+ // copying a potentially large result into the parent session branch;
456
+ // get_subagent_result reloads it on demand after cleanup/restart.
457
+ result: record.transcriptPath ? undefined : record.result,
458
+ error: record.error,
454
459
  startedAt: record.startedAt, completedAt: record.completedAt,
455
460
  toolUses: record.toolUses,
456
461
  lifetimeUsage: record.lifetimeUsage,
@@ -555,16 +560,29 @@ export default function (pi: ExtensionAPI) {
555
560
  }
556
561
  }
557
562
 
563
+ type AgentMenuSelection = { id?: string; index: number };
564
+ let runningAgentSelection: AgentMenuSelection = { index: 0 };
565
+ let historyAgentSelection: AgentMenuSelection = { index: 0 };
566
+
567
+ function resetAgentMenuSelections() {
568
+ runningAgentSelection = { index: 0 };
569
+ historyAgentSelection = { index: 0 };
570
+ }
571
+
558
572
  // Capture ctx from session_start for RPC spawn handler + start the scheduler.
559
573
  // This also wires the RPC handlers and broadcasts readiness — on the first
560
574
  // bound session_start, so a filtered-out activation never advertises (#142).
561
575
  pi.on("session_start", async (_event, ctx) => {
576
+ resetAgentMenuSelections();
562
577
  currentCtx = ctx;
563
578
  manager.clearCompleted(true);
564
579
  const branch = ctx.sessionManager?.getBranch?.() ?? [];
565
580
  manager.restoreCompleted(branch
566
581
  .filter((entry: any) => entry?.type === "custom" && entry?.customType === "subagents:record")
567
582
  .map((entry: any) => entry.data));
583
+ // Checkpoint files cover agents whose parent session never got a terminal
584
+ // branch entry (shutdown, session switch, or a process restart).
585
+ manager.restoreRecovered(ctx.cwd);
568
586
  // Attach the panel during TUI startup, after restored records are present,
569
587
  // so terminal agents from the session branch are immediately visible.
570
588
  if (ctx.mode === "tui") {
@@ -589,6 +607,10 @@ export default function (pi: ExtensionAPI) {
589
607
  });
590
608
 
591
609
  pi.on("session_before_switch", () => {
610
+ resetAgentMenuSelections();
611
+ // A switch is catchable. Stop and checkpoint live/queued agents before the
612
+ // old session context is discarded, then retain their unread history.
613
+ manager.abortAll();
592
614
  manager.clearCompleted(true);
593
615
  scheduler.stop();
594
616
  });
@@ -596,6 +618,7 @@ export default function (pi: ExtensionAPI) {
596
618
  // On shutdown, abort all agents immediately and clean up.
597
619
  // If the session is going down, there's nothing left to consume agent results.
598
620
  pi.on("session_shutdown", async () => {
621
+ resetAgentMenuSelections();
599
622
  rpcHandle?.unsubSpawn();
600
623
  rpcHandle?.unsubStop();
601
624
  rpcHandle?.unsubPing();
@@ -1163,6 +1186,7 @@ Terse command-style prompts produce shallow, generic work.
1163
1186
  rec.historyFile = createAgentHistoryPath(ctx.cwd, agentId);
1164
1187
  rec.transcriptPath = agentHistoryLocator(ctx.cwd, rec.historyFile);
1165
1188
  writeInitialEntry(rec.historyFile, agentId, params.prompt, ctx.cwd);
1189
+ manager.setTranscript(agentId, rec.historyFile, rec.transcriptPath, ctx.cwd);
1166
1190
  } catch (err) {
1167
1191
  rec.historyFile = undefined;
1168
1192
  rec.transcriptPath = undefined;
@@ -1274,9 +1298,10 @@ Terse command-style prompts produce shallow, generic work.
1274
1298
  const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns);
1275
1299
 
1276
1300
  // Wrap onSessionCreated to wire output file streaming.
1277
- // The callback lazily reads record.outputFile (set right after spawn)
1278
- // rather than closing over a value that doesn't exist yet.
1301
+ // The callback reads the transcript paths installed synchronously by
1302
+ // onSpawned before the agent can queue or start.
1279
1303
  let id: string;
1304
+ const joinMode = resolveJoinMode(defaultJoinMode, true);
1280
1305
  const origBgOnSession = bgCallbacks.onSessionCreated;
1281
1306
  bgCallbacks.onSessionCreated = (session: any) => {
1282
1307
  origBgOnSession(session);
@@ -1297,20 +1322,21 @@ Terse command-style prompts produce shallow, generic work.
1297
1322
  isBackground: true,
1298
1323
  isolation,
1299
1324
  invocation: agentInvocation,
1325
+ onSpawned: (spawnedId) => {
1326
+ attachTranscript(manager.getRecord(spawnedId), spawnedId);
1327
+ },
1300
1328
  ...bgCallbacks,
1301
1329
  });
1302
1330
  } catch (err) {
1303
1331
  return textResult(err instanceof Error ? err.message : String(err));
1304
1332
  }
1305
1333
 
1306
- // Set output file + join mode synchronously after spawn, before the
1307
- // event loop yields — onSessionCreated is async so this is safe.
1308
- const joinMode = resolveJoinMode(defaultJoinMode, true);
1334
+ // Set join metadata after spawn. Transcript metadata was installed by
1335
+ // the manager's synchronous onSpawned callback before this point.
1309
1336
  const record = manager.getRecord(id);
1310
1337
  if (record && joinMode) {
1311
1338
  record.joinMode = joinMode;
1312
1339
  record.toolCallId = toolCallId;
1313
- attachTranscript(record, id);
1314
1340
  }
1315
1341
 
1316
1342
  if (joinMode == null || joinMode === 'async') {
@@ -1512,6 +1538,9 @@ Terse command-style prompts produce shallow, generic work.
1512
1538
  if (record.promise) await abortable(record.promise, signal);
1513
1539
  }
1514
1540
 
1541
+ const durableResult = !record.result?.trim() && record.transcriptPath && currentCtx?.cwd
1542
+ ? readAgentHistoryResult(currentCtx.cwd, record.transcriptPath)
1543
+ : undefined;
1515
1544
  const displayName = getDisplayName(record.type);
1516
1545
  const duration = formatDuration(record.startedAt, record.completedAt);
1517
1546
  const tokens = formatLifetimeTokens(record);
@@ -1530,9 +1559,9 @@ Terse command-style prompts produce shallow, generic work.
1530
1559
  if (record.status === "running") {
1531
1560
  output += "Agent is still running. Use wait: true or check back later.";
1532
1561
  } else if (record.status === "error") {
1533
- output += `Error: ${record.error}${partialOutputSuffix(record)}`;
1562
+ output += `Error: ${record.error}${partialOutputSuffix(record, durableResult)}`;
1534
1563
  } else {
1535
- output += record.result?.trim() || "No output.";
1564
+ output += durableResult || record.result?.trim() || "No output.";
1536
1565
  }
1537
1566
 
1538
1567
  // Mark result as consumed — suppresses the completion notification
@@ -1789,6 +1818,59 @@ Terse command-style prompts produce shallow, generic work.
1789
1818
  });
1790
1819
  }
1791
1820
 
1821
+ async function selectAgentFromReadOnlyList(
1822
+ ctx: ExtensionCommandContext,
1823
+ title: string,
1824
+ pairs: Array<{ record: AgentRecord; label: string }>,
1825
+ selection: AgentMenuSelection,
1826
+ ): Promise<AgentRecord | undefined> {
1827
+ const options = pairs.map(({ record, label }) => ({ value: record.id, label }));
1828
+ const rememberedIndex = selection.id
1829
+ ? pairs.findIndex(({ record }) => record.id === selection.id)
1830
+ : -1;
1831
+ const initialIndex = rememberedIndex >= 0
1832
+ ? rememberedIndex
1833
+ : Math.max(0, Math.min(selection.index, pairs.length - 1));
1834
+
1835
+ const remember = (id: string) => {
1836
+ const index = pairs.findIndex(({ record }) => record.id === id);
1837
+ if (index >= 0) {
1838
+ selection.id = id;
1839
+ selection.index = index;
1840
+ }
1841
+ };
1842
+
1843
+ const choice = await ctx.ui.custom<string | undefined>((_tui, _theme, _kb, done) => {
1844
+ const list = new SelectList(
1845
+ options,
1846
+ Math.min(options.length, 10),
1847
+ getSelectListTheme(),
1848
+ );
1849
+ list.setSelectedIndex(initialIndex);
1850
+ const initialItem = options[initialIndex];
1851
+ if (initialItem) remember(initialItem.value);
1852
+ list.onSelectionChange = item => remember(item.value);
1853
+ list.onSelect = item => {
1854
+ remember(item.value);
1855
+ done(item.value);
1856
+ };
1857
+ list.onCancel = () => done(undefined);
1858
+
1859
+ const container = new Container();
1860
+ container.addChild(new Text(title, 0, 0));
1861
+ container.addChild(new Spacer(1));
1862
+ container.addChild(list);
1863
+ return {
1864
+ render: (w: number) => container.render(w),
1865
+ invalidate: () => container.invalidate(),
1866
+ handleInput: (data: string) => list.handleInput(data),
1867
+ };
1868
+ });
1869
+
1870
+ if (!choice) return undefined;
1871
+ return pairs.find(({ record }) => record.id === choice)?.record;
1872
+ }
1873
+
1792
1874
  async function showRunningAgents(ctx: ExtensionCommandContext) {
1793
1875
  const { active: agents } = splitAgentRecords(manager.listAgents(), ctx.cwd);
1794
1876
  if (agents.length === 0) {
@@ -1801,15 +1883,13 @@ Terse command-style prompts produce shallow, generic work.
1801
1883
  const dur = formatDuration(record.startedAt, record.completedAt);
1802
1884
  return { record, label: `${dn} (${record.description}) · ${record.toolUses} tools · ${record.status} · ${dur}` };
1803
1885
  });
1804
- const options = makeUniqueAgentOptionLabels(pairs);
1886
+ makeUniqueAgentOptionLabels(pairs);
1805
1887
 
1806
- const choice = await ctx.ui.select("Running agents", options);
1807
- if (!choice) return;
1808
- const record = pairs.find((pair) => pair.label === choice)?.record;
1888
+ const record = await selectAgentFromReadOnlyList(ctx, "Running agents", pairs, runningAgentSelection);
1809
1889
  if (!record) return;
1810
1890
 
1811
1891
  await viewAgentConversation(ctx, record, "live");
1812
- // Back-navigation: re-show the list
1892
+ // Back-navigation: re-show the list at the previously selected agent.
1813
1893
  await showRunningAgents(ctx);
1814
1894
  }
1815
1895
 
@@ -1821,13 +1901,13 @@ Terse command-style prompts produce shallow, generic work.
1821
1901
  }
1822
1902
 
1823
1903
  const pairs = history.map((record) => ({ record, label: formatAgentHistoryOption(record, Date.now()) }));
1824
- const options = makeUniqueAgentOptionLabels(pairs);
1825
- const choice = await ctx.ui.select("Agent history", options);
1826
- if (!choice) return;
1827
- const record = pairs.find((pair) => pair.label === choice)?.record;
1904
+ makeUniqueAgentOptionLabels(pairs);
1905
+ const record = await selectAgentFromReadOnlyList(ctx, "Agent history", pairs, historyAgentSelection);
1828
1906
  if (!record) return;
1829
1907
 
1830
1908
  await viewAgentConversation(ctx, record, "history");
1909
+ // Back-navigation: re-show the list at the previously selected agent.
1910
+ await showAgentHistory(ctx);
1831
1911
  }
1832
1912
 
1833
1913
  async function viewAgentConversation(
@@ -5,7 +5,7 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
 
8
- import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
8
+ import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
11
  import type { AgentInvocation, AgentRecord, SubagentType, WidgetMode } from "../types.js";
@@ -249,6 +249,8 @@ export class AgentWidget {
249
249
  private navigationActive = false;
250
250
  /** Stable identity of the selected row, so roster changes do not jump selection. */
251
251
  private selectedAgentId: string | undefined;
252
+ /** Last logical roster index of the selected row, used when it disappears. */
253
+ private selectedRosterIndex = 0;
252
254
  /** First logical row currently represented by the bounded viewport. */
253
255
  private viewportStart = 0;
254
256
 
@@ -307,6 +309,7 @@ export class AgentWidget {
307
309
  this.lastRenderKey = undefined;
308
310
  this.navigationActive = false;
309
311
  this.selectedAgentId = undefined;
312
+ this.selectedRosterIndex = 0;
310
313
  this.viewportStart = 0;
311
314
  // Print/RPC tests and lightweight embedders may provide only the widget
312
315
  // surface; real interactive contexts always implement this hook.
@@ -382,9 +385,29 @@ export class AgentWidget {
382
385
  }
383
386
 
384
387
  private deactivate(): void {
388
+ // Keep the selected row and viewport so re-entering navigation can resume
389
+ // where the user left off. Lifecycle resets (context, dispose, empty
390
+ // roster) clear this state explicitly instead of treating every exit as a
391
+ // reset.
385
392
  this.navigationActive = false;
386
- this.selectedAgentId = undefined;
387
- this.viewportStart = 0;
393
+ this.update();
394
+ }
395
+
396
+ /** Resume navigation from the retained row, or select the first row. */
397
+ private activate(records: readonly AgentRecord[]): void {
398
+ this.navigationActive = true;
399
+ const selectedIndex = this.selectedIndexOf(records);
400
+ if (selectedIndex >= 0) {
401
+ this.selectedRosterIndex = selectedIndex;
402
+ } else {
403
+ // The previously selected row may have been cleaned up while navigation
404
+ // was inactive. Resume at its old logical position, clamped to the new
405
+ // roster, rather than jumping back to the first row.
406
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, records.length - 1));
407
+ this.selectedAgentId = records[fallbackIndex].id;
408
+ this.selectedRosterIndex = fallbackIndex;
409
+ this.viewportStart = Math.min(this.viewportStart, Math.max(0, records.length - 1));
410
+ }
388
411
  this.update();
389
412
  }
390
413
 
@@ -396,10 +419,7 @@ export class AgentWidget {
396
419
 
397
420
  if (!this.navigationActive) {
398
421
  if (direction !== 1 || !this.editorHasFocus() || (ui.getEditorText?.() ?? "") !== "") return false;
399
- this.navigationActive = true;
400
- this.selectedAgentId = records[0].id;
401
- this.viewportStart = 0;
402
- this.update();
422
+ this.activate(records);
403
423
  return true;
404
424
  }
405
425
 
@@ -410,6 +430,7 @@ export class AgentWidget {
410
430
  }
411
431
  const nextIndex = Math.max(0, Math.min(records.length - 1, currentIndex + direction));
412
432
  this.selectedAgentId = records[nextIndex].id;
433
+ this.selectedRosterIndex = nextIndex;
413
434
  this.update();
414
435
  return true;
415
436
  }
@@ -431,13 +452,11 @@ export class AgentWidget {
431
452
  }
432
453
 
433
454
  if (!this.navigationActive) {
434
- if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || this.roster().length === 0) {
455
+ const records = this.roster();
456
+ if (!matchesKey(data, "down") || (this.uiCtx.getEditorText?.() ?? "") !== "" || records.length === 0) {
435
457
  return undefined;
436
458
  }
437
- this.navigationActive = true;
438
- this.selectedAgentId = this.roster()[0]?.id;
439
- this.viewportStart = 0;
440
- this.update();
459
+ this.activate(records);
441
460
  return { consume: true };
442
461
  }
443
462
 
@@ -518,7 +537,7 @@ export class AgentWidget {
518
537
  const w = tui.terminal.columns;
519
538
  const maxLines = getWidgetLineBudget(tui.terminal.rows);
520
539
  if (maxLines === 0) return [];
521
- const truncate = (line: string) => truncateToWidth(line, w);
540
+ const truncate = (line: string, width = w) => truncateToWidth(line, Math.max(0, width));
522
541
  const headingColor = hasActive ? "accent" : "dim";
523
542
  const headingIcon = hasActive ? "●" : "○";
524
543
  const frame = SPINNER[this.widgetFrame % SPINNER.length];
@@ -604,12 +623,12 @@ export class AgentWidget {
604
623
  // Reserve one line for a directional overflow summary. The viewport is
605
624
  // a contiguous slice in roster order, so the same slice is navigable and
606
625
  // renderable even when the selected row is currently hidden.
607
- const contentBudget = Math.max(0, maxBody - 1);
626
+ const baseContentBudget = Math.max(0, maxBody - 1);
608
627
  const heightAt = (index: number) => rows[index]?.lines.length ?? 0;
609
- const endFor = (start: number): number => {
628
+ const endFor = (start: number, budget: number): number => {
610
629
  let used = 0;
611
630
  let end = start;
612
- while (end < rows.length && used + heightAt(end) <= contentBudget) {
631
+ while (end < rows.length && used + heightAt(end) <= budget) {
613
632
  used += heightAt(end++);
614
633
  }
615
634
  return end;
@@ -621,21 +640,33 @@ export class AgentWidget {
621
640
  : -1;
622
641
  if (selectedIndex >= 0) {
623
642
  if (selectedIndex < start) start = selectedIndex;
624
- if (selectedIndex >= endFor(start)) start = selectedIndex;
643
+ if (selectedIndex >= endFor(start, baseContentBudget)) start = selectedIndex;
644
+ }
645
+
646
+ // Keep both directional affordances in the viewport chrome: the top
647
+ // count sits below the heading and the bottom count sits below the
648
+ // bounded roster. Each consumes one row when that direction is hidden.
649
+ let showTopMore = start > 0;
650
+ let contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
651
+ if (selectedIndex >= 0 && selectedIndex >= endFor(start, contentBudget)) {
652
+ start = selectedIndex;
653
+ showTopMore = start > 0;
654
+ contentBudget = Math.max(0, maxBody - (showTopMore ? 2 : 1));
625
655
  }
626
656
  this.viewportStart = start;
627
657
 
628
658
  let used = 0;
629
659
  let end = start;
660
+ const visibleBody: string[] = [];
630
661
  while (end < rows.length && used + heightAt(end) <= contentBudget) {
631
- lines.push(...rows[end].lines);
662
+ visibleBody.push(...rows[end].lines);
632
663
  used += heightAt(end);
633
664
  end++;
634
665
  }
635
666
  // A selected two-line row must remain addressable even if only one body
636
667
  // line is available. Showing its header is preferable to hiding it.
637
668
  if (end === start && rows[start] && contentBudget > 0) {
638
- lines.push(rows[start].lines[0]);
669
+ visibleBody.push(rows[start].lines[0]);
639
670
  end = start + 1;
640
671
  }
641
672
 
@@ -653,7 +684,44 @@ export class AgentWidget {
653
684
  ...(hiddenAfter > 0 ? [`↓ ${hiddenAfter} more`] : []),
654
685
  ].join(" · ");
655
686
  const summary = `+${hidden} more (${direction}${categoryCounts.length > 0 ? `; ${categoryCounts.join(", ")}` : ""})`;
656
- lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
687
+
688
+ if (showTopMore) lines.push(truncate(theme.fg("dim", `↑ ${hiddenBefore} more`)));
689
+
690
+ // Fill the bounded viewport so the scrollbar track has a stable height.
691
+ while (visibleBody.length < contentBudget) visibleBody.push("");
692
+ const totalBody = rows.reduce((total, row) => total + row.lines.length, 0);
693
+ const startOffset = rows.slice(0, start).reduce((total, row) => total + row.lines.length, 0);
694
+ const trackHeight = Math.max(0, contentBudget);
695
+ const minThumbHeight = Math.min(2, trackHeight);
696
+ const thumbHeight = Math.max(
697
+ minThumbHeight,
698
+ Math.min(trackHeight, Math.round((trackHeight * trackHeight) / totalBody)),
699
+ );
700
+ const maxScrollTop = Math.max(0, totalBody - trackHeight);
701
+ const maxThumbTop = Math.max(0, trackHeight - thumbHeight);
702
+ const thumbOffset = maxScrollTop === 0
703
+ ? 0
704
+ : Math.round((startOffset / maxScrollTop) * maxThumbTop);
705
+ const contentWidth = Math.max(0, w - 1);
706
+ for (let index = 0; index < visibleBody.length; index++) {
707
+ const isThumb = index >= thumbOffset && index < thumbOffset + thumbHeight;
708
+ const scrollbar = isThumb
709
+ ? theme.fg("scrollbarThumb", this.navigationActive ? "█" : "┃")
710
+ : theme.fg("scrollbarTrack", "│");
711
+ const bodyLine = truncate(visibleBody[index] ?? "", contentWidth);
712
+ lines.push(bodyLine + " ".repeat(Math.max(0, contentWidth - visibleWidth(bodyLine))) + scrollbar);
713
+ }
714
+
715
+ // The directional count is outside the viewport, like ScrollView's
716
+ // surrounding chrome, and therefore does not consume scrollbar track.
717
+ if (hiddenAfter > 0) {
718
+ lines.push(truncate(theme.fg("dim", `↓ ${hiddenAfter} more · +${hidden} more (${categoryCounts.length > 0 ? categoryCounts.join(", ") : ""})`)));
719
+ } else if (!showTopMore) {
720
+ lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", summary)}`));
721
+ } else {
722
+ // Keep a stable footer row when the selected viewport reaches the end.
723
+ lines.push(truncate(theme.fg("dim", "└─")));
724
+ }
657
725
  }
658
726
 
659
727
  return lines;
@@ -729,6 +797,7 @@ export class AgentWidget {
729
797
  this.lastRenderKey = undefined;
730
798
  this.navigationActive = false;
731
799
  this.selectedAgentId = undefined;
800
+ this.selectedRosterIndex = 0;
732
801
  this.viewportStart = 0;
733
802
  this.syncTimer(false);
734
803
  return;
@@ -753,13 +822,22 @@ export class AgentWidget {
753
822
  // an otherwise idle widget advance.
754
823
  if (advanceSpinner && runningCount > 0) this.widgetFrame++;
755
824
  this.syncTimer(runningCount > 0);
756
- if (this.navigationActive) {
825
+ const selectedIndex = this.selectedIndexOf(roster);
826
+ if (selectedIndex >= 0) {
827
+ this.selectedRosterIndex = selectedIndex;
828
+ } else if (this.navigationActive) {
757
829
  if (roster.length === 0) {
758
830
  this.navigationActive = false;
759
831
  this.selectedAgentId = undefined;
832
+ this.selectedRosterIndex = 0;
760
833
  this.viewportStart = 0;
761
- } else if (this.selectedIndexOf(roster) < 0) {
762
- this.selectedAgentId = roster[Math.min(this.viewportStart, roster.length - 1)].id;
834
+ } else {
835
+ // Keep the selection near the row that disappeared. Both the saved
836
+ // logical index and viewport are clamped as the roster shrinks.
837
+ const fallbackIndex = Math.max(0, Math.min(this.selectedRosterIndex, roster.length - 1));
838
+ this.selectedAgentId = roster[fallbackIndex].id;
839
+ this.selectedRosterIndex = fallbackIndex;
840
+ this.viewportStart = Math.min(this.viewportStart, roster.length - 1);
763
841
  }
764
842
  }
765
843
  const renderKey = this.renderKey(allAgents);
@@ -805,6 +883,7 @@ export class AgentWidget {
805
883
  this.lastRenderKey = undefined;
806
884
  this.navigationActive = false;
807
885
  this.selectedAgentId = undefined;
886
+ this.selectedRosterIndex = 0;
808
887
  this.viewportStart = 0;
809
888
  this.uiCtx = undefined;
810
889
  }
@@ -325,7 +325,10 @@ export class ConversationTimeline implements Component {
325
325
 
326
326
  try {
327
327
  const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
328
- color: (text) => this.theme.fg(block.role === "meta" ? "muted" : "text", text),
328
+ color: (text) => this.theme.fg(
329
+ block.role === "user" ? "userMessageText" : block.role === "meta" ? "muted" : "text",
330
+ text,
331
+ ),
329
332
  });
330
333
  for (const line of markdown.render(requestedWidth)) {
331
334
  lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });