@oh-my-pi/pi-tui 18.8.2 → 18.8.4

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.4] - 2026-10-08
6
+
7
+ ### Changed
8
+
9
+ - Agent Hub keeps existing agents in place while open; new agents appear first in the flat roster or within their tree sibling group ([#13066](https://github.com/can1357/oh-my-pi/pull/13066) by [@kmccleary3301](https://github.com/kmccleary3301))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed relative file links in Tern assistant replies opening against the folder omp was started in; they now open the file in the session's working directory ([#14879](https://github.com/can1357/oh-my-pi/pull/14879) by [@H4vC](https://github.com/H4vC))
14
+ - Fixed Tern modals with no clickable exit: BTW history, git shortcuts and autoresearch gain a Close button, plan review a Cancel button, the agent transcript viewer and `/annotate` review a top-right `esc` that runs Esc, and the `/move` dialog Accept, Cancel and Confirm buttons; new `escCloseButton()` builds the `esc` keycap button ([#14894](https://github.com/can1357/oh-my-pi/pull/14894) by [@H4vC](https://github.com/H4vC))
15
+
5
16
  ## [18.8.2] - 2026-10-07
6
17
 
7
18
  ### Removed
@@ -1,4 +1,4 @@
1
- import type { NativeNode } from "../../native/node.js";
1
+ import type { NativeNode, NativeUiEvent } from "../../native/node.js";
2
2
  import type { Component } from "../../tui.js";
3
3
  /** The shortcuts overlay; any of `?`, escape or `q` closes it. */
4
4
  export declare class GitHelpSheet implements Component {
@@ -10,6 +10,8 @@ export declare class GitHelpSheet implements Component {
10
10
  };
11
11
  constructor(close: () => void);
12
12
  handleInput(data: string): void;
13
+ /** Close runs what Esc runs. */
14
+ handleNativeEvent(event: NativeUiEvent): void;
13
15
  describe(): NativeNode;
14
16
  render(width: number): readonly string[];
15
17
  invalidate(): void;
@@ -33,6 +33,17 @@ export interface MarkdownLink {
33
33
  * Duplicate hrefs are kept; callers decide how to fold them.
34
34
  */
35
35
  export declare function extractMarkdownLinks(text: string): MarkdownLink[];
36
+ /**
37
+ * Rewrite the destinations of inline links and link reference definitions in
38
+ * `text` to `resolve(href)`, leaving everything else byte-for-byte intact.
39
+ * Hosts that render the Markdown source themselves (a native terminal's `md`
40
+ * node) resolve relative destinations against their own idea of the working
41
+ * directory; handing them the session-resolved target keeps links pointing at
42
+ * what the author meant. Links inside code, images, autolinks and reference
43
+ * uses (`[x][ref]`, rewritten through their definition) are untouched; a link
44
+ * whose source cannot be located exactly is left as written.
45
+ */
46
+ export declare function rewriteMarkdownLinkDestinations(text: string, resolve: (href: string) => string | undefined): string;
36
47
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
37
48
  export declare function clearRenderCache(): void;
38
49
  /**
@@ -58,6 +58,11 @@ export interface ActionButtonOptions {
58
58
  * which the component routes to the same code path as the key.
59
59
  */
60
60
  export declare function actionButton(label: string, act: string, options?: ActionButtonOptions): NativeNode;
61
+ /**
62
+ * A bare `esc` keycap button for a view's top-right corner: a click sends
63
+ * `act` (default `close`), which the component routes to its Esc path.
64
+ */
65
+ export declare function escCloseButton(act?: string, key?: string): NativeNode;
61
66
  /** A row of {@link actionButton}s (role `omp.actions`); `null` entries become the spacer that end-aligns what follows. */
62
67
  export declare function actionBar(buttons: readonly (NativeNode | null)[], key?: string): NativeNode;
63
68
  /** Spans of an annotation callout: a dim `label:` then the note. */
@@ -37,6 +37,6 @@ export declare function aggregateMetrics<TRecord extends AgentRecordLike>(args:
37
37
  metrics: AggregateMetrics;
38
38
  hasFallbackLiveSessions: boolean;
39
39
  };
40
- /** Parent-before-child projection preserving the roster's stable sibling order. */
41
- export declare function projectAgentTree<TRecord extends AgentRecordLike>(refs: readonly TRecord[]): AgentTreeProjection<TRecord>;
40
+ /** Parent-before-child projection preserving initial subtree rank and prepending new siblings. */
41
+ export declare function projectAgentTree<TRecord extends AgentRecordLike>(refs: readonly TRecord[], rosterRank: ReadonlyMap<TRecord, number> | undefined): AgentTreeProjection<TRecord>;
42
42
  export {};
@@ -114,6 +114,8 @@ export declare class AgentHubOverlayComponent<TRecord extends AgentRecordLike =
114
114
  * was raised by that gesture.
115
115
  */
116
116
  armCloseTap(): void;
117
+ /** Show `section`, as a slash-command deep link into an already-open hub does. */
118
+ showSection(section: AgentHubSection): void;
117
119
  /**
118
120
  * Open the fullscreen transcript viewer for an agent id (public for table Enter
119
121
  * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
@@ -23,7 +23,7 @@ import type { AgentHubRegistry } from "./agent-hub-types.js";
23
23
  import type { SessionMessageEntryLike } from "../chat/transcript-entry.js";
24
24
  import type { SessionObserverRegistry } from "./session-observer-registry.js";
25
25
  import type { AgentHubRemote } from "./agent-hub.js";
26
- import type { DescribeContext, NativeNode } from "../native/node.js";
26
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node.js";
27
27
  /** Parsed message and model metadata relevant to a transcript viewer. */
28
28
  export type AgentTranscriptEntry = SessionMessageEntryLike | {
29
29
  type: "model_change";
@@ -77,6 +77,8 @@ export declare class AgentTranscriptViewer implements Component {
77
77
  dispose(): void;
78
78
  handleInput(data: string): void;
79
79
  render(width: number): readonly string[];
80
+ /** The top-right `esc` runs Esc. */
81
+ handleNativeEvent(event: NativeUiEvent): void;
80
82
  /**
81
83
  * Header, transcript body (the builder's container, which describes its own
82
84
  * blocks), notice, message editor, stats and key hints. Scrolling is the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.8.2",
4
+ "version": "18.8.4",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.8.2",
45
- "@oh-my-pi/pi-agent-core": "18.8.2",
46
- "@oh-my-pi/pi-ai": "18.8.2",
47
- "@oh-my-pi/pi-catalog": "18.8.2",
48
- "@oh-my-pi/pi-natives": "18.8.2",
49
- "@oh-my-pi/pi-utils": "18.8.2",
50
- "@oh-my-pi/pi-wire": "18.8.2",
51
- "@oh-my-pi/snapcompact": "18.8.2"
44
+ "@oh-my-pi/omptype": "18.8.4",
45
+ "@oh-my-pi/pi-agent-core": "18.8.4",
46
+ "@oh-my-pi/pi-ai": "18.8.4",
47
+ "@oh-my-pi/pi-catalog": "18.8.4",
48
+ "@oh-my-pi/pi-natives": "18.8.4",
49
+ "@oh-my-pi/pi-utils": "18.8.4",
50
+ "@oh-my-pi/pi-wire": "18.8.4",
51
+ "@oh-my-pi/snapcompact": "18.8.4"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -17,8 +17,8 @@ import {
17
17
  import { formatNum, type ExperimentResult, type ExperimentState } from "../tools/autoresearch";
18
18
  import type { TspSpan, TspTableColumn, TspText } from "@oh-my-pi/pi-wire";
19
19
  import { card, col, elapsed, keyed, node, row, span, text } from "../native/describe";
20
- import type { DescribeContext, NativeNode } from "../native/node";
21
- import { hintsRow } from "../native/overlay";
20
+ import type { DescribeContext, NativeNode, NativeUiEvent } from "../native/node";
21
+ import { actionBar, actionButton, hintsRow } from "../native/overlay";
22
22
  import { Memo } from "../native/memo";
23
23
  import { isNativeRendering } from "../native/state";
24
24
 
@@ -190,9 +190,9 @@ export function createDashboardController(): DashboardController {
190
190
  { keys: ["up", "down", "j", "k"], label: "scroll" },
191
191
  { keys: ["pageUp", "pageDown"], label: "page" },
192
192
  { keys: ["g", "shift+g"], label: "top/bottom" },
193
- { keys: ["escape"], label: "close" },
194
193
  ]),
195
194
  );
195
+ children.push(actionBar([null, actionButton("Close", "close", { keys: "escape" })]));
196
196
  return col(children, { gap: "md", role: "omp.app.autoresearch" });
197
197
  },
198
198
  );
@@ -214,6 +214,10 @@ export function createDashboardController(): DashboardController {
214
214
  scrollView.setHeight(viewportRows);
215
215
  return [header, ...scrollView.render(width), renderOverlayFooter(width, theme)];
216
216
  },
217
+ /** The Close button runs Esc's path. */
218
+ handleNativeEvent(event: NativeUiEvent): void {
219
+ if (event.type === "action" && event.act === "close") done(undefined);
220
+ },
217
221
  handleInput(data: string): void {
218
222
  if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q") {
219
223
  done(undefined);
@@ -11,7 +11,8 @@ import { formatKeyHint } from "../../app-keybindings";
11
11
  import type { KeyName } from "../../key-hint-format";
12
12
  import { matchesKey } from "../../keys";
13
13
  import { col, node, span } from "../../native/describe";
14
- import type { NativeNode } from "../../native/node";
14
+ import type { NativeNode, NativeUiEvent } from "../../native/node";
15
+ import { actionBar, actionButton } from "../../native/overlay";
15
16
  import { theme } from "../../theme/theme";
16
17
  import type { Component } from "../../tui";
17
18
  import { truncateToWidth, visibleWidth } from "../../utils";
@@ -79,28 +80,36 @@ export class GitHelpSheet implements Component {
79
80
  if (data === "?" || data === "q" || matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) this.#close();
80
81
  }
81
82
 
83
+ /** Close runs what Esc runs. */
84
+ handleNativeEvent(event: NativeUiEvent): void {
85
+ if (event.type === "action" && event.act === "close") this.#close();
86
+ }
87
+
82
88
  describe(): NativeNode {
83
89
  this.#native ??= col(
84
- GROUPS.map(group =>
85
- node(
86
- "section",
87
- { head: group.title, role: "omp.hotkeys.group" },
88
- [
89
- node("table", {
90
- role: "omp.hotkeys.table",
91
- cols: [
92
- { id: "keys", head: "Keys" },
93
- { id: "does", head: "Action", grow: 1 },
94
- ],
95
- rows: group.rows.map((row, index) => ({
96
- id: String(index),
97
- cells: { keys: row.keys.map(key => span(formatKeyHint(key), "key")), does: row.label },
98
- })),
99
- }),
100
- ],
101
- group.title,
90
+ [
91
+ ...GROUPS.map(group =>
92
+ node(
93
+ "section",
94
+ { head: group.title, role: "omp.hotkeys.group" },
95
+ [
96
+ node("table", {
97
+ role: "omp.hotkeys.table",
98
+ cols: [
99
+ { id: "keys", head: "Keys" },
100
+ { id: "does", head: "Action", grow: 1 },
101
+ ],
102
+ rows: group.rows.map((row, index) => ({
103
+ id: String(index),
104
+ cells: { keys: row.keys.map(key => span(formatKeyHint(key), "key")), does: row.label },
105
+ })),
106
+ }),
107
+ ],
108
+ group.title,
109
+ ),
102
110
  ),
103
- ),
111
+ actionBar([null, actionButton("Close", "close", { keys: "escape" })]),
112
+ ],
104
113
  { gap: "lg", role: "omp.app.git.help" },
105
114
  );
106
115
  return this.#native;
@@ -2,7 +2,7 @@ import type { AssistantMessage, ImageContent, TextContent } from "@oh-my-pi/pi-a
2
2
  import { type Component, Container } from "../tui";
3
3
  import { Image, type ImageBudget } from "../components/image";
4
4
  import { ImageProtocol, TERMINAL } from "../terminal-capabilities";
5
- import { Markdown, type MarkdownTheme } from "../components/markdown";
5
+ import { Markdown, type MarkdownTheme, rewriteMarkdownLinkDestinations } from "../components/markdown";
6
6
  import { Spacer } from "../components/spacer";
7
7
  import { Text } from "../components/text";
8
8
  import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
@@ -364,6 +364,8 @@ export class AssistantMessageComponent extends Container {
364
364
  #textColorTransform?: (text: string) => string;
365
365
  #linkTargets: ReadonlyMap<string, string> = EMPTY_LINK_TARGETS;
366
366
  #markdownTheme: MarkdownTheme | undefined;
367
+ /** Text-block sources with {@link #linkTargets} applied, for the native `md` nodes; reset with the targets. */
368
+ #nativeLinkSources = new Map<string, string>();
367
369
  /** Block this reply reacts to; undefined when the preceding block takes no reactions. */
368
370
  #reactionTarget: ReactionTarget | undefined;
369
371
  /** Reaction lifted from the reply's opening emoji, once resolved. */
@@ -415,6 +417,8 @@ export class AssistantMessageComponent extends Container {
415
417
  this.#fastPathKey = undefined;
416
418
  this.#fastPathItems = undefined;
417
419
  for (const block of this.#figureBlocks.values()) block.restyle();
420
+ this.#nativeLinkSources.clear();
421
+ this.#nativeViewVersion++;
418
422
  if (this.#lastMessage) {
419
423
  this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
420
424
  }
@@ -842,8 +846,20 @@ export class AssistantMessageComponent extends Container {
842
846
  const streaming = live && index === tailIndex;
843
847
  if (content.type === "text" && canonicalizeMessage(content.text)) {
844
848
  const source = content.text.trim();
849
+ // The terminal renders this source itself and would resolve a relative
850
+ // link against its own idea of the cwd: hand it the session-resolved
851
+ // targets (resolved once the segment closes, so never while streaming).
852
+ const linked = (text: string, live: boolean): string => {
853
+ if (live || this.#linkTargets.size === 0) return text;
854
+ const targets = this.#linkTargets;
855
+ const resolved =
856
+ this.#nativeLinkSources.get(text) ??
857
+ rewriteMarkdownLinkDestinations(text, href => targets.get(href));
858
+ this.#nativeLinkSources.set(text, resolved);
859
+ return resolved;
860
+ };
845
861
  if (!this.#showImages || !this.#showTableCharts || !hasChartTable(source)) {
846
- children.push(markdown(`t${index}`, `t${index}`, source, streaming));
862
+ children.push(markdown(`t${index}`, `t${index}`, linked(source, streaming), streaming));
847
863
  continue;
848
864
  }
849
865
  // A chart follows its table as an SVG image node; the prose splits around it.
@@ -851,9 +867,8 @@ export class AssistantMessageComponent extends Container {
851
867
  segments.forEach((segment, part) => {
852
868
  const slot = part === 0 ? `t${index}` : `t${index}.${part}`;
853
869
  if (segment.kind === "markdown") {
854
- children.push(
855
- markdown(slot, slot, segment.text.trim(), streaming && part === segments.length - 1),
856
- );
870
+ const live = streaming && part === segments.length - 1;
871
+ children.push(markdown(slot, slot, linked(segment.text.trim(), live), live));
857
872
  return;
858
873
  }
859
874
  const chart = lookupTableChart(segment.table);
@@ -1173,6 +1173,120 @@ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1173
1173
  return links;
1174
1174
  }
1175
1175
 
1176
+ /**
1177
+ * Offset just past the destination that starts at or after `start` in `raw`
1178
+ * (CommonMark link destination grammar), plus where it begins. `null` when no
1179
+ * destination is there.
1180
+ */
1181
+ function scanLinkDestination(raw: string, start: number): { start: number; end: number } | null {
1182
+ let i = start;
1183
+ while (i < raw.length && (raw[i] === " " || raw[i] === "\t" || raw[i] === "\n" || raw[i] === "\r")) i++;
1184
+ if (i >= raw.length) return null;
1185
+ if (raw[i] === "<") {
1186
+ for (let j = i + 1; j < raw.length; j++) {
1187
+ if (raw[j] === "\\") j++;
1188
+ else if (raw[j] === ">") return { start: i, end: j + 1 };
1189
+ else if (raw[j] === "<" || raw[j] === "\n") return null;
1190
+ }
1191
+ return null;
1192
+ }
1193
+ let depth = 0;
1194
+ let j = i;
1195
+ for (; j < raw.length; j++) {
1196
+ const ch = raw[j]!;
1197
+ if (ch === "\\") {
1198
+ j++;
1199
+ continue;
1200
+ }
1201
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch.charCodeAt(0) < 0x20) break;
1202
+ if (ch === "(") depth++;
1203
+ else if (ch === ")") {
1204
+ if (depth === 0) break;
1205
+ depth--;
1206
+ }
1207
+ }
1208
+ return j > i ? { start: i, end: j } : null;
1209
+ }
1210
+
1211
+ /** A destination that stays one destination whatever bytes the target holds. */
1212
+ function formatLinkDestination(target: string): string {
1213
+ return /[\s()<>\\]/.test(target) ? `<${target.replaceAll(/[<>\\]/g, ch => encodeURIComponent(ch))}>` : target;
1214
+ }
1215
+
1216
+ function isListToken(token: Token): token is Tokens.List {
1217
+ return token.type === "list";
1218
+ }
1219
+
1220
+ function isTableToken(token: Token): token is Tokens.Table {
1221
+ return token.type === "table";
1222
+ }
1223
+
1224
+ /**
1225
+ * Rewrite the destinations of inline links and link reference definitions in
1226
+ * `text` to `resolve(href)`, leaving everything else byte-for-byte intact.
1227
+ * Hosts that render the Markdown source themselves (a native terminal's `md`
1228
+ * node) resolve relative destinations against their own idea of the working
1229
+ * directory; handing them the session-resolved target keeps links pointing at
1230
+ * what the author meant. Links inside code, images, autolinks and reference
1231
+ * uses (`[x][ref]`, rewritten through their definition) are untouched; a link
1232
+ * whose source cannot be located exactly is left as written.
1233
+ */
1234
+ export function rewriteMarkdownLinkDestinations(text: string, resolve: (href: string) => string | undefined): string {
1235
+ const edits: Array<{ start: number; end: number; target: string }> = [];
1236
+ let cursor = 0;
1237
+ const rewrite = (token: Token, labelEnd: number | undefined): void => {
1238
+ const at = text.indexOf(token.raw, cursor);
1239
+ if (at < 0) return;
1240
+ cursor = at + token.raw.length;
1241
+ const href = "href" in token && typeof token.href === "string" ? token.href : "";
1242
+ if (!href || labelEnd === undefined) return;
1243
+ const target = resolve(href);
1244
+ if (!target || target === href) return;
1245
+ const dest = scanLinkDestination(token.raw, labelEnd);
1246
+ if (dest) edits.push({ start: at + dest.start, end: at + dest.end, target });
1247
+ };
1248
+ const walk = (tokens: readonly Token[] | undefined): void => {
1249
+ if (!tokens) return;
1250
+ for (const token of tokens) {
1251
+ if (token.type === "link") {
1252
+ const label = "text" in token && typeof token.text === "string" ? `[${token.text}](` : undefined;
1253
+ rewrite(token, label && token.raw.startsWith(label) ? label.length : undefined);
1254
+ continue;
1255
+ }
1256
+ if (token.type === "def") {
1257
+ const close = token.raw.indexOf("]:");
1258
+ rewrite(token, close < 0 ? undefined : close + 2);
1259
+ continue;
1260
+ }
1261
+ const children = "tokens" in token && Array.isArray(token.tokens) ? token.tokens : undefined;
1262
+ const items = isListToken(token) ? token.items : undefined;
1263
+ const table = isTableToken(token) ? token : undefined;
1264
+ if (token.type === "image" || (!children && !items && !table)) {
1265
+ // A leaf (text, code span, fenced code, html, image…): step past it so a
1266
+ // later link's source is never matched inside it.
1267
+ const at = token.raw ? text.indexOf(token.raw, cursor) : -1;
1268
+ if (at >= 0) cursor = at + token.raw.length;
1269
+ continue;
1270
+ }
1271
+ walk(children);
1272
+ walk(items);
1273
+ if (table) {
1274
+ for (const cell of table.header) walk(cell.tokens);
1275
+ for (const row of table.rows) for (const cell of row) walk(cell.tokens);
1276
+ }
1277
+ }
1278
+ };
1279
+ walk(markdownParser.lexer(text));
1280
+ if (edits.length === 0) return text;
1281
+ let out = "";
1282
+ let last = 0;
1283
+ for (const edit of edits) {
1284
+ out += text.slice(last, edit.start) + formatLinkDestination(edit.target);
1285
+ last = edit.end;
1286
+ }
1287
+ return out + text.slice(last);
1288
+ }
1289
+
1176
1290
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
1177
1291
  export function clearRenderCache(): void {
1178
1292
  renderCache.clear();
@@ -119,6 +119,19 @@ export function actionButton(label: string, act: string, options: ActionButtonOp
119
119
  );
120
120
  }
121
121
 
122
+ /**
123
+ * A bare `esc` keycap button for a view's top-right corner: a click sends
124
+ * `act` (default `close`), which the component routes to its Esc path.
125
+ */
126
+ export function escCloseButton(act = "close", key = "esc-close"): NativeNode {
127
+ return node(
128
+ "row",
129
+ { role: "omp.btn", align: "center", actions: { click: act }, title: `Close ${formatTooltipKey("escape")}` },
130
+ [kbd("escape")],
131
+ key,
132
+ );
133
+ }
134
+
122
135
  /** A row of {@link actionButton}s (role `omp.actions`); `null` entries become the spacer that end-aligns what follows. */
123
136
  export function actionBar(buttons: readonly (NativeNode | null)[], key = "actions"): NativeNode {
124
137
  const children = buttons.map(button => button ?? node("spacer", { grow: 1 }));
@@ -251,9 +251,10 @@ export function aggregateMetrics<TRecord extends AgentRecordLike>(args: {
251
251
  return { metrics: total, hasFallbackLiveSessions };
252
252
  }
253
253
 
254
- /** Parent-before-child projection preserving the roster's stable sibling order. */
254
+ /** Parent-before-child projection preserving initial subtree rank and prepending new siblings. */
255
255
  export function projectAgentTree<TRecord extends AgentRecordLike>(
256
256
  refs: readonly TRecord[],
257
+ rosterRank: ReadonlyMap<TRecord, number> | undefined,
257
258
  ): AgentTreeProjection<TRecord> {
258
259
  const ids = new Set<string>();
259
260
  const operationalIndex = new Map<string, number>();
@@ -286,7 +287,12 @@ export function projectAgentTree<TRecord extends AgentRecordLike>(
286
287
  if (!current) continue;
287
288
  if (current.expanded) {
288
289
  let order = operationalIndex.get(current.ref.id) ?? Number.MAX_SAFE_INTEGER;
290
+ const parentRank = rosterRank?.get(current.ref);
289
291
  for (const child of children.get(current.ref.id) ?? []) {
292
+ const childRank = rosterRank?.get(child);
293
+ // A child spawned later cannot move an existing parent's group.
294
+ if (parentRank !== undefined && childRank !== undefined && childRank < 0 && childRank < parentRank)
295
+ continue;
290
296
  order = Math.min(order, subtreeOrder.get(child.id) ?? Number.MAX_SAFE_INTEGER);
291
297
  }
292
298
  subtreeOrder.set(current.ref.id, order);
@@ -200,6 +200,12 @@ const DATA_CHANGE_RENDER_COALESCE_MS = 100;
200
200
  /** Double-tap window for the table's left-left "close hub" gesture. */
201
201
  const LEFT_TAP_WINDOW_MS = 500;
202
202
 
203
+ function compareRosterAgents(a: AgentRecordLike, b: AgentRecordLike): number {
204
+ return (
205
+ STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity || a.id.localeCompare(b.id)
206
+ );
207
+ }
208
+
203
209
  function activityGlyph(row: AgentActivityRow): string {
204
210
  if (row.status === "error") return theme.fg("error", theme.status.error);
205
211
  if (row.status === "aborted") return theme.fg("warning", theme.status.aborted);
@@ -358,11 +364,10 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
358
364
  #rows: TRecord[] = [];
359
365
  #statusCounts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
360
366
  #selectedRow = 0;
361
- /** Stable roster order captured on first refresh: keyboard navigation must
362
- * not jump as agents heartbeat. Existing agent generations keep their rank
363
- * while the hub is open; newly appearing generations append at the end. */
367
+ /** Initial status/recency rank stays fixed while open; new generations
368
+ * prepend without changing the relative order of existing rows. */
364
369
  #rowOrder: Map<TRecord, number> | undefined;
365
- #nextRowOrder = 0;
370
+ #nextNewRowOrder = 0;
366
371
  #hoveredRow: number | null = null;
367
372
  /** Per-render screen-line to agent-row map, shared by click and hover routing. */
368
373
  #hitRows: Array<number | undefined> = [];
@@ -616,6 +621,11 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
616
621
  this.#lastLeftTap = Date.now();
617
622
  }
618
623
 
624
+ /** Show `section`, as a slash-command deep link into an already-open hub does. */
625
+ showSection(section: AgentHubSection): void {
626
+ this.#switchSection(section);
627
+ }
628
+
619
629
  /**
620
630
  * Open the fullscreen transcript viewer for an agent id (public for table Enter
621
631
  * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
@@ -1519,35 +1529,35 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1519
1529
  const refs = this.#registry.list().filter(ref => ref.id !== MAIN_AGENT_ID);
1520
1530
  this.#observedById = new Map();
1521
1531
  for (const session of this.#observers.getSessions()) this.#observedById.set(session.id, session);
1522
- // Stable roster order: capture the status+recency ranking once so keyboard
1523
- // navigation is not disrupted by heartbeats (issue #10524). Existing rows
1524
- // keep their rank while the hub is open; new agents append at the end.
1525
- // Defer the capture until persisted-subagent discovery settles so a
1526
- // mid-scan refresh cannot freeze a partial roster (the remaining agents
1527
- // would otherwise append in readdir order instead of being ranked).
1532
+ // Capture the status+recency ranking once so keyboard navigation does
1533
+ // not jump on heartbeats. Defer until persisted-subagent discovery
1534
+ // settles so a partial roster is not frozen in readdir order.
1528
1535
  const rowOrder = this.#rowOrder;
1529
1536
  let ordered: TRecord[];
1530
1537
  if (!rowOrder) {
1531
- ordered = refs.sort(
1532
- (a, b) =>
1533
- STATUS_ORDER[a.status] - STATUS_ORDER[b.status] ||
1534
- b.lastActivity - a.lastActivity ||
1535
- a.id.localeCompare(b.id),
1536
- );
1538
+ ordered = refs.sort(compareRosterAgents);
1537
1539
  if (!this.#loadingPersistedSubagents && ordered.length > 0) {
1538
- this.#rowOrder = new Map();
1539
- for (const ref of ordered) this.#rowOrder.set(ref, this.#nextRowOrder++);
1540
+ const rowOrder = new Map<TRecord, number>();
1541
+ for (const [index, ref] of ordered.entries()) rowOrder.set(ref, index);
1542
+ this.#rowOrder = rowOrder;
1540
1543
  }
1541
1544
  } else {
1542
1545
  for (const rankedRef of rowOrder.keys()) {
1543
1546
  if (!refs.includes(rankedRef)) rowOrder.delete(rankedRef);
1544
1547
  }
1545
- ordered = refs.sort(
1546
- (a, b) => (rowOrder.get(a) ?? Number.MAX_SAFE_INTEGER) - (rowOrder.get(b) ?? Number.MAX_SAFE_INTEGER),
1547
- );
1548
- for (const ref of ordered) {
1549
- if (!rowOrder.has(ref)) rowOrder.set(ref, this.#nextRowOrder++);
1548
+ // Each batch of newly appearing generations uses the initial sort,
1549
+ // then takes ranks above every agent already on screen.
1550
+ let newcomers: TRecord[] | undefined;
1551
+ for (const ref of refs) {
1552
+ if (!rowOrder.has(ref)) (newcomers ??= []).push(ref);
1553
+ }
1554
+ if (newcomers) {
1555
+ newcomers.sort(compareRosterAgents);
1556
+ for (let i = newcomers.length - 1; i >= 0; i--) {
1557
+ rowOrder.set(newcomers[i]!, --this.#nextNewRowOrder);
1558
+ }
1550
1559
  }
1560
+ ordered = refs.sort((a, b) => rowOrder.get(a)! - rowOrder.get(b)!);
1551
1561
  }
1552
1562
  const query = this.#agentFilter.getValue().trim();
1553
1563
  const rosterRows =
@@ -1556,7 +1566,7 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
1556
1566
  : ordered;
1557
1567
 
1558
1568
  if (this.#viewMode === "tree") {
1559
- const tree = projectAgentTree(rosterRows);
1569
+ const tree = projectAgentTree(rosterRows, rowOrder);
1560
1570
  this.#rows = tree.rows;
1561
1571
  this.#treeDepthById = tree.depthById;
1562
1572
  this.#treeParentById = tree.parentById;
@@ -41,8 +41,8 @@ import { sanitizeErrorLine } from "../chrome/error-block";
41
41
  import type { ScrollRangeAnchor } from "../components/scroll-view";
42
42
  import { formatContextUsage } from "../chrome/context-thresholds";
43
43
  import { node, span, text } from "../native/describe";
44
- import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
45
- import { actionHint, hintsRow, overlayCard } from "../native/overlay";
44
+ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
45
+ import { actionHint, escCloseButton, hintsRow, overlayCard } from "../native/overlay";
46
46
 
47
47
  /** Parsed message and model metadata relevant to a transcript viewer. */
48
48
  export type AgentTranscriptEntry = SessionMessageEntryLike | { type: "model_change"; model: string };
@@ -559,12 +559,7 @@ export class AgentTranscriptViewer implements Component {
559
559
  }
560
560
 
561
561
  if (matchesKey(data, "escape")) {
562
- if (this.#editor && this.#editor.getText().trim() !== "") {
563
- this.#editor.setText("");
564
- this.#deps.requestRender();
565
- return;
566
- }
567
- this.#deps.onClose();
562
+ this.#escape();
568
563
  return;
569
564
  }
570
565
 
@@ -647,6 +642,21 @@ export class AgentTranscriptViewer implements Component {
647
642
  return lines;
648
643
  }
649
644
 
645
+ /** The top-right `esc` runs Esc. */
646
+ handleNativeEvent(event: NativeUiEvent): void {
647
+ if (event.type === "action" && event.act === "close") this.#escape();
648
+ }
649
+
650
+ /** Esc: clear a non-empty draft first, else close the viewer. */
651
+ #escape(): void {
652
+ if (this.#editor && this.#editor.getText().trim() !== "") {
653
+ this.#editor.setText("");
654
+ this.#deps.requestRender();
655
+ return;
656
+ }
657
+ this.#deps.onClose();
658
+ }
659
+
650
660
  /**
651
661
  * Header, transcript body (the builder's container, which describes its own
652
662
  * blocks), notice, message editor, stats and key hints. Scrolling is the
@@ -675,6 +685,8 @@ export class AgentTranscriptViewer implements Component {
675
685
 
676
686
  const id = this.#deps.agentId;
677
687
  const children: NativeChild[] = [];
688
+ // Top row: the agent's meta on the left, a clickable `esc` (close) on the right.
689
+ const top: NativeChild[] = [];
678
690
  if (ref) {
679
691
  const kindTag = ref.parentId ? `${ref.kind} ${theme.sep.dot} of ${ref.parentId}` : ref.kind;
680
692
  const meta: NativeChild[] = [
@@ -683,8 +695,12 @@ export class AgentTranscriptViewer implements Component {
683
695
  text([span(kindTag, "dim")], { truncate: "end" }),
684
696
  ];
685
697
  if (this.#model) meta.push(text([span(this.#model, "muted")], { truncate: "end" }));
686
- children.push(node("row", { gap: "sm", align: "center" }, meta, "meta"));
698
+ top.push(node("row", { gap: "sm", align: "center", grow: 1, min: { w: 0 } }, meta, "meta"));
699
+ } else {
700
+ top.push(node("spacer", { grow: 1 }));
687
701
  }
702
+ top.push(escCloseButton());
703
+ children.push(node("row", { gap: "md", align: "center" }, top, "top"));
688
704
  children.push(
689
705
  placeholder === undefined
690
706
  ? node("col", { grow: 1 }, [this.#builder.container], "transcript")
@@ -747,7 +763,6 @@ export class AgentTranscriptViewer implements Component {
747
763
  children.push(
748
764
  hintsRow([
749
765
  this.#editor ? actionHint("tui.input.submit", "send") : undefined,
750
- { keys: ["escape"], label: "close" },
751
766
  { keys: [this.#deps.expandKeys[0] ?? "ctrl+o"], label: "expand" },
752
767
  ]),
753
768
  );
@@ -42,7 +42,15 @@ import { matchesAppExternalEditor } from "../keybinding-matchers";
42
42
  import type { KeyName } from "../key-hint-format";
43
43
  import { item, keyed, node, row as rowNode, span, text } from "../native/describe";
44
44
  import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
45
- import { hintsRow, itemIndex, type NativeHint, noteSpans, overlayCard, selectList } from "../native/overlay";
45
+ import {
46
+ escCloseButton,
47
+ hintsRow,
48
+ itemIndex,
49
+ type NativeHint,
50
+ noteSpans,
51
+ overlayCard,
52
+ selectList,
53
+ } from "../native/overlay";
46
54
 
47
55
  function fit(text: string, width: number): string {
48
56
  if (width <= 0) return "";
@@ -317,7 +325,7 @@ export class AnnotationOverlay implements Focusable {
317
325
  return;
318
326
  }
319
327
  if (this.#keybindings.matches(data, "tui.select.cancel")) {
320
- this.#finish(undefined);
328
+ this.#escape();
321
329
  return;
322
330
  }
323
331
  if (data === "A" && (this.#focus === "files" || this.#focus === "diff")) {
@@ -369,6 +377,13 @@ export class AnnotationOverlay implements Focusable {
369
377
  }
370
378
  }
371
379
 
380
+ /** Esc, by state: leave the note chooser, drop the note draft, else close the review. */
381
+ #escape(): void {
382
+ if (this.#annotationChooser) this.#annotationChooser = undefined;
383
+ else if (this.#annotating) this.#cancelAnnotation();
384
+ else this.#finish(undefined);
385
+ }
386
+
372
387
  #cycleFocus(direction: number): void {
373
388
  const regions: FocusRegion[] = this.#sidebarShown ? ["files", "diff", "actions"] : ["diff", "actions"];
374
389
  const current = regions.indexOf(this.#focus);
@@ -1263,7 +1278,10 @@ export class AnnotationOverlay implements Focusable {
1263
1278
  role: this.#textSource ? "omp.overlay.annotateText.lines" : "omp.overlay.codeReview.diff",
1264
1279
  tone: this.#focus === "diff" ? "accent" : undefined,
1265
1280
  });
1266
- const main = node("col", { grow: 1 }, [this.#describeHeader(), lines], "main");
1281
+ // The header row ends in a clickable `esc` at the top right.
1282
+ const title = node("col", { grow: 1, min: { w: 0 } }, [this.#describeHeader()], "title");
1283
+ const head = keyed(rowNode([title, escCloseButton()], { gap: "md", align: "center" }), "head");
1284
+ const main = node("col", { grow: 1 }, [head, lines], "main");
1267
1285
  if (this.#sidebarShown) {
1268
1286
  const files = selectList(
1269
1287
  "files",
@@ -1312,6 +1330,10 @@ export class AnnotationOverlay implements Focusable {
1312
1330
 
1313
1331
  handleNativeEvent(event: NativeUiEvent): void {
1314
1332
  if (this.#finished || this.#externalOperation) return;
1333
+ if (event.type === "action" && event.act === "close") {
1334
+ this.#escape();
1335
+ return;
1336
+ }
1315
1337
  if (event.type !== "select" && event.type !== "activate") return;
1316
1338
  const activate = event.type === "activate";
1317
1339
  const leaf = leafKey(event.key);
@@ -37,7 +37,7 @@ import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList
37
37
  import type { TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
38
38
  import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
- import { actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
40
+ import { actionBar, actionButton, actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
41
41
 
42
42
  interface BtwHistoryPanelOptions {
43
43
  records: readonly BtwHistoryRecord[];
@@ -485,6 +485,13 @@ export class BtwHistoryPanel implements Component, Focusable {
485
485
  children.push(node("col", undefined, lines, "composer"));
486
486
  }
487
487
  children.push({ ...this.#describeFooter(record, canFollowUp, copied), key: "footer" });
488
+ // Clicking Close always dismisses the sheet; the Esc keycap only shows where Esc does too.
489
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running";
490
+ const escapeCloses = !composer && !(running && !this.#options.escapeHides);
491
+ children.push({
492
+ ...actionBar([null, actionButton("Close", "close", escapeCloses ? { keys: "escape" } : undefined)]),
493
+ key: "actions",
494
+ });
488
495
  // The sheet is the frame (`nativeOverlay`): a borderless column.
489
496
  const described = col(children, { gap: "md" });
490
497
  this.#native = {
@@ -504,6 +511,10 @@ export class BtwHistoryPanel implements Component, Focusable {
504
511
  }
505
512
 
506
513
  handleNativeEvent(event: NativeUiEvent): void {
514
+ if (event.type === "action" && event.act === "close") {
515
+ this.#options.onClose();
516
+ return;
517
+ }
507
518
  if ((event.type !== "select" && event.type !== "activate") || this.#composer) return;
508
519
  const index = this.#records.findIndex(record => record.id === event.item);
509
520
  if (index === -1) return;
@@ -15,7 +15,8 @@ import { formatKeyHint } from "../app-keybindings";
15
15
  import { editorKey, editorKeys } from "../chrome/keybinding-hints";
16
16
  import type { NativeNode, NativeUiEvent } from "../native/node";
17
17
  import { node, span } from "../native/describe";
18
- import { actionHint, overlayCard, statusHintsRow } from "../native/overlay";
18
+ import { actionBar, actionButton, actionHint, overlayCard, statusHintsRow } from "../native/overlay";
19
+ import { getKeybindings } from "../keybindings";
19
20
 
20
21
  export interface MoveOverlayResult {
21
22
  directory: string;
@@ -122,12 +123,7 @@ export class MoveOverlay implements Component, Focusable {
122
123
  return;
123
124
  }
124
125
  if (matchesKey(data, Key.tab)) {
125
- const selected = this.#results[this.#selectedIndex];
126
- if (selected) {
127
- this.#field.setValue(selected.value);
128
- this.#selectedIndex = 0;
129
- this.#updateResults();
130
- }
126
+ this.#accept();
131
127
  return;
132
128
  }
133
129
  const before = this.#field.getValue();
@@ -192,19 +188,28 @@ export class MoveOverlay implements Component, Focusable {
192
188
  );
193
189
  const hints = statusHintsRow(
194
190
  [span("Type to filter", "dim")],
195
- [
196
- actionHint(["tui.select.up", "tui.select.down"], "navigate"),
197
- { keys: ["tab"], label: "accept" },
198
- { keys: ["enter"], label: "confirm" },
199
- actionHint("tui.select.cancel", "cancel"),
200
- ],
191
+ [actionHint(["tui.select.up", "tui.select.down"], "navigate")],
201
192
  );
202
- const described = overlayCard("omp.dialog.move", "Move to directory", [this.#field, list, hints]);
193
+ // Accept, Confirm and Cancel run what Tab, Enter and Esc run.
194
+ const actions = actionBar([
195
+ actionButton("Accept", "accept", { keys: "tab" }),
196
+ null,
197
+ // The bound cancel key; Esc also cancels when it is unbound.
198
+ actionButton("Cancel", "cancel", { keys: getKeybindings().getKeys("tui.select.cancel")[0] ?? "escape" }),
199
+ actionButton("Confirm", "confirm", { keys: "enter", tone: "accent" }),
200
+ ]);
201
+ const described = overlayCard("omp.dialog.move", "Move to directory", [this.#field, list, hints, actions]);
203
202
  this.#native = { revision: this.#revision, node: described };
204
203
  return described;
205
204
  }
206
205
 
207
206
  handleNativeEvent(event: NativeUiEvent): void {
207
+ if (event.type === "action") {
208
+ if (event.act === "accept") this.#accept();
209
+ else if (event.act === "confirm") this.#field.submit();
210
+ else if (event.act === "cancel") this.#done(undefined);
211
+ return;
212
+ }
208
213
  if (event.type !== "select" && event.type !== "activate") return;
209
214
  const index = this.#results.findIndex(result => result.value === event.item);
210
215
  if (index === -1) return;
@@ -216,6 +221,15 @@ export class MoveOverlay implements Component, Focusable {
216
221
  if (event.type === "activate") this.#field.submit();
217
222
  }
218
223
 
224
+ /** Tab: fill the field with the highlighted suggestion. */
225
+ #accept(): void {
226
+ const selected = this.#results[this.#selectedIndex];
227
+ if (!selected) return;
228
+ this.#field.setValue(selected.value);
229
+ this.#selectedIndex = 0;
230
+ this.#updateResults();
231
+ }
232
+
219
233
  invalidate(): void {
220
234
  this.#field.input.prompt = theme.fg("dim", "Path: ");
221
235
  this.#field.invalidate();
@@ -1607,7 +1607,7 @@ export class PlanReviewOverlay implements Component {
1607
1607
  return this.#nativeRoot;
1608
1608
  }
1609
1609
 
1610
- /** Head tools: Copy `c` and the external editor, mirroring their keys. */
1610
+ /** Head tools: Copy `c`, the external editor and Cancel, mirroring their keys. */
1611
1611
  #describeTools(): NativeNode | undefined {
1612
1612
  if (this.#committed) return undefined;
1613
1613
  const buttons: NativeNode[] = [];
@@ -1618,6 +1618,12 @@ export class PlanReviewOverlay implements Component {
1618
1618
  if (this.callbacks.onExternalEditor) {
1619
1619
  buttons.push(actionButton("Edit in $EDITOR", "externalEditor", editorKeyId ? { keys: editorKeyId } : {}));
1620
1620
  }
1621
+ // Cancel the review the way the cancel key does; while annotating that key
1622
+ // only leaves the draft/chooser, so the button is hidden there.
1623
+ if (!this.#annotating && !this.#annotationChooser) {
1624
+ const cancelKeyId = getKeybindings().getKeys("tui.select.cancel")[0];
1625
+ buttons.push(actionButton("Cancel", "cancel", cancelKeyId ? { keys: cancelKeyId } : {}));
1626
+ }
1621
1627
  if (buttons.length === 0) return undefined;
1622
1628
  return node("row", { role: "omp.plan.tools", gap: "sm", align: "center", justify: "end" }, buttons, "tools");
1623
1629
  }
@@ -1625,12 +1631,15 @@ export class PlanReviewOverlay implements Component {
1625
1631
  handleNativeEvent(event: NativeUiEvent): void {
1626
1632
  if (this.#committed) return;
1627
1633
  if (event.type === "action") {
1628
- // Head tools run exactly what `c` and the external-editor key run.
1634
+ // Head tools run exactly what `c`, the external-editor key and Esc run.
1629
1635
  if (this.#annotating || this.#annotationChooser) return;
1630
1636
  if (event.act === "copyPlan" && this.callbacks.onCopyPlan) {
1631
1637
  void this.callbacks.onCopyPlan(joinPlanSections(this.#sections));
1632
1638
  } else if (event.act === "externalEditor") {
1633
1639
  this.callbacks.onExternalEditor?.();
1640
+ } else if (event.act === "cancel") {
1641
+ this.#committed = true;
1642
+ this.callbacks.onCancel();
1634
1643
  }
1635
1644
  return;
1636
1645
  }