@adeildo/pi-look 4.0.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/LICENSE +21 -0
- package/README.md +163 -0
- package/assets/preview.png +0 -0
- package/package.json +54 -0
- package/src/data/git.ts +58 -0
- package/src/data/snapshot.ts +173 -0
- package/src/data/telemetry.ts +188 -0
- package/src/data/totals.ts +149 -0
- package/src/desktop/palette.ts +207 -0
- package/src/desktop/sync.ts +86 -0
- package/src/index.ts +448 -0
- package/src/render/fit.ts +89 -0
- package/src/render/format.ts +136 -0
- package/src/render/frame.ts +118 -0
- package/src/render/glyphs.ts +155 -0
- package/src/render/paint.ts +137 -0
- package/src/render/segments.ts +514 -0
- package/src/settings.ts +237 -0
- package/src/ui/editor.ts +194 -0
- package/src/ui/footer.ts +103 -0
- package/src/ui/header.ts +212 -0
- package/src/ui/screen.ts +39 -0
- package/src/ui/working.ts +73 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// What a session spent and how fast it went, added up from its own entries. Pi keeps the same totals
|
|
2
|
+
// for its footer but does not export the helper, so this is the one place that sums them.
|
|
3
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export interface Totals {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheWrite: number;
|
|
10
|
+
cost: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UsageLike {
|
|
14
|
+
input: number;
|
|
15
|
+
output: number;
|
|
16
|
+
cacheRead: number;
|
|
17
|
+
cacheWrite: number;
|
|
18
|
+
cost: { total: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What one request to the model left behind. Pi records usage without a duration, so this entry is
|
|
23
|
+
* where the session speeds come from after a resume.
|
|
24
|
+
*/
|
|
25
|
+
export interface RequestRecord {
|
|
26
|
+
/** Tokens the model wrote. */
|
|
27
|
+
output: number;
|
|
28
|
+
/** Tokens sent: fresh input plus what was read from and written to the cache. */
|
|
29
|
+
prompt: number;
|
|
30
|
+
/** From the request leaving to the first piece of the answer. */
|
|
31
|
+
firstTokenMs: number;
|
|
32
|
+
/** From the first piece of the answer to the last. */
|
|
33
|
+
generationMs: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const REQUEST_ENTRY = "pi-look:request";
|
|
37
|
+
/** Written by this package before it was renamed. It has no `prompt`, so it only counts for decode. */
|
|
38
|
+
const LEGACY_ENTRY = "pi-statusline:turn";
|
|
39
|
+
|
|
40
|
+
export interface Averages {
|
|
41
|
+
/** Output tokens per second of writing, across the branch. */
|
|
42
|
+
decode: number | undefined;
|
|
43
|
+
/** Prompt tokens per second of waiting for the first token, across the branch. */
|
|
44
|
+
prefill: number | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function emptyTotals(): Totals {
|
|
48
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function promptOf(totals: Totals): number {
|
|
52
|
+
return totals.input + totals.cacheRead + totals.cacheWrite;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function tokensOf(totals: Totals): number {
|
|
56
|
+
return promptOf(totals) + totals.output;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function usageTotals(usage: UsageLike): Totals {
|
|
60
|
+
const totals = emptyTotals();
|
|
61
|
+
add(totals, usage);
|
|
62
|
+
return totals;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The whole branch, not just the messages after the last compaction. */
|
|
66
|
+
export function totalsOf(entries: readonly SessionEntry[]): Totals {
|
|
67
|
+
const totals = emptyTotals();
|
|
68
|
+
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.type === "usage") add(totals, entry.usage);
|
|
71
|
+
else if (entry.type === "message" && entry.message.role === "assistant")
|
|
72
|
+
add(totals, entry.message.usage);
|
|
73
|
+
else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage)
|
|
74
|
+
add(totals, entry.message.usage);
|
|
75
|
+
else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage)
|
|
76
|
+
add(totals, entry.usage);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return totals;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How much of the last prompt came from the cache, the way pi's own footer reports it. */
|
|
83
|
+
export function cacheHitPercent(entries: readonly SessionEntry[]): number | undefined {
|
|
84
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
85
|
+
const entry = entries[index];
|
|
86
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
87
|
+
|
|
88
|
+
const usage = entry.message.usage;
|
|
89
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
|
|
90
|
+
return prompt === 0 ? undefined : (usage.cacheRead / prompt) * 100;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function cacheWarming(entries: readonly SessionEntry[]): boolean {
|
|
96
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
97
|
+
const entry = entries[index];
|
|
98
|
+
if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
|
|
99
|
+
return entry.message.usage.cacheRead === 0 && entry.message.usage.cacheWrite > 0;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function averagesOf(entries: readonly SessionEntry[]): Averages {
|
|
105
|
+
let output = 0;
|
|
106
|
+
let generationMs = 0;
|
|
107
|
+
let prompt = 0;
|
|
108
|
+
let waitingMs = 0;
|
|
109
|
+
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (entry.type !== "custom") continue;
|
|
112
|
+
if (entry.customType !== REQUEST_ENTRY && entry.customType !== LEGACY_ENTRY) continue;
|
|
113
|
+
const record = entry.data;
|
|
114
|
+
if (!isRecord(record)) continue;
|
|
115
|
+
|
|
116
|
+
output += record.output;
|
|
117
|
+
generationMs += record.generationMs;
|
|
118
|
+
if (typeof record.prompt === "number" && record.prompt > 0) {
|
|
119
|
+
prompt += record.prompt;
|
|
120
|
+
waitingMs += record.firstTokenMs;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { decode: perSecond(output, generationMs), prefill: perSecond(prompt, waitingMs) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function perSecond(tokens: number, milliseconds: number): number | undefined {
|
|
128
|
+
return tokens > 0 && milliseconds > 0 ? tokens / (milliseconds / 1000) : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isRecord(
|
|
132
|
+
data: unknown,
|
|
133
|
+
): data is Omit<RequestRecord, "prompt"> & { prompt?: number | undefined } {
|
|
134
|
+
if (typeof data !== "object" || data === null) return false;
|
|
135
|
+
const record = data as Partial<RequestRecord>;
|
|
136
|
+
return (
|
|
137
|
+
typeof record.output === "number" &&
|
|
138
|
+
typeof record.firstTokenMs === "number" &&
|
|
139
|
+
typeof record.generationMs === "number"
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function add(totals: Totals, usage: UsageLike): void {
|
|
144
|
+
totals.input += usage.input;
|
|
145
|
+
totals.output += usage.output;
|
|
146
|
+
totals.cacheRead += usage.cacheRead;
|
|
147
|
+
totals.cacheWrite += usage.cacheWrite;
|
|
148
|
+
totals.cost += usage.cost.total;
|
|
149
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// The desktop palette as a pi theme. DankMaterialShell runs matugen over the wallpaper and keeps the
|
|
2
|
+
// result in one file: the Material roles for dark and light, and sixteen terminal colours. Pi wants
|
|
3
|
+
// 51 tokens. This maps one onto the other, so the terminal, the shell and pi share one palette.
|
|
4
|
+
//
|
|
5
|
+
// Every value comes from the file. Where Material has no role, like success or a diff line, the
|
|
6
|
+
// terminal colour of that hue stands in, and tinted backgrounds are a surface mixed with that hue.
|
|
7
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
|
|
9
|
+
export const DESKTOP_THEME = "desktop";
|
|
10
|
+
|
|
11
|
+
export type Mode = "dark" | "light";
|
|
12
|
+
|
|
13
|
+
/** The parts of `dms-colors.json` the theme reads. */
|
|
14
|
+
export interface DesktopColors {
|
|
15
|
+
mode: Mode;
|
|
16
|
+
/** Material roles, like `primary` and `surface_container_high`. */
|
|
17
|
+
roles: Readonly<Record<string, string>>;
|
|
18
|
+
/** The sixteen terminal colours, `color0` to `color15`, for this mode. */
|
|
19
|
+
terminal: Readonly<Record<string, string>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ThemeJson {
|
|
23
|
+
$schema: string;
|
|
24
|
+
name: string;
|
|
25
|
+
vars: Record<string, string>;
|
|
26
|
+
colors: Record<string, string>;
|
|
27
|
+
export: { pageBg: string; cardBg: string; infoBg: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
31
|
+
|
|
32
|
+
/** Reads the file's JSON. Undefined when it is not the shape DankMaterialShell writes. */
|
|
33
|
+
export function parseDesktopColors(json: unknown, mode?: Mode): DesktopColors | undefined {
|
|
34
|
+
if (!isRecord(json) || !isRecord(json.colors) || !isRecord(json.dank16)) return undefined;
|
|
35
|
+
const chosen: Mode = mode ?? (json.mode === "light" ? "light" : "dark");
|
|
36
|
+
const roles = json.colors[chosen];
|
|
37
|
+
if (!isRecord(roles)) return undefined;
|
|
38
|
+
|
|
39
|
+
const terminal: Record<string, string> = {};
|
|
40
|
+
for (const [name, value] of Object.entries(json.dank16)) {
|
|
41
|
+
const hex = isRecord(value) ? (value[chosen] ?? value.default) : value;
|
|
42
|
+
if (typeof hex === "string" && HEX.test(hex)) terminal[name] = hex;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const material: Record<string, string> = {};
|
|
46
|
+
for (const [name, value] of Object.entries(roles)) {
|
|
47
|
+
if (typeof value === "string" && HEX.test(value)) material[name] = value;
|
|
48
|
+
}
|
|
49
|
+
return { mode: chosen, roles: material, terminal };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The roles and colours the mapping needs. A file missing any of them does not become a theme. */
|
|
53
|
+
const REQUIRED_ROLES = [
|
|
54
|
+
"primary",
|
|
55
|
+
"secondary",
|
|
56
|
+
"tertiary",
|
|
57
|
+
"error",
|
|
58
|
+
"error_container",
|
|
59
|
+
"outline",
|
|
60
|
+
"outline_variant",
|
|
61
|
+
"on_surface",
|
|
62
|
+
"on_surface_variant",
|
|
63
|
+
"surface",
|
|
64
|
+
"surface_container_low",
|
|
65
|
+
"surface_container",
|
|
66
|
+
"surface_container_high",
|
|
67
|
+
"surface_container_highest",
|
|
68
|
+
];
|
|
69
|
+
const REQUIRED_TERMINAL = ["color1", "color2", "color3", "color6", "color9"];
|
|
70
|
+
|
|
71
|
+
export function missingColors(colors: DesktopColors): string[] {
|
|
72
|
+
return [
|
|
73
|
+
...REQUIRED_ROLES.filter((role) => colors.roles[role] === undefined),
|
|
74
|
+
...REQUIRED_TERMINAL.filter((name) => colors.terminal[name] === undefined),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function desktopTheme(colors: DesktopColors): ThemeJson {
|
|
79
|
+
const role = (name: string): string => colors.roles[name] ?? "#808080";
|
|
80
|
+
const term = (name: string): string => colors.terminal[name] ?? "#808080";
|
|
81
|
+
|
|
82
|
+
const vars: Record<string, string> = {
|
|
83
|
+
primary: role("primary"),
|
|
84
|
+
secondary: role("secondary"),
|
|
85
|
+
tertiary: role("tertiary"),
|
|
86
|
+
error: role("error"),
|
|
87
|
+
outline: role("outline"),
|
|
88
|
+
outlineVariant: role("outline_variant"),
|
|
89
|
+
text: role("on_surface"),
|
|
90
|
+
subtext: role("on_surface_variant"),
|
|
91
|
+
surface: role("surface"),
|
|
92
|
+
containerLow: role("surface_container_low"),
|
|
93
|
+
container: role("surface_container"),
|
|
94
|
+
containerHigh: role("surface_container_high"),
|
|
95
|
+
containerHighest: role("surface_container_highest"),
|
|
96
|
+
red: term("color1"),
|
|
97
|
+
green: term("color2"),
|
|
98
|
+
yellow: term("color3"),
|
|
99
|
+
cyan: term("color6"),
|
|
100
|
+
brightRed: term("color9"),
|
|
101
|
+
successBg: mix(role("surface_container_low"), term("color2"), 0.12),
|
|
102
|
+
errorBg: mix(role("surface_container_low"), role("error"), 0.14),
|
|
103
|
+
infoBg: mix(role("surface_container"), term("color3"), 0.1),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const colorsByToken: Record<ThemeColor | BackgroundToken, string> = {
|
|
107
|
+
accent: "primary",
|
|
108
|
+
border: "outline",
|
|
109
|
+
borderAccent: "primary",
|
|
110
|
+
borderMuted: "outlineVariant",
|
|
111
|
+
success: "green",
|
|
112
|
+
error: "error",
|
|
113
|
+
warning: "yellow",
|
|
114
|
+
muted: "subtext",
|
|
115
|
+
dim: "outline",
|
|
116
|
+
text: "text",
|
|
117
|
+
thinkingText: "subtext",
|
|
118
|
+
|
|
119
|
+
selectedBg: "containerHigh",
|
|
120
|
+
scrollbarTrack: "outlineVariant",
|
|
121
|
+
scrollbarThumb: "subtext",
|
|
122
|
+
searchMatchBg: "containerHighest",
|
|
123
|
+
searchMatchText: "text",
|
|
124
|
+
userMessageBg: "container",
|
|
125
|
+
userMessageText: "text",
|
|
126
|
+
customMessageBg: "containerLow",
|
|
127
|
+
customMessageText: "text",
|
|
128
|
+
customMessageLabel: "tertiary",
|
|
129
|
+
toolPendingBg: "containerLow",
|
|
130
|
+
toolSuccessBg: "successBg",
|
|
131
|
+
toolErrorBg: "errorBg",
|
|
132
|
+
toolTitle: "text",
|
|
133
|
+
toolOutput: "subtext",
|
|
134
|
+
|
|
135
|
+
mdHeading: "tertiary",
|
|
136
|
+
mdLink: "primary",
|
|
137
|
+
mdLinkUrl: "outline",
|
|
138
|
+
mdCode: "secondary",
|
|
139
|
+
mdCodeBlock: "text",
|
|
140
|
+
mdCodeBlockBorder: "outlineVariant",
|
|
141
|
+
mdQuote: "subtext",
|
|
142
|
+
mdQuoteBorder: "outlineVariant",
|
|
143
|
+
mdHr: "outlineVariant",
|
|
144
|
+
mdListBullet: "primary",
|
|
145
|
+
|
|
146
|
+
toolDiffAdded: "green",
|
|
147
|
+
toolDiffRemoved: "red",
|
|
148
|
+
toolDiffContext: "subtext",
|
|
149
|
+
|
|
150
|
+
syntaxComment: "outline",
|
|
151
|
+
syntaxKeyword: "primary",
|
|
152
|
+
syntaxFunction: "tertiary",
|
|
153
|
+
syntaxVariable: "text",
|
|
154
|
+
syntaxString: "green",
|
|
155
|
+
syntaxNumber: "yellow",
|
|
156
|
+
syntaxType: "cyan",
|
|
157
|
+
syntaxOperator: "subtext",
|
|
158
|
+
syntaxPunctuation: "subtext",
|
|
159
|
+
|
|
160
|
+
// The effort ramp runs from the quiet outline through the palette's own hues to the terminal
|
|
161
|
+
// reds, which keep their meaning whatever the wallpaper, so the frame warms up with the effort.
|
|
162
|
+
thinkingOff: "outlineVariant",
|
|
163
|
+
thinkingMinimal: "outline",
|
|
164
|
+
thinkingLow: "secondary",
|
|
165
|
+
thinkingMedium: "primary",
|
|
166
|
+
thinkingHigh: "tertiary",
|
|
167
|
+
thinkingXhigh: "brightRed",
|
|
168
|
+
thinkingMax: "red",
|
|
169
|
+
|
|
170
|
+
bashMode: "green",
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
$schema:
|
|
175
|
+
"https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
176
|
+
name: DESKTOP_THEME,
|
|
177
|
+
vars,
|
|
178
|
+
colors: colorsByToken,
|
|
179
|
+
export: { pageBg: vars.surface ?? "", cardBg: vars.container ?? "", infoBg: vars.infoBg ?? "" },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type BackgroundToken =
|
|
184
|
+
| "selectedBg"
|
|
185
|
+
| "searchMatchBg"
|
|
186
|
+
| "userMessageBg"
|
|
187
|
+
| "customMessageBg"
|
|
188
|
+
| "toolPendingBg"
|
|
189
|
+
| "toolSuccessBg"
|
|
190
|
+
| "toolErrorBg";
|
|
191
|
+
|
|
192
|
+
/** `base` moved `amount` of the way towards `tint`, in sRGB, which is enough for a faint wash. */
|
|
193
|
+
export function mix(base: string, tint: string, amount: number): string {
|
|
194
|
+
const a = channels(base);
|
|
195
|
+
const b = channels(tint);
|
|
196
|
+
const mixed = a.map((value, index) => Math.round(value + ((b[index] ?? value) - value) * amount));
|
|
197
|
+
return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function channels(hex: string): number[] {
|
|
201
|
+
const value = HEX.test(hex) ? hex.slice(1) : "808080";
|
|
202
|
+
return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
206
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
207
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Keeps `~/.pi/agent/themes/desktop.json` equal to the desktop palette. Pi reloads the active theme
|
|
2
|
+
// from that folder by itself, so changing the wallpaper recolours a running pi with no restart.
|
|
3
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
import { DESKTOP_THEME, desktopTheme, missingColors, parseDesktopColors } from "./palette.ts";
|
|
11
|
+
|
|
12
|
+
/** matugen writes a few files in a burst; one rewrite per burst is enough. */
|
|
13
|
+
const SETTLE_MS = 250;
|
|
14
|
+
|
|
15
|
+
export type SyncResult =
|
|
16
|
+
| { kind: "written"; path: string }
|
|
17
|
+
| { kind: "unchanged"; path: string }
|
|
18
|
+
| { kind: "missing" }
|
|
19
|
+
| { kind: "invalid"; reason: string };
|
|
20
|
+
|
|
21
|
+
export function themePath(): string {
|
|
22
|
+
return join(getAgentDir(), "themes", `${DESKTOP_THEME}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function expandHome(path: string): string {
|
|
26
|
+
return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Writes the theme when the palette differs from what is on disk. */
|
|
30
|
+
export async function syncDesktopTheme(source: string): Promise<SyncResult> {
|
|
31
|
+
let raw: string;
|
|
32
|
+
try {
|
|
33
|
+
raw = await readFile(expandHome(source), "utf8");
|
|
34
|
+
} catch {
|
|
35
|
+
return { kind: "missing" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let json: unknown;
|
|
39
|
+
try {
|
|
40
|
+
json = JSON.parse(raw);
|
|
41
|
+
} catch {
|
|
42
|
+
return { kind: "invalid", reason: "the palette file is not JSON" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const colors = parseDesktopColors(json);
|
|
46
|
+
if (colors === undefined) return { kind: "invalid", reason: "not a DankMaterialShell palette" };
|
|
47
|
+
const missing = missingColors(colors);
|
|
48
|
+
if (missing.length > 0) return { kind: "invalid", reason: `missing ${missing.join(", ")}` };
|
|
49
|
+
|
|
50
|
+
const path = themePath();
|
|
51
|
+
const next = `${JSON.stringify(desktopTheme(colors), null, "\t")}\n`;
|
|
52
|
+
const current = await readFile(path, "utf8").catch(() => undefined);
|
|
53
|
+
if (current === next) return { kind: "unchanged", path };
|
|
54
|
+
|
|
55
|
+
await mkdir(dirname(path), { recursive: true });
|
|
56
|
+
await writeFile(path, next);
|
|
57
|
+
return { kind: "written", path };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Watches the folder rather than the file: tools that write atomically replace the file, and a
|
|
62
|
+
* watch on the old one goes quiet.
|
|
63
|
+
*/
|
|
64
|
+
export function watchDesktopPalette(source: string, changed: () => void): () => void {
|
|
65
|
+
const path = expandHome(source);
|
|
66
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
let watcher: FSWatcher | undefined;
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
watcher = watch(dirname(path), (_event, file) => {
|
|
71
|
+
if (file !== null && file !== basename(path)) return;
|
|
72
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
73
|
+
timer = setTimeout(changed, SETTLE_MS);
|
|
74
|
+
timer.unref?.();
|
|
75
|
+
});
|
|
76
|
+
watcher.unref?.();
|
|
77
|
+
watcher.on("error", () => watcher?.close());
|
|
78
|
+
} catch {
|
|
79
|
+
return () => {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
84
|
+
watcher?.close();
|
|
85
|
+
};
|
|
86
|
+
}
|