@hayasaka7/haya-pet 0.3.6 → 0.3.8
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 +40 -0
- package/README.md +3 -3
- package/apps/cli/src/haya-pet.js +52 -3
- package/apps/companion/src/main/display-manager.js +35 -0
- package/apps/companion/src/main/index.js +98 -29
- package/apps/companion/src/main/tray-menu.js +2 -28
- package/apps/companion/test/display-manager.test.mjs +51 -1
- package/apps/companion/test/tray-menu.test.mjs +3 -14
- package/docs/known-issues.md +115 -0
- package/docs/screenshots/README.md +1 -1
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/packages/cli-core/src/claude-transcript-watcher.js +25 -1
- package/packages/cli-core/src/codex-hook-injection.js +51 -2
- package/packages/cli-core/src/run-state.js +110 -0
- package/packages/cli-core/src/session-transcript-link.js +72 -0
- package/packages/cli-core/test/claude-transcript-watcher.test.mjs +73 -0
- package/packages/cli-core/test/codex-hook-injection.test.mjs +30 -1
- package/packages/cli-core/test/run-state.test.mjs +47 -1
- package/packages/cli-core/test/session-transcript-link.test.mjs +67 -0
- package/packages/platform-core/src/paths.js +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ 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.8]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **An interrupt or denial in one Claude Code session no longer leaks into a
|
|
14
|
+
concurrent, idle one.** The transcript watcher discovered its file by "newest
|
|
15
|
+
`.jsonl` by mtime in the project dir", so two Claude sessions in the same folder
|
|
16
|
+
(one project dir, one transcript each) could make an idle session's watcher lock
|
|
17
|
+
onto a *busy* session's transcript — then read its `[Request interrupted by
|
|
18
|
+
user]` marker (or a denial) and report the wrong pet as *interrupted*. Each
|
|
19
|
+
session's watcher now pins to its own transcript via the `transcript_path` Claude
|
|
20
|
+
includes in every hook payload (recorded as a per-session link by the `haya-pet
|
|
21
|
+
state` reporter) instead of guessing; until that link exists it idles rather than
|
|
22
|
+
locking onto another session's file. Codex shares the same discovery shape and is
|
|
23
|
+
tracked as a known issue.
|
|
24
|
+
- **The pet no longer disappears when the display layout changes.** The overlay
|
|
25
|
+
window's bounds were set once at creation to span one display's work area and
|
|
26
|
+
never re-homed, so unplugging a monitor, changing resolution/DPI, docking or
|
|
27
|
+
undocking, or waking from sleep could strand it off-screen (or on a display that
|
|
28
|
+
no longer exists) — the pet vanished while the process kept running, and neither
|
|
29
|
+
**Show/Hide Pet** (which only flips visibility) nor **Reset Position** (which only
|
|
30
|
+
moved the sprite *inside* the window) brought it back. The companion now re-homes
|
|
31
|
+
the overlay onto a valid display on `screen` display add/remove/metrics-change and
|
|
32
|
+
on resume from sleep, and **Reset Position** / **Show Pet** / relaunch re-home the
|
|
33
|
+
window itself. Automatic re-homes preserve the preferred display, so the pet
|
|
34
|
+
returns there when the monitor comes back.
|
|
35
|
+
|
|
36
|
+
## [0.3.7]
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **The tray menu no longer shows state-only controls.** Hidden **Display Mode**
|
|
40
|
+
and **Attach Bubbles to Terminals** until those settings have real runtime
|
|
41
|
+
behavior. **Active Sessions** stays visible while session actions continue in a
|
|
42
|
+
separate workflow.
|
|
43
|
+
|
|
10
44
|
## [0.3.6]
|
|
11
45
|
|
|
12
46
|
### Fixed
|
|
@@ -38,6 +72,12 @@ All notable changes to HAYA Pet are documented here. This project adheres to
|
|
|
38
72
|
watcher, so a resumed main rollout could be rejected before the guardian trunk
|
|
39
73
|
was matched to it. The guardian watcher now uses the same fresh-mtime + wrapped
|
|
40
74
|
cwd rule for resumed main sessions before following the guardian review trunk.
|
|
75
|
+
- **Codex hook review is one-time again.** Codex stores approved hook hashes in
|
|
76
|
+
the generated `$CODEX_HOME/haya-pet.config.toml` profile under `[hooks.state]`.
|
|
77
|
+
The injector used to rewrite the whole managed profile on every launch, deleting
|
|
78
|
+
that trust state and forcing Codex to ask for hook review every time. The
|
|
79
|
+
injector now preserves Codex's hook trust tables while regenerating the HAYA
|
|
80
|
+
hook definitions.
|
|
41
81
|
|
|
42
82
|
### Added
|
|
43
83
|
- **`HAYA_PET_DAEMON_DEBUG` diagnostic.** When set to a file path, the companion
|
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ success or failure briefly, then fades.
|
|
|
136
136
|
| Global pet | Reacts to the highest-priority session and can be dragged anywhere. |
|
|
137
137
|
| Session bubbles | One bubble per running AI session, ordered by connect time. |
|
|
138
138
|
| Folder button | Folds the bubbles away when you want a cleaner desktop. |
|
|
139
|
-
| Tray menu | Show/hide,
|
|
139
|
+
| Tray menu | Show/hide, active sessions, installed pets, reset position, updates, and quit. |
|
|
140
140
|
| Resize grip | Hover the pet, drag the corner, and keep the size you like. |
|
|
141
141
|
|
|
142
142
|
## Screenshots
|
|
@@ -144,7 +144,7 @@ success or failure briefly, then fades.
|
|
|
144
144
|
| | |
|
|
145
145
|
|---|---|
|
|
146
146
|
| **The global pet** - reacting to the highest-priority session.<br> | **Session bubbles** - one per active session, with status icons.<br> |
|
|
147
|
-
| **Folder collapsed** - bubbles tucked away beside the pet.<br> | **Tray menu** - show/hide, pets, reset position, quit.<br> |
|
|
147
|
+
| **Folder collapsed** - bubbles tucked away beside the pet.<br> | **Tray menu** - show/hide, sessions, pets, reset position, quit.<br> |
|
|
148
148
|
|
|
149
149
|
## Supported Clients
|
|
150
150
|
|
|
@@ -217,7 +217,7 @@ non-observe mode keeps terminal input native.
|
|
|
217
217
|
| Drag | Move the pet; position is saved. |
|
|
218
218
|
| Drag corner grip | Resize from 0.5x to 2x; size is saved. |
|
|
219
219
|
| Double-click grip | Reset to normal size. |
|
|
220
|
-
| Tray icon | Open menu for
|
|
220
|
+
| Tray icon | Open menu for sessions, pets, reset, updates, and quit. |
|
|
221
221
|
|
|
222
222
|
## Commands
|
|
223
223
|
|
package/apps/cli/src/haya-pet.js
CHANGED
|
@@ -5,7 +5,8 @@ import { randomUUID } from "node:crypto";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { runGenericCommand as defaultRunGenericCommand } from "../../../packages/cli-core/src/run-command.js";
|
|
8
|
-
import { parseStateArgs, runStateCommand } from "../../../packages/cli-core/src/run-state.js";
|
|
8
|
+
import { parseStateArgs, runStateCommand, readHookTranscriptPathFromStdin } from "../../../packages/cli-core/src/run-state.js";
|
|
9
|
+
import { removeSessionTranscriptLink } from "../../../packages/cli-core/src/session-transcript-link.js";
|
|
9
10
|
import { injectClaudeHooks as defaultInjectClaudeHooks } from "../../../packages/cli-core/src/claude-hook-injection.js";
|
|
10
11
|
import { injectCodexHooks as defaultInjectCodexHooks } from "../../../packages/cli-core/src/codex-hook-injection.js";
|
|
11
12
|
import { watchClaudeTranscript as defaultWatchClaudeTranscript } from "../../../packages/cli-core/src/claude-transcript-watcher.js";
|
|
@@ -353,6 +354,12 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
353
354
|
childEnv = { ...env, HAYA_PET_SESSION_ID: sessionId };
|
|
354
355
|
cleanup = injected.cleanup;
|
|
355
356
|
|
|
357
|
+
// The transcript watcher pins to THIS session's transcript via the
|
|
358
|
+
// session->transcript link the `haya-pet state` reporter writes from the hook
|
|
359
|
+
// payload. sessionDir is where that link lives; resolve it defensively so a
|
|
360
|
+
// missing home dir (tests) never breaks the run.
|
|
361
|
+
const sessionDir = resolveSessionDir(dependencies, env);
|
|
362
|
+
|
|
356
363
|
// Claude fires NO hook when the user manually denies a permission, so the
|
|
357
364
|
// pet would stay stuck on "waiting for approval". Tail the session transcript
|
|
358
365
|
// (ground truth) and clear to idle the moment a denial is recorded — never on
|
|
@@ -361,6 +368,8 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
361
368
|
cwd,
|
|
362
369
|
homeDir: dependencies.homeDir,
|
|
363
370
|
startedAt: now(),
|
|
371
|
+
sessionId,
|
|
372
|
+
sessionDir,
|
|
364
373
|
onDenial: (event) => {
|
|
365
374
|
hookDebugLog(env, now, { source: "transcript", event: "denied", state: "idle", toolUseId: event?.toolUseId });
|
|
366
375
|
messageSender
|
|
@@ -392,7 +401,10 @@ async function runRunCommand(parsed, dependencies) {
|
|
|
392
401
|
.catch(() => {});
|
|
393
402
|
}
|
|
394
403
|
});
|
|
395
|
-
stopWatcher =
|
|
404
|
+
stopWatcher = () => {
|
|
405
|
+
watcher.stop();
|
|
406
|
+
removeSessionTranscriptLink({ sessionDir, sessionId });
|
|
407
|
+
};
|
|
396
408
|
}
|
|
397
409
|
|
|
398
410
|
// Codex: no `--settings` equivalent, so inject a stable profile and add
|
|
@@ -624,6 +636,21 @@ function createConfigStateFile(dependencies) {
|
|
|
624
636
|
return createStateFile({ statePath: paths.statePath });
|
|
625
637
|
}
|
|
626
638
|
|
|
639
|
+
// Where per-session transcript links live. Resolved defensively: if no home dir
|
|
640
|
+
// is available (some tests), return undefined and the watcher falls back to its
|
|
641
|
+
// legacy discovery instead of crashing the run.
|
|
642
|
+
function resolveSessionDir(dependencies, env) {
|
|
643
|
+
try {
|
|
644
|
+
return getDefaultPaths({
|
|
645
|
+
platform: dependencies.platform,
|
|
646
|
+
env,
|
|
647
|
+
homeDir: dependencies.homeDir
|
|
648
|
+
}).sessionDir;
|
|
649
|
+
} catch {
|
|
650
|
+
return undefined;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
627
654
|
// Best-effort: mirror the reporter's HAYA_PET_HOOK_DEBUG log so transcript-driven
|
|
628
655
|
// events (which don't go through `haya-pet state`) show up in the same trace.
|
|
629
656
|
function hookDebugLog(env, now, entry) {
|
|
@@ -917,7 +944,7 @@ async function noopSend() {}
|
|
|
917
944
|
async function noopClose() {}
|
|
918
945
|
|
|
919
946
|
if (isDirectRun(import.meta.url, process.argv[1])) {
|
|
920
|
-
|
|
947
|
+
bootstrap()
|
|
921
948
|
.catch((error) => {
|
|
922
949
|
console.error(error.message);
|
|
923
950
|
process.exitCode = 1;
|
|
@@ -930,6 +957,28 @@ if (isDirectRun(import.meta.url, process.argv[1])) {
|
|
|
930
957
|
});
|
|
931
958
|
}
|
|
932
959
|
|
|
960
|
+
// Real-process entry. For a `haya-pet state` invocation — which is ALWAYS a client
|
|
961
|
+
// hook child — read the hook payload from stdin once to learn this session's real
|
|
962
|
+
// transcript path, and hand it to the reporter so it can record the
|
|
963
|
+
// session->transcript link. Done here (not inside main/runStateCommand) so unit
|
|
964
|
+
// tests, and every other command that needs stdin passed through to its child
|
|
965
|
+
// (e.g. `run`), never touch stdin.
|
|
966
|
+
async function bootstrap() {
|
|
967
|
+
const argv = process.argv.slice(2);
|
|
968
|
+
const dependencies = {};
|
|
969
|
+
if (argv[0] === "state") {
|
|
970
|
+
try {
|
|
971
|
+
const transcriptPath = await readHookTranscriptPathFromStdin();
|
|
972
|
+
if (transcriptPath) {
|
|
973
|
+
dependencies.transcriptPath = transcriptPath;
|
|
974
|
+
}
|
|
975
|
+
} catch {
|
|
976
|
+
// a missing/garbled payload just means no binding this time — never fatal
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return main(argv, dependencies);
|
|
980
|
+
}
|
|
981
|
+
|
|
933
982
|
function isDirectRun(moduleUrl, scriptPath) {
|
|
934
983
|
if (!scriptPath) {
|
|
935
984
|
return false;
|
|
@@ -22,6 +22,41 @@ export function resolveSavedPosition(savedPosition, displays, size = DEFAULT_SIZ
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Resolve where the overlay window should sit RIGHT NOW: which display's work
|
|
26
|
+
// area it spans, plus where the pet sprite sits inside it (work-area-relative).
|
|
27
|
+
// Shared by initial placement and by re-homing after a display change (monitor
|
|
28
|
+
// unplugged, resolution/DPI change, dock/undock, sleep→resume). Re-homing is what
|
|
29
|
+
// stops the overlay from being stranded off-screen on a display that no longer
|
|
30
|
+
// exists — the bug where the pet "vanishes" but the process is alive and neither
|
|
31
|
+
// Show/Hide nor Reset (which only move the sprite inside the window) recover it.
|
|
32
|
+
export function resolveOverlayPlacement({ savedPosition, displays, petSize = DEFAULT_SIZE } = {}) {
|
|
33
|
+
const saved = resolveSavedPosition(savedPosition, displays, petSize);
|
|
34
|
+
// saved.displayId always names a display that currently exists (resolveSavedPosition
|
|
35
|
+
// falls back to primary/first when the saved one is gone), so this re-homes a
|
|
36
|
+
// stranded position onto a valid display.
|
|
37
|
+
const display = findDisplayForSavedPosition({ displayId: saved.displayId }, displays);
|
|
38
|
+
const workArea = display.workArea ?? display.bounds;
|
|
39
|
+
const petLocal = clampLocalToWorkArea(
|
|
40
|
+
{ x: saved.x - workArea.x, y: saved.y - workArea.y },
|
|
41
|
+
workArea,
|
|
42
|
+
petSize
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return { workArea, displayId: display.id, petLocal };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Keep a pet's work-area-relative position inside the work area for a given pet
|
|
49
|
+
// size. Pure mirror of the companion's in-window clamp, so placement resolution
|
|
50
|
+
// can be unit-tested without Electron.
|
|
51
|
+
export function clampLocalToWorkArea(local, workArea, petSize = DEFAULT_SIZE) {
|
|
52
|
+
const maxX = Math.max(0, (workArea?.width ?? petSize.width) - petSize.width);
|
|
53
|
+
const maxY = Math.max(0, (workArea?.height ?? petSize.height) - petSize.height);
|
|
54
|
+
return {
|
|
55
|
+
x: clamp(local?.x ?? 0, 0, maxX),
|
|
56
|
+
y: clamp(local?.y ?? 0, 0, maxY)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
25
60
|
export function clampWindowBounds(bounds, display) {
|
|
26
61
|
const workArea = display.workArea ?? display.bounds;
|
|
27
62
|
const width = bounds.width ?? DEFAULT_SIZE.width;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron";
|
|
1
|
+
import { app, BrowserWindow, ipcMain, Menu, nativeImage, powerMonitor, screen, shell, Tray } from "electron";
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
3
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
@@ -14,7 +14,7 @@ import { getPlatformCapabilities } from "../../../../packages/platform-core/src/
|
|
|
14
14
|
import { buildBubbleViews } from "../../../../packages/session-core/src/bubble-view.js";
|
|
15
15
|
import { clampScale } from "../../../../packages/pet-core/src/pet-scale.js";
|
|
16
16
|
import { buildPetWindowOptions, PET_SIZE } from "./window-options.js";
|
|
17
|
-
import {
|
|
17
|
+
import { clampLocalToWorkArea, resolveOverlayPlacement } from "./display-manager.js";
|
|
18
18
|
import { getPetScale, setPetScale, setSelectedPet, updateGlobalPetPosition } from "./position-store.js";
|
|
19
19
|
import { buildTrayMenu, buildTrayTooltip } from "./tray-menu.js";
|
|
20
20
|
import { createStateFile } from "./state-file.js";
|
|
@@ -151,6 +151,7 @@ async function bootstrap() {
|
|
|
151
151
|
createPetWindow();
|
|
152
152
|
createTray();
|
|
153
153
|
registerRendererHandlers();
|
|
154
|
+
registerDisplayWatchers();
|
|
154
155
|
// Best-effort and cached in state.json (shared with the CLI's check, so at
|
|
155
156
|
// most one registry request per day between them); never blocks startup.
|
|
156
157
|
void detectUpdate();
|
|
@@ -171,16 +172,9 @@ async function bootstrap() {
|
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
function createPetWindow() {
|
|
174
|
-
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
const saved = resolveSavedPosition(positionState.globalPet, displays);
|
|
178
|
-
const display = displays.find((d) => d.id === saved.displayId)
|
|
179
|
-
?? displays.find((d) => d.primary)
|
|
180
|
-
?? displays[0];
|
|
181
|
-
currentWorkArea = display.workArea ?? display.bounds;
|
|
182
|
-
currentDisplayId = display.id;
|
|
183
|
-
petLocal = clampPetLocal({ x: saved.x - currentWorkArea.x, y: saved.y - currentWorkArea.y });
|
|
175
|
+
// Resolve which display the pet belongs on and where it sits inside that
|
|
176
|
+
// display's work area; the window then spans that work area.
|
|
177
|
+
applyOverlayPlacement(resolveCurrentPlacement());
|
|
184
178
|
|
|
185
179
|
const { browserWindow } = buildPetWindowOptions({ capabilities, bounds: currentWorkArea });
|
|
186
180
|
|
|
@@ -214,13 +208,69 @@ function scaledPetSize() {
|
|
|
214
208
|
}
|
|
215
209
|
|
|
216
210
|
function clampPetLocal(local) {
|
|
211
|
+
return clampLocalToWorkArea(local ?? petLocal, currentWorkArea, scaledPetSize());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Resolve where the overlay should sit right now, given the saved position and the
|
|
215
|
+
// CURRENT set of displays. Used at startup and on every re-home.
|
|
216
|
+
function resolveCurrentPlacement() {
|
|
217
|
+
return resolveOverlayPlacement({
|
|
218
|
+
savedPosition: positionState.globalPet,
|
|
219
|
+
displays: listDisplays(),
|
|
220
|
+
petSize: scaledPetSize()
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Adopt a resolved placement into the module's window/pet state (does NOT move the
|
|
225
|
+
// BrowserWindow — callers decide whether to create or setBounds).
|
|
226
|
+
function applyOverlayPlacement(placement) {
|
|
227
|
+
currentWorkArea = placement.workArea;
|
|
228
|
+
currentDisplayId = placement.displayId;
|
|
229
|
+
petLocal = placement.petLocal;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Re-home the overlay onto a currently-valid display and re-assert its bounds.
|
|
233
|
+
// A display change (monitor unplugged, resolution/DPI change, dock/undock,
|
|
234
|
+
// sleep→resume) can strand the window off-screen on a display that no longer
|
|
235
|
+
// exists: the pet "vanishes" while the process is alive, and neither Show/Hide
|
|
236
|
+
// nor Reset (which only move the sprite INSIDE the window) bring it back. This is
|
|
237
|
+
// the one operation that puts the window itself back on screen.
|
|
238
|
+
function rehomeOverlay({ recenter = false, persist = true } = {}) {
|
|
239
|
+
if (!petWindow || petWindow.isDestroyed()) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
applyOverlayPlacement(resolveCurrentPlacement());
|
|
244
|
+
if (recenter) {
|
|
245
|
+
petLocal = cornerPetLocal();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
petWindow.setBounds(currentWorkArea);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
// Setting bounds must never crash the overlay; the next display event retries.
|
|
252
|
+
console.error("overlay re-home failed:", error.message);
|
|
253
|
+
}
|
|
254
|
+
if (!petWindow.isVisible()) {
|
|
255
|
+
petWindow.show();
|
|
256
|
+
}
|
|
257
|
+
sendPetPosition();
|
|
258
|
+
// Automatic re-homes (display change / resume) deliberately do NOT persist, so
|
|
259
|
+
// the user's preferred display is preserved and the pet returns there once that
|
|
260
|
+
// display comes back. User-initiated re-homes (reset/show) persist as usual.
|
|
261
|
+
if (persist) {
|
|
262
|
+
persistPetPosition();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Bottom-right corner of the current work area (the Reset target).
|
|
267
|
+
function cornerPetLocal() {
|
|
268
|
+
const margin = 24;
|
|
217
269
|
const size = scaledPetSize();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
y: Math.min(Math.max(local.y ?? 0, 0), maxY)
|
|
223
|
-
};
|
|
270
|
+
return clampPetLocal({
|
|
271
|
+
x: (currentWorkArea?.width ?? size.width) - size.width - margin,
|
|
272
|
+
y: (currentWorkArea?.height ?? size.height) - size.height - margin
|
|
273
|
+
});
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
function createTray() {
|
|
@@ -348,6 +398,22 @@ function handleTrayClick(item) {
|
|
|
348
398
|
}
|
|
349
399
|
}
|
|
350
400
|
|
|
401
|
+
// The overlay window is positioned once at creation; without these it would never
|
|
402
|
+
// react to the display layout changing under it. Re-home on any display add/remove/
|
|
403
|
+
// metrics change, and on resume from sleep (which commonly fires a metrics change
|
|
404
|
+
// and, on Windows, can blank a transparent surface — re-asserting bounds repaints).
|
|
405
|
+
function registerDisplayWatchers() {
|
|
406
|
+
const onDisplayChange = () => rehomeOverlay({ persist: false });
|
|
407
|
+
screen.on("display-metrics-changed", onDisplayChange);
|
|
408
|
+
screen.on("display-added", onDisplayChange);
|
|
409
|
+
screen.on("display-removed", onDisplayChange);
|
|
410
|
+
try {
|
|
411
|
+
powerMonitor.on("resume", onDisplayChange);
|
|
412
|
+
} catch {
|
|
413
|
+
// powerMonitor is unavailable on some platforms/headless; never fatal.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
351
417
|
function registerRendererHandlers() {
|
|
352
418
|
ipcMain.handle("haya-pet:list-sessions", () => buildSessionPayload());
|
|
353
419
|
|
|
@@ -441,24 +507,27 @@ function togglePet() {
|
|
|
441
507
|
if (!petWindow) {
|
|
442
508
|
return;
|
|
443
509
|
}
|
|
444
|
-
petWindow.isVisible()
|
|
510
|
+
if (petWindow.isVisible()) {
|
|
511
|
+
petWindow.hide();
|
|
512
|
+
} else {
|
|
513
|
+
// Re-home on show: a stranded (off-screen) window still reports isVisible()
|
|
514
|
+
// === true, so the user's first click hides it and this second click must put
|
|
515
|
+
// it back on a valid display, not just show it at the same bad bounds.
|
|
516
|
+
rehomeOverlay();
|
|
517
|
+
}
|
|
445
518
|
refreshTrayMenu();
|
|
446
519
|
}
|
|
447
520
|
|
|
448
521
|
function focusPet() {
|
|
449
|
-
|
|
522
|
+
// Relaunching `haya-pet` (second-instance) is also a "bring it back" gesture, so
|
|
523
|
+
// re-home rather than show at possibly-stale bounds.
|
|
524
|
+
rehomeOverlay();
|
|
450
525
|
}
|
|
451
526
|
|
|
452
527
|
function resetPosition() {
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
petLocal = clampPetLocal({
|
|
457
|
-
x: (currentWorkArea?.width ?? size.width) - size.width - margin,
|
|
458
|
-
y: (currentWorkArea?.height ?? size.height) - size.height - margin
|
|
459
|
-
});
|
|
460
|
-
sendPetPosition();
|
|
461
|
-
persistPetPosition();
|
|
528
|
+
// Re-home onto a valid display FIRST (a stale display layout is exactly when
|
|
529
|
+
// users reach for Reset), then drop the pet to the bottom-right corner.
|
|
530
|
+
rehomeOverlay({ recenter: true });
|
|
462
531
|
}
|
|
463
532
|
|
|
464
533
|
function setDisplayMode(displayMode) {
|
|
@@ -2,13 +2,6 @@
|
|
|
2
2
|
// converts these descriptors into a native Menu; keeping it pure makes the
|
|
3
3
|
// recovery controls testable.
|
|
4
4
|
|
|
5
|
-
const DISPLAY_MODES = Object.freeze([
|
|
6
|
-
{ value: "global", label: "Global" },
|
|
7
|
-
{ value: "cluster", label: "Cluster" },
|
|
8
|
-
{ value: "per-terminal", label: "Per Terminal" },
|
|
9
|
-
{ value: "hybrid", label: "Hybrid" }
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
5
|
export function buildTrayTooltip() {
|
|
13
6
|
return "HAYA Pet";
|
|
14
7
|
}
|
|
@@ -22,17 +15,6 @@ export function buildTrayMenu(state = {}) {
|
|
|
22
15
|
id: "toggle_pet",
|
|
23
16
|
label: state.petVisible ? "Hide Pet" : "Show Pet"
|
|
24
17
|
},
|
|
25
|
-
{
|
|
26
|
-
id: "display_mode",
|
|
27
|
-
label: "Display Mode",
|
|
28
|
-
submenu: DISPLAY_MODES.map((mode) => ({
|
|
29
|
-
id: `display_mode:${mode.value}`,
|
|
30
|
-
label: mode.label,
|
|
31
|
-
value: mode.value,
|
|
32
|
-
type: "radio",
|
|
33
|
-
checked: state.displayMode === mode.value
|
|
34
|
-
}))
|
|
35
|
-
},
|
|
36
18
|
{
|
|
37
19
|
id: "sessions",
|
|
38
20
|
label: "Active Sessions",
|
|
@@ -43,17 +25,9 @@ export function buildTrayMenu(state = {}) {
|
|
|
43
25
|
label: "Installed Pets",
|
|
44
26
|
submenu: buildPetItems(pets, state.selectedPetId)
|
|
45
27
|
},
|
|
46
|
-
{
|
|
47
|
-
id: "attach_bubbles",
|
|
48
|
-
label: "Attach Bubbles to Terminals",
|
|
49
|
-
type: "checkbox",
|
|
50
|
-
checked: Boolean(state.attachBubblesToTerminals)
|
|
51
|
-
},
|
|
52
28
|
{ id: "reset_position", label: "Reset Position" },
|
|
53
|
-
// Parked until a real settings window exists
|
|
54
|
-
//
|
|
55
|
-
// item would be a dead button. Re-enable once settings outgrow the tray
|
|
56
|
-
// (e.g. bubble text size, linger duration) and a handler is wired up.
|
|
29
|
+
// Parked until a real settings window exists; partially implemented knobs
|
|
30
|
+
// stay hidden instead of showing dead or state-only controls.
|
|
57
31
|
// { id: "settings", label: "Open Settings" },
|
|
58
32
|
// Only present when the daily npm update check found a newer version;
|
|
59
33
|
// clicking it opens the package page (the app never runs npm itself).
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "../../../test/harness.mjs";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
clampLocalToWorkArea,
|
|
5
|
+
clampWindowBounds,
|
|
6
|
+
resolveOverlayPlacement,
|
|
7
|
+
resolveSavedPosition
|
|
8
|
+
} from "../src/main/display-manager.js";
|
|
4
9
|
|
|
5
10
|
const displays = [
|
|
6
11
|
{
|
|
@@ -46,3 +51,48 @@ test("uses primary display when no saved position exists", () => {
|
|
|
46
51
|
{ x: 416, y: 144, width: 384, height: 416, displayId: "primary", scaleFactor: 1.25 }
|
|
47
52
|
);
|
|
48
53
|
});
|
|
54
|
+
|
|
55
|
+
test("resolveOverlayPlacement spans the saved display and places the sprite inside it", () => {
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
resolveOverlayPlacement({
|
|
58
|
+
savedPosition: { x: 900, y: 120, width: 192, height: 208, displayId: "secondary" },
|
|
59
|
+
displays,
|
|
60
|
+
petSize: { width: 192, height: 208 }
|
|
61
|
+
}),
|
|
62
|
+
{
|
|
63
|
+
workArea: { x: 820, y: 20, width: 1160, height: 840 },
|
|
64
|
+
displayId: "secondary",
|
|
65
|
+
petLocal: { x: 80, y: 100 } // 900-820, 120-20
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("resolveOverlayPlacement re-homes a stranded position onto a valid display", () => {
|
|
71
|
+
// The pet was last on a display that is now gone (monitor unplugged). It must
|
|
72
|
+
// come back on the primary display, fully inside its work area — this is the
|
|
73
|
+
// recovery the disappear-and-can't-restore bug needed.
|
|
74
|
+
assert.deepEqual(
|
|
75
|
+
resolveOverlayPlacement({
|
|
76
|
+
savedPosition: { x: 4000, y: 4000, width: 192, height: 208, displayId: "gone" },
|
|
77
|
+
displays,
|
|
78
|
+
petSize: { width: 192, height: 208 }
|
|
79
|
+
}),
|
|
80
|
+
{
|
|
81
|
+
workArea: { x: 0, y: 0, width: 800, height: 560 },
|
|
82
|
+
displayId: "primary",
|
|
83
|
+
petLocal: { x: 608, y: 352 } // clamped to primary work area (800-192, 560-208)
|
|
84
|
+
}
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("clampLocalToWorkArea keeps the sprite within the work area for its size", () => {
|
|
89
|
+
const workArea = { x: 0, y: 0, width: 800, height: 560 };
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
clampLocalToWorkArea({ x: 5000, y: 5000 }, workArea, { width: 192, height: 208 }),
|
|
92
|
+
{ x: 608, y: 352 }
|
|
93
|
+
);
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
clampLocalToWorkArea({ x: -50, y: -50 }, workArea, { width: 192, height: 208 }),
|
|
96
|
+
{ x: 0, y: 0 }
|
|
97
|
+
);
|
|
98
|
+
});
|
|
@@ -13,9 +13,11 @@ const baseState = {
|
|
|
13
13
|
test("includes the documented recovery controls", () => {
|
|
14
14
|
const menu = buildTrayMenu(baseState);
|
|
15
15
|
const ids = menu.map((item) => item.id);
|
|
16
|
-
for (const id of ["toggle_pet", "
|
|
16
|
+
for (const id of ["toggle_pet", "sessions", "pets", "reset_position", "quit"]) {
|
|
17
17
|
assert.ok(ids.includes(id), `missing ${id}`);
|
|
18
18
|
}
|
|
19
|
+
assert.ok(!ids.includes("display_mode"), "display mode should stay hidden until implemented");
|
|
20
|
+
assert.ok(!ids.includes("attach_bubbles"), "attach bubbles should stay hidden until implemented");
|
|
19
21
|
// "Open Settings" is parked until a settings window exists (every current
|
|
20
22
|
// setting already has a tray/CLI/gesture home) — it must not be shown dead.
|
|
21
23
|
assert.ok(!ids.includes("settings"), "settings item should stay hidden until implemented");
|
|
@@ -26,14 +28,6 @@ test("toggles the pet label based on visibility", () => {
|
|
|
26
28
|
assert.equal(buildTrayMenu({ ...baseState, petVisible: false }).find((i) => i.id === "toggle_pet").label, "Show Pet");
|
|
27
29
|
});
|
|
28
30
|
|
|
29
|
-
test("checks the current display mode in the submenu", () => {
|
|
30
|
-
const submenu = buildTrayMenu(baseState).find((i) => i.id === "display_mode").submenu;
|
|
31
|
-
const hybrid = submenu.find((i) => i.value === "hybrid");
|
|
32
|
-
const global = submenu.find((i) => i.value === "global");
|
|
33
|
-
assert.equal(hybrid.checked, true);
|
|
34
|
-
assert.equal(global.checked, false);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
31
|
test("lists active sessions or shows an empty placeholder", () => {
|
|
38
32
|
const withSessions = buildTrayMenu(baseState).find((i) => i.id === "sessions").submenu;
|
|
39
33
|
assert.equal(withSessions[0].label, "Codex · netdisk-server");
|
|
@@ -42,11 +36,6 @@ test("lists active sessions or shows an empty placeholder", () => {
|
|
|
42
36
|
assert.equal(empty[0].enabled, false);
|
|
43
37
|
});
|
|
44
38
|
|
|
45
|
-
test("reflects the attach-bubbles checkbox state", () => {
|
|
46
|
-
assert.equal(buildTrayMenu(baseState).find((i) => i.id === "attach_bubbles").checked, true);
|
|
47
|
-
assert.equal(buildTrayMenu({ ...baseState, attachBubblesToTerminals: false }).find((i) => i.id === "attach_bubbles").checked, false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
39
|
test("shows the update item only when a newer version is known", () => {
|
|
51
40
|
const withoutUpdate = buildTrayMenu(baseState);
|
|
52
41
|
assert.ok(!withoutUpdate.some((i) => i.id === "update"), "no update item by default");
|