@oh-my-pi/pi-tui 18.4.4 → 18.4.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 (63) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/apps/git/sidebar.d.ts +2 -2
  3. package/dist/types/chat/recap-notice.d.ts +13 -0
  4. package/dist/types/components/editor.d.ts +15 -3
  5. package/dist/types/components/input.d.ts +11 -1
  6. package/dist/types/native/blobs.d.ts +1 -1
  7. package/dist/types/native/node.d.ts +63 -1
  8. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  9. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  10. package/dist/types/overlays/copy-selector.d.ts +0 -23
  11. package/dist/types/overlays/error-banner.d.ts +5 -2
  12. package/dist/types/overlays/hook-editor.d.ts +12 -0
  13. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  14. package/dist/types/overlays/model-hub.d.ts +2 -0
  15. package/dist/types/overlays/rewind-selector.d.ts +17 -4
  16. package/dist/types/prompt/custom-editor.d.ts +16 -8
  17. package/dist/types/prompt/welcome.d.ts +3 -3
  18. package/dist/types/render/render-utils.d.ts +5 -0
  19. package/dist/types/status-line/component.d.ts +2 -0
  20. package/dist/types/status-line/host.d.ts +2 -0
  21. package/dist/types/status-line/metrics.d.ts +4 -0
  22. package/dist/types/status-line/types.d.ts +13 -2
  23. package/dist/types/tools/edit.d.ts +2 -0
  24. package/dist/types/tools/task.d.ts +7 -0
  25. package/dist/types/tui.d.ts +8 -1
  26. package/package.json +9 -9
  27. package/src/apps/git/sidebar.ts +15 -2
  28. package/src/chat/assistant-message.ts +2 -2
  29. package/src/chat/recap-notice.ts +30 -0
  30. package/src/chat/tool-execution.ts +19 -3
  31. package/src/chat/user-message.ts +2 -1
  32. package/src/components/editor.ts +115 -16
  33. package/src/components/input.ts +57 -1
  34. package/src/native/backend.ts +28 -10
  35. package/src/native/blobs.ts +1 -1
  36. package/src/native/encode.ts +1 -0
  37. package/src/native/node.ts +108 -1
  38. package/src/native/reconcile.ts +5 -4
  39. package/src/overlays/agent-hub-projection.ts +25 -0
  40. package/src/overlays/agent-hub.ts +4 -15
  41. package/src/overlays/ask-dialog.ts +20 -32
  42. package/src/overlays/copy-selector.ts +6 -6
  43. package/src/overlays/error-banner.ts +14 -3
  44. package/src/overlays/hook-editor.ts +59 -6
  45. package/src/overlays/jobs-panel.ts +132 -49
  46. package/src/overlays/model-browser.ts +56 -12
  47. package/src/overlays/model-hub.ts +82 -29
  48. package/src/overlays/rewind-selector.ts +235 -332
  49. package/src/overlays/session-selector.ts +19 -20
  50. package/src/prompt/custom-editor.ts +119 -30
  51. package/src/prompt/welcome.ts +4 -33
  52. package/src/render/render-utils.ts +51 -3
  53. package/src/status-line/component.ts +61 -30
  54. package/src/status-line/host.ts +2 -0
  55. package/src/status-line/metrics.ts +36 -5
  56. package/src/status-line/segments.ts +9 -1
  57. package/src/status-line/types.ts +13 -2
  58. package/src/terminal.ts +27 -6
  59. package/src/tools/edit.ts +23 -0
  60. package/src/tools/eval.ts +43 -11
  61. package/src/tools/grouped-file-output.ts +6 -0
  62. package/src/tools/task.ts +29 -4
  63. package/src/tui.ts +9 -1
@@ -1376,7 +1376,8 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1376
1376
  /** The native picker shows `#message`'s error until the next key or pointer event. */
1377
1377
  #pickerErrorOpen = false;
1378
1378
  readonly #standalone: boolean;
1379
- readonly #pickerTitle: string;
1379
+ /** A caller's heading for the picker head; the default `/resume` picker has none (the search names it). */
1380
+ readonly #pickerTitle: string | undefined;
1380
1381
  /** The open delete confirmation's two answers, for the picker's confirm strip. */
1381
1382
  #deleteChoice: DeleteChoice<T> | null = null;
1382
1383
  /** The preview pane's content and the session it shows; follows the selection once it settles. */
@@ -1423,7 +1424,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1423
1424
  this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
1424
1425
  this.#fillHeight = options.fillHeight ?? false;
1425
1426
  this.#title = options.title ?? "Resume Session";
1426
- this.#pickerTitle = options.title ?? "Resume session";
1427
+ this.#pickerTitle = options.title;
1427
1428
  this.#standalone = options.standalone ?? false;
1428
1429
  this.#scopeLabel = options.scopeLabel;
1429
1430
  this.title = this.#headerLabel();
@@ -1709,10 +1710,11 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1709
1710
  }
1710
1711
 
1711
1712
  /**
1712
- * `lg` cards sheet (`screen` for the standalone app): This folder / All
1713
- * projects tabs, sessions grouped by day (ranked flat while searching),
1714
- * the selected session's preview, the delete confirm strip, and the
1715
- * actions of the keys Enter, Delete/Backspace, Tab and Esc.
1713
+ * `lg` cards sheet (`screen` for the standalone app): sessions grouped by
1714
+ * day (ranked flat while searching), the selected session's preview, the
1715
+ * delete confirm strip, and the actions of the keys Enter, Delete/Backspace,
1716
+ * Tab (the scope toggle, labelled with the scope it switches to) and Esc.
1717
+ * Untitled unless the caller named it; the placeholder says the scope.
1716
1718
  */
1717
1719
  #describePicker(): NativeNode {
1718
1720
  const list = this.#sessionList;
@@ -1742,11 +1744,17 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1742
1744
  actions.push(
1743
1745
  pickerAction("close", "Close", boundKeys("app.interrupt", ["escape"])[0] ?? "escape", { end: true }),
1744
1746
  );
1745
- const subtitle = this.#scopeLabel === false ? undefined : (this.#scopeLabel ?? path.basename(getProjectDir()));
1747
+ const folder = this.#scopeLabel === false ? undefined : (this.#scopeLabel ?? path.basename(getProjectDir()));
1748
+ const title = this.#pickerTitle;
1749
+ const placeholder =
1750
+ loading || this.#scope === "all"
1751
+ ? "Search all sessions…"
1752
+ : folder && !title
1753
+ ? `Search sessions in ${folder}…`
1754
+ : "Search sessions…";
1746
1755
  const result = picker(
1747
1756
  {
1748
- title: this.#pickerTitle,
1749
- ...(subtitle ? { subtitle } : {}),
1757
+ ...(title ? { title, ...(folder ? { subtitle: folder } : {}) } : {}),
1750
1758
  icon: "history",
1751
1759
  noun: "sessions",
1752
1760
  size: this.#standalone ? "screen" : "lg",
@@ -1754,16 +1762,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1754
1762
  preview: "side",
1755
1763
  query: view.query,
1756
1764
  cursor: view.cursor,
1757
- placeholder: "Search sessions…",
1758
- ...(this.#hasScopeTabs()
1759
- ? {
1760
- tabs: [
1761
- { id: "folder", label: "This folder" },
1762
- { id: "all", label: "All projects" },
1763
- ],
1764
- tab: loading ? "all" : this.#scope,
1765
- }
1766
- : {}),
1765
+ placeholder,
1767
1766
  columns: [
1768
1767
  { id: "when", format: "time" },
1769
1768
  { id: "size", format: "dim" },
@@ -1846,7 +1845,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1846
1845
  } else if (choice) return;
1847
1846
  else if (ev.act === "resume") list.resumeSelected();
1848
1847
  else if (ev.act === "delete") list.requestDelete();
1849
- else if (ev.act === "scope" || (ev.act === "tab" && ev.value !== undefined && ev.value !== this.#scope)) {
1848
+ else if (ev.act === "scope") {
1850
1849
  list.onToggleScope?.();
1851
1850
  } else if (ev.act === "clear") list.clearSearch();
1852
1851
  }
@@ -14,6 +14,7 @@ import { SpaceHoldGesture } from "../space-hold";
14
14
  import { type Component, TUI } from "../tui";
15
15
  import type { AppKeybinding } from "../app-keybindings";
16
16
  import { formatKeyHint } from "../key-hint-format";
17
+ import { MAIN_AGENT_ID } from "../overlays/agent-hub-types";
17
18
  import { compact, keyed, node, row, span } from "../native/describe";
18
19
  import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
19
20
  import type { ComposerFacts, ComposerFactsSource } from "../status-line/types";
@@ -60,11 +61,19 @@ export interface ComposerNativeState {
60
61
  readonly thinking?: string;
61
62
  /** A turn is running: the send keycap becomes a Stop button. */
62
63
  readonly running: boolean;
64
+ /** The viewed subagent's lineage, outermost first and the viewed agent last; undefined on the main session. */
65
+ readonly viewing?: readonly string[];
63
66
  }
64
67
 
68
+ /** Action code prefix of the viewing header's links: `focus:<agent id>`, {@link MAIN_AGENT_ID} for the main session. */
69
+ const FOCUS_ACTION = "focus:";
70
+
65
71
  const IDLE_COMPOSER: ComposerNativeState = { running: false };
66
72
 
67
- /** Filled steps (of four) of the effort chip's meter per thinking level; `auto` before it resolves has none known. */
73
+ /**
74
+ * Filled steps (of four) of the effort chip's fallback meter, for terminals without the `effort`
75
+ * kind; `auto` before it resolves has none known.
76
+ */
68
77
  const EFFORT_STEPS: Partial<Record<string, number>> = {
69
78
  off: 0,
70
79
  minimal: 1,
@@ -932,7 +941,7 @@ export class CustomEditor extends Editor {
932
941
  }
933
942
  }
934
943
  for (const typo of this.#spelling.typoRanges(value, { editorText: text, lines, line, startCol: 0 })) {
935
- decor.push({ from: lineStart + typo.start, to: lineStart + typo.start + typo.length, s: "mark" });
944
+ decor.push({ from: lineStart + typo.start, to: lineStart + typo.start + typo.length, s: "typo" });
936
945
  }
937
946
  lineStart += value.length + 1;
938
947
  }
@@ -986,7 +995,11 @@ export class CustomEditor extends Editor {
986
995
  }, CustomEditor.SHIMMER_FRAME_MS);
987
996
  this.#shimmerTimer.unref?.();
988
997
  }
989
- override nativePlaceholder: string | undefined = NATIVE_COMPOSER_PLACEHOLDER;
998
+ /** Viewing a subagent, the draft goes to it: the placeholder names it. */
999
+ override describePlaceholder = (): string => {
1000
+ const agent = this.composerState().viewing?.at(-1);
1001
+ return agent === undefined ? NATIVE_COMPOSER_PLACEHOLDER : `Message ${agent}`;
1002
+ };
990
1003
  /** A shell-mode draft highlights as its language. */
991
1004
  override describeLanguage = (): string | undefined => this.composerState().shell?.kind;
992
1005
  /** Host-owned live state for the TSP composer (shell mode, effort chip, send/stop). */
@@ -1001,6 +1014,7 @@ export class CustomEditor extends Editor {
1001
1014
  facts: ComposerFacts | undefined;
1002
1015
  chips: Component | undefined;
1003
1016
  input: NativeNode;
1017
+ focus: NativeNode | undefined;
1004
1018
  mode: NativeNode | undefined;
1005
1019
  bar: NativeNode;
1006
1020
  layout: NativeEditorLayout;
@@ -1009,13 +1023,15 @@ export class CustomEditor extends Editor {
1009
1023
 
1010
1024
  /**
1011
1025
  * The TSP composer: role `omp.editor[.bash|.python]` (tone `pending` while
1012
- * a turn runs) over the context hairline, the attachment chips, a `line`
1013
- * row of the shell-mode chip and the input, and the `bar`: model chip,
1014
- * effort chip, the other status facts, usage, then send (Stop while a turn
1015
- * runs). Clicks come back as `status.model`, `thinking.cycle`, `submit`
1016
- * and `interrupt` actions.
1026
+ * a turn runs) over the context hairline, the viewing header while a
1027
+ * subagent is focused, the attachment chips, a `line` row of the
1028
+ * shell-mode chip and the input, and the `bar`: model chip, effort chip,
1029
+ * the other status facts, usage, then send (Stop while a turn runs).
1030
+ * Clicks come back as `status.model`, `thinking.cycle`, `submit`,
1031
+ * `interrupt` and `focus:<id>` actions.
1017
1032
  */
1018
- override describeLayout = (input: NativeNode, _cx: DescribeContext): NativeEditorLayout => {
1033
+ override describeLayout = (input: NativeNode, cx: DescribeContext): NativeEditorLayout => {
1034
+ const effortGlyph = cx.supports("effort");
1019
1035
  const state = this.composerState();
1020
1036
  const shell = state.shell;
1021
1037
  const facts = this.composerFacts?.describeComposerFacts();
@@ -1030,36 +1046,98 @@ export class CustomEditor extends Editor {
1030
1046
  thinkingKey,
1031
1047
  modelKey,
1032
1048
  interruptKey,
1049
+ state.viewing?.join("\u0001"),
1050
+ effortGlyph,
1033
1051
  ].join("\0");
1034
1052
  const chips = this.attachmentChips;
1035
1053
  const memo = this.#nativeComposer;
1036
1054
  if (memo && memo.key === key && memo.facts === facts && memo.input === input && memo.chips === chips) {
1037
1055
  return memo.layout;
1038
1056
  }
1039
- const { mode, bar } =
1057
+ const { focus, mode, bar } =
1040
1058
  memo?.key === key && memo.facts === facts
1041
1059
  ? memo
1042
- : this.#describeComposerControls(state, facts, thinkingKey, modelKey, interruptKey);
1060
+ : this.#describeComposerControls(state, facts, thinkingKey, modelKey, interruptKey, effortGlyph);
1043
1061
  const line = keyed(row(compact([mode, input]), { role: "omp.composer.line", align: "start", gap: "sm" }), "line");
1044
1062
  const layout: NativeEditorLayout = {
1045
1063
  role: shell ? `omp.editor.${shell.kind}` : "omp.editor",
1046
1064
  tone: state.running ? "pending" : undefined,
1047
- children: compact([facts?.context, chips, line, bar]),
1065
+ children: compact([facts?.context, focus, chips, line, bar]),
1048
1066
  caret: "line/input",
1049
1067
  };
1050
- this.#nativeComposer = { key, facts, chips, input, mode, bar, layout };
1068
+ this.#nativeComposer = { key, facts, chips, input, focus, mode, bar, layout };
1051
1069
  return layout;
1052
1070
  };
1053
1071
 
1054
- /** The shell-mode chip before the input, and the bar under it. */
1072
+ /**
1073
+ * The viewing header (`omp.composer.focus`) while a subagent is focused:
1074
+ * an eye, the agent's ancestors as `omp.composer.crumb` links, the agent
1075
+ * itself (`omp.composer.agent`), then the way back to the main session
1076
+ * (`omp.composer.exit`, the interrupt key's keycap: Esc on an empty draft).
1077
+ */
1078
+ #describeViewing(viewing: readonly string[], interruptKey: KeyId): NativeNode | undefined {
1079
+ const agent = viewing.at(-1);
1080
+ if (agent === undefined) return undefined;
1081
+ const back = interruptKey === "escape" ? "esc" : formatKeyHint(interruptKey);
1082
+ const crumbs = viewing.slice(0, -1).map(id =>
1083
+ node(
1084
+ "text",
1085
+ {
1086
+ role: "omp.composer.crumb",
1087
+ text: id,
1088
+ wrap: "none",
1089
+ title: `View ${id}`,
1090
+ actions: { click: `${FOCUS_ACTION}${id}` },
1091
+ },
1092
+ undefined,
1093
+ `crumb:${id}`,
1094
+ ),
1095
+ );
1096
+ return keyed(
1097
+ row(
1098
+ [
1099
+ node("icon", { name: "eye" }, undefined, "icon"),
1100
+ node("text", { text: "Viewing", wrap: "none" }, undefined, "label"),
1101
+ ...crumbs,
1102
+ node("text", { role: "omp.composer.agent", text: agent, wrap: "none" }, undefined, "agent"),
1103
+ node(
1104
+ "row",
1105
+ {
1106
+ role: "omp.composer.exit",
1107
+ gap: "xs",
1108
+ align: "center",
1109
+ title: `Back to the main session ${back}`,
1110
+ actions: { click: `${FOCUS_ACTION}${MAIN_AGENT_ID}` },
1111
+ },
1112
+ [
1113
+ node("kbd", { keys: [interruptKey] }, undefined, "key"),
1114
+ node("text", { text: "main", wrap: "none" }, undefined, "label"),
1115
+ ],
1116
+ "exit",
1117
+ ),
1118
+ ],
1119
+ {
1120
+ role: "omp.composer.focus",
1121
+ gap: "xs",
1122
+ align: "center",
1123
+ title: `Viewing subagent ${agent}: what you send goes to it`,
1124
+ },
1125
+ ),
1126
+ "focus",
1127
+ );
1128
+ }
1129
+
1130
+ /** The viewing header over the text, the shell-mode chip before the input, and the bar under it. */
1055
1131
  #describeComposerControls(
1056
1132
  state: ComposerNativeState,
1057
1133
  facts: ComposerFacts | undefined,
1058
1134
  thinkingKey: KeyId | undefined,
1059
1135
  modelKey: KeyId | undefined,
1060
1136
  interruptKey: KeyId,
1061
- ): { mode: NativeNode | undefined; bar: NativeNode } {
1137
+ effortGlyph: boolean,
1138
+ ): { focus: NativeNode | undefined; mode: NativeNode | undefined; bar: NativeNode } {
1062
1139
  const shell = state.shell;
1140
+ const focus = state.viewing && this.#describeViewing(state.viewing, interruptKey);
1063
1141
  const model =
1064
1142
  facts &&
1065
1143
  node(
@@ -1079,9 +1157,10 @@ export class CustomEditor extends Editor {
1079
1157
  ],
1080
1158
  "model",
1081
1159
  );
1082
- const effortSteps = state.thinking === undefined ? undefined : EFFORT_STEPS[state.thinking];
1160
+ const thinking = state.thinking;
1161
+ const effortSteps = thinking === undefined ? undefined : EFFORT_STEPS[thinking];
1083
1162
  const effort =
1084
- state.thinking !== undefined &&
1163
+ thinking !== undefined &&
1085
1164
  node(
1086
1165
  "row",
1087
1166
  {
@@ -1092,13 +1171,15 @@ export class CustomEditor extends Editor {
1092
1171
  actions: { click: "thinking.cycle" },
1093
1172
  },
1094
1173
  [
1095
- node(
1096
- "meter",
1097
- { value: effortSteps === undefined ? null : effortSteps / 4, style: "blocks", steps: 4 },
1098
- undefined,
1099
- "meter",
1100
- ),
1101
- node("text", { text: state.thinking, wrap: "none" }, undefined, "level"),
1174
+ effortGlyph
1175
+ ? node("effort", { level: thinking }, undefined, "glyph")
1176
+ : node(
1177
+ "meter",
1178
+ { value: effortSteps === undefined ? null : effortSteps / 4, style: "blocks", steps: 4 },
1179
+ undefined,
1180
+ "meter",
1181
+ ),
1182
+ node("text", { text: thinking, wrap: "none" }, undefined, "level"),
1102
1183
  ],
1103
1184
  "effort",
1104
1185
  );
@@ -1135,7 +1216,7 @@ export class CustomEditor extends Editor {
1135
1216
  }),
1136
1217
  "bar",
1137
1218
  );
1138
- if (!shell) return { mode: undefined, bar };
1219
+ if (!shell) return { focus, mode: undefined, bar };
1139
1220
  const runs = shell.kind === "bash" ? "Runs in your shell" : "Runs in Python";
1140
1221
  const mode = keyed(
1141
1222
  row(
@@ -1158,15 +1239,20 @@ export class CustomEditor extends Editor {
1158
1239
  ),
1159
1240
  "mode",
1160
1241
  );
1161
- return { mode, bar };
1242
+ return { focus, mode, bar };
1162
1243
  }
1163
1244
 
1164
1245
  /**
1165
1246
  * Clicks on the composer's controls take the same paths as their keys: ⇧⇥,
1166
- * ⏎ and Esc; the status facts' clicks (`status.*`) go to their source.
1247
+ * ⏎ and Esc; the viewing header's links (`focus:<id>`) go to the host,
1248
+ * the status facts' clicks (`status.*`) to their source. Selection edits
1249
+ * go to the buffer.
1167
1250
  */
1168
- handleNativeEvent(event: NativeUiEvent): void {
1169
- if (event.type !== "action") return;
1251
+ override handleNativeEvent(event: NativeUiEvent): void {
1252
+ if (event.type !== "action") {
1253
+ super.handleNativeEvent(event);
1254
+ return;
1255
+ }
1170
1256
  switch (event.act) {
1171
1257
  case "thinking.cycle":
1172
1258
  this.onCycleThinkingLevel?.();
@@ -1178,7 +1264,8 @@ export class CustomEditor extends Editor {
1178
1264
  this.onEscape?.();
1179
1265
  return;
1180
1266
  default:
1181
- this.composerFacts?.handleNativeEvent(event);
1267
+ if (event.act.startsWith(FOCUS_ACTION)) this.onFocusAgent?.(event.act.slice(FOCUS_ACTION.length));
1268
+ else this.composerFacts?.handleNativeEvent(event);
1182
1269
  }
1183
1270
  }
1184
1271
 
@@ -1208,6 +1295,8 @@ export class CustomEditor extends Editor {
1208
1295
  onCapsLock?: () => void;
1209
1296
  /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
1210
1297
  onLeftAtStart?: () => void;
1298
+ /** Called when the viewing header asks to view agent `id` ({@link MAIN_AGENT_ID}: the main session). */
1299
+ onFocusAgent?: (id: string) => void;
1211
1300
 
1212
1301
  /** Space-bar push-to-talk; set its `handler` to enable it. It is a text-composition gesture, so it
1213
1302
  * stays out of Vim's Normal/Visual modes (where the space bar is the `l` motion) and away from an
@@ -3,7 +3,6 @@ import type { TspSpan } from "@oh-my-pi/pi-wire";
3
3
  import { formatDoubleTap, formatKeyHint, formatKeyHints, type KeyName } from "../app-keybindings";
4
4
  import { editorKey } from "../chrome/keybinding-hints";
5
5
  import { getKeybindings, type Keybinding } from "../keybindings";
6
- import { registerNativeBlob } from "../native/blobs";
7
6
  import { card, col, kbd, keyed, node, row, span, text } from "../native/describe";
8
7
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
9
8
  import { runTranscriptAction } from "../chat/transcript-actions";
@@ -221,9 +220,9 @@ export class WelcomeComponent implements Component {
221
220
 
222
221
  /**
223
222
  * A `card` (`omp.welcome`) titled with the app version. The brand column
224
- * (`omp.welcome.brand`: greeting, the animated SVG mark, model, provider) sits
225
- * beside the info column (`omp.welcome.info`: prompt-sigil keycaps, LSP
226
- * servers, recent sessions); the tip of the session closes the card. Roles
223
+ * (`omp.welcome.brand`: greeting and the terminal's builtin `omp` mark, which
224
+ * it animates) sits beside the info column (`omp.welcome.info`: prompt-sigil
225
+ * keycaps, LSP servers, recent sessions); the tip of the session closes the card. Roles
227
226
  * carry the look (gradient logo, type scale, column hairline); a "[NEW]" tip
228
227
  * carries a terminal-clocked shimmering tag.
229
228
  */
@@ -242,7 +241,7 @@ export class WelcomeComponent implements Component {
242
241
  node(
243
242
  "image",
244
243
  {
245
- blob: welcomeLogoBlob(),
244
+ builtin: "omp",
246
245
  alt: APP_NAME,
247
246
  w: 128,
248
247
  role: "omp.welcome.logo",
@@ -250,8 +249,6 @@ export class WelcomeComponent implements Component {
250
249
  undefined,
251
250
  "logo",
252
251
  ),
253
- art([span(plainLine(this.modelName), "accent")], "omp.welcome.model"),
254
- art([span(plainLine(this.providerName), "muted")], "omp.welcome.provider"),
255
252
  ],
256
253
  { align: "center", role: "omp.welcome.brand" },
257
254
  ),
@@ -682,32 +679,6 @@ export class WelcomeComponent implements Component {
682
679
  }
683
680
  }
684
681
 
685
- /**
686
- * {@link PI_LOGO} as SVG for the native welcome, on the terminal's grid: a
687
- * cell is 3×6 units, so the 12×5-cell art spans 36×30 from (14,16). The left
688
- * leg's `▒▒` tail is a half-opacity cell; the gradient spans the whole art in
689
- * user space (per-axis normalized, like {@link gradientLogo}) so the tail
690
- * keeps its colour. Tern mounts SVG blobs as live DOM, so the classes are
691
- * animation hooks: `trace` (the outline, `pathLength=1` for a draw-on), `mark`
692
- * (the fills) and the gradient stops `s0`–`s2`.
693
- */
694
- const WELCOME_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 12 44 38">
695
- <defs><linearGradient id="g" gradientUnits="userSpaceOnUse" gradientTransform="matrix(36 0 0 30 14 16)" x1="0" y1="0" x2="1" y2="1">
696
- <stop class="s0" offset="0" stop-color="#ed4abf"/><stop class="s1" offset=".5" stop-color="#9b4dff"/><stop class="s2" offset="1" stop-color="#5ad8e6"/>
697
- </linearGradient></defs>
698
- <path class="mark" fill="url(#g)" d="M14 16h36v6h-9v24h-6V22h-6v12h-6V22h-9z"/>
699
- <rect class="mark" fill="url(#g)" opacity=".5" x="23" y="34" width="6" height="6"/>
700
- <path class="trace" fill="none" stroke="url(#g)" stroke-width="1" stroke-linejoin="round" pathLength="1" d="M14 16h36v6h-9v24h-6V22h-6v18h-6V22h-9z"/>
701
- </svg>`;
702
-
703
- let welcomeLogoId: string | undefined;
704
-
705
- /** The registered blob id of {@link WELCOME_LOGO_SVG}. */
706
- function welcomeLogoBlob(): string {
707
- welcomeLogoId ??= registerNativeBlob(new TextEncoder().encode(WELCOME_LOGO_SVG), "image/svg+xml");
708
- return welcomeLogoId;
709
- }
710
-
711
682
  /** Block-grid brand mark shared by the welcome and setup surfaces. */
712
683
  export const PI_LOGO = ["████████████", " ██ ██ ", " ██ ██ ", " ▒▒ ██ ", " ██ "];
713
684
 
@@ -909,10 +909,16 @@ function homePatternFor(homeDir: string, windowsStyle: boolean): HomePattern {
909
909
  })
910
910
  .join("[\\\\/]");
911
911
  }
912
+ // `*` and `_` are also identifier and glob characters (`/home/me_old`, `src/**/home/me/**`), so they
913
+ // bound the home directory only as Markdown emphasis: opened at start or after whitespace, and
914
+ // closed before end, whitespace, or punctuation.
915
+ const end = `[\\\\/\\s"'\\x60)\\]},;:<>&|]`;
912
916
  pattern = {
913
917
  leading: new RegExp(`^${escapedHome}(?=$|[\\\\/])`, windowsStyle ? "i" : ""),
914
918
  embedded: new RegExp(
915
- `[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s"'<>]+|(^|[\\s"'\\x60([{=,:])(${escapedHome})(?=$|[\\\\/\\s"'\\x60)\\]},;:])`,
919
+ `[a-zA-Z][a-zA-Z0-9+.-]*://[^\\s"'<>]+` +
920
+ `|(^|[\\s"'\\x60([{=,:<>&|])(${escapedHome})(?=$|${end})` +
921
+ `|((?:^|\\s)[*_]{1,3})(${escapedHome})(?=$|${end}|[*_]{1,3}(?=$|[\\s.,;:!?)\\]}"'\\x60]))`,
916
922
  windowsStyle ? "gi" : "g",
917
923
  ),
918
924
  };
@@ -946,8 +952,13 @@ export function shortenEmbeddedPaths(text: string, homeDir?: string, preserveSep
946
952
  const homePattern = homePatternFor(resolvedHome, windowsStyle);
947
953
  const textWithShortenedHome = text.replace(
948
954
  homePattern.embedded,
949
- (match, boundary: string | undefined, candidate: string | undefined) =>
950
- candidate === undefined ? match : `${boundary}~`,
955
+ (
956
+ match,
957
+ boundary: string | undefined,
958
+ candidate: string | undefined,
959
+ emphasis: string | undefined,
960
+ emphasized: string | undefined,
961
+ ) => (candidate !== undefined ? `${boundary}~` : emphasized !== undefined ? `${emphasis}~` : match),
951
962
  );
952
963
  if (preserveSeparators) return textWithShortenedHome;
953
964
  return textWithShortenedHome
@@ -966,6 +977,43 @@ export function shortenEmbeddedPaths(text: string, homeDir?: string, preserveSep
966
977
  .join(" ");
967
978
  }
968
979
 
980
+ /**
981
+ * Shorten filesystem and command arguments without rewriting literal search patterns. A producer that
982
+ * names no argument key gets the general embedded-path shortening.
983
+ */
984
+ export function shortenToolArgumentPaths(text: string, key: string | undefined, homeDir?: string): string {
985
+ if (key === "url") {
986
+ try {
987
+ const url = new URL(text);
988
+ if (url.protocol === "file:") {
989
+ let decodedPath = url.pathname;
990
+ try {
991
+ decodedPath = decodeURIComponent(decodedPath);
992
+ } catch {
993
+ /* Retain malformed percent escapes as literal path bytes. */
994
+ }
995
+ const filePath = url.hostname
996
+ ? `//${url.hostname}${decodedPath}`
997
+ : /^[A-Za-z]:$/.test(decodedPath.slice(1, 3))
998
+ ? decodedPath.slice(1)
999
+ : decodedPath;
1000
+ return shortenEmbeddedPaths(filePath, homeDir);
1001
+ }
1002
+ } catch {
1003
+ // Preserve malformed and non-file URL arguments verbatim.
1004
+ }
1005
+ return text;
1006
+ }
1007
+ return key === undefined ||
1008
+ key === "path" ||
1009
+ key === "file_path" ||
1010
+ key === "command" ||
1011
+ key === "task" ||
1012
+ key === "prompt"
1013
+ ? shortenEmbeddedPaths(text, homeDir)
1014
+ : text;
1015
+ }
1016
+
969
1017
  /** Sanitize warning text before showing it in TUI, including embedded home paths. */
970
1018
  export function sanitizeDisplayWarning(text: string): string {
971
1019
  return shortenEmbeddedPaths(
@@ -39,10 +39,10 @@ import { canReuseCachedPr, createPrCacheContext, isSamePrCacheContext, type PrCa
39
39
  import { summarizeUsageResetCredits } from "../overlays/usage-display";
40
40
  import { getPreset } from "./presets";
41
41
  import { describeSegment, renderSegment, type SegmentContext } from "./segments";
42
- import type { TspProps } from "@oh-my-pi/pi-wire";
42
+ import type { TspMeterMark, TspProps } from "@oh-my-pi/pi-wire";
43
43
  import type { NativeNode, NativeUiEvent } from "../native/node";
44
44
  import { col, node, span } from "../native/describe";
45
- import { getContextMeterThresholds, getContextUsageLevel, getContextUsageTone } from "../chrome/context-thresholds";
45
+ import { getContextMeterThresholds } from "../chrome/context-thresholds";
46
46
  import { isNativeRendering } from "../native/state";
47
47
  import { getSeparator } from "./separators";
48
48
  import type {
@@ -81,10 +81,12 @@ const PINNED_NATIVE_PRIORITY = 1000;
81
81
  /**
82
82
  * Segments a TSP terminal shows outside the composer's facts: the model chip,
83
83
  * the context hairline and usage text (context, cost), Tern's pane header
84
- * (path, git), the tab title (session name, PR), the HUD pills (subagents)
85
- * and the editor (vim). The brand (`pi`) stays only while focus-proxied.
84
+ * (path, git), the tab title (session name, PR), the HUD pills (subagents),
85
+ * the editor (vim) and the brand (`pi`; while focus-proxied, the viewed agent
86
+ * is the composer's viewing header).
86
87
  */
87
88
  const COMPOSER_HOMED_SEGMENTS: Partial<Record<StatusLineSegmentId, true>> = {
89
+ pi: true,
88
90
  model: true,
89
91
  context_pct: true,
90
92
  context_total: true,
@@ -642,6 +644,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
642
644
  #sortedHookStatuses: readonly string[] = [];
643
645
  #subagentCount: number = 0;
644
646
  #runningSubagentIds = new Set<string>();
647
+ #subagentTreeCost = 0;
645
648
  /**
646
649
  * Active-processing accounting for the `time_spent` segment, keyed per
647
650
  * {@link StatusLineSession} so the focus-controller mid-turn attach path
@@ -888,6 +891,14 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
888
891
  this.#invalidateStatusLineRenderCache();
889
892
  }
890
893
 
894
+ /** Host-computed spend of the main session's whole subagent tree (Agent Hub projection). */
895
+ setSubagentTreeCost(cost: number): void {
896
+ const next = Number.isFinite(cost) && cost > 0 ? cost : 0;
897
+ if (next === this.#subagentTreeCost) return;
898
+ this.#subagentTreeCost = next;
899
+ this.#invalidateStatusLineRenderCache();
900
+ }
901
+
891
902
  /**
892
903
  * Compatibility shim for callers predating the simplified subagent badge.
893
904
  * The status line now intentionally shows only the active count.
@@ -2391,6 +2402,9 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2391
2402
  compactionSpeculation,
2392
2403
  speculationBlinkOn: this.#speculationBlinkOn,
2393
2404
  subagentCount: this.#subagentCount,
2405
+ // The tree total describes the main session; a focused subagent's
2406
+ // view falls back to its own completed task results.
2407
+ subagentTreeCost: this.#focusedAgentId ? 0 : this.#subagentTreeCost,
2394
2408
  activeMs: this.getActiveMs(),
2395
2409
  turnElapsedMs,
2396
2410
  now: new Date(nowMs),
@@ -3367,7 +3381,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
3367
3381
  const facts: NativeNode[] = [];
3368
3382
  const collect = (side: "left" | "right", ids: readonly StatusLineSegmentId[]): void => {
3369
3383
  ids.forEach((id, index) => {
3370
- if (COMPOSER_HOMED_SEGMENTS[id] || (id === "pi" && ctx.focusedAgentId === undefined)) return;
3384
+ if (COMPOSER_HOMED_SEGMENTS[id]) return;
3371
3385
  const view = describeSegment(id, ctx);
3372
3386
  if (!view) return;
3373
3387
  const props: TspProps<"seg"> = {
@@ -3397,48 +3411,65 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
3397
3411
  window > 0 ? `${formatNumber(ctx.contextTokens)} of ${formatNumber(window)} tokens` : "No context window",
3398
3412
  ];
3399
3413
  if (boundaries) lines.push(`Auto-compact at ${Math.round(boundaries.thresholdPercent)}%`);
3400
- if (ctx.compactionSpeculation === "running") lines.push("Compaction summary in progress");
3401
- else if (ctx.compactionSpeculation === "armed") lines.push("Compaction summary ready");
3414
+ const speculation = ctx.compactionSpeculation;
3415
+ if (speculation === "running") lines.push("Compaction summary in progress");
3416
+ else if (speculation === "armed") lines.push("Compaction summary ready");
3417
+ // The line spans the whole window: omp's boundary symbols sit where speculation
3418
+ // starts and compaction fires, and the share past the speculation point is accent.
3419
+ const used = pct === null ? null : Math.min(1, pct / 100);
3420
+ const speculationAt =
3421
+ boundaries?.speculationPercent == null ? null : Math.min(1, boundaries.speculationPercent / 100);
3422
+ const marks: TspMeterMark[] = [];
3423
+ if (boundaries) {
3424
+ if (speculationAt !== null) {
3425
+ const start: TspMeterMark = {
3426
+ at: speculationAt,
3427
+ icon: "context.speculation",
3428
+ title: `Compaction summary starts at ${Math.round(speculationAt * 100)}%`,
3429
+ };
3430
+ // Lit while a background summary runs or waits armed.
3431
+ if (speculation !== "idle") start.tone = "accent";
3432
+ marks.push(start);
3433
+ }
3434
+ marks.push({
3435
+ at: Math.min(1, boundaries.thresholdPercent / 100),
3436
+ icon: "context.compaction",
3437
+ title: `Auto-compact at ${Math.round(boundaries.thresholdPercent)}%`,
3438
+ });
3439
+ }
3402
3440
  const context = node(
3403
3441
  "meter",
3404
3442
  {
3405
3443
  role: "omp.composer.context",
3406
- value: pct === null ? null : Math.min(1, pct / 100),
3444
+ value: used,
3407
3445
  style: "bar",
3408
3446
  thresholds: getContextMeterThresholds(window),
3409
- marks: boundaries
3410
- ? [
3411
- {
3412
- at: boundaries.thresholdPercent / 100,
3413
- title: `Auto-compact at ${Math.round(boundaries.thresholdPercent)}%`,
3414
- },
3415
- ]
3416
- : undefined,
3447
+ ...(used !== null && speculationAt !== null && used > speculationAt
3448
+ ? { parts: [{ value: speculationAt }, { value: used - speculationAt, token: "accent" }] }
3449
+ : {}),
3450
+ ...(marks.length > 0 ? { marks } : {}),
3451
+ ...(pct === null ? {} : { label: `${Math.round(pct)}%` }),
3452
+ ...(window > 0 ? { total: formatNumber(window) } : {}),
3417
3453
  title: lines.join("\n"),
3454
+ actions: { click: "status.context" },
3418
3455
  },
3419
3456
  undefined,
3420
3457
  "context",
3421
3458
  );
3422
3459
 
3423
- const share =
3424
- pct !== null && window > 0
3425
- ? `${Math.round(pct)}% of ${formatNumber(window)}`
3426
- : window > 0
3427
- ? formatNumber(window)
3428
- : `${formatNumber(ctx.contextTokens)} tokens`;
3429
- const cost = describeSegment("cost", ctx)
3430
- ?.spans.map(part => part.t)
3431
- .join("");
3432
- if (cost) lines.push(`Session cost ${cost}`);
3460
+ // The meter reads the context; the bar's usage is the session's cost.
3461
+ const cost =
3462
+ describeSegment("cost", ctx)
3463
+ ?.spans.map(part => part.t)
3464
+ .join("") ?? "";
3433
3465
  const usage = node(
3434
3466
  "text",
3435
3467
  {
3436
3468
  role: "omp.composer.usage",
3437
- tone: getContextUsageTone(getContextUsageLevel(pct ?? 0, window)),
3438
- text: cost ? `${share} · ${cost}` : share,
3469
+ text: cost,
3439
3470
  wrap: "none",
3440
- title: lines.join("\n"),
3441
- actions: { click: "status.context" },
3471
+ ...(cost ? { title: `Session cost ${cost}` } : {}),
3472
+ actions: { click: "status.cost" },
3442
3473
  },
3443
3474
  undefined,
3444
3475
  "usage",