@oh-my-pi/pi-tui 18.14.0 → 18.14.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +6 -0
  3. package/dist/types/charts/table-data.d.ts +4 -2
  4. package/dist/types/chat/figure-markdown.d.ts +2 -0
  5. package/dist/types/chat/svg-figure.d.ts +5 -0
  6. package/dist/types/chat/table-chart.d.ts +2 -0
  7. package/dist/types/chat/transcript-render-helpers.d.ts +3 -3
  8. package/dist/types/components/image.d.ts +6 -0
  9. package/dist/types/native/describe.d.ts +4 -2
  10. package/dist/types/native/overlay.d.ts +42 -2
  11. package/dist/types/native/state.d.ts +4 -0
  12. package/dist/types/overlays/btw-history-panel.d.ts +2 -0
  13. package/dist/types/prompt/composer.d.ts +13 -5
  14. package/dist/types/render/render-utils.d.ts +2 -0
  15. package/dist/types/theme/theme.d.ts +1 -1
  16. package/dist/types/theme/tui-adapters.d.ts +2 -0
  17. package/dist/types/thinking.d.ts +8 -6
  18. package/dist/types/tools/ast-grep.d.ts +2 -0
  19. package/dist/types/tools/eval.d.ts +2 -0
  20. package/dist/types/tools/find.d.ts +1 -1
  21. package/dist/types/tools/grouped-file-output.d.ts +24 -11
  22. package/dist/types/tools/read.d.ts +4 -4
  23. package/dist/types/tools/task.d.ts +2 -2
  24. package/package.json +9 -9
  25. package/src/apps/git/git-tui.ts +10 -17
  26. package/src/apps/git/sidebar.ts +21 -35
  27. package/src/apps/ps-top.ts +4 -12
  28. package/src/charts/chart-plan.ts +0 -29
  29. package/src/charts/table-data.ts +55 -6
  30. package/src/chat/figure-markdown.ts +6 -4
  31. package/src/chat/svg-figure.ts +55 -16
  32. package/src/chat/table-chart.ts +19 -2
  33. package/src/chat/transcript-render-helpers.ts +18 -16
  34. package/src/components/image.ts +13 -0
  35. package/src/native/backend.ts +7 -3
  36. package/src/native/describe.ts +9 -4
  37. package/src/native/overlay.ts +124 -16
  38. package/src/native/state.ts +25 -5
  39. package/src/overlays/btw-history-panel.ts +8 -0
  40. package/src/overlays/extensions/extension-dashboard.ts +7 -8
  41. package/src/overlays/jobs-panel.ts +38 -4
  42. package/src/overlays/login-dialog.ts +8 -26
  43. package/src/overlays/tree-selector.ts +81 -27
  44. package/src/overlays/usage-dashboard.ts +2 -4
  45. package/src/prompt/composer.ts +17 -5
  46. package/src/render/render-utils.ts +2 -0
  47. package/src/theme/theme-class.ts +12 -3
  48. package/src/theme/theme.ts +1 -0
  49. package/src/theme/tui-adapters.ts +8 -3
  50. package/src/thinking.ts +17 -11
  51. package/src/tools/ast-grep.ts +10 -2
  52. package/src/tools/eval.ts +2 -0
  53. package/src/tools/find.ts +7 -2
  54. package/src/tools/grep.ts +8 -2
  55. package/src/tools/grouped-file-output.ts +172 -47
  56. package/src/tools/lsp.ts +158 -8
  57. package/src/tools/read.ts +6 -5
  58. package/src/tools/task.ts +2 -2
@@ -103,6 +103,13 @@ export interface RuntimeChildrenOptions {
103
103
  * to the roots below the transcript in render order.
104
104
  */
105
105
  readonly nativeDock?: readonly Component[];
106
+ /**
107
+ * Below-transcript containers whose children continue `main` on a TSP
108
+ * terminal, one block each after the transcript's, instead of riding in the
109
+ * dock: output on its way into the transcript (shell runs deferred until the
110
+ * next prompt) reads as its tail, and keeps its block when it moves in.
111
+ */
112
+ readonly nativeTail?: readonly Container[];
106
113
  /**
107
114
  * Below-transcript roots the user opens and closes (the editor surface that
108
115
  * drafts and dialogs swap into, docked reports). Their rows never become
@@ -259,6 +266,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
259
266
  #headerAfter: readonly Component[] = [];
260
267
  #runtimeChildren: readonly Component[] = [];
261
268
  #nativeDock: readonly Component[] | undefined;
269
+ #nativeTail: readonly Container[] = [];
262
270
  /** Cache-driven status line shown until {@link setStatusComponent} mounts the session's. */
263
271
  #startupStatus: StatusLineComponent | undefined;
264
272
  #runtimeMounted = false;
@@ -653,10 +661,11 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
653
661
  /**
654
662
  * Describe the whole surface for a TSP terminal. `main` is the flowing
655
663
  * document: the header (welcome, extras) and every transcript block as its
656
- * own component child. `dock` is the live chrome below the transcript in
657
- * render order: pending messages, HUDs, the working row, the editor and the
658
- * status line. There is no history/viewport split: retirement, resize
659
- * replay and hover bands are ANSI concerns.
664
+ * own component child, then the children of the
665
+ * {@link RuntimeChildrenOptions.nativeTail} roots. `dock` is the live chrome
666
+ * below the transcript in render order: pending messages, HUDs, the working
667
+ * row, the editor and the status line. There is no history/viewport split:
668
+ * retirement, resize replay and hover bands are ANSI concerns.
660
669
  */
661
670
  describeSurface(): NativeSurface {
662
671
  if (!this.#started || this.#stopped) return this.#nativeSurface;
@@ -673,7 +682,9 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
673
682
  } else {
674
683
  const transcript = roots[transcriptIndex] as TranscriptContainer;
675
684
  main.push(...roots.slice(0, transcriptIndex), ...transcript.nativeBlocks());
676
- dock.push(...(this.#nativeDock ?? roots.slice(transcriptIndex + 1)));
685
+ for (const root of this.#nativeTail) main.push(...root.children);
686
+ const tail: readonly Component[] = this.#nativeTail;
687
+ dock.push(...(this.#nativeDock ?? roots.slice(transcriptIndex + 1).filter(root => !tail.includes(root))));
677
688
  }
678
689
  }
679
690
  const previous = this.#nativeSurface;
@@ -1259,6 +1270,7 @@ export class Composer implements TerminalFrameProvider, NativeSurfaceProvider {
1259
1270
  this.#chromeFloorPromoted = false;
1260
1271
  this.#resetIdleChrome();
1261
1272
  this.#nativeDock = options.nativeDock;
1273
+ this.#nativeTail = options.nativeTail ?? [];
1262
1274
  this.#transientChrome = new Set(options.transientChrome);
1263
1275
  this.#selfClearingChrome = new Set(options.selfClearingChrome);
1264
1276
  this.#transientFloor = undefined;
@@ -103,6 +103,8 @@ export const PREVIEW_LIMITS = {
103
103
  EXPANDED_LINES: 12,
104
104
  /** Items (files, results) shown in collapsed view */
105
105
  COLLAPSED_ITEMS: 8,
106
+ /** Definition, type-definition and implementation locations shown in collapsed view */
107
+ LOCATIONS_COLLAPSED: 2,
106
108
  /** Output preview lines in collapsed view */
107
109
  OUTPUT_COLLAPSED: 3,
108
110
  /** Output preview lines in expanded view */
@@ -138,6 +138,15 @@ const LANG_BRAND_COLORS: Partial<Record<SymbolKey, string>> = {
138
138
  const BACKGROUND_RESET_PATTERN = /\x1b\[(?:0|49)m/g;
139
139
  const FOREGROUND_RESET_PATTERN = /\x1b\[(?:0|39)m/g;
140
140
 
141
+ /**
142
+ * Color `text` with the foreground `ansi`, restoring it after every nested reset so the rest of `text`
143
+ * keeps it (`fg("dim", `${fg("success", "+2")} more`)`); only the foreground is reset at the end.
144
+ */
145
+ function wrapForeground(ansi: string, text: string): string {
146
+ const body = text.includes("\x1b[") ? text.replace(FOREGROUND_RESET_PATTERN, `$&${ansi}`) : text;
147
+ return `${ansi}${body}\x1b[39m`;
148
+ }
149
+
141
150
  // Prebuilt stylers: each `chalk.<style>` access builds a fresh builder. Builders share the
142
151
  // root chalk context, so later `chalk.level` changes still apply.
143
152
  const boldStyler = chalk.bold;
@@ -350,7 +359,7 @@ export class Theme {
350
359
  fg(color: ThemeColor, text: string): string {
351
360
  const ansi = this.#fgColors[color];
352
361
  if (!ansi) throw new Error(`Unknown theme color: ${color}`);
353
- return `${ansi}${text}\x1b[39m`; // Reset only foreground color
362
+ return wrapForeground(ansi, text);
354
363
  }
355
364
 
356
365
  /** Apply a foreground, replacing terminal-default tokens with the theme's contrast-safe fallback. */
@@ -362,7 +371,7 @@ export class Theme {
362
371
  resolved = ansi === "\x1b[39m" ? colorToAnsi(this.getColorHex(color), this.mode) : ansi;
363
372
  this.#resolvedFgColors.set(color, resolved);
364
373
  }
365
- return `${resolved}${text.replace(FOREGROUND_RESET_PATTERN, `$&${resolved}`)}\x1b[39m`;
374
+ return wrapForeground(resolved, text);
366
375
  }
367
376
 
368
377
  bg(color: ThemeBg, text: string): string {
@@ -390,7 +399,7 @@ export class Theme {
390
399
  */
391
400
  fgOnBg(color: ThemeColor, background: ThemeBg, text: string): string {
392
401
  const ansi = this.getFgOnBgAnsi(color, background);
393
- return `${ansi}${text.replace(FOREGROUND_RESET_PATTERN, `$&${ansi}`)}\x1b[39m`;
402
+ return wrapForeground(ansi, text);
394
403
  }
395
404
 
396
405
  bold(text: string): string {
@@ -32,6 +32,7 @@ export {
32
32
  getSettingsListTheme,
33
33
  getSymbolTheme,
34
34
  highlightCode,
35
+ isHighlightLanguage,
35
36
  setMarkdownMermaidRendering,
36
37
  warmHighlighter,
37
38
  } from "./tui-adapters";
@@ -96,12 +96,17 @@ function highlightCached(code: string, validLang: string | undefined, highlightT
96
96
  return highlighted;
97
97
  }
98
98
 
99
+ /** Whether {@link highlightCode} colors `lang`; text in any other language comes back unstyled. */
100
+ export function isHighlightLanguage(lang: string | undefined): lang is string {
101
+ return lang !== undefined && nativeSupportsLanguage(lang);
102
+ }
103
+
99
104
  /**
100
105
  * Highlight code with syntax coloring based on file extension or language.
101
106
  * Returns array of highlighted lines.
102
107
  */
103
108
  export function highlightCode(code: string, lang?: string, highlightTheme: Theme = theme): string[] {
104
- const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
109
+ const validLang = isHighlightLanguage(lang) ? lang : undefined;
105
110
  const highlighted = highlightCached(code, validLang, highlightTheme);
106
111
  // Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
107
112
  // onto the result, which would corrupt the cached string otherwise.
@@ -110,7 +115,7 @@ export function highlightCode(code: string, lang?: string, highlightTheme: Theme
110
115
 
111
116
  /** Create a stateful highlighter for progressive terminal rendering. */
112
117
  export function createHighlightStream(lang?: string, highlightTheme: Theme = theme): NativeHighlightStream | null {
113
- const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
118
+ const validLang = isHighlightLanguage(lang) ? lang : undefined;
114
119
  if (!validLang) return null;
115
120
  // Workspace loads skip the natives version sentinel, so a stale local
116
121
  // `.node` can omit `HighlightStream` after a pull. Napi constructors can
@@ -305,7 +310,7 @@ export function getMarkdownTheme(): MarkdownTheme {
305
310
  })
306
311
  : undefined,
307
312
  highlightCode: (code: string, lang?: string): string[] => {
308
- const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
313
+ const validLang = isHighlightLanguage(lang) ? lang : undefined;
309
314
  const highlighted = highlightCached(code, validLang, theme);
310
315
  if (highlighted !== null) return highlighted.split("\n");
311
316
  return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
package/src/thinking.ts CHANGED
@@ -312,23 +312,21 @@ export function resolveTaskEffortLevel(
312
312
  break;
313
313
  }
314
314
  if (maxEffort === undefined) return resolved;
315
+ if (model) assertModelSupportsEffortCeiling(model, maxEffort);
315
316
  const maxIndex = THINKING_EFFORTS.indexOf(maxEffort);
316
- const ceiling = supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
317
- if (ceiling === undefined) {
318
- const modelName = model ? `${model.provider}/${model.id}` : "Selected model";
319
- throw new RangeError(`${modelName} has no supported thinking effort at or below task.maxEffort=${maxEffort}`);
320
- }
317
+ // Without a model the full canonical range always holds `maxEffort` itself.
318
+ const ceiling = supported.findLast(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex) ?? maxEffort;
321
319
  return THINKING_EFFORTS.indexOf(resolved) > THINKING_EFFORTS.indexOf(ceiling) ? ceiling : resolved;
322
320
  }
323
321
 
324
322
  /**
325
323
  * Clamps a concrete thinking selector to a per-session effort ceiling (e.g. a
326
- * task spawn's `task.maxEffort`-capped effort hint). `off`/`inherit`/
327
- * `undefined` pass through, as do levels already at or below the ceiling.
328
- * Levels above it snap to the highest model-supported effort at or below the
329
- * ceiling. A model whose floor exceeds the ceiling has nothing valid to snap
330
- * to; the requested level is returned unchanged and the caller is responsible
331
- * for rejecting or skipping such models (see {@link modelSupportsEffortCeiling}).
324
+ * task spawn's `task.maxEffort`). Unset/`inherit`/`off` retain their existing
325
+ * meaning, as do levels already at or below the ceiling. Levels above it snap
326
+ * to the highest model-supported effort at or below the ceiling. A model whose
327
+ * floor exceeds the ceiling has nothing valid to snap to; the requested level
328
+ * is returned unchanged and the caller must reject or skip such models (see
329
+ * {@link modelSupportsEffortCeiling}).
332
330
  */
333
331
  export function clampThinkingLevelToCeiling(
334
332
  model: Model | undefined,
@@ -367,6 +365,14 @@ export function modelSupportsEffortCeiling(model: Model, ceiling: Effort): boole
367
365
  return supported.some(candidate => THINKING_EFFORTS.indexOf(candidate) <= maxIndex);
368
366
  }
369
367
 
368
+ /** Throws when `model` has no supported effort at or below `task.maxEffort`, so a capped spawn fails instead of running at its floor. */
369
+ export function assertModelSupportsEffortCeiling(model: Model, maxEffort: Effort): void {
370
+ if (modelSupportsEffortCeiling(model, maxEffort)) return;
371
+ throw new RangeError(
372
+ `${model.provider}/${model.id} has no supported thinking effort at or below task.maxEffort=${maxEffort}`,
373
+ );
374
+ }
375
+
370
376
  /**
371
377
  * The provisional concrete level shown while `auto` is configured but before a
372
378
  * turn has been classified, and the fallback when classification fails. Prefers
@@ -14,7 +14,12 @@ import {
14
14
  PREVIEW_LIMITS,
15
15
  toPathList,
16
16
  } from "../render/render-utils";
17
- import { classifyGroupedLines, describeGroupedOutput, groupLineIndicesByBlank } from "./grouped-file-output";
17
+ import {
18
+ classifyGroupedLines,
19
+ createCodeFrameStyler,
20
+ describeGroupedOutput,
21
+ groupLineIndicesByBlank,
22
+ } from "./grouped-file-output";
18
23
  import { code, compact, node, span } from "../native/describe";
19
24
  import type { NativeChild } from "../native/node";
20
25
  import { errorText, noteText, resultText, toolHead } from "./native-view";
@@ -51,6 +56,8 @@ interface AstGrepRenderArgs {
51
56
  /** Legacy pre-`path` argument name; kept so historical transcripts still render a scope. */
52
57
  paths?: string[];
53
58
  skip?: number;
59
+ /** Language the search parsed the files as; highlighting follows it over the file extension. */
60
+ lang?: string;
54
61
  }
55
62
 
56
63
  const COLLAPSED_MATCH_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
@@ -124,6 +131,7 @@ export const astGrepToolRenderer = {
124
131
  // Resolve hyperlinks over the whole output so nested directory headers
125
132
  // reconstruct across the blank-line groups the tree list collapses by.
126
133
  const contexts = classifyGroupedLines(allLines, details?.cwd ?? details?.searchPath, details?.searchPath);
134
+ const styleCodeFrame = createCodeFrameStyler(allLines, contexts, "toolOutput", uiTheme, args?.lang);
127
135
  // The displayed lines are sanitized, but OSC 8 destinations must retain the
128
136
  // original path bytes. Only substitute targets for headers already linked
129
137
  // by the sanitized output; never let raw text create additional links.
@@ -159,7 +167,7 @@ export const astGrepToolRenderer = {
159
167
  const tinted = uiTheme.fg(ctx.depth === 1 ? "accent" : "dim", line);
160
168
  styled = target ? fileHyperlink(target, tinted) : tinted;
161
169
  } else {
162
- styled = uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
170
+ styled = styleCodeFrame(index) ?? uiTheme.fg(line.startsWith(" meta:") ? "dim" : "toolOutput", line);
163
171
  }
164
172
  styledLines[index] = styled;
165
173
  return styled;
package/src/tools/eval.ts CHANGED
@@ -120,6 +120,8 @@ export interface EvalToolDetails {
120
120
  statusEvents?: EvalStatusEvent[];
121
121
  /** Evictable events dropped from the front of `statusEvents` (see {@link recordStatusEvent}). */
122
122
  statusEventsElided?: number;
123
+ /** Text the cell's tool calls sent to `out://`, oldest first; transcripts draw each as a reply after the call. */
124
+ replies?: string[];
123
125
  isError?: boolean;
124
126
  meta?: OutputMeta;
125
127
  /** First backend that produced cells. Kept for transcript compatibility. */
package/src/tools/find.ts CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  PREVIEW_LIMITS,
18
18
  replaceTabs,
19
19
  } from "../render/render-utils";
20
- import type { Theme, ThemeColor } from "../theme/theme";
20
+ import { getLanguageFromPath } from "../lang-from-path";
21
+ import { highlightCode, isHighlightLanguage, type Theme, type ThemeColor } from "../theme/theme";
21
22
  import type { Component } from "../tui";
22
23
  import type { OutputMeta } from "./output-meta";
23
24
  import type {
@@ -171,10 +172,14 @@ function renderHit(hit: FindHit, rangeLimit: number, cwd: string | undefined, ra
171
172
  `${gauge(hit.contentScore, theme)} ${theme.fg(scoreColor(hit.contentScore), hit.contentScore.toFixed(2))} ${link(theme.fg("accent", hit.rel))} ${theme.fg("dim", coverage)}`,
172
173
  ];
173
174
  const ranges = [...hit.ranges].sort((a, b) => b.p - a.p || a.start - b.start).slice(0, rangeLimit);
175
+ const lang = getLanguageFromPath(hit.rel);
174
176
  ranges.forEach((range, index) => {
175
177
  const span = range.start === range.end ? String(range.start) : `${range.start}-${range.end}`;
176
178
  const ref = link(theme.fg("muted", `:${span}`), range.start);
177
- const snippet = theme.fg("toolOutput", replaceTabs(range.snippet.trim()));
179
+ const source = replaceTabs(range.snippet.trim());
180
+ const snippet = isHighlightLanguage(lang)
181
+ ? highlightCode(source, lang, theme).join("\n")
182
+ : theme.fg("toolOutput", source);
178
183
  const branch = theme.fg("dim", getTreeBranch(index === ranges.length - 1, theme));
179
184
  lines.push(` ${branch} ${ref} ${theme.fg("dim", range.p.toFixed(2))} ${snippet}`);
180
185
  });
package/src/tools/grep.ts CHANGED
@@ -34,7 +34,12 @@ import {
34
34
  PREVIEW_LIMITS,
35
35
  replaceTabs,
36
36
  } from "../render/render-utils";
37
- import { classifyGroupedLines, describeGroupedOutput, groupLineIndicesByBlank } from "./grouped-file-output";
37
+ import {
38
+ classifyGroupedLines,
39
+ createCodeFrameStyler,
40
+ describeGroupedOutput,
41
+ groupLineIndicesByBlank,
42
+ } from "./grouped-file-output";
38
43
 
39
44
  /** Display metadata for grep tool results. */
40
45
  export interface GrepToolDetails {
@@ -214,6 +219,7 @@ function createSearchLineStyler(
214
219
  target: (displayPath: string) => string,
215
220
  ): (index: number) => string {
216
221
  const contexts = classifyGroupedLines(lines, headerBase, fileScope);
222
+ const styleCodeFrame = createCodeFrameStyler(lines, contexts, "toolOutput", uiTheme);
217
223
  // `classifyGroupedLines` can't resolve internal URLs (TUI-only), so track the
218
224
  // resolved URL target here and use it for the body lines that follow.
219
225
  const urlRaw: (string | undefined)[] = Array.from({ length: lines.length }, () => undefined);
@@ -257,7 +263,7 @@ function createSearchLineStyler(
257
263
  styled = ctx.headerPath ? fileHyperlink(target(ctx.headerPath), tinted) : tinted;
258
264
  }
259
265
  } else {
260
- const tinted = uiTheme.fg("toolOutput", line);
266
+ const tinted = styleCodeFrame(index) ?? uiTheme.fg("toolOutput", line);
261
267
  const lineNumber = parseSearchDisplayLineNumber(line);
262
268
  const filePath = ctx.filePath ? target(ctx.filePath) : urlFiles[index];
263
269
  styled = filePath && lineNumber !== undefined ? fileHyperlink(filePath, tinted, { line: lineNumber }) : tinted;
@@ -2,8 +2,11 @@ import * as path from "node:path";
2
2
 
3
3
  import { buildPathTree, isUrlLikePath, type PathTreeInput, walkPathTree } from "@oh-my-pi/pi-utils";
4
4
  import type { TspTone } from "@oh-my-pi/pi-wire";
5
- import { code, col, keyed, span, text } from "../native/describe";
5
+ import { code, col, keyed } from "../native/describe";
6
6
  import type { NativeNode } from "../native/node";
7
+ import { getLanguageFromPath } from "../lang-from-path";
8
+ import { replaceTabs } from "../render/render-utils";
9
+ import { highlightCode, isHighlightLanguage, type Theme, type ThemeColor } from "../theme/theme";
7
10
  import { formatHashlineHeader } from "./hashline-format";
8
11
  import { fileRow } from "./native-view";
9
12
 
@@ -266,6 +269,135 @@ export function groupLineIndicesByBlank(rawLines: readonly string[]): number[][]
266
269
  return groups;
267
270
  }
268
271
 
272
+ // =============================================================================
273
+ // Code frames highlighted in their file's language (TUI renderers)
274
+ // =============================================================================
275
+
276
+ /** A code-frame row split after its rule: the gutter (match marker, line number, rule) and the source line. */
277
+ interface CodeFrameRow {
278
+ gutter: string;
279
+ source: string;
280
+ }
281
+
282
+ /**
283
+ * Draw the code-frame rows of consecutive source lines: each gutter in
284
+ * `color`, the sources highlighted in `lang` as one run, so a multi-line
285
+ * string or comment keeps its colors. Without a language the sources take
286
+ * `color` too.
287
+ */
288
+ function renderCodeFrameRun(
289
+ rows: readonly CodeFrameRow[],
290
+ lang: string | undefined,
291
+ color: ThemeColor,
292
+ theme: Theme,
293
+ ): string[] {
294
+ const sources = rows.map(row => replaceTabs(row.source));
295
+ const highlighted = isHighlightLanguage(lang) ? highlightCode(sources.join("\n"), lang, theme) : undefined;
296
+ return rows.map((row, index) => {
297
+ const source = highlighted?.[index] ?? theme.fg(color, sources[index]!);
298
+ return `${theme.fg(color, row.gutter)}${source}`;
299
+ });
300
+ }
301
+
302
+ /** A numbered code-frame row of search output, with its line number. */
303
+ interface NumberedCodeFrame {
304
+ row: CodeFrameRow;
305
+ n: number;
306
+ }
307
+
308
+ function parseNumberedCodeFrame(line: string): NumberedCodeFrame | undefined {
309
+ const match = GROUPED_NUMBERED_LINE_RE.exec(line);
310
+ if (!match) return undefined;
311
+ const source = match[3]!;
312
+ return { row: { gutter: line.slice(0, line.length - source.length), source }, n: Number(match[2]) };
313
+ }
314
+
315
+ /** The file a grouped file header names, without its suffix and hashline tag: `## a.ts#3f2a (2)` → `a.ts`. */
316
+ function groupedHeaderName(line: string): string {
317
+ const rest = GROUPED_HEADER_RE.exec(line)?.[2]?.trimEnd() ?? "";
318
+ return rest.replace(HEADER_SUFFIX_RE, "").replace(HEADER_HASH_TAG_RE, "");
319
+ }
320
+
321
+ /**
322
+ * Source rows highlighted together. A run longer than this (a huge AST match) highlights chunk by chunk as rows
323
+ * are requested, so a collapsed view never tokenizes source it hides; a string or comment spanning a chunk
324
+ * boundary loses its continuation there.
325
+ */
326
+ const CODE_FRAME_CHUNK_ROWS = 64;
327
+
328
+ /**
329
+ * Styled code-frame rows of grouped search output, highlighted in each owning
330
+ * file's language (`lang` overrides it when the highlighter knows it). A run of
331
+ * consecutive line numbers in one file highlights together on its first
332
+ * request; a gap row, a jump in line numbers or another file starts a new run.
333
+ * Rows without a numbered code frame (headers, gap rows, notes) return
334
+ * undefined, for the caller to style.
335
+ */
336
+ export function createCodeFrameStyler(
337
+ lines: readonly string[],
338
+ contexts: readonly GroupedLineContext[],
339
+ color: ThemeColor,
340
+ theme: Theme,
341
+ lang?: string,
342
+ ): (index: number) => string | undefined {
343
+ // Each body row's language: the override, else its owning file's, from the resolved path or its header's name.
344
+ const langOverride = isHighlightLanguage(lang) ? lang : undefined;
345
+ const langs: (string | undefined)[] = [];
346
+ let headerLang: string | undefined;
347
+ for (let index = 0; index < lines.length; index++) {
348
+ const ctx = contexts[index]!;
349
+ if (ctx.kind === "content") {
350
+ langs.push(langOverride ?? (ctx.filePath ? getLanguageFromPath(ctx.filePath) : headerLang));
351
+ continue;
352
+ }
353
+ headerLang = ctx.kind === "file" ? getLanguageFromPath(groupedHeaderName(lines[index]!)) : undefined;
354
+ langs.push(undefined);
355
+ }
356
+
357
+ const frames: (NumberedCodeFrame | null)[] = [];
358
+ const frameAt = (index: number): NumberedCodeFrame | null => {
359
+ let frame = frames[index];
360
+ if (frame === undefined) {
361
+ frame = contexts[index]!.kind === "content" ? (parseNumberedCodeFrame(lines[index]!) ?? null) : null;
362
+ frames[index] = frame;
363
+ }
364
+ return frame;
365
+ };
366
+ // Whether the row at `index` continues the run of the row above it.
367
+ const continuesRun = (index: number): boolean => {
368
+ if (index <= 0 || langs[index] !== langs[index - 1]) return false;
369
+ const frame = frameAt(index);
370
+ const above = frameAt(index - 1);
371
+ return frame !== null && above !== null && frame.n === above.n + 1;
372
+ };
373
+ // First row of each row's run, memoized so walking back through a long run happens once.
374
+ const runStarts: number[] = [];
375
+ const runStartOf = (index: number): number => {
376
+ let row = index;
377
+ while (runStarts[row] === undefined && continuesRun(row)) row--;
378
+ const start = runStarts[row] ?? row;
379
+ for (let filled = row; filled <= index; filled++) runStarts[filled] = start;
380
+ return start;
381
+ };
382
+
383
+ const styled: string[] = [];
384
+ return index => {
385
+ if (frameAt(index) === null) return undefined;
386
+ const cached = styled[index];
387
+ if (cached !== undefined) return cached;
388
+ const runStart = runStartOf(index);
389
+ const start = runStart + Math.floor((index - runStart) / CODE_FRAME_CHUNK_ROWS) * CODE_FRAME_CHUNK_ROWS;
390
+ let end = index + 1;
391
+ while (end < start + CODE_FRAME_CHUNK_ROWS && end < lines.length && continuesRun(end)) end++;
392
+ const chunkRows: CodeFrameRow[] = [];
393
+ for (let row = start; row < end; row++) chunkRows.push(frameAt(row)!.row);
394
+ renderCodeFrameRun(chunkRows, langs[index], color, theme).forEach((row, offset) => {
395
+ styled[start + offset] = row;
396
+ });
397
+ return styled[index];
398
+ };
399
+ }
400
+
269
401
  // =============================================================================
270
402
  // Native (TSP) description of grouped output
271
403
  // =============================================================================
@@ -306,10 +438,10 @@ export function* walkGroupedOutput(lines: readonly string[]): Generator<GroupedO
306
438
 
307
439
  /** `*12│text`, ` 12|text`, `*12:text`: optional match marker, line number, gutter, content. */
308
440
  const GROUPED_NUMBERED_LINE_RE = /^\s*(\*?)(\d+)(?:│|[:|])(.*)$/;
309
- /** ` │...`: grep's gap between non-adjacent context runs (drawn as {@link numberedCode}'s `…` row). */
441
+ /** ` │...`: grep's gap between non-adjacent context runs (Tern draws it from the jump in {@link numberedCode}'s numbers). */
310
442
  const GROUPED_GAP_LINE_RE = /^\s*│\.\.\.$/;
311
443
 
312
- /** One source line for {@link numberedCode}: its number (`null` for an elision marker), text and match mark. */
444
+ /** One source line for {@link numberedCode}: its number (`null` for an elision marker, which adds no line), text and match mark. */
313
445
  export interface NumberedLine {
314
446
  n: number | null;
315
447
  text: string;
@@ -317,58 +449,52 @@ export interface NumberedLine {
317
449
  }
318
450
 
319
451
  /**
320
- * Numbered `code` for possibly non-contiguous source lines: one block per run
321
- * of consecutive line numbers (`start` = the run's first line, match lines
322
- * `mark`ed), with a `…` row wherever the numbers jump or an elision marker
323
- * (`n: null`) sits. `path` is the file the lines come from: Tern picks their
324
- * grammar from it and draws no header (the row or head above names the file).
325
- * `wrap` soft-wraps long lines instead of scrolling the block sideways.
452
+ * One numbered `code` block for possibly non-contiguous source lines (match
453
+ * lines `mark`ed by their line number): `start` when the numbers run on
454
+ * consecutively, else `nums` (each line's number), from whose jumps Tern draws
455
+ * the `…` gap rows in the gutter. Elision markers (`n: null`) add no line; the
456
+ * numbers jump around them. `path` is the file the lines come from: Tern picks
457
+ * their grammar from it and draws no header (the row or head above names the
458
+ * file). `wrap` soft-wraps long lines instead of scrolling the block sideways.
459
+ * `undefined` when no line is numbered.
326
460
  */
327
461
  export function numberedCode(
328
462
  lines: readonly NumberedLine[],
329
463
  opts: { path?: string; wrap?: boolean } = {},
330
- ): NativeNode[] {
331
- const out: NativeNode[] = [];
332
- let run: { start: number; text: string[]; marks: { line: number; tone: TspTone }[] } | undefined;
333
- const flush = () => {
334
- if (!run) return;
335
- // The quiet `…` row between two non-adjacent runs.
336
- if (out.length > 0) out.push(keyed(text([span("…", "dim")], { wrap: "none" }), `g${run.start}`));
337
- out.push(
338
- keyed(
339
- code(run.text.join("\n"), {
340
- start: run.start,
341
- numbers: true,
342
- path: opts.path,
343
- wrap: opts.wrap,
344
- marks: run.marks.length > 0 ? run.marks : undefined,
345
- }),
346
- `c${run.start}`,
347
- ),
348
- );
349
- run = undefined;
350
- };
464
+ ): NativeNode | undefined {
465
+ const nums: number[] = [];
466
+ const body: string[] = [];
467
+ const marks: { line: number; tone: TspTone }[] = [];
468
+ let contiguous = true;
351
469
  for (const line of lines) {
352
- if (line.n === null) {
353
- flush();
354
- continue;
355
- }
356
- if (run && run.start + run.text.length !== line.n) flush();
357
- run ??= { start: line.n, text: [], marks: [] };
358
- run.text.push(line.text);
359
- if (line.mark) run.marks.push({ line: line.n, tone: "accent" });
470
+ if (line.n === null) continue;
471
+ if (nums.length > 0 && line.n !== nums[nums.length - 1]! + 1) contiguous = false;
472
+ nums.push(line.n);
473
+ body.push(line.text);
474
+ if (line.mark) marks.push({ line: line.n, tone: "accent" });
360
475
  }
361
- flush();
362
- return out;
476
+ if (nums.length === 0) return undefined;
477
+ return keyed(
478
+ code(body.join("\n"), {
479
+ start: contiguous ? nums[0] : undefined,
480
+ nums: contiguous ? undefined : nums,
481
+ numbers: true,
482
+ path: opts.path,
483
+ wrap: opts.wrap,
484
+ marks: marks.length > 0 ? marks : undefined,
485
+ }),
486
+ `c${nums[0]}`,
487
+ );
363
488
  }
364
489
 
365
490
  /**
366
491
  * Describe grouped search output (grep / ast-grep / ast-edit display content)
367
492
  * by file (§7.3 grep): per file a 22px path row (dim dir, strong name, the
368
493
  * header's suffix or the match count as a chip), then its lines as
369
- * {@link numberedCode} with match lines marked and `…` between non-adjacent
370
- * runs. Unnumbered body lines stay plain `code`. `maxFiles` keeps only the
371
- * first files (the caller says how many were left out). `file` owns the lines
494
+ * one {@link numberedCode} block with match lines marked (gaps between
495
+ * non-adjacent runs drawn in its gutter). Unnumbered body lines stay plain
496
+ * `code`. `maxFiles` keeps only the first files (the caller says how many
497
+ * were left out). `file` owns the lines
372
498
  * before any header: a single-file search prints none, yet its lines get the
373
499
  * same file row as a directory search's.
374
500
  */
@@ -386,10 +512,9 @@ export function describeGroupedOutput(
386
512
 
387
513
  const flushLines = () => {
388
514
  const target = current?.children ?? loose;
389
- if (numbered.length > 0) {
390
- target.push(...numberedCode(numbered, { path: current?.path }));
391
- numbered = [];
392
- }
515
+ const block = numberedCode(numbered, { path: current?.path });
516
+ if (block) target.push(block);
517
+ numbered = [];
393
518
  if (plain.length > 0) {
394
519
  target.push(keyed(code(plain.join("\n")), `p${target.length}`));
395
520
  plain = [];