pi-crew 0.6.3 → 0.7.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 (56) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +80 -1
  3. package/docs/ui-optimization-plan.md +447 -0
  4. package/package.json +2 -1
  5. package/src/extension/knowledge-injection.ts +71 -0
  6. package/src/extension/pi-api.ts +47 -0
  7. package/src/extension/register.ts +32 -1
  8. package/src/extension/registration/commands.ts +65 -1
  9. package/src/extension/registration/compaction-guard.ts +154 -14
  10. package/src/extension/registration/subagent-tools.ts +8 -3
  11. package/src/extension/registration/team-tool.ts +18 -11
  12. package/src/extension/team-tool/handle-settings.ts +57 -0
  13. package/src/extension/team-tool/inspect.ts +4 -1
  14. package/src/extension/team-tool/plan.ts +8 -1
  15. package/src/extension/team-tool/run.ts +4 -3
  16. package/src/extension/team-tool-types.ts +2 -0
  17. package/src/runtime/intercom-bridge.ts +5 -1
  18. package/src/runtime/replace.ts +555 -0
  19. package/src/runtime/resilient-edit.ts +166 -0
  20. package/src/runtime/single-agent-compose.ts +87 -0
  21. package/src/runtime/team-runner.ts +23 -6
  22. package/src/schema/team-tool-schema.ts +6 -0
  23. package/src/state/session-state-map.ts +51 -0
  24. package/src/state/usage.ts +73 -0
  25. package/src/ui/card-colors.ts +126 -0
  26. package/src/ui/deploy-bundled-themes.ts +71 -0
  27. package/src/ui/powerbar-publisher.ts +1 -1
  28. package/src/ui/render-diff.ts +37 -3
  29. package/src/ui/settings-overlay.ts +70 -7
  30. package/src/ui/status-colors.ts +5 -1
  31. package/src/ui/syntax-highlight.ts +42 -23
  32. package/src/ui/theme-adapter.ts +80 -1
  33. package/src/ui/theme-discovery.ts +188 -0
  34. package/src/ui/tool-progress-formatter.ts +9 -5
  35. package/src/ui/tool-render.ts +4 -0
  36. package/src/ui/tool-renderers/brief-mode.ts +207 -0
  37. package/src/ui/tool-renderers/index.ts +640 -0
  38. package/src/ui/widget/index.ts +224 -0
  39. package/src/ui/widget/widget-formatters.ts +148 -0
  40. package/src/ui/widget/widget-model.ts +90 -0
  41. package/src/ui/widget/widget-renderer.ts +130 -0
  42. package/src/ui/widget/widget-types.ts +37 -0
  43. package/src/utils/guards.ts +110 -0
  44. package/themes/crew-catppuccin-latte.json +96 -0
  45. package/themes/crew-catppuccin-mocha.json +93 -0
  46. package/themes/crew-dark.json +90 -0
  47. package/themes/crew-dracula.json +83 -0
  48. package/themes/crew-gruvbox-dark.json +83 -0
  49. package/themes/crew-gruvbox-light.json +90 -0
  50. package/themes/crew-nord.json +85 -0
  51. package/themes/crew-one-dark.json +89 -0
  52. package/themes/crew-solarized-dark.json +90 -0
  53. package/themes/crew-solarized-light.json +92 -0
  54. package/themes/crew-tokyo-night.json +85 -0
  55. package/src/runtime/budget-tracker.ts +0 -354
  56. package/src/state/memory-store.ts +0 -244
@@ -22,6 +22,32 @@ export function replaceTabs(text: string): string {
22
22
  return text.replace(/\t/g, " ");
23
23
  }
24
24
 
25
+ /**
26
+ * Minimum similarity (fraction of unchanged chars) required before applying
27
+ * word-level intra-line diff highlighting. Below this, the two lines are
28
+ * considered unrelated and rendered plainly to avoid noisy full-line
29
+ * inverse-video (e.g. an empty line replaced by a large block of code).
30
+ */
31
+ const WORD_DIFF_MIN_SIM = 0.15;
32
+
33
+ /**
34
+ * Compute the similarity between two strings as the fraction of unchanged
35
+ * characters relative to the longer of the two strings, using word-level
36
+ * diff to identify the common (unchanged) parts. Returns a value in [0, 1].
37
+ */
38
+ function computeSimilarity(oldContent: string, newContent: string): number {
39
+ const wordDiff = Diff.diffWords(oldContent, newContent);
40
+ let commonChars = 0;
41
+ for (const part of wordDiff) {
42
+ if (!part.removed && !part.added) {
43
+ commonChars += part.value.length;
44
+ }
45
+ }
46
+ const maxLen = Math.max(oldContent.length, newContent.length);
47
+ if (maxLen === 0) return 1; // both empty -> identical
48
+ return commonChars / maxLen;
49
+ }
50
+
25
51
  function renderIntraLineDiff(theme: CrewTheme, oldContent: string, newContent: string): { removedLine: string; addedLine: string } {
26
52
  const wordDiff = Diff.diffWords(oldContent, newContent);
27
53
  let removedLine = "";
@@ -95,9 +121,17 @@ export function renderDiff(diffText: string, options: RenderDiffOptions = {}): s
95
121
  }
96
122
 
97
123
  if (removedLines.length === 1 && addedLines.length === 1) {
98
- const { removedLine, addedLine } = renderIntraLineDiff(theme, replaceTabs(removedLines[0]!.content), replaceTabs(addedLines[0]!.content));
99
- result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
100
- result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
124
+ const oldContent = replaceTabs(removedLines[0]!.content);
125
+ const newContent = replaceTabs(addedLines[0]!.content);
126
+ const similarity = computeSimilarity(oldContent, newContent);
127
+ if (similarity >= WORD_DIFF_MIN_SIM) {
128
+ const { removedLine, addedLine } = renderIntraLineDiff(theme, oldContent, newContent);
129
+ result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${removedLine}`));
130
+ result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${addedLine}`));
131
+ } else {
132
+ result.push(theme.fg("toolDiffRemoved", `-${removedLines[0]!.lineNum} ${oldContent}`));
133
+ result.push(theme.fg("toolDiffAdded", `+${addedLines[0]!.lineNum} ${newContent}`));
134
+ }
101
135
  } else {
102
136
  for (const removed of removedLines) {
103
137
  result.push(theme.fg("toolDiffRemoved", `-${removed.lineNum} ${replaceTabs(removed.content)}`));
@@ -5,12 +5,13 @@
5
5
  */
6
6
  import type { CrewTheme } from "./theme-adapter.ts";
7
7
  import { DynamicCrewBorder } from "./dynamic-border.ts";
8
+ import { discoverPiThemes, getActivePiTheme } from "./theme-discovery.ts";
8
9
 
9
10
  // ---------------------------------------------------------------------------
10
11
  // Types
11
12
  // ---------------------------------------------------------------------------
12
13
 
13
- export type SettingType = "boolean" | "enum" | "number" | "string" | "agent";
14
+ export type SettingType = "boolean" | "enum" | "number" | "string" | "agent" | "action";
14
15
 
15
16
  export interface SettingDef {
16
17
  id: string;
@@ -21,11 +22,16 @@ export interface SettingDef {
21
22
  values?: string[];
22
23
  /** Tab grouping */
23
24
  tab: string;
25
+ /** For type="action": identifier dispatched to onAction (e.g. "piTheme"). */
26
+ action?: string;
24
27
  }
25
28
 
26
29
  export interface SettingsOverlayCallbacks {
27
30
  onChange: (id: string, value: unknown) => void;
28
31
  onClose: () => void;
32
+ /** Dispatched for type="action" settings (e.g. Pi theme switch, which
33
+ * writes to settings.json rather than pi-crew config). Optional. */
34
+ onAction?: (action: string, value: unknown) => void;
29
35
  }
30
36
 
31
37
  interface TabDef {
@@ -43,6 +49,7 @@ const TABS: TabDef[] = [
43
49
  { id: "limits", label: "Limits", icon: "📐" },
44
50
  { id: "agents", label: "Agents", icon: "🤖" },
45
51
  { id: "ui", label: "UI", icon: "🖥" },
52
+ { id: "themes", label: "Themes", icon: "🎨" },
46
53
  { id: "autonomous", label: "Auto", icon: "🚀" },
47
54
  { id: "advanced", label: "Advanced", icon: "🔧" },
48
55
  ];
@@ -74,6 +81,8 @@ const SETTINGS: SettingDef[] = [
74
81
  { id: "ui.dashboardWidth", label: "Dashboard Width", type: "number", tab: "ui", description: "Dashboard width as percentage or pixels." },
75
82
  { id: "ui.autoOpenDashboard", label: "Auto Open Dashboard", type: "boolean", tab: "ui", description: "Auto-open dashboard when a run starts." },
76
83
  { id: "ui.widgetPlacement", label: "Widget Placement", type: "enum", values: ["bottom", "hidden"], tab: "ui", description: "Where to place the crew widget." },
84
+ // ── Themes tab ──
85
+ { id: "__piTheme__", label: "Pi UI Theme", type: "action", action: "piTheme", values: discoverPiThemes().map((t) => t.name), tab: "themes", description: "Overall terminal theme. Switches live (no restart). Currently: " + (getActivePiTheme() ?? "dark (default)") },
77
86
  // Autonomous
78
87
  { id: "autonomous.enabled", label: "Enabled", type: "boolean", tab: "autonomous", description: "Enable autonomous pi-crew delegation." },
79
88
  { id: "autonomous.injectPolicy", label: "Inject Policy", type: "boolean", tab: "autonomous", description: "Inject delegation policy into agent context." },
@@ -196,12 +205,21 @@ function isExplicitlySet(config: Record<string, unknown>, id: string): boolean {
196
205
  return getNestedValue(config, id) !== undefined;
197
206
  }
198
207
 
208
+ /** Resolve the live current value for a setting, including special-case IDs
209
+ * (e.g. __piTheme__ which lives in Pi's settings.json, not pi-crew config). */
210
+ function currentValueFor(config: Record<string, unknown>, id: string): unknown {
211
+ if (id === "__piTheme__") return getActivePiTheme() ?? "dark";
212
+ return getNestedValue(config, id);
213
+ }
214
+
199
215
  // ---------------------------------------------------------------------------
200
216
  // Submenu: Select from list (enum picker)
201
217
  // ---------------------------------------------------------------------------
202
218
 
203
219
  class SelectSubmenu {
204
220
  private selectedIndex = 0;
221
+ private scrollOffset = 0;
222
+ private readonly maxVisible = 14;
205
223
  private readonly items: string[];
206
224
  private readonly theme: CrewTheme;
207
225
  private readonly onSelect: (value: string) => void;
@@ -217,35 +235,57 @@ class SelectSubmenu {
217
235
  this.onSelect = onSelect;
218
236
  this.onCancel = onCancel;
219
237
  this.selectedIndex = Math.max(0, options.indexOf(current));
238
+ // Center the cursor in the visible window on open.
239
+ this.scrollOffset = Math.max(0, this.selectedIndex - Math.floor(this.maxVisible / 2));
220
240
  }
221
241
 
222
242
  invalidate(): void {}
223
243
 
244
+ private ensureVisible(): void {
245
+ if (this.selectedIndex < this.scrollOffset) {
246
+ this.scrollOffset = this.selectedIndex;
247
+ } else if (this.selectedIndex >= this.scrollOffset + this.maxVisible) {
248
+ this.scrollOffset = this.selectedIndex - this.maxVisible + 1;
249
+ }
250
+ }
251
+
224
252
  render(width: number): string[] {
253
+ void width;
225
254
  const lines: string[] = [];
226
255
  lines.push(this.theme.bold(this.theme.fg("accent", this.title)));
227
256
  if (this.description) {
228
257
  lines.push(this.theme.fg("muted", this.description));
229
258
  }
230
259
  lines.push("");
231
- for (const [i, item] of this.items.entries()) {
260
+ const start = this.scrollOffset;
261
+ const end = Math.min(start + this.maxVisible, this.items.length);
262
+ const needsScroll = this.items.length > this.maxVisible;
263
+ if (needsScroll && start > 0) {
264
+ lines.push(this.theme.fg("dim", ` ▲ ${start} more above`));
265
+ }
266
+ for (let i = start; i < end; i++) {
232
267
  const isSelected = i === this.selectedIndex;
233
268
  const prefix = isSelected ? " → " : " ";
234
- const line = `${prefix}${item}`;
269
+ const line = `${prefix}${this.items[i]}`;
235
270
  lines.push(isSelected ? (this.theme.inverse?.(line) ?? line) : line);
236
271
  }
272
+ if (needsScroll && end < this.items.length) {
273
+ lines.push(this.theme.fg("dim", ` ▼ ${this.items.length - end} more below`));
274
+ }
237
275
  lines.push("");
238
- lines.push(this.theme.fg("dim", "Enter to select · Esc to go back"));
276
+ lines.push(this.theme.fg("dim", "↑↓ navigate · Enter to select · Esc to go back"));
239
277
  return lines;
240
278
  }
241
279
 
242
280
  handleInput(data: string): void {
243
281
  if (data === "\x1b[A" || data === "k") {
244
282
  this.selectedIndex = (this.selectedIndex - 1 + this.items.length) % this.items.length;
283
+ this.ensureVisible();
245
284
  return;
246
285
  }
247
286
  if (data === "\x1b[B" || data === "j") {
248
287
  this.selectedIndex = (this.selectedIndex + 1) % this.items.length;
288
+ this.ensureVisible();
249
289
  return;
250
290
  }
251
291
  if (data === "\r" || data === "\n") {
@@ -514,8 +554,8 @@ class SettingsOverlay {
514
554
 
515
555
  const effective = this.changedValues.has(def.id)
516
556
  ? this.changedValues.get(def.id)
517
- : getNestedValue(this.config, def.id);
518
- const isDefault = !this.changedValues.has(def.id) && !isExplicitlySet(this.config, def.id);
557
+ : currentValueFor(this.config, def.id);
558
+ const isDefault = !this.changedValues.has(def.id) && !isExplicitlySet(this.config, def.id) && def.id !== "__piTheme__";
519
559
  const valueStr = formatValue(effective, def.id);
520
560
  const suffix = isDefault && (effective !== undefined || EFFECTIVE_DEFAULTS[def.id] !== undefined) ? " (default)" : "";
521
561
 
@@ -696,6 +736,28 @@ class SettingsOverlay {
696
736
  );
697
737
  break;
698
738
  }
739
+ case "action": {
740
+ if (!def.values?.length || !def.action) break;
741
+ const actionCurrent = typeof this.changedValues.get(def.id) === "string"
742
+ ? (this.changedValues.get(def.id) as string)
743
+ : (currentValueFor(this.config, def.id) as string | undefined) ?? "";
744
+ this.submenuSettingId = def.id;
745
+ this.submenu = new SelectSubmenu(
746
+ def.label,
747
+ def.description ?? "",
748
+ def.values,
749
+ actionCurrent,
750
+ this.theme,
751
+ (value: string) => {
752
+ this.changedValues.set(def.id, value);
753
+ this.callbacks.onAction?.(def.action!, value);
754
+ this.submenu = null;
755
+ this.submenuSettingId = null;
756
+ },
757
+ () => { this.submenu = null; this.submenuSettingId = null; },
758
+ );
759
+ break;
760
+ }
699
761
  }
700
762
  }
701
763
 
@@ -717,7 +779,8 @@ export function createSettingsOverlay(
717
779
  theme: CrewTheme,
718
780
  onChange: (id: string, value: unknown) => void,
719
781
  done: () => void,
782
+ onAction?: (action: string, value: unknown) => void,
720
783
  ) {
721
- const overlay = new SettingsOverlay(config, theme, { onChange, onClose: done });
784
+ const overlay = new SettingsOverlay(config, theme, { onChange, onClose: done, onAction });
722
785
  return { overlay, component: overlay };
723
786
  }
@@ -1,6 +1,6 @@
1
1
  import type { CrewTheme, CrewThemeColor } from "./theme-adapter.ts";
2
2
 
3
- export type RunStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | (string & {});
3
+ export type RunStatus = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "stopped" | "blocked" | "stale" | "needs_attention" | (string & {});
4
4
 
5
5
  export function colorForStatus(status: RunStatus): CrewThemeColor {
6
6
  switch (status) {
@@ -17,6 +17,8 @@ export function colorForStatus(status: RunStatus): CrewThemeColor {
17
17
  case "blocked":
18
18
  case "stopped":
19
19
  return "warning";
20
+ case "needs_attention":
21
+ return "warning";
20
22
  case "queued":
21
23
  default:
22
24
  return "dim";
@@ -42,6 +44,8 @@ export function iconForStatus(status: RunStatus, options?: { runningGlyph?: stri
42
44
  return "◦";
43
45
  case "blocked":
44
46
  return "⏸";
47
+ case "needs_attention":
48
+ return "⚠";
45
49
  default:
46
50
  return "·";
47
51
  }
@@ -1,7 +1,16 @@
1
+ /**
2
+ * Syntax highlighting for pi-crew UI.
3
+ *
4
+ * Uses cli-highlight (sync) with the active Pi theme's syntax colors.
5
+ * Falls back to plain text (theme.fg on every line) if the language is
6
+ * unsupported or highlighting fails.
7
+ */
1
8
  import { supportsLanguage, highlight } from "cli-highlight";
2
9
  import type { CrewTheme } from "./theme-adapter.ts";
3
10
  import { asCrewTheme } from "./theme-adapter.ts";
4
11
 
12
+ // ── cli-highlight theme bridge ──────────────────────────────────────────
13
+
5
14
  function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => string> {
6
15
  return {
7
16
  keyword: (text) => theme.fg("syntaxKeyword", text),
@@ -22,13 +31,29 @@ function buildCliTheme(theme: CrewTheme): Record<string, (text: string) => strin
22
31
  };
23
32
  }
24
33
 
25
- /** @internal */
26
- function detectLanguageFromPath(filePath: string): string | undefined {
27
- const ext = filePath.split(".").pop()?.toLowerCase();
28
- if (!ext) return undefined;
29
- return languageMap[ext];
34
+ function hlCliSync(code: string, language: string | undefined, theme: CrewTheme): string {
35
+ if (!language) {
36
+ return code
37
+ .split("\n")
38
+ .map((line) => theme.fg("mdCodeBlock", line))
39
+ .join("\n");
40
+ }
41
+ try {
42
+ return highlight(code, {
43
+ language,
44
+ ignoreIllegals: true,
45
+ theme: buildCliTheme(theme),
46
+ }).trimEnd();
47
+ } catch {
48
+ return code
49
+ .split("\n")
50
+ .map((line) => theme.fg("mdCodeBlock", line))
51
+ .join("\n");
52
+ }
30
53
  }
31
54
 
55
+ // ── Language detection ──────────────────────────────────────────────────
56
+
32
57
  export const languageMap: Record<string, string> = {
33
58
  ts: "typescript",
34
59
  tsx: "typescript",
@@ -69,27 +94,19 @@ export const languageMap: Record<string, string> = {
69
94
  php: "php",
70
95
  };
71
96
 
97
+ /** @internal */
98
+ function detectLanguageFromPath(filePath: string): string | undefined {
99
+ const ext = filePath.split(".").pop()?.toLowerCase();
100
+ if (!ext) return undefined;
101
+ return languageMap[ext];
102
+ }
103
+
104
+ // ── Public API ──────────────────────────────────────────────────────────
105
+
72
106
  export function highlightCode(code: string, language: string | undefined, themeLike: unknown = undefined): string {
73
107
  const theme = asCrewTheme(themeLike);
74
108
  const validLanguage = language && supportsLanguage(language) ? language : undefined;
75
- if (!validLanguage) {
76
- return code
77
- .split("\n")
78
- .map((line) => theme.fg("mdCodeBlock", line))
79
- .join("\n");
80
- }
81
- try {
82
- return highlight(code, {
83
- language: validLanguage,
84
- ignoreIllegals: true,
85
- theme: buildCliTheme(theme),
86
- }).trimEnd();
87
- } catch {
88
- return code
89
- .split("\n")
90
- .map((line) => theme.fg("mdCodeBlock", line))
91
- .join("\n");
92
- }
109
+ return hlCliSync(code, validLanguage, theme);
93
110
  }
94
111
 
95
112
  export function highlightJson(payload: string, themeLike: unknown = undefined): string {
@@ -115,3 +132,5 @@ export function highlightJson(payload: string, themeLike: unknown = undefined):
115
132
  }
116
133
  }
117
134
  }
135
+
136
+ export { detectLanguageFromPath };
@@ -9,9 +9,22 @@ export type CrewThemeColor =
9
9
  | "muted"
10
10
  | "dim"
11
11
  | "text"
12
+ | "thinkingText"
13
+ // Tool rendering
14
+ | "toolTitle"
15
+ | "toolOutput"
12
16
  | "toolDiffAdded"
13
17
  | "toolDiffRemoved"
14
18
  | "toolDiffContext"
19
+ // Markdown
20
+ | "mdHeading"
21
+ | "mdLink"
22
+ | "mdCode"
23
+ | "mdCodeBlock"
24
+ | "mdQuote"
25
+ | "mdHr"
26
+ | "mdListBullet"
27
+ // Syntax highlighting
15
28
  | "syntaxKeyword"
16
29
  | "syntaxString"
17
30
  | "syntaxNumber"
@@ -21,15 +34,81 @@ export type CrewThemeColor =
21
34
  | "syntaxType"
22
35
  | "syntaxOperator"
23
36
  | "syntaxPunctuation"
24
- | "mdCodeBlock";
37
+ // Message display
38
+ | "userMessageText"
39
+ | "customMessageLabel"
40
+ // Thinking gradient (6 levels, low→high intensity)
41
+ | "thinkingOff"
42
+ | "thinkingMinimal"
43
+ | "thinkingLow"
44
+ | "thinkingMedium"
45
+ | "thinkingHigh"
46
+ | "thinkingXhigh"
47
+ // Special
48
+ | "bashMode";
25
49
 
26
50
  export type CrewThemeBg =
27
51
  | "selectedBg"
28
52
  | "userMessageBg"
53
+ | "customMessageBg"
29
54
  | "toolPendingBg"
30
55
  | "toolSuccessBg"
31
56
  | "toolErrorBg";
32
57
 
58
+ /** ANSI fallback values for theme color slots when the active theme doesn't define them. */
59
+ export const THEME_COLOR_FALLBACKS: Record<CrewThemeColor, string> = {
60
+ accent: "\x1b[36m",
61
+ border: "\x1b[38;5;240m",
62
+ borderAccent: "\x1b[35m",
63
+ borderMuted: "\x1b[38;5;236m",
64
+ success: "\x1b[32m",
65
+ error: "\x1b[31m",
66
+ warning: "\x1b[33m",
67
+ muted: "\x1b[38;5;245m",
68
+ dim: "\x1b[38;5;240m",
69
+ text: "\x1b[39m",
70
+ thinkingText: "\x1b[38;5;245m",
71
+ toolTitle: "\x1b[36m",
72
+ toolOutput: "\x1b[38;5;245m",
73
+ toolDiffAdded: "\x1b[32m",
74
+ toolDiffRemoved: "\x1b[31m",
75
+ toolDiffContext: "\x1b[38;5;245m",
76
+ mdHeading: "\x1b[33m",
77
+ mdLink: "\x1b[35m",
78
+ mdCode: "\x1b[32m",
79
+ mdCodeBlock: "\x1b[39m",
80
+ mdQuote: "\x1b[38;5;245m",
81
+ mdHr: "\x1b[38;5;240m",
82
+ mdListBullet: "\x1b[36m",
83
+ syntaxKeyword: "\x1b[35m",
84
+ syntaxString: "\x1b[32m",
85
+ syntaxNumber: "\x1b[33m",
86
+ syntaxComment: "\x1b[38;5;245m",
87
+ syntaxFunction: "\x1b[36m",
88
+ syntaxVariable: "\x1b[39m",
89
+ syntaxType: "\x1b[35m",
90
+ syntaxOperator: "\x1b[35m",
91
+ syntaxPunctuation: "\x1b[35m",
92
+ userMessageText: "\x1b[39m",
93
+ customMessageLabel: "\x1b[35m",
94
+ thinkingOff: "\x1b[38;5;236m",
95
+ thinkingMinimal: "\x1b[38;5;245m",
96
+ thinkingLow: "\x1b[35m",
97
+ thinkingMedium: "\x1b[35m",
98
+ thinkingHigh: "\x1b[36m",
99
+ thinkingXhigh: "\x1b[35m",
100
+ bashMode: "\x1b[32m",
101
+ };
102
+
103
+ /** Map a thinking intensity level (0–5) to a theme color slot. */
104
+ export function thinkingColorForLevel(level: number): CrewThemeColor {
105
+ const slots: CrewThemeColor[] = [
106
+ "thinkingOff", "thinkingMinimal", "thinkingLow",
107
+ "thinkingMedium", "thinkingHigh", "thinkingXhigh",
108
+ ];
109
+ return slots[Math.min(Math.max(level, 0), 5)] ?? "thinkingOff";
110
+ }
111
+
33
112
  export interface CrewTheme {
34
113
  fg(color: CrewThemeColor, text: string): string;
35
114
  bg?(color: CrewThemeBg, text: string): string;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Pi UI theme discovery and selection.
3
+ *
4
+ * Exposes:
5
+ * - Pi UI theme discovery (builtins + custom ~/.pi/agent/themes/*.json)
6
+ * - The active Pi theme
7
+ * - setPiTheme() to persist a choice in ~/.pi/agent/settings.json
8
+ *
9
+ * Wired into the `team-settings themes` / `theme` subcommands.
10
+ */
11
+
12
+ export interface PiThemeInfo {
13
+ /** Theme name (filename stem or builtin id). */
14
+ name: string;
15
+ /** Where it comes from. */
16
+ source: "builtin" | "custom";
17
+ /** Absolute path to the .json file, if applicable. */
18
+ path?: string;
19
+ /** Human-friendly display name from the JSON `name` field, if present. */
20
+ displayName?: string;
21
+ /** "dark" or "light" — derived from background luminance. */
22
+ mode?: "dark" | "light";
23
+ }
24
+
25
+ /** Builtin Pi themes shipped with the pi-coding-agent package. */
26
+ const BUILTIN_PI_THEMES = ["dark", "light"];
27
+
28
+ /**
29
+ * Detect whether a theme is dark or light based on its background luminance.
30
+ * Reads vars.bg or export.pageBg or a resolved colors ref. Returns undefined
31
+ * if it can't be determined.
32
+ */
33
+ function detectThemeMode(json: Record<string, unknown>): "dark" | "light" | undefined {
34
+ const vars = json.vars as Record<string, string> | undefined;
35
+ const bgRaw = vars?.bg;
36
+ if (!bgRaw || !/^#[0-9a-fA-F]{6}$/.test(bgRaw)) return undefined;
37
+ const r = parseInt(bgRaw.slice(1, 3), 16);
38
+ const g = parseInt(bgRaw.slice(3, 5), 16);
39
+ const b = parseInt(bgRaw.slice(5, 7), 16);
40
+ // Relative luminance (ITU-R BT.709)
41
+ const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
42
+ return lum < 0.5 ? "dark" : "light";
43
+ }
44
+
45
+ function customThemesDir(): string {
46
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
47
+ return home ? `${home}/.pi/agent/themes` : "";
48
+ }
49
+
50
+ function settingsPath(): string {
51
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
52
+ return home ? `${home}/.pi/agent/settings.json` : "";
53
+ }
54
+
55
+ /** Discover all available Pi UI themes (builtins + custom). */
56
+ export function discoverPiThemes(): PiThemeInfo[] {
57
+ const out: PiThemeInfo[] = [];
58
+ const seen = new Set<string>();
59
+
60
+ // Builtins
61
+ for (const name of BUILTIN_PI_THEMES) {
62
+ if (seen.has(name)) continue;
63
+ seen.add(name);
64
+ out.push({ name, source: "builtin", displayName: name, mode: name === "light" ? "light" : "dark" });
65
+ }
66
+
67
+ // Custom themes from ~/.pi/agent/themes/
68
+ const dir = customThemesDir();
69
+ try {
70
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
71
+ const fs = require("node:fs");
72
+ if (dir && fs.existsSync(dir)) {
73
+ for (const file of fs.readdirSync(dir) as string[]) {
74
+ if (!file.endsWith(".json")) continue;
75
+ const name = file.slice(0, -5);
76
+ if (seen.has(name)) continue;
77
+ const fullPath = `${dir}/${file}`;
78
+ let displayName: string | undefined;
79
+ let mode: "dark" | "light" | undefined;
80
+ try {
81
+ const json = JSON.parse(fs.readFileSync(fullPath, "utf8"));
82
+ displayName = typeof json.name === "string" ? json.name : undefined;
83
+ mode = detectThemeMode(json);
84
+ } catch {
85
+ // keep undefined
86
+ }
87
+ seen.add(name);
88
+ out.push({ name, source: "custom", path: fullPath, displayName, mode });
89
+ }
90
+ }
91
+ } catch {
92
+ // directory unreadable — skip
93
+ }
94
+
95
+ return out.sort((a, b) => a.name.localeCompare(b.name));
96
+ }
97
+
98
+ /** Read the currently active Pi theme from ~/.pi/agent/settings.json. */
99
+ export function getActivePiTheme(): string | undefined {
100
+ try {
101
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
102
+ const fs = require("node:fs");
103
+ const p = settingsPath();
104
+ if (!p || !fs.existsSync(p)) return undefined;
105
+ const json = JSON.parse(fs.readFileSync(p, "utf8"));
106
+ return typeof json.theme === "string" ? json.theme : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ /** Persist a Pi theme choice in ~/.pi/agent/settings.json. Returns the path or throws. */
113
+ export function setPiTheme(name: string): string {
114
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
115
+ const fs = require("node:fs");
116
+ const p = settingsPath();
117
+ if (!p) throw new Error("Could not determine settings path (no HOME).");
118
+ let settings: Record<string, unknown> = {};
119
+ try {
120
+ if (fs.existsSync(p)) {
121
+ settings = JSON.parse(fs.readFileSync(p, "utf8"));
122
+ }
123
+ } catch {
124
+ // corrupt settings — start fresh
125
+ settings = {};
126
+ }
127
+ settings.theme = name;
128
+ fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf8");
129
+ return p;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Formatted listing for `team-settings themes`
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Build the full formatted listing of Pi UI themes for display.
138
+ * Shows available themes, the active selection, and switching instructions.
139
+ */
140
+ export function formatThemesListing(): string {
141
+ const piThemes = discoverPiThemes();
142
+ const activePi = getActivePiTheme();
143
+ const lines: string[] = [];
144
+
145
+ const darkThemes = piThemes.filter((t) => t.mode !== "light");
146
+ const lightThemes = piThemes.filter((t) => t.mode === "light");
147
+
148
+ lines.push("═══ Theme Gallery ═══");
149
+ lines.push("");
150
+
151
+ // ── Dark themes ──
152
+ if (darkThemes.length) {
153
+ lines.push("Dark themes:");
154
+ lines.push("");
155
+ for (const t of darkThemes) {
156
+ lines.push(themeLine(t, activePi));
157
+ }
158
+ lines.push("");
159
+ }
160
+
161
+ // ── Light themes ──
162
+ if (lightThemes.length) {
163
+ lines.push("Light themes:");
164
+ lines.push("");
165
+ for (const t of lightThemes) {
166
+ lines.push(themeLine(t, activePi));
167
+ }
168
+ lines.push("");
169
+ }
170
+
171
+ lines.push(" Switch (live, no restart): team-settings theme <name>");
172
+ lines.push(" Browse interactively: /team-settings → Themes tab");
173
+ lines.push("");
174
+
175
+ lines.push("Notes:");
176
+ lines.push(" • Switching applies live via ctx.ui.setTheme() (Pi redraws immediately).");
177
+ lines.push(" • Bundled crew-* themes deploy to ~/.pi/agent/themes/ on startup.");
178
+ lines.push(` • ${piThemes.length} themes available (${darkThemes.length} dark, ${lightThemes.length} light).`);
179
+
180
+ return lines.join("\n");
181
+ }
182
+
183
+ function themeLine(t: PiThemeInfo, active: string | undefined): string {
184
+ const isActive = t.name === active;
185
+ const tag = isActive ? " ← active" : "";
186
+ const src = t.source === "builtin" ? " (builtin)" : "";
187
+ return ` ${isActive ? "●" : "○"} ${t.name}${src}${tag}`;
188
+ }