@oh-my-pi/pi-coding-agent 15.11.4 → 15.11.6

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 (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/cli.js +450 -424
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/settings-schema.d.ts +53 -3
  6. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  7. package/dist/types/modes/components/session-selector.d.ts +1 -1
  8. package/dist/types/modes/components/tool-execution.d.ts +14 -0
  9. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  10. package/dist/types/modes/interactive-mode.d.ts +10 -0
  11. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  14. package/dist/types/session/agent-session.d.ts +14 -1
  15. package/dist/types/session/auth-storage.d.ts +1 -1
  16. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  17. package/dist/types/session/snapcompact-inline.d.ts +105 -4
  18. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  19. package/dist/types/task/render.d.ts +1 -0
  20. package/dist/types/tools/todo.d.ts +0 -11
  21. package/package.json +11 -11
  22. package/src/cli/usage-cli.ts +187 -16
  23. package/src/commands/usage.ts +8 -0
  24. package/src/config/settings-schema.ts +56 -3
  25. package/src/config/settings.ts +9 -0
  26. package/src/internal-urls/docs-index.generated.ts +1 -1
  27. package/src/modes/components/reset-usage-selector.ts +161 -0
  28. package/src/modes/components/session-selector.ts +8 -2
  29. package/src/modes/components/settings-selector.ts +62 -47
  30. package/src/modes/components/tool-execution.ts +18 -0
  31. package/src/modes/components/transcript-container.ts +23 -1
  32. package/src/modes/controllers/command-controller.ts +24 -1
  33. package/src/modes/controllers/selector-controller.ts +68 -0
  34. package/src/modes/interactive-mode.ts +59 -0
  35. package/src/modes/session-observer-registry.ts +61 -3
  36. package/src/modes/theme/theme.ts +2 -2
  37. package/src/modes/types.ts +2 -0
  38. package/src/modes/utils/context-usage.ts +75 -1
  39. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  40. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  41. package/src/prompts/system/snapcompact-toolresult-note.md +1 -1
  42. package/src/prompts/tools/browser.md +33 -43
  43. package/src/prompts/tools/eval.md +27 -50
  44. package/src/prompts/tools/irc.md +29 -31
  45. package/src/prompts/tools/read.md +31 -37
  46. package/src/prompts/tools/todo.md +1 -2
  47. package/src/sdk.ts +3 -2
  48. package/src/session/agent-session.ts +131 -6
  49. package/src/session/auth-storage.ts +3 -0
  50. package/src/session/codex-auto-reset.ts +190 -0
  51. package/src/session/snapcompact-inline.ts +396 -59
  52. package/src/slash-commands/builtin-registry.ts +145 -8
  53. package/src/slash-commands/helpers/context-report.ts +28 -1
  54. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  55. package/src/slash-commands/helpers/usage-report.ts +12 -0
  56. package/src/task/index.ts +30 -7
  57. package/src/task/render.ts +34 -19
  58. package/src/tools/todo.ts +8 -128
@@ -4,12 +4,11 @@ import {
4
4
  type Component,
5
5
  Container,
6
6
  extractPrintableText,
7
- fuzzyFilter,
7
+ fuzzyRank,
8
8
  getKeybindings,
9
9
  getSettingItemFilterText,
10
10
  Input,
11
11
  matchesKey,
12
- padding,
13
12
  parseSgrMouse,
14
13
  type SelectItem,
15
14
  SelectList,
@@ -68,8 +67,6 @@ class TextInputSubmenu extends Container {
68
67
  this.#input = new Input();
69
68
  if (currentValue) {
70
69
  this.#input.setValue(currentValue);
71
- // Move cursor to end of pre-filled value (ctrl+e = cursorLineEnd).
72
- this.#input.handleInput("\x05");
73
70
  }
74
71
  this.#input.onSubmit = value => {
75
72
  this.onSubmit(value); // empty string clears the setting
@@ -291,6 +288,8 @@ export class SettingsSelectorComponent implements Component {
291
288
  #currentTabId: SettingTab | "plugins" = "appearance";
292
289
  #preSearchTabId: SettingTab | "plugins" = "appearance";
293
290
  #searchQuery = "";
291
+ /** Single-line editor backing the search banner (cursor, word ops, paste). */
292
+ #searchInput = new Input();
294
293
  #searchMatchCount = 0;
295
294
  /** First matching item id per tab id, for Tab-key jumps while searching. */
296
295
  #searchFirstMatch = new Map<string, string>();
@@ -354,7 +353,7 @@ export class SettingsSelectorComponent implements Component {
354
353
 
355
354
  #footerHintText(): string {
356
355
  if (this.#searchList) {
357
- return "Enter/Space to change · Tab to jump tabs · Backspace to edit · Esc to exit search";
356
+ return "Enter to change · Tab to jump tabs · Esc to exit search";
358
357
  }
359
358
  if (this.#currentTabId === "plugins") {
360
359
  return "Tab to switch tabs · Esc to close";
@@ -366,28 +365,17 @@ export class SettingsSelectorComponent implements Component {
366
365
  return `Enter/Space to change · ${nav} · Type to search · Esc to close`;
367
366
  }
368
367
 
369
- /** Single-line search banner: accent icon, bold query + caret, right-aligned match count. */
368
+ /** Single-line search banner: accent icon, editable query with live cursor, right-aligned match count. */
370
369
  #renderSearchBanner(width: number): string {
371
370
  const icon = theme.symbol("icon.search");
372
371
  const countText = this.#searchMatchCount === 1 ? "1 match" : `${this.#searchMatchCount} matches`;
373
372
  const rightWidth = visibleWidth(countText) + 1; // trailing margin
374
- // Fixed chrome: " <icon> " prefix plus the "" cursor cell.
375
- const queryBudget = Math.max(4, width - visibleWidth(icon) - 4 - rightWidth - 1);
376
-
377
- // Keep the tail visible (where the cursor is) when the query overflows.
378
- let display = this.#searchQuery;
379
- if (visibleWidth(display) > queryBudget) {
380
- const chars = [...display];
381
- while (chars.length > 1 && visibleWidth(chars.join("")) > queryBudget - 1) {
382
- chars.shift();
383
- }
384
- display = `…${chars.join("")}`;
385
- }
386
-
387
- const left = ` ${theme.fg("accent", icon)} ${theme.bold(display)}${theme.fg("accent", "▌")}`;
373
+ const prefix = ` ${theme.fg("accent", icon)} `;
374
+ // The input pads itself to exactly this width and keeps the cursor in view.
375
+ const inputWidth = Math.max(4, width - visibleWidth(prefix) - rightWidth - 1);
376
+ const inputLine = this.#searchInput.render(inputWidth)[0] ?? "";
388
377
  const count = theme.fg(this.#searchMatchCount > 0 ? "dim" : "warning", countText);
389
- const gap = Math.max(1, width - visibleWidth(left) - rightWidth);
390
- return truncateToWidth(`${left}${padding(gap)}${count} `, width);
378
+ return truncateToWidth(`${prefix}${theme.bold(inputLine)} ${count} `, width);
391
379
  }
392
380
 
393
381
  /**
@@ -511,6 +499,9 @@ export class SettingsSelectorComponent implements Component {
511
499
  /** Swap the tab content for the global search result list. */
512
500
  #startSearch(initialQuery: string): void {
513
501
  this.#preSearchTabId = this.#currentTabId;
502
+ this.#searchInput = new Input();
503
+ this.#searchInput.prompt = "";
504
+ this.#searchInput.setValue(initialQuery);
514
505
  const list = new SettingsList(
515
506
  [],
516
507
  10,
@@ -547,6 +538,7 @@ export class SettingsSelectorComponent implements Component {
547
538
 
548
539
  const counts = new Map<SettingTab, number>();
549
540
  const items: SettingItem[] = [];
541
+ const tabResults: { tab: SettingTab; matched: SettingItem[]; bestScore: number; order: number }[] = [];
550
542
  this.#searchFirstMatch.clear();
551
543
  let total = 0;
552
544
  for (const tab of SETTING_TABS) {
@@ -555,24 +547,40 @@ export class SettingsSelectorComponent implements Component {
555
547
  const item = this.#defToItem(def);
556
548
  if (item) candidates.push(item);
557
549
  }
558
- const matched = fuzzyFilter(candidates, query, getSettingItemFilterText);
550
+ const ranked = fuzzyRank(candidates, query, getSettingItemFilterText);
551
+ const matched = ranked.map(result => result.item);
559
552
  counts.set(tab, matched.length);
560
553
  if (matched.length === 0) continue;
561
554
  total += matched.length;
562
- const meta = TAB_METADATA[tab];
555
+ tabResults.push({
556
+ tab,
557
+ matched,
558
+ bestScore: ranked[0]?.score ?? 0,
559
+ order: SETTING_TABS.indexOf(tab),
560
+ });
561
+ }
562
+
563
+ tabResults.sort((a, b) => a.bestScore - b.bestScore || a.order - b.order);
564
+ for (const result of tabResults) {
565
+ const meta = TAB_METADATA[result.tab];
563
566
  items.push({
564
- id: `__tab:${tab}`,
567
+ id: `__tab:${result.tab}`,
565
568
  label: `${theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0])} ${meta.label}`,
566
569
  currentValue: "",
567
570
  heading: true,
568
571
  });
569
- this.#searchFirstMatch.set(tab, matched[0].id);
570
- items.push(...matched);
572
+ this.#searchFirstMatch.set(result.tab, result.matched[0]?.id ?? "");
573
+ items.push(...result.matched);
571
574
  }
572
575
 
573
576
  this.#searchList.setItems(items);
574
577
  this.#searchMatchCount = total;
575
- this.#tabBar.setTabs(this.#buildSearchTabs(counts));
578
+ this.#tabBar.setTabs(
579
+ this.#buildSearchTabs(
580
+ counts,
581
+ tabResults.map(result => result.tab),
582
+ ),
583
+ );
576
584
  this.#syncTabBarToSelection(this.#searchList.getSelectedItem());
577
585
  }
578
586
 
@@ -597,20 +605,25 @@ export class SettingsSelectorComponent implements Component {
597
605
  }
598
606
  }
599
607
 
600
- /** Matching tabs first (counts attached), the rest muted at the end. */
601
- #buildSearchTabs(counts: Map<SettingTab, number>): Tab[] {
608
+ /** Matching tabs first (counts attached), ordered by best result score; the rest stay muted at the end. */
609
+ #buildSearchTabs(counts: Map<SettingTab, number>, matchedTabOrder: readonly SettingTab[]): Tab[] {
602
610
  const matched: Tab[] = [];
603
611
  const empty: Tab[] = [];
604
- for (const id of SETTING_TABS) {
612
+ const matchedIds = new Set<SettingTab>(matchedTabOrder);
613
+ for (const id of matchedTabOrder) {
605
614
  const meta = TAB_METADATA[id];
606
615
  const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
607
616
  const count = counts.get(id) ?? 0;
608
617
  if (count > 0) {
609
618
  matched.push({ id, label: `${icon} ${meta.label} (${count})`, short: `${icon} ${count}` });
610
- } else {
611
- empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true });
612
619
  }
613
620
  }
621
+ for (const id of SETTING_TABS) {
622
+ if (matchedIds.has(id)) continue;
623
+ const meta = TAB_METADATA[id];
624
+ const icon = theme.symbol(meta.icon as Parameters<typeof theme.symbol>[0]);
625
+ empty.push({ id, label: `${icon} ${meta.label}`, short: icon, muted: true });
626
+ }
614
627
  // Plugins hosts its own UI; it is not part of the schema-backed search.
615
628
  empty.push({ id: "plugins", label: `${theme.icon.package} Plugins`, short: theme.icon.package, muted: true });
616
629
  return [...matched, ...empty];
@@ -1029,25 +1042,27 @@ export class SettingsSelectorComponent implements Component {
1029
1042
  this.#endSearch(true);
1030
1043
  return;
1031
1044
  }
1032
- if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1033
- this.#setSearchQuery([...this.#searchQuery].slice(0, -1).join(""));
1034
- return;
1035
- }
1036
- if (
1037
- matchesKey(data, "tab") ||
1038
- matchesKey(data, "shift+tab") ||
1039
- matchesKey(data, "left") ||
1040
- matchesKey(data, "right")
1041
- ) {
1045
+ if (matchesKey(data, "tab") || matchesKey(data, "shift+tab")) {
1042
1046
  // Jump between tabs that have matches (muted tabs are skipped).
1043
1047
  this.#tabBar.handleInput(data);
1044
1048
  return;
1045
1049
  }
1046
- const printable = extractPrintableText(data);
1047
- if (printable !== undefined) {
1048
- this.#setSearchQuery(this.#searchQuery + printable);
1050
+ // Selection, paging, and activation stay with the result list.
1051
+ if (
1052
+ kb.matches(data, "tui.select.up") ||
1053
+ kb.matches(data, "tui.select.down") ||
1054
+ kb.matches(data, "tui.select.pageUp") ||
1055
+ kb.matches(data, "tui.select.pageDown") ||
1056
+ kb.matches(data, "tui.select.confirm") ||
1057
+ data === "\n"
1058
+ ) {
1059
+ list.handleInput(data);
1049
1060
  return;
1050
1061
  }
1051
- list.handleInput(data);
1062
+ // Everything else edits the query like a regular single-line editor:
1063
+ // cursor movement, word ops, kill ring, undo, paste.
1064
+ this.#searchInput.handleInput(data);
1065
+ const value = this.#searchInput.getValue();
1066
+ if (value !== this.#searchQuery) this.#setSearchQuery(value);
1052
1067
  }
1053
1068
  }
@@ -589,6 +589,24 @@ export class ToolExecutionComponent extends Container {
589
589
  return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
590
590
  }
591
591
 
592
+ /**
593
+ * Whether this still-live block's settled rows may enter native scrollback
594
+ * (see `FinalizableBlock.isTranscriptBlockCommitStable`). A pending
595
+ * collapsed preview is provisional: the tail-window streaming views
596
+ * (edit/bash/eval caps) are re-anchored top-first by the result render, so
597
+ * promoting their visually static head — e.g. an edit preview idling on
598
+ * its last frame while the apply + LSP pass runs — would strand a stale
599
+ * copy of the call box above the final block the moment the result lands.
600
+ * Expanded pending blocks stream top-anchored append-shaped content whose
601
+ * rows the result render preserves byte-stable (the over-tall write/eval
602
+ * scrollback contract), so they stay commit-eligible. Displaceable waiting
603
+ * polls are removed wholesale by the next poll and must never commit.
604
+ */
605
+ isTranscriptBlockCommitStable(): boolean {
606
+ if (this.#displaceable) return false;
607
+ return this.#expanded || this.isTranscriptBlockFinalized();
608
+ }
609
+
592
610
  /**
593
611
  * Mark the tool terminal even though no result arrived (the turn aborted or
594
612
  * abandoned it) and stop animating, so it can freeze and stops pinning the
@@ -63,6 +63,19 @@ interface FinalizableBlock {
63
63
  * never mutate post-finalize simply omit the method.
64
64
  */
65
65
  getTranscriptBlockVersion?(): number;
66
+ /**
67
+ * Whether a still-live block's visually settled leading rows are durable —
68
+ * guaranteed to survive the block's remaining transitions (finalize,
69
+ * displacement) byte-stable — and may therefore be promoted as commit-safe
70
+ * by {@link deriveLiveCommitState}. Blocks whose pending render is
71
+ * provisional (a tool call's tail-window streaming preview, replaced
72
+ * wholesale by the result render) return `false`: committing such rows
73
+ * strands a stale copy in immutable terminal history the moment the real
74
+ * content re-lays-out the block (the engine audit recommits below it —
75
+ * "duplication, never loss"). Absent = `true`, the default for blocks
76
+ * whose live rows persist (a streaming assistant message).
77
+ */
78
+ isTranscriptBlockCommitStable?(): boolean;
66
79
  }
67
80
 
68
81
  function isBlockFinalized(child: Component): boolean {
@@ -75,6 +88,11 @@ function getBlockVersion(child: Component): number | undefined {
75
88
  return fn ? fn.call(child) : undefined;
76
89
  }
77
90
 
91
+ function isBlockCommitStable(child: Component): boolean {
92
+ const fn = (child as Component & FinalizableBlock).isTranscriptBlockCommitStable;
93
+ return fn ? fn.call(child) : true;
94
+ }
95
+
78
96
  // A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
79
97
  // separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
80
98
  // to a background-colored padding row, whose escape sequences contain `\S` and
@@ -598,7 +616,11 @@ export class TranscriptContainer
598
616
  previous.generation === this.#generation);
599
617
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
600
618
  let liveCommitState: LiveCommitState | undefined;
601
- if (i >= liveStartIndex && !finalized) {
619
+ // Provisional live renders (commit-unstable blocks) never feed the
620
+ // promotion machinery: their settled-looking rows are replaced
621
+ // wholesale on finalize, so offering them would commit a stale
622
+ // preview the result render can only duplicate, never erase.
623
+ if (i >= liveStartIndex && !finalized && isBlockCommitStable(child)) {
602
624
  liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
603
625
  }
604
626
  // Cache the latest contribution as the next frame's diff input.
@@ -456,7 +456,7 @@ export class CommandController {
456
456
  }
457
457
 
458
458
  handleContextCommand(): void {
459
- const breakdown = computeContextBreakdown(this.ctx.session);
459
+ const breakdown = computeContextBreakdown(this.ctx.session, { snapcompactSavings: true });
460
460
  if (breakdown.contextWindow <= 0) {
461
461
  this.ctx.showWarning("Context usage is unavailable: no model is selected for this session.");
462
462
  return;
@@ -1528,6 +1528,29 @@ function renderUsageReports(
1528
1528
  lines.push(` ${uiTheme.fg("accent", "in use by this session:")} ${activeAccountLabel}`);
1529
1529
  }
1530
1530
 
1531
+ const resetAccountLines: string[] = [];
1532
+ for (const report of providerReports) {
1533
+ const count = report.resetCredits?.availableCount ?? 0;
1534
+ if (count <= 0) continue;
1535
+ const label =
1536
+ (report.metadata?.email as string | undefined) ??
1537
+ (report.metadata?.accountId as string | undefined) ??
1538
+ "account";
1539
+ const isActive =
1540
+ !!activeAccount &&
1541
+ ((!!activeAccount.accountId && activeAccount.accountId === report.metadata?.accountId) ||
1542
+ (!!activeAccount.email && activeAccount.email === report.metadata?.email));
1543
+ resetAccountLines.push(
1544
+ ` • ${label}: ${count} saved reset${count === 1 ? "" : "s"}${isActive ? " (active)" : ""}`,
1545
+ );
1546
+ }
1547
+ if (resetAccountLines.length > 0) {
1548
+ lines.push(
1549
+ ` ${uiTheme.fg("accent", "Saved rate-limit resets")} ${uiTheme.fg("dim", "(/usage reset to spend)")}`,
1550
+ );
1551
+ for (const line of resetAccountLines) lines.push(uiTheme.fg("dim", line));
1552
+ }
1553
+
1531
1554
  const renderableGroups = Array.from(limitGroups.values()).map(group => {
1532
1555
  const entries = group.limits.map((limit, index) => ({
1533
1556
  limit,
@@ -27,8 +27,14 @@ import {
27
27
  theme,
28
28
  } from "../../modes/theme/theme";
29
29
  import type { InteractiveModeContext } from "../../modes/types";
30
+ import type { ResetCreditRedeemOutcome } from "../../session/auth-storage";
30
31
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
31
32
  import { FileSessionStorage } from "../../session/session-storage";
33
+ import {
34
+ describeRedeemOutcome,
35
+ type ResetUsageAccount,
36
+ toResetUsageAccounts,
37
+ } from "../../slash-commands/helpers/reset-usage";
32
38
  import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking";
33
39
  import {
34
40
  isImageProviderPreference,
@@ -48,6 +54,7 @@ import { HistorySearchComponent } from "../components/history-search";
48
54
  import { ModelSelectorComponent } from "../components/model-selector";
49
55
  import { OAuthSelectorComponent } from "../components/oauth-selector";
50
56
  import { PluginSelectorComponent } from "../components/plugin-selector";
57
+ import { ResetUsageSelectorComponent } from "../components/reset-usage-selector";
51
58
  import { SessionSelectorComponent } from "../components/session-selector";
52
59
  import { SettingsSelectorComponent } from "../components/settings-selector";
53
60
  import { ToolExecutionComponent } from "../components/tool-execution";
@@ -1091,6 +1098,67 @@ export class SelectorController {
1091
1098
  });
1092
1099
  }
1093
1100
 
1101
+ async showResetUsageSelector(): Promise<void> {
1102
+ const session = this.ctx.session;
1103
+ this.ctx.showStatus("Checking saved rate-limit resets…", { dim: true });
1104
+ let statuses: Awaited<ReturnType<typeof session.listResetCredits>>;
1105
+ try {
1106
+ statuses = await session.listResetCredits();
1107
+ } catch (error) {
1108
+ this.ctx.showError(`Could not load saved resets: ${error instanceof Error ? error.message : String(error)}`);
1109
+ return;
1110
+ }
1111
+ const accounts = toResetUsageAccounts(statuses);
1112
+ if (accounts.length === 0) {
1113
+ this.ctx.showStatus("No Codex accounts found. Use /login to add one.");
1114
+ return;
1115
+ }
1116
+ if (!accounts.some(account => account.availableCount > 0)) {
1117
+ this.ctx.showStatus(
1118
+ accounts.some(account => account.error)
1119
+ ? "No saved resets available — some accounts couldn't be reached (try /login)."
1120
+ : "No saved rate-limit resets available to spend right now.",
1121
+ );
1122
+ return;
1123
+ }
1124
+ this.showSelector(done => {
1125
+ const selector = new ResetUsageSelectorComponent(
1126
+ accounts,
1127
+ account => {
1128
+ done();
1129
+ void this.#redeemReset(account);
1130
+ },
1131
+ () => {
1132
+ done();
1133
+ this.ctx.ui.requestRender();
1134
+ },
1135
+ );
1136
+ return { component: selector, focus: selector };
1137
+ });
1138
+ }
1139
+
1140
+ async #redeemReset(account: ResetUsageAccount): Promise<void> {
1141
+ this.ctx.showStatus(`Spending 1 saved reset for ${account.label}…`, { dim: true });
1142
+ let outcome: ResetCreditRedeemOutcome;
1143
+ try {
1144
+ outcome = await this.ctx.session.redeemResetCredit(account.target);
1145
+ } catch (error) {
1146
+ this.ctx.showError(
1147
+ `Reset failed for ${account.label}: ${error instanceof Error ? error.message : String(error)}`,
1148
+ );
1149
+ return;
1150
+ }
1151
+ const message = describeRedeemOutcome(outcome, account.label);
1152
+ if (outcome.ok) {
1153
+ this.ctx.showStatus(message);
1154
+ // Refresh the status-line usage so the freshly-reset window shows.
1155
+ this.ctx.statusLine.invalidate();
1156
+ this.ctx.ui.requestRender();
1157
+ } else {
1158
+ this.ctx.showWarning(message);
1159
+ }
1160
+ }
1161
+
1094
1162
  async showDebugSelector(): Promise<void> {
1095
1163
  const { DebugSelectorComponent } = await import("../../debug");
1096
1164
  this.showSelector(done => {
@@ -86,8 +86,10 @@ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES } from "../slash-commands/builtin-
86
86
  import { formatDuration } from "../slash-commands/helpers/format";
87
87
  import { STTController, type SttState } from "../stt";
88
88
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
89
+ import { formatTaskId } from "../task/render";
89
90
  import type { LspStartupServerInfo } from "../tools";
90
91
  import { normalizeLocalScheme } from "../tools/path-utils";
92
+ import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../tools/render-utils";
91
93
  import { setAutoQaConsentHandler } from "../tools/report-tool-issue";
92
94
  import { type ResolveToolDetails, runResolveInvocation } from "../tools/resolve";
93
95
  import { formatPhaseDisplayName, selectStickyTodoWindow, todoMatchesAnyDescription } from "../tools/todo";
@@ -132,6 +134,7 @@ import {
132
134
  parseLoopLimitArgs,
133
135
  } from "./loop-limit";
134
136
  import { OAuthManualInputManager } from "./oauth-manual-input";
137
+ import type { ObservableSession } from "./session-observer-registry";
135
138
  import { SessionObserverRegistry } from "./session-observer-registry";
136
139
  import { runProviderSetupWizard } from "./setup-wizard/lazy";
137
140
  import { interruptHint } from "./shared";
@@ -277,6 +280,41 @@ class StatusContainer extends Container implements NativeScrollbackLiveRegion {
277
280
  }
278
281
  }
279
282
 
283
+ /**
284
+ * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
285
+ * one hooked row per running agent in the same `Id: description` shape the
286
+ * inline task rows use (muted task preview when no description was given).
287
+ * Returns an empty array when nothing is running so the container can clear.
288
+ */
289
+ export function renderSubagentHudLines(sessions: ObservableSession[], columns: number): string[] {
290
+ const running = sessions.filter(session => session.kind === "subagent" && session.status === "active");
291
+ if (running.length === 0) return [];
292
+
293
+ const indent = " ";
294
+ const hook = theme.tree.hook;
295
+ const dot = theme.styledSymbol("status.done", "accent");
296
+ const lines = ["", indent + theme.bold(theme.fg("accent", "Subagents"))];
297
+ running.forEach((session, index) => {
298
+ const prefix = `${indent}${index === 0 ? hook : " "} `;
299
+ const displayId = formatTaskId(session.id);
300
+ let line = `${prefix}${dot} ${theme.fg("accent", theme.bold(displayId))}`;
301
+ const description = session.description?.trim() || session.progress?.description?.trim();
302
+ if (description) {
303
+ const budget = Math.max(TRUNCATE_LENGTHS.SHORT, columns - visibleWidth(prefix) - visibleWidth(displayId) - 6);
304
+ line += `${theme.fg("accent", ":")} ${theme.fg("accent", truncateToWidth(replaceTabs(description), budget))}`;
305
+ } else {
306
+ // No spawn description: fall back to a muted task preview, same as
307
+ // the inline task rows when a row has no label.
308
+ const taskPreview = session.progress?.task?.trim();
309
+ if (taskPreview) {
310
+ line += ` ${theme.fg("muted", truncateToWidth(replaceTabs(taskPreview), TRUNCATE_LENGTHS.SHORT))}`;
311
+ }
312
+ }
313
+ lines.push(line);
314
+ });
315
+ return lines;
316
+ }
317
+
280
318
  export class InteractiveMode implements InteractiveModeContext {
281
319
  session: AgentSession;
282
320
  sessionManager: SessionManager;
@@ -291,6 +329,7 @@ export class InteractiveMode implements InteractiveModeContext {
291
329
  pendingMessagesContainer: Container;
292
330
  statusContainer: Container;
293
331
  todoContainer: Container;
332
+ subagentContainer: Container;
294
333
  btwContainer: Container;
295
334
  omfgContainer: Container;
296
335
  errorBannerContainer: Container;
@@ -440,6 +479,7 @@ export class InteractiveMode implements InteractiveModeContext {
440
479
  this.pendingMessagesContainer = new Container();
441
480
  this.statusContainer = new StatusContainer();
442
481
  this.todoContainer = new Container();
482
+ this.subagentContainer = new Container();
443
483
  this.btwContainer = new Container();
444
484
  this.omfgContainer = new Container();
445
485
  this.errorBannerContainer = new Container();
@@ -606,6 +646,7 @@ export class InteractiveMode implements InteractiveModeContext {
606
646
  this.ui.addChild(this.pendingMessagesContainer);
607
647
  this.ui.addChild(this.statusContainer);
608
648
  this.ui.addChild(this.todoContainer);
649
+ this.ui.addChild(this.subagentContainer);
609
650
  this.ui.addChild(this.btwContainer);
610
651
  this.ui.addChild(this.omfgContainer);
611
652
  this.ui.addChild(this.errorBannerContainer);
@@ -632,6 +673,7 @@ export class InteractiveMode implements InteractiveModeContext {
632
673
  this.#reconcileTodosWithSubagents();
633
674
  this.#syncTodoAutoClearTimer();
634
675
  this.#renderTodoList();
676
+ this.#renderSubagentList();
635
677
  this.ui.requestRender();
636
678
  });
637
679
 
@@ -1282,6 +1324,19 @@ export class InteractiveMode implements InteractiveModeContext {
1282
1324
  this.todoContainer.addChild(new Text(lines.join("\n"), 1, 0));
1283
1325
  }
1284
1326
 
1327
+ /**
1328
+ * Anchored HUD of in-flight subagents, mirroring the Todos block above the
1329
+ * editor. Driven entirely by observer-registry change events, so rows appear
1330
+ * on spawn and the whole block clears itself once the last subagent leaves
1331
+ * the "active" state.
1332
+ */
1333
+ #renderSubagentList(): void {
1334
+ this.subagentContainer.clear();
1335
+ const lines = renderSubagentHudLines(this.#observerRegistry.getSessions(), this.ui.terminal.columns);
1336
+ if (lines.length === 0) return;
1337
+ this.subagentContainer.addChild(new Text(lines.join("\n"), 1, 0));
1338
+ }
1339
+
1285
1340
  async #loadTodoList(): Promise<void> {
1286
1341
  this.todoPhases = this.session.getTodoPhases();
1287
1342
  this.#syncTodoAutoClearTimer();
@@ -3235,6 +3290,10 @@ export class InteractiveMode implements InteractiveModeContext {
3235
3290
  return this.#selectorController.showOAuthSelector(mode, providerId);
3236
3291
  }
3237
3292
 
3293
+ showResetUsageSelector(): Promise<void> {
3294
+ return this.#selectorController.showResetUsageSelector();
3295
+ }
3296
+
3238
3297
  showProviderSetup(): Promise<void> {
3239
3298
  return runProviderSetupWizard(this);
3240
3299
  }
@@ -10,6 +10,8 @@ export interface ObservableSession {
10
10
  description?: string;
11
11
  status: "active" | "completed" | "failed" | "aborted";
12
12
  sessionFile?: string;
13
+ parentToolCallId?: string;
14
+ index?: number;
13
15
  lastUpdate: number;
14
16
  /** Latest progress snapshot from the subagent executor */
15
17
  progress?: AgentProgress;
@@ -26,6 +28,9 @@ export class SessionObserverRegistry {
26
28
  #sessions = new Map<string, ObservableSession>();
27
29
  #listeners = new Set<() => void>();
28
30
  #eventBusUnsubscribers: Array<() => void> = [];
31
+ #sortOrderById = new Map<string, number>();
32
+ #parentSortOrderById = new Map<string, number>();
33
+ #nextSortOrder = 0;
29
34
 
30
35
  /** Add a change listener. Returns unsubscribe function. */
31
36
  onChange(cb: () => void): () => void {
@@ -37,8 +42,34 @@ export class SessionObserverRegistry {
37
42
  for (const cb of this.#listeners) cb();
38
43
  }
39
44
 
45
+ #ensureSortOrder(id: string): number {
46
+ const existing = this.#sortOrderById.get(id);
47
+ if (existing !== undefined) return existing;
48
+ const order = this.#nextSortOrder++;
49
+ this.#sortOrderById.set(id, order);
50
+ return order;
51
+ }
52
+
53
+ #ensureParentSortOrder(parentToolCallId: string | undefined, order: number): void {
54
+ if (!parentToolCallId) return;
55
+ if (this.#parentSortOrderById.has(parentToolCallId)) return;
56
+ this.#parentSortOrderById.set(parentToolCallId, order);
57
+ }
58
+
59
+ #getStableOrder(session: ObservableSession): number {
60
+ return this.#sortOrderById.get(session.id) ?? Number.MAX_SAFE_INTEGER;
61
+ }
62
+
63
+ #getGroupOrder(session: ObservableSession): number {
64
+ const parentOrder = session.parentToolCallId
65
+ ? this.#parentSortOrderById.get(session.parentToolCallId)
66
+ : undefined;
67
+ return parentOrder ?? this.#getStableOrder(session);
68
+ }
69
+
40
70
  setMainSession(sessionFile?: string): void {
41
71
  const existing = this.#sessions.get("main");
72
+ this.#ensureSortOrder("main");
42
73
  this.#sessions.set("main", {
43
74
  id: "main",
44
75
  kind: "main",
@@ -53,9 +84,18 @@ export class SessionObserverRegistry {
53
84
  getSessions(): ObservableSession[] {
54
85
  const sessions = [...this.#sessions.values()];
55
86
  sessions.sort((a, b) => {
56
- if (a.kind === "main") return -1;
57
- if (b.kind === "main") return 1;
58
- return a.lastUpdate - b.lastUpdate;
87
+ if (a.kind === "main" && b.kind !== "main") return -1;
88
+ if (b.kind === "main" && a.kind !== "main") return 1;
89
+ if (a.kind === "main" || b.kind === "main") return 0;
90
+
91
+ const groupDiff = this.#getGroupOrder(a) - this.#getGroupOrder(b);
92
+ if (groupDiff !== 0) return groupDiff;
93
+
94
+ const aIndex = a.index ?? Number.MAX_SAFE_INTEGER;
95
+ const bIndex = b.index ?? Number.MAX_SAFE_INTEGER;
96
+ if (aIndex !== bIndex) return aIndex - bIndex;
97
+
98
+ return this.#getStableOrder(a) - this.#getStableOrder(b);
59
99
  });
60
100
  return sessions;
61
101
  }
@@ -71,6 +111,9 @@ export class SessionObserverRegistry {
71
111
  /** Clear all tracked sessions (e.g. on session switch). Keeps EventBus subscriptions and listeners. */
72
112
  resetSessions(): void {
73
113
  this.#sessions.clear();
114
+ this.#sortOrderById.clear();
115
+ this.#parentSortOrderById.clear();
116
+ this.#nextSortOrder = 0;
74
117
  this.#notifyListeners();
75
118
  }
76
119
 
@@ -78,6 +121,9 @@ export class SessionObserverRegistry {
78
121
  for (const unsub of this.#eventBusUnsubscribers) unsub();
79
122
  this.#eventBusUnsubscribers = [];
80
123
  this.#sessions.clear();
124
+ this.#sortOrderById.clear();
125
+ this.#parentSortOrderById.clear();
126
+ this.#nextSortOrder = 0;
81
127
  this.#listeners.clear();
82
128
  }
83
129
 
@@ -92,10 +138,14 @@ export class SessionObserverRegistry {
92
138
  const status = STATUS_MAP[payload.status];
93
139
  if (!status) return;
94
140
 
141
+ const sortOrder = this.#ensureSortOrder(payload.id);
142
+ this.#ensureParentSortOrder(payload.parentToolCallId, sortOrder);
95
143
  const existing = this.#sessions.get(payload.id);
96
144
  if (existing) {
97
145
  existing.status = status;
98
146
  existing.lastUpdate = Date.now();
147
+ existing.index = payload.index;
148
+ existing.parentToolCallId = payload.parentToolCallId ?? existing.parentToolCallId;
99
149
  if (payload.description) existing.description = payload.description;
100
150
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
101
151
  } else {
@@ -107,6 +157,8 @@ export class SessionObserverRegistry {
107
157
  description: payload.description,
108
158
  status,
109
159
  sessionFile: payload.sessionFile,
160
+ parentToolCallId: payload.parentToolCallId,
161
+ index: payload.index,
110
162
  lastUpdate: Date.now(),
111
163
  });
112
164
  }
@@ -121,8 +173,12 @@ export class SessionObserverRegistry {
121
173
  const id = progress.id;
122
174
  const existing = this.#sessions.get(id);
123
175
 
176
+ const sortOrder = this.#ensureSortOrder(id);
177
+ this.#ensureParentSortOrder(payload.parentToolCallId, sortOrder);
124
178
  if (existing) {
125
179
  existing.lastUpdate = Date.now();
180
+ existing.index = payload.index;
181
+ existing.parentToolCallId = payload.parentToolCallId ?? existing.parentToolCallId;
126
182
  existing.progress = progress;
127
183
  if (progress.description) existing.description = progress.description;
128
184
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
@@ -135,6 +191,8 @@ export class SessionObserverRegistry {
135
191
  description: progress.description,
136
192
  status: "active",
137
193
  sessionFile: payload.sessionFile,
194
+ parentToolCallId: payload.parentToolCallId,
195
+ index: payload.index,
138
196
  lastUpdate: Date.now(),
139
197
  progress,
140
198
  });