@oh-my-pi/pi-tui 18.0.9 → 18.0.11

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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.11] - 2026-08-29
6
+
7
+ ### Added
8
+
9
+ - Added `setTerminalHyperlinks()` to let hosts control OSC 8 hyperlink behavior in rendered Markdown links.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed inline color swatches appearing for words with hex-like prefixes, such as `#each`; swatches now appear only when the entire word is a valid color.
14
+
15
+ ## [18.0.10] - 2026-08-28
16
+
17
+ ### Added
18
+
19
+ - Press the Right Arrow at the end of a line to accept autocomplete suggestions, inline ghost-text completions, and spelling corrections, just like Tab.
20
+ - Added a smooth brand-color transition and an elapsed turn timer to the status line while the agent is working.
21
+ - Added a full-width “band” composer style for flush status lines.
22
+
5
23
  ## [18.0.9] - 2026-08-28
6
24
 
7
25
  ### Added
@@ -0,0 +1,2 @@
1
+ import type { ComposerStyle } from "./types.js";
2
+ export declare const bandComposerStyle: ComposerStyle;
@@ -1,3 +1,4 @@
1
+ export * from "./band.js";
1
2
  export * from "./borderless.js";
2
3
  export * from "./box.js";
3
4
  export * from "./claude.js";
@@ -1,6 +1,13 @@
1
1
  import type { ComposerStyle, EditorBorderStyle } from "./types.js";
2
2
  /** Whether an id names a composer style shipped by pi-tui. */
3
3
  export declare function isBuiltinComposerStyle(id: string): boolean;
4
+ /**
5
+ * Whether a style paints its own row foreground.
6
+ *
7
+ * Extensions registered before `filledSurface` existed received undecorated
8
+ * row text, so an omitted flag remains filled for extension-owned ids.
9
+ */
10
+ export declare function isFilledComposerStyle(style: ComposerStyle): boolean;
4
11
  /**
5
12
  * Register one extension-owned composer style for this process.
6
13
  *
@@ -10,7 +10,7 @@ import type { SymbolTheme } from "../../symbols.js";
10
10
  /** Box-drawing glyph set used for composer chrome (the theme's `boxRound`). */
11
11
  export type ComposerBox = SymbolTheme["boxRound"];
12
12
  /** Built-in composer shape identifiers shipped by pi-tui. */
13
- export declare const BUILTIN_EDITOR_BORDER_STYLES: readonly ["box", "claude", "pi", "borderless", "rule", "field", "rail"];
13
+ export declare const BUILTIN_EDITOR_BORDER_STYLES: readonly ["box", "band", "claude", "pi", "borderless", "rule", "field", "rail"];
14
14
  /** Identifier for a built-in composer shape. */
15
15
  export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
16
16
  /** Composer shape identifier; extensions may register additional strings. */
@@ -60,14 +60,20 @@ export interface ComposerRowContext extends ComposerChromeContext {
60
60
  }
61
61
  export interface ComposerStyle {
62
62
  readonly id: EditorBorderStyle;
63
+ /**
64
+ * True when rows paint their own foreground through `surfaceColor`.
65
+ * Built-ins default to transparent; registered extensions that omit this
66
+ * field retain the pre-field behavior and receive undecorated row text.
67
+ */
68
+ readonly filledSurface?: boolean;
63
69
  /** Content rows carry left/right border glyphs; drives the cursor-reserve
64
70
  * column, IME-safe layout, and the right-border scrollbar. */
65
71
  readonly sideBorders: boolean;
66
72
  /** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
67
73
  readonly verticalChrome: 0 | 1 | 2;
68
74
  /** Where the host should attach the status bar: embedded in the top border,
69
- * docked onto a top rule, or detached into a standalone bottom bar. */
70
- readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
75
+ * rendered as a flush soft-capped band above the input, docked onto a top rule, or detached into a standalone bottom bar. */
76
+ readonly statusAttachment: "top-border" | "top-band" | "top-rule-chip" | "none";
71
77
  /** Which segment groups the standalone bottom status bar shows. */
72
78
  readonly bottomBar: "none" | "left" | "full";
73
79
  /** Insert a blank spacer row between the editor and the standalone bottom
@@ -10,6 +10,8 @@ export interface EditorTheme {
10
10
  accentColor?: (str: string) => string;
11
11
  /** Background fill used by filled composer styles. */
12
12
  surfaceColor?: (str: string) => string;
13
+ /** Foreground used when the composer shape leaves its text surface transparent. */
14
+ textColor?: (str: string) => string;
13
15
  selectList: SelectListTheme;
14
16
  symbols: SymbolTheme;
15
17
  editorPaddingX?: number;
@@ -1,5 +1,7 @@
1
1
  import type { TUI } from "../tui.js";
2
2
  import { Text } from "./text.js";
3
+ /** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
4
+ export declare const SPINNER_ADVANCE_MS = 80;
3
5
  type ColorFn = (str: string) => string;
4
6
  /**
5
7
  * Styles Loader message fragments without changing their visible text or width.
@@ -22,6 +24,10 @@ export declare class Loader extends Text {
22
24
  stop(): void;
23
25
  /** Lifecycle teardown: stop the animation timer. Idempotent. */
24
26
  dispose(): void;
27
+ /** Install a lazy right-docked suffix for the spinner row (e.g. a styled
28
+ * session title). Re-evaluated every paint; dropped when the row leaves
29
+ * less than a two-cell gap. */
30
+ setTrailer(trailer: (() => string | undefined) | undefined): void;
25
31
  setMessage(message: string): void;
26
32
  }
27
33
  export {};
@@ -206,6 +206,15 @@ export declare function setTerminalScreenToScrollback(enabled: boolean): void;
206
206
  * capability); tests flip it directly to exercise the scaled-heading path.
207
207
  */
208
208
  export declare function setTerminalTextSizing(enabled: boolean): void;
209
+ /**
210
+ * Override OSC 8 hyperlink capability at runtime. The coding-agent calls this
211
+ * from the `tui.hyperlinks` setting so its resolved policy (`off`/`auto`/`always`)
212
+ * drives every renderer that gates on {@link TERMINAL}`.hyperlinks` — notably the
213
+ * Markdown component's `[text](url)`/bare-URL links — consistently with the
214
+ * path/resource links that already consult the setting directly. Tests flip it
215
+ * to exercise the OSC 8 and plain-text paths deterministically.
216
+ */
217
+ export declare function setTerminalHyperlinks(enabled: boolean): void;
209
218
  export declare function getTerminalInfo(terminalId: TerminalId, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): TerminalInfo;
210
219
  export interface CellDimensions {
211
220
  widthPx: number;
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.0.9",
4
+ "version": "18.0.11",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.0.9",
41
- "@oh-my-pi/pi-utils": "18.0.9"
40
+ "@oh-my-pi/pi-natives": "18.0.11",
41
+ "@oh-my-pi/pi-utils": "18.0.11"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Soft status band composer (the rust omp default): the full status line sits
3
+ * flush against the terminal's left edge as a filled powerline band with a
4
+ * soft opening cap — no frame, rules, or corners — above an unboxed prompt
5
+ * anchored by a single curved `╰─ ` cue.
6
+ */
7
+ import { truncateToWidth } from "../../utils";
8
+ import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
9
+
10
+ export const bandComposerStyle: ComposerStyle = {
11
+ id: "band",
12
+ sideBorders: false,
13
+ verticalChrome: 1,
14
+ statusAttachment: "top-band",
15
+ bottomBar: "none",
16
+ bottomBarGap: false,
17
+ defaultPromptGutter: "╰─ ",
18
+
19
+ defaultPaddingX(): number {
20
+ return 0;
21
+ },
22
+
23
+ sideChromeWidth(): number {
24
+ return 0;
25
+ },
26
+
27
+ renderTop(ctx: ComposerChromeContext): string | undefined {
28
+ const { topBorder, width } = ctx;
29
+ if (!topBorder?.content) return undefined;
30
+ // The band builder already sizes its groups + gauge to the full width;
31
+ // truncation only guards against a stale provider during resize.
32
+ return topBorder.width > width ? truncateToWidth(topBorder.content, width) : topBorder.content;
33
+ },
34
+
35
+ renderRow(ctx: ComposerRowContext): string[] {
36
+ return [(ctx.gutter ? ctx.borderColor(ctx.gutter) : "") + ctx.text + ctx.pad];
37
+ },
38
+
39
+ renderBottom(): undefined {
40
+ return undefined;
41
+ },
42
+ };
@@ -11,6 +11,7 @@ const RIGHT_CAP = "▌";
11
11
  /** One-row filled field with accent caps. */
12
12
  export const fieldComposerStyle: ComposerStyle = {
13
13
  id: "field",
14
+ filledSurface: true,
14
15
  sideBorders: true,
15
16
  verticalChrome: 0,
16
17
  statusAttachment: "none",
@@ -1,3 +1,4 @@
1
+ export * from "./band";
1
2
  export * from "./borderless";
2
3
  export * from "./box";
3
4
  export * from "./claude";
@@ -10,6 +10,7 @@ const ACCENT_RAIL = "▎";
10
10
  /** Filled composer surface anchored by a single left accent rail. */
11
11
  export const railComposerStyle: ComposerStyle = {
12
12
  id: "rail",
13
+ filledSurface: true,
13
14
  sideBorders: true,
14
15
  verticalChrome: 0,
15
16
  statusAttachment: "none",
@@ -1,3 +1,4 @@
1
+ import { bandComposerStyle } from "./band";
1
2
  import { borderlessComposerStyle } from "./borderless";
2
3
  import { boxComposerStyle } from "./box";
3
4
  import { claudeComposerStyle } from "./claude";
@@ -9,6 +10,7 @@ import type { ComposerStyle, EditorBorderStyle } from "./types";
9
10
 
10
11
  const BUILTIN_COMPOSER_STYLES: Readonly<Record<string, ComposerStyle>> = {
11
12
  box: boxComposerStyle,
13
+ band: bandComposerStyle,
12
14
  claude: claudeComposerStyle,
13
15
  pi: piComposerStyle,
14
16
  borderless: borderlessComposerStyle,
@@ -23,6 +25,16 @@ export function isBuiltinComposerStyle(id: string): boolean {
23
25
  return Object.hasOwn(BUILTIN_COMPOSER_STYLES, id);
24
26
  }
25
27
 
28
+ /**
29
+ * Whether a style paints its own row foreground.
30
+ *
31
+ * Extensions registered before `filledSurface` existed received undecorated
32
+ * row text, so an omitted flag remains filled for extension-owned ids.
33
+ */
34
+ export function isFilledComposerStyle(style: ComposerStyle): boolean {
35
+ return style.filledSurface ?? !isBuiltinComposerStyle(style.id);
36
+ }
37
+
26
38
  /**
27
39
  * Register one extension-owned composer style for this process.
28
40
  *
@@ -12,7 +12,16 @@ import type { SymbolTheme } from "../../symbols";
12
12
  export type ComposerBox = SymbolTheme["boxRound"];
13
13
 
14
14
  /** Built-in composer shape identifiers shipped by pi-tui. */
15
- export const BUILTIN_EDITOR_BORDER_STYLES = ["box", "claude", "pi", "borderless", "rule", "field", "rail"] as const;
15
+ export const BUILTIN_EDITOR_BORDER_STYLES = [
16
+ "box",
17
+ "band",
18
+ "claude",
19
+ "pi",
20
+ "borderless",
21
+ "rule",
22
+ "field",
23
+ "rail",
24
+ ] as const;
16
25
 
17
26
  /** Identifier for a built-in composer shape. */
18
27
  export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
@@ -68,14 +77,20 @@ export interface ComposerRowContext extends ComposerChromeContext {
68
77
 
69
78
  export interface ComposerStyle {
70
79
  readonly id: EditorBorderStyle;
80
+ /**
81
+ * True when rows paint their own foreground through `surfaceColor`.
82
+ * Built-ins default to transparent; registered extensions that omit this
83
+ * field retain the pre-field behavior and receive undecorated row text.
84
+ */
85
+ readonly filledSurface?: boolean;
71
86
  /** Content rows carry left/right border glyphs; drives the cursor-reserve
72
87
  * column, IME-safe layout, and the right-border scrollbar. */
73
88
  readonly sideBorders: boolean;
74
89
  /** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
75
90
  readonly verticalChrome: 0 | 1 | 2;
76
91
  /** Where the host should attach the status bar: embedded in the top border,
77
- * docked onto a top rule, or detached into a standalone bottom bar. */
78
- readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
92
+ * rendered as a flush soft-capped band above the input, docked onto a top rule, or detached into a standalone bottom bar. */
93
+ readonly statusAttachment: "top-border" | "top-band" | "top-rule-chip" | "none";
79
94
  /** Which segment groups the standalone bottom status bar shows. */
80
95
  readonly bottomBar: "none" | "left" | "full";
81
96
  /** Insert a blank spacer row between the editor and the standalone bottom
@@ -32,6 +32,7 @@ import {
32
32
  type EditorBorderStyle,
33
33
  type EditorTopBorder,
34
34
  getComposerStyle,
35
+ isFilledComposerStyle,
35
36
  } from "./composer";
36
37
 
37
38
  export type { EditorBorderStyle, EditorTopBorder };
@@ -401,6 +402,8 @@ export interface EditorTheme {
401
402
  accentColor?: (str: string) => string;
402
403
  /** Background fill used by filled composer styles. */
403
404
  surfaceColor?: (str: string) => string;
405
+ /** Foreground used when the composer shape leaves its text surface transparent. */
406
+ textColor?: (str: string) => string;
404
407
  selectList: SelectListTheme;
405
408
  symbols: SymbolTheme;
406
409
  editorPaddingX?: number;
@@ -1271,13 +1274,16 @@ export class Editor implements Component, Focusable {
1271
1274
  displayWidth = visibleWidth(displayText);
1272
1275
  }
1273
1276
  }
1277
+ const renderedText = isFilledComposerStyle(style)
1278
+ ? displayText
1279
+ : (this.#theme.textColor ?? PASSTHROUGH_COLOR)(displayText);
1274
1280
 
1275
1281
  const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
1276
1282
 
1277
1283
  result.push(
1278
1284
  ...style.renderRow({
1279
1285
  ...chromeCtx,
1280
- text: displayText,
1286
+ text: renderedText,
1281
1287
  pad: linePad,
1282
1288
  gutter: gutterText,
1283
1289
  isLastRow: visibleIndex === visibleLayoutLines.length - 1,
@@ -1403,11 +1409,17 @@ export class Editor implements Component, Focusable {
1403
1409
  this.#cancelAutocomplete(true);
1404
1410
  return;
1405
1411
  }
1412
+ // Right arrow at end of line accepts the selection like Tab (fish-style).
1413
+ // Mid-line, right arrow keeps its cursor-movement role and falls through.
1414
+ const rightArrowAccepts =
1415
+ kb.matchesCanonical(canonical, "tui.editor.cursorRight") &&
1416
+ this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length;
1406
1417
  if (
1407
1418
  this.#autocompleteState === "assist" &&
1408
1419
  (kb.matchesCanonical(canonical, "tui.input.submit") ||
1409
1420
  data === "\n" ||
1410
- kb.matchesCanonical(canonical, "tui.input.tab"))
1421
+ kb.matchesCanonical(canonical, "tui.input.tab") ||
1422
+ rightArrowAccepts)
1411
1423
  ) {
1412
1424
  this.#applySpellingSuggestion();
1413
1425
  return;
@@ -1420,7 +1432,8 @@ export class Editor implements Component, Focusable {
1420
1432
  kb.matchesCanonical(canonical, "tui.select.pageDown") ||
1421
1433
  kb.matchesCanonical(canonical, "tui.input.submit") ||
1422
1434
  data === "\n" ||
1423
- kb.matchesCanonical(canonical, "tui.input.tab")
1435
+ kb.matchesCanonical(canonical, "tui.input.tab") ||
1436
+ rightArrowAccepts
1424
1437
  ) {
1425
1438
  // Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
1426
1439
  if (
@@ -1435,7 +1448,7 @@ export class Editor implements Component, Focusable {
1435
1448
  }
1436
1449
 
1437
1450
  // If Tab was pressed, always apply the selection
1438
- if (kb.matchesCanonical(canonical, "tui.input.tab")) {
1451
+ if (kb.matchesCanonical(canonical, "tui.input.tab") || rightArrowAccepts) {
1439
1452
  const selected = this.#autocompleteList.getSelectedItem();
1440
1453
  // Check for stale autocomplete state due to buffer edits since last refresh
1441
1454
  // (destructive keys or paste can outrun the debounced update).
@@ -1731,6 +1744,13 @@ export class Editor implements Component, Focusable {
1731
1744
  }
1732
1745
  } else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
1733
1746
  // Right
1747
+ // At end of line, accept the inline ghost word completion (IME-style) like Tab.
1748
+ if (
1749
+ this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length &&
1750
+ this.#acceptWordCompletion()
1751
+ ) {
1752
+ return;
1753
+ }
1734
1754
  this.#moveCursor(0, 1);
1735
1755
  } else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
1736
1756
  // Left
@@ -3435,13 +3455,7 @@ export class Editor implements Component, Focusable {
3435
3455
  }
3436
3456
 
3437
3457
  async #handleTabCompletion(): Promise<void> {
3438
- const wordCompletion = this.#getWordCompletion();
3439
- if (wordCompletion) {
3440
- const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
3441
- const after = currentLine.slice(this.#state.cursorCol);
3442
- this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
3443
- return;
3444
- }
3458
+ if (this.#acceptWordCompletion()) return;
3445
3459
  if (!this.#autocompleteProvider) return;
3446
3460
 
3447
3461
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -3458,6 +3472,16 @@ export class Editor implements Component, Focusable {
3458
3472
  await this.#forceFileAutocomplete();
3459
3473
  }
3460
3474
  }
3475
+ /** Insert the inline ghost word completion at the cursor, if any. Shared by Tab and right-arrow accept. */
3476
+ #acceptWordCompletion(): boolean {
3477
+ const wordCompletion = this.#getWordCompletion();
3478
+ if (!wordCompletion) return false;
3479
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
3480
+ const after = currentLine.slice(this.#state.cursorCol);
3481
+ this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
3482
+ return true;
3483
+ }
3484
+
3461
3485
  async #showSpellingSuggestions(): Promise<void> {
3462
3486
  const cursorLine = this.#state.cursorLine;
3463
3487
  const cursorCol = this.#state.cursorCol;
@@ -1,9 +1,10 @@
1
1
  import type { TUI } from "../tui";
2
- import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
2
+ import { getPaddingX, padding, sliceByColumn, visibleWidth } from "../utils";
3
3
  import { Text } from "./text";
4
4
 
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
- const SPINNER_ADVANCE_MS = 80;
6
+ /** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
7
+ export const SPINNER_ADVANCE_MS = 80;
7
8
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
8
9
  const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
9
10
 
@@ -28,6 +29,7 @@ export class Loader extends Text {
28
29
  #layout?: readonly { leading: string; content: string; trailing: string }[];
29
30
  #layoutFrames: readonly string[];
30
31
  #layoutFrame: string;
32
+ #trailer?: () => string | undefined;
31
33
 
32
34
  constructor(
33
35
  ui: TUI,
@@ -102,6 +104,15 @@ export class Loader extends Text {
102
104
  lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
103
105
  }
104
106
  }
107
+ if (this.#trailer && lines.length > 1) {
108
+ const trailer = this.#trailer();
109
+ if (trailer) {
110
+ // Text pads rows to full width; drop that pad before docking right.
111
+ const body = lines[1].trimEnd();
112
+ const gap = width - visibleWidth(body) - visibleWidth(trailer);
113
+ if (gap >= 2) lines[1] = body + padding(gap) + trailer;
114
+ }
115
+ }
105
116
  return lines;
106
117
  }
107
118
 
@@ -124,6 +135,12 @@ export class Loader extends Text {
124
135
  dispose() {
125
136
  this.stop();
126
137
  }
138
+ /** Install a lazy right-docked suffix for the spinner row (e.g. a styled
139
+ * session title). Re-evaluated every paint; dropped when the row leaves
140
+ * less than a two-cell gap. */
141
+ setTrailer(trailer: (() => string | undefined) | undefined): void {
142
+ this.#trailer = trailer;
143
+ }
127
144
 
128
145
  setMessage(message: string) {
129
146
  if (message === this.message) {
@@ -1515,11 +1515,11 @@ const DEFAULT_COLOR_SWATCH_GLYPH = "■";
1515
1515
 
1516
1516
  // `#` + 3-8 hex digits, not glued to a surrounding word/`#`/`&` (avoids HTML
1517
1517
  // entities like &#9731; and paths like foo#fff), not the start of a canonical
1518
- // UUID, and not trailed by more hex (so over-long runs never produce a
1519
- // misleading swatch). Length/letter rules are enforced in classifyHexColor
1520
- // since the alternation can't express "exactly 3, 6, or 8".
1521
- const HEX_COLOR_REGEX =
1522
- /(?<![\w#&])#(?![0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})([0-9a-fA-F]{3,8})(?![0-9a-fA-F])/g;
1518
+ // UUID, and not trailed by another word char (over-long runs and word
1519
+ // fragments like the "#eac" of "#each" never produce a misleading swatch).
1520
+ // Length/letter rules are enforced in classifyHexColor since the alternation
1521
+ // can't express "exactly 3, 6, or 8".
1522
+ const HEX_COLOR_REGEX = /(?<![\w#&])#(?![0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})([0-9a-fA-F]{3,8})(?!\w)/g;
1523
1523
  const HEX_COLOR_EXACT_REGEX = /^#([0-9a-fA-F]{3,8})$/;
1524
1524
 
1525
1525
  /**
@@ -676,6 +676,18 @@ export function setTerminalTextSizing(enabled: boolean): void {
676
676
  TERMINAL.textSizing = enabled;
677
677
  }
678
678
 
679
+ /**
680
+ * Override OSC 8 hyperlink capability at runtime. The coding-agent calls this
681
+ * from the `tui.hyperlinks` setting so its resolved policy (`off`/`auto`/`always`)
682
+ * drives every renderer that gates on {@link TERMINAL}`.hyperlinks` — notably the
683
+ * Markdown component's `[text](url)`/bare-URL links — consistently with the
684
+ * path/resource links that already consult the setting directly. Tests flip it
685
+ * to exercise the OSC 8 and plain-text paths deterministically.
686
+ */
687
+ export function setTerminalHyperlinks(enabled: boolean): void {
688
+ TERMINAL.hyperlinks = enabled;
689
+ }
690
+
679
691
  export function getTerminalInfo(
680
692
  terminalId: TerminalId,
681
693
  platform: NodeJS.Platform = process.platform,