@hayasaka7/haya-pet 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +34 -0
- package/.github/workflows/release.yml +61 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/apps/cli/src/haya-pet.js +395 -0
- package/apps/cli/test/haya-pet.test.mjs +339 -0
- package/apps/companion/README.md +83 -0
- package/apps/companion/package.json +17 -0
- package/apps/companion/src/main/display-manager.js +71 -0
- package/apps/companion/src/main/index.js +349 -0
- package/apps/companion/src/main/lock-file.js +52 -0
- package/apps/companion/src/main/panel-placement.js +45 -0
- package/apps/companion/src/main/pet-loader.js +2 -0
- package/apps/companion/src/main/position-store.js +3 -0
- package/apps/companion/src/main/preload.cjs +13 -0
- package/apps/companion/src/main/state-file.js +2 -0
- package/apps/companion/src/main/terminal-helper-client.js +79 -0
- package/apps/companion/src/main/terminal-locator.js +44 -0
- package/apps/companion/src/main/tray-menu.js +79 -0
- package/apps/companion/src/main/window-options.js +66 -0
- package/apps/companion/src/renderer/index.html +18 -0
- package/apps/companion/src/renderer/interaction-controller.js +114 -0
- package/apps/companion/src/renderer/pet-window.js +275 -0
- package/apps/companion/src/renderer/session-bubbles.js +138 -0
- package/apps/companion/src/renderer/styles.css +225 -0
- package/apps/companion/src/renderer/task-talk-window.js +141 -0
- package/apps/companion/test/display-manager.test.mjs +48 -0
- package/apps/companion/test/interaction-controller.test.mjs +107 -0
- package/apps/companion/test/panel-placement.test.mjs +60 -0
- package/apps/companion/test/position-store.test.mjs +54 -0
- package/apps/companion/test/state-file.test.mjs +52 -0
- package/apps/companion/test/terminal-helper-client.test.mjs +68 -0
- package/apps/companion/test/terminal-locator.test.mjs +35 -0
- package/apps/companion/test/tray-menu.test.mjs +45 -0
- package/apps/companion/test/window-options.test.mjs +62 -0
- package/apps/pet-preview/index.html +42 -0
- package/apps/pet-preview/src/preview-app.js +123 -0
- package/apps/pet-preview/src/preview-state.js +70 -0
- package/apps/pet-preview/src/preview.css +125 -0
- package/apps/pet-preview/test/preview-state.test.mjs +62 -0
- package/assets/fallback-pet/README.md +16 -0
- package/assets/fallback-pet/pet.json +13 -0
- package/docs/architecture.md +144 -0
- package/docs/known-issues.md +49 -0
- package/docs/publishing.md +48 -0
- package/docs/screenshots/README.md +7 -0
- package/docs/screenshots/folder-collapsed.png +0 -0
- package/docs/screenshots/hero.png +0 -0
- package/docs/screenshots/pet-overlay.png +0 -0
- package/docs/screenshots/session-bubbles.png +0 -0
- package/docs/screenshots/tray-menu.png +0 -0
- package/docs/troubleshooting.md +36 -0
- package/native/README.md +80 -0
- package/native/linux-window-helper/README.md +29 -0
- package/native/mac-window-helper/README.md +30 -0
- package/native/win-window-helper/Program.cs +312 -0
- package/native/win-window-helper/README.md +53 -0
- package/native/win-window-helper/win-window-helper.csproj +12 -0
- package/package.json +35 -0
- package/packages/adapters/src/adapter-info.js +61 -0
- package/packages/adapters/src/capabilities.js +39 -0
- package/packages/adapters/src/heuristics.js +114 -0
- package/packages/adapters/src/output-observer.js +164 -0
- package/packages/adapters/src/routing.js +86 -0
- package/packages/adapters/test/adapter-info.test.mjs +35 -0
- package/packages/adapters/test/capabilities.test.mjs +44 -0
- package/packages/adapters/test/heuristics.test.mjs +42 -0
- package/packages/adapters/test/output-observer.test.mjs +142 -0
- package/packages/adapters/test/routing.test.mjs +93 -0
- package/packages/app-state/src/state-file.js +53 -0
- package/packages/app-state/src/state.js +80 -0
- package/packages/app-state/test/state.test.mjs +36 -0
- package/packages/cli-core/src/companion-launcher.js +69 -0
- package/packages/cli-core/src/pty-runner.js +96 -0
- package/packages/cli-core/src/run-command.js +353 -0
- package/packages/cli-core/src/strip-ansi.js +16 -0
- package/packages/cli-core/test/companion-launcher.test.mjs +98 -0
- package/packages/cli-core/test/run-command.test.mjs +177 -0
- package/packages/cli-core/test/strip-ansi.test.mjs +27 -0
- package/packages/daemon-core/src/daemon-runtime.js +49 -0
- package/packages/daemon-core/src/ipc-server.js +180 -0
- package/packages/daemon-core/src/ipc-transport.js +70 -0
- package/packages/daemon-core/src/singleton.js +46 -0
- package/packages/daemon-core/test/daemon-runtime.test.mjs +65 -0
- package/packages/daemon-core/test/ipc-server.test.mjs +70 -0
- package/packages/daemon-core/test/ipc-transport.test.mjs +72 -0
- package/packages/daemon-core/test/singleton.test.mjs +32 -0
- package/packages/pet-core/src/animation-state.js +84 -0
- package/packages/pet-core/src/animator.js +26 -0
- package/packages/pet-core/src/atlas.js +81 -0
- package/packages/pet-core/src/discovery.js +90 -0
- package/packages/pet-core/src/manifest.js +112 -0
- package/packages/pet-core/src/validation.js +43 -0
- package/packages/pet-core/test/animation-state.test.mjs +47 -0
- package/packages/pet-core/test/animator.test.mjs +31 -0
- package/packages/pet-core/test/atlas.test.mjs +81 -0
- package/packages/pet-core/test/discovery.test.mjs +93 -0
- package/packages/pet-core/test/manifest.test.mjs +93 -0
- package/packages/pet-core/test/validation.test.mjs +69 -0
- package/packages/platform-core/src/capabilities.js +49 -0
- package/packages/platform-core/src/paths.js +75 -0
- package/packages/platform-core/src/platform.js +15 -0
- package/packages/platform-core/test/platform.test.mjs +84 -0
- package/packages/protocol/src/messages.js +156 -0
- package/packages/protocol/test/messages.test.mjs +112 -0
- package/packages/session-core/src/bubble-linger.js +47 -0
- package/packages/session-core/src/bubble-view.js +79 -0
- package/packages/session-core/src/pet-state.js +56 -0
- package/packages/session-core/src/priority.js +56 -0
- package/packages/session-core/src/registry.js +144 -0
- package/packages/session-core/src/summaries.js +54 -0
- package/packages/session-core/test/bubble-linger.test.mjs +96 -0
- package/packages/session-core/test/bubble-view.test.mjs +79 -0
- package/packages/session-core/test/pet-state.test.mjs +118 -0
- package/packages/session-core/test/priority.test.mjs +53 -0
- package/packages/session-core/test/registry.test.mjs +161 -0
- package/packages/session-core/test/summaries.test.mjs +38 -0
- package/packages/task-core/src/approvals.js +91 -0
- package/packages/task-core/src/controls.js +61 -0
- package/packages/task-core/src/replies.js +80 -0
- package/packages/task-core/src/task-events.js +101 -0
- package/packages/task-core/src/task-status.js +93 -0
- package/packages/task-core/src/task-store.js +74 -0
- package/packages/task-core/test/approvals.test.mjs +61 -0
- package/packages/task-core/test/controls.test.mjs +61 -0
- package/packages/task-core/test/replies.test.mjs +51 -0
- package/packages/task-core/test/task-events.test.mjs +67 -0
- package/packages/task-core/test/task-status.test.mjs +49 -0
- package/packages/task-core/test/task-store.test.mjs +65 -0
- package/test/harness.mjs +22 -0
- package/test/run-tests.mjs +47 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
--bubble-bg: rgba(24, 26, 32, 0.92);
|
|
4
|
+
--bubble-fg: #f4f5f7;
|
|
5
|
+
--accent: #6ea8fe;
|
|
6
|
+
--kind-working: #6ea8fe;
|
|
7
|
+
--kind-attention: #f0b429;
|
|
8
|
+
--kind-failed: #e5687a;
|
|
9
|
+
--kind-done: #5cd6a0;
|
|
10
|
+
--kind-idle: rgba(255, 255, 255, 0.4);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
* {
|
|
14
|
+
box-sizing: border-box;
|
|
15
|
+
font-family: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
html,
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
width: 100%;
|
|
23
|
+
height: 100%;
|
|
24
|
+
background: transparent;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
user-select: none;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* The pet sits at the window's top-left; the bubble panel fills the space to
|
|
30
|
+
its right. The window itself is large + transparent, and pointer events are
|
|
31
|
+
forwarded so only the marked .interactive regions catch clicks — everything
|
|
32
|
+
else passes through to the desktop (see pet-window.js). */
|
|
33
|
+
#pet-canvas {
|
|
34
|
+
position: absolute;
|
|
35
|
+
left: 0;
|
|
36
|
+
top: 0;
|
|
37
|
+
image-rendering: pixelated;
|
|
38
|
+
cursor: grab;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#pet-canvas:active {
|
|
42
|
+
cursor: grabbing;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Panel container is inert; its children opt back into pointer events so the
|
|
46
|
+
gaps between bubbles stay click-through. Its left/top are set by JS
|
|
47
|
+
(panel-placement) so it always sits beside the pet, fully on-screen. The
|
|
48
|
+
container's box is just the folder button — the list is positioned absolutely
|
|
49
|
+
relative to it, so showing/hiding the list never moves the button. */
|
|
50
|
+
.bubbles {
|
|
51
|
+
position: absolute;
|
|
52
|
+
left: 0;
|
|
53
|
+
top: 0;
|
|
54
|
+
width: max-content;
|
|
55
|
+
pointer-events: none;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.bubbles > * {
|
|
59
|
+
pointer-events: auto;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* --- Folder toggle --- */
|
|
63
|
+
|
|
64
|
+
.folder-toggle {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 6px;
|
|
68
|
+
background: var(--bubble-bg);
|
|
69
|
+
color: var(--bubble-fg);
|
|
70
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
71
|
+
border-radius: 999px;
|
|
72
|
+
padding: 4px 10px;
|
|
73
|
+
font-size: 12px;
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Monochrome disclosure caret (CSS triangle), points right when collapsed and
|
|
79
|
+
rotates down when the folder is open. */
|
|
80
|
+
.caret {
|
|
81
|
+
width: 0;
|
|
82
|
+
height: 0;
|
|
83
|
+
border-top: 4px solid transparent;
|
|
84
|
+
border-bottom: 4px solid transparent;
|
|
85
|
+
border-left: 5px solid currentColor;
|
|
86
|
+
opacity: 0.7;
|
|
87
|
+
transition: transform 0.15s ease;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.folder-toggle[aria-expanded="true"] .caret {
|
|
91
|
+
transform: rotate(90deg);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.folder-count {
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
font-size: 11px;
|
|
97
|
+
opacity: 0.9;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.folder-summary {
|
|
101
|
+
width: 8px;
|
|
102
|
+
height: 8px;
|
|
103
|
+
border-radius: 50%;
|
|
104
|
+
background: var(--kind-idle);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.folder-summary[data-kind="working"] { background: var(--kind-working); }
|
|
108
|
+
.folder-summary[data-kind="attention"] { background: var(--kind-attention); }
|
|
109
|
+
.folder-summary[data-kind="failed"] { background: var(--kind-failed); }
|
|
110
|
+
.folder-summary[data-kind="done"] { background: var(--kind-done); }
|
|
111
|
+
|
|
112
|
+
/* --- Bubble list --- */
|
|
113
|
+
|
|
114
|
+
/* Absolutely positioned relative to the folder button so it can grow without
|
|
115
|
+
shifting the button. JS sets the open direction/alignment toward screen
|
|
116
|
+
centre, and an exact max-height so it always fits on the chosen side. */
|
|
117
|
+
.bubble-list {
|
|
118
|
+
position: absolute;
|
|
119
|
+
display: flex;
|
|
120
|
+
flex-direction: column;
|
|
121
|
+
gap: 6px;
|
|
122
|
+
width: max-content;
|
|
123
|
+
max-width: 300px;
|
|
124
|
+
overflow-y: auto;
|
|
125
|
+
pointer-events: auto;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.bubble-list[data-open-direction="down"] { top: calc(100% + 6px); }
|
|
129
|
+
.bubble-list[data-open-direction="up"] { bottom: calc(100% + 6px); }
|
|
130
|
+
.bubble-list[data-open-align="left"] { left: 0; right: auto; }
|
|
131
|
+
.bubble-list[data-open-align="right"] { right: 0; left: auto; }
|
|
132
|
+
|
|
133
|
+
.bubble {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: flex-start;
|
|
136
|
+
gap: 8px;
|
|
137
|
+
background: var(--bubble-bg);
|
|
138
|
+
color: var(--bubble-fg);
|
|
139
|
+
border-radius: 10px;
|
|
140
|
+
padding: 8px 10px;
|
|
141
|
+
font-size: 12px;
|
|
142
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
|
|
143
|
+
border-left: 3px solid transparent;
|
|
144
|
+
min-width: 180px;
|
|
145
|
+
max-width: 280px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.bubble[data-kind="working"] { border-left-color: var(--kind-working); }
|
|
149
|
+
.bubble[data-kind="attention"] { border-left-color: var(--kind-attention); }
|
|
150
|
+
.bubble[data-kind="failed"] { border-left-color: var(--kind-failed); }
|
|
151
|
+
.bubble[data-kind="done"] { border-left-color: var(--kind-done); }
|
|
152
|
+
|
|
153
|
+
.bubble .body {
|
|
154
|
+
min-width: 0;
|
|
155
|
+
flex: 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.bubble .title {
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
text-overflow: ellipsis;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.bubble .client {
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.bubble .project {
|
|
169
|
+
opacity: 0.7;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.bubble .activity {
|
|
173
|
+
margin-top: 2px;
|
|
174
|
+
opacity: 0.85;
|
|
175
|
+
white-space: nowrap;
|
|
176
|
+
overflow: hidden;
|
|
177
|
+
text-overflow: ellipsis;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* --- Status icons --- */
|
|
181
|
+
|
|
182
|
+
.status-icon {
|
|
183
|
+
flex: 0 0 auto;
|
|
184
|
+
width: 16px;
|
|
185
|
+
height: 16px;
|
|
186
|
+
margin-top: 1px;
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
font-size: 12px;
|
|
191
|
+
font-weight: 700;
|
|
192
|
+
line-height: 1;
|
|
193
|
+
border-radius: 50%;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.status-icon[data-kind="done"] {
|
|
197
|
+
color: #00210f;
|
|
198
|
+
background: var(--kind-done);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.status-icon[data-kind="attention"] {
|
|
202
|
+
color: #2a1d00;
|
|
203
|
+
background: var(--kind-attention);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.status-icon[data-kind="failed"] {
|
|
207
|
+
color: #2a0008;
|
|
208
|
+
background: var(--kind-failed);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.status-icon[data-kind="idle"] {
|
|
212
|
+
background: var(--kind-idle);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* Working = a spinning pending circle (no glyph). */
|
|
216
|
+
.status-icon[data-kind="working"] {
|
|
217
|
+
border: 2px solid rgba(110, 168, 254, 0.25);
|
|
218
|
+
border-top-color: var(--kind-working);
|
|
219
|
+
background: transparent;
|
|
220
|
+
animation: status-spin 0.8s linear infinite;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@keyframes status-spin {
|
|
224
|
+
to { transform: rotate(360deg); }
|
|
225
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Layer 3 renderer: the task talk window control surface. Uses the pure
|
|
2
|
+
// task-core helpers for reply-mode safety and control gating, and the adapter
|
|
3
|
+
// capabilities registry to decide what is safe to show per client.
|
|
4
|
+
|
|
5
|
+
import { resolveReplyMode } from "../../../../packages/task-core/src/replies.js";
|
|
6
|
+
import { getAdapterCapabilities } from "../../../../packages/adapters/src/capabilities.js";
|
|
7
|
+
|
|
8
|
+
export function createTalkWindow(container, bridge) {
|
|
9
|
+
let current;
|
|
10
|
+
|
|
11
|
+
function close() {
|
|
12
|
+
current = undefined;
|
|
13
|
+
container.classList.add("hidden");
|
|
14
|
+
container.innerHTML = "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function open(bubble, mode = "peek") {
|
|
18
|
+
current = bubble;
|
|
19
|
+
container.classList.remove("hidden");
|
|
20
|
+
render(bubble, mode);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function render(bubble, mode) {
|
|
24
|
+
const capabilities = getAdapterCapabilities(bubble.clientId);
|
|
25
|
+
const replyMode = resolveReplyMode(capabilities);
|
|
26
|
+
const controls = resolveControls(bubble);
|
|
27
|
+
|
|
28
|
+
container.innerHTML = "";
|
|
29
|
+
container.append(
|
|
30
|
+
renderHeader(bubble, close),
|
|
31
|
+
renderActivity(bubble),
|
|
32
|
+
...(bubble.state === "waiting_approval" ? [renderApproval(bubble, capabilities, bridge)] : []),
|
|
33
|
+
renderComposer(bubble, replyMode, bridge),
|
|
34
|
+
renderControls(controls, bubble, bridge)
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
if (mode === "peek") {
|
|
38
|
+
// Peek keeps only the essentials visible; expanded/full reuse the same
|
|
39
|
+
// building blocks with more history once a transcript store is wired.
|
|
40
|
+
container.dataset.mode = "peek";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function resolveControls(bubble) {
|
|
45
|
+
if (bridge?.getTaskControls) {
|
|
46
|
+
return bridge.getTaskControls({ clientId: bubble.clientId, status: bubble.state });
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { open, close, isOpen: () => Boolean(current), current: () => current };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function renderHeader(bubble, close) {
|
|
55
|
+
const header = document.createElement("header");
|
|
56
|
+
const title = document.createElement("span");
|
|
57
|
+
title.innerHTML = `<strong>${bubble.clientName}</strong> · ${bubble.projectName}`;
|
|
58
|
+
const pill = document.createElement("span");
|
|
59
|
+
pill.className = "pill";
|
|
60
|
+
pill.dataset.state = bubble.state;
|
|
61
|
+
pill.textContent = bubble.statusLabel;
|
|
62
|
+
const closeBtn = document.createElement("button");
|
|
63
|
+
closeBtn.textContent = "×";
|
|
64
|
+
closeBtn.addEventListener("click", close);
|
|
65
|
+
header.append(title, pill, closeBtn);
|
|
66
|
+
return header;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderActivity(bubble) {
|
|
70
|
+
const activity = document.createElement("div");
|
|
71
|
+
activity.className = "activity";
|
|
72
|
+
activity.textContent = `${bubble.summary} · ${bubble.elapsedLabel}`;
|
|
73
|
+
return activity;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderApproval(bubble, capabilities, bridge) {
|
|
77
|
+
const panel = document.createElement("div");
|
|
78
|
+
panel.className = "approval";
|
|
79
|
+
panel.textContent = "Approval requested";
|
|
80
|
+
|
|
81
|
+
const canApprove = capabilities.canApprove !== "unsupported";
|
|
82
|
+
for (const decision of ["approve", "deny"]) {
|
|
83
|
+
const btn = document.createElement("button");
|
|
84
|
+
btn.textContent = decision === "approve" ? "Approve" : "Deny";
|
|
85
|
+
btn.disabled = !canApprove;
|
|
86
|
+
btn.addEventListener("click", () =>
|
|
87
|
+
bridge?.resolveApproval?.({ sessionId: bubble.sessionId, approvalId: bubble.approvalId, decision })
|
|
88
|
+
);
|
|
89
|
+
panel.appendChild(btn);
|
|
90
|
+
}
|
|
91
|
+
return panel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderComposer(bubble, replyMode, bridge) {
|
|
95
|
+
const composer = document.createElement("div");
|
|
96
|
+
composer.className = "composer";
|
|
97
|
+
|
|
98
|
+
if (replyMode === "open-terminal") {
|
|
99
|
+
const note = document.createElement("button");
|
|
100
|
+
note.textContent = "Open terminal to reply";
|
|
101
|
+
note.addEventListener("click", () => bridge?.reply?.({ sessionId: bubble.sessionId, text: "", focusTerminal: true }));
|
|
102
|
+
composer.appendChild(note);
|
|
103
|
+
return composer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const input = document.createElement("input");
|
|
107
|
+
input.type = "text";
|
|
108
|
+
input.placeholder = replyMode === "best-effort" ? "Reply (best effort)…" : "Reply…";
|
|
109
|
+
|
|
110
|
+
const send = document.createElement("button");
|
|
111
|
+
send.textContent = "Send";
|
|
112
|
+
send.addEventListener("click", () => {
|
|
113
|
+
const text = input.value.trim();
|
|
114
|
+
if (text) {
|
|
115
|
+
bridge?.reply?.({ sessionId: bubble.sessionId, text });
|
|
116
|
+
input.value = "";
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
composer.append(input, send);
|
|
121
|
+
return composer;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderControls(controlsPromise, bubble, bridge) {
|
|
125
|
+
const wrap = document.createElement("div");
|
|
126
|
+
wrap.className = "controls";
|
|
127
|
+
|
|
128
|
+
Promise.resolve(controlsPromise).then((controls) => {
|
|
129
|
+
for (const control of controls) {
|
|
130
|
+
if (control.id === "reply" || control.id === "approve" || control.id === "deny") {
|
|
131
|
+
continue; // rendered in the composer / approval panel
|
|
132
|
+
}
|
|
133
|
+
const btn = document.createElement("button");
|
|
134
|
+
btn.textContent = control.label;
|
|
135
|
+
btn.disabled = !control.enabled;
|
|
136
|
+
wrap.appendChild(btn);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return wrap;
|
|
141
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { clampWindowBounds, resolveSavedPosition } from "../src/main/display-manager.js";
|
|
4
|
+
|
|
5
|
+
const displays = [
|
|
6
|
+
{
|
|
7
|
+
id: "primary",
|
|
8
|
+
primary: true,
|
|
9
|
+
scaleFactor: 1.25,
|
|
10
|
+
bounds: { x: 0, y: 0, width: 800, height: 600 },
|
|
11
|
+
workArea: { x: 0, y: 0, width: 800, height: 560 }
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "secondary",
|
|
15
|
+
primary: false,
|
|
16
|
+
scaleFactor: 2,
|
|
17
|
+
bounds: { x: 800, y: 0, width: 1200, height: 900 },
|
|
18
|
+
workArea: { x: 820, y: 20, width: 1160, height: 840 }
|
|
19
|
+
}
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
test("keeps saved position inside the matching display work area", () => {
|
|
23
|
+
assert.deepEqual(
|
|
24
|
+
resolveSavedPosition({ x: 900, y: 120, width: 192, height: 208, displayId: "secondary" }, displays),
|
|
25
|
+
{ x: 900, y: 120, width: 192, height: 208, displayId: "secondary", scaleFactor: 2 }
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("clamps window bounds to the visible work area", () => {
|
|
30
|
+
assert.deepEqual(
|
|
31
|
+
clampWindowBounds({ x: 1900, y: 880, width: 192, height: 208 }, displays[1]),
|
|
32
|
+
{ x: 1788, y: 652, width: 192, height: 208 }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("falls back to primary display when saved display is missing", () => {
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
resolveSavedPosition({ x: 2000, y: 2000, width: 192, height: 208, displayId: "gone" }, displays),
|
|
39
|
+
{ x: 608, y: 352, width: 192, height: 208, displayId: "primary", scaleFactor: 1.25 }
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("uses primary display when no saved position exists", () => {
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
resolveSavedPosition(undefined, displays, { width: 384, height: 416 }),
|
|
46
|
+
{ x: 416, y: 144, width: 384, height: 416, displayId: "primary", scaleFactor: 1.25 }
|
|
47
|
+
);
|
|
48
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { createInteractionController } from "../src/renderer/interaction-controller.js";
|
|
4
|
+
|
|
5
|
+
// Test harness: collects emitted gestures and gives manual control over the
|
|
6
|
+
// deferred single-click timer.
|
|
7
|
+
function makeController(overrides = {}) {
|
|
8
|
+
const actions = [];
|
|
9
|
+
const timers = [];
|
|
10
|
+
const controller = createInteractionController({
|
|
11
|
+
onAction: (event) => actions.push(event),
|
|
12
|
+
setTimer: (fn) => {
|
|
13
|
+
timers.push(fn);
|
|
14
|
+
return timers.length - 1;
|
|
15
|
+
},
|
|
16
|
+
clearTimer: (id) => {
|
|
17
|
+
timers[id] = undefined;
|
|
18
|
+
},
|
|
19
|
+
...overrides
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
controller,
|
|
23
|
+
actions,
|
|
24
|
+
fireTimer: (id = 0) => timers[id] && timers[id](),
|
|
25
|
+
timerCount: () => timers.filter(Boolean).length
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("a single click is deferred and fires waving after the double-click window", () => {
|
|
30
|
+
const { controller, actions, fireTimer } = makeController();
|
|
31
|
+
|
|
32
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
33
|
+
controller.pointerUp({ x: 12, y: 11, time: 1100 });
|
|
34
|
+
|
|
35
|
+
// Nothing fires immediately — it waits to see if a second click arrives.
|
|
36
|
+
assert.deepEqual(actions, []);
|
|
37
|
+
|
|
38
|
+
fireTimer();
|
|
39
|
+
assert.deepEqual(actions, [{ type: "click", action: "waving" }]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("a double click jumps and does NOT also fire a single click", () => {
|
|
43
|
+
const { controller, actions } = makeController();
|
|
44
|
+
|
|
45
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
46
|
+
controller.pointerUp({ x: 10, y: 10, time: 1100 });
|
|
47
|
+
controller.pointerDown({ x: 10, y: 10, time: 1300 });
|
|
48
|
+
controller.pointerUp({ x: 10, y: 10, time: 1350 });
|
|
49
|
+
|
|
50
|
+
// Only the double-click fires; the pending single click was cancelled.
|
|
51
|
+
assert.deepEqual(actions, [{ type: "double-click", action: "jumping" }]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("the pending single click is cancelled once a double click resolves", () => {
|
|
55
|
+
const { controller, actions, fireTimer, timerCount } = makeController();
|
|
56
|
+
|
|
57
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
58
|
+
controller.pointerUp({ x: 10, y: 10, time: 1100 });
|
|
59
|
+
controller.pointerDown({ x: 10, y: 10, time: 1300 });
|
|
60
|
+
controller.pointerUp({ x: 10, y: 10, time: 1350 });
|
|
61
|
+
|
|
62
|
+
// Firing the (now cleared) timer must not produce a stray click.
|
|
63
|
+
fireTimer(0);
|
|
64
|
+
assert.deepEqual(actions, [{ type: "double-click", action: "jumping" }]);
|
|
65
|
+
assert.equal(timerCount(), 0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("drag is reported synchronously with the initial direction", () => {
|
|
69
|
+
const { controller } = makeController();
|
|
70
|
+
|
|
71
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
72
|
+
assert.deepEqual(controller.pointerMove({ x: 20, y: 10, time: 1050 }), {
|
|
73
|
+
type: "drag",
|
|
74
|
+
direction: "right",
|
|
75
|
+
action: "running-right"
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(controller.pointerUp({ x: 30, y: 10, time: 1100 }), { type: "drag-end" });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("drag direction flips immediately on reversal (incremental, not cumulative)", () => {
|
|
81
|
+
const { controller } = makeController();
|
|
82
|
+
|
|
83
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
84
|
+
// move far right
|
|
85
|
+
assert.equal(controller.pointerMove({ x: 60, y: 10, time: 1050 }).direction, "right");
|
|
86
|
+
// now move slightly left — still right of the start, but should read "left" now
|
|
87
|
+
assert.equal(controller.pointerMove({ x: 55, y: 10, time: 1060 }).direction, "left");
|
|
88
|
+
assert.equal(controller.pointerMove({ x: 58, y: 10, time: 1070 }).direction, "right");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("movement within the drag threshold is not a drag (stays a click)", () => {
|
|
92
|
+
const { controller, actions, fireTimer } = makeController();
|
|
93
|
+
|
|
94
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
95
|
+
assert.equal(controller.pointerMove({ x: 13, y: 11, time: 1010 }), undefined); // < 6px
|
|
96
|
+
controller.pointerUp({ x: 13, y: 11, time: 1050 });
|
|
97
|
+
fireTimer();
|
|
98
|
+
assert.deepEqual(actions, [{ type: "click", action: "waving" }]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a long press past clickMaxDuration is neither click nor drag", () => {
|
|
102
|
+
const { controller, actions } = makeController();
|
|
103
|
+
|
|
104
|
+
controller.pointerDown({ x: 10, y: 10, time: 1000 });
|
|
105
|
+
controller.pointerUp({ x: 10, y: 10, time: 1500 }); // 500ms > 300ms
|
|
106
|
+
assert.deepEqual(actions, []);
|
|
107
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { resolvePanelPlacement } from "../src/main/panel-placement.js";
|
|
4
|
+
|
|
5
|
+
const workArea = { x: 0, y: 0, width: 1920, height: 1080 };
|
|
6
|
+
const panel = { width: 320, height: 240 };
|
|
7
|
+
|
|
8
|
+
test("places the panel to the right when there is room", () => {
|
|
9
|
+
const result = resolvePanelPlacement({
|
|
10
|
+
pet: { x: 200, y: 400, width: 192, height: 208 },
|
|
11
|
+
panel,
|
|
12
|
+
workArea
|
|
13
|
+
});
|
|
14
|
+
assert.equal(result.side, "right");
|
|
15
|
+
assert.equal(result.x, 200 + 192 + 8);
|
|
16
|
+
assert.equal(result.y, 400);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("flips to the left when the pet is near the right edge", () => {
|
|
20
|
+
const result = resolvePanelPlacement({
|
|
21
|
+
pet: { x: 1700, y: 400, width: 192, height: 208 },
|
|
22
|
+
panel,
|
|
23
|
+
workArea
|
|
24
|
+
});
|
|
25
|
+
assert.equal(result.side, "left");
|
|
26
|
+
assert.equal(result.x, 1700 - 8 - 320);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("clamps the cross-axis so the panel stays on screen (bottom-right corner)", () => {
|
|
30
|
+
const result = resolvePanelPlacement({
|
|
31
|
+
pet: { x: 1700, y: 1000, width: 192, height: 208 },
|
|
32
|
+
panel,
|
|
33
|
+
workArea
|
|
34
|
+
});
|
|
35
|
+
assert.equal(result.side, "left");
|
|
36
|
+
// pet.y 1000 + panel 240 = 1240 > 1080, so it clamps up to 1080 - 240 = 840
|
|
37
|
+
assert.equal(result.y, 840);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("falls back to below when neither side fits", () => {
|
|
41
|
+
const narrow = { x: 0, y: 0, width: 400, height: 1080 };
|
|
42
|
+
const result = resolvePanelPlacement({
|
|
43
|
+
pet: { x: 60, y: 50, width: 192, height: 208 },
|
|
44
|
+
panel: { width: 360, height: 200 },
|
|
45
|
+
workArea: narrow
|
|
46
|
+
});
|
|
47
|
+
assert.equal(result.side, "below");
|
|
48
|
+
assert.equal(result.y, 50 + 208 + 8);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("overlaps only as a last resort on a tiny work area", () => {
|
|
52
|
+
const tiny = { x: 0, y: 0, width: 300, height: 300 };
|
|
53
|
+
const result = resolvePanelPlacement({
|
|
54
|
+
pet: { x: 50, y: 50, width: 192, height: 208 },
|
|
55
|
+
panel: { width: 320, height: 240 },
|
|
56
|
+
workArea: tiny
|
|
57
|
+
});
|
|
58
|
+
assert.equal(result.side, "overlap");
|
|
59
|
+
assert.ok(result.x >= 0 && result.y >= 0);
|
|
60
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import {
|
|
4
|
+
createDefaultPositionState,
|
|
5
|
+
parsePositionState,
|
|
6
|
+
serializePositionState,
|
|
7
|
+
updateGlobalPetPosition
|
|
8
|
+
} from "../src/main/position-store.js";
|
|
9
|
+
|
|
10
|
+
test("creates default position state", () => {
|
|
11
|
+
assert.deepEqual(createDefaultPositionState(), {
|
|
12
|
+
globalPet: {
|
|
13
|
+
open: true,
|
|
14
|
+
selectedPetId: undefined,
|
|
15
|
+
manual: false
|
|
16
|
+
},
|
|
17
|
+
sessions: {},
|
|
18
|
+
settings: {
|
|
19
|
+
displayMode: "hybrid",
|
|
20
|
+
attachBubblesToTerminals: true
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("updates global pet position as a manual placement", () => {
|
|
26
|
+
const state = updateGlobalPetPosition(createDefaultPositionState(), {
|
|
27
|
+
x: 100,
|
|
28
|
+
y: 200,
|
|
29
|
+
width: 192,
|
|
30
|
+
height: 208,
|
|
31
|
+
displayId: "primary"
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
assert.equal(state.globalPet.manual, true);
|
|
35
|
+
assert.equal(state.globalPet.x, 100);
|
|
36
|
+
assert.equal(state.globalPet.displayId, "primary");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("serializes and parses position state", () => {
|
|
40
|
+
const state = updateGlobalPetPosition(createDefaultPositionState(), {
|
|
41
|
+
x: 100,
|
|
42
|
+
y: 200,
|
|
43
|
+
width: 192,
|
|
44
|
+
height: 208,
|
|
45
|
+
displayId: "primary"
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.deepEqual(parsePositionState(serializePositionState(state)), state);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("invalid stored position state falls back to defaults", () => {
|
|
52
|
+
assert.deepEqual(parsePositionState("{not-json}"), createDefaultPositionState());
|
|
53
|
+
assert.deepEqual(parsePositionState("{}"), createDefaultPositionState());
|
|
54
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "../../../test/harness.mjs";
|
|
3
|
+
import { createStateFile } from "../src/main/state-file.js";
|
|
4
|
+
|
|
5
|
+
function fakeFs(initial) {
|
|
6
|
+
const files = new Map(initial ? Object.entries(initial) : []);
|
|
7
|
+
return {
|
|
8
|
+
files,
|
|
9
|
+
async readFile(path) {
|
|
10
|
+
if (!files.has(path)) {
|
|
11
|
+
const error = new Error("not found");
|
|
12
|
+
error.code = "ENOENT";
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
return files.get(path);
|
|
16
|
+
},
|
|
17
|
+
async writeFile(path, content) {
|
|
18
|
+
files.set(path, content);
|
|
19
|
+
},
|
|
20
|
+
async mkdir() {}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("returns default state when the file is missing", async () => {
|
|
25
|
+
const fs = fakeFs();
|
|
26
|
+
const store = createStateFile({ statePath: "/tmp/haya-pet/state.json", ...fs });
|
|
27
|
+
const state = await store.load();
|
|
28
|
+
assert.equal(state.globalPet.open, true);
|
|
29
|
+
assert.equal(state.settings.displayMode, "hybrid");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("persists and reloads global pet position", async () => {
|
|
33
|
+
const fs = fakeFs();
|
|
34
|
+
const store = createStateFile({ statePath: "/tmp/haya-pet/state.json", ...fs });
|
|
35
|
+
|
|
36
|
+
await store.save({
|
|
37
|
+
globalPet: { open: true, x: 100, y: 200, manual: true },
|
|
38
|
+
sessions: {},
|
|
39
|
+
settings: { displayMode: "hybrid", attachBubblesToTerminals: true }
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const reloaded = await store.load();
|
|
43
|
+
assert.equal(reloaded.globalPet.x, 100);
|
|
44
|
+
assert.equal(reloaded.globalPet.y, 200);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("recovers from corrupt state content", async () => {
|
|
48
|
+
const fs = fakeFs({ "/tmp/haya-pet/state.json": "{ not json" });
|
|
49
|
+
const store = createStateFile({ statePath: "/tmp/haya-pet/state.json", ...fs });
|
|
50
|
+
const state = await store.load();
|
|
51
|
+
assert.equal(state.globalPet.open, true);
|
|
52
|
+
});
|