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

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,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.12.1] - 2026-10-17
6
+
7
+ ### Breaking Changes
8
+
9
+ - `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
10
+
11
+ ### Changed
12
+
13
+ - 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
14
+
15
+ ### Fixed
16
+
17
+ - 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
18
+ - 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
19
+
5
20
  ## [18.12.0] - 2026-10-17
6
21
 
7
22
  ### Breaking Changes
@@ -2512,14 +2527,4 @@
2512
2527
 
2513
2528
  - Changed truncation debug logging to run only when `debugRedraw` is enabled
2514
2529
 
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).
2530
+ Older entries are archived in [packages/tui/CHANGELOG.md@200e7d2c6ebb](https://github.com/can1357/oh-my-pi/blob/200e7d2c6ebb2e187b167f807b9f68f779c0bf4b/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;
@@ -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;
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.1",
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.1",
45
+ "@oh-my-pi/pi-agent-core": "18.12.1",
46
+ "@oh-my-pi/pi-ai": "18.12.1",
47
+ "@oh-my-pi/pi-catalog": "18.12.1",
48
+ "@oh-my-pi/pi-natives": "18.12.1",
49
+ "@oh-my-pi/pi-utils": "18.12.1",
50
+ "@oh-my-pi/pi-wire": "18.12.1",
51
+ "@oh-my-pi/snapcompact": "18.12.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -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,
@@ -2,7 +2,7 @@ import { diffWords } from "@oh-my-pi/pi-natives";
2
2
  import { DEFAULT_TAB_WIDTH, sanitizeText } from "@oh-my-pi/pi-utils";
3
3
  import type { TspDiffHunk, TspProps } from "@oh-my-pi/pi-wire";
4
4
  import { theme as activeTheme, getLanguageFromPath, highlightCode, type Theme } from "../theme/index";
5
- import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs, shortenPath } from "../render/render-utils";
5
+ import { type CodeFrameMarker, formatCodeFrameLine, replaceTabs } from "../render/render-utils";
6
6
  import { node } from "../native/describe";
7
7
  import type { NativeNode } from "../native/node";
8
8
  /** SGR dim on / normal intensity — additive, preserves fg/bg colors. */
@@ -106,9 +106,9 @@ function renderIntraLineDiff(
106
106
  * becomes one hunk; the terminal draws gutters, word emphasis and highlighting.
107
107
  * Removed rows carry old line numbers, added rows new ones and context rows
108
108
  * old ones, so the other side is derived from the running line delta.
109
+ * Tern picks the grammar from `path`.
109
110
  */
110
- export function nativeDiff(diffText: string, options: { filePath?: string } & TspProps<"diff"> = {}): NativeNode {
111
- const { filePath, ...props } = options;
111
+ export function nativeDiff(diffText: string, props: TspProps<"diff"> = {}): NativeNode {
112
112
  const hunks: TspDiffHunk[] = [];
113
113
  let lines: string[] = [];
114
114
  let oldStart = 0;
@@ -148,12 +148,7 @@ export function nativeDiff(diffText: string, options: { filePath?: string } & Ts
148
148
  lines.push(`${parsed.prefix}${parsed.content}`);
149
149
  }
150
150
  close();
151
- return node("diff", {
152
- ...props,
153
- hunks,
154
- path: props.path ?? (filePath ? shortenPath(filePath) : undefined),
155
- lang: props.lang ?? (filePath ? getLanguageFromPath(filePath) : undefined),
156
- });
151
+ return node("diff", { ...props, hunks });
157
152
  }
158
153
 
159
154
  export interface RenderDiffOptions {
@@ -17,7 +17,7 @@ import {
17
17
  terminalMultiplexerForTerm,
18
18
  terminalMultiplexerSessions,
19
19
  } from "./terminal-multiplexer";
20
- import { resolveTmuxClientTerminalNames, wrapTmuxPassthroughIfNeeded } from "./tmux";
20
+ import { resolveTmuxClientTerminalNames, tmuxPaneAllowsPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
21
21
  import { detectTerminalBackground } from "./theme/theme";
22
22
  import type { HangulCompatibilityJamoWidth } from "./utils";
23
23
 
@@ -560,6 +560,12 @@ export function resolveImageProtocol(
560
560
  ) {
561
561
  return null;
562
562
  }
563
+ // Kitty graphics reach a tmux client only through the DCS passthrough
564
+ // envelope, which tmux drops unless `allow-passthrough` is enabled. A Kitty
565
+ // client identity alone would otherwise send images into the void.
566
+ if (imageProtocol === ImageProtocol.Kitty && env.TMUX && !tmuxPaneAllowsPassthrough(env)) {
567
+ return null;
568
+ }
563
569
  return imageProtocol;
564
570
  }
565
571
 
package/src/tmux.ts CHANGED
@@ -150,3 +150,33 @@ export function tmuxClientRendersSixel(env: NodeJS.ProcessEnv = Bun.env): boolea
150
150
  if (cachedClientSixel?.tmux !== tmux) cachedClientSixel = { tmux, renders: queryTmuxClientRendersSixel(env) };
151
151
  return cachedClientSixel.renders;
152
152
  }
153
+
154
+ let cachedPanePassthrough: { key: string; allowed: boolean } | undefined;
155
+
156
+ function queryTmuxPaneAllowsPassthrough(env: NodeJS.ProcessEnv): boolean {
157
+ const pane = env.TMUX_PANE;
158
+ const target = pane ? ["-t", pane] : [];
159
+ const value = runTmux(["show-options", "-Apqv", ...target, "allow-passthrough"], env)?.trim();
160
+ if (value === undefined) return false;
161
+ // tmux before 3.3 has no `allow-passthrough` option (`-q` prints nothing)
162
+ // and always forwards DCS passthrough.
163
+ return value === "" || value === "on" || value === "all";
164
+ }
165
+
166
+ /**
167
+ * Whether tmux forwards this pane's `\x1bPtmux;…\x1b\\` passthrough envelopes
168
+ * to the attached client. tmux 3.3+ defaults `allow-passthrough` to `off` and
169
+ * then silently drops every wrapped sequence, so Kitty graphics sent through
170
+ * the envelope never reach a Kitty-capable outer terminal. Resolves the
171
+ * effective pane value (pane → window → global). Queried once per pane;
172
+ * `false` outside tmux or when the query fails.
173
+ */
174
+ export function tmuxPaneAllowsPassthrough(env: NodeJS.ProcessEnv = Bun.env): boolean {
175
+ const tmux = env.TMUX;
176
+ if (!tmux) return false;
177
+ const key = `${tmux}\0${env.TMUX_PANE ?? ""}`;
178
+ if (cachedPanePassthrough?.key !== key) {
179
+ cachedPanePassthrough = { key, allowed: queryTmuxPaneAllowsPassthrough(env) };
180
+ }
181
+ return cachedPanePassthrough.allowed;
182
+ }
@@ -25,7 +25,6 @@ import type {
25
25
  import { code, compact, diff, node, span, text } from "../native/describe";
26
26
  import type { NativeChild, NativeNode } from "../native/node";
27
27
  import { diffStatsMeta, displayPath, errorText, fileDiffSection, noteText, resultText, statsText } from "./native-view";
28
- import { getLanguageFromPath } from "../lang-from-path";
29
28
 
30
29
  /** Display metadata returned by ast-edit. */
31
30
  export interface AstEditToolDetails {
@@ -155,9 +154,9 @@ function parseAstEditChanges(lines: readonly string[]): AstEditFileDiff[] {
155
154
  return files;
156
155
  }
157
156
 
158
- /** A file's proposed rewrite as a `diff`: highlighted by the path's language, no path header. */
157
+ /** A file's proposed rewrite as a `diff`: Tern highlights it by the file's grammar and draws no path header. */
159
158
  function astEditDiff(file: AstEditFileDiff): NativeNode {
160
- return diff(file.lines.join("\n"), { lang: getLanguageFromPath(file.path) });
159
+ return diff(file.lines.join("\n"), { path: file.path });
161
160
  }
162
161
 
163
162
  /** The single rewrite pattern (`target:"pattern"`), or the rewrite count, for the head. */
@@ -14,7 +14,6 @@ import {
14
14
  toPathList,
15
15
  } from "../render/render-utils";
16
16
  import { classifyGroupedLines, describeGroupedOutput, groupLineIndicesByBlank } from "./grouped-file-output";
17
- import { getLanguageFromPath } from "../lang-from-path";
18
17
  import { code, compact, node, span } from "../native/describe";
19
18
  import type { NativeChild } from "../native/node";
20
19
  import { errorText, noteText, resultText, toolHead } from "./native-view";
@@ -279,7 +278,7 @@ export const astGrepToolRenderer = {
279
278
  return {
280
279
  head,
281
280
  body: compact<NativeChild>([
282
- ...describeGroupedOutput(kept, { lang: getLanguageFromPath }),
281
+ ...describeGroupedOutput(kept),
283
282
  limitReached && noteText("limit reached; narrow path or use skip", "warning"),
284
283
  parseNote,
285
284
  ]),
package/src/tools/edit.ts CHANGED
@@ -1222,7 +1222,7 @@ export const editToolRenderer = {
1222
1222
  diffText = args.previewDiff ?? args.diff ?? "";
1223
1223
  body.push(editDiff(diffText, rawPath));
1224
1224
  } else if (args.diff || args.newText || args.patch) {
1225
- body.push(code(args.diff ?? args.newText ?? args.patch ?? "", { lang: getLanguageFromPath(rawPath) }));
1225
+ body.push(code(args.diff ?? args.newText ?? args.patch ?? "", { path: rawPath || undefined }));
1226
1226
  } else if (renderContext?.editDiffPreview) {
1227
1227
  const preview = renderContext.editDiffPreview;
1228
1228
  if ("error" in preview && preview.error) body.push(errorText(preview.error));
@@ -1362,9 +1362,9 @@ function editToolHead(facts: EditHeadFacts): NativeToolHead {
1362
1362
  };
1363
1363
  }
1364
1364
 
1365
- /** A file's diff: hunks highlighted by the path's language, no path header (the head or section names the file). */
1365
+ /** A file's diff: Tern highlights the hunks by the file's grammar and draws no path header (the head or section names the file). */
1366
1366
  function editDiff(diffText: string, filePath: string): NativeNode {
1367
- return nativeDiff(diffText, { lang: filePath ? getLanguageFromPath(filePath) : undefined });
1367
+ return nativeDiff(diffText, { path: filePath || undefined });
1368
1368
  }
1369
1369
 
1370
1370
  /** One file's edit result for native views: head facts plus its body (diff or error, then diagnostics). */
package/src/tools/eval.ts CHANGED
@@ -36,7 +36,6 @@ import {
36
36
  } from "./json-tree";
37
37
  import { formatStyledTruncationWarning, stripOutputNotice } from "./output-meta";
38
38
  import { splitPathAndSel } from "./read";
39
- import { getLanguageFromPath } from "../lang-from-path";
40
39
  import {
41
40
  DEFAULT_TERMINAL_PREVIEW_LINES,
42
41
  PREVIEW_LIMITS,
@@ -65,31 +64,57 @@ export interface EvalStatusEvent {
65
64
  [key: string]: unknown;
66
65
  }
67
66
 
67
+ /** One column of a table view. */
68
+ export interface EvalTableColumn {
69
+ head: string;
70
+ /** Numeric values: renderers right-align the column. */
71
+ numeric?: boolean;
72
+ }
73
+
74
+ /**
75
+ * A pretty rendering of something a cell printed, shown to the user in place
76
+ * of its text; the model only ever sees the text.
77
+ * - `table`: a data frame or `console.table`; leading index columns are plain
78
+ * columns, and a truncated frame's elided rows/columns are `…` cells.
79
+ * - `json`: a structured value (dict/list, object/array) as a collapsible tree.
80
+ * - `markdown`: an object's Markdown representation.
81
+ */
82
+ export type EvalView =
83
+ | { kind: "table"; columns: EvalTableColumn[]; rows: string[][] }
84
+ | { kind: "json"; value: unknown }
85
+ | { kind: "markdown"; text: string };
86
+
87
+ /**
88
+ * A view and the exact printed text it stands for. Renderers locate `text` in
89
+ * the cell's `output` (entries in print order) and show the view there; an
90
+ * entry whose text is no longer in the output (truncated) is skipped.
91
+ */
92
+ export interface EvalViewEntry {
93
+ text: string;
94
+ view: EvalView;
95
+ }
96
+
68
97
  /** Per-cell execution result for transcript rendering. */
69
98
  export interface EvalCellResult {
70
99
  index: number;
71
100
  title?: string;
72
101
  code: string;
73
102
  language?: EvalLanguage;
103
+ /** What the model saw: the cell's printed text plus image notes. */
74
104
  output: string;
75
- /**
76
- * `output` without its `display[N]:` JSON sections, which renderers show
77
- * from `jsonOutputs` instead. Absent when the cell displayed no JSON.
78
- */
79
- textOutput?: string;
105
+ /** Pretty views of spans of `output`, in print order. */
106
+ views?: EvalViewEntry[];
80
107
  status: "pending" | "running" | "complete" | "error";
81
108
  durationMs?: number;
82
109
  exitCode?: number;
83
110
  statusEvents?: EvalStatusEvent[];
84
111
  /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
85
112
  statusEventsElided?: number;
86
- hasMarkdown?: boolean;
87
113
  }
88
114
 
89
115
  /** Tool result detail object surfaced to the UI/transcript. */
90
116
  export interface EvalToolDetails {
91
117
  cells?: EvalCellResult[];
92
- jsonOutputs?: unknown[];
93
118
  images?: ImageContent[];
94
119
  statusEvents?: EvalStatusEvent[];
95
120
  /** Discrete events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
@@ -593,8 +618,7 @@ function formatCellOutputLines(
593
618
  theme: Theme,
594
619
  width: number,
595
620
  ): { lines: readonly string[]; hiddenCount: number } {
596
- // JSON displays render once, as the `jsonOutputs` tree below the last cell.
597
- const output = cell.textOutput ?? cell.output;
621
+ const output = cell.output;
598
622
  if (!output) {
599
623
  return { lines: [], hiddenCount: 0 };
600
624
  }
@@ -607,23 +631,47 @@ function formatCellOutputLines(
607
631
  // exact hidden-count row, so the pane marker stays off here.
608
632
  const innerWidth = outputBlockContentWidth(width);
609
633
 
610
- if (cell.hasMarkdown && cell.status !== "error") {
611
- const md = new Markdown(output, 0, 0, getMarkdownTheme());
612
- const allLines = md.render(innerWidth);
634
+ const isError = cell.status === "error";
635
+ const styleText = (line: string): string => theme.fg(isError ? "error" : "toolOutput", replaceTabs(line));
636
+ // A table's printed text is already a terminal table, aligned to the line it starts on: keep it as text.
637
+ const parts = splitEvalOutput(
638
+ output,
639
+ cell.views?.filter(entry => entry.view.kind !== "table"),
640
+ );
641
+ if (parts.some(part => part.kind === "view")) {
642
+ // Printed text keeps its place; json and markdown views replace theirs with a tree / rendered Markdown.
643
+ const treeDepth = expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
644
+ const treeLineCap = expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
645
+ const treeScalarLen = expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
646
+ const allLines: string[] = [];
647
+ for (const part of parts) {
648
+ if (part.kind === "text") {
649
+ for (const line of part.text.split("\n")) allLines.push(styleText(line));
650
+ continue;
651
+ }
652
+ const view = part.view;
653
+ if (view.kind === "json") {
654
+ const tree = renderJsonTreeLines(view.value, theme, treeDepth, treeLineCap, treeScalarLen);
655
+ allLines.push(...tree.lines);
656
+ if (tree.truncated) allLines.push(theme.fg("dim", "…"));
657
+ } else if (view.kind === "markdown") {
658
+ allLines.push(...new Markdown(view.text, 0, 0, getMarkdownTheme()).render(innerWidth));
659
+ }
660
+ }
613
661
  const formatted = formatOutputPaneLines(
614
662
  {
615
663
  lines: allLines,
616
664
  expanded,
617
665
  collapsedMaxLines: previewLines,
618
666
  edge: "tail",
667
+ visual: true,
668
+ width: innerWidth,
619
669
  showHiddenMarker: false,
620
670
  },
621
671
  theme,
622
672
  );
623
673
  return { lines: formatted.lines, hiddenCount: formatted.hiddenCount };
624
674
  }
625
-
626
- const isError = cell.status === "error";
627
675
  const formatted = formatOutputPaneLines(
628
676
  {
629
677
  lines: output.split("\n"),
@@ -632,7 +680,7 @@ function formatCellOutputLines(
632
680
  edge: "tail",
633
681
  visual: true,
634
682
  width: innerWidth,
635
- styleLine: line => theme.fg(isError ? "error" : "toolOutput", replaceTabs(line)),
683
+ styleLine: styleText,
636
684
  showHiddenMarker: false,
637
685
  },
638
686
  theme,
@@ -664,13 +712,12 @@ function describeStatusEvent(event: EvalStatusEvent, expanded: boolean): NativeN
664
712
  if (!preview) return line;
665
713
  const head = cappedHeadLines(sanitizeDisplayLines(preview), PREVIEW_LIMITS.OUTPUT_EXPANDED).lines;
666
714
  const path = eventString(event.path);
667
- const lang = path ? getLanguageFromPath(splitPathAndSel(path).path) : undefined;
668
715
  return node("col", { role: "omp.tool.eval.status.event", gap: "none" }, [
669
716
  keyed(line, "line"),
670
717
  keyed(
671
718
  code(head.join("\n"), {
672
719
  role: "omp.tool.eval.status.preview",
673
- lang,
720
+ path: path ? splitPathAndSel(path).path : undefined,
674
721
  wrap: false,
675
722
  preview: { lines: PREVIEW_LIMITS.COLLAPSED_LINES },
676
723
  }),
@@ -702,96 +749,79 @@ function evalToolHead(
702
749
  };
703
750
  }
704
751
 
705
- /** Box-drawing `console.table` output split out of plain text: `text` runs and parsed tables, in order. */
706
- type EvalOutputPart =
707
- | { readonly kind: "text"; readonly text: string }
708
- | { readonly kind: "table"; readonly head: readonly string[]; readonly rows: readonly string[][] };
709
-
710
- /** The cells of a `│ a │ b │` row, trimmed; undefined when the line is not a table row. */
711
- function tableRowCells(line: string): string[] | undefined {
712
- const plain = plainText(line).trim();
713
- if (plain.length < 2 || !plain.startsWith("│") || !plain.endsWith("│")) return undefined;
714
- return plain
715
- .slice(1, -1)
716
- .split("│")
717
- .map(cell => cell.trim());
718
- }
752
+ /** One ordered piece of a cell's output: printed text, or a view shown in place of the text it matched. */
753
+ export type EvalOutputPart = { kind: "text"; text: string } | { kind: "view"; view: EvalView };
719
754
 
720
755
  /**
721
- * Split `console.table` blocks (`┌…┐` header `├…┤` rows `└…┘`, as Node/Bun
722
- * print them) out of cell output so they render as a `table`; anything that
723
- * does not parse as one stays text.
756
+ * Split `output` around its views, in order: each view's `text` is searched
757
+ * from the end of the previous match, unmatched entries are skipped, and
758
+ * blank text between views is dropped (newlines bordering a view trimmed).
724
759
  */
725
- function splitConsoleTables(output: string): EvalOutputPart[] {
726
- const lines = output.split("\n");
760
+ export function splitEvalOutput(output: string, views: readonly EvalViewEntry[] | undefined): EvalOutputPart[] {
727
761
  const parts: EvalOutputPart[] = [];
728
- let textStart = 0;
729
- const flushText = (end: number): void => {
730
- const chunk = lines
731
- .slice(textStart, end)
732
- .join("\n")
733
- .replace(/^\n+|\s+$/g, "");
734
- if (chunk.length > 0) parts.push({ kind: "text", text: chunk });
762
+ const pushText = (chunk: string, trimStart: boolean, trimEnd: boolean): void => {
763
+ let text = chunk;
764
+ if (trimStart) text = text.replace(/^\n+/, "");
765
+ if (trimEnd) text = text.replace(/\n+$/, "");
766
+ if (text.trim().length > 0) parts.push({ kind: "text", text });
735
767
  };
736
- for (let i = 0; i < lines.length; i++) {
737
- if (!plainText(lines[i]!).trimStart().startsWith("┌")) continue;
738
- const head = tableRowCells(lines[i + 1] ?? "");
739
- if (
740
- !head ||
741
- !plainText(lines[i + 2] ?? "")
742
- .trimStart()
743
- .startsWith("├")
744
- )
745
- continue;
746
- const rows: string[][] = [];
747
- let end = i + 3;
748
- for (; end < lines.length; end++) {
749
- const cells = tableRowCells(lines[end]!);
750
- if (!cells) break;
751
- rows.push(cells);
752
- }
753
- if (
754
- !plainText(lines[end] ?? "")
755
- .trimStart()
756
- .startsWith("└")
757
- )
758
- continue;
759
- if (rows.some(row => row.length !== head.length)) continue;
760
- flushText(i);
761
- parts.push({ kind: "table", head, rows });
762
- i = end;
763
- textStart = end + 1;
768
+ let cursor = 0;
769
+ for (const entry of views ?? []) {
770
+ if (!entry.text) continue;
771
+ const at = output.indexOf(entry.text, cursor);
772
+ if (at < 0) continue;
773
+ pushText(output.slice(cursor, at), cursor > 0, true);
774
+ parts.push({ kind: "view", view: entry.view });
775
+ cursor = at + entry.text.length;
764
776
  }
765
- flushText(lines.length);
777
+ pushText(output.slice(cursor), cursor > 0, cursor > 0);
766
778
  return parts;
767
779
  }
768
780
 
781
+ /** A table view as a native `table`; numeric columns align to the end. */
782
+ function evalTableNode(view: Extract<EvalView, { kind: "table" }>, key: string): NativeNode {
783
+ return node(
784
+ "table",
785
+ {
786
+ cols: view.columns.map((column, c) => ({
787
+ id: `c${c}`,
788
+ head: column.head,
789
+ align: column.numeric ? "end" : undefined,
790
+ })),
791
+ rows: view.rows.map((cells, r) => ({
792
+ id: `r${r}`,
793
+ cells: Object.fromEntries(cells.map((cell, c) => [`c${c}`, cell])),
794
+ })),
795
+ role: "omp.tool.eval.table",
796
+ },
797
+ undefined,
798
+ key,
799
+ );
800
+ }
801
+
769
802
  /**
770
- * One cell's outputs: markdown, else terminal text (following its tail,
771
- * clamped to the last `previewLines` while folded) with any `console.table`
772
- * blocks as tables.
803
+ * One cell's outputs in order: printed text (following its tail, clamped to
804
+ * the last `previewLines` while folded) with each view in place of its text.
773
805
  */
774
- function evalOutputNodes(output: string, markdown: boolean, previewLines: number): NativeNode[] {
806
+ function evalOutputNodes(
807
+ output: string,
808
+ views: readonly EvalViewEntry[] | undefined,
809
+ previewLines: number,
810
+ ): NativeNode[] {
775
811
  if (output.trim().length === 0) return [];
776
- if (markdown) return [keyed(md(output), "output")];
777
- return splitConsoleTables(output).map((part, i) =>
778
- part.kind === "text"
779
- ? runOutput(part.text, { role: "omp.tool.eval.output", previewLines, key: i === 0 ? "output" : `output-${i}` })
780
- : node(
781
- "table",
782
- {
783
- // Node's index column header is noise in a real table.
784
- cols: part.head.map((head, c) => ({ id: `c${c}`, head: head === "(index)" ? "" : head })),
785
- rows: part.rows.map((cells, r) => ({
786
- id: `r${r}`,
787
- cells: Object.fromEntries(cells.map((cell, c) => [`c${c}`, cell])),
788
- })),
789
- role: "omp.tool.eval.table",
790
- },
791
- undefined,
792
- `table-${i}`,
793
- ),
794
- );
812
+ return splitEvalOutput(output, views).map((part, i) => {
813
+ if (part.kind === "text") {
814
+ return runOutput(part.text, {
815
+ role: "omp.tool.eval.output",
816
+ previewLines,
817
+ key: i === 0 ? "output" : `output-${i}`,
818
+ });
819
+ }
820
+ const view = part.view;
821
+ if (view.kind === "table") return evalTableNode(view, `table-${i}`);
822
+ if (view.kind === "json") return keyed(describeJsonTree(view.value, { hiddenRootKeys: [] }), `json-${i}`);
823
+ return keyed(md(view.text), `markdown-${i}`);
824
+ });
795
825
  }
796
826
 
797
827
  /** Status lines a folded cell keeps: the newest ones. */
@@ -958,18 +988,6 @@ export const evalToolRenderer = {
958
988
  // the styled `warningLine` below carries the same text in ⟨…⟩ form.
959
989
  const output = stripOutputNotice(rawOutput, details?.meta).trimEnd();
960
990
 
961
- const jsonOutputs = details?.jsonOutputs ?? [];
962
- const treeExpanded = options.renderContext?.expanded ?? options.expanded;
963
- const treeDepth = treeExpanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
964
- const treeLineCap = treeExpanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
965
- const treeScalarLen = treeExpanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
966
- const labelOutputs = jsonOutputs.length > 1;
967
- const jsonLines = jsonOutputs.flatMap((value, index) => {
968
- const tree = renderJsonTreeLines(value, uiTheme, treeDepth, treeLineCap, treeScalarLen);
969
- const body = tree.truncated ? [...tree.lines, uiTheme.fg("dim", "…")] : tree.lines;
970
- return labelOutputs ? [uiTheme.fg("dim", `display[${index + 1}]`), ...body] : body;
971
- });
972
-
973
991
  const timeoutSeconds = options.renderContext?.timeout;
974
992
  const timeoutLine =
975
993
  typeof timeoutSeconds === "number"
@@ -1093,7 +1111,6 @@ export const evalToolRenderer = {
1093
1111
  lines.push("");
1094
1112
  }
1095
1113
  }
1096
- lines.push(...jsonLines);
1097
1114
  if (timeoutLine) {
1098
1115
  lines.push(timeoutLine);
1099
1116
  }
@@ -1116,9 +1133,6 @@ export const evalToolRenderer = {
1116
1133
  });
1117
1134
  }
1118
1135
 
1119
- const displayOutput = output;
1120
- const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
1121
-
1122
1136
  const statusEvents = details?.statusEvents ?? [];
1123
1137
  const statusLines = renderStatusEvents(
1124
1138
  statusEvents,
@@ -1127,12 +1141,12 @@ export const evalToolRenderer = {
1127
1141
  details?.statusEventsElided,
1128
1142
  );
1129
1143
 
1130
- if (!combinedOutput && statusLines.length === 0) {
1144
+ if (!output && statusLines.length === 0) {
1131
1145
  const lines = [timeoutLine, noticeLine, asyncLine, warningLine].filter(Boolean) as string[];
1132
1146
  return new Text(lines.join("\n"), 0, 0);
1133
1147
  }
1134
1148
 
1135
- if (!combinedOutput && statusLines.length > 0) {
1149
+ if (!output && statusLines.length > 0) {
1136
1150
  const lines = [
1137
1151
  uiTheme.fg("dim", "Status"),
1138
1152
  ...statusLines,
@@ -1145,7 +1159,7 @@ export const evalToolRenderer = {
1145
1159
  }
1146
1160
 
1147
1161
  if (options.renderContext?.expanded ?? options.expanded) {
1148
- const styledOutput = combinedOutput
1162
+ const styledOutput = output
1149
1163
  .split("\n")
1150
1164
  .map(line => uiTheme.fg("toolOutput", line))
1151
1165
  .join("\n");
@@ -1160,7 +1174,7 @@ export const evalToolRenderer = {
1160
1174
  return new Text(lines.join("\n"), 0, 0);
1161
1175
  }
1162
1176
 
1163
- const styledOutput = combinedOutput
1177
+ const styledOutput = output
1164
1178
  .split("\n")
1165
1179
  .map(line => uiTheme.fg("toolOutput", line))
1166
1180
  .join("\n");
@@ -1250,9 +1264,6 @@ export const evalToolRenderer = {
1250
1264
  const cancelled = options.cancelled === true;
1251
1265
  const expanded = options.renderContext?.expanded ?? options.expanded;
1252
1266
  const previewLines = options.renderContext?.previewLines ?? EVAL_DEFAULT_PREVIEW_LINES;
1253
- const jsonNodes = (details?.jsonOutputs ?? []).map((value, index) =>
1254
- keyed(describeJsonTree(value, { hiddenRootKeys: [] }), `display-${index}`),
1255
- );
1256
1267
  // The call's notice and truncation join the last run's foot facts (a background job is a head badge).
1257
1268
  const callFacts = { facts: compact([details?.notice]), meta: details?.meta };
1258
1269
  const cellResults = details?.cells;
@@ -1278,15 +1289,7 @@ export const evalToolRenderer = {
1278
1289
  language,
1279
1290
  code: formatEvalCodeForDisplay(cell.code, language),
1280
1291
  title: cell.title,
1281
- outputs: [
1282
- ...evalOutputNodes(
1283
- cell.textOutput ?? cell.output,
1284
- cell.hasMarkdown === true && cell.status !== "error",
1285
- previewLines,
1286
- ),
1287
-
1288
- ...(i === cellResults.length - 1 ? jsonNodes : []),
1289
- ],
1292
+ outputs: [...evalOutputNodes(cell.output, cell.views, previewLines)],
1290
1293
  status: evalStatusSection(cell, expanded),
1291
1294
  foot: state && {
1292
1295
  state,
@@ -1311,10 +1314,7 @@ export const evalToolRenderer = {
1311
1314
  const rawOutput = options.renderContext?.output ?? resultText(result).trimEnd();
1312
1315
  const state: RunState = cancelled ? "cancelled" : isPartial ? "running" : result.isError ? "failed" : "done";
1313
1316
  const last: Omit<EvalCellRun, "language" | "code" | "title"> = {
1314
- outputs: [
1315
- ...evalOutputNodes(stripOutputNotice(rawOutput, details?.meta).trimEnd(), false, previewLines),
1316
- ...jsonNodes,
1317
- ],
1317
+ outputs: evalOutputNodes(stripOutputNotice(rawOutput, details?.meta).trimEnd(), undefined, previewLines),
1318
1318
  status: evalStatusSection(details ?? {}, expanded),
1319
1319
  foot: { state, elapsedMs: options.elapsedMs, ...callFacts },
1320
1320
  };
package/src/tools/find.ts CHANGED
@@ -37,7 +37,6 @@ import type {
37
37
  import type { TspTone } from "@oh-my-pi/pi-wire";
38
38
  import { code, col, compact, keyed, node, span, text } from "../native/describe";
39
39
  import type { NativeChild, NativeNode } from "../native/node";
40
- import { getLanguageFromPath } from "../lang-from-path";
41
40
  import { fileHref, fileRow, inlineErrorView } from "./native-view";
42
41
  import { splitUrlScheme } from "./url-scheme-host";
43
42
 
@@ -214,10 +213,14 @@ function describeHit(hit: FindHit, rangeLimit: number, cwd: string | undefined,
214
213
  href: isUrlHit || cwd === undefined ? undefined : fileHref(path.join(cwd, rawRel)),
215
214
  key: "hit",
216
215
  });
217
- const lang = getLanguageFromPath(hit.rel);
218
216
  const snippets = ranges.map(range =>
219
217
  keyed(
220
- code(range.snippet.trimEnd(), { lang, start: range.start, numbers: true, title: range.p.toFixed(2) }),
218
+ code(range.snippet.trimEnd(), {
219
+ path: hit.rel,
220
+ start: range.start,
221
+ numbers: true,
222
+ title: range.p.toFixed(2),
223
+ }),
221
224
  `${range.start}-${range.end}`,
222
225
  ),
223
226
  );
package/src/tools/grep.ts CHANGED
@@ -9,7 +9,6 @@ import type {
9
9
  RenderResultOptions,
10
10
  ToolRenderer,
11
11
  } from "./renderer";
12
- import { getLanguageFromPath } from "../lang-from-path";
13
12
  import { compact } from "../native/describe";
14
13
  import type { NativeChild } from "../native/node";
15
14
  import { footnoteText, inlineErrorView, resultText } from "./native-view";
@@ -557,7 +556,6 @@ export const grepToolRenderer = {
557
556
  inline: true,
558
557
  body: compact<NativeChild>([
559
558
  ...describeGroupedOutput(textContent.split("\n"), {
560
- lang: getLanguageFromPath,
561
559
  maxFiles: options.expanded ? undefined : NATIVE_COLLAPSED_FILES,
562
560
  file: details?.files?.length === 1 ? details.files[0] : undefined,
563
561
  }),
@@ -314,12 +314,13 @@ export interface NumberedLine {
314
314
  * Numbered `code` for possibly non-contiguous source lines: one block per run
315
315
  * of consecutive line numbers (`start` = the run's first line, match lines
316
316
  * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
317
- * (`n: null`) sits. No path header: the row or head above names the file.
317
+ * (`n: null`) sits. `path` is the file the lines come from: Tern picks their
318
+ * grammar from it and draws no header (the row or head above names the file).
318
319
  * `wrap` soft-wraps long lines instead of scrolling the block sideways.
319
320
  */
320
321
  export function numberedCode(
321
322
  lines: readonly NumberedLine[],
322
- opts: { lang?: string; wrap?: boolean } = {},
323
+ opts: { path?: string; wrap?: boolean } = {},
323
324
  ): NativeNode[] {
324
325
  const out: NativeNode[] = [];
325
326
  let run: { start: number; text: string[]; marks: { line: number; tone: TspTone }[] } | undefined;
@@ -332,7 +333,7 @@ export function numberedCode(
332
333
  code(run.text.join("\n"), {
333
334
  start: run.start,
334
335
  numbers: true,
335
- lang: opts.lang,
336
+ path: opts.path,
336
337
  wrap: opts.wrap,
337
338
  marks: run.marks.length > 0 ? run.marks : undefined,
338
339
  }),
@@ -367,7 +368,7 @@ export function numberedCode(
367
368
  */
368
369
  export function describeGroupedOutput(
369
370
  lines: readonly string[],
370
- options: { lang?: (path: string) => string | undefined; maxFiles?: number; file?: string } = {},
371
+ options: { maxFiles?: number; file?: string } = {},
371
372
  ): NativeNode[] {
372
373
  const files: NativeNode[] = [];
373
374
  const loose: NativeNode[] = [];
@@ -380,7 +381,7 @@ export function describeGroupedOutput(
380
381
  const flushLines = () => {
381
382
  const target = current?.children ?? loose;
382
383
  if (numbered.length > 0) {
383
- target.push(...numberedCode(numbered, { lang: current ? options.lang?.(current.path) : undefined }));
384
+ target.push(...numberedCode(numbered, { path: current?.path }));
384
385
  numbered = [];
385
386
  }
386
387
  if (plain.length > 0) {
package/src/tools/read.ts CHANGED
@@ -431,12 +431,12 @@ export function readContentCode(
431
431
  content: string,
432
432
  start: number,
433
433
  numbers: readonly (number | null)[] | undefined,
434
- lang: string | undefined,
434
+ path: string | undefined,
435
435
  ): NativeNode[] {
436
436
  const lines = content.split("\n");
437
437
  return numberedCode(
438
438
  lines.map((text, i) => ({ n: numbers && numbers.length === lines.length ? numbers[i]! : start + i, text })),
439
- { lang, wrap: true },
439
+ { path, wrap: true },
440
440
  );
441
441
  }
442
442
 
@@ -766,7 +766,7 @@ export const readToolRenderer = {
766
766
  contentText,
767
767
  details?.displayContent?.startLine ?? readRangeStart(rawPath, args),
768
768
  details?.displayContent?.lineNumbers,
769
- getLanguageFromPath(renderPath.path),
769
+ renderPath.path,
770
770
  );
771
771
  return { tool: head, inline: true, body: compact<NativeChild>([media, ...content, foot]) };
772
772
  },
@@ -164,10 +164,12 @@ function describeFigure(figure: ToolFigure | undefined): NativeNode | undefined
164
164
  return { ...md(text, { role: "omp.tool.write.figure", stream: !figure.closed }), key: "figure" };
165
165
  }
166
166
 
167
- /** The written content as numbered code, keyed so it keeps its node as the figure above it comes and goes. */
167
+ /**
168
+ * The written content as numbered code, highlighted by Tern from the file's path,
169
+ * keyed so it keeps its node as the figure above it comes and goes.
170
+ */
168
171
  function describeSource(rawPath: string, content: string): NativeNode {
169
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
170
- return { ...code(content, { lang, numbers: true, wrap: true }), key: "source" };
172
+ return { ...code(content, { path: rawPath || undefined, numbers: true, wrap: true }), key: "source" };
171
173
  }
172
174
 
173
175
  function countLines(text: string): number {