@oh-my-pi/pi-tui 18.12.0 → 18.12.2

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,36 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.2] - 2026-10-18
6
+
7
+ ### Added
8
+
9
+ - Added `ComposerPreferences.completeScrollback` (default `false`): rows a growing HUD or dialog pushes off screen go straight to scrollback instead of being held back until it shrinks ([#15692](https://github.com/can1357/oh-my-pi/pull/15692) by [@Ilm-Alan](https://github.com/Ilm-Alan))
10
+
11
+ ### Fixed
12
+
13
+ - Fixed table charts picking the wrong label and measure columns when numbers group thousands with spaces (`1 988`, `17 083`); they now read like `1,988` ([#16175](https://github.com/can1357/oh-my-pi/issues/16175))
14
+ - Fixed terminal capability replies relayed through win32-input-mode appearing as typed input in WSL terminals ([#16137](https://github.com/can1357/oh-my-pi/issues/16137))
15
+ - Fixed the status-bar activity spinner and turn timer freezing until the next keypress when the working-row loader is stopped mid-turn; an open turn window now repaints the status line on its own timer ([#16109](https://github.com/can1357/oh-my-pi/pull/16109) by [@tommymorgan](https://github.com/tommymorgan)).
16
+ - Fixed pasting sometimes inserting a block of base64 text instead of the pasted text in terminals that deliver pastes through the Kitty clipboard protocol (OSC 5522), most often with dictation apps that paste for you; a slow paste now arrives whole, and keys typed while a paste never finishes are given back instead of lost ([#16170](https://github.com/can1357/oh-my-pi/pull/16170) by [@H4vC](https://github.com/H4vC))
17
+ - Fixed a streaming table bouncing everything below it up and down while its last cell was still being written ([#16116](https://github.com/can1357/oh-my-pi/pull/16116) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
18
+ - Fixed inline images in iTerm2 being sized for the wrong cell shape, which left charts smeared or offset ([#16118](https://github.com/can1357/oh-my-pi/pull/16118) by [@galtamam-cmyk](https://github.com/galtamam-cmyk))
19
+
20
+ ## [18.12.1] - 2026-10-17
21
+
22
+ ### Breaking Changes
23
+
24
+ - `numberedCode`, `readContentCode` and `nativeDiff` take the file's path (`path`) instead of a language, and `describeGroupedOutput` drops its `lang` option: Tern picks the grammar of file contents from their path
25
+
26
+ ### Changed
27
+
28
+ - Advisor notes in Tern read as a callout tinted by their worst severity: its icon and a tally (`Concern`, `1 blocker · 2 concerns`) head the card with the advisor's name at the end, and several notes each lead with their own severity icon
29
+
30
+ ### Fixed
31
+
32
+ - Fixed images rendering blank inside tmux on Kitty-capable terminals when tmux's `allow-passthrough` is off (the default); omp now falls back to SIXEL or the text placeholder instead of sending Kitty graphics tmux drops
33
+ - Fixed Tern drawing tool cards' file contents (write, read, edit, grep, find, ast-grep, ast-edit) plain or in the wrong language when omp did not know the file's extension (`.ptx`) or mapped it to a neighbor (`.cu` as C++); omp now sends the file's path and Tern picks the grammar
34
+
5
35
  ## [18.12.0] - 2026-10-17
6
36
 
7
37
  ### Breaking Changes
@@ -2447,79 +2477,4 @@
2447
2477
 
2448
2478
  - Increased the minimum required Bun version for the TUI package from >=1.3.7 to >=1.3.14
2449
2479
 
2450
- ## [14.9.8] - 2026-05-12
2451
-
2452
- ### Added
2453
-
2454
- - Added `Terminal.setProgress(active)` to emit OSC 9;4 progress sequences with a ~1s keepalive interval so Ghostty does not clear the indicator during long-running work (ports pi-mono `a900d251` + `76bc605a`)
2455
- - Added optional `argumentHint?: string` to `SlashCommand`; rendered before the description in the autocomplete dropdown (ports pi-mono `aa25726e`)
2456
- - Added `VirtualTerminal.waitForRender()` test helper for the throttled render pipeline (ports pi-mono `41377ee8`)
2457
-
2458
- ### Changed
2459
-
2460
- - `ProcessTerminal` `columns`/`rows` getters consult `Bun.env.COLUMNS` / `Bun.env.LINES` before falling back to 80×24, so piped/non-TTY runs honour environment-provided dimensions (ports pi-mono `32f7fc6a`)
2461
- - `requestRender()` non-force calls are coalesced to a ~16ms frame budget; `requestRender(true)` still flushes immediately via `process.nextTick` (ports pi-mono `6f5f37f8`)
2462
- - `KNOWN_TERMINALS.base` / `KNOWN_TERMINALS.trueColor` default `hyperlinks: false`; tmux and screen (`TMUX` env or `TERM` starts with `tmux`/`screen`) force `hyperlinks: false` even when the outer terminal would advertise OSC 8 (adapts pi-mono `30a8a41f`)
2463
- - `SlashCommand.getArgumentCompletions()` may return a `Promise`; results are now awaited and non-array returns are ignored (ports pi-mono `a1e10789`)
2464
- - Fuzzy `@` autocomplete now follows symlinked directories via `ScanOptions.follow_links` plumbed through the native walker (ports pi-mono `780d5367`)
2465
- - Plain `@<query>` (no slash) fuzzy matches by basename only, so `@plan` no longer surfaces every file whose ancestor directories contain `plan` (ports pi-mono `968430f6`)
2466
- - Changed slash-command autocomplete list rendering to combine command hint and description in a single displayed suggestion text
2467
- - Changed render scheduling to throttle `requestRender` calls to roughly 60fps by batching updates
2468
- - Changed terminal input handling to process complete cell-size responses without buffering partial input
2469
- - Changed `KeyId` to accept super-modifier combinations and improve typed key-id validation
2470
-
2471
- ### Fixed
2472
-
2473
- - Fixed editor corruption on Thai Sara Am (U+0E33) and Lao AM (U+0EB3) vowels by normalizing to their compatibility decompositions on the terminal-write path while keeping editor content logically unchanged (ports pi-mono `bc668826` + `338ce3a3` + `20ca45d5`)
2474
- - Fixed cell-size detection (`CSI 6;h;w t` response) to consume only exact replies, so a bare `Escape` keystroke is no longer swallowed while waiting for terminal image metadata (ports pi-mono `49c0d860`)
2475
- - Fixed Kitty CSI-u printable input duplicating on layouts (e.g. Italian) where the terminal also emits the raw character: the immediately-following matching codepoint is now suppressed (ports pi-mono `bdb416cb`)
2476
- - Fixed bracketed-paste CSI-u `Ctrl+<letter>` re-encoding (tmux popup with `extended-keys-format=csi-u`) leaking literal `[<code>;5u` into the editor; control bytes are decoded back to their literal byte before per-char filtering (ports pi-mono `d06db09a`)
2477
- - Fixed xterm `modifyOtherKeys` shifted printable input so uppercase letters inserted via `CSI 27;mod;codepoint~` reach the editor correctly (ports pi-mono `6b55d685`)
2478
- - Fixed `super`-modified Kitty shortcuts (`super+k`, `ctrl+super+enter`, …) to parse and match via the new `KITTY_MOD_SUPER` mask (ports pi-mono `ddb8454c` + `5ed46003`)
2479
- - Fixed `ctrl+alt+<letter>` in tmux falling through to CSI-u / `modifyOtherKeys` when the legacy `ESC<ctrl-char>` form does not match (ports pi-mono `6cf5098f`)
2480
- - Fixed Markdown strikethrough requiring strict `~~text~~` delimiters with non-whitespace boundaries; single tildes no longer render strikethrough (ports pi-mono `db5274b4`)
2481
- - Allowed `SlashCommand.getArgumentCompletions` to return asynchronous results by accepting Promise-based completions
2482
- - Added `argumentHint` support to slash command definitions and displayed it in command suggestion descriptions
2483
- - Added support for xterm `modifyOtherKeys` printable key sequences by decoding `CSI 27;mod;key~` into text input
2484
- - Normalized line output during rendering to correct Thai/Lao AM glyph composition for displayed text
2485
- - Fixed duplicated Kitty key input emissions by dropping the matching unmodified follow-up sequence after a Kitty CSI-u printable-key event
2486
-
2487
- ## [14.9.5] - 2026-05-12
2488
-
2489
- ### Fixed
2490
-
2491
- - Fixed rapidly blinking cursor artifact during task execution by consolidating cursor control sequences into the synchronized output buffer ([#992](https://github.com/can1357/oh-my-pi/issues/992))
2492
-
2493
- ## [14.5.7] - 2026-04-29
2494
-
2495
- ### Fixed
2496
-
2497
- - Fixed editor Ctrl+Enter handling to recognize NumLock and keypad Enter variants.
2498
-
2499
- ## [14.3.0] - 2026-04-25
2500
-
2501
- ### Fixed
2502
-
2503
- - Fixed shared Markdown Mermaid fenced-block rendering to resolve diagrams from fenced source text instead of external prerender state
2504
-
2505
- ## [14.1.1] - 2026-04-14
2506
-
2507
- ### Breaking Changes
2508
-
2509
- - Removed the `searchDb` constructor argument from `CombinedAutocompleteProvider`, requiring callers to use the built-in search behavior
2510
-
2511
- ### Changed
2512
-
2513
- - Changed truncation debug logging to run only when `debugRedraw` is enabled
2514
-
2515
- ## [14.0.5] - 2026-04-11
2516
-
2517
- ### Changed
2518
-
2519
- - Updated hash computation to use `Bun.hash()` instead of `Bun.hash.xxHash64()`, which may return `number` in addition to `bigint`
2520
- - Simplified cache key computation in Box component by removing intermediate hash updates and consolidating hash operations
2521
- - Wrapped native text utility functions (`sliceWithWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `extractSegments`) to automatically pass the current default tab width, simplifying the API for consumers
2522
- - Added `getIndentationNoescape` wrapper that uses `process.cwd()` as the project root for relative file paths
2523
- - Re-export `getDefaultTabWidth`, `getIndentation`, and `setDefaultTabWidth` from `@oh-my-pi/pi-utils`; native text helpers still receive tab width via wrappers that read the JS default
2524
-
2525
- Older entries are archived in [packages/tui/CHANGELOG.md@691b3d448f3d](https://github.com/can1357/oh-my-pi/blob/691b3d448f3da1f99010854dea4a562395fd190d/packages/tui/CHANGELOG.md).
2480
+ Older entries are archived in [packages/tui/CHANGELOG.md@04d4ab14b45f](https://github.com/can1357/oh-my-pi/blob/04d4ab14b45f8dbd4055b675519f8815a1eb1841/packages/tui/CHANGELOG.md).
@@ -1,6 +1,6 @@
1
1
  import { type Component } from "../tui.js";
2
2
  import type { AdvisorMessageDetails } from "./messages.js";
3
- import { type Theme } from "../theme/index.js";
3
+ import type { Theme } from "../theme/index.js";
4
4
  import { type NativeUiEvent } from "../native/node.js";
5
5
  /** The advisor card: shared by the controlled expansion getter and native toggles. */
6
6
  export interface AdvisorMessageCard extends Component {
@@ -14,6 +14,9 @@ export interface AdvisorMessageCard extends Component {
14
14
  * output (whose `thinkingText` color equals `toolOutput` in most themes):
15
15
  * a bold `customMessageLabel` header tag (skill-card convention), a heavy
16
16
  * rail tinted per-note severity, and the note body on the default text color.
17
+ * Natively it is a callout tinted by its worst note: that severity's icon and
18
+ * the tally head it, the advisor's name ends the head, and with several notes
19
+ * each leads with its own severity icon.
17
20
  *
18
21
  * `getExpanded` owns expansion; a native toggle overrides it for this card
19
22
  * until the next transcript-wide change.
@@ -173,7 +173,9 @@ export declare function isUserInvokedSkillPrompt(message: CustomMessage): boolea
173
173
  * reminders, and auto-continues are not turn starts.
174
174
  */
175
175
  export declare function isUserTurnInitiator(message: CustomMessage): boolean;
176
- export type AdvisorSeverity = "nit" | "concern" | "blocker";
176
+ /** Advisor note severities, weakest first; ranks severity thresholds such as `advisor.continueSeverity`. */
177
+ export declare const ADVISOR_SEVERITIES: readonly ["nit", "concern", "blocker"];
178
+ export type AdvisorSeverity = (typeof ADVISOR_SEVERITIES)[number];
177
179
  /** One queued advice note. */
178
180
  export interface AdvisorNote {
179
181
  note: string;
@@ -7,10 +7,9 @@ import type { NativeNode } from "../native/node.js";
7
7
  * becomes one hunk; the terminal draws gutters, word emphasis and highlighting.
8
8
  * Removed rows carry old line numbers, added rows new ones and context rows
9
9
  * old ones, so the other side is derived from the running line delta.
10
+ * Tern picks the grammar from `path`.
10
11
  */
11
- export declare function nativeDiff(diffText: string, options?: {
12
- filePath?: string;
13
- } & TspProps<"diff">): NativeNode;
12
+ export declare function nativeDiff(diffText: string, props?: TspProps<"diff">): NativeNode;
14
13
  export interface RenderDiffOptions {
15
14
  /** File path used to resolve indentation (.editorconfig + defaults) */
16
15
  filePath?: string;
@@ -21,6 +21,8 @@ export interface StatusNoticeOptions {
21
21
  export declare class StatusNotice extends Container {
22
22
  #private;
23
23
  constructor(message: string, options?: StatusNoticeOptions);
24
+ /** The text the notice currently shows. */
25
+ get message(): string;
24
26
  setMessage(message: string, options?: StatusNoticeOptions): void;
25
27
  describe(): NativeNode;
26
28
  }
@@ -14,6 +14,8 @@ export interface ComposerPreferences {
14
14
  readonly showHardwareCursor: boolean;
15
15
  readonly maxInlineImages: number;
16
16
  readonly resizeScrollback: ResizeScrollbackMode;
17
+ /** Send rows a growing HUD or dialog pushes off screen straight to scrollback instead of holding them back until it shrinks. */
18
+ readonly completeScrollback: boolean;
17
19
  readonly imeSafeCursor: boolean;
18
20
  readonly autocompleteMaxVisible: number;
19
21
  readonly spellingTypoDetection: boolean;
@@ -45,3 +45,12 @@ export declare function resolveTmuxClientTerminalNames(env?: NodeJS.ProcessEnv):
45
45
  * for features.
46
46
  */
47
47
  export declare function tmuxClientRendersSixel(env?: NodeJS.ProcessEnv): boolean;
48
+ /**
49
+ * Whether tmux forwards this pane's `\x1bPtmux;…\x1b\\` passthrough envelopes
50
+ * to the attached client. tmux 3.3+ defaults `allow-passthrough` to `off` and
51
+ * then silently drops every wrapped sequence, so Kitty graphics sent through
52
+ * the envelope never reach a Kitty-capable outer terminal. Resolves the
53
+ * effective pane value (pane → window → global). Queried once per pane;
54
+ * `false` outside tmux or when the query fails.
55
+ */
56
+ export declare function tmuxPaneAllowsPassthrough(env?: NodeJS.ProcessEnv): boolean;
@@ -13,30 +13,60 @@ export interface EvalStatusEvent {
13
13
  resolvedThinkingLevel?: ConfiguredThinkingLevel;
14
14
  [key: string]: unknown;
15
15
  }
16
+ /** One column of a table view. */
17
+ export interface EvalTableColumn {
18
+ head: string;
19
+ /** Numeric values: renderers right-align the column. */
20
+ numeric?: boolean;
21
+ }
22
+ /**
23
+ * A pretty rendering of something a cell printed, shown to the user in place
24
+ * of its text; the model only ever sees the text.
25
+ * - `table`: a data frame or `console.table`; leading index columns are plain
26
+ * columns, and a truncated frame's elided rows/columns are `…` cells.
27
+ * - `json`: a structured value (dict/list, object/array) as a collapsible tree.
28
+ * - `markdown`: an object's Markdown representation.
29
+ */
30
+ export type EvalView = {
31
+ kind: "table";
32
+ columns: EvalTableColumn[];
33
+ rows: string[][];
34
+ } | {
35
+ kind: "json";
36
+ value: unknown;
37
+ } | {
38
+ kind: "markdown";
39
+ text: string;
40
+ };
41
+ /**
42
+ * A view and the exact printed text it stands for. Renderers locate `text` in
43
+ * the cell's `output` (entries in print order) and show the view there; an
44
+ * entry whose text is no longer in the output (truncated) is skipped.
45
+ */
46
+ export interface EvalViewEntry {
47
+ text: string;
48
+ view: EvalView;
49
+ }
16
50
  /** Per-cell execution result for transcript rendering. */
17
51
  export interface EvalCellResult {
18
52
  index: number;
19
53
  title?: string;
20
54
  code: string;
21
55
  language?: EvalLanguage;
56
+ /** What the model saw: the cell's printed text plus image notes. */
22
57
  output: string;
23
- /**
24
- * `output` without its `display[N]:` JSON sections, which renderers show
25
- * from `jsonOutputs` instead. Absent when the cell displayed no JSON.
26
- */
27
- textOutput?: string;
58
+ /** Pretty views of spans of `output`, in print order. */
59
+ views?: EvalViewEntry[];
28
60
  status: "pending" | "running" | "complete" | "error";
29
61
  durationMs?: number;
30
62
  exitCode?: number;
31
63
  statusEvents?: EvalStatusEvent[];
32
64
  /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
33
65
  statusEventsElided?: number;
34
- hasMarkdown?: boolean;
35
66
  }
36
67
  /** Tool result detail object surfaced to the UI/transcript. */
37
68
  export interface EvalToolDetails {
38
69
  cells?: EvalCellResult[];
39
- jsonOutputs?: unknown[];
40
70
  images?: ImageContent[];
41
71
  statusEvents?: EvalStatusEvent[];
42
72
  /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
@@ -105,6 +135,20 @@ export declare function upsertStatusEvent(events: EvalStatusEvent[], event: Eval
105
135
  * events behind an "… N earlier" row.
106
136
  */
107
137
  export declare function recordStatusEvent(log: StatusEventLog, event: EvalStatusEvent): void;
138
+ /** One ordered piece of a cell's output: printed text, or a view shown in place of the text it matched. */
139
+ export type EvalOutputPart = {
140
+ kind: "text";
141
+ text: string;
142
+ } | {
143
+ kind: "view";
144
+ view: EvalView;
145
+ };
146
+ /**
147
+ * Split `output` around its views, in order: each view's `text` is searched
148
+ * from the end of the previous match, unmatched entries are skipped, and
149
+ * blank text between views is dropped (newlines bordering a view trimmed).
150
+ */
151
+ export declare function splitEvalOutput(output: string, views: readonly EvalViewEntry[] | undefined): EvalOutputPart[];
108
152
  /** Render eval code cells, structured display output, and progress events. */
109
153
  export declare const evalToolRenderer: {
110
154
  animatedPendingPreview: true;
@@ -110,11 +110,12 @@ export interface NumberedLine {
110
110
  * Numbered `code` for possibly non-contiguous source lines: one block per run
111
111
  * of consecutive line numbers (`start` = the run's first line, match lines
112
112
  * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
113
- * (`n: null`) sits. No path header: the row or head above names the file.
113
+ * (`n: null`) sits. `path` is the file the lines come from: Tern picks their
114
+ * grammar from it and draws no header (the row or head above names the file).
114
115
  * `wrap` soft-wraps long lines instead of scrolling the block sideways.
115
116
  */
116
117
  export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
117
- lang?: string;
118
+ path?: string;
118
119
  wrap?: boolean;
119
120
  }): NativeNode[];
120
121
  /**
@@ -128,7 +129,6 @@ export declare function numberedCode(lines: readonly NumberedLine[], opts?: {
128
129
  * same file row as a directory search's.
129
130
  */
130
131
  export declare function describeGroupedOutput(lines: readonly string[], options?: {
131
- lang?: (path: string) => string | undefined;
132
132
  maxFiles?: number;
133
133
  file?: string;
134
134
  }): NativeNode[];
@@ -136,7 +136,7 @@ export declare function readResultIsMarkdown(details: ReadToolDetails | undefine
136
136
  * block; runs split with `…` between) when they cover every line, else count
137
137
  * up from `start`.
138
138
  */
139
- export declare function readContentCode(content: string, start: number, numbers: readonly (number | null)[] | undefined, lang: string | undefined): NativeNode[];
139
+ export declare function readContentCode(content: string, start: number, numbers: readonly (number | null)[] | undefined, path: string | undefined): NativeNode[];
140
140
  /** Render file, image, and URL reads in the transcript. */
141
141
  export declare const readToolRenderer: {
142
142
  activitySummary(args: unknown, _context: ToolActivityContext): ToolActivitySummary;
@@ -25,6 +25,7 @@ export declare const SEARCH_PROVIDER_LABELS: {
25
25
  readonly tavily: "Tavily";
26
26
  readonly firecrawl: "Firecrawl";
27
27
  readonly brave: "Brave";
28
+ readonly deepseek: "DeepSeek";
28
29
  readonly kimi: "Kimi";
29
30
  readonly synthetic: "Synthetic";
30
31
  readonly ollama: "Ollama";
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.12.0",
4
+ "version": "18.12.2",
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.12.0",
45
- "@oh-my-pi/pi-agent-core": "18.12.0",
46
- "@oh-my-pi/pi-ai": "18.12.0",
47
- "@oh-my-pi/pi-catalog": "18.12.0",
48
- "@oh-my-pi/pi-natives": "18.12.0",
49
- "@oh-my-pi/pi-utils": "18.12.0",
50
- "@oh-my-pi/pi-wire": "18.12.0",
51
- "@oh-my-pi/snapcompact": "18.12.0"
44
+ "@oh-my-pi/omptype": "18.12.2",
45
+ "@oh-my-pi/pi-agent-core": "18.12.2",
46
+ "@oh-my-pi/pi-ai": "18.12.2",
47
+ "@oh-my-pi/pi-catalog": "18.12.2",
48
+ "@oh-my-pi/pi-natives": "18.12.2",
49
+ "@oh-my-pi/pi-utils": "18.12.2",
50
+ "@oh-my-pi/pi-wire": "18.12.2",
51
+ "@oh-my-pi/snapcompact": "18.12.2"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -148,7 +148,17 @@ export interface TableAnalysis {
148
148
  readonly composition?: Composition;
149
149
  }
150
150
 
151
- const NUMBER = String.raw`(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
151
+ /**
152
+ * A whole number as written: bare, or grouped in thousands by commas (`1,988`)
153
+ * or by spaces as many locales write it (`1 988`, also U+00A0 and U+202F).
154
+ * Space groups take exactly three digits, so `12 5` and `128 2560` stay apart.
155
+ */
156
+ const INTEGER = String.raw`\d{1,3}(?:,\d{3})+|\d{1,3}(?:[ \u00A0\u202F]\d{3})+(?!\d)|\d+`;
157
+ const NUMBER = String.raw`(?:${INTEGER})(?:\.\d+)?(?:[eE][+-]?\d+)?|\.\d+`;
158
+ /** A cell holding one whole number and nothing else (`38`, `1,980`). */
159
+ const WHOLE = new RegExp(String.raw`^(?:${INTEGER})$`);
160
+ /** The first number of a figure, its whole part and its decimals apart. */
161
+ const WRITTEN = new RegExp(String.raw`(${INTEGER})(?:\.(\d+))?`);
152
162
  const CELL = new RegExp(
153
163
  String.raw`^(?<approx>~|≈|<=|>=|<|>|≤|≥)?\s*(?<sign>[+\-−±])?\s*(?<cur>[$€£¥])?\s*(?<num>${NUMBER})(?<plus>\+(?=\s*[A-Za-zµμ%]))?\s*(?<unit>%|[A-Za-zµμ×]+(?:\/[A-Za-z]+)?|\/s)?(?<rest>.*)$`,
154
164
  );
@@ -517,8 +527,9 @@ export function parseCell(markdown: string): Cell {
517
527
  return { ...cell, fit: "prose" };
518
528
  }
519
529
 
530
+ /** The value of a number {@link NUMBER} matched, its comma or space group separators removed. */
520
531
  function toNumber(written: string): number {
521
- return Number(written.replaceAll(",", ""));
532
+ return Number(written.replace(/[,\s]/g, ""));
522
533
  }
523
534
 
524
535
  /** A number of a list or sum: its value as written, its unit (known or a noun) and how it was marked. */
@@ -632,7 +643,7 @@ function listCell(text: string, base: CellBase): NumberCell | undefined {
632
643
  // A spaced pair of bare numbers that is no score (`25.0 / 27.8`, `12.6 / 9.4`: the second is no whole, or
633
644
  // the first exceeds it) lists two values of the column's quantity; `27 / 30` stays a score.
634
645
  const [first, second] = items;
635
- const whole = /^\d[\d,]*$/.test(text.slice(text.indexOf("/") + 1, end).trim());
646
+ const whole = WHOLE.test(text.slice(text.indexOf("/") + 1, end).trim());
636
647
  const score = whole && first!.value >= 0 && first!.value <= second!.value;
637
648
  const pair = items.length === 2 && !marked.some(Boolean) && separators[0] !== "/" && !score;
638
649
  if (!pair && (!marked.at(-1) || !(marked.every(Boolean) || carried))) return undefined;
@@ -876,9 +887,9 @@ const WHOLE_HEADER = /\b(?:total|all|sum|overall)\b/i;
876
887
  */
877
888
  export function writtenSlack(cell: NumberCell): number {
878
889
  if (cell.denominator !== undefined && cell.dim === "percent") return 0;
879
- const match = /(\d[\d,]*)(?:\.(\d+))?/.exec(cell.figure);
890
+ const match = WRITTEN.exec(cell.figure);
880
891
  if (!match) return 0;
881
- const written = Number(`${match[1]!.replaceAll(",", "")}${match[2] ? `.${match[2]}` : ""}`);
892
+ const written = toNumber(`${match[1]!}${match[2] ? `.${match[2]}` : ""}`);
882
893
  const factor = written ? Math.abs(cell.value) / written : 1;
883
894
  const decimals = match[2]?.length ?? 0;
884
895
  if (cell.dim === "count" && decimals === 0 && factor === 1 && !cell.approx) return 0;
@@ -1097,7 +1108,7 @@ const MIN_LISTS = 2;
1097
1108
  /** A spaced slash between list items. */
1098
1109
  const LIST_SLASH = /\s+\/\s+/;
1099
1110
  /** A list item that is one number, as written (`12`, `~1,400`, `3.5 ms`, `40%`). */
1100
- const LISTED_NUMBER = /^[~≈]?[$€£¥]?\d[\d,]*(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$/;
1111
+ const LISTED_NUMBER = new RegExp(String.raw`^[~≈]?[$€£¥]?(?:${INTEGER})(?:\.\d+)?\s*(?:%|[A-Za-zµμ]+)?$`);
1101
1112
 
1102
1113
  /**
1103
1114
  * Rows folding several rows into one (`response anthropic / chat / responses /
@@ -4,9 +4,9 @@ import { visibleWidth } from "../utils";
4
4
  import type { AdvisorMessageDetails, AdvisorNote, AdvisorSeverity } from "./messages";
5
5
  import { formatBadge, replaceTabs, type ToolUIColor, wrapTextWithAnsi } from "../render/render-utils";
6
6
  import { Ellipsis, truncateToWidth } from "../render";
7
- import { getThemeEpoch, type Theme } from "../theme";
8
- import type { TspSpan } from "@oh-my-pi/pi-wire";
9
- import { card, span, text } from "../native/describe";
7
+ import type { Theme } from "../theme";
8
+ import type { TspSpan, TspTone } from "@oh-my-pi/pi-wire";
9
+ import { card, node, span, text } from "../native/describe";
10
10
  import { type NativeNode, type NativeUiEvent, rootToggleExpanded } from "../native/node";
11
11
  import { plainText } from "../native/spans";
12
12
  import { Memo } from "../native/memo";
@@ -111,13 +111,16 @@ class AdvisorNotes implements Component {
111
111
  }
112
112
  }
113
113
 
114
+ /** The advisor a note came from; undefined for the implicit single ("default") advisor, which goes unlabeled. */
115
+ function sourceOf(entry: AdvisorNote): string | undefined {
116
+ return entry.advisor && entry.advisor !== "default" ? entry.advisor : undefined;
117
+ }
118
+
114
119
  /** Wrapped, truncated rail rows for a single note; shared by both branches. */
115
120
  function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): string[] {
116
121
  const badge = entry.severity ? `${formatBadge(entry.severity, severityColor(entry.severity), uiTheme)} ` : "";
117
- // Multi-advisor: attribute the note to its source. The implicit
118
- // single ("default") advisor renders unlabeled, as before.
119
- const who =
120
- entry.advisor && entry.advisor !== "default" ? `${uiTheme.fg("dim", `[${replaceTabs(entry.advisor)}]`)} ` : "";
122
+ const source = sourceOf(entry);
123
+ const who = source ? `${uiTheme.fg("dim", `[${replaceTabs(source)}]`)} ` : "";
121
124
  const age = entry.turnsAgo !== undefined ? `${uiTheme.fg("dim", `T-${entry.turnsAgo}`)} ` : "";
122
125
  const railGlyph = uiTheme.symbol("advisor.rail");
123
126
  const rail = uiTheme.fg(severityColor(entry.severity), railGlyph);
@@ -145,16 +148,47 @@ function renderAdvisorNote(entry: AdvisorNote, width: number, uiTheme: Theme): s
145
148
  );
146
149
  }
147
150
 
148
- /** Native spans for one note: severity badge, advisor attribution, then the note text. */
149
- function advisorNoteSpans(entry: AdvisorNote): TspSpan[] {
151
+ /** A note's rank in the native callout: its severity, `note` when it has none. */
152
+ type AdvisorRank = AdvisorSeverity | "note";
153
+
154
+ /** Ranks worst first: the callout wears the worst rank among its notes. */
155
+ const RANKS: readonly AdvisorRank[] = ["blocker", "concern", "nit", "note"];
156
+
157
+ /** Each rank's native look, GitHub-alert style: card/icon tone, Tern icon name, label and its span style. */
158
+ const RANK_LOOK: Record<AdvisorRank, { tone: TspTone; icon: string; label: string; style: string }> = {
159
+ blocker: { tone: "error", icon: "octagon-alert", label: "Blocker", style: "error strong" },
160
+ concern: { tone: "warning", icon: "warn", label: "Concern", style: "warning strong" },
161
+ nit: { tone: "neutral", icon: "lightbulb", label: "Nit", style: "strong" },
162
+ note: { tone: "neutral", icon: "info", label: "Note", style: "strong" },
163
+ };
164
+
165
+ function rankOf(entry: AdvisorNote): AdvisorRank {
166
+ return entry.severity ?? "note";
167
+ }
168
+
169
+ /** The callout's title: one note's rank (`Concern`), else a tally worst first (`1 blocker · 2 concerns`). */
170
+ function tallySpans(notes: readonly AdvisorNote[]): TspSpan[] {
171
+ if (notes.length === 1) {
172
+ const { label, style } = RANK_LOOK[rankOf(notes[0]!)];
173
+ return [span(label, style)];
174
+ }
150
175
  const spans: TspSpan[] = [];
151
- if (entry.severity)
152
- spans.push(span(entry.severity.toUpperCase(), `${severityColor(entry.severity)} strong`), span(" "));
153
- if (entry.advisor && entry.advisor !== "default") spans.push(span(`[${entry.advisor}] `, "dim"));
154
- spans.push(span(plainText(entry.note), "customMessageText"));
176
+ for (const rank of RANKS) {
177
+ const count = notes.filter(entry => rankOf(entry) === rank).length;
178
+ if (count === 0) continue;
179
+ if (spans.length > 0) spans.push(span(" · ", "dim"));
180
+ spans.push(span(`${count} ${rank}${count === 1 ? "" : "s"}`, RANK_LOOK[rank].style));
181
+ }
155
182
  return spans;
156
183
  }
157
184
 
185
+ /** Native spans for one note: its advisor when the callout mixes several, then the text. */
186
+ function noteSpans(entry: AdvisorNote, attribute: boolean): TspSpan[] {
187
+ const who = attribute ? sourceOf(entry) : undefined;
188
+ const body = span(plainText(entry.note));
189
+ return who ? [span(`${who} · `, "dim"), body] : [body];
190
+ }
191
+
158
192
  /** The advisor card: shared by the controlled expansion getter and native toggles. */
159
193
  export interface AdvisorMessageCard extends Component {
160
194
  /** Transcript-wide expansion (`Ctrl+O`); clears a toggle made in the terminal. */
@@ -168,6 +202,9 @@ export interface AdvisorMessageCard extends Component {
168
202
  * output (whose `thinkingText` color equals `toolOutput` in most themes):
169
203
  * a bold `customMessageLabel` header tag (skill-card convention), a heavy
170
204
  * rail tinted per-note severity, and the note body on the default text color.
205
+ * Natively it is a callout tinted by its worst note: that severity's icon and
206
+ * the tally head it, the advisor's name ends the head, and with several notes
207
+ * each leads with its own severity icon.
171
208
  *
172
209
  * `getExpanded` owns expansion; a native toggle overrides it for this card
173
210
  * until the next transcript-wide change.
@@ -193,24 +230,40 @@ export function createAdvisorMessageCard(
193
230
  };
194
231
  const nativeMemo = new Memo();
195
232
  const describeCard = (isExpanded: boolean): NativeNode => {
196
- const head: TspSpan[] = [
197
- span(`${uiTheme.status.info} Advisor`, "customMessageLabel strong"),
198
- span(` ${notes.length} ${notes.length === 1 ? "note" : "notes"}`, "dim"),
199
- ];
200
- if (blockers > 0) head.push(span(`${uiTheme.sep.dot}${blockers} blocker${blockers === 1 ? "" : "s"}`, "error"));
201
- const body = notes.map((entry, index) =>
202
- text(advisorNoteSpans(entry), { wrap: "word", role: "omp.advisor.note", key: `n${index}` }),
233
+ const look = RANK_LOOK[RANKS.find(rank => notes.some(entry => rankOf(entry) === rank)) ?? "note"];
234
+ const sources = new Set(notes.map(sourceOf));
235
+ const [sole] = sources;
236
+ const head = node(
237
+ "row",
238
+ { gap: "sm", align: "center", role: "omp.advisor.head" },
239
+ [
240
+ node("icon", { name: look.icon, tone: look.tone }),
241
+ text(tallySpans(notes), { grow: 1 }),
242
+ text([span(sources.size === 1 && sole ? sole : "Advisor", "dim")], { role: "omp.advisor.source" }),
243
+ ],
244
+ "head",
203
245
  );
246
+ const attribute = sources.size > 1;
247
+ const body = notes.map((entry, index) => {
248
+ const spans = noteSpans(entry, attribute);
249
+ if (notes.length === 1) return text(spans, { wrap: "word", role: "omp.advisor.note", key: `n${index}` });
250
+ const { tone, icon, label } = RANK_LOOK[rankOf(entry)];
251
+ return node(
252
+ "row",
253
+ { gap: "sm", align: "start", role: "omp.advisor.note", tone },
254
+ [node("icon", { name: icon, tone, aria: label }), text(spans, { wrap: "word", grow: 1, shrink: 1 })],
255
+ `n${index}`,
256
+ );
257
+ });
204
258
  return card(
205
259
  {
206
260
  role: "omp.advisor",
207
- tone: blockers > 0 ? "error" : "info",
208
- head,
261
+ tone: look.tone,
209
262
  collapsible: notes.length > COLLAPSED_NOTES,
210
263
  collapsed: notes.length > COLLAPSED_NOTES ? !isExpanded : undefined,
211
264
  preview: notes.length > COLLAPSED_NOTES ? "auto" : undefined,
212
265
  },
213
- body,
266
+ [head, ...body],
214
267
  );
215
268
  };
216
269
 
@@ -231,7 +284,7 @@ export function createAdvisorMessageCard(
231
284
  },
232
285
  describe(): NativeNode {
233
286
  const isExpanded = expanded();
234
- return nativeMemo.get([isExpanded, getThemeEpoch()], () => describeCard(isExpanded));
287
+ return nativeMemo.get([isExpanded], () => describeCard(isExpanded));
235
288
  },
236
289
  setExpanded(value: boolean): void {
237
290
  lastGlobal = value;
@@ -286,7 +286,10 @@ export function isUserTurnInitiator(message: CustomMessage): boolean {
286
286
  );
287
287
  }
288
288
 
289
- export type AdvisorSeverity = "nit" | "concern" | "blocker";
289
+ /** Advisor note severities, weakest first; ranks severity thresholds such as `advisor.continueSeverity`. */
290
+ export const ADVISOR_SEVERITIES = ["nit", "concern", "blocker"] as const;
291
+
292
+ export type AdvisorSeverity = (typeof ADVISOR_SEVERITIES)[number];
290
293
 
291
294
  /** One queued advice note. */
292
295
  export interface AdvisorNote {
@@ -761,7 +761,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
761
761
  lines.slice(0, shown).join("\n"),
762
762
  entry.codeStartLine ?? firstSelectorLine(split.sel) ?? 1,
763
763
  entry.codeLineNumbers?.slice(0, shown),
764
- getLanguageFromPath(split.path),
764
+ split.path,
765
765
  );
766
766
  const hidden = lines.length - shown;
767
767
  if (hidden > 0) {
@@ -836,7 +836,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
836
836
  previewsMarkdown(entry)
837
837
  ? md(entry.contentText ?? "")
838
838
  : code(entry.contentText ?? "", {
839
- lang: getLanguageFromPath(split.path),
839
+ path: split.path,
840
840
  start: entry.codeStartLine,
841
841
  numbers: true,
842
842
  wrap: true,