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,137 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { renderAccentRailEditorFrame } from "./accent-rail-editor.ts";
4
+ import type { PolishedTuiConfig } from "../app/config/shell.ts";
5
+ import { sanitizeEditorMetadataText } from "./editor-metadata-format.ts";
6
+ import { renderMinimalistFrame } from "./minimalist-editor.ts";
7
+ import { safeThemeFg } from "./style.ts";
8
+ import { renderPolishedEditorFrame } from "./ui.ts";
9
+ import { renderUserMessageStyle } from "./user-message-styles.ts";
10
+
11
+ export const SETTINGS_PREVIEW_MAX_WIDTH = 72;
12
+ export const SETTINGS_PREVIEW_MAX_ROWS = 10;
13
+ export const EDITOR_PREVIEW_INPUT = "Explain this change safely.";
14
+ export const USER_MESSAGE_PREVIEW_MARKDOWN =
15
+ "Please review **this change** safely.";
16
+
17
+ function boundedRows(rows: string[], width: number): string[] {
18
+ const safeWidth = Math.max(0, Math.min(SETTINGS_PREVIEW_MAX_WIDTH, width));
19
+ if (safeWidth <= 0) return [];
20
+ const bounded = rows
21
+ .slice(0, SETTINGS_PREVIEW_MAX_ROWS)
22
+ .map((line) => truncateToWidth(line, safeWidth, ""));
23
+ while (bounded.length > 0 && visibleWidth(bounded.at(-1) ?? "") === 0)
24
+ bounded.pop();
25
+ return bounded;
26
+ }
27
+
28
+ function previewConfig(config: PolishedTuiConfig): PolishedTuiConfig {
29
+ const derived = structuredClone(config);
30
+ derived.icons = Object.fromEntries(
31
+ Object.entries(derived.icons).map(([key, value]) => [
32
+ key,
33
+ typeof value === "string" ? sanitizeEditorMetadataText(value) : value,
34
+ ]),
35
+ ) as typeof derived.icons;
36
+ return derived;
37
+ }
38
+
39
+ function adaptiveBorder(theme: Theme): (text: string) => string {
40
+ return (text) => safeThemeFg(theme, "thinkingHigh", text);
41
+ }
42
+
43
+ export function renderEditorSettingsPreview(
44
+ config: PolishedTuiConfig,
45
+ theme: Theme,
46
+ width: number,
47
+ ): string[] {
48
+ const previewWidth = Math.max(0, Math.min(SETTINGS_PREVIEW_MAX_WIDTH, width));
49
+ if (previewWidth <= 0) return [];
50
+ const safeConfig = previewConfig(config);
51
+ const editor = safeConfig.components.editor;
52
+ const borderColor = adaptiveBorder(theme);
53
+ const modelLabel = editor.modelLabel === "name" ? "Sonnet 4" : "sonnet-4";
54
+ const editorLines = [EDITOR_PREVIEW_INPUT];
55
+ const autocompleteLines = [
56
+ safeThemeFg(theme, "accent", "→ settings Open settings"),
57
+ " files Search files",
58
+ safeThemeFg(theme, "muted", " (1/47)"),
59
+ ];
60
+ const viewport = editor.viewportIndicators
61
+ ? { above: "2", below: "3" }
62
+ : undefined;
63
+ let frame: string[];
64
+ if (editor.style === "accent-rail") {
65
+ frame = renderAccentRailEditorFrame({
66
+ width: previewWidth,
67
+ editorLines,
68
+ autocompleteLines,
69
+ viewport,
70
+ uiTheme: theme,
71
+ config: safeConfig,
72
+ });
73
+ } else if (editor.style === "minimalist") {
74
+ frame = renderMinimalistFrame({
75
+ width: previewWidth,
76
+ editorLines,
77
+ viewport,
78
+ inputText: EDITOR_PREVIEW_INPUT,
79
+ metadata: {
80
+ cwd: "/workspace/zentui/src",
81
+ projectRoot: "/workspace/zentui",
82
+ branch: "feat/settings-previews",
83
+ dirty: true,
84
+ ahead: 2,
85
+ behind: 1,
86
+ costLabel: "$.12",
87
+ modelLabel,
88
+ thinkingLevel: "high",
89
+ contextPercent: 75,
90
+ contextWindow: 372_000,
91
+ sessionName: "Preview",
92
+ agentDurationMs: 12_000,
93
+ agentActive: true,
94
+ },
95
+ uiTheme: theme,
96
+ config: safeConfig,
97
+ borderColor,
98
+ });
99
+ } else {
100
+ frame = renderPolishedEditorFrame({
101
+ width: previewWidth,
102
+ editorLines,
103
+ autocompleteLines,
104
+ viewport,
105
+ uiTheme: theme,
106
+ config: safeConfig,
107
+ modelMeta: {
108
+ modelLabel,
109
+ modelId: "sonnet-4",
110
+ modelName: "Sonnet 4",
111
+ providerLabel: "Anthropic",
112
+ sessionName: "Preview",
113
+ },
114
+ thinkingLevel: "high",
115
+ borderColor,
116
+ });
117
+ }
118
+ return boundedRows(frame, previewWidth);
119
+ }
120
+
121
+ export function renderUserMessageSettingsPreview(
122
+ config: PolishedTuiConfig,
123
+ theme: Theme,
124
+ width: number,
125
+ text = USER_MESSAGE_PREVIEW_MARKDOWN,
126
+ ): string[] {
127
+ const previewWidth = Math.max(0, Math.min(SETTINGS_PREVIEW_MAX_WIDTH, width));
128
+ if (previewWidth <= 0) return [];
129
+ const safeConfig = previewConfig(config);
130
+ const frame = renderUserMessageStyle({
131
+ text,
132
+ width: previewWidth,
133
+ theme,
134
+ config: safeConfig,
135
+ });
136
+ return boundedRows(frame, previewWidth);
137
+ }
@@ -0,0 +1,83 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelLabelSource } from "../app/config/shell.ts";
3
+ import {
4
+ buildCacheReadLabel,
5
+ buildCacheWriteLabel,
6
+ buildContextLabel,
7
+ buildCostLabel,
8
+ buildTokenLabel,
9
+ formatProviderLabel,
10
+ getUsageTotals,
11
+ } from "./format.ts";
12
+ import type { GitStatusSummary } from "./git.ts";
13
+ import type { PackageVersionResult } from "./package-version.ts";
14
+ import type { RuntimeInfo } from "./runtime.ts";
15
+ import type { FooterTelemetry } from "./telemetry.ts";
16
+
17
+ export type FooterState = GitStatusSummary & {
18
+ modelLabel: string;
19
+ modelId: string;
20
+ modelName: string;
21
+ providerLabel: string;
22
+ contextLabel: string;
23
+ tokenLabel: string;
24
+ cacheReadLabel: string;
25
+ cacheWriteLabel: string;
26
+ costLabel: string;
27
+ subscription: boolean;
28
+ autoCompaction: boolean;
29
+ runtime?: RuntimeInfo;
30
+ packageVersion?: PackageVersionResult;
31
+ sessionStartEpoch?: number;
32
+ };
33
+
34
+ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
35
+ return {
36
+ modelLabel: "no-model",
37
+ modelId: "",
38
+ modelName: "",
39
+ providerLabel: "Unknown",
40
+ contextLabel: "--",
41
+ tokenLabel: "↑0 ↓0",
42
+ cacheReadLabel: "",
43
+ cacheWriteLabel: "",
44
+ costLabel: "$0.000",
45
+ subscription: false,
46
+ autoCompaction: false,
47
+ runtime: undefined,
48
+ packageVersion: undefined,
49
+ sessionStartEpoch: Date.now(),
50
+ ...gitDefaults,
51
+ };
52
+ }
53
+
54
+ export function modelLabelFor(
55
+ state: Pick<FooterState, "modelId" | "modelName">,
56
+ source: ModelLabelSource,
57
+ ): string {
58
+ return source === "name"
59
+ ? state.modelName || state.modelId || "no-model"
60
+ : state.modelId || "no-model";
61
+ }
62
+
63
+ export function syncState(
64
+ state: FooterState,
65
+ ctx: ExtensionContext,
66
+ cacheHitIcon: string,
67
+ telemetry: FooterTelemetry = {},
68
+ ): void {
69
+ const totals = getUsageTotals(ctx);
70
+ const m = ctx.model;
71
+ state.modelId = m?.id ?? "";
72
+ state.modelName = m?.name ?? "";
73
+ // Retained as a compatibility snapshot only; production surfaces format from raw fields.
74
+ state.modelLabel = modelLabelFor(state, "id");
75
+ state.providerLabel = formatProviderLabel(ctx.model?.provider);
76
+ state.contextLabel = buildContextLabel(ctx);
77
+ state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
78
+ state.cacheReadLabel = buildCacheReadLabel(totals.cacheRead);
79
+ state.cacheWriteLabel = buildCacheWriteLabel(totals.cacheWrite);
80
+ state.costLabel = buildCostLabel(totals);
81
+ state.subscription = telemetry.subscription === true;
82
+ state.autoCompaction = telemetry.autoCompaction === true;
83
+ }
@@ -0,0 +1,480 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { ColorSource, ColorSpec } from "../app/config/shell.ts";
3
+
4
+ type ThemeLike = {
5
+ fg(color: string, text: string): string;
6
+ bold?: (text: string) => string;
7
+ italic?: (text: string) => string;
8
+ underline?: (text: string) => string;
9
+ };
10
+
11
+ export type { ThemeLike };
12
+
13
+ export const EDITOR_ACCENT_STYLE = "blue";
14
+ export const EDITOR_BORDER_STYLE = "bright-black";
15
+ export const MAX_SAFE_SGR_PREFIX_CODE_UNITS = 96;
16
+ export const MAX_SAFE_SGR_PREFIX_SEQUENCES = 4;
17
+
18
+ export type SourceStyleFallback = {
19
+ theme: ColorSpec;
20
+ terminal: ColorSpec;
21
+ };
22
+
23
+ export const EDITOR_ACCENT_FALLBACK: SourceStyleFallback = {
24
+ theme: "accent",
25
+ terminal: EDITOR_ACCENT_STYLE,
26
+ };
27
+
28
+ export const EDITOR_BORDER_FALLBACK: SourceStyleFallback = {
29
+ theme: "borderMuted",
30
+ terminal: EDITOR_BORDER_STYLE,
31
+ };
32
+
33
+ function isHexColor(value: string): boolean {
34
+ return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value);
35
+ }
36
+
37
+ function expandHexColor(hex: string): string {
38
+ const body = hex.slice(1);
39
+ if (body.length === 3) {
40
+ return body
41
+ .split("")
42
+ .map((ch) => ch + ch)
43
+ .join("");
44
+ }
45
+ return body;
46
+ }
47
+
48
+ function hexToAnsi(hex: string, isBackground = false): string {
49
+ const normalized = expandHexColor(hex);
50
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
51
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
52
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
53
+ return `\x1b[${isBackground ? 48 : 38};2;${r};${g};${b}m`;
54
+ }
55
+
56
+ const terminalColorCodes = new Map([
57
+ ["black", 30],
58
+ ["red", 31],
59
+ ["green", 32],
60
+ ["yellow", 33],
61
+ ["blue", 34],
62
+ ["purple", 35],
63
+ ["cyan", 36],
64
+ ["white", 37],
65
+ ["bright-black", 90],
66
+ ["bright-red", 91],
67
+ ["bright-green", 92],
68
+ ["bright-yellow", 93],
69
+ ["bright-blue", 94],
70
+ ["bright-purple", 95],
71
+ ["bright-cyan", 96],
72
+ ["bright-white", 97],
73
+ ]);
74
+
75
+ const terminalStyleModifiers = new Map([
76
+ ["bold", 1],
77
+ ["dim", 2],
78
+ ["dimmed", 2],
79
+ ["italic", 3],
80
+ ["underline", 4],
81
+ ]);
82
+
83
+ const themeColorNameMap = new Map([
84
+ ["red", "error"],
85
+ ["bright-red", "error"],
86
+ ["green", "success"],
87
+ ["bright-green", "success"],
88
+ ["yellow", "warning"],
89
+ ["bright-yellow", "warning"],
90
+ ["blue", "syntaxFunction"],
91
+ ["bright-blue", "syntaxFunction"],
92
+ ["cyan", "syntaxFunction"],
93
+ ["bright-cyan", "syntaxFunction"],
94
+ ["purple", "syntaxKeyword"],
95
+ ["bright-purple", "syntaxKeyword"],
96
+ ["black", "muted"],
97
+ ["bright-black", "muted"],
98
+ ["white", "text"],
99
+ ["bright-white", "text"],
100
+ ]);
101
+
102
+ const themeStyleModifiers = new Set(["bold", "italic", "underline"]);
103
+
104
+ const themeColorTokens = new Set<ThemeColor>([
105
+ "accent",
106
+ "border",
107
+ "borderAccent",
108
+ "borderMuted",
109
+ "success",
110
+ "error",
111
+ "warning",
112
+ "muted",
113
+ "dim",
114
+ "text",
115
+ "thinkingText",
116
+ "userMessageText",
117
+ "customMessageText",
118
+ "customMessageLabel",
119
+ "toolTitle",
120
+ "toolOutput",
121
+ "mdHeading",
122
+ "mdLink",
123
+ "mdLinkUrl",
124
+ "mdCode",
125
+ "mdCodeBlock",
126
+ "mdCodeBlockBorder",
127
+ "mdQuote",
128
+ "mdQuoteBorder",
129
+ "mdHr",
130
+ "mdListBullet",
131
+ "toolDiffAdded",
132
+ "toolDiffRemoved",
133
+ "toolDiffContext",
134
+ "syntaxComment",
135
+ "syntaxKeyword",
136
+ "syntaxFunction",
137
+ "syntaxVariable",
138
+ "syntaxString",
139
+ "syntaxNumber",
140
+ "syntaxType",
141
+ "syntaxOperator",
142
+ "syntaxPunctuation",
143
+ "thinkingOff",
144
+ "thinkingMinimal",
145
+ "thinkingLow",
146
+ "thinkingMedium",
147
+ "thinkingHigh",
148
+ "thinkingXhigh",
149
+ "thinkingMax",
150
+ "bashMode",
151
+ ]);
152
+
153
+ function terminalColorToAnsi(
154
+ color: string,
155
+ isBackground = false,
156
+ ): string | undefined {
157
+ const normalized = color.toLowerCase();
158
+ const colorCode = terminalColorCodes.get(normalized);
159
+ if (colorCode !== undefined)
160
+ return `${isBackground ? colorCode + 10 : colorCode}`;
161
+
162
+ if (/^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(normalized)) {
163
+ return `${isBackground ? 48 : 38};5;${normalized}`;
164
+ }
165
+
166
+ if (isHexColor(normalized))
167
+ return hexToAnsi(normalized, isBackground).slice(2, -1);
168
+ return undefined;
169
+ }
170
+
171
+ function isExplicitTerminalColorToken(token: string): boolean {
172
+ const normalized = token.toLowerCase();
173
+ if (normalized.startsWith("fg:") || normalized.startsWith("bg:")) return true;
174
+ if (isHexColor(normalized)) return true;
175
+ return /^(?:[0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/.test(
176
+ normalized,
177
+ );
178
+ }
179
+
180
+ function isSupportedStyleToken(token: string): boolean {
181
+ const normalized = token.toLowerCase();
182
+ if (terminalStyleModifiers.has(normalized)) return true;
183
+ if (terminalColorToAnsi(normalized) !== undefined) return true;
184
+
185
+ const isForeground = normalized.startsWith("fg:");
186
+ const isBackground = normalized.startsWith("bg:");
187
+ if (isForeground || isBackground) {
188
+ return terminalColorToAnsi(normalized.slice(3), isBackground) !== undefined;
189
+ }
190
+
191
+ return themeColorTokens.has(token as ThemeColor);
192
+ }
193
+
194
+ export function isSupportedColorSpec(style: ColorSpec): boolean {
195
+ const trimmed = style.trim();
196
+ if (trimmed === "") return true;
197
+ return trimmed.split(/\s+/).every(isSupportedStyleToken);
198
+ }
199
+
200
+ function applyThemeModifiers(
201
+ theme: ThemeLike,
202
+ styleTokens: string[],
203
+ text: string,
204
+ ): string {
205
+ let rendered = text;
206
+ for (const token of styleTokens) {
207
+ const normalized = token.toLowerCase();
208
+ if (normalized === "bold") rendered = theme.bold?.(rendered) ?? rendered;
209
+ if (normalized === "italic")
210
+ rendered = theme.italic?.(rendered) ?? rendered;
211
+ if (normalized === "underline")
212
+ rendered = theme.underline?.(rendered) ?? rendered;
213
+ }
214
+ return rendered;
215
+ }
216
+
217
+ export function safeThemeFg(
218
+ theme: ThemeLike,
219
+ color: string,
220
+ text: string,
221
+ ): string {
222
+ try {
223
+ return theme.fg(color, text);
224
+ } catch {
225
+ return text;
226
+ }
227
+ }
228
+
229
+ function mapThemeColor(styleTokens: string[]): string | undefined {
230
+ let fallback: string | undefined;
231
+ for (const token of styleTokens) {
232
+ const normalized = token.toLowerCase();
233
+ if (themeStyleModifiers.has(normalized)) continue;
234
+ if (normalized === "dim" || normalized === "dimmed") {
235
+ fallback = "muted";
236
+ continue;
237
+ }
238
+
239
+ const mapped = themeColorNameMap.get(normalized);
240
+ if (mapped) return mapped;
241
+ return token;
242
+ }
243
+ return fallback;
244
+ }
245
+
246
+ /**
247
+ * Colorize text using a theme color token or hex color.
248
+ * Non-hex values are passed directly to `theme.fg()`; invalid tokens fall back
249
+ * to unstyled text so a config typo does not break rendering.
250
+ */
251
+ export function colorize(
252
+ theme: ThemeLike,
253
+ color: ColorSpec,
254
+ text: string,
255
+ ): string {
256
+ if (isHexColor(color)) {
257
+ return `${hexToAnsi(color)}${text}\x1b[39m`;
258
+ }
259
+ return safeThemeFg(theme, color, text);
260
+ }
261
+
262
+ /**
263
+ * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202",
264
+ * "bg:blue", "underline bg:#bf5700").
265
+ */
266
+ function isSafeSgrParameters(parameters: string): boolean {
267
+ const tokens = parameters.split(";");
268
+ if (tokens.some((token) => !/^\d+$/.test(token))) return false;
269
+ const values = tokens.map(Number);
270
+ for (let index = 0; index < values.length; index += 1) {
271
+ const value = values[index];
272
+ if (
273
+ value === 1 ||
274
+ value === 2 ||
275
+ value === 3 ||
276
+ value === 4 ||
277
+ (value !== undefined && value >= 30 && value <= 37) ||
278
+ (value !== undefined && value >= 40 && value <= 47) ||
279
+ (value !== undefined && value >= 90 && value <= 97) ||
280
+ (value !== undefined && value >= 100 && value <= 107)
281
+ ) {
282
+ continue;
283
+ }
284
+ if (value !== 38 && value !== 48) return false;
285
+ const mode = values[index + 1];
286
+ if (mode === 5) {
287
+ const color = values[index + 2];
288
+ if (color === undefined || color < 0 || color > 255) return false;
289
+ index += 2;
290
+ continue;
291
+ }
292
+ if (mode === 2) {
293
+ const channels = values.slice(index + 2, index + 5);
294
+ if (
295
+ channels.length !== 3 ||
296
+ channels.some((channel) => channel < 0 || channel > 255)
297
+ ) {
298
+ return false;
299
+ }
300
+ index += 4;
301
+ continue;
302
+ }
303
+ return false;
304
+ }
305
+ return true;
306
+ }
307
+
308
+ /** Accept only the bounded SGR subset emitted by Zentui's supported style tokens. */
309
+ export function isSafeSgrStylePrefix(value: unknown): value is string {
310
+ if (
311
+ typeof value !== "string" ||
312
+ value.length === 0 ||
313
+ value.length > MAX_SAFE_SGR_PREFIX_CODE_UNITS
314
+ ) {
315
+ return false;
316
+ }
317
+ const sequence = /\x1b\[([0-9]+(?:;[0-9]+)*)m/g;
318
+ let offset = 0;
319
+ let count = 0;
320
+ for (const match of value.matchAll(sequence)) {
321
+ if (match.index !== offset || !isSafeSgrParameters(match[1] ?? ""))
322
+ return false;
323
+ offset = match.index + match[0].length;
324
+ count += 1;
325
+ if (count > MAX_SAFE_SGR_PREFIX_SEQUENCES) return false;
326
+ }
327
+ return count > 0 && offset === value.length;
328
+ }
329
+
330
+ export function renderTerminalStyle(style: string, text: string): string {
331
+ const codes: string[] = [];
332
+ for (const token of style.trim().split(/\s+/)) {
333
+ if (!token) continue;
334
+
335
+ const normalized = token.toLowerCase();
336
+ const modifier = terminalStyleModifiers.get(normalized);
337
+ if (modifier !== undefined) {
338
+ codes.push(`${modifier}`);
339
+ continue;
340
+ }
341
+
342
+ const isForeground = normalized.startsWith("fg:");
343
+ const isBackground = normalized.startsWith("bg:");
344
+ const colorName =
345
+ isForeground || isBackground ? normalized.slice(3) : normalized;
346
+ const color = terminalColorToAnsi(colorName, isBackground);
347
+ if (color) codes.push(color);
348
+ }
349
+
350
+ return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
351
+ }
352
+
353
+ /**
354
+ * Apply Starship-style terminal styling first, falling back to Pi theme tokens for
355
+ * legacy config values such as "accent" or "syntaxKeyword".
356
+ */
357
+ export function renderStyle(
358
+ theme: ThemeLike,
359
+ style: ColorSpec,
360
+ text: string,
361
+ ): string {
362
+ if (style.trim() === "") return text;
363
+ const styled = renderTerminalStyle(style, text);
364
+ return styled === text ? colorize(theme, style, text) : styled;
365
+ }
366
+
367
+ export function renderThemeStyle(
368
+ theme: ThemeLike,
369
+ style: ColorSpec,
370
+ text: string,
371
+ ): string {
372
+ const trimmed = style.trim();
373
+ if (trimmed === "") return text;
374
+
375
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
376
+ if (tokens.some(isExplicitTerminalColorToken))
377
+ return renderTerminalStyle(style, text);
378
+
379
+ const color = mapThemeColor(tokens) ?? "text";
380
+ return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
381
+ }
382
+
383
+ function renderStyleStrict(
384
+ theme: ThemeLike,
385
+ style: ColorSpec,
386
+ text: string,
387
+ ): string {
388
+ if (style.trim() === "") return text;
389
+ const styled = renderTerminalStyle(style, text);
390
+ return styled === text ? theme.fg(style, text) : styled;
391
+ }
392
+
393
+ function renderThemeStyleStrict(
394
+ theme: ThemeLike,
395
+ style: ColorSpec,
396
+ text: string,
397
+ ): string {
398
+ const trimmed = style.trim();
399
+ if (trimmed === "") return text;
400
+ const tokens = trimmed.split(/\s+/).filter(Boolean);
401
+ if (tokens.some(isExplicitTerminalColorToken))
402
+ return renderTerminalStyle(style, text);
403
+ const color = mapThemeColor(tokens) ?? "text";
404
+ return theme.fg(color, applyThemeModifiers(theme, tokens, text));
405
+ }
406
+
407
+ export function renderStyleForSource(
408
+ theme: ThemeLike,
409
+ source: ColorSource,
410
+ style: ColorSpec,
411
+ text: string,
412
+ ): string {
413
+ return source === "terminal"
414
+ ? renderStyle(theme, style, text)
415
+ : renderThemeStyle(theme, style, text);
416
+ }
417
+
418
+ export function renderStyleForSourceOrFallback(
419
+ theme: ThemeLike,
420
+ source: ColorSource,
421
+ style: ColorSpec | undefined,
422
+ fallback: ColorSpec | SourceStyleFallback,
423
+ text: string,
424
+ ): string {
425
+ const fallbackStyle =
426
+ typeof fallback === "string" ? fallback : fallback[source];
427
+ return renderStyleForSource(theme, source, style ?? fallbackStyle, text);
428
+ }
429
+
430
+ export function renderStyleForSourceOrFallbackStrict(
431
+ theme: ThemeLike,
432
+ source: ColorSource,
433
+ style: ColorSpec | undefined,
434
+ fallback: ColorSpec | SourceStyleFallback,
435
+ text: string,
436
+ ): string {
437
+ const fallbackStyle =
438
+ typeof fallback === "string" ? fallback : fallback[source];
439
+ const resolvedStyle = style ?? fallbackStyle;
440
+ return source === "terminal"
441
+ ? renderStyleStrict(theme, resolvedStyle, text)
442
+ : renderThemeStyleStrict(theme, resolvedStyle, text);
443
+ }
444
+
445
+ export function renderEditorAccent(text: string): string {
446
+ return renderTerminalStyle(EDITOR_ACCENT_STYLE, text);
447
+ }
448
+
449
+ export function renderEditorBorder(text: string): string {
450
+ return renderTerminalStyle(EDITOR_BORDER_STYLE, text);
451
+ }
452
+
453
+ export function renderAccentLine(
454
+ theme: ThemeLike,
455
+ source: ColorSource,
456
+ text: string,
457
+ ): string {
458
+ return renderStyleForSourceOrFallback(
459
+ theme,
460
+ source,
461
+ undefined,
462
+ EDITOR_ACCENT_FALLBACK,
463
+ text,
464
+ );
465
+ }
466
+
467
+ export function renderChromeBorder(
468
+ theme: ThemeLike,
469
+ source: ColorSource,
470
+ terminalFallbackStyle: ColorSpec,
471
+ text: string,
472
+ ): string {
473
+ return renderStyleForSourceOrFallback(
474
+ theme,
475
+ source,
476
+ undefined,
477
+ { theme: "borderMuted", terminal: terminalFallbackStyle },
478
+ text,
479
+ );
480
+ }