@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.
- package/CHANGELOG.md +36 -0
- package/README.md +6 -0
- package/dist/types/charts/table-data.d.ts +4 -2
- package/dist/types/chat/figure-markdown.d.ts +2 -0
- package/dist/types/chat/svg-figure.d.ts +5 -0
- package/dist/types/chat/table-chart.d.ts +2 -0
- package/dist/types/chat/transcript-render-helpers.d.ts +3 -3
- package/dist/types/components/image.d.ts +6 -0
- package/dist/types/native/describe.d.ts +4 -2
- package/dist/types/native/overlay.d.ts +42 -2
- package/dist/types/native/state.d.ts +4 -0
- package/dist/types/overlays/btw-history-panel.d.ts +2 -0
- package/dist/types/prompt/composer.d.ts +13 -5
- package/dist/types/render/render-utils.d.ts +2 -0
- package/dist/types/theme/theme.d.ts +1 -1
- package/dist/types/theme/tui-adapters.d.ts +2 -0
- package/dist/types/thinking.d.ts +8 -6
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +2 -0
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/grouped-file-output.d.ts +24 -11
- package/dist/types/tools/read.d.ts +4 -4
- package/dist/types/tools/task.d.ts +2 -2
- package/package.json +9 -9
- package/src/apps/git/git-tui.ts +10 -17
- package/src/apps/git/sidebar.ts +21 -35
- package/src/apps/ps-top.ts +4 -12
- package/src/charts/chart-plan.ts +0 -29
- package/src/charts/table-data.ts +55 -6
- package/src/chat/figure-markdown.ts +6 -4
- package/src/chat/svg-figure.ts +55 -16
- package/src/chat/table-chart.ts +19 -2
- package/src/chat/transcript-render-helpers.ts +18 -16
- package/src/components/image.ts +13 -0
- package/src/native/backend.ts +7 -3
- package/src/native/describe.ts +9 -4
- package/src/native/overlay.ts +124 -16
- package/src/native/state.ts +25 -5
- package/src/overlays/btw-history-panel.ts +8 -0
- package/src/overlays/extensions/extension-dashboard.ts +7 -8
- package/src/overlays/jobs-panel.ts +38 -4
- package/src/overlays/login-dialog.ts +8 -26
- package/src/overlays/tree-selector.ts +81 -27
- package/src/overlays/usage-dashboard.ts +2 -4
- package/src/prompt/composer.ts +17 -5
- package/src/render/render-utils.ts +2 -0
- package/src/theme/theme-class.ts +12 -3
- package/src/theme/theme.ts +1 -0
- package/src/theme/tui-adapters.ts +8 -3
- package/src/thinking.ts +17 -11
- package/src/tools/ast-grep.ts +10 -2
- package/src/tools/eval.ts +2 -0
- package/src/tools/find.ts +7 -2
- package/src/tools/grep.ts +8 -2
- package/src/tools/grouped-file-output.ts +172 -47
- package/src/tools/lsp.ts +158 -8
- package/src/tools/read.ts +6 -5
- package/src/tools/task.ts +2 -2
package/src/prompt/composer.ts
CHANGED
|
@@ -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
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
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
|
-
|
|
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 */
|
package/src/theme/theme-class.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
402
|
+
return wrapForeground(ansi, text);
|
|
394
403
|
}
|
|
395
404
|
|
|
396
405
|
bold(text: string): string {
|
package/src/theme/theme.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
317
|
-
|
|
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
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
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
|
package/src/tools/ast-grep.ts
CHANGED
|
@@ -14,7 +14,12 @@ import {
|
|
|
14
14
|
PREVIEW_LIMITS,
|
|
15
15
|
toPathList,
|
|
16
16
|
} from "../render/render-utils";
|
|
17
|
-
import {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
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
|
-
|
|
362
|
-
return
|
|
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
|
|
370
|
-
* runs. Unnumbered body lines stay plain
|
|
371
|
-
* first files (the caller says how many
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 = [];
|