uv-suite 0.22.0 → 0.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uv-suite",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
5
5
  "author": "Utsav Anand",
6
6
  "license": "MIT",
@@ -30,29 +30,48 @@
30
30
  .session-tag:hover { border-color: #424245; }
31
31
  .session-tag.active { border-color: #fff; }
32
32
 
33
- .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 240px); }
34
- .event { padding: 8px 24px; display: grid; grid-template-columns: 70px 110px 120px 60px 1fr; gap: 8px; align-items: start; font-size: 12px; border-bottom: 1px solid #0d0d0d; transition: background 0.15s; }
35
- .event:hover { background: #0d0d0d; }
36
- .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; }
37
- .event .type { font-weight: 500; }
38
- .event .session { font-size: 11px; border-radius: 8px; padding: 1px 8px; display: inline-block; }
39
- .event .tool { color: #a1a1a6; font-family: 'SF Mono', monospace; font-size: 11px; }
40
- .event .detail { color: #a1a1a6; font-size: 11px; line-height: 1.4; }
33
+ .timeline { padding: 8px 0; overflow-y: auto; max-height: calc(100vh - 280px); }
34
+
35
+ .event { padding: 10px 24px; display: grid; grid-template-columns: 70px 110px 140px 65px 1fr; gap: 10px; align-items: start; font-size: 13px; border-bottom: 1px solid #0d0d0d; transition: all 0.2s; opacity: 0.75; }
36
+ .event:hover { background: #0d0d0d; opacity: 1; }
37
+ .event .time { color: #6e6e73; font-variant-numeric: tabular-nums; font-family: 'SF Mono', monospace; font-size: 11px; padding-top: 2px; }
38
+ .event .type { font-weight: 500; font-size: 12px; }
39
+ .event .session { font-size: 11px; border-radius: 8px; padding: 2px 8px; display: inline-block; max-width: 140px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
40
+ .event .tool { color: #a1a1a6; font-family: 'SF Mono', monospace; font-size: 11px; padding-top: 2px; }
41
+ .event .detail { color: #a1a1a6; font-size: 12px; line-height: 1.5; word-break: break-all; }
41
42
  .event .detail .filepath { color: #64d2ff; }
42
43
  .event .detail .cmd { color: #ffd60a; }
43
44
 
44
- /* Human interventionpulsing highlight */
45
- .event.needs-human { background: #ff375f12; border-left: 3px solid #ff375f; }
45
+ /* Latest eventfull opacity, larger, highlighted */
46
+ .event.latest { opacity: 1; font-size: 14px; background: #0a0a0a; border-bottom: 2px solid #2d2d2f; }
47
+ .event.latest .type { font-size: 13px; }
48
+ .event.latest .detail { font-size: 13px; color: #d1d1d6; }
49
+
50
+ /* Human intervention — strong highlight */
51
+ .event.needs-human { background: #ff375f18; border-left: 4px solid #ff375f; opacity: 1; }
46
52
  .event.needs-human .type { color: #ff375f; }
47
- .event.needs-human::after { content: 'NEEDS HUMAN'; position: absolute; right: 24px; font-size: 9px; font-weight: 700; color: #ff375f; letter-spacing: 0.5px; }
48
- .event.needs-human { position: relative; }
53
+ .event.needs-human .human-badge { display: inline-block; background: #ff375f; color: #fff; font-size: 9px; font-weight: 700; padding: 1px 6px; border-radius: 3px; letter-spacing: 0.5px; margin-left: 8px; vertical-align: middle; }
49
54
 
50
55
  /* Failure highlight */
51
- .event.failure { background: #ff696112; border-left: 3px solid #ff6961; }
56
+ .event.failure { background: #ff696115; border-left: 4px solid #ff6961; opacity: 1; }
52
57
 
53
- /* Session start/end highlight */
58
+ /* Session start/end */
54
59
  .event.session-boundary { background: #30d15808; }
55
60
 
61
+ /* User prompt — show what was asked */
62
+ .event.user-prompt { background: #ffd60a08; }
63
+ .event.user-prompt .detail { color: #ffd60a; font-style: italic; }
64
+
65
+ /* Bottom loader area */
66
+ .timeline-end { padding: 24px; text-align: center; }
67
+ .loader { display: inline-block; width: 40px; height: 4px; position: relative; }
68
+ .loader::before { content: ''; position: absolute; width: 8px; height: 4px; background: #424245; border-radius: 2px; animation: loader 1.2s ease-in-out infinite; }
69
+ @keyframes loader {
70
+ 0%, 100% { left: 0; }
71
+ 50% { left: 32px; }
72
+ }
73
+ .timeline-end .waiting { font-size: 11px; color: #424245; margin-top: 8px; }
74
+
56
75
  .empty { padding: 60px 24px; text-align: center; color: #6e6e73; }
57
76
  .empty p { margin-top: 8px; font-size: 13px; }
58
77
 
@@ -102,6 +121,12 @@
102
121
  </div>
103
122
  </div>
104
123
 
124
+ <!-- Bottom loader — always visible, shows system is listening -->
125
+ <div class="timeline-end" id="timelineEnd">
126
+ <div class="loader"></div>
127
+ <div class="waiting" id="waitingText">Listening for events...</div>
128
+ </div>
129
+
105
130
  <script>
106
131
  const timeline = document.getElementById('timeline');
107
132
  const emptyState = document.getElementById('emptyState');
@@ -110,6 +135,7 @@ const filterSession = document.getElementById('filterSession');
110
135
  const statusDot = document.getElementById('statusDot');
111
136
  const statusText = document.getElementById('statusText');
112
137
  const sessionBar = document.getElementById('sessionBar');
138
+ const waitingText = document.getElementById('waitingText');
113
139
 
114
140
  let events = [];
115
141
  let sessions = {};
@@ -117,6 +143,7 @@ let autoScroll = true;
117
143
  let humanOnly = false;
118
144
  let selectedSession = '';
119
145
  let selectedType = '';
146
+ let lastEventDiv = null;
120
147
 
121
148
  // Session colors and naming
122
149
  const palette = ['#0a84ff','#30d158','#ff9f0a','#bf5af2','#ff375f','#64d2ff','#ffd60a','#ac8ee0','#ff6961','#5e5ce6'];
@@ -132,19 +159,17 @@ function sessionColor(id) {
132
159
  return sessions[id].color;
133
160
  }
134
161
 
135
- // Build a meaningful session label from the first user prompt or app name
136
162
  function updateSessionLabel(sid, ev) {
137
163
  if (!sessions[sid]) return;
138
- // Capture source_app as fallback name
139
164
  if (!sessions[sid].app && ev.source_app) {
140
165
  sessions[sid].app = ev.source_app;
141
166
  }
142
- // Use first UserPromptSubmit content as the session label
143
- if (!sessions[sid].label && ev.event_type === 'UserPromptSubmit') {
167
+ // Use first UserPromptSubmit as session label
168
+ const type = ev.event_type || ev.hook_event_name || '';
169
+ if (!sessions[sid].label && type === 'UserPromptSubmit') {
144
170
  const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
145
171
  if (prompt.length > 0) {
146
- // Take first 40 chars, trim to last word boundary
147
- let label = prompt.slice(0, 40).replace(/\s+\S*$/, '');
172
+ let label = prompt.slice(0, 45).replace(/\s+\S*$/, '');
148
173
  if (prompt.length > label.length) label += '...';
149
174
  sessions[sid].label = label;
150
175
  updateSessionBar();
@@ -156,7 +181,9 @@ function updateSessionLabel(sid, ev) {
156
181
  function sessionDisplayName(id) {
157
182
  const s = sessions[id];
158
183
  if (!s) return shortId(id);
159
- if (s.label) return s.label;
184
+ if (s.label) {
185
+ return s.app ? s.app + ': ' + s.label : s.label;
186
+ }
160
187
  if (s.app) return s.app;
161
188
  return shortId(id);
162
189
  }
@@ -166,32 +193,36 @@ function shortId(id) {
166
193
  return id.length > 10 ? id.slice(0, 8) + '..' : id;
167
194
  }
168
195
 
196
+ function shortSession(id) {
197
+ return sessionDisplayName(id);
198
+ }
199
+
169
200
  function formatTime(ts) {
170
201
  const d = new Date(ts);
171
202
  return d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
172
203
  }
173
204
 
174
- function shortSession(id) {
175
- return sessionDisplayName(id);
205
+ function timeSince(ts) {
206
+ const secs = Math.floor((Date.now() - ts) / 1000);
207
+ if (secs < 5) return 'just now';
208
+ if (secs < 60) return secs + 's ago';
209
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
210
+ return Math.floor(secs / 3600) + 'h ago';
176
211
  }
177
212
 
178
- // Detect if event needs human intervention
213
+ // Detect human intervention needed
179
214
  function needsHuman(ev) {
180
215
  const type = ev.event_type || ev.hook_event_name || '';
181
- // Permission requests always need human
182
216
  if (type === 'PermissionRequest') return true;
183
- // Notifications that are permission prompts
184
217
  if (type === 'Notification' && ev.notification_type === 'permission_prompt') return true;
185
- // Tool failures after multiple attempts
186
218
  if (type === 'PostToolUseFailure') return true;
187
- // Stuck indicators in messages
188
219
  if (ev.message && /stuck|blocked|escalat|need.*help|can.?t.*find/i.test(ev.message)) return true;
189
220
  return false;
190
221
  }
191
222
 
192
223
  function isFailure(ev) {
193
224
  const type = ev.event_type || ev.hook_event_name || '';
194
- return type.includes('Failure') || type === 'StopFailure';
225
+ return type.includes('Failure');
195
226
  }
196
227
 
197
228
  function isSessionBoundary(ev) {
@@ -199,7 +230,12 @@ function isSessionBoundary(ev) {
199
230
  return type === 'SessionStart' || type === 'SessionEnd' || type === 'Stop';
200
231
  }
201
232
 
202
- // Build rich detail string
233
+ function isUserPrompt(ev) {
234
+ const type = ev.event_type || ev.hook_event_name || '';
235
+ return type === 'UserPromptSubmit';
236
+ }
237
+
238
+ // Rich detail
203
239
  function eventDetail(ev) {
204
240
  const parts = [];
205
241
  const tool = ev.tool_name || '';
@@ -214,27 +250,29 @@ function eventDetail(ev) {
214
250
  if (input.content) parts.push(`(${input.content.length} chars)`);
215
251
  } else if (tool === 'Edit' && input.file_path) {
216
252
  parts.push(`<span class="filepath">${input.file_path}</span>`);
217
- if (input.old_string) parts.push(`replacing "${input.old_string.slice(0, 30)}..."`);
253
+ if (input.old_string) parts.push(`"${input.old_string.slice(0, 40)}..."`);
218
254
  } else if (tool === 'Bash' && input.command) {
219
- parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
220
- if (input.timeout) parts.push(`timeout:${input.timeout}ms`);
255
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 100)}</span>`);
221
256
  } else if (tool === 'Grep' && input.pattern) {
222
257
  parts.push(`pattern: "${input.pattern}"`);
223
258
  if (input.path) parts.push(`in <span class="filepath">${input.path}</span>`);
224
259
  } else if (tool === 'Glob' && input.pattern) {
225
260
  parts.push(`<span class="filepath">${input.pattern}</span>`);
226
261
  } else if (tool === 'Agent') {
227
- parts.push(input.description || input.prompt?.slice(0, 60) || 'subagent');
262
+ parts.push(input.description || input.prompt?.slice(0, 80) || 'subagent');
228
263
  } else if (tool === 'WebSearch' && input.query) {
229
- parts.push(`"${input.query.slice(0, 60)}"`);
264
+ parts.push(`"${input.query.slice(0, 80)}"`);
230
265
  } else if (tool === 'WebFetch' && input.url) {
231
- parts.push(`<span class="filepath">${input.url.slice(0, 60)}</span>`);
266
+ parts.push(`<span class="filepath">${input.url.slice(0, 80)}</span>`);
267
+ } else if (isUserPrompt(ev)) {
268
+ const prompt = input.prompt || input.content || ev.message || '';
269
+ parts.push(prompt.slice(0, 120));
232
270
  } else if (ev.message) {
233
- parts.push(ev.message.slice(0, 80));
271
+ parts.push(ev.message.slice(0, 100));
234
272
  } else if (input.file_path) {
235
273
  parts.push(`<span class="filepath">${input.file_path}</span>`);
236
274
  } else if (input.command) {
237
- parts.push(`<span class="cmd">$ ${input.command.slice(0, 60)}</span>`);
275
+ parts.push(`<span class="cmd">$ ${input.command.slice(0, 80)}</span>`);
238
276
  } else if (ev.source_app) {
239
277
  parts.push(ev.source_app);
240
278
  }
@@ -250,18 +288,22 @@ function renderEvent(ev) {
250
288
  const human = needsHuman(ev);
251
289
  const fail = isFailure(ev);
252
290
  const boundary = isSessionBoundary(ev);
291
+ const prompt = isUserPrompt(ev);
253
292
 
254
293
  const div = document.createElement('div');
255
294
  let cls = 'event';
256
295
  if (human) cls += ' needs-human';
257
296
  else if (fail) cls += ' failure';
297
+ else if (prompt) cls += ' user-prompt';
258
298
  else if (boundary) cls += ' session-boundary';
259
299
  div.className = cls;
260
300
 
301
+ const humanBadge = human ? '<span class="human-badge">NEEDS HUMAN</span>' : '';
302
+
261
303
  div.innerHTML = `
262
304
  <span class="time">${formatTime(ev._ts)}</span>
263
- <span class="type type-${type}">${type}</span>
264
- <span class="session" style="background:${color}22;color:${color}">${shortSession(sid)}</span>
305
+ <span class="type type-${type}">${type}${humanBadge}</span>
306
+ <span class="session" style="background:${color}22;color:${color}" title="${sessionDisplayName(sid)}">${shortSession(sid)}</span>
265
307
  <span class="tool">${tool}</span>
266
308
  <span class="detail">${eventDetail(ev)}</span>
267
309
  `;
@@ -280,12 +322,36 @@ function addEvent(ev) {
280
322
  if (emptyState.parentNode) emptyState.remove();
281
323
  const sid = ev.session_id || ev.source_app || 'unknown';
282
324
  updateSessionLabel(sid, ev);
283
- timeline.appendChild(renderEvent(ev));
325
+
326
+ // Remove "latest" class from previous latest
327
+ if (lastEventDiv) lastEventDiv.classList.remove('latest');
328
+
329
+ const div = renderEvent(ev);
330
+ div.classList.add('latest');
331
+ lastEventDiv = div;
332
+ timeline.appendChild(div);
333
+
284
334
  updateStats();
285
335
  updateFilterType(ev);
286
- if (autoScroll) timeline.scrollTop = timeline.scrollHeight;
336
+ updateWaitingText(ev);
337
+
338
+ if (autoScroll) {
339
+ document.getElementById('timelineEnd').scrollIntoView({ behavior: 'smooth' });
340
+ }
287
341
  }
288
342
 
343
+ function updateWaitingText(ev) {
344
+ if (ev) {
345
+ const sid = ev.session_id || ev.source_app || 'unknown';
346
+ waitingText.textContent = `Last: ${sessionDisplayName(sid)} — ${timeSince(ev._ts)}`;
347
+ }
348
+ }
349
+
350
+ // Keep "time since" updated
351
+ setInterval(() => {
352
+ if (events.length > 0) updateWaitingText(events[events.length - 1]);
353
+ }, 5000);
354
+
289
355
  function updateStats() {
290
356
  document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
291
357
  document.getElementById('eventCount').textContent = events.length;
@@ -305,7 +371,8 @@ function updateSessionBar() {
305
371
  tag.className = 'session-tag' + (selectedSession === id ? ' active' : '');
306
372
  tag.style.background = s.color + '22';
307
373
  tag.style.color = s.color;
308
- tag.textContent = shortSession(id) + ' (' + s.count + ')';
374
+ tag.textContent = sessionDisplayName(id) + ' (' + s.count + ')';
375
+ tag.title = id;
309
376
  tag.onclick = () => { selectedSession = selectedSession === id ? '' : id; refilter(); updateSessionBar(); };
310
377
  sessionBar.appendChild(tag);
311
378
  }
@@ -326,7 +393,7 @@ function updateFilterSession() {
326
393
  filterSession.innerHTML = '<option value="">All sessions</option>';
327
394
  for (const id of Object.keys(sessions)) {
328
395
  const opt = document.createElement('option');
329
- opt.value = id; opt.textContent = shortSession(id);
396
+ opt.value = id; opt.textContent = sessionDisplayName(id);
330
397
  filterSession.appendChild(opt);
331
398
  }
332
399
  }
@@ -349,7 +416,7 @@ function refilter() {
349
416
  filterType.onchange = refilter;
350
417
  filterSession.onchange = refilter;
351
418
 
352
- function clearEvents() { events = []; sessions = {}; colorIdx = 0; timeline.innerHTML = ''; updateStats(); sessionBar.innerHTML = ''; }
419
+ function clearEvents() { events = []; sessions = {}; colorIdx = 0; timeline.innerHTML = ''; lastEventDiv = null; updateStats(); sessionBar.innerHTML = ''; }
353
420
  function toggleAutoScroll() {
354
421
  autoScroll = !autoScroll;
355
422
  document.getElementById('btnAutoScroll').classList.toggle('active', autoScroll);
@@ -360,7 +427,7 @@ function toggleHumanOnly() {
360
427
  refilter();
361
428
  }
362
429
 
363
- // Server-Sent Events connection
430
+ // Server-Sent Events
364
431
  function connect() {
365
432
  const source = new EventSource('/stream');
366
433