@adeildo/pi-look 5.2.0 → 5.4.0
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/README.md +1 -3
- package/assets/preview.png +0 -0
- package/package.json +2 -2
- package/src/data/snapshot.ts +5 -0
- package/src/index.ts +22 -48
- package/src/render/format.ts +1 -0
- package/src/render/segments.ts +43 -4
- package/src/settings.ts +10 -42
- package/src/ui/editor.ts +16 -1
- package/src/ui/footer.ts +1 -1
- package/src/ui/screen.ts +12 -1
- package/src/ui/tool-frame.ts +89 -7
- package/src/desktop/palette.ts +0 -193
- package/src/desktop/sync.ts +0 -79
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
## The screen
|
|
26
26
|
|
|
27
|
-
A finished answer takes a box of its own, so you can see what the model wants to run before it runs. The mark of the tool and its name sit on the top line, the command under them, and the reason it was allowed under that, when another model answered it. A line cuts the command off from what it printed, and the bottom line says how it went: a check, a cross, and how long it took. A command still running says `running` with the time so far. A tool from an MCP server shows its server and tool instead of `mcp__server__tool`.
|
|
27
|
+
A finished answer takes a box of its own, so you can see what the model wants to run before it runs. The mark of the tool and its name sit on the top line, the command under them, and the reason it was allowed under that, when another model answered it. A line cuts the command off from what it printed, and the bottom line says how it went: a check, a cross, and how long it took. A command still running says `running` with the time so far. A tool from an MCP server shows its server and tool instead of `mcp__server__tool`. A tool with no drawing of its own, like the memory calls, gets the same box with its name and arguments on top and its text under the cut.
|
|
28
28
|
|
|
29
29
|
<!-- docs:look/call -->
|
|
30
30
|
|
|
@@ -65,8 +65,6 @@ Pi records usage without a duration, so every finished answer leaves one small e
|
|
|
65
65
|
|
|
66
66
|
The look never names a colour. Every piece names a theme token, so it follows whatever theme pi runs, and it moves with it when you change themes. The [reference](docs/reference.md#colours) maps each piece to its token.
|
|
67
67
|
|
|
68
|
-
If you run [DankMaterialShell](https://github.com/AvengeMedia/DankMaterialShell), matugen already made a palette from your wallpaper. The look turns it into the pi theme `desktop` and rewrites that file when the palette changes, so a new wallpaper recolours a running pi. `Switch to it now`, in the settings, does that at once.
|
|
69
|
-
|
|
70
68
|
## Settings
|
|
71
69
|
|
|
72
70
|
`Alt+S` opens them on the Look tab. You can reorder every piece of the strip, the frame and the footer, pick the frame style, the icons and the separator, and switch off what you do not want. The [reference](docs/reference.md#settings) lists every key and every segment.
|
package/assets/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-look",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"description": "Pi's start screen, a framed editor and the footer, redrawn around the data that matters and painted only with the theme's colours.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"access": "public"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@adeildo/pi-kit": "5.
|
|
41
|
+
"@adeildo/pi-kit": "5.4.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
44
|
"@earendil-works/pi-ai": "*",
|
package/src/data/snapshot.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
|
|
3
|
+
import type { AccountState } from "@adeildo/pi-kit";
|
|
3
4
|
import { VERSION, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
import type { GitState } from "./git.ts";
|
|
@@ -36,6 +37,7 @@ export interface Snapshot {
|
|
|
36
37
|
sessionName: string | undefined;
|
|
37
38
|
version: string;
|
|
38
39
|
model: ModelInfo | undefined;
|
|
40
|
+
account: AccountState | undefined;
|
|
39
41
|
thinking: string | undefined;
|
|
40
42
|
context: ContextInfo | undefined;
|
|
41
43
|
totals: Totals;
|
|
@@ -60,6 +62,7 @@ export function emptySnapshot(): Snapshot {
|
|
|
60
62
|
sessionName: undefined,
|
|
61
63
|
version: VERSION,
|
|
62
64
|
model: undefined,
|
|
65
|
+
account: undefined,
|
|
63
66
|
thinking: undefined,
|
|
64
67
|
context: undefined,
|
|
65
68
|
totals: emptyTotals(),
|
|
@@ -77,6 +80,7 @@ export function emptySnapshot(): Snapshot {
|
|
|
77
80
|
export interface LiveInputs {
|
|
78
81
|
branch: string | null;
|
|
79
82
|
git: GitState | undefined;
|
|
83
|
+
account?: AccountState | undefined;
|
|
80
84
|
request: RequestView | undefined;
|
|
81
85
|
last: RequestView | undefined;
|
|
82
86
|
run: RunView | undefined;
|
|
@@ -117,6 +121,7 @@ export class SnapshotReader {
|
|
|
117
121
|
reasoning: model.reasoning,
|
|
118
122
|
},
|
|
119
123
|
thinking: ctx.thinkingLevel,
|
|
124
|
+
account: live.account,
|
|
120
125
|
context: contextOf(ctx),
|
|
121
126
|
...derived,
|
|
122
127
|
subscription: model === undefined ? false : ctx.modelRegistry.isUsingOAuth(model),
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accountStateOf,
|
|
3
|
+
createApp,
|
|
4
|
+
defineFeature,
|
|
5
|
+
drawSkillRefs,
|
|
6
|
+
type FeatureScope,
|
|
7
|
+
SKILL_REF,
|
|
8
|
+
skillNames,
|
|
9
|
+
} from "@adeildo/pi-kit";
|
|
2
10
|
import type {
|
|
3
11
|
ExtensionAPI,
|
|
4
12
|
ExtensionContext,
|
|
@@ -11,18 +19,15 @@ import { emptySnapshot, type Snapshot, SnapshotReader } from "./data/snapshot.ts
|
|
|
11
19
|
import { Telemetry } from "./data/telemetry.ts";
|
|
12
20
|
import type { DeltaKind } from "./data/telemetry.ts";
|
|
13
21
|
import { REQUEST_ENTRY, type RequestRecord } from "./data/totals.ts";
|
|
14
|
-
import { DESKTOP_THEME } from "./desktop/palette.ts";
|
|
15
|
-
import { syncDesktopTheme, watchDesktopPalette } from "./desktop/sync.ts";
|
|
16
22
|
import { resolveIcons } from "./render/glyphs.ts";
|
|
17
23
|
import { PLAIN, themePaint } from "./render/paint.ts";
|
|
18
24
|
import { claimedStatuses, renderSegments, SEGMENTS } from "./render/segments.ts";
|
|
19
25
|
import {
|
|
20
26
|
below,
|
|
27
|
+
belowRight,
|
|
21
28
|
bottomLeft,
|
|
22
29
|
bottomRight,
|
|
23
30
|
cursor,
|
|
24
|
-
desktop,
|
|
25
|
-
desktopSource,
|
|
26
31
|
frame,
|
|
27
32
|
gaugeCells,
|
|
28
33
|
header,
|
|
@@ -47,7 +52,6 @@ import { type Activity, describe, draftedTool } from "./ui/working.ts";
|
|
|
47
52
|
|
|
48
53
|
export { SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
|
|
49
54
|
export { ROLE_TOKENS } from "./render/paint.ts";
|
|
50
|
-
export { desktopTheme, parseDesktopColors } from "./desktop/palette.ts";
|
|
51
55
|
|
|
52
56
|
const STRIP_WIDGET = "pi-look:strip";
|
|
53
57
|
const TICK_MS = 500;
|
|
@@ -60,7 +64,6 @@ interface Live {
|
|
|
60
64
|
git: GitState | undefined;
|
|
61
65
|
probing: boolean;
|
|
62
66
|
ticker: ReturnType<typeof setInterval> | undefined;
|
|
63
|
-
stopWatching: (() => void) | undefined;
|
|
64
67
|
frame: Snapshot | undefined;
|
|
65
68
|
activity: string | undefined;
|
|
66
69
|
}
|
|
@@ -82,7 +85,6 @@ export const look = defineFeature({
|
|
|
82
85
|
git: undefined,
|
|
83
86
|
probing: false,
|
|
84
87
|
ticker: undefined,
|
|
85
|
-
stopWatching: undefined,
|
|
86
88
|
frame: undefined,
|
|
87
89
|
activity: undefined,
|
|
88
90
|
};
|
|
@@ -97,7 +99,7 @@ export const look = defineFeature({
|
|
|
97
99
|
cursor.listen(scope, (style) => live.tui && applyCursor(live.tui, style));
|
|
98
100
|
peek.listen(scope, (on) => !on && live.ctx?.ui.setWorkingMessage());
|
|
99
101
|
|
|
100
|
-
scope.onSessionStart(
|
|
102
|
+
scope.onSessionStart((ctx) => {
|
|
101
103
|
live.ctx = ctx;
|
|
102
104
|
telemetry.reset();
|
|
103
105
|
void probeGit(live);
|
|
@@ -121,13 +123,10 @@ export const look = defineFeature({
|
|
|
121
123
|
});
|
|
122
124
|
installEditor(ctx, screen, live, scope);
|
|
123
125
|
installHeader(ctx, screen, scope);
|
|
124
|
-
if (desktop.get(scope)) await startDesktopSync(ctx, scope, live);
|
|
125
126
|
});
|
|
126
127
|
|
|
127
128
|
scope.onShutdown(() => {
|
|
128
129
|
stopTicking(live);
|
|
129
|
-
live.stopWatching?.();
|
|
130
|
-
live.stopWatching = undefined;
|
|
131
130
|
const ctx = live.ctx;
|
|
132
131
|
if (ctx?.mode === "tui") {
|
|
133
132
|
ctx.ui.setFooter(undefined);
|
|
@@ -214,28 +213,9 @@ export const look = defineFeature({
|
|
|
214
213
|
description: "Every piece the slots show right now, and what it means.",
|
|
215
214
|
run: () => explain(screen),
|
|
216
215
|
});
|
|
217
|
-
|
|
218
|
-
scope.screen.action({
|
|
219
|
-
id: "desktop.apply",
|
|
220
|
-
section: "Desktop theme",
|
|
221
|
-
label: "Switch to it now",
|
|
222
|
-
description: "Builds the desktop theme from the palette file and makes it pi's theme.",
|
|
223
|
-
run: (ctx) => switchToDesktopTheme(ctx, scope),
|
|
224
|
-
});
|
|
225
216
|
},
|
|
226
217
|
});
|
|
227
218
|
|
|
228
|
-
async function switchToDesktopTheme(ctx: ExtensionContext, scope: FeatureScope): Promise<string> {
|
|
229
|
-
const source = desktopSource.get(scope);
|
|
230
|
-
const result = await syncDesktopTheme(source);
|
|
231
|
-
if (result.kind === "missing") throw new Error(`no palette at ${source}`);
|
|
232
|
-
if (result.kind === "invalid") throw new Error(`cannot build the theme: ${result.reason}`);
|
|
233
|
-
const switched = ctx.ui.setTheme(DESKTOP_THEME);
|
|
234
|
-
if (!switched.success)
|
|
235
|
-
throw new Error(`wrote ${result.path}, but pi did not load it: ${switched.error ?? "unknown"}`);
|
|
236
|
-
return `Now on the ${DESKTOP_THEME} theme, built from ${source}.`;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
219
|
const SLOT_TITLES: Record<SlotName, string> = {
|
|
240
220
|
strip: "Above the editor, this answer",
|
|
241
221
|
topLeft: "Frame, top left",
|
|
@@ -243,6 +223,7 @@ const SLOT_TITLES: Record<SlotName, string> = {
|
|
|
243
223
|
bottomLeft: "Frame, bottom left",
|
|
244
224
|
bottomRight: "Frame, bottom right",
|
|
245
225
|
below: "Below the editor, the session",
|
|
226
|
+
belowRight: "Below the editor, on the right",
|
|
246
227
|
};
|
|
247
228
|
|
|
248
229
|
function explain(screen: Screen): string {
|
|
@@ -290,6 +271,7 @@ function createScreen(
|
|
|
290
271
|
bottomLeft,
|
|
291
272
|
bottomRight,
|
|
292
273
|
below,
|
|
274
|
+
belowRight,
|
|
293
275
|
};
|
|
294
276
|
|
|
295
277
|
return {
|
|
@@ -300,6 +282,7 @@ function createScreen(
|
|
|
300
282
|
const snapshot = reader.read(ctx, {
|
|
301
283
|
branch: live.footerData?.getGitBranch() ?? null,
|
|
302
284
|
git: live.git,
|
|
285
|
+
account: accountStateOf(scope.events, ctx.model?.provider),
|
|
303
286
|
request: telemetry.request(),
|
|
304
287
|
last: telemetry.last(),
|
|
305
288
|
run: telemetry.run(),
|
|
@@ -323,6 +306,14 @@ function createScreen(
|
|
|
323
306
|
slot: (name) => slots[name].get(scope),
|
|
324
307
|
frameStyle: () => frame.get(scope),
|
|
325
308
|
cursor: () => cursor.get(scope),
|
|
309
|
+
decorate: (line) => {
|
|
310
|
+
const theme = live.ctx?.ui.theme;
|
|
311
|
+
if (theme === undefined) return line;
|
|
312
|
+
return drawSkillRefs(line, skillNames(scope.events), (name) =>
|
|
313
|
+
theme.fg("customMessageLabel", `${SKILL_REF}${name}`),
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
skills: () => skillNames(scope.events).length > 0,
|
|
326
317
|
};
|
|
327
318
|
}
|
|
328
319
|
|
|
@@ -377,23 +368,6 @@ function loadedCounts(pi: ExtensionAPI): LoadedCounts {
|
|
|
377
368
|
};
|
|
378
369
|
}
|
|
379
370
|
|
|
380
|
-
async function startDesktopSync(ctx: ExtensionContext, scope: FeatureScope, live: Live) {
|
|
381
|
-
const source = desktopSource.get(scope);
|
|
382
|
-
const sync = async () => {
|
|
383
|
-
const result = await syncDesktopTheme(source);
|
|
384
|
-
if (result.kind === "invalid") scope.warn(`desktop theme: ${result.reason}`);
|
|
385
|
-
return result;
|
|
386
|
-
};
|
|
387
|
-
|
|
388
|
-
const first = await sync();
|
|
389
|
-
if (first.kind === "missing") return;
|
|
390
|
-
if (first.kind === "written" && ctx.ui.theme.name !== DESKTOP_THEME) {
|
|
391
|
-
ctx.ui.notify(`look: wrote the ${DESKTOP_THEME} theme. /look theme switches to it.`, "info");
|
|
392
|
-
}
|
|
393
|
-
live.stopWatching?.();
|
|
394
|
-
live.stopWatching = watchDesktopPalette(source, () => void sync());
|
|
395
|
-
}
|
|
396
|
-
|
|
397
371
|
function setActivity(live: Live, scope: FeatureScope, activity: Activity): void {
|
|
398
372
|
if (!peek.get(scope)) return;
|
|
399
373
|
const message = describe(activity);
|
package/src/render/format.ts
CHANGED
package/src/render/segments.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AccountWindow } from "@adeildo/pi-kit";
|
|
2
|
+
|
|
1
3
|
import type { Snapshot } from "../data/snapshot.ts";
|
|
2
4
|
import { promptOf, tokensOf, type Totals } from "../data/totals.ts";
|
|
3
5
|
import type { Piece } from "./fit.ts";
|
|
@@ -38,6 +40,7 @@ export const SEGMENT_IDS = [
|
|
|
38
40
|
"request",
|
|
39
41
|
"costRate",
|
|
40
42
|
"cost",
|
|
43
|
+
"quota",
|
|
41
44
|
"tokens",
|
|
42
45
|
"cache",
|
|
43
46
|
"average",
|
|
@@ -58,11 +61,19 @@ export function isSegmentId(value: string): value is SegmentId {
|
|
|
58
61
|
);
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
/** Status keys a builtin segment already draws, so the statuses segment skips them. */
|
|
65
|
+
const CLAIMED_BY_SEGMENT: Partial<Record<BuiltinSegment, string>> = {
|
|
66
|
+
model: "pi-providers:account",
|
|
67
|
+
};
|
|
68
|
+
|
|
61
69
|
export function claimedStatuses(slots: readonly (readonly SegmentId[])[]): Set<string> {
|
|
62
70
|
const claimed = new Set<string>();
|
|
63
71
|
for (const ids of slots) {
|
|
64
|
-
for (const id of ids)
|
|
72
|
+
for (const id of ids) {
|
|
65
73
|
if (id.startsWith(STATUS_PREFIX)) claimed.add(id.slice(STATUS_PREFIX.length));
|
|
74
|
+
const bySegment = CLAIMED_BY_SEGMENT[id as BuiltinSegment];
|
|
75
|
+
if (bySegment !== undefined) claimed.add(bySegment);
|
|
76
|
+
}
|
|
66
77
|
}
|
|
67
78
|
return claimed;
|
|
68
79
|
}
|
|
@@ -180,13 +191,32 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
180
191
|
},
|
|
181
192
|
model: {
|
|
182
193
|
priority: 100,
|
|
183
|
-
describe: "the model doing the work, after its provider",
|
|
194
|
+
describe: "the model doing the work, after its provider and the account it runs on",
|
|
184
195
|
render: ({ snapshot, glyphs, paint }) => {
|
|
185
196
|
const icon = mark(glyphs.model, paint, "provider");
|
|
186
197
|
if (snapshot.model === undefined) return { text: `${icon}${paint.dim("no model")}` };
|
|
187
198
|
const name = paint.bold(paint.role("model", snapshot.model.name));
|
|
188
|
-
const
|
|
189
|
-
|
|
199
|
+
const account =
|
|
200
|
+
snapshot.account === undefined
|
|
201
|
+
? ""
|
|
202
|
+
: `${paint.dim("(")}${paint.role("provider", snapshot.account.label)}${paint.dim(")")}`;
|
|
203
|
+
const provider = `${paint.role("provider", snapshot.model.provider)}${account}${paint.dim("/")}`;
|
|
204
|
+
return {
|
|
205
|
+
text: `${icon}${provider}${name}`,
|
|
206
|
+
compact: account === "" ? `${icon}${name}` : `${icon}${name} ${account}`,
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
quota: {
|
|
211
|
+
priority: 58,
|
|
212
|
+
describe: "what each window of the plan spent, and when it resets",
|
|
213
|
+
render: ({ snapshot, paint }) => {
|
|
214
|
+
const account = snapshot.account;
|
|
215
|
+
if (account === undefined) return undefined;
|
|
216
|
+
if (account.needsLogin === true) return { text: paint.stress(100, "sign in again") };
|
|
217
|
+
if (account.windows.length === 0) return undefined;
|
|
218
|
+
const text = account.windows.map((window) => quotaWindow(paint, window)).join(" ");
|
|
219
|
+
return { text };
|
|
190
220
|
},
|
|
191
221
|
},
|
|
192
222
|
effort: {
|
|
@@ -350,6 +380,7 @@ export const SEGMENTS: Record<BuiltinSegment, Segment> = {
|
|
|
350
380
|
"what the session cost; `sub` means a subscription, so it is what the tokens would have cost",
|
|
351
381
|
render: ({ snapshot, glyphs, paint }) => {
|
|
352
382
|
const amount = snapshot.totals.cost;
|
|
383
|
+
// A session that spent nothing has nothing to say yet.
|
|
353
384
|
if (amount === 0) return undefined;
|
|
354
385
|
const value = paint.money(amount, money(amount));
|
|
355
386
|
const sub = snapshot.subscription ? paint.dim(" sub") : "";
|
|
@@ -445,6 +476,14 @@ export function renderSegments(ids: readonly SegmentId[], input: SegmentInput):
|
|
|
445
476
|
});
|
|
446
477
|
}
|
|
447
478
|
|
|
479
|
+
/** One plan window, like `5h 62% resets in 2h`. The percent carries the color. */
|
|
480
|
+
function quotaWindow(paint: Paint, window: AccountWindow): string {
|
|
481
|
+
const used = paint.stress(window.used, `${window.used}%`);
|
|
482
|
+
const reset =
|
|
483
|
+
window.resetsIn === undefined ? "" : ` ${paint.muted(`resets in ${window.resetsIn}`)}`;
|
|
484
|
+
return `${paint.dim(window.name)} ${used}${reset}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
448
487
|
function mark(glyph: string, paint: Paint, role: Role): string {
|
|
449
488
|
return glyph === "" ? "" : `${paint.role(role, glyph)} `;
|
|
450
489
|
}
|
package/src/settings.ts
CHANGED
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
pass,
|
|
8
8
|
problem,
|
|
9
9
|
setting,
|
|
10
|
-
string,
|
|
11
10
|
} from "@adeildo/pi-kit";
|
|
12
11
|
|
|
13
12
|
import type { FrameStyle } from "./render/frame.ts";
|
|
@@ -15,21 +14,12 @@ import type { IconMode } from "./render/glyphs.ts";
|
|
|
15
14
|
import { isSegmentId, SEGMENT_IDS, SEGMENTS, type SegmentId } from "./render/segments.ts";
|
|
16
15
|
|
|
17
16
|
const PERMISSION_STATUS: SegmentId = "status:pi-ask-permission:mode";
|
|
18
|
-
const ACCOUNT_STATUS: SegmentId = "status:pi-providers:account";
|
|
19
17
|
|
|
20
18
|
const STATUS_LABELS: Partial<Record<SegmentId, string>> = {
|
|
21
19
|
[PERMISSION_STATUS]: "permission mode",
|
|
22
|
-
[ACCOUNT_STATUS]: "account",
|
|
23
20
|
};
|
|
24
21
|
|
|
25
|
-
export const SECTIONS = [
|
|
26
|
-
"Editor",
|
|
27
|
-
"Slots",
|
|
28
|
-
"Footer",
|
|
29
|
-
"Start screen",
|
|
30
|
-
"Working line",
|
|
31
|
-
"Desktop theme",
|
|
32
|
-
];
|
|
22
|
+
export const SECTIONS = ["Editor", "Slots", "Footer", "Start screen", "Working line"];
|
|
33
23
|
|
|
34
24
|
export type HeaderStyle = "card" | "compact" | "off";
|
|
35
25
|
export type SeparatorStyle = "dot" | "bar" | "slash" | "space";
|
|
@@ -65,11 +55,7 @@ function segments(fallback: readonly SegmentId[]): Decoder<SegmentId[]> {
|
|
|
65
55
|
|
|
66
56
|
// Any other package's status can have a slot of its own, so the ones already placed are offered too.
|
|
67
57
|
function slotControl(fallback: readonly SegmentId[]): Control {
|
|
68
|
-
const statuses = [
|
|
69
|
-
PERMISSION_STATUS,
|
|
70
|
-
ACCOUNT_STATUS,
|
|
71
|
-
...fallback.filter((id) => id.startsWith("status:")),
|
|
72
|
-
];
|
|
58
|
+
const statuses = [PERMISSION_STATUS, ...fallback.filter((id) => id.startsWith("status:"))];
|
|
73
59
|
return {
|
|
74
60
|
type: "list",
|
|
75
61
|
options: [
|
|
@@ -113,7 +99,7 @@ export const bottomLeft = slot(
|
|
|
113
99
|
"frame.bottomLeft",
|
|
114
100
|
"Frame, bottom left",
|
|
115
101
|
"How what you type is handled, and who does the work.",
|
|
116
|
-
[PERMISSION_MODE, "model",
|
|
102
|
+
[PERMISSION_MODE, "model", "effort"],
|
|
117
103
|
);
|
|
118
104
|
export const bottomRight = slot("frame.bottomRight", "Frame, bottom right", "How full it is.", [
|
|
119
105
|
"context",
|
|
@@ -124,6 +110,12 @@ export const below = slot(
|
|
|
124
110
|
"The session: cost, tokens, cache, average speeds, and what other packages report, last because it changes the most.",
|
|
125
111
|
["cost", "tokens", "cache", "average", "statuses"],
|
|
126
112
|
);
|
|
113
|
+
export const belowRight = slot(
|
|
114
|
+
"belowRight",
|
|
115
|
+
"Below the editor, on the right",
|
|
116
|
+
"The plan's quota: what each window spent, and when it resets.",
|
|
117
|
+
["quota"],
|
|
118
|
+
);
|
|
127
119
|
|
|
128
120
|
export const frame = setting<FrameStyle | "off">({
|
|
129
121
|
id: "look.frame.style",
|
|
@@ -225,29 +217,6 @@ export const peek = setting({
|
|
|
225
217
|
},
|
|
226
218
|
});
|
|
227
219
|
|
|
228
|
-
export const desktop = setting({
|
|
229
|
-
id: "look.desktop.theme",
|
|
230
|
-
default: true,
|
|
231
|
-
decoder: boolean,
|
|
232
|
-
ui: {
|
|
233
|
-
section: "Desktop theme",
|
|
234
|
-
label: "Desktop theme",
|
|
235
|
-
description:
|
|
236
|
-
"Write the matugen palette from DankMaterialShell as the pi theme `desktop`, and keep it in sync.",
|
|
237
|
-
},
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
export const desktopSource = setting({
|
|
241
|
-
id: "look.desktop.source",
|
|
242
|
-
default: "~/.cache/DankMaterialShell/dms-colors.json",
|
|
243
|
-
decoder: string,
|
|
244
|
-
ui: {
|
|
245
|
-
section: "Desktop theme",
|
|
246
|
-
label: "Desktop palette",
|
|
247
|
-
description: "The DankMaterialShell colour file the desktop theme is made from.",
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
|
|
251
220
|
export const LOOK_SETTINGS = [
|
|
252
221
|
strip,
|
|
253
222
|
topLeft,
|
|
@@ -255,6 +224,7 @@ export const LOOK_SETTINGS = [
|
|
|
255
224
|
bottomLeft,
|
|
256
225
|
bottomRight,
|
|
257
226
|
below,
|
|
227
|
+
belowRight,
|
|
258
228
|
frame,
|
|
259
229
|
cursor,
|
|
260
230
|
header,
|
|
@@ -264,6 +234,4 @@ export const LOOK_SETTINGS = [
|
|
|
264
234
|
gaugeCells,
|
|
265
235
|
labels,
|
|
266
236
|
peek,
|
|
267
|
-
desktop,
|
|
268
|
-
desktopSource,
|
|
269
237
|
];
|
package/src/ui/editor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { skillQueryAt } from "@adeildo/pi-kit";
|
|
1
2
|
import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
type EditorTheme,
|
|
@@ -59,6 +60,14 @@ export class LookEditor extends CustomEditor {
|
|
|
59
60
|
this.tui.requestRender();
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** Pi opens its palette on `/` only at the start of a message. Further in, `/` lists the skills. */
|
|
64
|
+
override handleInput(data: string): void {
|
|
65
|
+
super.handleInput(data);
|
|
66
|
+
if (data !== "/" || this.isShowingAutocomplete() || !this.#screen.skills()) return;
|
|
67
|
+
const { line, col } = this.getCursor();
|
|
68
|
+
if (skillQueryAt(this.getLines(), line, col) !== undefined) openAutocomplete(this);
|
|
69
|
+
}
|
|
70
|
+
|
|
62
71
|
override handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
63
72
|
if (this.#inset === 0) return super.handleMouse(event);
|
|
64
73
|
return super.handleMouse({
|
|
@@ -146,7 +155,7 @@ export class LookEditor extends CustomEditor {
|
|
|
146
155
|
);
|
|
147
156
|
inside = false;
|
|
148
157
|
} else if (inside) {
|
|
149
|
-
const text = cursor === "block" ? line : withoutSoftwareCursor(line);
|
|
158
|
+
const text = this.#screen.decorate(cursor === "block" ? line : withoutSoftwareCursor(line));
|
|
150
159
|
result.push(`${left}${fill(text, inner)}${right}`);
|
|
151
160
|
} else {
|
|
152
161
|
result.push(`${" ".repeat(this.#inset)}${line}`);
|
|
@@ -165,6 +174,12 @@ export class LookEditor extends CustomEditor {
|
|
|
165
174
|
}
|
|
166
175
|
}
|
|
167
176
|
|
|
177
|
+
// The editor keeps the method that opens its list private. Nothing opens if a pi release renames it.
|
|
178
|
+
function openAutocomplete(editor: CustomEditor): void {
|
|
179
|
+
const open: unknown = Reflect.get(editor, "tryTriggerAutocomplete");
|
|
180
|
+
if (typeof open === "function") open.call(editor);
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
/** With the terminal's cursor showing, pi's inverted cell would be a second cursor. */
|
|
169
184
|
function withoutSoftwareCursor(line: string): string {
|
|
170
185
|
return line.replace(INVERTED_CELL, "$1");
|
package/src/ui/footer.ts
CHANGED
|
@@ -57,7 +57,7 @@ export class FooterComponent implements Component {
|
|
|
57
57
|
lines.push(spread(screen, "topLeft", "topRight", room));
|
|
58
58
|
lines.push(spread(screen, "bottomLeft", "bottomRight", room));
|
|
59
59
|
}
|
|
60
|
-
lines.push(
|
|
60
|
+
lines.push(spread(screen, "below", "belowRight", room));
|
|
61
61
|
|
|
62
62
|
const kept = lines.filter((line) => line !== "").map((line) => `${pad}${line}`);
|
|
63
63
|
return kept.length === 0 ? [""] : kept;
|
package/src/ui/screen.ts
CHANGED
|
@@ -6,7 +6,14 @@ import type { Paint } from "../render/paint.ts";
|
|
|
6
6
|
import { renderSegments, type SegmentId, type SegmentOptions } from "../render/segments.ts";
|
|
7
7
|
import type { CursorStyle } from "../settings.ts";
|
|
8
8
|
|
|
9
|
-
export type SlotName =
|
|
9
|
+
export type SlotName =
|
|
10
|
+
| "strip"
|
|
11
|
+
| "topLeft"
|
|
12
|
+
| "topRight"
|
|
13
|
+
| "bottomLeft"
|
|
14
|
+
| "bottomRight"
|
|
15
|
+
| "below"
|
|
16
|
+
| "belowRight";
|
|
10
17
|
|
|
11
18
|
export interface Screen {
|
|
12
19
|
snapshot(): Snapshot;
|
|
@@ -17,6 +24,10 @@ export interface Screen {
|
|
|
17
24
|
slot(name: SlotName): readonly SegmentId[];
|
|
18
25
|
frameStyle(): FrameStyle | "off";
|
|
19
26
|
cursor(): CursorStyle;
|
|
27
|
+
/** A line of what you type, with the skills it names colored. */
|
|
28
|
+
decorate(line: string): string;
|
|
29
|
+
/** True when a skills feature expands `/skill:` references. */
|
|
30
|
+
skills(): boolean;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
33
|
export function slotPieces(
|
package/src/ui/tool-frame.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
import type { Theme, ToolRenderers } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import {
|
|
14
14
|
type Component,
|
|
15
|
+
getKeybindings,
|
|
15
16
|
stripTerminalSequences,
|
|
17
|
+
Text,
|
|
16
18
|
truncateToWidth,
|
|
17
19
|
visibleWidth,
|
|
18
20
|
} from "@earendil-works/pi-tui";
|
|
@@ -32,6 +34,9 @@ const PHASE_COLORS = {
|
|
|
32
34
|
failed: "error",
|
|
33
35
|
} as const;
|
|
34
36
|
const BODY_PAD = 4;
|
|
37
|
+
const COLLAPSED_ARGS_CHARS = 100;
|
|
38
|
+
/** How many lines of a result the frame prints before telling you the rest is there. */
|
|
39
|
+
const RESULT_PREVIEW_LINES = 10;
|
|
35
40
|
|
|
36
41
|
export function registerToolFrames(scope: FeatureScope): void {
|
|
37
42
|
const redraws = new Map<string, () => void>();
|
|
@@ -61,12 +66,11 @@ function framed(
|
|
|
61
66
|
scope: FeatureScope,
|
|
62
67
|
base: ToolRenderers | undefined,
|
|
63
68
|
frame: FrameContext,
|
|
64
|
-
): ToolRenderers
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
const call = base?.renderCall;
|
|
68
|
-
const result = base?.renderResult;
|
|
69
|
-
if (base === undefined || call === undefined || result === undefined) return base;
|
|
69
|
+
): ToolRenderers {
|
|
70
|
+
// A tool may bring renderers of its own, one of them, or none: the frame wraps whichever half
|
|
71
|
+
// exists, and the arguments or the text of the result stand in for the rest.
|
|
72
|
+
const call = base?.renderCall ?? genericCall;
|
|
73
|
+
const result = base?.renderResult ?? genericResult;
|
|
70
74
|
|
|
71
75
|
// A renderer of pi's own mutates the component it returned last time, which sits inside the
|
|
72
76
|
// frame, so it gets that one back instead of the frame.
|
|
@@ -94,7 +98,7 @@ function framed(
|
|
|
94
98
|
body,
|
|
95
99
|
ruling: rulingOf(scope.events, context.toolCallId),
|
|
96
100
|
expanded: context.expanded,
|
|
97
|
-
phase: context
|
|
101
|
+
phase: phaseOf(context),
|
|
98
102
|
mark: stateMark,
|
|
99
103
|
set: frame.set,
|
|
100
104
|
elapsed: elapsedOf(clock, context),
|
|
@@ -130,6 +134,84 @@ function framed(
|
|
|
130
134
|
};
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
/*
|
|
138
|
+
* The stand-in for a tool that brings no renderer of its own: the arguments, under the top rule
|
|
139
|
+
* that already names the tool, and the text of the result.
|
|
140
|
+
*/
|
|
141
|
+
const genericCall: NonNullable<ToolRenderers["renderCall"]> = (args, theme, context) =>
|
|
142
|
+
new Text(callText(args, theme, context.expanded), 0, 0);
|
|
143
|
+
|
|
144
|
+
const genericResult: NonNullable<ToolRenderers["renderResult"]> = (result, options, theme) =>
|
|
145
|
+
new Text(resultText(result, options.expanded, theme), 0, 0);
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The phase the call closes with while it still has no result: writing the arguments, then waiting
|
|
149
|
+
* to run. A call with a result, live or replayed, stays open for the result half to close.
|
|
150
|
+
*/
|
|
151
|
+
function phaseOf(context: {
|
|
152
|
+
isPartial: boolean;
|
|
153
|
+
executionStarted: boolean;
|
|
154
|
+
argsComplete: boolean;
|
|
155
|
+
}): Phase | undefined {
|
|
156
|
+
if (!context.isPartial || context.executionStarted) return undefined;
|
|
157
|
+
return context.argsComplete ? "waiting" : "writing";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function callText(args: unknown, theme: Theme, expanded: boolean): string {
|
|
161
|
+
if (args === null || args === undefined) return "";
|
|
162
|
+
|
|
163
|
+
const entries: [string, unknown][] =
|
|
164
|
+
typeof args === "object" && !Array.isArray(args) ? Object.entries(args) : [["args", args]];
|
|
165
|
+
if (entries.length === 0) return "";
|
|
166
|
+
|
|
167
|
+
if (expanded) {
|
|
168
|
+
const lines = entries.map(([key, value]) => {
|
|
169
|
+
const text =
|
|
170
|
+
typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? String(value));
|
|
171
|
+
return ` ${key}: ${replaceTabs(text).split("\n").join("\n ")}`;
|
|
172
|
+
});
|
|
173
|
+
return theme.fg("muted", lines.join("\n"));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const pairs = entries
|
|
177
|
+
.map(([key, value]) => `${key}=${JSON.stringify(value) ?? String(value)}`)
|
|
178
|
+
.join(" ");
|
|
179
|
+
const preview =
|
|
180
|
+
pairs.length > COLLAPSED_ARGS_CHARS ? `${pairs.slice(0, COLLAPSED_ARGS_CHARS - 3)}...` : pairs;
|
|
181
|
+
return theme.fg("muted", preview);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resultText(
|
|
185
|
+
result: { content: readonly { type: string; text?: string }[] },
|
|
186
|
+
expanded: boolean,
|
|
187
|
+
theme: Theme,
|
|
188
|
+
): string {
|
|
189
|
+
const text = result.content
|
|
190
|
+
.filter((block) => block.type === "text")
|
|
191
|
+
.map((block) => stripTerminalSequences(block.text ?? "").replace(/\r/g, ""))
|
|
192
|
+
.join("\n");
|
|
193
|
+
if (text === "") return "";
|
|
194
|
+
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
const shown = expanded ? lines : lines.slice(0, RESULT_PREVIEW_LINES);
|
|
197
|
+
const printed = shown.map((line) => theme.fg("toolOutput", line));
|
|
198
|
+
if (shown.length < lines.length)
|
|
199
|
+
printed.push(
|
|
200
|
+
theme.fg("muted", `... (${lines.length - shown.length} more lines, ${expandHint(theme)})`),
|
|
201
|
+
);
|
|
202
|
+
return printed.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function expandHint(theme: Theme): string {
|
|
206
|
+
const keys = getKeybindings().getKeys("app.tools.expand");
|
|
207
|
+
if (keys.length === 0) return theme.fg("muted", "expand");
|
|
208
|
+
return `${theme.fg("dim", keys.join("/"))}${theme.fg("muted", " to expand")}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function replaceTabs(text: string): string {
|
|
212
|
+
return text.replace(/\t/g, " ");
|
|
213
|
+
}
|
|
214
|
+
|
|
133
215
|
interface FrameParts {
|
|
134
216
|
theme: Theme;
|
|
135
217
|
body: Component;
|
package/src/desktop/palette.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const DESKTOP_THEME = "desktop";
|
|
4
|
-
|
|
5
|
-
export type Mode = "dark" | "light";
|
|
6
|
-
|
|
7
|
-
export interface DesktopColors {
|
|
8
|
-
mode: Mode;
|
|
9
|
-
roles: Readonly<Record<string, string>>;
|
|
10
|
-
terminal: Readonly<Record<string, string>>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface ThemeJson {
|
|
14
|
-
$schema: string;
|
|
15
|
-
name: string;
|
|
16
|
-
vars: Record<string, string>;
|
|
17
|
-
colors: Record<string, string>;
|
|
18
|
-
export: { pageBg: string; cardBg: string; infoBg: string };
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const HEX = /^#[0-9a-f]{6}$/i;
|
|
22
|
-
|
|
23
|
-
export function parseDesktopColors(json: unknown, mode?: Mode): DesktopColors | undefined {
|
|
24
|
-
if (!isRecord(json) || !isRecord(json.colors) || !isRecord(json.dank16)) return undefined;
|
|
25
|
-
const chosen: Mode = mode ?? (json.mode === "light" ? "light" : "dark");
|
|
26
|
-
const roles = json.colors[chosen];
|
|
27
|
-
if (!isRecord(roles)) return undefined;
|
|
28
|
-
|
|
29
|
-
const terminal: Record<string, string> = {};
|
|
30
|
-
for (const [name, value] of Object.entries(json.dank16)) {
|
|
31
|
-
const hex = isRecord(value) ? (value[chosen] ?? value.default) : value;
|
|
32
|
-
if (typeof hex === "string" && HEX.test(hex)) terminal[name] = hex;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const material: Record<string, string> = {};
|
|
36
|
-
for (const [name, value] of Object.entries(roles)) {
|
|
37
|
-
if (typeof value === "string" && HEX.test(value)) material[name] = value;
|
|
38
|
-
}
|
|
39
|
-
return { mode: chosen, roles: material, terminal };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const REQUIRED_ROLES = [
|
|
43
|
-
"primary",
|
|
44
|
-
"secondary",
|
|
45
|
-
"tertiary",
|
|
46
|
-
"error",
|
|
47
|
-
"error_container",
|
|
48
|
-
"outline",
|
|
49
|
-
"outline_variant",
|
|
50
|
-
"on_surface",
|
|
51
|
-
"on_surface_variant",
|
|
52
|
-
"surface",
|
|
53
|
-
"surface_container_low",
|
|
54
|
-
"surface_container",
|
|
55
|
-
"surface_container_high",
|
|
56
|
-
"surface_container_highest",
|
|
57
|
-
];
|
|
58
|
-
const REQUIRED_TERMINAL = ["color1", "color2", "color3", "color6", "color9"];
|
|
59
|
-
|
|
60
|
-
export function missingColors(colors: DesktopColors): string[] {
|
|
61
|
-
return [
|
|
62
|
-
...REQUIRED_ROLES.filter((role) => colors.roles[role] === undefined),
|
|
63
|
-
...REQUIRED_TERMINAL.filter((name) => colors.terminal[name] === undefined),
|
|
64
|
-
];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function desktopTheme(colors: DesktopColors): ThemeJson {
|
|
68
|
-
const role = (name: string): string => colors.roles[name] ?? "#808080";
|
|
69
|
-
const term = (name: string): string => colors.terminal[name] ?? "#808080";
|
|
70
|
-
|
|
71
|
-
const vars: Record<string, string> = {
|
|
72
|
-
primary: role("primary"),
|
|
73
|
-
secondary: role("secondary"),
|
|
74
|
-
tertiary: role("tertiary"),
|
|
75
|
-
error: role("error"),
|
|
76
|
-
outline: role("outline"),
|
|
77
|
-
outlineVariant: role("outline_variant"),
|
|
78
|
-
text: role("on_surface"),
|
|
79
|
-
subtext: role("on_surface_variant"),
|
|
80
|
-
surface: role("surface"),
|
|
81
|
-
containerLow: role("surface_container_low"),
|
|
82
|
-
container: role("surface_container"),
|
|
83
|
-
containerHigh: role("surface_container_high"),
|
|
84
|
-
containerHighest: role("surface_container_highest"),
|
|
85
|
-
red: term("color1"),
|
|
86
|
-
green: term("color2"),
|
|
87
|
-
yellow: term("color3"),
|
|
88
|
-
cyan: term("color6"),
|
|
89
|
-
brightRed: term("color9"),
|
|
90
|
-
successBg: mix(role("surface_container_low"), term("color2"), 0.12),
|
|
91
|
-
errorBg: mix(role("surface_container_low"), role("error"), 0.14),
|
|
92
|
-
infoBg: mix(role("surface_container"), term("color3"), 0.1),
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const colorsByToken: Record<ThemeColor | BackgroundToken, string> = {
|
|
96
|
-
accent: "primary",
|
|
97
|
-
border: "outline",
|
|
98
|
-
borderAccent: "primary",
|
|
99
|
-
borderMuted: "outlineVariant",
|
|
100
|
-
success: "green",
|
|
101
|
-
error: "error",
|
|
102
|
-
warning: "yellow",
|
|
103
|
-
muted: "subtext",
|
|
104
|
-
dim: "outline",
|
|
105
|
-
text: "text",
|
|
106
|
-
thinkingText: "subtext",
|
|
107
|
-
|
|
108
|
-
selectedBg: "containerHigh",
|
|
109
|
-
scrollbarTrack: "outlineVariant",
|
|
110
|
-
scrollbarThumb: "subtext",
|
|
111
|
-
searchMatchBg: "containerHighest",
|
|
112
|
-
searchMatchText: "text",
|
|
113
|
-
userMessageBg: "container",
|
|
114
|
-
userMessageText: "text",
|
|
115
|
-
customMessageBg: "containerLow",
|
|
116
|
-
customMessageText: "text",
|
|
117
|
-
customMessageLabel: "tertiary",
|
|
118
|
-
toolPendingBg: "containerLow",
|
|
119
|
-
toolSuccessBg: "successBg",
|
|
120
|
-
toolErrorBg: "errorBg",
|
|
121
|
-
toolTitle: "text",
|
|
122
|
-
toolOutput: "subtext",
|
|
123
|
-
|
|
124
|
-
mdHeading: "tertiary",
|
|
125
|
-
mdLink: "primary",
|
|
126
|
-
mdLinkUrl: "outline",
|
|
127
|
-
mdCode: "secondary",
|
|
128
|
-
mdCodeBlock: "text",
|
|
129
|
-
mdCodeBlockBorder: "outlineVariant",
|
|
130
|
-
mdQuote: "subtext",
|
|
131
|
-
mdQuoteBorder: "outlineVariant",
|
|
132
|
-
mdHr: "outlineVariant",
|
|
133
|
-
mdListBullet: "primary",
|
|
134
|
-
|
|
135
|
-
toolDiffAdded: "green",
|
|
136
|
-
toolDiffRemoved: "red",
|
|
137
|
-
toolDiffContext: "subtext",
|
|
138
|
-
|
|
139
|
-
syntaxComment: "outline",
|
|
140
|
-
syntaxKeyword: "primary",
|
|
141
|
-
syntaxFunction: "tertiary",
|
|
142
|
-
syntaxVariable: "text",
|
|
143
|
-
syntaxString: "green",
|
|
144
|
-
syntaxNumber: "yellow",
|
|
145
|
-
syntaxType: "cyan",
|
|
146
|
-
syntaxOperator: "subtext",
|
|
147
|
-
syntaxPunctuation: "subtext",
|
|
148
|
-
|
|
149
|
-
thinkingOff: "outlineVariant",
|
|
150
|
-
thinkingMinimal: "outline",
|
|
151
|
-
thinkingLow: "secondary",
|
|
152
|
-
thinkingMedium: "primary",
|
|
153
|
-
thinkingHigh: "tertiary",
|
|
154
|
-
thinkingXhigh: "brightRed",
|
|
155
|
-
thinkingMax: "red",
|
|
156
|
-
|
|
157
|
-
bashMode: "green",
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
$schema:
|
|
162
|
-
"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
163
|
-
name: DESKTOP_THEME,
|
|
164
|
-
vars,
|
|
165
|
-
colors: colorsByToken,
|
|
166
|
-
export: { pageBg: vars.surface ?? "", cardBg: vars.container ?? "", infoBg: vars.infoBg ?? "" },
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
type BackgroundToken =
|
|
171
|
-
| "selectedBg"
|
|
172
|
-
| "searchMatchBg"
|
|
173
|
-
| "userMessageBg"
|
|
174
|
-
| "customMessageBg"
|
|
175
|
-
| "toolPendingBg"
|
|
176
|
-
| "toolSuccessBg"
|
|
177
|
-
| "toolErrorBg";
|
|
178
|
-
|
|
179
|
-
export function mix(base: string, tint: string, amount: number): string {
|
|
180
|
-
const a = channels(base);
|
|
181
|
-
const b = channels(tint);
|
|
182
|
-
const mixed = a.map((value, index) => Math.round(value + ((b[index] ?? value) - value) * amount));
|
|
183
|
-
return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function channels(hex: string): number[] {
|
|
187
|
-
const value = HEX.test(hex) ? hex.slice(1) : "808080";
|
|
188
|
-
return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16));
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
192
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
193
|
-
}
|
package/src/desktop/sync.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
|
|
8
|
-
import { DESKTOP_THEME, desktopTheme, missingColors, parseDesktopColors } from "./palette.ts";
|
|
9
|
-
|
|
10
|
-
const SETTLE_MS = 250;
|
|
11
|
-
|
|
12
|
-
export type SyncResult =
|
|
13
|
-
| { kind: "written"; path: string }
|
|
14
|
-
| { kind: "unchanged"; path: string }
|
|
15
|
-
| { kind: "missing" }
|
|
16
|
-
| { kind: "invalid"; reason: string };
|
|
17
|
-
|
|
18
|
-
export function themePath(): string {
|
|
19
|
-
return join(getAgentDir(), "themes", `${DESKTOP_THEME}.json`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function expandHome(path: string): string {
|
|
23
|
-
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function syncDesktopTheme(source: string): Promise<SyncResult> {
|
|
27
|
-
let raw: string;
|
|
28
|
-
try {
|
|
29
|
-
raw = await readFile(expandHome(source), "utf8");
|
|
30
|
-
} catch {
|
|
31
|
-
return { kind: "missing" };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let json: unknown;
|
|
35
|
-
try {
|
|
36
|
-
json = JSON.parse(raw);
|
|
37
|
-
} catch {
|
|
38
|
-
return { kind: "invalid", reason: "the palette file is not JSON" };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const colors = parseDesktopColors(json);
|
|
42
|
-
if (colors === undefined) return { kind: "invalid", reason: "not a DankMaterialShell palette" };
|
|
43
|
-
const missing = missingColors(colors);
|
|
44
|
-
if (missing.length > 0) return { kind: "invalid", reason: `missing ${missing.join(", ")}` };
|
|
45
|
-
|
|
46
|
-
const path = themePath();
|
|
47
|
-
const next = `${JSON.stringify(desktopTheme(colors), null, "\t")}\n`;
|
|
48
|
-
const current = await readFile(path, "utf8").catch(() => undefined);
|
|
49
|
-
if (current === next) return { kind: "unchanged", path };
|
|
50
|
-
|
|
51
|
-
await mkdir(dirname(path), { recursive: true });
|
|
52
|
-
await writeFile(path, next);
|
|
53
|
-
return { kind: "written", path };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Watches the folder: an atomic write replaces the file, and a watch on the old one goes quiet. */
|
|
57
|
-
export function watchDesktopPalette(source: string, changed: () => void): () => void {
|
|
58
|
-
const path = expandHome(source);
|
|
59
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
60
|
-
let watcher: FSWatcher | undefined;
|
|
61
|
-
|
|
62
|
-
try {
|
|
63
|
-
watcher = watch(dirname(path), (_event, file) => {
|
|
64
|
-
if (file !== null && file !== basename(path)) return;
|
|
65
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
66
|
-
timer = setTimeout(changed, SETTLE_MS);
|
|
67
|
-
timer.unref?.();
|
|
68
|
-
});
|
|
69
|
-
watcher.unref?.();
|
|
70
|
-
watcher.on("error", () => watcher?.close());
|
|
71
|
-
} catch {
|
|
72
|
-
return () => {};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return () => {
|
|
76
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
77
|
-
watcher?.close();
|
|
78
|
-
};
|
|
79
|
-
}
|