@tryinget/pi-activity-strip 0.2.0 → 0.4.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.
@@ -1,8 +1,47 @@
1
- export function createStripHtml({ interactive = false } = {}) {
1
+ // ---
2
+ // summary: "generates the calm, interactive activity strip with accessible cards and rich detail reveal"
3
+ // read_when:
4
+ // - "changing strip markup, styling, state rendering, ordering, or interactions"
5
+ // ---
6
+
7
+ import {
8
+ isActiveSession,
9
+ isMonitoringSession,
10
+ moveOrderItem,
11
+ reconcileActivityOrder,
12
+ } from "../common/activity-order.mjs";
13
+ import {
14
+ disambiguatedRepoLabel,
15
+ findDuplicateLabels,
16
+ isStalledSession,
17
+ } from "../common/card-display.mjs";
18
+ import {
19
+ ACTIVITY_STRIP_EVENT_STALL_MS,
20
+ ACTIVITY_STRIP_ORDER_REFRESH_MS,
21
+ } from "../common/constants.mjs";
22
+
23
+ const ORDER_RUNTIME = [
24
+ 'const ACTIVE_STATES = new Set(["thinking", "tool", "waiting"]);',
25
+ isActiveSession.toString(),
26
+ isMonitoringSession.toString(),
27
+ reconcileActivityOrder.toString(),
28
+ moveOrderItem.toString(),
29
+ isStalledSession.toString(),
30
+ findDuplicateLabels.toString(),
31
+ disambiguatedRepoLabel.toString(),
32
+ ].join("\n");
33
+
34
+ export function shouldRetainExpandedCard({ hovered, activeElement, documentFocused }) {
35
+ return Boolean(hovered || (documentFocused && activeElement));
36
+ }
37
+
38
+ const INTERACTION_RUNTIME = shouldRetainExpandedCard.toString();
39
+
40
+ export function createStripHtml({ interactive = true, initiallyVisible = true } = {}) {
2
41
  const pointerEvents = interactive ? "auto" : "none";
3
42
 
4
43
  return `<!doctype html>
5
- <html>
44
+ <html data-strip-visible="${initiallyVisible ? "true" : "false"}">
6
45
  <head>
7
46
  <meta charset="utf-8" />
8
47
  <meta name="viewport" content="width=device-width,initial-scale=1" />
@@ -10,27 +49,25 @@ export function createStripHtml({ interactive = false } = {}) {
10
49
  :root {
11
50
  color-scheme: dark;
12
51
  --panel: rgba(9, 14, 24, 0.82);
13
- --panel-strong: rgba(14, 21, 35, 0.94);
52
+ --panel-strong: rgba(14, 21, 35, 0.96);
14
53
  --line: rgba(255, 255, 255, 0.08);
15
54
  --line-strong: rgba(255, 255, 255, 0.14);
16
55
  --text: rgba(247, 250, 255, 0.96);
17
56
  --muted: rgba(194, 205, 224, 0.74);
18
57
  --dim: rgba(167, 180, 203, 0.48);
19
58
  --accent: #79b8ff;
59
+ --current-line: color-mix(in srgb, var(--accent) 78%, white 12%);
20
60
  --thinking: #8ab4ff;
21
61
  --tool: #ffd166;
22
62
  --waiting: #ff9f7a;
23
63
  --success: #57d9a3;
24
64
  --error: #ff7d7d;
25
- --shadow: 0 16px 42px rgba(0, 0, 0, 0.42);
65
+ --shadow: none;
26
66
  }
27
67
 
28
- * {
29
- box-sizing: border-box;
30
- }
31
-
32
- html,
33
- body {
68
+ * { box-sizing: border-box; }
69
+ html[data-strip-visible="false"] { visibility: hidden; }
70
+ html, body {
34
71
  margin: 0;
35
72
  width: 100%;
36
73
  height: 100%;
@@ -39,10 +76,8 @@ export function createStripHtml({ interactive = false } = {}) {
39
76
  font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI", sans-serif;
40
77
  pointer-events: ${pointerEvents};
41
78
  }
42
-
43
- body {
44
- padding: 4px 10px 6px;
45
- }
79
+ body { padding: 4px 10px 6px; }
80
+ button { font: inherit; }
46
81
 
47
82
  .strip {
48
83
  display: grid;
@@ -51,15 +86,12 @@ export function createStripHtml({ interactive = false } = {}) {
51
86
  width: 100%;
52
87
  height: 100%;
53
88
  }
54
-
55
- .brand,
56
- .cards {
89
+ .brand, .cards {
57
90
  backdrop-filter: blur(18px) saturate(1.25);
58
91
  -webkit-backdrop-filter: blur(18px) saturate(1.25);
59
92
  border: 1px solid var(--line);
60
93
  box-shadow: var(--shadow);
61
94
  }
62
-
63
95
  .brand {
64
96
  display: flex;
65
97
  flex-direction: column;
@@ -68,76 +100,49 @@ export function createStripHtml({ interactive = false } = {}) {
68
100
  border-radius: 20px;
69
101
  background: linear-gradient(180deg, rgba(12, 19, 33, 0.92), rgba(8, 13, 22, 0.82));
70
102
  }
71
-
72
- .brand__eyebrow,
73
- .meta,
74
- .tool,
75
- .card__state,
76
- .placeholder__eyebrow {
103
+ .brand__eyebrow, .meta, .tool, .card__state, .inspector__key, .placeholder__eyebrow {
77
104
  font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
78
- letter-spacing: 0.14em;
105
+ letter-spacing: 0.12em;
79
106
  text-transform: uppercase;
80
107
  }
81
-
82
- .brand__eyebrow,
83
- .placeholder__eyebrow {
84
- color: var(--accent);
85
- font-size: 10px;
86
- }
87
-
88
- .brand__title {
89
- margin-top: 6px;
90
- color: var(--text);
91
- font-size: 20px;
92
- font-weight: 650;
93
- line-height: 1;
94
- }
95
-
96
- .brand__subtitle {
97
- margin-top: 6px;
98
- color: var(--muted);
99
- font-size: 11px;
100
- line-height: 1.35;
101
- }
102
-
103
- .meta {
104
- color: var(--dim);
105
- font-size: 11px;
106
- }
108
+ .brand__eyebrow, .placeholder__eyebrow { color: var(--accent); font-size: 9px; }
109
+ .brand__title { margin-top: 4px; color: var(--text); font-size: 18px; font-weight: 650; line-height: 1; }
110
+ .brand__subtitle { margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.25; }
111
+ .meta { color: var(--dim); font-size: 9px; line-height: 1.3; }
107
112
 
108
113
  .cards {
109
114
  display: flex;
110
115
  gap: 8px;
111
- align-items: stretch;
116
+ align-items: flex-start;
112
117
  min-width: 0;
113
118
  padding: 6px 8px 8px;
114
119
  border-radius: 20px;
120
+ overflow-x: auto;
121
+ overflow-y: hidden;
122
+ scrollbar-width: none;
115
123
  background: linear-gradient(180deg, rgba(10, 16, 28, 0.9), rgba(6, 10, 18, 0.74));
116
124
  }
125
+ .cards::-webkit-scrollbar { display: none; }
126
+ .cards--empty { align-items: center; justify-content: center; }
117
127
 
118
- .cards--empty {
119
- align-items: center;
120
- justify-content: center;
121
- }
122
-
123
- .card,
124
- .placeholder {
128
+ .card, .placeholder {
125
129
  position: relative;
126
130
  overflow: hidden;
127
131
  border-radius: 16px;
128
132
  border: 1px solid var(--line-strong);
129
- background: linear-gradient(180deg, rgba(13, 20, 35, 0.96), rgba(9, 14, 24, 0.84));
133
+ background: linear-gradient(180deg, rgba(13, 20, 35, 0.96), rgba(9, 14, 24, 0.9));
130
134
  }
131
-
132
135
  .card {
136
+ appearance: none;
133
137
  display: grid;
134
- grid-template-rows: auto auto 1fr;
135
- gap: 6px;
136
- min-width: 0;
137
- flex: 1 1 220px;
138
- padding: 10px 12px 10px;
138
+ flex: 0 0 224px;
139
+ height: 60px;
140
+ padding: 8px 10px 8px 12px;
141
+ color: inherit;
142
+ text-align: left;
143
+ cursor: pointer;
144
+ transition: border-color 120ms ease, background 120ms ease, height 120ms ease;
139
145
  }
140
-
141
146
  .card::before {
142
147
  content: "";
143
148
  position: absolute;
@@ -145,229 +150,349 @@ export function createStripHtml({ interactive = false } = {}) {
145
150
  width: 3px;
146
151
  background: var(--state-color, var(--accent));
147
152
  }
148
-
149
- .card__header,
150
- .card__footer {
151
- display: flex;
152
- justify-content: space-between;
153
- align-items: center;
154
- gap: 10px;
155
- min-width: 0;
156
- }
157
-
158
- .card__label {
159
- min-width: 0;
160
- }
161
-
162
- .card__repo {
163
- color: var(--text);
164
- font-size: 13px;
165
- font-weight: 650;
166
- line-height: 1.15;
167
- white-space: nowrap;
168
- overflow: hidden;
169
- text-overflow: ellipsis;
170
- }
171
-
172
- .card__cwd {
173
- margin-top: 2px;
174
- color: var(--dim);
175
- font-size: 10px;
176
- white-space: nowrap;
177
- overflow: hidden;
178
- text-overflow: ellipsis;
179
- }
180
-
181
- .card__state {
182
- flex: 0 0 auto;
183
- padding: 4px 7px;
184
- border-radius: 999px;
185
- background: color-mix(in srgb, var(--state-color, var(--accent)) 14%, transparent);
186
- border: 1px solid color-mix(in srgb, var(--state-color, var(--accent)) 34%, transparent);
187
- color: var(--state-color, var(--accent));
188
- font-size: 9px;
189
- line-height: 1;
190
- }
191
-
192
- .card__phase {
193
- color: var(--text);
194
- font-size: 12px;
195
- font-weight: 600;
196
- line-height: 1.2;
197
- white-space: nowrap;
198
- overflow: hidden;
199
- text-overflow: ellipsis;
200
- }
201
-
202
- .card__detail {
203
- color: var(--muted);
204
- font-size: 11px;
205
- line-height: 1.2;
206
- display: -webkit-box;
207
- -webkit-line-clamp: 3;
208
- -webkit-box-orient: vertical;
209
- overflow: hidden;
210
- min-height: 3.6em;
211
- }
212
-
213
- .tool,
214
- .elapsed {
215
- color: var(--dim);
216
- font-size: 9px;
217
- }
218
-
219
- .placeholder {
220
- display: flex;
221
- flex-direction: column;
222
- justify-content: center;
223
- gap: 8px;
224
- width: 100%;
225
- max-width: 420px;
226
- padding: 18px 20px;
227
- }
228
-
229
- .placeholder__title {
230
- color: var(--text);
231
- font-size: 16px;
232
- font-weight: 650;
233
- }
234
-
235
- .placeholder__copy {
236
- color: var(--muted);
237
- font-size: 13px;
238
- line-height: 1.45;
153
+ .card:hover, .card:focus-visible, .card[data-open="true"] {
154
+ border-color: color-mix(in srgb, var(--state-color, var(--accent)) 58%, white 4%);
155
+ background: linear-gradient(180deg, rgba(18, 28, 47, 0.99), rgba(10, 16, 28, 0.96));
156
+ outline: none;
157
+ }
158
+ .card[data-current="true"] {
159
+ border-color: var(--current-line);
160
+ outline: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
161
+ outline-offset: -3px;
162
+ }
163
+ .card[data-current="true"]::before { width: 5px; }
164
+ .card[data-current="true"]:focus-visible {
165
+ outline: 2px solid var(--current-line);
166
+ outline-offset: -4px;
167
+ }
168
+ body[data-expanded="true"] .card[data-open="true"] { height: 228px; }
169
+ .card__header, .card__footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-width: 0; }
170
+ .card__label { min-width: 0; }
171
+ .card__repo { color: var(--text); font-size: 12px; font-weight: 650; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
172
+ .card__phase { margin-top: 4px; color: var(--muted); font-size: 10px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
173
+ .card__state { flex: 0 0 auto; padding: 4px 6px; border-radius: 999px; background: color-mix(in srgb, var(--state-color, var(--accent)) 14%, transparent); border: 1px solid color-mix(in srgb, var(--state-color, var(--accent)) 34%, transparent); color: var(--state-color, var(--accent)); font-size: 8px; line-height: 1; }
174
+ .card__footer { margin-top: 5px; }
175
+ .tool, .elapsed { color: var(--dim); font-size: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
176
+ .tool { max-width: 120px; }
177
+
178
+ .inspector {
179
+ display: none;
180
+ grid-template-columns: 58px minmax(0, 1fr);
181
+ gap: 6px 8px;
182
+ margin-top: 10px;
183
+ padding-top: 9px;
184
+ border-top: 1px solid var(--line);
185
+ }
186
+ body[data-expanded="true"] .card[data-open="true"] .inspector { display: grid; }
187
+ .inspector__key { color: var(--dim); font-size: 8px; }
188
+ .inspector__value { color: var(--muted); font-size: 10px; line-height: 1.25; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
189
+ .inspector__value--path { direction: rtl; text-align: left; white-space: nowrap; text-overflow: ellipsis; display: block; }
190
+ .activation { grid-column: 1 / -1; min-height: 14px; color: var(--dim); font-size: 9px; }
191
+ .activation[data-kind="error"] { color: var(--error); }
192
+ .activation[data-kind="success"] { color: var(--success); }
193
+
194
+ .placeholder { display: flex; flex-direction: column; justify-content: center; gap: 6px; width: 100%; max-width: 420px; padding: 12px 18px; }
195
+ .placeholder__title { color: var(--text); font-size: 14px; font-weight: 650; }
196
+ .placeholder__copy { color: var(--muted); font-size: 11px; line-height: 1.35; }
197
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
198
+ .card[data-stalled="true"] { opacity: 0.72; } .card[data-stalled="true"] .card__phase { text-decoration: line-through; }
199
+
200
+ @media (prefers-reduced-motion: reduce) {
201
+ .card { transition: none; }
239
202
  }
240
203
  </style>
241
204
  </head>
242
- <body>
205
+ <body data-expanded="false">
243
206
  <div class="strip">
244
- <section class="brand">
207
+ <section class="brand" aria-label="Pi activity summary">
245
208
  <div>
246
209
  <div class="brand__eyebrow">π telemetry ribbon</div>
247
210
  <div class="brand__title">Activity</div>
248
- <div class="brand__subtitle">Live Pi session detail across your active tabs.</div>
211
+ <div class="brand__subtitle">Calm order · live detail</div>
249
212
  </div>
250
213
  <div class="meta" id="meta">Waiting for sessions…</div>
251
214
  </section>
252
- <section class="cards cards--empty" id="cards"></section>
215
+ <section class="cards cards--empty" id="cards" aria-label="Live Pi sessions"></section>
216
+ <div class="sr-only" id="announcer" aria-live="polite"></div>
253
217
  </div>
254
218
 
255
219
  <script>
220
+ ${ORDER_RUNTIME}
221
+ ${INTERACTION_RUNTIME}
222
+ const ORDER_REFRESH_MS = ${ACTIVITY_STRIP_ORDER_REFRESH_MS};
223
+ const EVENT_STALL_MS = ${ACTIVITY_STRIP_EVENT_STALL_MS};
256
224
  const meta = document.getElementById("meta");
257
225
  const cards = document.getElementById("cards");
258
- const api = window.activityStrip || { subscribe() { return () => {}; } };
259
- let snapshot = { generatedAt: Date.now(), sessions: [] };
260
-
261
- const STATE_LABELS = {
262
- idle: "idle",
263
- thinking: "thinking",
264
- tool: "tool",
265
- waiting: "waiting",
266
- success: "done",
267
- error: "error",
268
- };
269
-
270
- const STATE_COLORS = {
271
- idle: "var(--accent)",
272
- thinking: "var(--thinking)",
273
- tool: "var(--tool)",
274
- waiting: "var(--waiting)",
275
- success: "var(--success)",
276
- error: "var(--error)",
226
+ const announcer = document.getElementById("announcer");
227
+ const api = window.activityStrip || {
228
+ activate: async () => ({ ok: false, error: "Activity bridge unavailable" }),
229
+ setExpanded: async () => {},
230
+ onCollapse() { return () => {}; },
231
+ onVisibility() { return () => {}; },
232
+ subscribe() { return () => {}; },
277
233
  };
234
+ let snapshot = { generatedAt: Date.now(), focusedSessionId: null, sessions: [] };
235
+ let orderedIds = [];
236
+ let nextOrderRefreshAt = 0;
237
+ let collapseTimer = null;
278
238
 
279
- function escapeHtml(value) {
280
- return String(value ?? "").replace(/[&<>"']/g, (character) => {
281
- switch (character) {
282
- case "&": return "&amp;";
283
- case "<": return "&lt;";
284
- case ">": return "&gt;";
285
- case '"': return "&quot;";
286
- case "'": return "&#39;";
287
- default: return character;
288
- }
289
- });
290
- }
239
+ const STATE_LABELS = { idle: "idle", thinking: "thinking", tool: "tool", waiting: "waiting", success: "done", error: "error" };
240
+ const STATE_COLORS = { idle: "var(--accent)", thinking: "var(--thinking)", tool: "var(--tool)", waiting: "var(--waiting)", success: "var(--success)", error: "var(--error)" };
291
241
 
292
242
  function formatDuration(totalSeconds) {
293
243
  const minutes = Math.floor(totalSeconds / 60);
294
244
  const seconds = totalSeconds % 60;
295
245
  return String(minutes) + ":" + String(seconds).padStart(2, "0");
296
246
  }
297
-
298
247
  function formatElapsed(session) {
299
248
  const anchor = Number(session.agentStartedAt || session.startedAt || snapshot.generatedAt || Date.now());
300
249
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
301
250
  }
302
-
303
251
  function formatLastSeen(session) {
304
- const anchor = Number(session.updatedAt || snapshot.generatedAt || Date.now());
252
+ const anchor = Number(session.lastEventAt || session.updatedAt || snapshot.generatedAt || Date.now());
305
253
  return formatDuration(Math.max(0, Math.floor((Date.now() - anchor) / 1000)));
306
254
  }
307
-
308
- function placeholderHtml() {
309
- return [
310
- '<div class="placeholder">',
311
- '<div class="placeholder__eyebrow">ready</div>',
312
- '<div class="placeholder__title">No active Pi sessions yet</div>',
313
- '<div class="placeholder__copy">Open Pi in Ghostty, install this package, and the ribbon will populate automatically as sessions stream status into the broker.</div>',
314
- '</div>',
315
- ].join("");
316
- }
317
-
318
- function sessionCardHtml(session) {
319
- const stateColor = STATE_COLORS[session.state] || "var(--accent)";
320
- const stateLabel = STATE_LABELS[session.state] || session.state || "idle";
321
- const repoLabel = escapeHtml(session.repoLabel || "pi session");
322
- const cwd = escapeHtml(session.cwd || "");
323
- const phase = escapeHtml(session.phase || "Idle");
324
- const detail = escapeHtml(session.detail || session.assistantPreview || "Ready");
325
- const tool = escapeHtml(session.toolName || session.toolTarget || "monitoring");
326
- const elapsed = escapeHtml(formatElapsed(session));
327
- const lastSeen = escapeHtml(formatLastSeen(session));
328
-
329
- return [
330
- '<article class="card" style="--state-color:' + stateColor + '">',
331
- '<header class="card__header">',
332
- '<div class="card__label">',
333
- '<div class="card__repo">' + repoLabel + '</div>',
334
- '<div class="card__cwd">' + cwd + '</div>',
335
- '</div>',
336
- '<div class="card__state">' + escapeHtml(stateLabel) + '</div>',
337
- '</header>',
338
- '<div>',
339
- '<div class="card__phase">' + phase + '</div>',
340
- '<div class="card__detail">' + detail + '</div>',
255
+ function sessionById(sessionId) {
256
+ return (Array.isArray(snapshot.sessions) ? snapshot.sessions : []).find((session) => session.sessionId === sessionId);
257
+ }
258
+ function safeDomId(sessionId) {
259
+ return "session-detail-" + String(sessionId).replace(/[^a-zA-Z0-9_-]/g, "-");
260
+ }
261
+ function setText(card, selector, value) {
262
+ const target = card.querySelector(selector);
263
+ if (target) target.textContent = String(value ?? "");
264
+ }
265
+ function cardTemplate(sessionId) {
266
+ const card = document.createElement("button");
267
+ card.type = "button";
268
+ card.className = "card";
269
+ card.dataset.sessionId = sessionId;
270
+ card.dataset.current = "false";
271
+ card.dataset.open = "false";
272
+ card.setAttribute("aria-expanded", "false");
273
+ card.setAttribute("aria-describedby", safeDomId(sessionId));
274
+ card.innerHTML = [
275
+ '<div class="card__header"><div class="card__label"><div class="card__repo"></div><div class="card__phase"></div></div><div class="card__state"></div></div>',
276
+ '<div class="card__footer"><div class="tool"></div><div class="elapsed"></div></div>',
277
+ '<div class="inspector" id="' + safeDomId(sessionId) + '" role="tooltip">',
278
+ '<div class="inspector__key">detail</div><div class="inspector__value detail"></div>',
279
+ '<div class="inspector__key">prompt</div><div class="inspector__value prompt"></div>',
280
+ '<div class="inspector__key">reply</div><div class="inspector__value reply"></div>',
281
+ '<div class="inspector__key">path</div><div class="inspector__value inspector__value--path path"></div>',
282
+ '<div class="activation" aria-live="polite"></div>',
341
283
  '</div>',
342
- '<footer class="card__footer">',
343
- '<div class="tool">' + tool + '</div>',
344
- '<div class="elapsed">' + elapsed + ' · seen ' + lastSeen + '</div>',
345
- '</footer>',
346
- '</article>',
347
284
  ].join("");
285
+ return card;
286
+ }
287
+ function updateCard(card, session, duplicateLabels) {
288
+ const stalled = isStalledSession(session, Date.now(), EVENT_STALL_MS);
289
+ const stateColor = stalled ? "var(--waiting)" : STATE_COLORS[session.state] || "var(--accent)";
290
+ const stateLabel = stalled ? "stalled" : STATE_LABELS[session.state] || session.state || "idle";
291
+ const isCurrent = session.sessionId === snapshot.focusedSessionId;
292
+ card.style.setProperty("--state-color", stateColor);
293
+ card.dataset.stalled = stalled ? "true" : "false";
294
+ card.dataset.group = isMonitoringSession(session)
295
+ ? "monitoring"
296
+ : isActiveSession(session)
297
+ ? "active"
298
+ : "settled";
299
+ card.dataset.current = isCurrent ? "true" : "false";
300
+ if (isCurrent) card.setAttribute("aria-current", "true");
301
+ else card.removeAttribute("aria-current");
302
+ card.setAttribute("aria-label", (stalled ? "Stalled, " : isCurrent ? "Current terminal, " : "") + (session.repoLabel || "Pi session") + ", " + (stalled ? "no events recently" : session.phase || "Idle") + ". Press Enter to focus its Ghostty window.");
303
+ card.title = (isCurrent ? "Current terminal · " : "Focus ") + (session.repoLabel || "Pi session");
304
+ setText(card, ".card__repo", disambiguatedRepoLabel(session, duplicateLabels));
305
+ setText(card, ".card__phase", session.phase || "Idle");
306
+ setText(card, ".card__state", stateLabel);
307
+ setText(card, ".tool", session.toolName || session.toolTarget || "monitoring");
308
+ setText(card, ".elapsed", formatElapsed(session) + " · " + formatLastSeen(session));
309
+ setText(card, ".detail", session.detail || "Ready");
310
+ setText(card, ".prompt", session.lastPromptPreview || "—");
311
+ setText(card, ".reply", session.assistantPreview || "—");
312
+ setText(card, ".path", session.cwd || "—");
313
+ }
314
+ function syncOrder(regroup) {
315
+ const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
316
+ orderedIds = reconcileActivityOrder(sessions, orderedIds, { regroup });
317
+ if (regroup) nextOrderRefreshAt = Date.now() + ORDER_REFRESH_MS;
348
318
  }
349
-
350
319
  function render() {
351
320
  const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
321
+ const byId = new Map(sessions.map((session) => [session.sessionId, session]));
322
+ const activeCount = sessions.filter(isActiveSession).length;
352
323
  meta.textContent = sessions.length
353
- ? String(sessions.length) + ' live ' + (sessions.length === 1 ? 'session' : 'sessions')
354
- : 'Waiting for sessions…';
355
-
324
+ ? String(activeCount) + " active · " + String(sessions.length - activeCount) + " settled · order " + String(Math.max(0, Math.ceil((nextOrderRefreshAt - Date.now()) / 1000))) + "s"
325
+ : "Waiting for sessions…";
356
326
  if (sessions.length === 0) {
357
- cards.classList.add('cards--empty');
358
- cards.innerHTML = placeholderHtml();
327
+ if (document.body.dataset.expanded === "true") setExpanded(null, false).catch(() => {});
328
+ cards.classList.add("cards--empty");
329
+ cards.innerHTML = '<div class="placeholder"><div class="placeholder__eyebrow">ready</div><div class="placeholder__title">No active Pi sessions yet</div><div class="placeholder__copy">Open Pi in Ghostty and the ribbon will populate automatically.</div></div>';
359
330
  return;
360
331
  }
361
-
362
- cards.classList.remove('cards--empty');
363
- cards.innerHTML = sessions.map((session) => sessionCardHtml(session)).join('');
332
+ cards.classList.remove("cards--empty");
333
+ cards.querySelector(".placeholder")?.remove();
334
+ const cardById = new Map(
335
+ [...cards.querySelectorAll(".card")].map((card) => [card.dataset.sessionId, card]),
336
+ );
337
+ for (const [sessionId, orphan] of cardById) {
338
+ if (!byId.has(sessionId)) {
339
+ orphan.remove();
340
+ cardById.delete(sessionId);
341
+ }
342
+ }
343
+ if (
344
+ document.body.dataset.expanded === "true" &&
345
+ !cards.querySelector('.card[data-open="true"]')
346
+ ) {
347
+ setExpanded(null, false).catch(() => {});
348
+ }
349
+ let targetIndex = 0;
350
+ const duplicateLabels = findDuplicateLabels(sessions);
351
+ for (const sessionId of orderedIds) {
352
+ const session = byId.get(sessionId);
353
+ if (!session) continue;
354
+ let card = cardById.get(sessionId);
355
+ if (!card) {
356
+ card = cardTemplate(sessionId);
357
+ cardById.set(sessionId, card);
358
+ }
359
+ updateCard(card, session, duplicateLabels);
360
+ const nodeAtTarget = cards.children[targetIndex] ?? null;
361
+ if (nodeAtTarget !== card) cards.insertBefore(card, nodeAtTarget);
362
+ targetIndex += 1;
363
+ }
364
+ }
365
+ async function setExpanded(card, expanded) {
366
+ if (collapseTimer) { clearTimeout(collapseTimer); collapseTimer = null; }
367
+ for (const candidate of cards.querySelectorAll(".card")) {
368
+ const isOpen = candidate === card && expanded;
369
+ candidate.dataset.open = isOpen ? "true" : "false";
370
+ candidate.setAttribute("aria-expanded", isOpen ? "true" : "false");
371
+ }
372
+ document.body.dataset.expanded = expanded ? "true" : "false";
373
+ await api.setExpanded(Boolean(expanded));
374
+ }
375
+ function scheduleCollapse() {
376
+ if (collapseTimer) clearTimeout(collapseTimer);
377
+ collapseTimer = setTimeout(() => {
378
+ const engagedCard = [...cards.querySelectorAll(".card")].find((candidate) =>
379
+ shouldRetainExpandedCard({
380
+ hovered: candidate.matches(":hover"),
381
+ activeElement: candidate === document.activeElement,
382
+ documentFocused: document.hasFocus(),
383
+ }),
384
+ );
385
+ if (engagedCard) {
386
+ setExpanded(engagedCard, true).catch(() => {});
387
+ return;
388
+ }
389
+ setExpanded(null, false).catch(() => {});
390
+ }, 120);
391
+ }
392
+ async function activate(card) {
393
+ const session = sessionById(card.dataset.sessionId);
394
+ if (!session) return;
395
+ const activation = card.querySelector(".activation");
396
+ if (activation) { activation.dataset.kind = ""; activation.textContent = "Locating exact Ghostty session…"; }
397
+ let result;
398
+ try {
399
+ result = await api.activate(session.sessionId);
400
+ } catch {
401
+ result = { ok: false, error: "Focus bridge failed; nothing focused." };
402
+ }
403
+ if (activation) {
404
+ activation.dataset.kind = result?.ok ? "success" : "error";
405
+ activation.textContent = result?.ok ? "Focused Ghostty window." : (result?.error || "No exact window match; nothing focused.");
406
+ }
407
+ announcer.textContent = activation?.textContent || "";
364
408
  }
365
409
 
366
- setInterval(() => render(), 1000);
410
+ cards.addEventListener("pointerover", (event) => {
411
+ const card = event.target.closest?.(".card");
412
+ if (card && !card.contains(event.relatedTarget)) setExpanded(card, true).catch(() => {});
413
+ });
414
+ cards.addEventListener("pointerout", (event) => {
415
+ const card = event.target.closest?.(".card");
416
+ if (card && !card.contains(event.relatedTarget)) scheduleCollapse();
417
+ });
418
+ cards.addEventListener("focusin", (event) => {
419
+ const card = event.target.closest?.(".card");
420
+ if (card) setExpanded(card, true).catch(() => {});
421
+ });
422
+ cards.addEventListener("focusout", (event) => {
423
+ const card = event.target.closest?.(".card");
424
+ if (card && !card.contains(event.relatedTarget)) scheduleCollapse();
425
+ });
426
+ window.addEventListener("blur", () => setExpanded(null, false).catch(() => {}));
427
+ document.addEventListener("pointerleave", () =>
428
+ setExpanded(null, false).catch(() => {}),
429
+ );
430
+ document.addEventListener("visibilitychange", () => {
431
+ if (document.hidden) setExpanded(null, false).catch(() => {});
432
+ });
433
+ cards.addEventListener("click", (event) => {
434
+ const card = event.target.closest?.(".card");
435
+ if (card) activate(card).catch(() => {});
436
+ });
437
+ cards.addEventListener("keydown", (event) => {
438
+ const card = event.target.closest?.(".card");
439
+ if (!card || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
440
+ event.preventDefault();
441
+ const direction = event.key === "ArrowLeft" ? -1 : 1;
442
+ if (event.shiftKey) {
443
+ orderedIds = moveOrderItem(orderedIds, card.dataset.sessionId, direction);
444
+ nextOrderRefreshAt = Date.now() + ORDER_REFRESH_MS;
445
+ render();
446
+ const moved = [...cards.querySelectorAll(".card")].find((candidate) => candidate.dataset.sessionId === card.dataset.sessionId);
447
+ moved?.focus();
448
+ moved?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
449
+ announcer.textContent = "Session card moved " + (direction < 0 ? "left" : "right") + ".";
450
+ return;
451
+ }
452
+ const index = orderedIds.indexOf(card.dataset.sessionId);
453
+ const targetId = orderedIds[Math.max(0, Math.min(orderedIds.length - 1, index + direction))];
454
+ const target = [...cards.querySelectorAll(".card")].find((candidate) => candidate.dataset.sessionId === targetId);
455
+ target?.focus();
456
+ target?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
457
+ });
458
+
459
+ setInterval(() => {
460
+ if (Date.now() >= nextOrderRefreshAt) syncOrder(true);
461
+ render();
462
+ }, 1000);
463
+ api.onCollapse?.(() => setExpanded(null, false).catch(() => {}));
464
+ api.onVisibility?.(async (visible, isCurrent = () => true) => {
465
+ if (!visible) {
466
+ document.documentElement.dataset.stripVisible = "false";
467
+ if (document.hidden) return isCurrent();
468
+ await new Promise((resolve) =>
469
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
470
+ );
471
+ return isCurrent();
472
+ }
473
+ if (document.hidden) {
474
+ if (!isCurrent()) return false;
475
+ document.documentElement.dataset.stripVisible = "true";
476
+ return true;
477
+ }
478
+ await new Promise((resolve) =>
479
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
480
+ );
481
+ if (!isCurrent()) return false;
482
+ document.documentElement.dataset.stripVisible = "true";
483
+ await new Promise((resolve) => requestAnimationFrame(resolve));
484
+ if (!isCurrent()) {
485
+ document.documentElement.dataset.stripVisible = "false";
486
+ return false;
487
+ }
488
+ return true;
489
+ });
367
490
  api.subscribe((nextSnapshot) => {
368
- snapshot = nextSnapshot || { generatedAt: Date.now(), sessions: [] };
491
+ snapshot = nextSnapshot || { generatedAt: Date.now(), focusedSessionId: null, sessions: [] };
492
+ syncOrder(orderedIds.length === 0);
369
493
  render();
370
494
  });
495
+ syncOrder(true);
371
496
  render();
372
497
  </script>
373
498
  </body>