@hayasaka7/haya-pet 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/apps/cli/src/haya-pet.js +34 -0
- package/apps/cli/test/haya-pet.test.mjs +50 -0
- package/apps/companion/src/renderer/pet-hit-test.js +23 -0
- package/apps/companion/src/renderer/pet-window.js +62 -6
- package/apps/companion/src/renderer/session-bubbles.js +14 -9
- package/apps/companion/src/renderer/styles.css +5 -1
- package/apps/companion/test/pet-hit-test.test.mjs +30 -0
- package/apps/companion/test/session-bubbles.test.mjs +28 -0
- package/docs/architecture.md +10 -3
- package/docs/known-issues.md +26 -0
- package/package.json +1 -1
- package/packages/adapters/src/claude-transcript.js +16 -0
- package/packages/adapters/src/codex-transcript.js +13 -2
- package/packages/adapters/test/claude-transcript.test.mjs +38 -0
- package/packages/adapters/test/codex-transcript.test.mjs +22 -0
- package/packages/cli-core/src/claude-transcript-watcher.js +3 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +28 -0
- package/packages/cli-core/test/codex-transcript-watcher.test.mjs +29 -0
- package/packages/pet-core/src/atlas.js +1 -0
- package/packages/protocol/src/messages.js +3 -0
- package/packages/protocol/test/messages.test.mjs +3 -0
- package/packages/session-core/src/bubble-view.js +5 -1
- package/packages/session-core/src/pet-state.js +6 -4
- package/packages/session-core/src/summaries.js +15 -0
- package/packages/session-core/test/bubble-linger.test.mjs +16 -0
- package/packages/session-core/test/bubble-view.test.mjs +16 -0
- package/packages/session-core/test/pet-state.test.mjs +23 -0
- package/packages/session-core/test/summaries.test.mjs +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,37 @@ All notable changes to HAYA Pet are documented here. This project adheres to
|
|
|
7
7
|
> 0.2.0 npm publish; they are listed under 0.2.1, which is the first version that
|
|
8
8
|
> ships them.
|
|
9
9
|
|
|
10
|
+
## [0.3.2]
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Session bubble titles no longer run off the screen.** A long project name
|
|
14
|
+
used to stretch the bubble out to the panel's full width. The title now keeps
|
|
15
|
+
the **client name in full** (Codex, Claude Code, …) and shows the **project
|
|
16
|
+
name capped at 10 characters** with an ellipsis when it's longer (e.g.
|
|
17
|
+
`netdisk-server` → `netdisk-se...`); the complete `Client · Project` is kept
|
|
18
|
+
as a hover tooltip so nothing is lost.
|
|
19
|
+
|
|
20
|
+
## [0.3.1]
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Clicks pass through the empty space around the pet.** The overlay used the
|
|
24
|
+
pet's whole rectangular sprite cell as its click target, so the transparent
|
|
25
|
+
margins around the character still swallowed clicks meant for the window
|
|
26
|
+
behind it. The pet now intercepts the mouse only where the current frame
|
|
27
|
+
actually has opaque pixels — sampled live from the canvas, with no per-frame
|
|
28
|
+
cost — so the catch area hugs the visible silhouette. The resize grip is
|
|
29
|
+
unaffected: it still reveals and works across the pet's full bounding box.
|
|
30
|
+
- **Interrupting a turn no longer leaves the pet stuck "thinking".** When you
|
|
31
|
+
press Esc while the agent is working — especially mid-thought, with no tool
|
|
32
|
+
running — neither Claude Code nor Codex fires a hook (`Stop` only fires on a
|
|
33
|
+
normal turn end), so the pet kept spinning on *thinking* until the 30 s stale
|
|
34
|
+
sweep. The transcript watchers now recognise each client's interrupt marker
|
|
35
|
+
(Claude's `[Request interrupted by user]` message; Codex's `turn_aborted`
|
|
36
|
+
record) and report a new `interrupted` status — a red ✕ that, unlike a real
|
|
37
|
+
failure, is **not** treated as a finished session, so the bubble stays put
|
|
38
|
+
(the session is still alive) instead of disappearing, and returns to the live
|
|
39
|
+
status as soon as you continue.
|
|
40
|
+
|
|
10
41
|
## [0.3.0]
|
|
11
42
|
|
|
12
43
|
### Fixed
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -241,6 +241,22 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
241
241
|
updatedAt: now()
|
|
242
242
|
})
|
|
243
243
|
.catch(() => {});
|
|
244
|
+
},
|
|
245
|
+
// Esc-interrupt fires no Stop hook, so without this the pet stays stuck on
|
|
246
|
+
// "thinking"/"running". The transcript's interrupt marker is the only signal.
|
|
247
|
+
onInterrupt: () => {
|
|
248
|
+
hookDebugLog(env, now, { source: "transcript", event: "interrupted", state: "interrupted" });
|
|
249
|
+
messageSender
|
|
250
|
+
.send({
|
|
251
|
+
type: "state",
|
|
252
|
+
sessionId,
|
|
253
|
+
state: "interrupted",
|
|
254
|
+
summary: "interrupted",
|
|
255
|
+
confidence: 0.9,
|
|
256
|
+
source: "client_log",
|
|
257
|
+
updatedAt: now()
|
|
258
|
+
})
|
|
259
|
+
.catch(() => {});
|
|
244
260
|
}
|
|
245
261
|
});
|
|
246
262
|
stopWatcher = watcher.stop;
|
|
@@ -277,6 +293,24 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
277
293
|
state: event.state
|
|
278
294
|
});
|
|
279
295
|
|
|
296
|
+
// Esc-interrupt fires no Stop hook, so without this the pet stays stuck
|
|
297
|
+
// on "thinking"/"running" until the stale sweep.
|
|
298
|
+
if (event.type === "turn_aborted") {
|
|
299
|
+
activeToolCalls.clear();
|
|
300
|
+
messageSender
|
|
301
|
+
.send({
|
|
302
|
+
type: "state",
|
|
303
|
+
sessionId,
|
|
304
|
+
state: "interrupted",
|
|
305
|
+
summary: "interrupted",
|
|
306
|
+
confidence: 0.9,
|
|
307
|
+
source: "client_log",
|
|
308
|
+
updatedAt: now()
|
|
309
|
+
})
|
|
310
|
+
.catch(() => {});
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
280
314
|
if (event.type === "tool_started") {
|
|
281
315
|
activeToolCalls.add(event.toolCallId);
|
|
282
316
|
messageSender
|
|
@@ -683,6 +683,56 @@ test("a transcript denial clears the stuck approval to idle", async () => {
|
|
|
683
683
|
assert.equal(idle.updatedAt, 42);
|
|
684
684
|
});
|
|
685
685
|
|
|
686
|
+
test("a transcript interrupt reports a failed status for Claude", async () => {
|
|
687
|
+
const sent = [];
|
|
688
|
+
let fireInterrupt;
|
|
689
|
+
await runAiPet(["run", "--client", "claude-code", "--", "claude"], {
|
|
690
|
+
cwd: process.cwd(),
|
|
691
|
+
env: { HAYA_PET_HOOKS: "1", USERPROFILE: "C:\\Users\\A" },
|
|
692
|
+
now: () => 42,
|
|
693
|
+
heartbeatIntervalMs: 10,
|
|
694
|
+
send: async (message) => sent.push(message),
|
|
695
|
+
injectClaudeHooks: () => ({ settingsPath: "/tmp/s.json", cleanup: () => {} }),
|
|
696
|
+
watchClaudeTranscript: ({ onInterrupt }) => { fireInterrupt = onInterrupt; return { stop: () => {} }; },
|
|
697
|
+
runGenericCommand: async (options) => {
|
|
698
|
+
// Simulate the user pressing Esc to interrupt mid-turn.
|
|
699
|
+
fireInterrupt({ type: "interrupted" });
|
|
700
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log");
|
|
705
|
+
assert.ok(interrupted, "a client_log state was sent on interrupt");
|
|
706
|
+
assert.equal(interrupted.state, "interrupted");
|
|
707
|
+
assert.equal(interrupted.summary, "interrupted");
|
|
708
|
+
assert.equal(interrupted.updatedAt, 42);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
test("a transcript turn_aborted reports a failed status for Codex", async () => {
|
|
712
|
+
const sent = [];
|
|
713
|
+
let fireToolEvent;
|
|
714
|
+
await runAiPet(["run", "--client", "codex", "--", "codex"], {
|
|
715
|
+
cwd: process.cwd(),
|
|
716
|
+
env: { USERPROFILE: "C:\\Users\\A" },
|
|
717
|
+
now: () => 42,
|
|
718
|
+
heartbeatIntervalMs: 10,
|
|
719
|
+
send: async (message) => sent.push(message),
|
|
720
|
+
createStateFile: hooksStateFile(true),
|
|
721
|
+
injectCodexHooks: () => ({ profileName: "haya-pet", cleanup: () => {} }),
|
|
722
|
+
watchCodexTranscript: ({ onToolEvent }) => { fireToolEvent = onToolEvent; return { stop: () => {} }; },
|
|
723
|
+
runGenericCommand: async (options) => {
|
|
724
|
+
// Simulate the user pressing Esc: Codex writes a turn_aborted record.
|
|
725
|
+
fireToolEvent({ type: "turn_aborted", reason: "interrupted" });
|
|
726
|
+
return { sessionId: options.sessionId, pid: 1, exitCode: 0 };
|
|
727
|
+
}
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const interrupted = sent.find((m) => m.type === "state" && m.source === "client_log" && m.state === "interrupted");
|
|
731
|
+
assert.ok(interrupted, "a client_log interrupted state was sent on turn_aborted");
|
|
732
|
+
assert.equal(interrupted.summary, "interrupted");
|
|
733
|
+
assert.equal(interrupted.updatedAt, 42);
|
|
734
|
+
});
|
|
735
|
+
|
|
686
736
|
test("non-hook-capable clients are never injected even with HAYA_PET_HOOKS=1", async () => {
|
|
687
737
|
const calls = [];
|
|
688
738
|
await runAiPet(["run", "--client", "generic", "--", "aider"], {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Pure helpers for the pet's pixel-precise click-through. The browser glue in
|
|
2
|
+
// pet-window.js supplies the live pixel alpha and the canvas bounding box; these
|
|
3
|
+
// functions hold the (testable) decisions.
|
|
4
|
+
|
|
5
|
+
// Default minimum alpha for a pixel to count as "the pet" rather than the
|
|
6
|
+
// transparent margin of its sprite cell. A small threshold ignores faint
|
|
7
|
+
// anti-aliased edges so the click-through hugs the visible silhouette.
|
|
8
|
+
export const DEFAULT_ALPHA_THRESHOLD = 16;
|
|
9
|
+
|
|
10
|
+
// Top-left inclusive, bottom-right exclusive — matches canvas pixel indexing so
|
|
11
|
+
// the same predicate works for both bounding-box and per-pixel tests.
|
|
12
|
+
export function isPointInsideRect(point, rect) {
|
|
13
|
+
return (
|
|
14
|
+
point.x >= rect.left &&
|
|
15
|
+
point.x < rect.right &&
|
|
16
|
+
point.y >= rect.top &&
|
|
17
|
+
point.y < rect.bottom
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isOpaqueAlpha(alpha, threshold = DEFAULT_ALPHA_THRESHOLD) {
|
|
22
|
+
return alpha >= threshold;
|
|
23
|
+
}
|
|
@@ -19,12 +19,15 @@ import { resolvePanelPlacement } from "../main/panel-placement.js";
|
|
|
19
19
|
import { resolveBubbleListMaxHeight } from "../main/bubble-list-viewport.js";
|
|
20
20
|
import { createInteractionController } from "./interaction-controller.js";
|
|
21
21
|
import { createBubbleList } from "./session-bubbles.js";
|
|
22
|
+
import { isOpaqueAlpha, isPointInsideRect } from "./pet-hit-test.js";
|
|
22
23
|
|
|
23
24
|
const bridge = window.aiPet;
|
|
24
25
|
const petEl = document.getElementById("pet");
|
|
25
26
|
const canvas = document.getElementById("pet-canvas");
|
|
26
27
|
const gripEl = document.getElementById("pet-resize-grip");
|
|
27
|
-
|
|
28
|
+
// willReadFrequently: the click-through hit-test samples a single pixel on every
|
|
29
|
+
// pointer move (see pointerHitsPetPixel), so keep the canvas CPU-backed.
|
|
30
|
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
|
28
31
|
const panelEl = document.getElementById("bubbles");
|
|
29
32
|
|
|
30
33
|
// The sprite's natural cell size; the canvas is this times the user's scale.
|
|
@@ -190,6 +193,10 @@ function playOneShot(action) {
|
|
|
190
193
|
|
|
191
194
|
canvas.addEventListener("pointerdown", (event) => {
|
|
192
195
|
canvas.setPointerCapture(event.pointerId);
|
|
196
|
+
// Hold click-through off for the whole press: a drag swaps to the running
|
|
197
|
+
// frames, whose opaque pixels differ from the grabbed one, so re-running the
|
|
198
|
+
// pixel test mid-gesture could flip the window to pass-through and drop it.
|
|
199
|
+
petPressed = true;
|
|
193
200
|
dragOffset = { x: event.offsetX, y: event.offsetY };
|
|
194
201
|
// Window-local (clientX/Y) coords throughout — the overlay covers the work area.
|
|
195
202
|
controller.pointerDown({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
@@ -206,11 +213,18 @@ canvas.addEventListener("pointermove", (event) => {
|
|
|
206
213
|
canvas.addEventListener("pointerup", (event) => {
|
|
207
214
|
// Click / double-click are delivered asynchronously via onAction; only the
|
|
208
215
|
// synchronous drag-end is handled here.
|
|
216
|
+
petPressed = false;
|
|
209
217
|
const result = controller.pointerUp({ x: event.clientX, y: event.clientY, time: performance.now() });
|
|
210
218
|
if (result?.type === "drag-end") {
|
|
211
219
|
animationState = clearDragAction(animationState);
|
|
212
220
|
bridge?.savePetPosition?.(petLocal);
|
|
213
221
|
}
|
|
222
|
+
refreshMouseIgnore(event.clientX, event.clientY);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
canvas.addEventListener("pointercancel", () => {
|
|
226
|
+
petPressed = false;
|
|
227
|
+
animationState = clearDragAction(animationState);
|
|
214
228
|
});
|
|
215
229
|
|
|
216
230
|
// --- Resize grip: drag to scale the pet, double-click to reset ---
|
|
@@ -310,23 +324,54 @@ function renderBubbles() {
|
|
|
310
324
|
// The overlay window covers a large area but should only intercept the mouse
|
|
311
325
|
// over the pet and the bubble chips; everywhere else must pass through to the
|
|
312
326
|
// desktop. The window is created ignoring mouse events (with forwarding), and
|
|
313
|
-
// we flip it back on whenever the cursor is over an `.interactive` element.
|
|
327
|
+
// we flip it back on whenever the cursor is over an `.interactive` element. Over
|
|
328
|
+
// the pet canvas we go further and only intercept where the sprite has opaque
|
|
329
|
+
// pixels, so the transparent margins of the cell pass clicks through too.
|
|
314
330
|
|
|
315
331
|
let mouseIgnored;
|
|
332
|
+
let petPressed = false; // a press/drag is in progress on the pet canvas
|
|
316
333
|
let lastPointer = { x: -1, y: -1 };
|
|
317
334
|
|
|
335
|
+
// True when the cursor is over a non-transparent pixel of the current frame. The
|
|
336
|
+
// canvas already holds the current frame at the current scale, so sampling it
|
|
337
|
+
// directly needs no atlas math. (Electron lets us read a file://-drawn canvas;
|
|
338
|
+
// if a future version ever taints it, getImageData throws and we fall back to
|
|
339
|
+
// treating the whole canvas box as interactive.)
|
|
340
|
+
function pointerHitsPetPixel(x, y) {
|
|
341
|
+
if (!spritesheet) {
|
|
342
|
+
return true; // dev placeholder has no sprite: keep the whole box interactive
|
|
343
|
+
}
|
|
344
|
+
const rect = canvas.getBoundingClientRect();
|
|
345
|
+
if (!isPointInsideRect({ x, y }, rect)) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
const lx = Math.floor(x - rect.left);
|
|
349
|
+
const ly = Math.floor(y - rect.top);
|
|
350
|
+
try {
|
|
351
|
+
return isOpaqueAlpha(ctx.getImageData(lx, ly, 1, 1).data[3]);
|
|
352
|
+
} catch {
|
|
353
|
+
return true; // tainted canvas: degrade to full-box interactivity
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
318
357
|
function refreshMouseIgnore(x, y) {
|
|
319
358
|
// While the grip is captured, the pointer can briefly leave it (the pet only
|
|
320
359
|
// approximately tracks the diagonal); flipping click-through mid-drag would
|
|
321
|
-
// drop the pointerup, so hold interaction until the drag ends.
|
|
322
|
-
|
|
360
|
+
// drop the pointerup, so hold interaction until the drag ends. The same holds
|
|
361
|
+
// for a press/drag on the pet body (petPressed).
|
|
362
|
+
if (resizeDrag || petPressed) {
|
|
323
363
|
return;
|
|
324
364
|
}
|
|
325
365
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
326
366
|
return;
|
|
327
367
|
}
|
|
328
|
-
const
|
|
329
|
-
|
|
368
|
+
const interactiveEl = document.elementFromPoint(x, y)?.closest(".interactive");
|
|
369
|
+
let interactive = Boolean(interactiveEl);
|
|
370
|
+
// Pixel precision applies only to the pet body; the grip and bubbles (their
|
|
371
|
+
// own .interactive elements) keep their full hit area.
|
|
372
|
+
if (interactiveEl === canvas) {
|
|
373
|
+
interactive = pointerHitsPetPixel(x, y);
|
|
374
|
+
}
|
|
330
375
|
const ignore = !interactive;
|
|
331
376
|
if (ignore !== mouseIgnored) {
|
|
332
377
|
mouseIgnored = ignore;
|
|
@@ -334,9 +379,20 @@ function refreshMouseIgnore(x, y) {
|
|
|
334
379
|
}
|
|
335
380
|
}
|
|
336
381
|
|
|
382
|
+
// The resize grip reveals whenever the cursor is over the pet's bounding box —
|
|
383
|
+
// the same judgement as before — kept independent of the pixel-precise
|
|
384
|
+
// click-through so passing clicks through transparent areas never hides it. The
|
|
385
|
+
// window forwards move events even while click-through is on, so this stays
|
|
386
|
+
// accurate over transparent (passed-through) areas too.
|
|
387
|
+
function refreshGripVisibility(x, y) {
|
|
388
|
+
const inside = Number.isFinite(x) && Number.isFinite(y) && isPointInsideRect({ x, y }, canvas.getBoundingClientRect());
|
|
389
|
+
petEl.classList.toggle("show-grip", inside || Boolean(resizeDrag));
|
|
390
|
+
}
|
|
391
|
+
|
|
337
392
|
window.addEventListener("mousemove", (event) => {
|
|
338
393
|
lastPointer = { x: event.clientX, y: event.clientY };
|
|
339
394
|
refreshMouseIgnore(event.clientX, event.clientY);
|
|
395
|
+
refreshGripVisibility(event.clientX, event.clientY);
|
|
340
396
|
});
|
|
341
397
|
|
|
342
398
|
if (bridge) {
|
|
@@ -218,6 +218,15 @@ function renderBubble(bubble) {
|
|
|
218
218
|
const title = document.createElement("div");
|
|
219
219
|
title.className = "title";
|
|
220
220
|
|
|
221
|
+
// Persistent title parts (mutated in place across updates, like everything
|
|
222
|
+
// else here). The client name is always shown in full; the project name is
|
|
223
|
+
// shown compact (bubble.projectLabel) with the full name kept as a tooltip.
|
|
224
|
+
const client = document.createElement("span");
|
|
225
|
+
client.className = "client";
|
|
226
|
+
const project = document.createElement("span");
|
|
227
|
+
project.className = "project";
|
|
228
|
+
title.append(client, project);
|
|
229
|
+
|
|
221
230
|
const activity = document.createElement("div");
|
|
222
231
|
activity.className = "activity";
|
|
223
232
|
|
|
@@ -239,8 +248,11 @@ function applyBubble(el, bubble) {
|
|
|
239
248
|
icon.title = bubble.statusLabel;
|
|
240
249
|
|
|
241
250
|
const [title, activity] = body.children;
|
|
242
|
-
|
|
243
|
-
|
|
251
|
+
const [client, project] = title.children;
|
|
252
|
+
client.textContent = bubble.clientName;
|
|
253
|
+
project.textContent = bubble.projectLabel ?? bubble.projectName;
|
|
254
|
+
// Hover reveals the full, untruncated "Client · Project".
|
|
255
|
+
title.title = bubble.projectName ? `${bubble.clientName} · ${bubble.projectName}` : bubble.clientName;
|
|
244
256
|
activity.textContent = bubble.summary;
|
|
245
257
|
activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
|
|
246
258
|
}
|
|
@@ -257,10 +269,3 @@ function mostUrgentKind(bubbles) {
|
|
|
257
269
|
}
|
|
258
270
|
return best;
|
|
259
271
|
}
|
|
260
|
-
|
|
261
|
-
function escapeHtml(value) {
|
|
262
|
-
return String(value ?? "")
|
|
263
|
-
.replace(/&/g, "&")
|
|
264
|
-
.replace(/</g, "<")
|
|
265
|
-
.replace(/>/g, ">");
|
|
266
|
-
}
|
|
@@ -61,7 +61,10 @@ body {
|
|
|
61
61
|
transition: opacity 0.15s ease;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
/* .show-grip is toggled by pet-window.js based on the pet's bounding box, so the
|
|
65
|
+
grip's reveal is independent of the pixel-precise click-through (which would
|
|
66
|
+
otherwise hide it over the sprite's transparent corners). */
|
|
67
|
+
#pet.show-grip .resize-grip,
|
|
65
68
|
.resize-grip.active {
|
|
66
69
|
opacity: 1;
|
|
67
70
|
}
|
|
@@ -206,6 +209,7 @@ body {
|
|
|
206
209
|
|
|
207
210
|
.bubble .client {
|
|
208
211
|
font-weight: 600;
|
|
212
|
+
margin-right: 0.4em;
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
.bubble .project {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { isOpaqueAlpha, isPointInsideRect } from "../src/renderer/pet-hit-test.js";
|
|
4
|
+
|
|
5
|
+
const RECT = { left: 10, top: 20, right: 110, bottom: 220 };
|
|
6
|
+
|
|
7
|
+
test("isPointInsideRect includes the top-left edge and excludes the bottom-right", () => {
|
|
8
|
+
assert.equal(isPointInsideRect({ x: 10, y: 20 }, RECT), true); // top-left corner is inside
|
|
9
|
+
assert.equal(isPointInsideRect({ x: 60, y: 120 }, RECT), true); // centre
|
|
10
|
+
assert.equal(isPointInsideRect({ x: 110, y: 120 }, RECT), false); // right edge is exclusive
|
|
11
|
+
assert.equal(isPointInsideRect({ x: 60, y: 220 }, RECT), false); // bottom edge is exclusive
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("isPointInsideRect rejects points outside the box", () => {
|
|
15
|
+
assert.equal(isPointInsideRect({ x: 9, y: 120 }, RECT), false);
|
|
16
|
+
assert.equal(isPointInsideRect({ x: 60, y: 19 }, RECT), false);
|
|
17
|
+
assert.equal(isPointInsideRect({ x: 200, y: 300 }, RECT), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isOpaqueAlpha compares against the threshold (default 16)", () => {
|
|
21
|
+
assert.equal(isOpaqueAlpha(255), true);
|
|
22
|
+
assert.equal(isOpaqueAlpha(16), true); // threshold is inclusive
|
|
23
|
+
assert.equal(isOpaqueAlpha(15), false);
|
|
24
|
+
assert.equal(isOpaqueAlpha(0), false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("isOpaqueAlpha honours a custom threshold", () => {
|
|
28
|
+
assert.equal(isOpaqueAlpha(120, 128), false);
|
|
29
|
+
assert.equal(isOpaqueAlpha(128, 128), true);
|
|
30
|
+
});
|
|
@@ -170,6 +170,34 @@ test("scrolls to a session when it newly fails", () => {
|
|
|
170
170
|
}
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
test("shows the compact project label and keeps the full name as a tooltip", () => {
|
|
174
|
+
const restoreDocument = installFakeDocument();
|
|
175
|
+
try {
|
|
176
|
+
const container = new FakeElement("div");
|
|
177
|
+
const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
|
|
178
|
+
|
|
179
|
+
listView.render([{
|
|
180
|
+
sessionId: "s1",
|
|
181
|
+
statusKind: "working",
|
|
182
|
+
statusLabel: "Working",
|
|
183
|
+
clientName: "Claude Code",
|
|
184
|
+
projectName: "netdisk-server",
|
|
185
|
+
projectLabel: "netdisk-se...",
|
|
186
|
+
summary: "running",
|
|
187
|
+
elapsedLabel: "1s"
|
|
188
|
+
}]);
|
|
189
|
+
|
|
190
|
+
const bubble = findBubble(container, "s1");
|
|
191
|
+
const title = childByClass(childByClass(bubble, "body"), "title");
|
|
192
|
+
assert.equal(childByClass(title, "client").textContent, "Claude Code");
|
|
193
|
+
assert.equal(childByClass(title, "project").textContent, "netdisk-se...");
|
|
194
|
+
// The full, untruncated name stays reachable on hover.
|
|
195
|
+
assert.equal(title.title, "Claude Code · netdisk-server");
|
|
196
|
+
} finally {
|
|
197
|
+
restoreDocument();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
173
201
|
test("clears everything when no sessions remain", () => {
|
|
174
202
|
const restoreDocument = installFakeDocument();
|
|
175
203
|
try {
|
package/docs/architecture.md
CHANGED
|
@@ -99,9 +99,16 @@ L2/PTY tradeoffs.
|
|
|
99
99
|
|
|
100
100
|
The overlay is a transparent, always-on-top window spanning the work area, kept
|
|
101
101
|
click-through except over the pet and bubble chips (via `setIgnoreMouseEvents`
|
|
102
|
-
with mouse-move forwarding).
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
with mouse-move forwarding). Over the pet the hit test is **pixel-precise**: the
|
|
103
|
+
renderer samples the live canvas (`getImageData`) at the cursor and only
|
|
104
|
+
intercepts where the current frame has opaque pixels, so the transparent margins
|
|
105
|
+
of the sprite cell pass clicks through too. The press/drag is held interactive
|
|
106
|
+
for its whole duration (the running frames have a different silhouette), and the
|
|
107
|
+
resize grip keeps its own bounding-box reveal so pixel precision never hides it
|
|
108
|
+
(`pet-hit-test.js`, `pet-window.js`). The pet is positioned inside the window and
|
|
109
|
+
dragged via CSS; the bubble panel is placed on whichever side of the pet has room
|
|
110
|
+
so it stays fully on-screen. The pet currently lives on a single display's work
|
|
111
|
+
area.
|
|
105
112
|
|
|
106
113
|
The bubble panel shows at most three sessions and scrolls for the rest (capped
|
|
107
114
|
by the smaller of a height budget and a count budget, see
|
package/docs/known-issues.md
CHANGED
|
@@ -103,6 +103,32 @@ Issues found in live use, with their current status.
|
|
|
103
103
|
`Notification` hook by type (`permission_prompt`→approval, `idle_prompt`→idle) so
|
|
104
104
|
non-approval notifications no longer masquerade as approvals.
|
|
105
105
|
|
|
106
|
+
## ✅ Resolved: pet stuck on "thinking" after an Esc interrupt
|
|
107
|
+
|
|
108
|
+
- **Symptom:** With hooks enabled, pressing Esc to interrupt the agent — most
|
|
109
|
+
visibly while it was *thinking* with no tool running — left the pet spinning on
|
|
110
|
+
*thinking* (or *running*) until the 30 s stale sweep, instead of showing the
|
|
111
|
+
turn was cut short. Affected both Claude Code and Codex.
|
|
112
|
+
- **Root cause:** Neither client fires a hook on an interrupt. `Stop` fires only
|
|
113
|
+
on a *normal* turn end, so the hook-driven status had no event to leave the
|
|
114
|
+
working state. A timeout was rejected (same reasoning as the denial case — it
|
|
115
|
+
would misreport a genuinely long turn).
|
|
116
|
+
- **Fix:** The existing **L3 transcript watchers** already tail each client's
|
|
117
|
+
session JSONL, so they now also recognise the interrupt marker each client
|
|
118
|
+
*does* write — ground truth, not a timer. Claude records a synthetic user
|
|
119
|
+
message `[Request interrupted by user]` (and `…for tool use]`); Codex records
|
|
120
|
+
an `event_msg` with `payload.type: "turn_aborted"`. On seeing it the wrapper
|
|
121
|
+
reports a dedicated **`interrupted`** state (summary "interrupted", source
|
|
122
|
+
`client_log`).
|
|
123
|
+
- **Why a new state, not `failed`:** the first attempt reported `failed`, which
|
|
124
|
+
*looked* right (red ✕) but is in `bubble-linger.js`'s `ENDED_STATES`, so the
|
|
125
|
+
linger logic treated the interrupt as a finished session and **hid the bubble
|
|
126
|
+
after ~2 s** — even though the session was still alive (no unregister on an
|
|
127
|
+
interrupt). `interrupted` maps to the same red ✕ kind and the same one-shot pet
|
|
128
|
+
reaction as `failed`, but is **not** terminal (not in `ENDED_STATES` or the
|
|
129
|
+
pet's `TERMINAL_STATES`), so the bubble stays visible until the next turn (or a
|
|
130
|
+
real exit). Heartbeats keep it from going stale.
|
|
131
|
+
|
|
106
132
|
## ✅ Resolved: pet stuck on "waiting for approval" after the user ACCEPTS
|
|
107
133
|
|
|
108
134
|
- **Symptom:** The denial fix above covered "deny", but **accepting** a prompt
|
package/package.json
CHANGED
|
@@ -17,6 +17,12 @@ const REJECTION_MARKERS = Object.freeze([
|
|
|
17
17
|
"user chose not to"
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
+
// When the user presses Esc, Claude fires no Stop hook — it writes a synthetic
|
|
21
|
+
// USER message carrying this marker (e.g. "[Request interrupted by user]" or
|
|
22
|
+
// "[Request interrupted by user for tool use]"). That's the only signal the turn
|
|
23
|
+
// was aborted, so the pet doesn't stay stuck on "thinking"/"running".
|
|
24
|
+
const INTERRUPT_MARKER = "request interrupted by user";
|
|
25
|
+
|
|
20
26
|
// Returns a resolution event for a single transcript line, or undefined.
|
|
21
27
|
// Currently we only surface denials — the approve path is already covered by the
|
|
22
28
|
// PostToolUse hook, and emitting on every result would race with it.
|
|
@@ -33,6 +39,8 @@ export function parseTranscriptLine(line) {
|
|
|
33
39
|
return undefined;
|
|
34
40
|
}
|
|
35
41
|
|
|
42
|
+
const isUserMessage = entry?.message?.role === "user";
|
|
43
|
+
|
|
36
44
|
for (const block of content) {
|
|
37
45
|
if (block?.type === "tool_result" && block.is_error === true) {
|
|
38
46
|
const text = extractText(block.content).toLowerCase();
|
|
@@ -40,6 +48,14 @@ export function parseTranscriptLine(line) {
|
|
|
40
48
|
return { type: "tool_denied", toolUseId: block.tool_use_id };
|
|
41
49
|
}
|
|
42
50
|
}
|
|
51
|
+
|
|
52
|
+
// Only a user message carries the synthetic interrupt marker; guarding on the
|
|
53
|
+
// role avoids matching the assistant merely quoting the phrase.
|
|
54
|
+
if (block?.type === "text" && isUserMessage) {
|
|
55
|
+
if (extractText(block).toLowerCase().includes(INTERRUPT_MARKER)) {
|
|
56
|
+
return { type: "interrupted" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
return undefined;
|
|
@@ -12,12 +12,12 @@ export function parseCodexTranscriptLine(line, options = {}) {
|
|
|
12
12
|
return undefined;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
if (entry
|
|
15
|
+
if (!entry || typeof entry !== "object") {
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Skip records from before the current session (used when replaying a
|
|
20
|
-
// freshly-discovered transcript so an earlier session's
|
|
20
|
+
// freshly-discovered transcript so an earlier session's events don't
|
|
21
21
|
// masquerade as live activity). Records without a parseable timestamp are
|
|
22
22
|
// kept — losing live events is worse than a rare stale one.
|
|
23
23
|
const minTimestampMs = options.minTimestampMs ?? 0;
|
|
@@ -33,6 +33,17 @@ export function parseCodexTranscriptLine(line, options = {}) {
|
|
|
33
33
|
return undefined;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// The user pressing Esc aborts the turn. Codex fires no Stop hook on an abort,
|
|
37
|
+
// so this event_msg is the only signal the turn ended by interruption — without
|
|
38
|
+
// it the pet stays stuck on "thinking" until the stale sweep.
|
|
39
|
+
if (entry.type === "event_msg" && payload.type === "turn_aborted") {
|
|
40
|
+
return { type: "turn_aborted", reason: typeof payload.reason === "string" ? payload.reason : undefined };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (entry.type !== "response_item") {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
if (payload.type === "function_call" || payload.type === "custom_tool_call") {
|
|
37
48
|
const toolName = typeof payload.name === "string" ? payload.name : undefined;
|
|
38
49
|
const toolCallId = typeof payload.call_id === "string" ? payload.call_id : undefined;
|
|
@@ -68,3 +68,41 @@ test("parseTranscriptLines collects only denial events from a batch", () => {
|
|
|
68
68
|
{ type: "tool_denied", toolUseId: "toolu_b" }
|
|
69
69
|
]);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
function interruptLine(text = "[Request interrupted by user]") {
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
type: "user",
|
|
75
|
+
message: { role: "user", content: [{ type: "text", text }] },
|
|
76
|
+
interruptedMessageId: "msg_1"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("parseTranscriptLine detects a user interrupt during text generation", () => {
|
|
81
|
+
assert.deepEqual(parseTranscriptLine(interruptLine()), { type: "interrupted" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("parseTranscriptLine detects a user interrupt during tool use", () => {
|
|
85
|
+
assert.deepEqual(
|
|
86
|
+
parseTranscriptLine(interruptLine("[Request interrupted by user for tool use]")),
|
|
87
|
+
{ type: "interrupted" }
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("parseTranscriptLine ignores the interrupt phrase quoted by the assistant", () => {
|
|
92
|
+
const line = JSON.stringify({
|
|
93
|
+
message: { role: "assistant", content: [{ type: "text", text: "Earlier you saw [Request interrupted by user]." }] }
|
|
94
|
+
});
|
|
95
|
+
assert.equal(parseTranscriptLine(line), undefined);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("parseTranscriptLine ignores a normal user prompt", () => {
|
|
99
|
+
const line = JSON.stringify({ message: { role: "user", content: [{ type: "text", text: "fix this bug" }] } });
|
|
100
|
+
assert.equal(parseTranscriptLine(line), undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseTranscriptLines collects interrupt events alongside denials", () => {
|
|
104
|
+
assert.deepEqual(parseTranscriptLines([interruptLine(), rejectionLine("toolu_z")]), [
|
|
105
|
+
{ type: "interrupted" },
|
|
106
|
+
{ type: "tool_denied", toolUseId: "toolu_z" }
|
|
107
|
+
]);
|
|
108
|
+
});
|
|
@@ -95,3 +95,25 @@ test("parseCodexTranscriptLines can skip records older than the session start",
|
|
|
95
95
|
}
|
|
96
96
|
]);
|
|
97
97
|
});
|
|
98
|
+
|
|
99
|
+
test("parseCodexTranscriptLine detects a user-interrupted turn", () => {
|
|
100
|
+
const event = parseCodexTranscriptLine(JSON.stringify({
|
|
101
|
+
type: "event_msg",
|
|
102
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(event, { type: "turn_aborted", reason: "interrupted" });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("parseCodexTranscriptLine skips a turn_aborted older than the session start", () => {
|
|
109
|
+
const old = JSON.stringify({
|
|
110
|
+
timestamp: "2026-06-08T10:59:59.000Z",
|
|
111
|
+
type: "event_msg",
|
|
112
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
assert.equal(
|
|
116
|
+
parseCodexTranscriptLine(old, { minTimestampMs: Date.parse("2026-06-08T11:00:00.000Z") }),
|
|
117
|
+
undefined
|
|
118
|
+
);
|
|
119
|
+
});
|
|
@@ -34,6 +34,7 @@ export function watchClaudeTranscript(options = {}) {
|
|
|
34
34
|
homeDir = process.env.USERPROFILE || process.env.HOME,
|
|
35
35
|
startedAt = 0,
|
|
36
36
|
onDenial = () => {},
|
|
37
|
+
onInterrupt = () => {},
|
|
37
38
|
pollIntervalMs = DEFAULT_POLL_MS,
|
|
38
39
|
projectsRoot,
|
|
39
40
|
transcriptPath: fixedPath,
|
|
@@ -82,6 +83,8 @@ export function watchClaudeTranscript(options = {}) {
|
|
|
82
83
|
for (const event of parseTranscriptLines(lines)) {
|
|
83
84
|
if (event.type === "tool_denied") {
|
|
84
85
|
onDenial(event);
|
|
86
|
+
} else if (event.type === "interrupted") {
|
|
87
|
+
onInterrupt(event);
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
} catch {
|
|
@@ -17,6 +17,14 @@ function rejection(toolUseId) {
|
|
|
17
17
|
})}\n`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
function interrupt() {
|
|
21
|
+
return `${JSON.stringify({
|
|
22
|
+
type: "user",
|
|
23
|
+
message: { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] },
|
|
24
|
+
interruptedMessageId: "msg_1"
|
|
25
|
+
})}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
test("claudeProjectDirName sanitizes drive + separators like Claude does", () => {
|
|
21
29
|
assert.equal(claudeProjectDirName("D:\\Projects\\AI\\haya-pet"), "D--Projects-AI-haya-pet");
|
|
22
30
|
assert.equal(claudeProjectDirName("/home/a/proj"), "-home-a-proj");
|
|
@@ -119,3 +127,23 @@ test("watchClaudeTranscript handles a line split across two appends", () => {
|
|
|
119
127
|
|
|
120
128
|
watcher.stop();
|
|
121
129
|
});
|
|
130
|
+
|
|
131
|
+
test("watchClaudeTranscript reports an interrupt appended after it starts tailing", () => {
|
|
132
|
+
const dir = mkdtempSync(join(tmpdir(), "transcript-"));
|
|
133
|
+
const path = join(dir, "session.jsonl");
|
|
134
|
+
writeFileSync(path, "");
|
|
135
|
+
|
|
136
|
+
const interrupts = [];
|
|
137
|
+
const watcher = watchClaudeTranscript({
|
|
138
|
+
transcriptPath: path,
|
|
139
|
+
onInterrupt: (event) => interrupts.push(event),
|
|
140
|
+
...noopTimers
|
|
141
|
+
});
|
|
142
|
+
watcher._tick();
|
|
143
|
+
|
|
144
|
+
appendFileSync(path, interrupt());
|
|
145
|
+
watcher._tick();
|
|
146
|
+
assert.deepEqual(interrupts, [{ type: "interrupted" }]);
|
|
147
|
+
|
|
148
|
+
watcher.stop();
|
|
149
|
+
});
|
|
@@ -15,6 +15,14 @@ function toolStart(toolName = "shell_command", callId = "call_1", timestamp) {
|
|
|
15
15
|
})}\n`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
function turnAborted(timestamp) {
|
|
19
|
+
return `${JSON.stringify({
|
|
20
|
+
...(timestamp ? { timestamp } : {}),
|
|
21
|
+
type: "event_msg",
|
|
22
|
+
payload: { type: "turn_aborted", reason: "interrupted" }
|
|
23
|
+
})}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
test("discoverCodexTranscript finds the newest session jsonl under date folders", () => {
|
|
19
27
|
const root = mkdtempSync(join(tmpdir(), "codex-sessions-"));
|
|
20
28
|
const oldDir = join(root, "2026", "06", "07");
|
|
@@ -106,3 +114,24 @@ test("watchCodexTranscript replays current-session records when a transcript is
|
|
|
106
114
|
|
|
107
115
|
watcher.stop();
|
|
108
116
|
});
|
|
117
|
+
|
|
118
|
+
test("watchCodexTranscript forwards a turn_aborted interrupt event", () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "codex-transcript-"));
|
|
120
|
+
const path = join(dir, "session.jsonl");
|
|
121
|
+
writeFileSync(path, "");
|
|
122
|
+
|
|
123
|
+
const events = [];
|
|
124
|
+
const watcher = watchCodexTranscript({
|
|
125
|
+
transcriptPath: path,
|
|
126
|
+
onToolEvent: (event) => events.push(event),
|
|
127
|
+
...noopTimers
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
watcher._tick();
|
|
131
|
+
appendFileSync(path, turnAborted());
|
|
132
|
+
watcher._tick();
|
|
133
|
+
|
|
134
|
+
assert.deepEqual(events, [{ type: "turn_aborted", reason: "interrupted" }]);
|
|
135
|
+
|
|
136
|
+
watcher.stop();
|
|
137
|
+
});
|
|
@@ -8,6 +8,9 @@ export const AI_CLIENT_STATES = Object.freeze([
|
|
|
8
8
|
"reviewing",
|
|
9
9
|
"compacting",
|
|
10
10
|
"failed",
|
|
11
|
+
// The user interrupted the turn (Esc). Looks like a failure (red ✕) but is NOT
|
|
12
|
+
// terminal — the session is still alive, so its bubble must not linger-out.
|
|
13
|
+
"interrupted",
|
|
11
14
|
"success",
|
|
12
15
|
"stale",
|
|
13
16
|
"exited"
|
|
@@ -20,11 +20,14 @@ test("declares normalized AI states and state sources from the plan", () => {
|
|
|
20
20
|
"reviewing",
|
|
21
21
|
"compacting",
|
|
22
22
|
"failed",
|
|
23
|
+
"interrupted",
|
|
23
24
|
"success",
|
|
24
25
|
"stale",
|
|
25
26
|
"exited"
|
|
26
27
|
]);
|
|
27
28
|
|
|
29
|
+
assert.equal(isAiClientState("interrupted"), true);
|
|
30
|
+
|
|
28
31
|
assert.deepEqual(STATE_SOURCES, [
|
|
29
32
|
"wrapper",
|
|
30
33
|
"pty_output",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
2
|
-
import { buildSessionSummary, buildStatusLabel, formatElapsed } from "./summaries.js";
|
|
2
|
+
import { buildSessionSummary, buildStatusLabel, formatElapsed, truncateProjectName } from "./summaries.js";
|
|
3
3
|
|
|
4
4
|
// Collapses the full AI-state vocabulary into the four progress kinds the
|
|
5
5
|
// bubble panel renders: a spinning "working" circle, a "done" check mark (held
|
|
@@ -16,6 +16,9 @@ const STATUS_KIND_BY_STATE = Object.freeze({
|
|
|
16
16
|
waiting_approval: "attention",
|
|
17
17
|
stale: "attention",
|
|
18
18
|
failed: "failed",
|
|
19
|
+
// An interrupt shows the same red ✕ as a failure, but it is a live (non-ended)
|
|
20
|
+
// state — see bubble-linger ENDED_STATES — so the bubble stays put.
|
|
21
|
+
interrupted: "failed",
|
|
19
22
|
idle: "done",
|
|
20
23
|
success: "done",
|
|
21
24
|
exited: "done"
|
|
@@ -33,6 +36,7 @@ export function buildBubbleView(session, now = Date.now(), options = {}) {
|
|
|
33
36
|
clientId: session.clientId,
|
|
34
37
|
clientName: session.clientDisplayName ?? session.clientId,
|
|
35
38
|
projectName: session.projectName,
|
|
39
|
+
projectLabel: truncateProjectName(session.projectName),
|
|
36
40
|
state: session.state,
|
|
37
41
|
statusLabel: buildStatusLabel(session.state),
|
|
38
42
|
statusKind: resolveBubbleStatusKind(session.state),
|
|
@@ -4,14 +4,16 @@ import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
|
|
|
4
4
|
const TERMINAL_STATES = new Set(["exited", "success"]);
|
|
5
5
|
|
|
6
6
|
// States that should NOT drive a looping stable action. success/exited are
|
|
7
|
-
// terminal; failed
|
|
8
|
-
// the "failed" animation forever).
|
|
9
|
-
|
|
7
|
+
// terminal; failed/interrupted are timed reactions (a stray error or an Esc must
|
|
8
|
+
// not pin the pet on the "failed" animation forever). Note interrupted is NOT in
|
|
9
|
+
// TERMINAL_STATES — the session is still alive, it just had its turn cut short.
|
|
10
|
+
const NON_STABLE_STATES = new Set(["exited", "success", "failed", "interrupted"]);
|
|
10
11
|
|
|
11
12
|
// States that play a one-shot reaction exactly once when first entered.
|
|
12
13
|
const ONE_SHOT_BY_STATE = Object.freeze({
|
|
13
14
|
success: "jumping",
|
|
14
|
-
failed: "failed"
|
|
15
|
+
failed: "failed",
|
|
16
|
+
interrupted: "failed"
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
// Active-work states. Transitioning from one of these to idle means a turn
|
|
@@ -8,6 +8,7 @@ const STATUS_LABELS = Object.freeze({
|
|
|
8
8
|
reviewing: "Reviewing",
|
|
9
9
|
compacting: "Compacting context",
|
|
10
10
|
failed: "Failed",
|
|
11
|
+
interrupted: "Interrupted",
|
|
11
12
|
success: "Done",
|
|
12
13
|
stale: "Stale",
|
|
13
14
|
exited: "Exited"
|
|
@@ -25,6 +26,20 @@ export function buildSessionSummary(session) {
|
|
|
25
26
|
return buildStatusLabel(session?.state);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
// Compacts a project name for the session bubble title, which sits beside the
|
|
30
|
+
// (always-full) client name in a narrow overlay. Names up to `maxLength`
|
|
31
|
+
// characters show whole; longer ones are cut to `maxLength` and marked with an
|
|
32
|
+
// ellipsis. The full name is kept elsewhere on the view model for a tooltip.
|
|
33
|
+
const DEFAULT_PROJECT_NAME_LENGTH = 10;
|
|
34
|
+
|
|
35
|
+
export function truncateProjectName(name, maxLength = DEFAULT_PROJECT_NAME_LENGTH) {
|
|
36
|
+
const text = typeof name === "string" ? name : "";
|
|
37
|
+
if (text.length <= maxLength) {
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
return `${text.slice(0, maxLength)}...`;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
export function formatElapsed(ms) {
|
|
29
44
|
const totalSeconds = Math.max(0, Math.floor((Number.isFinite(ms) ? ms : 0) / 1000));
|
|
30
45
|
|
|
@@ -12,6 +12,22 @@ test("ENDED_STATES covers the process-finished states", () => {
|
|
|
12
12
|
assert.ok(ENDED_STATES.has("failed"));
|
|
13
13
|
assert.ok(!ENDED_STATES.has("idle"));
|
|
14
14
|
assert.ok(!ENDED_STATES.has("running_tool"));
|
|
15
|
+
// An interrupt ends the TURN, not the session — the session is still alive, so
|
|
16
|
+
// its bubble must NOT linger-out and vanish.
|
|
17
|
+
assert.ok(!ENDED_STATES.has("interrupted"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("keeps an interrupted bubble visible (the session is still alive)", () => {
|
|
21
|
+
// Long after any linger window would have elapsed, an interrupted bubble stays.
|
|
22
|
+
const result = resolveVisibleBubbles({
|
|
23
|
+
bubbles: [bubble("a", "interrupted")],
|
|
24
|
+
now: 1_000_000,
|
|
25
|
+
lingerState: { a: 1000 },
|
|
26
|
+
lingerMs: 2000
|
|
27
|
+
});
|
|
28
|
+
assert.deepEqual(result.visible.map((b) => b.sessionId), ["a"]);
|
|
29
|
+
assert.deepEqual(result.lingerState, {});
|
|
30
|
+
assert.equal(result.nextWakeMs, undefined);
|
|
15
31
|
});
|
|
16
32
|
|
|
17
33
|
test("keeps active (non-ended) bubbles visible with no linger tracking", () => {
|
|
@@ -20,6 +20,8 @@ test("builds a bubble view model with label, summary, action, and elapsed", () =
|
|
|
20
20
|
assert.equal(view.clientId, "codex");
|
|
21
21
|
assert.equal(view.clientName, "Codex");
|
|
22
22
|
assert.equal(view.projectName, "netdisk-server");
|
|
23
|
+
// The full name is preserved; projectLabel is the compact bubble display form.
|
|
24
|
+
assert.equal(view.projectLabel, "netdisk-se...");
|
|
23
25
|
assert.equal(view.state, "waiting_approval");
|
|
24
26
|
assert.equal(view.statusLabel, "Waiting for approval");
|
|
25
27
|
assert.equal(view.summary, "waiting for command approval");
|
|
@@ -64,6 +66,12 @@ test("breaks connect-time ties by session id for a deterministic order", () => {
|
|
|
64
66
|
assert.deepEqual(views.map((view) => view.sessionId), ["sess_a", "sess_b"]);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
test("keeps a short project name whole in projectLabel", () => {
|
|
70
|
+
const view = buildBubbleView({ ...baseSession, projectName: "api" }, 6_000);
|
|
71
|
+
assert.equal(view.projectName, "api");
|
|
72
|
+
assert.equal(view.projectLabel, "api");
|
|
73
|
+
});
|
|
74
|
+
|
|
67
75
|
test("marks the selected/pinned session", () => {
|
|
68
76
|
const views = buildBubbleViews([baseSession], 6_000, { selectedSessionId: "sess_a" });
|
|
69
77
|
assert.equal(views[0].selected, true);
|
|
@@ -85,6 +93,14 @@ test("resolves failure to the 'failed' status kind (red cross)", () => {
|
|
|
85
93
|
assert.equal(resolveBubbleStatusKind("failed"), "failed");
|
|
86
94
|
});
|
|
87
95
|
|
|
96
|
+
test("resolves an interrupt to the 'failed' status kind (red cross) but keeps it a live state", () => {
|
|
97
|
+
assert.equal(resolveBubbleStatusKind("interrupted"), "failed");
|
|
98
|
+
const view = buildBubbleView({ ...baseSession, state: "interrupted", summary: "interrupted" }, 6_000);
|
|
99
|
+
assert.equal(view.statusKind, "failed");
|
|
100
|
+
assert.equal(view.statusLabel, "Interrupted");
|
|
101
|
+
assert.equal(view.petAction, "failed");
|
|
102
|
+
});
|
|
103
|
+
|
|
88
104
|
test("resolves idle/finished states to the 'done' status kind (check mark)", () => {
|
|
89
105
|
for (const state of ["idle", "success", "exited"]) {
|
|
90
106
|
assert.equal(resolveBubbleStatusKind(state), "done", state);
|
|
@@ -82,6 +82,29 @@ test("a failed session still yields to active work for the stable action", () =>
|
|
|
82
82
|
assert.equal(result.stableAction, "running");
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
test("an interrupt is a one-shot reaction, not a sticky stable action (like failed)", () => {
|
|
86
|
+
const result = resolveCompanionPetState({
|
|
87
|
+
bubbles: [bubble("a", "interrupted")],
|
|
88
|
+
prioritySessionId: "a",
|
|
89
|
+
previousStates: { a: "thinking" }
|
|
90
|
+
});
|
|
91
|
+
// plays the failed reaction once on the interrupt...
|
|
92
|
+
assert.deepEqual(result.oneShots, ["failed"]);
|
|
93
|
+
// ...but does not pin the pet on a looping action, and the session stays alive
|
|
94
|
+
// (still in the active bubble list, unlike exited/success).
|
|
95
|
+
assert.equal(result.stableAction, "idle");
|
|
96
|
+
assert.deepEqual(result.activeBubbles.map((b) => b.sessionId), ["a"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("does not repeat the interrupt reaction while it stays interrupted", () => {
|
|
100
|
+
const result = resolveCompanionPetState({
|
|
101
|
+
bubbles: [bubble("a", "interrupted")],
|
|
102
|
+
prioritySessionId: "a",
|
|
103
|
+
previousStates: { a: "interrupted" }
|
|
104
|
+
});
|
|
105
|
+
assert.deepEqual(result.oneShots, []);
|
|
106
|
+
});
|
|
107
|
+
|
|
85
108
|
test("celebrates with a one-shot jump when a working turn finishes (working -> idle)", () => {
|
|
86
109
|
const result = resolveCompanionPetState({
|
|
87
110
|
bubbles: [bubble("a", "idle")],
|
|
@@ -3,7 +3,8 @@ import { test } from "../../../test/harness.mjs";
|
|
|
3
3
|
import {
|
|
4
4
|
buildStatusLabel,
|
|
5
5
|
buildSessionSummary,
|
|
6
|
-
formatElapsed
|
|
6
|
+
formatElapsed,
|
|
7
|
+
truncateProjectName
|
|
7
8
|
} from "../src/summaries.js";
|
|
8
9
|
|
|
9
10
|
test("maps every normalized state to a human status label", () => {
|
|
@@ -36,3 +37,25 @@ test("formats elapsed durations compactly", () => {
|
|
|
36
37
|
assert.equal(formatElapsed(65_000), "1m 5s");
|
|
37
38
|
assert.equal(formatElapsed(3_725_000), "1h 2m");
|
|
38
39
|
});
|
|
40
|
+
|
|
41
|
+
test("keeps short project names whole", () => {
|
|
42
|
+
assert.equal(truncateProjectName("api"), "api");
|
|
43
|
+
// Exactly at the 10-character budget stays untouched (no ellipsis).
|
|
44
|
+
assert.equal(truncateProjectName("ten-charrr"), "ten-charrr");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("truncates long project names to 10 characters plus an ellipsis", () => {
|
|
48
|
+
assert.equal(truncateProjectName("netdisk-server"), "netdisk-se...");
|
|
49
|
+
assert.equal(truncateProjectName("a-very-long-project-name"), "a-very-lon...");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("honours a custom max length", () => {
|
|
53
|
+
assert.equal(truncateProjectName("netdisk-server", 4), "netd...");
|
|
54
|
+
assert.equal(truncateProjectName("abc", 4), "abc");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("coerces missing or non-string project names to an empty string", () => {
|
|
58
|
+
assert.equal(truncateProjectName(undefined), "");
|
|
59
|
+
assert.equal(truncateProjectName(null), "");
|
|
60
|
+
assert.equal(truncateProjectName(123), "");
|
|
61
|
+
});
|