agentgui 1.0.941 → 1.0.942

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/AGENTS.md CHANGED
@@ -144,3 +144,7 @@ The GUI runs fully offline. `site/app/vendor/cdn/` holds marked, dompurify, pris
144
144
  - `agents.list` (WS) returns `available` + `npxInstallable` per agent; `agents.models` returns model choices (claude-code → sonnet/opus/haiku). The chat picker is **agent-then-model**, not a flat model list. Unavailable agents are disabled/gated.
145
145
  - `chat.sendMessage` accepts `cwd` (defaults to STARTUP_CWD) and `model`/`agentId` separately. `chat.active` (WS) lists in-flight chats with agentId/model/cwd/startedAt/pid; the history tab polls it (3s) and shows a running panel with per-session stop.
146
146
  - Client (`app.js`): chat transcript persists to `localStorage[agentgui.chat]` and restores on load; tool_use/result events render as chat parts; keyboard shortcuts (g+c/h/s, n, /, ?); settings has an agents-status panel from `health.acp[]`.
147
+
148
+ ## DS CSS cascade — overriding component styles (2026-05-28)
149
+
150
+ **`installStyles()` injects DS CSS into a runtime `<style>` after the head `<style>`, so local overrides need `!important` or higher specificity than the DS's `.ds-247420`-prefixed rules.** Full detail (font vars `--ff-display`/`--ff-mono`, the `.chat-head .sub` "00 msgs" quirk, EventList `.row[role=button]`, `[data-prog-focus]` focus suppression, `projectLabel()` for cwd slugs) is in rs-learn (recall "agentgui GUI styling DS cascade installStyles").
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.941",
3
+ "version": "1.0.942",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -21,11 +21,39 @@
21
21
  height: 100%;
22
22
  background: var(--bg, var(--agentgui-bg));
23
23
  color: var(--fg, var(--agentgui-fg));
24
- font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
24
+ /* The DS exposes --ff-display / --ff-mono (there is no --font-sans), so the
25
+ old reference fell through to the system font. Use the DS display face. */
26
+ font-family: var(--ff-display, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif);
25
27
  }
26
28
  #app { height: 100vh; height: 100dvh; }
27
29
  #app > * { height: 100%; }
28
30
 
31
+ /* Themed thin scrollbars — the native chrome scrollbar is chunky/light and
32
+ clashes with the dark theme on the history sidebar and settings column. */
33
+ * {
34
+ scrollbar-width: thin;
35
+ scrollbar-color: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 22%, transparent) transparent;
36
+ }
37
+ *::-webkit-scrollbar { width: 9px; height: 9px; }
38
+ *::-webkit-scrollbar-track { background: transparent; }
39
+ *::-webkit-scrollbar-thumb {
40
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 20%, transparent);
41
+ border-radius: 999px;
42
+ border: 2px solid transparent;
43
+ background-clip: padding-box;
44
+ }
45
+ *::-webkit-scrollbar-thumb:hover {
46
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 34%, transparent);
47
+ background-clip: padding-box;
48
+ }
49
+
50
+ /* We move focus to the page heading on tab change for AT users; suppress the
51
+ resulting green outline box on those programmatically-focused elements.
52
+ !important + .ds-247420 prefix to beat the runtime-injected DS focus rule. */
53
+ .ds-247420 [data-prog-focus]:focus,
54
+ .ds-247420 [data-prog-focus]:focus-visible,
55
+ [data-prog-focus]:focus, [data-prog-focus]:focus-visible { outline: none !important; box-shadow: none !important; }
56
+
29
57
  /* skip link for keyboard/AT users */
30
58
  .skip-link {
31
59
  position: absolute; left: -9999px; top: 0; z-index: 1000;
@@ -127,6 +155,92 @@
127
155
  outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px;
128
156
  }
129
157
 
158
+ /* Topbar nav: the DS renders the active tab as a large filled green pill that
159
+ sits taller than the inactive text links and reads as misaligned. Make all
160
+ tabs consistent — equal padding, the active one a subtle tinted underline-pill
161
+ rather than an oversized oval. */
162
+ /* Prefix with .ds-247420 (the <html> class) to match the DS selector's
163
+ specificity; source order then lets these win. */
164
+ .ds-247420 .app-topbar nav { display: flex; align-items: center; gap: .15em; }
165
+ .ds-247420 .app-topbar nav a {
166
+ padding: .35em .75em; border-radius: 8px; line-height: 1.4;
167
+ color: var(--fg-2, color-mix(in srgb, var(--fg, var(--agentgui-fg)) 75%, transparent));
168
+ text-decoration: none; background: transparent;
169
+ transition: background-color .15s ease, color .15s ease;
170
+ }
171
+ .ds-247420 .app-topbar nav a:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent); color: var(--fg, var(--agentgui-fg)); }
172
+ /* installStyles() injects the DS CSS into a runtime <style> appended after
173
+ this block, so equal-specificity rules lose on source order; !important is
174
+ the targeted override for the few props we reshape on the active tab. */
175
+ .ds-247420 .app-topbar nav a.active {
176
+ background: color-mix(in srgb, var(--accent, var(--agentgui-accent)) 16%, transparent) !important;
177
+ color: var(--accent, var(--agentgui-accent)) !important;
178
+ box-shadow: inset 0 -2px 0 0 var(--accent, var(--agentgui-accent)) !important;
179
+ border-radius: 8px !important;
180
+ font-weight: 600;
181
+ }
182
+
183
+ /* Compact working-directory bar (replaces the full-width tall banner box). */
184
+ .cwd-bar {
185
+ display: flex; align-items: center; gap: .5em; flex-wrap: wrap;
186
+ padding: .15em 0 .5em; font-size: .85rem;
187
+ }
188
+ .cwd-bar-text {
189
+ font-family: var(--ff-mono, ui-monospace, monospace);
190
+ color: var(--fg-3, #999); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60vw;
191
+ }
192
+ .cwd-bar-btn {
193
+ cursor: pointer; font: inherit; line-height: 1.3;
194
+ padding: .15em .55em; border-radius: 6px;
195
+ background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 8%, transparent);
196
+ border: 1px solid color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent);
197
+ color: var(--fg-2, var(--agentgui-fg));
198
+ }
199
+ .cwd-bar-btn:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 14%, transparent); }
200
+ .cwd-bar-btn:focus-visible { outline: 2px solid var(--accent, var(--agentgui-accent)); outline-offset: 2px; }
201
+
202
+ /* History no-session empty state: fill the void with a centered prompt. */
203
+ .history-empty {
204
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
205
+ gap: .4em; text-align: center; min-height: 50vh; color: var(--fg-3, #888);
206
+ }
207
+ .history-empty-glyph { font-size: 3rem; opacity: .25; line-height: 1; }
208
+ .history-empty-title { margin: 0; font-size: 1.05rem; color: var(--fg-2, var(--agentgui-fg)); }
209
+ .history-empty-sub { margin: 0; max-width: 42ch; }
210
+
211
+ /* Settings: two-column on wide screens (backend + agents) so it uses the
212
+ full width instead of a cramped centered measure with empty margins. */
213
+ .settings-grid {
214
+ display: grid; gap: var(--space-4, 16px);
215
+ grid-template-columns: minmax(0, 1fr);
216
+ align-items: start;
217
+ }
218
+ @media (min-width: 900px) {
219
+ .settings-grid { grid-template-columns: minmax(0, 420px) minmax(0, 1fr); }
220
+ }
221
+ /* The DS PageHeader carries large vertical margins that leave a big empty
222
+ band above the heading and between the lede and the panels. Neutralize
223
+ them so settings/history read as normal top-aligned scrolling pages. */
224
+ .agentgui-main [class*="page-header"],
225
+ .agentgui-main .ds-page-header { margin-top: 0 !important; margin-bottom: var(--space-4, 16px) !important; }
226
+ .agentgui-main > :first-child { margin-top: 0 !important; }
227
+ .agentgui-main-settings .settings-grid { margin-top: 0; }
228
+
229
+ /* The DS Chat head computes its own zero-padded count ("00 msgs") and ignores
230
+ our sub prop; it reads as a bug. Hide the DS sub — streaming state shows via
231
+ the title and the busy banner. Also hide the DS's empty decorative head dot. */
232
+ .chat-head .sub { display: none; }
233
+ .chat-head .dot { display: none; }
234
+
235
+ /* EventList rows are role=button (click to expand) but the DS doesn't give
236
+ them a pointer cursor, so the affordance is invisible. */
237
+ .ds-event-list .row[role="button"] { cursor: pointer; }
238
+ .ds-event-list .row[role="button"]:hover { background: color-mix(in srgb, var(--fg, var(--agentgui-fg)) 5%, transparent); }
239
+
240
+ /* Chat composer: hide the idle scrollbar on the (empty/short) textarea. */
241
+ .chat-composer textarea { overflow-y: auto; scrollbar-width: thin; }
242
+ .chat-composer textarea:not(:focus) { overflow-y: hidden; }
243
+
130
244
  /* touch targets on small screens */
131
245
  @media (max-width: 640px) {
132
246
  .pill { min-height: 36px; padding: .4em .8em; }
@@ -150,12 +150,31 @@ function navTo(tab) {
150
150
  render();
151
151
  // Move focus into the new region for keyboard/AT users.
152
152
  requestAnimationFrame(() => {
153
+ syncAriaCurrent();
153
154
  const region = document.querySelector('#agentgui-main');
154
155
  if (!region) return;
155
156
  const heading = region.querySelector('h1, h2');
156
157
  const target = heading || region;
157
158
  if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1');
159
+ // Mark as programmatically focused so CSS can suppress the focus ring — we
160
+ // move focus here for AT, but a visible green outline box around the heading
161
+ // reads as an accidental border to sighted users.
162
+ target.setAttribute('data-prog-focus', '');
158
163
  try { target.focus({ preventScroll: true }); } catch {}
164
+ const clear = () => { target.removeAttribute('data-prog-focus'); target.removeEventListener('blur', clear); };
165
+ target.addEventListener('blur', clear);
166
+ });
167
+ }
168
+
169
+ // The DS Topbar derives aria-current from href↔location.hash matching, which
170
+ // drifts from our hash-based active tab (e.g. aria-current lands on "settings"
171
+ // while we're on "chat"). Re-assert aria-current on the actually-active tab.
172
+ function syncAriaCurrent() {
173
+ const links = document.querySelectorAll('.app-topbar nav a');
174
+ links.forEach((a) => {
175
+ const isActive = a.classList.contains('active');
176
+ if (isActive) a.setAttribute('aria-current', 'page');
177
+ else a.removeAttribute('aria-current');
159
178
  });
160
179
  }
161
180
 
@@ -255,8 +274,10 @@ function view() {
255
274
  const glyphMatch = dotText.match(/^([●◌○])\s*(.*)$/);
256
275
  const dotGlyph = glyphMatch ? glyphMatch[1] : '';
257
276
  const dotLabel = glyphMatch ? glyphMatch[2] : dotText;
277
+ // When live, the CSS .status-dot-live::before draws the (pulsing) dot, so the
278
+ // literal glyph would render a second dot — only emit the glyph when NOT live.
258
279
  const dot = h('span', { key: 'dot', class: 'status-dot' + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
259
- dotGlyph ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
280
+ (!dotLive && dotGlyph) ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
260
281
  h('span', { key: 'dl' }, dotLabel));
261
282
 
262
283
  const topbar = Topbar({
@@ -331,8 +352,7 @@ function view() {
331
352
  children: 'g then c/h/s — chat/history/settings · n — new chat · / — focus search/composer · ? — toggle this · Esc — blur field' })
332
353
  : null;
333
354
  const main = h('div', { id: 'agentgui-main', role: 'main', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab, style: mainStyle }, [shortcutsHint, ...mainContent()].filter(Boolean));
334
- // settings reads better centered in a measure; chat + history use full width.
335
- return AppShell({ topbar, crumb, side, main, status, narrow: state.tab === 'settings' });
355
+ return AppShell({ topbar, crumb, side, main, status, narrow: false });
336
356
  }
337
357
 
338
358
  function mainContent() {
@@ -434,7 +454,14 @@ function chatMain() {
434
454
  ...banners,
435
455
  Chat({
436
456
  title: agentName + (state.selectedModel ? ' · ' + state.selectedModel : '') + (state.chat.resumeSid ? ' · resume' : ''),
437
- sub: state.chat.busy ? 'streaming…' : undefined,
457
+ // Explicit human-readable sub; the DS default ("NN msgs", zero-padded)
458
+ // leaks an event-list style into chat. Hide it when there are no messages
459
+ // (the empty-state already says "no messages yet").
460
+ sub: state.chat.busy
461
+ ? 'streaming…'
462
+ : (state.chat.messages.length
463
+ ? state.chat.messages.length + (state.chat.messages.length === 1 ? ' message' : ' messages')
464
+ : ''),
438
465
  messages: msgs,
439
466
  composer,
440
467
  }),
@@ -459,10 +486,11 @@ function cwdBanner() {
459
486
  }, children: 'save' }),
460
487
  Btn({ key: 'cwdcancel', onClick: () => { state.cwdEditing = false; state.cwdDraft = undefined; render(); }, children: 'cancel' }));
461
488
  }
462
- return h('div', { key: 'cwdb', class: 'resume-banner', role: 'group', 'aria-label': 'Working directory' },
463
- h('span', { class: 'lede' }, state.chatCwd ? ' cwd: ' + state.chatCwd : ' cwd: server default'),
464
- Btn({ key: 'cwdset', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); }, children: state.chatCwd ? 'change' : 'set' }),
465
- state.chatCwd ? Btn({ key: 'cwdclr', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); }, children: default' }) : null);
489
+ return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
490
+ h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
491
+ state.chatCwd ? '' + truncate(state.chatCwd, 28, 60) : ' cwd: server default'),
492
+ h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
493
+ state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, '× default') : null);
466
494
  }
467
495
 
468
496
  function newChat() {
@@ -553,22 +581,31 @@ function reconnectAlert() {
553
581
 
554
582
  function historyMain() {
555
583
  if (!state.selectedSid) {
584
+ const count = (Array.isArray(state.sessions) ? state.sessions : []).length;
556
585
  return [
557
586
  reconnectAlert(),
558
587
  PageHeader({
559
588
  title: '§ history',
560
589
  lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
561
590
  }),
591
+ h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
592
+ h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
593
+ h('p', { key: 'gt', class: 'history-empty-title' },
594
+ count ? 'Select a session to view its events' : 'No sessions yet'),
595
+ h('p', { key: 'gs', class: 'lede history-empty-sub' },
596
+ count
597
+ ? count + ' session' + (count === 1 ? '' : 's') + ' available · use the search box or press / to filter'
598
+ : 'Start a chat or run a local coding agent — its session will appear here live.')),
562
599
  ].filter(Boolean);
563
600
  }
564
601
 
565
602
  const sess = (Array.isArray(state.sessions) ? state.sessions : []).find(s => s.sid === state.selectedSid);
566
603
  const lede = sess
567
- ? (sess.project || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
604
+ ? (projectLabel(sess.project) || sess.cwd || '?') + ' · ' + (sess.events || 0) + ' events · ' + (sess.userTurns || 0) + ' turns · ' + fmtRelTime(sess.last)
568
605
  : state.selectedSid;
569
606
 
570
607
  const head = PageHeader({
571
- title: '§ ' + truncate(sess?.title || state.selectedSid, 40, 80),
608
+ title: '§ ' + truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
572
609
  lede,
573
610
  });
574
611
 
@@ -668,6 +705,16 @@ function visibleSessions() {
668
705
  return filtered.slice().sort((a, b) => (b.last || 0) - (a.last || 0));
669
706
  }
670
707
 
708
+ // ccsniff derives `project` from the ~/.claude/projects dir name, which encodes
709
+ // the cwd as a dash-joined path (e.g. "-config-workspace-agentgui"). Show the
710
+ // last meaningful segment ("agentgui") rather than the raw slug.
711
+ function projectLabel(project) {
712
+ if (!project) return '';
713
+ if (/[/\\]/.test(project)) return project.split(/[/\\]/).filter(Boolean).pop() || project;
714
+ const segs = project.split('-').filter(Boolean);
715
+ return segs.length ? segs[segs.length - 1] : project;
716
+ }
717
+
671
718
  function uniqueProjects() {
672
719
  const arr = Array.isArray(state.sessions) ? state.sessions : [];
673
720
  const seen = new Map();
@@ -699,7 +746,7 @@ function historySide() {
699
746
  Row({
700
747
  key: 'sess' + s.sid,
701
748
  rank: String(i + 1).padStart(3, '0'),
702
- title: (s.isSubagent ? '↳ ' : '') + (s.title || s.project || s.sid),
749
+ title: (s.isSubagent ? '↳ ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
703
750
  sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
704
751
  rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
705
752
  active: s.sid === state.selectedSid,
@@ -755,7 +802,7 @@ function historySide() {
755
802
  ? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
756
803
  pillButton('allp', 'all', !state.projectFilter, 'Show all projects', () => { state.projectFilter = ''; render(); }),
757
804
  ...projects.slice(0, 8).map(([name, count]) =>
758
- pillButton('p'+name, truncate(name, 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
805
+ pillButton('p'+name, truncate(projectLabel(name), 14, 20) + ' (' + count + ')', state.projectFilter === name, name, () => { state.projectFilter = state.projectFilter === name ? '' : name; render(); })))
759
806
  : null,
760
807
  !searching && subagentCount
761
808
  ? h('label', { key: 'subtog', class: 'lede subagent-toggle' },
@@ -814,6 +861,7 @@ function settingsMain() {
814
861
  title: '⌘ settings',
815
862
  lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
816
863
  }),
864
+ h('div', { key: 'settings-grid', class: 'settings-grid' }, [
817
865
  Panel({
818
866
  title: 'backend',
819
867
  children: h('form', {
@@ -847,6 +895,7 @@ function settingsMain() {
847
895
  ]),
848
896
  }),
849
897
  agentsPanel(),
898
+ ]),
850
899
  ];
851
900
  }
852
901
 
@@ -914,6 +963,8 @@ async function runSearch() {
914
963
  const debouncedSearch = debounce(runSearch, 300);
915
964
 
916
965
  async function loadSession(sid) {
966
+ // Guard against a bad sid from a malformed hash (e.g. "?sid=undefined").
967
+ if (!sid || sid === 'undefined' || sid === 'null') { state.selectedSid = null; render(); return; }
917
968
  state.selectedSid = sid;
918
969
  state.events = [];
919
970
  state.eventsLoaded = false;
@@ -984,6 +1035,7 @@ function registerWsStatusOnce() {
984
1035
 
985
1036
  restoreChat();
986
1037
  render = mount(document.getElementById('app'), view);
1038
+ requestAnimationFrame(syncAriaCurrent);
987
1039
 
988
1040
  // Re-render on resize so isNarrow()/truncate() reflect the current width
989
1041
  // (they read window.innerWidth only at render time).