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.
- package/CHANGELOG.md +62 -0
- package/README.md +68 -0
- package/package.json +2 -1
- package/src/errors.ts +20 -2
- package/src/extension/knowledge-injection.ts +71 -0
- package/src/extension/pi-api.ts +47 -0
- package/src/extension/register.ts +19 -6
- package/src/extension/registration/commands.ts +40 -1
- package/src/extension/registration/compaction-guard.ts +154 -14
- package/src/extension/team-tool/handle-settings.ts +57 -0
- package/src/extension/team-tool/inspect.ts +4 -1
- package/src/extension/team-tool/plan.ts +8 -1
- package/src/runtime/intercom-bridge.ts +5 -1
- package/src/runtime/replace.ts +555 -0
- package/src/runtime/resilient-edit.ts +166 -0
- package/src/runtime/single-agent-compose.ts +87 -0
- package/src/runtime/task-runner/prompt-builder.ts +6 -0
- package/src/runtime/team-runner.ts +24 -7
- package/src/schema/team-tool-schema.ts +6 -0
- package/src/state/usage.ts +73 -0
- package/src/ui/card-colors.ts +126 -0
- package/src/ui/deploy-bundled-themes.ts +71 -0
- package/src/ui/render-diff.ts +37 -3
- package/src/ui/settings-overlay.ts +70 -7
- package/src/ui/syntax-highlight.ts +42 -23
- package/src/ui/theme-discovery.ts +188 -0
- package/src/ui/tool-renderers/index.ts +27 -14
- package/themes/crew-catppuccin-latte.json +96 -0
- package/themes/crew-catppuccin-mocha.json +93 -0
- package/themes/crew-dark.json +90 -0
- package/themes/crew-dracula.json +83 -0
- package/themes/crew-gruvbox-dark.json +83 -0
- package/themes/crew-gruvbox-light.json +90 -0
- package/themes/crew-nord.json +85 -0
- package/themes/crew-one-dark.json +89 -0
- package/themes/crew-solarized-dark.json +90 -0
- package/themes/crew-solarized-light.json +92 -0
- package/themes/crew-tokyo-night.json +85 -0
- package/src/extension/registration/brief-tool-overrides.ts +0 -400
- package/src/runtime/budget-tracker.ts +0 -354
- package/src/state/memory-store.ts +0 -244
package/src/ui/render-diff.ts
CHANGED
|
@@ -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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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}${
|
|
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
|
-
:
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────────
|