@rosetears/aili-pi 0.1.0 → 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 (46) hide show
  1. package/README.md +7 -1
  2. package/THIRD_PARTY_NOTICES.md +11 -0
  3. package/extensions/header/index.ts +92 -0
  4. package/extensions/matrix/index.ts +375 -0
  5. package/extensions/zentui/config.ts +1014 -0
  6. package/extensions/zentui/extension-status.ts +96 -0
  7. package/extensions/zentui/fixed-editor/cluster.ts +98 -0
  8. package/extensions/zentui/fixed-editor/compositor.ts +719 -0
  9. package/extensions/zentui/fixed-editor/index.ts +223 -0
  10. package/extensions/zentui/fixed-editor/input.ts +85 -0
  11. package/extensions/zentui/fixed-editor/pi-compat.ts +296 -0
  12. package/extensions/zentui/fixed-editor/selection.ts +217 -0
  13. package/extensions/zentui/fixed-editor/terminal-modes.ts +75 -0
  14. package/extensions/zentui/fixed-editor/types.ts +37 -0
  15. package/extensions/zentui/footer-format.ts +279 -0
  16. package/extensions/zentui/footer.ts +595 -0
  17. package/extensions/zentui/format.ts +434 -0
  18. package/extensions/zentui/git.ts +384 -0
  19. package/extensions/zentui/gradient.ts +70 -0
  20. package/extensions/zentui/icons.ts +252 -0
  21. package/extensions/zentui/index.ts +577 -0
  22. package/extensions/zentui/live-context.ts +75 -0
  23. package/extensions/zentui/package-version.ts +650 -0
  24. package/extensions/zentui/project-refresh.ts +104 -0
  25. package/extensions/zentui/project-state.ts +59 -0
  26. package/extensions/zentui/prototype-patch-registry.ts +111 -0
  27. package/extensions/zentui/runtime.ts +841 -0
  28. package/extensions/zentui/selector-border.ts +77 -0
  29. package/extensions/zentui/session-lifecycle.ts +60 -0
  30. package/extensions/zentui/settings-command.ts +897 -0
  31. package/extensions/zentui/state.ts +55 -0
  32. package/extensions/zentui/style.ts +332 -0
  33. package/extensions/zentui/thinking-message.ts +159 -0
  34. package/extensions/zentui/tool-execution.ts +189 -0
  35. package/extensions/zentui/ui.ts +618 -0
  36. package/extensions/zentui/user-message.ts +252 -0
  37. package/licenses/pi-sakura-cyberdeck-MIT.txt +21 -0
  38. package/licenses/pi-zentui-MIT.txt +21 -0
  39. package/manifests/provenance.json +11 -0
  40. package/manifests/sbom.json +17 -1
  41. package/notices/pi-sakura-cyberdeck-NOTICE.txt +6 -0
  42. package/package.json +11 -2
  43. package/src/runtime/doctor.ts +1 -1
  44. package/src/runtime/registry.ts +1 -1
  45. package/src/runtime/rem-head.txt +38 -0
  46. package/themes/rem-cyberdeck.json +32 -0
@@ -0,0 +1,384 @@
1
+ import { execFile } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ const GIT_COMMAND_TIMEOUT_MS = 2_000;
8
+
9
+ export type GitOperationState =
10
+ | "REBASING"
11
+ | "MERGING"
12
+ | "CHERRY-PICKING"
13
+ | "REVERTING"
14
+ | "BISECTING";
15
+
16
+ /**
17
+ * Starship `git_commit`-style info derived from the existing porcelain probe.
18
+ *
19
+ * `oid` is the full `branch.oid` from `git status --porcelain=2 --branch`
20
+ * (free — no extra git call). `detached` mirrors `branch.head === "(detached)"`.
21
+ * `tag` is populated only when the caller opts into the exact-tag probe
22
+ * (`readGitStatus({ readExactTag: true })`); `null` means no exact-match tag
23
+ * or the probe was skipped.
24
+ */
25
+ export type GitCommitInfo = {
26
+ oid: string | null;
27
+ detached: boolean;
28
+ tag: string | null;
29
+ };
30
+
31
+ /**
32
+ * Starship `git_metrics`-style aggregate line-change counts.
33
+ * See https://starship.rs/config/#git-metrics
34
+ */
35
+ export type GitMetricsInfo = {
36
+ added: number;
37
+ deleted: number;
38
+ };
39
+
40
+ export type GitStatusSummary = {
41
+ branch?: string;
42
+ dirty: boolean;
43
+ ahead: number;
44
+ behind: number;
45
+ conflicted: number;
46
+ untracked: number;
47
+ stashed: number;
48
+ modified: number;
49
+ staged: number;
50
+ renamed: number;
51
+ deleted: number;
52
+ typechanged: number;
53
+ gitState?: GitOperationState;
54
+ gitStateLabel?: string;
55
+ commit?: GitCommitInfo;
56
+ metrics?: GitMetricsInfo | null;
57
+ };
58
+
59
+ export type GitReadResult =
60
+ | { kind: "ok"; status: GitStatusSummary }
61
+ | { kind: "not_a_repo" }
62
+ | { kind: "error" };
63
+
64
+ export type GitStatePaths = {
65
+ rebaseMerge?: string;
66
+ rebaseApply?: string;
67
+ mergeHead?: string;
68
+ cherryPickHead?: string;
69
+ revertHead?: string;
70
+ bisectLog?: string;
71
+ rebaseMsgnum?: string;
72
+ rebaseEnd?: string;
73
+ };
74
+
75
+ export function emptyGitStatus(): GitStatusSummary {
76
+ return {
77
+ branch: undefined,
78
+ dirty: false,
79
+ ahead: 0,
80
+ behind: 0,
81
+ conflicted: 0,
82
+ untracked: 0,
83
+ stashed: 0,
84
+ modified: 0,
85
+ staged: 0,
86
+ renamed: 0,
87
+ deleted: 0,
88
+ typechanged: 0,
89
+ gitState: undefined,
90
+ gitStateLabel: undefined,
91
+ commit: undefined,
92
+ metrics: undefined,
93
+ };
94
+ }
95
+
96
+ export function parseGitStatusPorcelain(stdoutText: string, stashCount: number): GitStatusSummary {
97
+ const status = emptyGitStatus();
98
+ status.stashed = stashCount;
99
+
100
+ let oid: string | null = null;
101
+ let sawBranchHead = false;
102
+ let detached = false;
103
+
104
+ for (const line of stdoutText.split(/\r?\n/)) {
105
+ if (!line) continue;
106
+ if (line.startsWith("# branch.oid ")) {
107
+ const value = line.slice("# branch.oid ".length).trim();
108
+ if (value && value !== "(initial)") oid = value;
109
+ continue;
110
+ }
111
+ if (line.startsWith("# branch.head ")) {
112
+ sawBranchHead = true;
113
+ const branch = line.slice("# branch.head ".length).trim();
114
+ if (branch === "(detached)") {
115
+ detached = true;
116
+ status.branch = undefined;
117
+ } else if (branch) {
118
+ status.branch = branch;
119
+ }
120
+ continue;
121
+ }
122
+ if (line.startsWith("# branch.ab ")) {
123
+ const match = line.match(/\+(\d+)\s+-(\d+)/);
124
+ if (match) {
125
+ status.ahead = Number(match[1] ?? 0);
126
+ status.behind = Number(match[2] ?? 0);
127
+ }
128
+ continue;
129
+ }
130
+ if (line.startsWith("#")) continue;
131
+
132
+ status.dirty = true;
133
+
134
+ if (line.startsWith("? ")) {
135
+ status.untracked += 1;
136
+ continue;
137
+ }
138
+ if (line.startsWith("u ")) {
139
+ status.conflicted += 1;
140
+ continue;
141
+ }
142
+ if (!(line.startsWith("1 ") || line.startsWith("2 "))) continue;
143
+
144
+ const xy = line.split(" ")[1] ?? "..";
145
+ const x = xy[0] ?? ".";
146
+ const y = xy[1] ?? ".";
147
+
148
+ if (x === "R") status.renamed += 1;
149
+ else if (x === "D") status.deleted += 1;
150
+ else if (x === "T") status.typechanged += 1;
151
+ else if (x !== "." && x !== " ") status.staged += 1;
152
+
153
+ if (y === "M") status.modified += 1;
154
+ else if (y === "D") status.deleted += 1;
155
+ else if (y === "T") status.typechanged += 1;
156
+ }
157
+
158
+ // Only populate commit info when we actually saw branch headers (inside
159
+ // a repo with commits). An unborn branch has no oid.
160
+ if (sawBranchHead) {
161
+ status.commit = { oid, detached, tag: null };
162
+ }
163
+
164
+ return status;
165
+ }
166
+
167
+ /**
168
+ * Parse `git diff --numstat` output into aggregate added/deleted counts.
169
+ *
170
+ * Each line is `added\tdeleted\tpath` (or `added\tdeleted\told\tnew` for
171
+ * renames). Binary rows carry `-` in both numeric columns and are skipped.
172
+ * Malformed/unparseable lines are ignored rather than aborting the sum.
173
+ *
174
+ * See https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---numstat
175
+ */
176
+ export function parseGitNumstat(stdoutText: string): GitMetricsInfo {
177
+ let added = 0;
178
+ let deleted = 0;
179
+ for (const line of stdoutText.split(/\r?\n/)) {
180
+ if (!line) continue;
181
+ const parts = line.split("\t");
182
+ // `added\tdeleted\tpath...` — need at least 3 tab-delimited fields.
183
+ if (parts.length < 3) continue;
184
+ const a = parts[0];
185
+ const d = parts[1];
186
+ // Binary files report `-` for both; skip.
187
+ if (a === "-" || d === "-") continue;
188
+ const na = Number(a);
189
+ const nd = Number(d);
190
+ if (!Number.isFinite(na) || !Number.isFinite(nd)) continue;
191
+ if (na < 0 || nd < 0) continue;
192
+ added += na;
193
+ deleted += nd;
194
+ }
195
+ return { added, deleted };
196
+ }
197
+
198
+ function readOptionalText(path: string | undefined): string | undefined {
199
+ if (!path || !existsSync(path)) return undefined;
200
+ try {
201
+ return readFileSync(path, "utf8").trim();
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Pure git operation-state detector. Paths that exist (truthy strings that
209
+ * callers verified with `existsSync`) select the active state in Starship order.
210
+ */
211
+ export function detectGitState(paths: GitStatePaths): {
212
+ gitState?: GitOperationState;
213
+ gitStateLabel?: string;
214
+ } {
215
+ if (paths.rebaseMerge || paths.rebaseApply) {
216
+ const msgnum = readOptionalText(paths.rebaseMsgnum);
217
+ const end = readOptionalText(paths.rebaseEnd);
218
+ if (msgnum && end) {
219
+ return { gitState: "REBASING", gitStateLabel: `REBASING ${msgnum}/${end}` };
220
+ }
221
+ return { gitState: "REBASING", gitStateLabel: "REBASING" };
222
+ }
223
+ if (paths.mergeHead) return { gitState: "MERGING", gitStateLabel: "MERGING" };
224
+ if (paths.cherryPickHead) {
225
+ return { gitState: "CHERRY-PICKING", gitStateLabel: "CHERRY-PICKING" };
226
+ }
227
+ if (paths.revertHead) return { gitState: "REVERTING", gitStateLabel: "REVERTING" };
228
+ if (paths.bisectLog) return { gitState: "BISECTING", gitStateLabel: "BISECTING" };
229
+ return {};
230
+ }
231
+
232
+ async function resolveGitPath(cwd: string, pathSpec: string): Promise<string | undefined> {
233
+ try {
234
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", pathSpec], {
235
+ cwd,
236
+ timeout: GIT_COMMAND_TIMEOUT_MS,
237
+ });
238
+ const resolved = (typeof stdout === "string" ? stdout : String(stdout)).trim();
239
+ if (!resolved) return undefined;
240
+ return resolved.startsWith("/") ? resolved : join(cwd, resolved);
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ }
245
+
246
+ async function readGitOperationState(cwd: string): Promise<{
247
+ gitState?: GitOperationState;
248
+ gitStateLabel?: string;
249
+ }> {
250
+ const [rebaseMerge, rebaseApply, mergeHead, cherryPickHead, revertHead, bisectLog] =
251
+ await Promise.all([
252
+ resolveGitPath(cwd, "rebase-merge"),
253
+ resolveGitPath(cwd, "rebase-apply"),
254
+ resolveGitPath(cwd, "MERGE_HEAD"),
255
+ resolveGitPath(cwd, "CHERRY_PICK_HEAD"),
256
+ resolveGitPath(cwd, "REVERT_HEAD"),
257
+ resolveGitPath(cwd, "BISECT_LOG"),
258
+ ]);
259
+
260
+ const existing = (path: string | undefined) => (path && existsSync(path) ? path : undefined);
261
+ const rebaseDir = existing(rebaseMerge) ?? existing(rebaseApply);
262
+
263
+ return detectGitState({
264
+ rebaseMerge: existing(rebaseMerge),
265
+ rebaseApply: existing(rebaseApply),
266
+ mergeHead: existing(mergeHead),
267
+ cherryPickHead: existing(cherryPickHead),
268
+ revertHead: existing(revertHead),
269
+ bisectLog: existing(bisectLog),
270
+ rebaseMsgnum: rebaseDir ? join(rebaseDir, "msgnum") : undefined,
271
+ rebaseEnd: rebaseDir ? join(rebaseDir, "end") : undefined,
272
+ });
273
+ }
274
+
275
+ function isNotARepoError(error: unknown): boolean {
276
+ const message =
277
+ error instanceof Error
278
+ ? `${error.message}\n${"stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : ""}`
279
+ : String(error);
280
+ return /not a git repository|outside repository|not a git repo/i.test(message);
281
+ }
282
+
283
+ /**
284
+ * Options for the optional probes that piggyback on the existing git refresh.
285
+ * Both default to `false` so no extra git process is spawned unless a segment
286
+ * is enabled and needs the data.
287
+ */
288
+ export type ReadGitStatusOptions = {
289
+ /** Run `git describe --tags --exact-match HEAD` for the git_commit segment. */
290
+ readExactTag?: boolean;
291
+ /**
292
+ * Run `git diff HEAD --numstat` for the git_metrics segment. Uses the
293
+ * Starship-like “total dirty” view (staged + unstaged combined).
294
+ */
295
+ readMetrics?: boolean;
296
+ /** Add `--ignore-submodules=all` to the metrics diff when requested. */
297
+ ignoreSubmodules?: boolean;
298
+ };
299
+
300
+ export async function readGitStatus(
301
+ cwd: string,
302
+ options: ReadGitStatusOptions = {},
303
+ ): Promise<GitReadResult> {
304
+ const readExactTag = options.readExactTag === true;
305
+ const readMetrics = options.readMetrics === true;
306
+ try {
307
+ const numstatArgs = ["diff", "HEAD", "--numstat"];
308
+ if (options.ignoreSubmodules) numstatArgs.push("--ignore-submodules=all");
309
+ const [{ stdout: statusStdout }, stashResult, tagResult, metricsResult] = await Promise.all([
310
+ execFileAsync("git", ["status", "--porcelain=2", "--branch"], {
311
+ cwd,
312
+ timeout: GIT_COMMAND_TIMEOUT_MS,
313
+ }),
314
+ execFileAsync("git", ["stash", "list"], {
315
+ cwd,
316
+ timeout: GIT_COMMAND_TIMEOUT_MS,
317
+ }).catch(() => ({ stdout: "" })),
318
+ readExactTag
319
+ ? execFileAsync("git", ["describe", "--tags", "--exact-match", "HEAD"], {
320
+ cwd,
321
+ timeout: GIT_COMMAND_TIMEOUT_MS,
322
+ }).then(
323
+ (r) => ({ stdout: typeof r.stdout === "string" ? r.stdout : String(r.stdout) }),
324
+ () => ({ stdout: "" }),
325
+ )
326
+ : Promise.resolve({ stdout: "" }),
327
+ readMetrics
328
+ ? execFileAsync("git", numstatArgs, {
329
+ cwd,
330
+ timeout: GIT_COMMAND_TIMEOUT_MS,
331
+ }).then(
332
+ (r) => ({ stdout: typeof r.stdout === "string" ? r.stdout : String(r.stdout) }),
333
+ () => ({ stdout: "", failed: true as const }),
334
+ )
335
+ : Promise.resolve({ stdout: "", failed: true as const }),
336
+ ]);
337
+ const stdoutText = typeof statusStdout === "string" ? statusStdout : String(statusStdout);
338
+ const stashStdout =
339
+ typeof stashResult.stdout === "string" ? stashResult.stdout : String(stashResult.stdout);
340
+ const stashCount = stashStdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
341
+ const status = parseGitStatusPorcelain(stdoutText, stashCount);
342
+ if (status.commit) {
343
+ const tagStdout =
344
+ typeof tagResult.stdout === "string" ? tagResult.stdout : String(tagResult.stdout);
345
+ const tag = tagStdout.trim();
346
+ status.commit = { ...status.commit, tag: tag || null };
347
+ }
348
+ if (readMetrics) {
349
+ if ("failed" in metricsResult && metricsResult.failed) {
350
+ status.metrics = null;
351
+ } else {
352
+ const metricsStdout =
353
+ typeof metricsResult.stdout === "string"
354
+ ? metricsResult.stdout
355
+ : String(metricsResult.stdout);
356
+ status.metrics = parseGitNumstat(metricsStdout);
357
+ }
358
+ }
359
+ const operation = await readGitOperationState(cwd);
360
+ return {
361
+ kind: "ok",
362
+ status: {
363
+ ...status,
364
+ ...operation,
365
+ },
366
+ };
367
+ } catch (error) {
368
+ if (isNotARepoError(error)) return { kind: "not_a_repo" };
369
+
370
+ // Distinguish not-a-repo vs transient with a cheap rev-parse on the error path.
371
+ try {
372
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--is-inside-work-tree"], {
373
+ cwd,
374
+ timeout: GIT_COMMAND_TIMEOUT_MS,
375
+ });
376
+ const inside = (typeof stdout === "string" ? stdout : String(stdout)).trim();
377
+ if (inside !== "true") return { kind: "not_a_repo" };
378
+ return { kind: "error" };
379
+ } catch (inner) {
380
+ if (isNotARepoError(inner)) return { kind: "not_a_repo" };
381
+ return { kind: "error" };
382
+ }
383
+ }
384
+ }
@@ -0,0 +1,70 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ export type RGB = readonly [number, number, number];
4
+
5
+ export const SAKURA_MACARON_GRADIENT = "sakura-macaron-gradient";
6
+ export const SAKURA_MACARON_STOPS: readonly RGB[] = [
7
+ [136, 184, 255], // sakura pink #88B8FF
8
+ [125, 228, 255], // sakura-iro #7DE4FF
9
+ [188, 167, 255], // petal #BCA7FF
10
+ [188, 167, 255], // lavender #BCA7FF
11
+ [125, 228, 255], // sky macaron #7DE4FF
12
+ ];
13
+
14
+ const RESET = "\x1b[0m";
15
+ const GRADIENT_CACHE_LIMIT = 128;
16
+ const gradientCache = new Map<string, string>();
17
+
18
+ function mix(from: RGB, to: RGB, amount: number): RGB {
19
+ const t = Math.max(0, Math.min(1, amount));
20
+ return [
21
+ Math.round(from[0] + (to[0] - from[0]) * t),
22
+ Math.round(from[1] + (to[1] - from[1]) * t),
23
+ Math.round(from[2] + (to[2] - from[2]) * t),
24
+ ];
25
+ }
26
+
27
+ function sampleGradient(position: number): RGB {
28
+ const stops = SAKURA_MACARON_STOPS;
29
+ const normalized = Math.max(0, Math.min(1, position));
30
+ const scaled = normalized * (stops.length - 1);
31
+ const index = Math.min(stops.length - 2, Math.floor(scaled));
32
+ const from = stops[index] ?? SAKURA_MACARON_STOPS[0] ?? [136, 184, 255];
33
+ const to = stops[index + 1] ?? from;
34
+ return mix(from, to, scaled - index);
35
+ }
36
+
37
+ function foreground(color: RGB, text: string): string {
38
+ return `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}`;
39
+ }
40
+
41
+ /** Render the stable Sakura → sky gradient used by the editor and sent prompts. */
42
+ export function renderSakuraGradient(text: string): string {
43
+ const cached = gradientCache.get(text);
44
+ if (cached !== undefined) return cached;
45
+ const chars = [...text];
46
+ if (chars.length === 0) return text;
47
+ const span = Math.max(1, chars.length - 1);
48
+ const rendered = `${chars.map((char, index) => foreground(sampleGradient(index / span), char)).join("")}${RESET}`;
49
+ if (gradientCache.size >= GRADIENT_CACHE_LIMIT) {
50
+ gradientCache.delete(gradientCache.keys().next().value ?? "");
51
+ }
52
+ gradientCache.set(text, rendered);
53
+ return rendered;
54
+ }
55
+
56
+ /** Add symmetric colored side rails while preserving the terminal width contract. */
57
+ export function renderBoxedLine(
58
+ line: string,
59
+ width: number,
60
+ leftRail: string,
61
+ rightRail: string,
62
+ ): string {
63
+ if (width <= 0) return "";
64
+ const leftWidth = visibleWidth(leftRail);
65
+ const rightWidth = visibleWidth(rightRail);
66
+ const innerWidth = Math.max(0, width - leftWidth - rightWidth);
67
+ const content = truncateToWidth(line, innerWidth, "");
68
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
69
+ return truncateToWidth(`${leftRail}${content}${padding}${rightRail}`, width, "");
70
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Icon mode defaults and resolvers.
3
+ *
4
+ * Nerd defaults must stay byte-identical to historical `defaultConfig.icons`,
5
+ * except where intentionally changed to match the Starship Nerd Font preset.
6
+ * User string overrides always win over mode defaults.
7
+ */
8
+
9
+ export type IconMode = "auto" | "nerd" | "ascii";
10
+
11
+ export type IconGlyphs = {
12
+ cwd: string;
13
+ git: string;
14
+ ahead: string;
15
+ behind: string;
16
+ diverged: string;
17
+ conflicted: string;
18
+ untracked: string;
19
+ stashed: string;
20
+ modified: string;
21
+ staged: string;
22
+ renamed: string;
23
+ deleted: string;
24
+ typechanged: string;
25
+ cacheHit: string;
26
+ editorPrompt: string;
27
+ rail: string;
28
+ username: string;
29
+ time: string;
30
+ os: string;
31
+ package: string;
32
+ };
33
+
34
+ export type ResolvedIcons = IconGlyphs & { mode: IconMode };
35
+
36
+ export const ICON_GLYPH_KEYS = [
37
+ "cwd",
38
+ "git",
39
+ "ahead",
40
+ "behind",
41
+ "diverged",
42
+ "conflicted",
43
+ "untracked",
44
+ "stashed",
45
+ "modified",
46
+ "staged",
47
+ "renamed",
48
+ "deleted",
49
+ "typechanged",
50
+ "cacheHit",
51
+ "editorPrompt",
52
+ "rail",
53
+ "username",
54
+ "time",
55
+ "os",
56
+ "package",
57
+ ] as const satisfies readonly (keyof IconGlyphs)[];
58
+
59
+ /**
60
+ * Nerd Font defaults.
61
+ *
62
+ * The `cwd` icon is intentionally empty — Starship's `directory` module
63
+ * has no default symbol. Other defaults match historical values; new
64
+ * additions (e.g. `package`) come from the Starship Nerd Font preset
65
+ * (https://starship.rs/presets/nerd-font).
66
+ */
67
+ export const NERD_DEFAULT_ICONS: IconGlyphs = {
68
+ cwd: "",
69
+ git: "",
70
+ ahead: "↑",
71
+ behind: "↓",
72
+ diverged: "⇕",
73
+ conflicted: "=",
74
+ untracked: "?",
75
+ stashed: "$",
76
+ modified: "!",
77
+ staged: "+",
78
+ renamed: "»",
79
+ deleted: "✘",
80
+ typechanged: "T",
81
+ cacheHit: "󰆼",
82
+ editorPrompt: "",
83
+ rail: "│",
84
+ username: "",
85
+ time: "",
86
+ os: "",
87
+ // Starship Nerd Font preset — `package` module glyph.
88
+ package: "",
89
+ };
90
+
91
+ export const ASCII_DEFAULT_ICONS: IconGlyphs = {
92
+ cwd: "",
93
+ git: "*",
94
+ ahead: "^",
95
+ behind: "v",
96
+ diverged: "^v",
97
+ conflicted: "=",
98
+ untracked: "?",
99
+ stashed: "$",
100
+ modified: "!",
101
+ staged: "+",
102
+ renamed: ">",
103
+ deleted: "x",
104
+ typechanged: "T",
105
+ cacheHit: "c",
106
+ editorPrompt: "",
107
+ rail: "|",
108
+ username: "@",
109
+ time: "t",
110
+ os: "o",
111
+ package: "pkg",
112
+ };
113
+
114
+ export const OS_PLATFORM_ICONS_NERD: Record<string, string> = {
115
+ darwin: "\uf179",
116
+ linux: "\uf17c",
117
+ win32: "\uf17a",
118
+ };
119
+
120
+ export const OS_PLATFORM_ICONS_ASCII: Record<string, string> = {
121
+ darwin: "mac",
122
+ linux: "linux",
123
+ win32: "win",
124
+ };
125
+
126
+ /** Short ASCII labels keyed by runtime `name`. */
127
+ export const RUNTIME_ASCII_SYMBOLS: Record<string, string> = {
128
+ xmake: "xm",
129
+ maven: "mvn",
130
+ gradle: "grd",
131
+ bun: "bun",
132
+ deno: "deno",
133
+ lua: "lua",
134
+ nodejs: "node",
135
+ python: "py",
136
+ golang: "go",
137
+ rust: "rs",
138
+ java: "java",
139
+ ruby: "rb",
140
+ php: "php",
141
+ buf: "buf",
142
+ cmake: "cmake",
143
+ cpp: "c++",
144
+ c: "c",
145
+ cobol: "cob",
146
+ conda: "conda",
147
+ crystal: "cr",
148
+ dart: "dart",
149
+ dotnet: ".net",
150
+ elixir: "ex",
151
+ elm: "elm",
152
+ erlang: "erl",
153
+ fennel: "fnl",
154
+ fortran: "f90",
155
+ gleam: "glm",
156
+ guix_shell: "guix",
157
+ haskell: "hs",
158
+ haxe: "hx",
159
+ helm: "helm",
160
+ julia: "jl",
161
+ kotlin: "kt",
162
+ meson: "meson",
163
+ mojo: "mojo",
164
+ nim: "nim",
165
+ nix_shell: "nix",
166
+ ocaml: "ml",
167
+ odin: "odin",
168
+ opa: "opa",
169
+ perl: "pl",
170
+ pixi: "pixi",
171
+ pulumi: "pul",
172
+ purescript: "purs",
173
+ raku: "raku",
174
+ red: "red",
175
+ rlang: "R",
176
+ scala: "scala",
177
+ solidity: "sol",
178
+ spack: "spack",
179
+ swift: "swift",
180
+ terraform: "tf",
181
+ typst: "typ",
182
+ vagrant: "vag",
183
+ vlang: "v",
184
+ zig: "zig",
185
+ };
186
+
187
+ export function isIconMode(value: unknown): value is IconMode {
188
+ return value === "auto" || value === "nerd" || value === "ascii";
189
+ }
190
+
191
+ export function normalizeIconMode(value: unknown): IconMode {
192
+ return isIconMode(value) ? value : "auto";
193
+ }
194
+
195
+ export function modeDefaultIcons(mode: IconMode): IconGlyphs {
196
+ return mode === "ascii" ? { ...ASCII_DEFAULT_ICONS } : { ...NERD_DEFAULT_ICONS };
197
+ }
198
+
199
+ export function resolveConfiguredIcons(
200
+ mode: IconMode,
201
+ overrides: Partial<IconGlyphs> = {},
202
+ ): ResolvedIcons {
203
+ const base = modeDefaultIcons(mode);
204
+ const rail =
205
+ typeof overrides.rail === "string" && overrides.rail.trim().length > 0
206
+ ? overrides.rail
207
+ : base.rail;
208
+ return {
209
+ mode,
210
+ ...base,
211
+ ...overrides,
212
+ rail,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Honor a custom `icons.os` when it differs from the mode default.
218
+ * Otherwise map by platform for the active mode.
219
+ */
220
+ export function resolveOsIcon(
221
+ configuredOsIcon: string,
222
+ mode: IconMode = "auto",
223
+ platform: string = process.platform,
224
+ ): string {
225
+ const modeDefault = modeDefaultIcons(mode).os;
226
+ if (configuredOsIcon !== modeDefault) return configuredOsIcon;
227
+ const platformMap = mode === "ascii" ? OS_PLATFORM_ICONS_ASCII : OS_PLATFORM_ICONS_NERD;
228
+ return platformMap[platform] ?? configuredOsIcon;
229
+ }
230
+
231
+ export function resolveRuntimeSymbol(
232
+ name: string,
233
+ nerdSymbol: string,
234
+ mode: IconMode = "auto",
235
+ ): string {
236
+ if (mode !== "ascii") return nerdSymbol;
237
+ return RUNTIME_ASCII_SYMBOLS[name] ?? (name.slice(0, 3) || "*");
238
+ }
239
+
240
+ /**
241
+ * Resolve the package-version segment icon for the active mode.
242
+ *
243
+ * Honors a configured `icons.package` override; otherwise falls back to the
244
+ * mode default (Nerd Font preset / ASCII label).
245
+ */
246
+ export function resolvePackageIcon(configuredPackageIcon: string, mode: IconMode = "auto"): string {
247
+ const modeDefault = modeDefaultIcons(mode).package;
248
+ if (typeof configuredPackageIcon === "string" && configuredPackageIcon.length > 0) {
249
+ return configuredPackageIcon;
250
+ }
251
+ return modeDefault;
252
+ }