pi-jar 0.1.0 → 0.1.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/extensions/index.ts +10 -7
- package/package.json +1 -1
- package/src/animations.ts +1 -1
- package/src/changes.ts +64 -15
- package/src/composer.ts +30 -16
- package/src/diff-view.ts +25 -3
- package/src/mascot.ts +14 -0
package/extensions/index.ts
CHANGED
|
@@ -187,7 +187,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
187
187
|
ctx.ui.setWorkingMessage?.(view.message);
|
|
188
188
|
const indicatorKey = `${working.phase}:${animations}`;
|
|
189
189
|
if (indicatorKey !== workingIndicatorKey) {
|
|
190
|
-
ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs:
|
|
190
|
+
ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 600 });
|
|
191
191
|
workingIndicatorKey = indicatorKey;
|
|
192
192
|
}
|
|
193
193
|
if (active && !workingClock) {
|
|
@@ -360,15 +360,16 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
360
360
|
let frame = 0;
|
|
361
361
|
let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
362
362
|
let memoryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
const MEMORY_SAMPLE_MS = 10_000;
|
|
363
364
|
const sampleMemory = () => {
|
|
364
365
|
if (disposed) return;
|
|
365
|
-
|
|
366
|
-
tui.requestRender();
|
|
367
|
-
memoryTimer = setTimeout(sampleMemory,
|
|
366
|
+
const next = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
367
|
+
if (next !== memory) { memory = next; tui.requestRender(); }
|
|
368
|
+
memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
|
|
368
369
|
memoryTimer.unref?.();
|
|
369
370
|
};
|
|
370
371
|
if (footerSettings.memory) {
|
|
371
|
-
memoryTimer = setTimeout(sampleMemory,
|
|
372
|
+
memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
|
|
372
373
|
memoryTimer.unref?.();
|
|
373
374
|
}
|
|
374
375
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
@@ -402,8 +403,10 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
402
403
|
role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
|
|
403
404
|
if (nearest != null) expiryTimer = setTimeout(() => tui.requestRender(), Math.max(1, nearest - now));
|
|
404
405
|
const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
|
|
405
|
-
if (active && !timer)
|
|
406
|
-
|
|
406
|
+
if (active && !timer) {
|
|
407
|
+
timer = setInterval(() => { frame += 1; tui.requestRender(); }, 600);
|
|
408
|
+
timer.unref?.();
|
|
409
|
+
} else if (!active && timer) { clearInterval(timer); timer = undefined; }
|
|
407
410
|
const usage = ctx.getContextUsage();
|
|
408
411
|
const context = usage?.percent == null || !Number.isFinite(usage.percent)
|
|
409
412
|
? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/animations.ts
CHANGED
|
@@ -20,4 +20,4 @@ export function roleFrame(state: RoleState, frame: number, animations = true): s
|
|
|
20
20
|
|
|
21
21
|
export const WORKING_FRAMES = ["◇", "◆", "◇", "◈"] as const;
|
|
22
22
|
/** Pixel-fire tick for the welcome flame; frozen when motion is disabled. */
|
|
23
|
-
export const WELCOME_INTERVAL_MS =
|
|
23
|
+
export const WELCOME_INTERVAL_MS = 150;
|
package/src/changes.ts
CHANGED
|
@@ -4,11 +4,14 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
4
4
|
/** Files larger than this, or binary files, are not tracked for review. */
|
|
5
5
|
export const MAX_TRACKED_BYTES = 1024 * 1024;
|
|
6
6
|
export const MAX_TRACKED_FILES = 200;
|
|
7
|
-
/**
|
|
8
|
-
const
|
|
7
|
+
/** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
|
|
8
|
+
export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
/** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
|
|
10
|
+
const MAX_DIFF_CELLS = 500_000;
|
|
11
|
+
const MAX_DIFF_MIDDLE_LINES = 4_000;
|
|
9
12
|
|
|
10
13
|
export type ChangeStatus = "added" | "modified" | "deleted";
|
|
11
|
-
export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number }
|
|
14
|
+
export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
|
|
12
15
|
export type DiffOp = { op: " " | "+" | "-"; text: string };
|
|
13
16
|
export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number };
|
|
14
17
|
|
|
@@ -32,10 +35,11 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
|
|
|
32
35
|
let endA = a.length, endB = b.length;
|
|
33
36
|
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
|
|
34
37
|
const midA = a.slice(start, endA), midB = b.slice(start, endB);
|
|
35
|
-
if (midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
|
|
38
|
+
if (midA.length + midB.length > MAX_DIFF_MIDDLE_LINES || midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
|
|
36
39
|
const n = midA.length, m = midB.length;
|
|
37
|
-
// lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened.
|
|
38
|
-
|
|
40
|
+
// lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened. The matrix is
|
|
41
|
+
// intentionally capped above and Uint16 is enough because the capped middle is < 65k lines.
|
|
42
|
+
const lcs = new Uint16Array((n + 1) * (m + 1));
|
|
39
43
|
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
|
|
40
44
|
lcs[i * (m + 1) + j] = midA[i] === midB[j] ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
|
|
41
45
|
: Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
|
|
@@ -95,10 +99,20 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
|
|
|
95
99
|
*/
|
|
96
100
|
export class ChangeTracker {
|
|
97
101
|
private baselines = new Map<string, string | null>();
|
|
102
|
+
private dirty = new Set<string>();
|
|
103
|
+
private diffCache = new Map<string, FileChange>();
|
|
104
|
+
private baselineBytes = 0;
|
|
98
105
|
private readonly cwd: () => string;
|
|
99
106
|
constructor(cwd: () => string) { this.cwd = cwd; }
|
|
100
107
|
|
|
101
108
|
private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
|
|
109
|
+
private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
|
|
110
|
+
private drop(target: string): void {
|
|
111
|
+
if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
|
|
112
|
+
this.baselines.delete(target);
|
|
113
|
+
this.dirty.delete(target);
|
|
114
|
+
this.diffCache.delete(target);
|
|
115
|
+
}
|
|
102
116
|
|
|
103
117
|
/** Record the pre-change content once; returns false when the file cannot be tracked. */
|
|
104
118
|
capture(path: string): boolean {
|
|
@@ -110,29 +124,64 @@ export class ChangeTracker {
|
|
|
110
124
|
if (this.baselines.size >= MAX_TRACKED_FILES) return false;
|
|
111
125
|
const content = readText(target);
|
|
112
126
|
if (content === undefined) return false;
|
|
127
|
+
const bytes = this.bytes(content);
|
|
128
|
+
if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
113
129
|
this.baselines.set(target, content);
|
|
130
|
+
this.baselineBytes += bytes;
|
|
114
131
|
return true;
|
|
115
132
|
}
|
|
116
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Mark one captured file after an edit/write completes. This performs only one cheap file read;
|
|
136
|
+
* no line diff is calculated until the review UI is opened.
|
|
137
|
+
*/
|
|
138
|
+
markDirty(path: string): boolean {
|
|
139
|
+
const target = this.absolute(path);
|
|
140
|
+
if (!this.baselines.has(target)) return false;
|
|
141
|
+
const before = this.baselines.get(target)!;
|
|
142
|
+
const now = readText(target);
|
|
143
|
+
this.diffCache.delete(target);
|
|
144
|
+
if (now === undefined) { this.drop(target); return false; }
|
|
145
|
+
if (now === before) {
|
|
146
|
+
this.dirty.delete(target);
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
this.dirty.add(target);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Materialize detailed diffs lazily for the review UI only. */
|
|
117
154
|
changes(): FileChange[] {
|
|
118
155
|
const result: FileChange[] = [];
|
|
119
|
-
for (const
|
|
156
|
+
for (const path of [...this.dirty]) {
|
|
157
|
+
const before = this.baselines.get(path);
|
|
158
|
+
if (before === undefined) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
|
|
120
159
|
const now = readText(path);
|
|
121
|
-
if (now === undefined
|
|
160
|
+
if (now === undefined) { this.drop(path); continue; }
|
|
161
|
+
if (now === before) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
|
|
162
|
+
const cached = this.diffCache.get(path);
|
|
163
|
+
if (cached) { result.push(cached); continue; }
|
|
122
164
|
const ops = lineDiff(before ?? "", now ?? "");
|
|
123
165
|
const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
|
|
124
166
|
const removed = ops ? ops.filter((op) => op.op === "-").length : splitLines(before ?? "").length;
|
|
125
167
|
const rel = relative(this.cwd(), path);
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
const change: FileChange = {
|
|
169
|
+
path, rel: rel && !rel.startsWith("..") ? rel : path,
|
|
170
|
+
status: before === null ? "added" : now === null ? "deleted" : "modified",
|
|
171
|
+
before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
|
|
172
|
+
};
|
|
173
|
+
this.diffCache.set(path, change);
|
|
174
|
+
result.push(change);
|
|
128
175
|
}
|
|
129
176
|
return result.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
130
177
|
}
|
|
131
178
|
|
|
132
|
-
|
|
179
|
+
/** O(1): the footer never calculates file diffs just to show its badge. */
|
|
180
|
+
count(): number { return this.dirty.size; }
|
|
181
|
+
trackedBytes(): number { return this.baselineBytes; }
|
|
133
182
|
/** Keep the current content: stop tracking the file. */
|
|
134
|
-
accept(path: string): void { this.
|
|
135
|
-
acceptAll(): void { this.baselines.clear(); }
|
|
183
|
+
accept(path: string): void { this.drop(this.absolute(path)); }
|
|
184
|
+
acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.diffCache.clear(); this.baselineBytes = 0; }
|
|
136
185
|
/** Restore the pre-change content (or remove a file the agent created). */
|
|
137
186
|
revert(path: string): void {
|
|
138
187
|
const target = this.absolute(path);
|
|
@@ -140,7 +189,7 @@ export class ChangeTracker {
|
|
|
140
189
|
const before = this.baselines.get(target)!;
|
|
141
190
|
if (before === null) { if (existsSync(target)) unlinkSync(target); }
|
|
142
191
|
else writeFileSync(target, before);
|
|
143
|
-
this.
|
|
192
|
+
this.drop(target);
|
|
144
193
|
}
|
|
145
|
-
clear(): void { this.
|
|
194
|
+
clear(): void { this.acceptAll(); }
|
|
146
195
|
}
|
package/src/composer.ts
CHANGED
|
@@ -11,6 +11,7 @@ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
|
|
|
11
11
|
const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
|
|
12
12
|
idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
|
|
13
13
|
};
|
|
14
|
+
export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
|
|
14
15
|
/** Plain one-line face for a phase; every phase has the same width. */
|
|
15
16
|
export function composerIcon(phase: WorkingPhase, frame = 0): string {
|
|
16
17
|
const moods = PHASE_MOODS[phase];
|
|
@@ -256,7 +257,7 @@ export class ComposerStyle {
|
|
|
256
257
|
private owner?: EditorFactory;
|
|
257
258
|
private previous?: EditorFactory;
|
|
258
259
|
private tui?: TUI;
|
|
259
|
-
private timer?: ReturnType<typeof
|
|
260
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
260
261
|
private phase: WorkingPhase = "idle";
|
|
261
262
|
private frame = 0;
|
|
262
263
|
private animations = true;
|
|
@@ -291,7 +292,7 @@ export class ComposerStyle {
|
|
|
291
292
|
return text;
|
|
292
293
|
},
|
|
293
294
|
typed: () => this.suggestions?.clear(),
|
|
294
|
-
poke: () => { this.mascot.flash("poke", 1500); this.tui?.requestRender(); },
|
|
295
|
+
poke: () => { this.mascot.flash("poke", 1500); this.restartTimer(); this.tui?.requestRender(); },
|
|
295
296
|
dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
|
|
296
297
|
attachments: (text) => {
|
|
297
298
|
if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
|
|
@@ -318,36 +319,49 @@ export class ComposerStyle {
|
|
|
318
319
|
/** Short-lived expression, e.g. on errors or a completed goal. */
|
|
319
320
|
flash(mood: "error" | "complete" | "poke", ms = 3000): void {
|
|
320
321
|
this.mascot.flash(mood, ms);
|
|
322
|
+
this.restartTimer();
|
|
321
323
|
if (this.enabled) this.tui?.requestRender();
|
|
322
324
|
}
|
|
323
325
|
|
|
324
326
|
setActivity(phase: WorkingPhase, animations: boolean): void {
|
|
325
327
|
const changed = this.phase !== phase || this.animations !== animations;
|
|
326
328
|
this.mascot.setPhase(phase);
|
|
327
|
-
if (!changed
|
|
329
|
+
if (!changed) return;
|
|
328
330
|
this.phase = phase;
|
|
329
331
|
this.animations = animations;
|
|
330
|
-
|
|
332
|
+
this.frame = 0;
|
|
331
333
|
this.restartTimer();
|
|
332
|
-
if (
|
|
334
|
+
if (this.enabled) this.tui?.requestRender();
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
/**
|
|
337
|
+
/**
|
|
338
|
+
* Active motion ticks at a modest 600 ms. Idle motion has no polling loop at all: it sleeps
|
|
339
|
+
* until the mascot's next blink/sleep/flash deadline, then schedules the following deadline.
|
|
340
|
+
*/
|
|
336
341
|
private restartTimer(): void {
|
|
337
342
|
this.stopTimer();
|
|
338
|
-
if (!this.enabled || !this.animations) return;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
343
|
+
if (!this.enabled || !this.animations || !this.mascotOn) return;
|
|
344
|
+
this.lastKey = this.mascot.key(Date.now(), this.frame);
|
|
345
|
+
this.scheduleTimer();
|
|
346
|
+
}
|
|
347
|
+
private scheduleTimer(): void {
|
|
348
|
+
if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
|
|
349
|
+
const active = this.phase === "generating" || this.phase === "tool" || this.phase === "waiting";
|
|
350
|
+
const delay = active ? COMPOSER_ACTIVE_INTERVAL_MS : this.mascot.nextTransitionDelay(Date.now());
|
|
351
|
+
if (delay === undefined) return;
|
|
352
|
+
this.timer = setTimeout(() => {
|
|
353
|
+
this.timer = undefined;
|
|
342
354
|
this.frame++;
|
|
343
|
-
const key = this.
|
|
344
|
-
if (key
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
355
|
+
const key = this.mascot.key(Date.now(), this.frame);
|
|
356
|
+
if (key !== this.lastKey) {
|
|
357
|
+
this.lastKey = key;
|
|
358
|
+
this.tui?.requestRender();
|
|
359
|
+
}
|
|
360
|
+
this.scheduleTimer();
|
|
361
|
+
}, delay);
|
|
348
362
|
this.timer.unref?.();
|
|
349
363
|
}
|
|
350
|
-
private stopTimer(): void { if (this.timer)
|
|
364
|
+
private stopTimer(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; }
|
|
351
365
|
refreshSession(ctx: ExtensionContext): void {
|
|
352
366
|
const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
|
|
353
367
|
const next = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
|
package/src/diff-view.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, "
|
|
|
18
18
|
|
|
19
19
|
/** Styled diff rows for one file, with a gutter of old/new line numbers. */
|
|
20
20
|
export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string): string[] {
|
|
21
|
-
const ops = lineDiff(change.before, change.after);
|
|
21
|
+
const ops = change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after));
|
|
22
22
|
if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
|
|
23
23
|
const rows: DiffRow[] = diffRows(ops);
|
|
24
24
|
if (!rows.length) return [fg("dim", "No textual changes.")];
|
|
@@ -142,12 +142,34 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
|
|
|
142
142
|
|
|
143
143
|
/** Track edit/write targets before they run, and expose `/diff` plus ctrl+alt+d. */
|
|
144
144
|
export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTracker | undefined, changed: () => void): void {
|
|
145
|
+
// Keep the path that belonged to each in-flight edit/write so tool completion dirties only
|
|
146
|
+
// that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
|
|
147
|
+
const pending = new Map<string, string>();
|
|
148
|
+
const fallback: string[] = [];
|
|
145
149
|
pi.on("tool_call", (event) => {
|
|
146
150
|
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
147
151
|
const path = (event.input as { path?: unknown }).path;
|
|
148
|
-
if (typeof path
|
|
152
|
+
if (typeof path !== "string" || !path.trim() || !tracker()?.capture(path)) return;
|
|
153
|
+
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
154
|
+
if (typeof id === "string" && id) pending.set(id, path);
|
|
155
|
+
fallback.push(path);
|
|
156
|
+
});
|
|
157
|
+
pi.on("tool_result", (event) => {
|
|
158
|
+
if (event.toolName !== "edit" && event.toolName !== "write") return;
|
|
159
|
+
const id = (event as { toolCallId?: unknown }).toolCallId;
|
|
160
|
+
let path: string | undefined;
|
|
161
|
+
if (typeof id === "string" && id) {
|
|
162
|
+
path = pending.get(id);
|
|
163
|
+
pending.delete(id);
|
|
164
|
+
if (path) {
|
|
165
|
+
const index = fallback.indexOf(path);
|
|
166
|
+
if (index >= 0) fallback.splice(index, 1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!path) path = fallback.shift();
|
|
170
|
+
if (path) tracker()?.markDirty(path);
|
|
171
|
+
changed();
|
|
149
172
|
});
|
|
150
|
-
pi.on("tool_result", (event) => { if (event.toolName === "edit" || event.toolName === "write") changed(); });
|
|
151
173
|
const open = async (ctx: ExtensionContext) => {
|
|
152
174
|
const current = tracker();
|
|
153
175
|
if (!current) return;
|
package/src/mascot.ts
CHANGED
|
@@ -79,6 +79,20 @@ export class Mascot {
|
|
|
79
79
|
return tips[((frame % tips.length) + tips.length) % tips.length]!;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Milliseconds until an idle mascot can visibly change again. Active phases are driven by
|
|
84
|
+
* the composer's low-frequency animation clock; idle uses this deadline instead of polling.
|
|
85
|
+
*/
|
|
86
|
+
nextTransitionDelay(now = Date.now()): number | undefined {
|
|
87
|
+
const current = this.mood(now);
|
|
88
|
+
if (this.flashMood && now < this.flashMood.until) return Math.max(16, this.flashMood.until - now);
|
|
89
|
+
if (this.phase !== "idle") return undefined;
|
|
90
|
+
const sleepyAt = this.lastActive + SLEEPY_AFTER_MS;
|
|
91
|
+
if (now >= sleepyAt) return undefined;
|
|
92
|
+
const next = current === "blink" ? Math.min(this.nextBlink + BLINK_MS, sleepyAt) : Math.min(this.nextBlink, sleepyAt);
|
|
93
|
+
return Math.max(16, next - now);
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
/** Visual identity for change detection (avoids needless renders). */
|
|
83
97
|
key(now = Date.now(), frame = 0): string { return this.mood(now) + ":" + this.tip(now, frame); }
|
|
84
98
|
}
|