pi-one-ui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -0
  3. package/extensions/app/config/renderer.ts +452 -0
  4. package/extensions/app/config/shell.ts +2484 -0
  5. package/extensions/app/ownership.ts +18 -0
  6. package/extensions/app/panel.ts +533 -0
  7. package/extensions/app/presets.ts +36 -0
  8. package/extensions/features/agent-summary/core.ts +194 -0
  9. package/extensions/features/agent-summary/index.ts +37 -0
  10. package/extensions/features/compact-thinking.ts +1262 -0
  11. package/extensions/features/context.ts +857 -0
  12. package/extensions/features/reference/index.ts +643 -0
  13. package/extensions/features/reference/session.ts +278 -0
  14. package/extensions/features/reference/subagent.ts +218 -0
  15. package/extensions/features/shell/aliases.ts +28 -0
  16. package/extensions/features/shell/flush-docked-bash.ts +39 -0
  17. package/extensions/features/shell/startup-header.ts +357 -0
  18. package/extensions/features/shell/working-message.ts +226 -0
  19. package/extensions/index.ts +52 -0
  20. package/extensions/shell/accent-rail-editor.ts +112 -0
  21. package/extensions/shell/accent-rail-layout-patch.ts +530 -0
  22. package/extensions/shell/completion-menu.ts +186 -0
  23. package/extensions/shell/editor-metadata-format.ts +287 -0
  24. package/extensions/shell/editor-transfer.ts +73 -0
  25. package/extensions/shell/extension-status.ts +102 -0
  26. package/extensions/shell/footer-format.ts +388 -0
  27. package/extensions/shell/footer-layout.ts +144 -0
  28. package/extensions/shell/footer.ts +1035 -0
  29. package/extensions/shell/format.ts +577 -0
  30. package/extensions/shell/git.ts +434 -0
  31. package/extensions/shell/icons.ts +261 -0
  32. package/extensions/shell/index.ts +1659 -0
  33. package/extensions/shell/interaction-summary.ts +1194 -0
  34. package/extensions/shell/live-context.ts +82 -0
  35. package/extensions/shell/minimalist-editor.ts +548 -0
  36. package/extensions/shell/package-version.ts +675 -0
  37. package/extensions/shell/project-refresh.ts +130 -0
  38. package/extensions/shell/project-state.ts +60 -0
  39. package/extensions/shell/prototype-patch-registry.ts +183 -0
  40. package/extensions/shell/runtime.ts +915 -0
  41. package/extensions/shell/selector-border.ts +109 -0
  42. package/extensions/shell/session-lifecycle.ts +60 -0
  43. package/extensions/shell/settings-command.ts +2144 -0
  44. package/extensions/shell/settings-previews.ts +137 -0
  45. package/extensions/shell/state.ts +83 -0
  46. package/extensions/shell/style.ts +480 -0
  47. package/extensions/shell/telemetry.ts +90 -0
  48. package/extensions/shell/ui.ts +1326 -0
  49. package/extensions/shell/user-message-osc.ts +279 -0
  50. package/extensions/shell/user-message-styles.ts +266 -0
  51. package/extensions/shell/user-message.ts +226 -0
  52. package/extensions/shell/working-line-messages.ts +24 -0
  53. package/extensions/shell/working-line-spinners.ts +54 -0
  54. package/extensions/shell/working-line.ts +1603 -0
  55. package/extensions/tools/ansi-color.ts +44 -0
  56. package/extensions/tools/ansi-text.ts +52 -0
  57. package/extensions/tools/component-tree.ts +37 -0
  58. package/extensions/tools/format.ts +28 -0
  59. package/extensions/tools/fullscreen-detect.ts +11 -0
  60. package/extensions/tools/patch-keys.ts +158 -0
  61. package/extensions/tools/sgr-mouse.ts +59 -0
  62. package/extensions/tools/tool-loading-icon.ts +20 -0
  63. package/extensions/tools/tool-result-sanitize.ts +27 -0
  64. package/extensions/transcript/index.ts +65 -0
  65. package/extensions/transcript/renderer/compact-mode.ts +1443 -0
  66. package/extensions/transcript/renderer/default-mode.ts +735 -0
  67. package/extensions/transcript/renderer/index.ts +286 -0
  68. package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
  69. package/extensions/transcript/renderer/mouse/hover.ts +175 -0
  70. package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
  71. package/extensions/transcript/renderer/mouse/layout.ts +207 -0
  72. package/extensions/transcript/renderer/mouse/packets.ts +96 -0
  73. package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
  74. package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
  75. package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
  76. package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
  77. package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
  78. package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
  79. package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
  80. package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
  81. package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
  82. package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
  83. package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
  84. package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
  85. package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
  86. package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
  87. package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
  88. package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
  89. package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
  90. package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
  91. package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
  92. package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
  93. package/extensions/transcript/renderer/tool/grouping.ts +619 -0
  94. package/extensions/transcript/renderer/tool/message-display.ts +183 -0
  95. package/extensions/transcript/renderer/tool/names.ts +195 -0
  96. package/extensions/transcript/renderer/tool/result.ts +842 -0
  97. package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
  98. package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
  99. package/package.json +58 -0
  100. package/themes/cc-dark.json +91 -0
  101. package/themes/cc-light.json +91 -0
@@ -0,0 +1,112 @@
1
+ import {
2
+ DEFAULT_TOOL_DISPLAY_CONFIG,
3
+ type DiffIndicatorMode,
4
+ type ToolDisplayConfig,
5
+ } from "../../../../app/config/renderer.ts";
6
+ import type { DiffPresentationMode } from "./diff-presentation.ts";
7
+ import { RICH_DIFF_COMPONENT } from "../../../../tools/patch-keys.ts";
8
+ export { RICH_DIFF_COMPONENT };
9
+
10
+ /** Snapshot or live getter — panel changes must apply on the next paint. */
11
+ export type DisplayConfigInput = ToolDisplayConfig | (() => ToolDisplayConfig);
12
+
13
+ export interface DiffRenderOptions {
14
+ expanded: boolean;
15
+ filePath?: string;
16
+ previousContent?: string;
17
+ fileExistedBeforeWrite?: boolean;
18
+ headerLabel?: string;
19
+ /** Live hover state for the collapsed hint row (muted → text on hover). */
20
+ isHovered?: () => boolean;
21
+ invalidate?: () => void;
22
+ }
23
+
24
+ export function isRichDiffComponent(value: unknown): boolean {
25
+ return Boolean(
26
+ value &&
27
+ typeof value === "object" &&
28
+ (value as Record<symbol, unknown>)[RICH_DIFF_COMPONENT] === true,
29
+ );
30
+ }
31
+
32
+ export function resolveLiveDisplayConfig(
33
+ input: DisplayConfigInput,
34
+ ): ToolDisplayConfig {
35
+ return typeof input === "function" ? input() : input;
36
+ }
37
+
38
+ /** Cache key fragment so indicator/wrap/limits invalidate without host recreate. */
39
+ export function displayConfigCacheKey(config: ToolDisplayConfig): string {
40
+ return [
41
+ config.diffViewMode,
42
+ config.diffIndicatorMode,
43
+ String(config.diffSplitMinWidth),
44
+ String(config.editDiffCollapsedLines),
45
+ String(config.writeDiffCollapsedLines),
46
+ config.diffWordWrap ? "1" : "0",
47
+ String(config.expandedPreviewMaxLines),
48
+ ].join(":");
49
+ }
50
+
51
+ export function resolveDiffIndicatorMode(
52
+ config: Partial<Pick<ToolDisplayConfig, "diffIndicatorMode">>,
53
+ ): DiffIndicatorMode {
54
+ return (
55
+ config.diffIndicatorMode ?? DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode
56
+ );
57
+ }
58
+
59
+ export function createDiffRenderCache() {
60
+ let cachedWidth: number | undefined;
61
+ let cachedExpanded: boolean | undefined;
62
+ let cachedMode: DiffPresentationMode | undefined;
63
+ let cachedConfigKey: string | undefined;
64
+ let cachedHovered: boolean | undefined;
65
+ let cachedLines: string[] | undefined;
66
+
67
+ return {
68
+ get(
69
+ width: number,
70
+ expanded: boolean,
71
+ mode: DiffPresentationMode,
72
+ configKey: string,
73
+ hovered: boolean,
74
+ ): string[] | undefined {
75
+ if (
76
+ cachedLines &&
77
+ cachedWidth === width &&
78
+ cachedExpanded === expanded &&
79
+ cachedMode === mode &&
80
+ cachedConfigKey === configKey &&
81
+ cachedHovered === hovered
82
+ ) {
83
+ return cachedLines;
84
+ }
85
+ return undefined;
86
+ },
87
+ set(
88
+ width: number,
89
+ expanded: boolean,
90
+ mode: DiffPresentationMode,
91
+ configKey: string,
92
+ hovered: boolean,
93
+ lines: string[],
94
+ ): string[] {
95
+ cachedWidth = width;
96
+ cachedExpanded = expanded;
97
+ cachedMode = mode;
98
+ cachedConfigKey = configKey;
99
+ cachedHovered = hovered;
100
+ cachedLines = lines;
101
+ return lines;
102
+ },
103
+ invalidate(): void {
104
+ cachedWidth = undefined;
105
+ cachedExpanded = undefined;
106
+ cachedMode = undefined;
107
+ cachedConfigKey = undefined;
108
+ cachedHovered = undefined;
109
+ cachedLines = undefined;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,255 @@
1
+ import { Text, type Component } from "@earendil-works/pi-tui";
2
+ import type { EditToolDetails } from "@earendil-works/pi-coding-agent";
3
+ import { sanitizeToolResultText } from "../../../../tools/tool-result-sanitize.ts";
4
+ import {
5
+ getLineNumberWidth,
6
+ parseDiff,
7
+ type ParsedDiff,
8
+ } from "./diff-parse.ts";
9
+ import { buildInlineHighlightMap, buildSplitRows } from "./diff-inline.ts";
10
+ import { resolveDiffPalette, type DiffTheme } from "./diff-palette.ts";
11
+ import {
12
+ createCodeLineHighlighter,
13
+ resolveLanguageFromPath,
14
+ } from "./diff-highlight.ts";
15
+ import {
16
+ canRenderSplitLayout,
17
+ renderCompact,
18
+ renderSplit,
19
+ renderUnified,
20
+ type DiffRenderContext,
21
+ } from "./diff-layout.ts";
22
+ import {
23
+ renderDiffFrameLine,
24
+ renderHeaderRows,
25
+ renderSingleDiffRow,
26
+ } from "./diff-header.ts";
27
+ import {
28
+ applyLineLimit,
29
+ clampDiffLinesToWidth,
30
+ resolveDiffDisplayLimit,
31
+ resolveDiffProcessBudget,
32
+ takeEntriesForLineBudget,
33
+ takeSplitRowsForBudget,
34
+ } from "./diff-limits.ts";
35
+ import {
36
+ RICH_DIFF_COMPONENT,
37
+ createDiffRenderCache,
38
+ displayConfigCacheKey,
39
+ resolveDiffIndicatorMode,
40
+ resolveLiveDisplayConfig,
41
+ type DiffRenderOptions,
42
+ type DisplayConfigInput,
43
+ } from "./diff-component.ts";
44
+ import {
45
+ buildDiffSummaryText,
46
+ normalizeDiffRenderWidth,
47
+ resolveDiffPresentationMode,
48
+ } from "./diff-presentation.ts";
49
+
50
+ function safeGetDiff(details: unknown): string {
51
+ if (!details || typeof details !== "object") {
52
+ return "";
53
+ }
54
+ const typed = details as Partial<EditToolDetails>;
55
+ return typeof typed.diff === "string" ? typed.diff : "";
56
+ }
57
+
58
+ /**
59
+ * edit 变更行统计(compact 单行 `(+A -D)` 用):复用现有 parser,diff 文件头不计入。
60
+ * 无 diff 或解析失败时返回 undefined,不把未知状态误报为零变更。
61
+ */
62
+ export function countEditDiffStats(
63
+ details: unknown,
64
+ ): { added: number; removed: number } | undefined {
65
+ const diffText = sanitizeToolResultText(safeGetDiff(details));
66
+ if (!diffText.trim()) return undefined;
67
+ try {
68
+ const parsed = parseDiff(diffText);
69
+ return { added: parsed.stats.added, removed: parsed.stats.removed };
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ export function renderEditDiffResult(
76
+ details: unknown,
77
+ options: DiffRenderOptions,
78
+ config: DisplayConfigInput,
79
+ theme: DiffTheme,
80
+ fallbackText: string,
81
+ ): Component {
82
+ const diffText = sanitizeToolResultText(safeGetDiff(details));
83
+ const safeFallbackText = sanitizeToolResultText(fallbackText);
84
+ if (!diffText.trim()) {
85
+ if (!safeFallbackText.trim()) {
86
+ return new Text(
87
+ theme.fg("muted", "↳ edit completed (no diff payload)"),
88
+ 0,
89
+ 0,
90
+ );
91
+ }
92
+ return new Text(theme.fg("toolOutput", safeFallbackText), 0, 0);
93
+ }
94
+
95
+ let parsed: ParsedDiff;
96
+ try {
97
+ parsed = parseDiff(diffText);
98
+ } catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ return new Text(
101
+ theme.fg("warning", `↳ unable to render diff: ${message}`),
102
+ 0,
103
+ 0,
104
+ );
105
+ }
106
+
107
+ if (parsed.entries.length === 0) {
108
+ return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
109
+ }
110
+
111
+ const splitRows = buildSplitRows(parsed.entries);
112
+ const showHashlineAnchors =
113
+ options.expanded === true &&
114
+ parsed.entries.some(
115
+ (entry) => entry.kind === "line" && !!entry.hashlineAnchorContent,
116
+ );
117
+ const lineNumberWidth = getLineNumberWidth(
118
+ parsed.entries,
119
+ showHashlineAnchors,
120
+ );
121
+ const palette = resolveDiffPalette(theme);
122
+ // Rich diffs use ccstyle's self shell. Keep the panel transparent so the
123
+ // separator cannot leak toolSuccessBg across the entire new column.
124
+ const containerBgAnsi = undefined;
125
+ const language = resolveLanguageFromPath(options.filePath);
126
+ const cache = createDiffRenderCache();
127
+ const highlightLine = createCodeLineHighlighter(
128
+ language,
129
+ theme,
130
+ parsed.entries,
131
+ () => {
132
+ cache.invalidate();
133
+ options.invalidate?.();
134
+ },
135
+ );
136
+
137
+ return {
138
+ [RICH_DIFF_COMPONENT]: true,
139
+ render(width: number): string[] {
140
+ // Live config: panel can change indicator/wrap/limits after this component is created.
141
+ const live = resolveLiveDisplayConfig(config);
142
+ const wordWrap = live.diffWordWrap;
143
+ const indicatorMode = resolveDiffIndicatorMode(live);
144
+ const configKey = displayConfigCacheKey(live);
145
+ const safeWidth = normalizeDiffRenderWidth(width);
146
+ const mode = resolveDiffPresentationMode(
147
+ live,
148
+ safeWidth,
149
+ canRenderSplitLayout(safeWidth),
150
+ );
151
+ const hovered = options.isHovered?.() ?? false;
152
+ const cached = cache.get(
153
+ safeWidth,
154
+ options.expanded,
155
+ mode,
156
+ configKey,
157
+ hovered,
158
+ );
159
+ if (cached) {
160
+ return cached;
161
+ }
162
+
163
+ if (mode === "summary") {
164
+ return cache.set(
165
+ safeWidth,
166
+ options.expanded,
167
+ mode,
168
+ configKey,
169
+ hovered,
170
+ clampDiffLinesToWidth(
171
+ renderSingleDiffRow(
172
+ buildDiffSummaryText(parsed.stats, safeWidth),
173
+ "toolOutput",
174
+ safeWidth,
175
+ theme,
176
+ ),
177
+ safeWidth,
178
+ ),
179
+ );
180
+ }
181
+
182
+ const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
183
+ const displayLimit = resolveDiffDisplayLimit(
184
+ options.expanded,
185
+ live.editDiffCollapsedLines,
186
+ live.expandedPreviewMaxLines,
187
+ );
188
+ const processBudget = resolveDiffProcessBudget(displayLimit, wordWrap);
189
+ // Only highlight/render a prefix that can fill the display limit; full-diff
190
+ // LCS + syntax highlight on thousands of hidden lines is pure waste when collapsed.
191
+ const entryBudget = takeEntriesForLineBudget(
192
+ parsed.entries,
193
+ processBudget,
194
+ );
195
+ const splitBudget = takeSplitRowsForBudget(splitRows, processBudget);
196
+ const inlineHighlights = buildInlineHighlightMap(splitBudget.rows);
197
+ const renderCtx: DiffRenderContext = {
198
+ width: safeWidth,
199
+ theme,
200
+ inlineHighlights,
201
+ palette,
202
+ highlightLine,
203
+ containerBgAnsi,
204
+ wordWrap,
205
+ indicatorMode,
206
+ showHashlineAnchors,
207
+ };
208
+ const bodyRows =
209
+ mode === "split"
210
+ ? renderSplit(splitBudget.rows, renderCtx, lineNumberWidth)
211
+ : mode === "compact"
212
+ ? renderCompact(entryBudget.entries, renderCtx)
213
+ : renderUnified(entryBudget.entries, renderCtx, lineNumberWidth);
214
+ const unprocessedLogicalRows =
215
+ mode === "split"
216
+ ? Math.max(0, splitBudget.totalRows - splitBudget.processedRows)
217
+ : Math.max(
218
+ 0,
219
+ entryBudget.totalLineEntries - entryBudget.processedLineEntries,
220
+ );
221
+ const bodyWithLimit = applyLineLimit(
222
+ bodyRows,
223
+ safeWidth,
224
+ options.expanded,
225
+ live.editDiffCollapsedLines,
226
+ live.expandedPreviewMaxLines,
227
+ parsed.stats.hunks,
228
+ theme,
229
+ unprocessedLogicalRows,
230
+ hovered,
231
+ );
232
+ const frame = renderDiffFrameLine(safeWidth, theme);
233
+ const renderedLines =
234
+ mode === "unified"
235
+ ? [
236
+ ...headerRows.map((row) => row.text),
237
+ frame,
238
+ ...bodyWithLimit,
239
+ frame,
240
+ ]
241
+ : [...headerRows.map((row) => row.text), ...bodyWithLimit];
242
+
243
+ const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
244
+ return cache.set(
245
+ safeWidth,
246
+ options.expanded,
247
+ mode,
248
+ configKey,
249
+ hovered,
250
+ clampedLines,
251
+ );
252
+ },
253
+ invalidate: cache.invalidate,
254
+ } as Component;
255
+ }
@@ -0,0 +1,184 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { sanitizeAnsiForThemedOutput } from "./ansi-utils.ts";
3
+ import { normalizeCodeWhitespace, wrapToWidth } from "./diff-text.ts";
4
+ import {
5
+ emphasis,
6
+ stabilizeBackgroundResets,
7
+ type DiffTheme,
8
+ } from "./diff-palette.ts";
9
+ import { clampDiffLineToWidth } from "./diff-limits.ts";
10
+ import type { DiffMetaEntry, DiffStats } from "./diff-parse.ts";
11
+ import type { DiffPresentationMode } from "./diff-presentation.ts";
12
+ import type { RenderedRow } from "./diff-layout.ts";
13
+
14
+ export function formatMetaEntryRows(
15
+ entry: DiffMetaEntry,
16
+ width: number,
17
+ theme: DiffTheme,
18
+ wordWrap: boolean,
19
+ ): RenderedRow[] {
20
+ const normalized = sanitizeAnsiForThemedOutput(
21
+ normalizeCodeWhitespace(entry.raw),
22
+ );
23
+ const lines = wordWrap
24
+ ? wrapToWidth(normalized, width, true)
25
+ : [truncateToWidth(normalized, width)];
26
+
27
+ const mapColor = (line: string): string => {
28
+ if (entry.kind === "hunk") {
29
+ return stabilizeBackgroundResets(theme.fg("accent", line));
30
+ }
31
+ if (entry.kind === "file") {
32
+ return stabilizeBackgroundResets(theme.fg("muted", line));
33
+ }
34
+ return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
35
+ };
36
+
37
+ return lines.map((line) => ({
38
+ text: mapColor(line),
39
+ hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
40
+ }));
41
+ }
42
+
43
+ function renderDiffStatBar(
44
+ stats: DiffStats,
45
+ width: number,
46
+ theme: DiffTheme,
47
+ ): string | null {
48
+ const totalChanges = stats.added + stats.removed;
49
+ if (totalChanges === 0 || width < 20) {
50
+ return null;
51
+ }
52
+
53
+ const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
54
+ let addedSlots = Math.max(
55
+ 0,
56
+ Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)),
57
+ );
58
+ if (stats.added > 0 && addedSlots === 0) {
59
+ addedSlots = 1;
60
+ }
61
+ if (stats.removed > 0 && addedSlots >= barSlots) {
62
+ addedSlots = barSlots - 1;
63
+ }
64
+ const removedSlots = Math.max(0, barSlots - addedSlots);
65
+
66
+ const addedBar =
67
+ addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
68
+ const removedBar =
69
+ removedSlots > 0
70
+ ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots))
71
+ : "";
72
+ return stabilizeBackgroundResets(
73
+ `${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`,
74
+ );
75
+ }
76
+
77
+ function buildDiffSummaryBasePieces(
78
+ stats: DiffStats,
79
+ theme: DiffTheme,
80
+ ): string[] {
81
+ return [
82
+ theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
83
+ theme.fg("toolDiffAdded", `+${stats.added}`),
84
+ theme.fg("toolDiffRemoved", `-${stats.removed}`),
85
+ ];
86
+ }
87
+
88
+ export function renderHeaderRows(
89
+ stats: DiffStats,
90
+ mode: Exclude<DiffPresentationMode, "summary">,
91
+ width: number,
92
+ theme: DiffTheme,
93
+ ): RenderedRow[] {
94
+ if (mode === "compact") {
95
+ const summary = buildDiffSummaryBasePieces(stats, theme).join(" ");
96
+ return [
97
+ {
98
+ text: stabilizeBackgroundResets(truncateToWidth(summary, width)),
99
+ hunkIndex: null,
100
+ },
101
+ ];
102
+ }
103
+
104
+ const summaryPieces = [
105
+ ...buildDiffSummaryBasePieces(stats, theme),
106
+ theme.fg("muted", mode),
107
+ ];
108
+
109
+ const summary = summaryPieces.join(
110
+ mode === "split" ? " " : theme.fg("muted", " • "),
111
+ );
112
+ const meter = renderDiffStatBar(stats, width, theme);
113
+ if (!meter) {
114
+ return [
115
+ {
116
+ text: stabilizeBackgroundResets(truncateToWidth(summary, width)),
117
+ hunkIndex: null,
118
+ },
119
+ ];
120
+ }
121
+
122
+ const meterSeparator = " ";
123
+ const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
124
+ if (meterWidth >= width) {
125
+ return [
126
+ {
127
+ text: stabilizeBackgroundResets(truncateToWidth(summary, width)),
128
+ hunkIndex: null,
129
+ },
130
+ ];
131
+ }
132
+
133
+ const summaryWidth = Math.max(0, width - meterWidth);
134
+ const fittedSummary = truncateToWidth(summary, summaryWidth);
135
+ return [
136
+ {
137
+ text: stabilizeBackgroundResets(
138
+ `${fittedSummary}${meterSeparator}${meter}`,
139
+ ),
140
+ hunkIndex: null,
141
+ },
142
+ ];
143
+ }
144
+
145
+ export function renderDiffFrameLine(width: number, theme: DiffTheme): string {
146
+ const frameWidth = Math.max(0, width);
147
+ if (frameWidth === 0) {
148
+ return "";
149
+ }
150
+ return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
151
+ }
152
+
153
+ export function renderSingleDiffRow(
154
+ text: string,
155
+ color: string,
156
+ width: number,
157
+ theme: DiffTheme,
158
+ ): string[] {
159
+ if (width <= 0) {
160
+ return [""];
161
+ }
162
+ return [
163
+ clampDiffLineToWidth(
164
+ stabilizeBackgroundResets(theme.fg(color, text)),
165
+ width,
166
+ ),
167
+ ];
168
+ }
169
+
170
+ export function renderWriteHeader(
171
+ wasOverwrite: boolean,
172
+ width: number,
173
+ theme: DiffTheme,
174
+ headerLabel?: string,
175
+ ): string {
176
+ const actionLabel =
177
+ headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
178
+ return stabilizeBackgroundResets(
179
+ truncateToWidth(
180
+ theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`),
181
+ width,
182
+ ),
183
+ );
184
+ }
@@ -0,0 +1,131 @@
1
+ import {
2
+ getLanguageFromPath,
3
+ highlightCode,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { sanitizeAnsiForThemedOutput } from "./ansi-utils.ts";
6
+ import { sanitizeToolResultText } from "../../../../tools/tool-result-sanitize.ts";
7
+ import { MAX_HL_CHARS, shikiHighlightCache } from "./shiki-highlight.ts";
8
+ import { normalizeCodeWhitespace } from "./diff-text.ts";
9
+ import {
10
+ applyInlineSpanHighlight,
11
+ getLineBackground,
12
+ resolveShikiTheme,
13
+ type DiffPalette,
14
+ type DiffTheme,
15
+ } from "./diff-palette.ts";
16
+ import type { DiffLineEntry, ParsedDiffEntry } from "./diff-parse.ts";
17
+ import type { DiffSpan } from "./diff-inline.ts";
18
+
19
+ export type CodeLineHighlighter = (
20
+ line: string,
21
+ entry: DiffLineEntry,
22
+ ) => string;
23
+
24
+ function cleanCodeLine(line: string): string {
25
+ return sanitizeToolResultText(line).replace(/\n/g, "");
26
+ }
27
+
28
+ export function shouldHighlightCodeBlock(code: string): boolean {
29
+ return code.length <= MAX_HL_CHARS;
30
+ }
31
+
32
+ export function resolveLanguageFromPath(
33
+ rawPath: string | undefined,
34
+ ): string | undefined {
35
+ if (!rawPath || !rawPath.trim()) {
36
+ return undefined;
37
+ }
38
+ const normalizedPath = rawPath.replace(/^@/, "").trim();
39
+ if (!normalizedPath) {
40
+ return undefined;
41
+ }
42
+ try {
43
+ return getLanguageFromPath(normalizedPath);
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ export function createCodeLineHighlighter(
50
+ language: string | undefined,
51
+ theme: DiffTheme,
52
+ entries: readonly ParsedDiffEntry[],
53
+ invalidate?: () => void,
54
+ ): CodeLineHighlighter {
55
+ const codeEntries = entries.filter(
56
+ (entry): entry is DiffLineEntry => entry.kind === "line",
57
+ );
58
+ const cleanLines = codeEntries.map((entry) =>
59
+ cleanCodeLine(normalizeCodeWhitespace(entry.content)),
60
+ );
61
+ const code = cleanLines.join("\n");
62
+ const shouldHighlight = !!language && shouldHighlightCodeBlock(code);
63
+ const fallbackLines = shouldHighlight
64
+ ? (() => {
65
+ try {
66
+ return highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
67
+ } catch {
68
+ return cleanLines.map(sanitizeAnsiForThemedOutput);
69
+ }
70
+ })()
71
+ : cleanLines.map(sanitizeAnsiForThemedOutput);
72
+ const fallbackByEntry = new WeakMap(
73
+ codeEntries.map((entry, index) => [
74
+ entry,
75
+ fallbackLines[index] ?? cleanLines[index] ?? "",
76
+ ]),
77
+ );
78
+ if (!shouldHighlight)
79
+ return (_line, entry) => fallbackByEntry.get(entry) ?? "";
80
+
81
+ const shikiTheme = resolveShikiTheme(theme);
82
+ let highlightedByEntry: WeakMap<DiffLineEntry, string> | undefined;
83
+ const resolveHighlighted = () => {
84
+ if (highlightedByEntry) return;
85
+ const highlighted = shikiHighlightCache.get(
86
+ code,
87
+ language,
88
+ shikiTheme,
89
+ fallbackLines,
90
+ invalidate,
91
+ );
92
+ if (highlighted) {
93
+ highlightedByEntry = new WeakMap(
94
+ codeEntries.map((entry, index) => [
95
+ entry,
96
+ sanitizeAnsiForThemedOutput(
97
+ highlighted[index] ?? fallbackByEntry.get(entry) ?? "",
98
+ ),
99
+ ]),
100
+ );
101
+ }
102
+ };
103
+ resolveHighlighted();
104
+ return (_line, entry) => {
105
+ resolveHighlighted();
106
+ return highlightedByEntry?.get(entry) ?? fallbackByEntry.get(entry) ?? "";
107
+ };
108
+ }
109
+
110
+ export function highlightDiffLine(
111
+ codeText: string,
112
+ entry: DiffLineEntry,
113
+ inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
114
+ palette: DiffPalette,
115
+ highlightLine: CodeLineHighlighter,
116
+ containerBgAnsi: string | undefined,
117
+ ): { highlighted: string; rowBg: string | undefined } {
118
+ const syntaxHighlighted = highlightLine(codeText, entry);
119
+ const rowBg = getLineBackground(entry.lineKind, palette, false);
120
+ const emphasisBg = getLineBackground(entry.lineKind, palette, true);
121
+ const inlineSpans = inlineHighlights.get(entry) ?? [];
122
+ const highlighted = applyInlineSpanHighlight(
123
+ codeText,
124
+ syntaxHighlighted,
125
+ inlineSpans,
126
+ emphasisBg,
127
+ rowBg,
128
+ containerBgAnsi,
129
+ );
130
+ return { highlighted, rowBg };
131
+ }