pi-crew 0.6.4 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +68 -0
  3. package/package.json +2 -1
  4. package/src/errors.ts +20 -2
  5. package/src/extension/knowledge-injection.ts +71 -0
  6. package/src/extension/pi-api.ts +47 -0
  7. package/src/extension/register.ts +19 -6
  8. package/src/extension/registration/commands.ts +40 -1
  9. package/src/extension/registration/compaction-guard.ts +154 -14
  10. package/src/extension/team-tool/handle-settings.ts +57 -0
  11. package/src/extension/team-tool/inspect.ts +4 -1
  12. package/src/extension/team-tool/plan.ts +8 -1
  13. package/src/runtime/intercom-bridge.ts +5 -1
  14. package/src/runtime/replace.ts +555 -0
  15. package/src/runtime/resilient-edit.ts +166 -0
  16. package/src/runtime/single-agent-compose.ts +87 -0
  17. package/src/runtime/task-runner/prompt-builder.ts +6 -0
  18. package/src/runtime/team-runner.ts +24 -7
  19. package/src/schema/team-tool-schema.ts +6 -0
  20. package/src/state/usage.ts +73 -0
  21. package/src/ui/card-colors.ts +126 -0
  22. package/src/ui/deploy-bundled-themes.ts +71 -0
  23. package/src/ui/render-diff.ts +37 -3
  24. package/src/ui/settings-overlay.ts +70 -7
  25. package/src/ui/syntax-highlight.ts +42 -23
  26. package/src/ui/theme-discovery.ts +188 -0
  27. package/src/ui/tool-renderers/index.ts +27 -14
  28. package/themes/crew-catppuccin-latte.json +96 -0
  29. package/themes/crew-catppuccin-mocha.json +93 -0
  30. package/themes/crew-dark.json +90 -0
  31. package/themes/crew-dracula.json +83 -0
  32. package/themes/crew-gruvbox-dark.json +83 -0
  33. package/themes/crew-gruvbox-light.json +90 -0
  34. package/themes/crew-nord.json +85 -0
  35. package/themes/crew-one-dark.json +89 -0
  36. package/themes/crew-solarized-dark.json +90 -0
  37. package/themes/crew-solarized-light.json +92 -0
  38. package/themes/crew-tokyo-night.json +85 -0
  39. package/src/extension/registration/brief-tool-overrides.ts +0 -400
  40. package/src/runtime/budget-tracker.ts +0 -354
  41. 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,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 };
@@ -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
+ }
@@ -102,16 +102,25 @@ function progressBar(ratio: number, barWidth: number, theme: CrewTheme): string
102
102
  * With renderShell="self", Pi no longer wraps in Box(1,1).
103
103
  * Frame uses full totalWidth.
104
104
  */
105
- function buildFrame(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
105
+ import { deriveCardBackground, padWithBackground } from "../card-colors.ts";
106
+
107
+ /** Create a rounded-corner framed card.
108
+ * With renderShell="self", Pi no longer wraps in Box(1,1).
109
+ * Frame uses full totalWidth.
110
+ * When bgSlot is provided, the interior is filled with a subtle status-tinted
111
+ * background derived from the theme (mixBg at 8% intensity).
112
+ */
113
+ function buildFrame(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border", bgSlot?: "success" | "error" | "border" | "borderAccent"): string {
106
114
  const frameW = totalWidth - 2; // available after Box(1,1) padding
107
115
  const innerW = frameW - 2; // │ chars
108
116
  const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
109
117
  const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
110
118
  const v = theme.fg(borderSlot, "│");
119
+ const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
111
120
 
112
121
  const lines: string[] = [top];
113
122
  for (const line of contentLines) {
114
- const padded = padVisual(line, innerW);
123
+ const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
115
124
  lines.push(v + padded + v);
116
125
  }
117
126
  lines.push(bottom);
@@ -120,28 +129,32 @@ function buildFrame(contentLines: string[], totalWidth: number, theme: CrewTheme
120
129
 
121
130
  /** Build frame TOP: top border + content lines, NO bottom border.
122
131
  * Pairs with buildFrameBottom() so renderCall + renderResult merge into ONE frame. */
123
- function buildFrameTop(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
132
+ function buildFrameTop(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border", bgSlot?: "success" | "error" | "border" | "borderAccent"): string {
124
133
  const frameW = totalWidth - 2;
125
134
  const innerW = frameW - 2;
126
135
  const top = theme.fg(borderSlot, `╭${"─".repeat(innerW)}╮`);
127
136
  const v = theme.fg(borderSlot, "│");
137
+ const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
128
138
  const lines: string[] = [top];
129
139
  for (const line of contentLines) {
130
- lines.push(v + padVisual(line, innerW) + v);
140
+ const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
141
+ lines.push(v + padded + v);
131
142
  }
132
143
  return lines.join("\n");
133
144
  }
134
145
 
135
146
  /** Build frame BOTTOM: content lines + bottom border, NO top border.
136
147
  * Pairs with buildFrameTop() so renderCall + renderResult merge into ONE frame. */
137
- function buildFrameBottom(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border"): string {
148
+ function buildFrameBottom(contentLines: string[], totalWidth: number, theme: CrewTheme, borderSlot: "success" | "error" | "border" | "borderAccent" = "border", bgSlot?: "success" | "error" | "border" | "borderAccent"): string {
138
149
  const frameW = totalWidth - 2;
139
150
  const innerW = frameW - 2;
140
151
  const v = theme.fg(borderSlot, "│");
141
152
  const bottom = theme.fg(borderSlot, `╰${"─".repeat(innerW)}╯`);
153
+ const bg = bgSlot ? deriveCardBackground(theme, bgSlot) : "";
142
154
  const lines: string[] = [];
143
155
  for (const line of contentLines) {
144
- lines.push(v + padVisual(line, innerW) + v);
156
+ const padded = bg ? padWithBackground(line, innerW, bg) : padVisual(line, innerW);
157
+ lines.push(v + padded + v);
145
158
  }
146
159
  lines.push(bottom);
147
160
  return lines.join("\n");
@@ -180,7 +193,7 @@ export const teamToolRenderer: ToolRenderer = {
180
193
  contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
181
194
  }
182
195
 
183
- return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx)), 0, 0);
196
+ return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx), borderFromContext(ctx)), 0, 0);
184
197
  },
185
198
 
186
199
  renderResult(result, _options, theme, ctx) {
@@ -238,7 +251,7 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
238
251
  }
239
252
 
240
253
  if (contentLines.length > 0) {
241
- return buildFrameBottom(contentLines, w, theme, "borderAccent");
254
+ return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
242
255
  }
243
256
  }
244
257
 
@@ -247,7 +260,7 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
247
260
  if (isBrief() && !ctx.expanded && action !== "run") {
248
261
  const briefText = briefToolResult("team", result as { content?: unknown[] }, theme);
249
262
  contentLines.push(padVisual(` ${briefText}`, innerW));
250
- return buildFrameBottom(contentLines, w, theme, bColor);
263
+ return buildFrameBottom(contentLines, w, theme, bColor, bColor);
251
264
  }
252
265
 
253
266
  if (!ctx.expanded) {
@@ -277,7 +290,7 @@ function renderTeamResult(result: Record<string, unknown>, options: unknown, the
277
290
  }
278
291
  }
279
292
 
280
- return buildFrameBottom(contentLines, w, theme, bColor);
293
+ return buildFrameBottom(contentLines, w, theme, bColor, bColor);
281
294
  }
282
295
 
283
296
  // ── Agent Tool Renderer ────────────────────────────────────────────────
@@ -301,7 +314,7 @@ export const agentToolRenderer: ToolRenderer = {
301
314
  contentLines.push(padVisual(` ${theme.fg("dim", previewText)}`, innerW));
302
315
  }
303
316
 
304
- return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx)), 0, 0);
317
+ return new Text(buildFrameTop(contentLines, w, theme, borderFromContext(ctx), borderFromContext(ctx)), 0, 0);
305
318
  },
306
319
 
307
320
  renderResult(result, _options, theme, ctx) {
@@ -330,14 +343,14 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
330
343
  const spinner = theme.fg("warning", "◉");
331
344
  const label = theme.fg("muted", "agent working...");
332
345
  contentLines.push(padVisual(` ${spinner} ${label}`, innerW));
333
- return buildFrameBottom(contentLines, w, theme, "borderAccent");
346
+ return buildFrameBottom(contentLines, w, theme, "borderAccent", "borderAccent");
334
347
  }
335
348
 
336
349
  // Brief mode: non-agent results get brief treatment
337
350
  if (!results?.length && !d.agentId) {
338
351
  const briefText = briefToolResult("agent", result as { content?: unknown[] }, theme);
339
352
  contentLines.push(padVisual(` ${briefText}`, innerW));
340
- return buildFrameBottom(contentLines, w, theme, bColor);
353
+ return buildFrameBottom(contentLines, w, theme, bColor, bColor);
341
354
  }
342
355
 
343
356
  if (!ctx.expanded) {
@@ -391,7 +404,7 @@ function renderAgentResult(result: Record<string, unknown>, options: unknown, th
391
404
  }
392
405
  }
393
406
 
394
- return buildFrameBottom(contentLines, w, theme, bColor);
407
+ return buildFrameBottom(contentLines, w, theme, bColor, bColor);
395
408
  }
396
409
 
397
410
  // ── Card builders ──────────────────────────────────────────────────────