@hayasaka7/haya-pet 0.3.11 → 0.3.13

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 CHANGED
@@ -7,6 +7,28 @@ 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.13]
11
+
12
+ ### Added
13
+ - **Right-clicking the pet now opens the tray menu.** A right-click used to behave
14
+ like a left-click (wave + fold/unfold the bubbles). It now pops up the same
15
+ menu as the system-tray icon — Show/Hide Pet, Active Sessions, Installed Pets,
16
+ Reset Position, update, Quit — which is far more discoverable than the tray icon
17
+ (often hidden in the Windows overflow). The menu is built from the one pure tray
18
+ model, so both entry points always match. Left-click behaviour is unchanged.
19
+
20
+ ## [0.3.12]
21
+
22
+ ### Fixed
23
+ - **A long status or tool-call name no longer stretches the session bubble.** The
24
+ activity line is `white-space: nowrap`, and the bubble sizes to its content, so
25
+ a long summary (e.g. a tool call like `Read packages/session-core/src/...`)
26
+ dragged the bubble out to its max width before the CSS ellipsis could engage.
27
+ The status/activity text is now length-capped in the view model (`summaryLabel`,
28
+ 32 chars + `...`) exactly like the project name (`projectLabel`), so it can't
29
+ widen the bubble; the full summary stays reachable on hover and in the expanded
30
+ task-talk popup.
31
+
10
32
  ## [0.3.11]
11
33
 
12
34
  ### Fixed
package/README.md CHANGED
@@ -220,6 +220,7 @@ non-observe mode keeps terminal input native.
220
220
  |---|---|
221
221
  | Single click | Wave and fold or unfold session bubbles. |
222
222
  | Double click | Jump and expand session bubbles. |
223
+ | Right click | Open the same menu as the tray icon (sessions, pets, reset, updates, quit). |
223
224
  | Drag | Move the pet; position is saved. |
224
225
  | Drag corner grip | Resize from 0.5x to 2x; size is saved. |
225
226
  | Double-click grip | Reset to normal size. |
@@ -291,17 +291,15 @@ function loadTrayIcon() {
291
291
  return fileIcon.isEmpty() ? nativeImage.createFromDataURL(TRAY_ICON_DATA_URL) : fileIcon;
292
292
  }
293
293
 
294
- function refreshTrayMenu() {
295
- if (!tray) {
296
- return;
297
- }
298
-
294
+ // Builds the native menu template from the pure tray model. Shared by the tray
295
+ // icon and the pet's right-click context menu so both stay identical.
296
+ function buildTrayMenuTemplate() {
299
297
  const sessions = (runtime?.listSessions() ?? []).map((session) => ({
300
298
  sessionId: session.sessionId,
301
299
  label: `${session.clientDisplayName} · ${session.projectName}`
302
300
  }));
303
301
 
304
- const template = buildTrayMenu({
302
+ return buildTrayMenu({
305
303
  petVisible: petWindow?.isVisible() ?? true,
306
304
  displayMode: positionState.settings.displayMode,
307
305
  attachBubblesToTerminals: positionState.settings.attachBubblesToTerminals,
@@ -310,8 +308,14 @@ function refreshTrayMenu() {
310
308
  pets: pets.map((pet) => ({ id: pet.manifest.id, name: pet.manifest.name })),
311
309
  updateAvailable
312
310
  }).map(toElectronMenuItem);
311
+ }
312
+
313
+ function refreshTrayMenu() {
314
+ if (!tray) {
315
+ return;
316
+ }
313
317
 
314
- tray.setContextMenu(Menu.buildFromTemplate(template));
318
+ tray.setContextMenu(Menu.buildFromTemplate(buildTrayMenuTemplate()));
315
319
  }
316
320
 
317
321
  function toElectronMenuItem(item) {
@@ -425,6 +429,16 @@ function registerRendererHandlers() {
425
429
  }
426
430
  });
427
431
 
432
+ // Right-click on the pet pops up the same menu as the tray icon (built from
433
+ // the one pure tray model), since the tray icon is often buried in the Windows
434
+ // overflow. Fire-and-forget: the native menu is shown and dispatched in main.
435
+ ipcMain.on("haya-pet:show-pet-menu", () => {
436
+ if (!petWindow || petWindow.isDestroyed()) {
437
+ return;
438
+ }
439
+ Menu.buildFromTemplate(buildTrayMenuTemplate()).popup({ window: petWindow });
440
+ });
441
+
428
442
  // The pet moves within the overlay (CSS), so the renderer reports its new
429
443
  // work-area-relative position instead of moving the window.
430
444
  ipcMain.handle("haya-pet:save-pet-position", async (_event, local) => {
@@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld("aiPet", {
7
7
  savePetPosition: (local) => ipcRenderer.invoke("haya-pet:save-pet-position", local),
8
8
  savePetScale: (scale) => ipcRenderer.invoke("haya-pet:save-pet-scale", scale),
9
9
  setMouseIgnore: (ignore) => ipcRenderer.send("haya-pet:set-mouse-ignore", ignore),
10
+ showPetMenu: () => ipcRenderer.send("haya-pet:show-pet-menu"),
10
11
  onConfig: (handler) => ipcRenderer.on("haya-pet:config", (_event, config) => handler(config)),
11
12
  onSessions: (handler) => ipcRenderer.on("haya-pet:sessions", (_event, payload) => handler(payload)),
12
13
  onPetPosition: (handler) => ipcRenderer.on("haya-pet:pet-position", (_event, pos) => handler(pos)),
@@ -192,6 +192,11 @@ function playOneShot(action) {
192
192
  // --- Pointer interaction (click vs drag distinction lives in the controller) ---
193
193
 
194
194
  canvas.addEventListener("pointerdown", (event) => {
195
+ // Only the primary button drives click/drag; right-click pops the context menu
196
+ // (handled below) and must not also fire a wave/toggle or start a drag.
197
+ if (event.button !== 0) {
198
+ return;
199
+ }
195
200
  canvas.setPointerCapture(event.pointerId);
196
201
  // Hold click-through off for the whole press: a drag swaps to the running
197
202
  // frames, whose opaque pixels differ from the grabbed one, so re-running the
@@ -211,6 +216,11 @@ canvas.addEventListener("pointermove", (event) => {
211
216
  });
212
217
 
213
218
  canvas.addEventListener("pointerup", (event) => {
219
+ // Mirror pointerdown: ignore non-primary releases so a right-click never feeds
220
+ // the click controller (its pointerDown was skipped anyway).
221
+ if (event.button !== 0) {
222
+ return;
223
+ }
214
224
  // Click / double-click are delivered asynchronously via onAction; only the
215
225
  // synchronous drag-end is handled here.
216
226
  petPressed = false;
@@ -227,6 +237,15 @@ canvas.addEventListener("pointercancel", () => {
227
237
  animationState = clearDragAction(animationState);
228
238
  });
229
239
 
240
+ // Right-click the pet to open the same menu as the tray icon. The native menu is
241
+ // built and shown in the main process; preventDefault stops Electron's default
242
+ // context menu. Only fires over opaque pet pixels (transparent areas are
243
+ // click-through and the right-click falls to the desktop, like a left-click).
244
+ canvas.addEventListener("contextmenu", (event) => {
245
+ event.preventDefault();
246
+ bridge?.showPetMenu?.();
247
+ });
248
+
230
249
  // --- Resize grip: drag to scale the pet, double-click to reset ---
231
250
 
232
251
  let resizeDrag; // { startScale, startPointer } while a grip drag is active
@@ -253,8 +253,10 @@ function applyBubble(el, bubble) {
253
253
  project.textContent = bubble.projectLabel ?? bubble.projectName;
254
254
  // Hover reveals the full, untruncated "Client · Project".
255
255
  title.title = bubble.projectName ? `${bubble.clientName} · ${bubble.projectName}` : bubble.clientName;
256
- activity.textContent = bubble.summary;
257
- activity.title = `${bubble.statusLabel} · ${bubble.elapsedLabel}`;
256
+ // Compact label keeps a long status/tool name from stretching the bubble; the
257
+ // full summary stays reachable on hover (like the project name above).
258
+ activity.textContent = bubble.summaryLabel ?? bubble.summary;
259
+ activity.title = `${bubble.summary ?? bubble.statusLabel} · ${bubble.elapsedLabel}`;
258
260
  }
259
261
 
260
262
  // Picks the kind that should win the collapsed-folder dot: a failure or a
@@ -198,6 +198,34 @@ test("shows the compact project label and keeps the full name as a tooltip", ()
198
198
  }
199
199
  });
200
200
 
201
+ test("shows the compact summary label and keeps the full summary as a tooltip", () => {
202
+ const restoreDocument = installFakeDocument();
203
+ try {
204
+ const container = new FakeElement("div");
205
+ const listView = createBubbleList(container, { onRender: createHostOnRender(container) });
206
+
207
+ listView.render([{
208
+ sessionId: "s1",
209
+ statusKind: "working",
210
+ statusLabel: "Running tools",
211
+ clientName: "Claude Code",
212
+ projectName: "haya-pet",
213
+ projectLabel: "haya-pet",
214
+ summary: "Read packages/session-core/src/summaries.js",
215
+ summaryLabel: "Read packages/session-core/src/s...",
216
+ elapsedLabel: "1s"
217
+ }]);
218
+
219
+ const activity = findActivity(findBubble(container, "s1"));
220
+ // The bubble renders the capped label so a long tool name can't widen it...
221
+ assert.equal(activity.textContent, "Read packages/session-core/src/s...");
222
+ // ...while the full summary stays reachable on hover.
223
+ assert.equal(activity.title, "Read packages/session-core/src/summaries.js · 1s");
224
+ } finally {
225
+ restoreDocument();
226
+ }
227
+ });
228
+
201
229
  test("clears everything when no sessions remain", () => {
202
230
  const restoreDocument = installFakeDocument();
203
231
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hayasaka7/haya-pet",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "description": "Generic AI CLI pet runtime foundation.",
6
6
  "keywords": [
@@ -1,5 +1,5 @@
1
1
  import { mapAiStateToPetAction } from "../../pet-core/src/atlas.js";
2
- import { buildSessionSummary, buildStatusLabel, formatElapsed, truncateProjectName } from "./summaries.js";
2
+ import { buildSessionSummary, buildStatusLabel, formatElapsed, truncateProjectName, truncateSummary } 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
@@ -30,6 +30,7 @@ export function resolveBubbleStatusKind(state) {
30
30
 
31
31
  export function buildBubbleView(session, now = Date.now(), options = {}) {
32
32
  const elapsedMs = Math.max(0, numeric(now) - numeric(session.startedAt));
33
+ const summary = buildSessionSummary(session);
33
34
 
34
35
  return {
35
36
  sessionId: session.sessionId,
@@ -40,7 +41,11 @@ export function buildBubbleView(session, now = Date.now(), options = {}) {
40
41
  state: session.state,
41
42
  statusLabel: buildStatusLabel(session.state),
42
43
  statusKind: resolveBubbleStatusKind(session.state),
43
- summary: buildSessionSummary(session),
44
+ // Full summary stays for the detail popup + hover tooltip; summaryLabel is
45
+ // the compact form the bubble renders so a long status/tool name can't
46
+ // stretch the bubble (mirrors projectName / projectLabel).
47
+ summary,
48
+ summaryLabel: truncateSummary(summary),
44
49
  petAction: safePetAction(session.state),
45
50
  elapsedMs,
46
51
  elapsedLabel: formatElapsed(elapsedMs),
@@ -26,18 +26,34 @@ export function buildSessionSummary(session) {
26
26
  return buildStatusLabel(session?.state);
27
27
  }
28
28
 
29
+ // Ellipsis truncation shared by the bubble's title and activity lines: text up
30
+ // to `maxLength` characters shows whole; longer text is cut to `maxLength` and
31
+ // marked with a trailing "...". Non-strings coerce to "". The full text is kept
32
+ // elsewhere on the view model for a hover tooltip.
33
+ function truncateWithEllipsis(text, maxLength) {
34
+ const value = typeof text === "string" ? text : "";
35
+ if (value.length <= maxLength) {
36
+ return value;
37
+ }
38
+ return `${value.slice(0, maxLength)}...`;
39
+ }
40
+
29
41
  // 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.
42
+ // (always-full) client name in a narrow overlay.
33
43
  const DEFAULT_PROJECT_NAME_LENGTH = 10;
34
44
 
35
45
  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)}...`;
46
+ return truncateWithEllipsis(name, maxLength);
47
+ }
48
+
49
+ // Compacts the activity/status line the same way, so a long status or tool-call
50
+ // summary can't stretch the bubble wider than its (already capped) title. The
51
+ // budget comfortably clears the longest built-in status label so those always
52
+ // show whole; only genuinely long custom summaries get the ellipsis.
53
+ const DEFAULT_SUMMARY_LENGTH = 32;
54
+
55
+ export function truncateSummary(text, maxLength = DEFAULT_SUMMARY_LENGTH) {
56
+ return truncateWithEllipsis(text, maxLength);
41
57
  }
42
58
 
43
59
  export function formatElapsed(ms) {
@@ -25,6 +25,8 @@ test("builds a bubble view model with label, summary, action, and elapsed", () =
25
25
  assert.equal(view.state, "waiting_approval");
26
26
  assert.equal(view.statusLabel, "Waiting for approval");
27
27
  assert.equal(view.summary, "waiting for command approval");
28
+ // Fits the 32-char budget, so the compact label matches the full summary.
29
+ assert.equal(view.summaryLabel, "waiting for command approval");
28
30
  assert.equal(view.petAction, "waiting");
29
31
  assert.equal(view.elapsedMs, 64_000);
30
32
  assert.equal(view.elapsedLabel, "1m 4s");
@@ -72,6 +74,15 @@ test("keeps a short project name whole in projectLabel", () => {
72
74
  assert.equal(view.projectLabel, "api");
73
75
  });
74
76
 
77
+ test("compacts a long status/tool summary into summaryLabel, keeping the full summary", () => {
78
+ const longSummary = "Read packages/session-core/src/summaries.js";
79
+ const view = buildBubbleView({ ...baseSession, state: "running_tool", summary: longSummary }, 6_000);
80
+ // Full text is preserved (for the detail popup + hover tooltip)...
81
+ assert.equal(view.summary, longSummary);
82
+ // ...while the bubble renders the capped form so it can't stretch the width.
83
+ assert.equal(view.summaryLabel, "Read packages/session-core/src/s...");
84
+ });
85
+
75
86
  test("marks the selected/pinned session", () => {
76
87
  const views = buildBubbleViews([baseSession], 6_000, { selectedSessionId: "sess_a" });
77
88
  assert.equal(views[0].selected, true);
@@ -4,7 +4,8 @@ import {
4
4
  buildStatusLabel,
5
5
  buildSessionSummary,
6
6
  formatElapsed,
7
- truncateProjectName
7
+ truncateProjectName,
8
+ truncateSummary
8
9
  } from "../src/summaries.js";
9
10
 
10
11
  test("maps every normalized state to a human status label", () => {
@@ -59,3 +60,23 @@ test("coerces missing or non-string project names to an empty string", () => {
59
60
  assert.equal(truncateProjectName(null), "");
60
61
  assert.equal(truncateProjectName(123), "");
61
62
  });
63
+
64
+ test("keeps built-in status labels whole in the summary budget", () => {
65
+ // The longest built-in label ("Waiting for approval", 20 chars) and a
66
+ // slightly longer custom message must both fit without an ellipsis.
67
+ assert.equal(truncateSummary("Waiting for approval"), "Waiting for approval");
68
+ assert.equal(truncateSummary("waiting for command approval"), "waiting for command approval");
69
+ });
70
+
71
+ test("truncates a long status or tool-call summary to 32 characters plus an ellipsis", () => {
72
+ assert.equal(
73
+ truncateSummary("Read packages/session-core/src/summaries.js"),
74
+ "Read packages/session-core/src/s..."
75
+ );
76
+ });
77
+
78
+ test("honours a custom summary max length and coerces non-strings", () => {
79
+ assert.equal(truncateSummary("running tools", 4), "runn...");
80
+ assert.equal(truncateSummary(undefined), "");
81
+ assert.equal(truncateSummary(123), "");
82
+ });