@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.
@@ -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
  },
@@ -26,6 +26,7 @@ export const SEARCH_PROVIDER_LABELS = {
26
26
  tavily: "Tavily",
27
27
  firecrawl: "Firecrawl",
28
28
  brave: "Brave",
29
+ deepseek: "DeepSeek",
29
30
  kimi: "Kimi",
30
31
  synthetic: "Synthetic",
31
32
  ollama: "Ollama",
@@ -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 {
package/src/tui.ts CHANGED
@@ -107,6 +107,11 @@ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
107
107
  // native text selection.
108
108
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
109
109
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
110
+ // Cell-size replies: CSI 6 ; height ; width t in pixels, and iTerm2's
111
+ // OSC 1337 ; ReportCellSize = height ; width [; scale] ST in points.
112
+ const CELL_SIZE_REPLY = /^\x1b\[6;(\d+);(\d+)t$/;
113
+ const ITERM2_CELL_SIZE_REPLY =
114
+ /^\x1b\]1337;ReportCellSize=(\d+(?:\.\d+)?);(\d+(?:\.\d+)?)(?:;(\d+(?:\.\d+)?))?(?:\x07|\x1b\\)$/;
110
115
 
111
116
  type MouseTrackingState = "off" | "inline" | "full";
112
117
 
@@ -2562,6 +2567,9 @@ export class TUI extends Container {
2562
2567
  // Query terminal for cell size in pixels: CSI 16 t
2563
2568
  // Response format: CSI 6 ; height ; width t
2564
2569
  this.terminal.write("\x1b[16t");
2570
+ // iTerm2 never answers CSI 16 t. Without a size, rows reserved for an image
2571
+ // and crops of a partly scrolled one assume 9x18 cells and land offset.
2572
+ if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) this.terminal.write("\x1b]1337;ReportCellSize\x1b\\");
2565
2573
  }
2566
2574
 
2567
2575
  /**
@@ -3051,14 +3059,19 @@ export class TUI extends Container {
3051
3059
  }
3052
3060
 
3053
3061
  #consumeCellSizeResponse(data: string): boolean {
3054
- // Response format: ESC [ 6 ; height ; width t
3055
- const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
3056
- if (!match) {
3057
- return false;
3062
+ let heightPx: number;
3063
+ let widthPx: number;
3064
+ const pixels = CELL_SIZE_REPLY.exec(data);
3065
+ if (pixels) {
3066
+ heightPx = parseInt(pixels[1], 10);
3067
+ widthPx = parseInt(pixels[2], 10);
3068
+ } else {
3069
+ const points = ITERM2_CELL_SIZE_REPLY.exec(data);
3070
+ if (!points) return false;
3071
+ const scale = points[3] ? Number(points[3]) : 1;
3072
+ heightPx = Math.round(Number(points[1]) * scale);
3073
+ widthPx = Math.round(Number(points[2]) * scale);
3058
3074
  }
3059
-
3060
- const heightPx = parseInt(match[1], 10);
3061
- const widthPx = parseInt(match[2], 10);
3062
3075
  if (heightPx <= 0 || widthPx <= 0) {
3063
3076
  return true;
3064
3077
  }