@oh-my-pi/pi-tui 18.8.3 → 18.8.5

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 (32) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/types/apps/git/help.d.ts +3 -1
  3. package/dist/types/chat/user-message.d.ts +1 -1
  4. package/dist/types/components/markdown.d.ts +11 -0
  5. package/dist/types/native/overlay.d.ts +5 -0
  6. package/dist/types/overlays/agent-hub-projection.d.ts +2 -2
  7. package/dist/types/overlays/agent-transcript-viewer.d.ts +3 -1
  8. package/dist/types/overlays/model-browser.d.ts +13 -0
  9. package/dist/types/overlays/model-hub.d.ts +6 -0
  10. package/dist/types/overlays/usage-dashboard.d.ts +5 -3
  11. package/dist/types/prompt/custom-editor.d.ts +2 -2
  12. package/dist/types/prompt/image-source.d.ts +2 -1
  13. package/package.json +9 -9
  14. package/src/apps/autoresearch-dashboard.ts +7 -3
  15. package/src/apps/git/help.ts +29 -20
  16. package/src/chat/assistant-message.ts +20 -5
  17. package/src/chat/user-message.ts +1 -1
  18. package/src/components/editor.ts +6 -3
  19. package/src/components/markdown.ts +114 -0
  20. package/src/native/overlay.ts +13 -0
  21. package/src/overlays/agent-hub-projection.ts +7 -1
  22. package/src/overlays/agent-hub.ts +29 -24
  23. package/src/overlays/agent-transcript-viewer.ts +25 -10
  24. package/src/overlays/annotation-overlay.ts +25 -3
  25. package/src/overlays/btw-history-panel.ts +12 -1
  26. package/src/overlays/model-browser.ts +26 -1
  27. package/src/overlays/model-hub.ts +133 -17
  28. package/src/overlays/move-overlay.ts +28 -14
  29. package/src/overlays/plan-review-overlay.ts +11 -2
  30. package/src/overlays/usage-dashboard.ts +18 -7
  31. package/src/prompt/custom-editor.ts +3 -4
  32. package/src/prompt/image-source.ts +2 -1
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.5] - 2026-10-08
6
+
7
+ ### Added
8
+
9
+ - Added a **Compacts at** fact to the model hub preview and a Roles-view **Compaction limit** action (`k`) that edits the selected row's model limit through the new `ModelBrowserSource.compactionPointFor` and `ModelHubCallbacks.onCompactionPointChange` hooks ([#14952](https://github.com/can1357/oh-my-pi/pull/14952) by [@H4vC](https://github.com/H4vC))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed `/usage` dashboard cards reordering their limit rows by usage; rows now keep the provider's window order (e.g. 5 hour → weekly → monthly) ([#14953](https://github.com/can1357/oh-my-pi/pull/14953) by [@H4vC](https://github.com/H4vC))
14
+ - Fixed Cmd+A, Cmd+C, Cmd+X and Shift+arrow selection doing nothing in the Tern prompt while Vim mode is in Insert mode ([#14954](https://github.com/can1357/oh-my-pi/pull/14954) by [@H4vC](https://github.com/H4vC))
15
+
16
+ ## [18.8.4] - 2026-10-08
17
+
18
+ ### Changed
19
+
20
+ - 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))
21
+
22
+ ### Fixed
23
+
24
+ - 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))
25
+ - 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))
26
+
5
27
  ## [18.8.2] - 2026-10-07
6
28
 
7
29
  ### 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;
@@ -4,7 +4,7 @@ import type { ReactionTarget } from "./reaction.js";
4
4
  import { type DescribeContext, type NativeNode, type NativeUiEvent } from "../native/node.js";
5
5
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
6
6
  export interface UserBubbleOptions {
7
- /** Materialized `file://` targets per attached image, indexed by chip number. */
7
+ /** Filesystem paths for attached image chips, indexed by chip number. */
8
8
  imageLinks?: readonly (string | undefined)[];
9
9
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
10
10
  images?: readonly ImageContent[];
@@ -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 {};
@@ -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
@@ -37,6 +37,17 @@ export interface ResolvedModelRoleValue {
37
37
  explicitThinkingLevel: boolean;
38
38
  warning?: string;
39
39
  }
40
+ /** Where auto-compaction triggers for one model, as the model hub's preview shows it (edited from the Roles view). */
41
+ export interface ModelCompactionPoint {
42
+ /** Context tokens that trigger auto-compaction; undefined when auto-compaction is off or the window is unknown. */
43
+ tokens: number | undefined;
44
+ /** The configured percentage of the window, when the trigger is percent-based. */
45
+ percent?: number;
46
+ /** What sets it: the matching `compaction.modelThresholds` key, `global`, or `default`. */
47
+ source: string;
48
+ /** The model's own exact entry as editable text (`90k`, `80%`); absent when it has none. */
49
+ draft?: string;
50
+ }
40
51
  /** Host-provided preferences and model-role resolution for the browser. */
41
52
  export interface ModelBrowserSource extends ModelRoleLookup {
42
53
  /**
@@ -58,6 +69,8 @@ export interface ModelBrowserSource extends ModelRoleLookup {
58
69
  getRoleInfo(role: string): ModelBrowserRoleInfo;
59
70
  defaultRoleChain(role: string): string[];
60
71
  resolveRoleValue(value: string | undefined, models: Model[], roleLookup?: ModelRoleLookup): ResolvedModelRoleValue;
72
+ /** Auto-compaction point for `model`; absent hosts show no compaction row in the preview. */
73
+ compactionPointFor?(model: Model): ModelCompactionPoint | undefined;
61
74
  }
62
75
  /** Read-only catalog surface consumed by model browsers. */
63
76
  export interface ModelBrowserRegistry {
@@ -64,6 +64,12 @@ export interface ModelHubCallbacks {
64
64
  onSwitchPreset?: (name: string) => void | Promise<void>;
65
65
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
66
66
  onCycleOrderChange?: (order: string[]) => void;
67
+ /**
68
+ * Persist typed text as `model`'s own compaction point: a token count (`90000`,
69
+ * `90k`, `1M`), a percentage (`80%`), or empty to reset. Returns an error
70
+ * message for input it rejects, which keeps the field open.
71
+ */
72
+ onCompactionPointChange?: (model: Model, input: string) => string | undefined;
67
73
  onCancel: () => void;
68
74
  }
69
75
  export interface ModelHubOptions {
@@ -33,7 +33,7 @@ export interface ProviderCard {
33
33
  /** Number of represented accounts, including unavailable usage lookups. */
34
34
  accounts: number;
35
35
  unavailableAccounts: string[];
36
- /** Window rows sorted most-pressing first. */
36
+ /** Window rows in provider-declared order (e.g. 5h → weekly → monthly); the fullest CARD_MAX_WINDOWS lead. */
37
37
  windows: CardWindowRow[];
38
38
  /** True when every account reports no limits (e.g. enterprise plans). */
39
39
  unlimited: boolean;
@@ -52,8 +52,10 @@ export interface ProviderCard {
52
52
  * Collapse usage reports into one compact card per provider: limits grouped by
53
53
  * quota bucket (label + window), each bucket showing the mean used fraction
54
54
  * across accounts (matching the classic report's aggregate "% free") with the
55
- * most-used account's reset countdown. Cards sort most-pressing first so
56
- * what's burning is on top-left; fully idle providers collapse into a tick.
55
+ * most-used account's reset countdown. Rows keep the provider's declared window
56
+ * order so 5h → week → month reads the same every time; cards sort by their
57
+ * fullest window so what's burning is on top-left; fully idle providers
58
+ * collapse into a tick.
57
59
  */
58
60
  export declare function buildProviderCards(reports: UsageReport[], nowMs: number, unavailableAccounts?: readonly UnavailableUsageAccount[]): ProviderCard[];
59
61
  /** GitHub-style week-per-column heatmap grid derived from daily activity. */
@@ -118,8 +118,8 @@ export declare class CustomEditor extends Editor {
118
118
  * Numbered by a per-draft monotonic counter so a deleted chip never recycles its number
119
119
  * (labels key the atom table). */
120
120
  pendingTexts: TextAttachment[];
121
- /** Host-wired producer of per-image `file://` links (session blob store); drives clickable
122
- * chip tokens for restored drafts (esc-esc, `/tree`, branch). */
121
+ /** Host-wired producer of per-image chip targets (a file on disk, or a session blob copy);
122
+ * drives clickable chip tokens for restored drafts (esc-esc, `/tree`, branch). */
123
123
  draftImageLinkMaterializer?: (images: readonly ImageContent[]) => Promise<(string | undefined)[] | undefined>;
124
124
  /**
125
125
  * The host {@link TUI}, captured when a plugin constructs this editor through
@@ -4,7 +4,8 @@
4
4
  * to the session's `local://` root, and generated video contact-sheet previews.
5
5
  * Symbol metadata stays out of serialized/model-bound image data while traveling
6
6
  * with the draft object, until AgentSession creates the hidden companion message
7
- * that tells the model the path (and link materialization prefers it over a blob copy).
7
+ * that tells the model the path. The model keeps `local://` references across
8
+ * `/move`; chips for internal URLs instead use a stable blob copy.
8
9
  */
9
10
  import type { ImageContent } from "@oh-my-pi/pi-ai";
10
11
  /** How an image attachment's backing file entered the session. */
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.3",
4
+ "version": "18.8.5",
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.3",
45
- "@oh-my-pi/pi-agent-core": "18.8.3",
46
- "@oh-my-pi/pi-ai": "18.8.3",
47
- "@oh-my-pi/pi-catalog": "18.8.3",
48
- "@oh-my-pi/pi-natives": "18.8.3",
49
- "@oh-my-pi/pi-utils": "18.8.3",
50
- "@oh-my-pi/pi-wire": "18.8.3",
51
- "@oh-my-pi/snapcompact": "18.8.3"
44
+ "@oh-my-pi/omptype": "18.8.5",
45
+ "@oh-my-pi/pi-agent-core": "18.8.5",
46
+ "@oh-my-pi/pi-ai": "18.8.5",
47
+ "@oh-my-pi/pi-catalog": "18.8.5",
48
+ "@oh-my-pi/pi-natives": "18.8.5",
49
+ "@oh-my-pi/pi-utils": "18.8.5",
50
+ "@oh-my-pi/pi-wire": "18.8.5",
51
+ "@oh-my-pi/snapcompact": "18.8.5"
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);
@@ -57,7 +57,7 @@ const OSC133_ZONE_CLOSE = OSC133_ZONE_END + OSC133_COMMAND_START + OSC133_COMMAN
57
57
 
58
58
  /** How a user bubble styles its prose and chips (see {@link userBubbleColor}). */
59
59
  export interface UserBubbleOptions {
60
- /** Materialized `file://` targets per attached image, indexed by chip number. */
60
+ /** Filesystem paths for attached image chips, indexed by chip number. */
61
61
  imageLinks?: readonly (string | undefined)[];
62
62
  /** The message's attached images in chip order (`#1` first); a native bubble shows them. */
63
63
  images?: readonly ImageContent[];
@@ -74,9 +74,12 @@ export interface NativeEditorLayout {
74
74
  readonly caret: string;
75
75
  }
76
76
 
77
- /** Vim mode as the TSP `editor.mode` label. */
78
- const VIM_MODE_LABELS: Record<VimMode, string> = {
79
- insert: "INSERT",
77
+ /**
78
+ * Vim mode as the TSP `editor.mode` label. Insert carries none: Insert edits like a plain field,
79
+ * and a host such as Tern stops native editing (selection, ⌘A/⌘C/⌘X) for any labelled mode.
80
+ */
81
+ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
82
+ insert: undefined,
80
83
  normal: "NORMAL",
81
84
  visual: "VISUAL",
82
85
  "visual-line": "VISUAL",
@@ -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);