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.
- package/CHANGELOG.md +84 -0
- package/README.md +80 -1
- package/docs/ui-optimization-plan.md +447 -0
- package/package.json +2 -1
- package/src/extension/knowledge-injection.ts +71 -0
- package/src/extension/pi-api.ts +47 -0
- package/src/extension/register.ts +32 -1
- package/src/extension/registration/commands.ts +65 -1
- package/src/extension/registration/compaction-guard.ts +154 -14
- package/src/extension/registration/subagent-tools.ts +8 -3
- package/src/extension/registration/team-tool.ts +18 -11
- 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/extension/team-tool/run.ts +4 -3
- package/src/extension/team-tool-types.ts +2 -0
- 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/team-runner.ts +23 -6
- package/src/schema/team-tool-schema.ts +6 -0
- package/src/state/session-state-map.ts +51 -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/powerbar-publisher.ts +1 -1
- package/src/ui/render-diff.ts +37 -3
- package/src/ui/settings-overlay.ts +70 -7
- package/src/ui/status-colors.ts +5 -1
- package/src/ui/syntax-highlight.ts +42 -23
- package/src/ui/theme-adapter.ts +80 -1
- package/src/ui/theme-discovery.ts +188 -0
- package/src/ui/tool-progress-formatter.ts +9 -5
- package/src/ui/tool-render.ts +4 -0
- package/src/ui/tool-renderers/brief-mode.ts +207 -0
- package/src/ui/tool-renderers/index.ts +640 -0
- package/src/ui/widget/index.ts +224 -0
- package/src/ui/widget/widget-formatters.ts +148 -0
- package/src/ui/widget/widget-model.ts +90 -0
- package/src/ui/widget/widget-renderer.ts +130 -0
- package/src/ui/widget/widget-types.ts +37 -0
- package/src/utils/guards.ts +110 -0
- 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/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
|
}
|
package/src/ui/status-colors.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/src/ui/theme-adapter.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|