agentgui 1.0.1115 → 1.0.1117

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
@@ -8,6 +8,10 @@ ACP lifecycle lives in `lib/acp-sdk-manager.js` alone; no other module spawns or
8
8
 
9
9
  Same-origin app auth must never use the `Authorization` header when an upstream proxy may own Basic auth — a `Bearer` header overwrites the browser's cached `Authorization: Basic` credentials, and nginx `auth_basic` only accepts `Basic`, so the request is rejected at the proxy before it ever reaches agentgui. Use the **`?token=` query param** (`withToken()`, exactly like the WS / EventSource / image / download URLs) instead — it coexists with upstream Basic auth, and agentgui accepts `?token=` on every HTTP route, plus the `agentgui_token` cookie.
10
10
 
11
+ ## CRITICAL — no Chrome/Puppeteer/Playwright dependency anywhere in this repo
12
+
13
+ Never add `puppeteer`/`puppeteer-core`/`playwright`/`playwright-core` as a dependency, and never hand-roll a raw headless-Chrome launch in a script. Live browser verification for this project is done via the gm skill's `browser` verb (direct CDP, no relay), not by this repo owning its own browser-automation dependency. `scripts/capture-screenshots.mjs` (puppeteer-core-driven, dead code — not wired into any CI workflow) was removed for this reason.
14
+
11
15
  ## Standing engineering rules
12
16
 
13
17
  - **All GUI/design decisions live in the kit (`../design`), none in agentgui.** New surface styling is a kit CSS rule, never an inline `<style>` or `style=` prop in agentgui. A new kit component must be re-exported through `src/components.js`'s barrel to be consumable — adding it to a component file alone leaves it invisible to the built bundle even with 0 lint errors; grep the built dist for the export name before wiring the app against it. New CSS class tokens in the kit must carry a registered family prefix (`ds-`, `app-`, `ws-`, `chat-`, etc. — see `scripts/lint-classes.mjs`'s `PREFIXES`/`FROZEN` lists) or the build's lint-classes check fails; a legacy bare name like `chip` being grandfathered on the FROZEN list does not cover new sub-tokens off it (e.g. a new `chip-remove` class still needs a `ds-` prefix).
@@ -79,7 +83,7 @@ Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-c
79
83
 
80
84
  ## CI / GitHub Actions
81
85
 
82
- **capture-screenshots must run under bun, not node** (gh-pages.yml `--ignore-scripts` leaves `better-sqlite3` uncompiled, so `bun:sqlite`'s fallback also fails under Node — detail in rs-learn). Rule: any CI step that spawns the agentgui server must invoke it with `bun`.
86
+ Any CI step that spawns the agentgui server must invoke it with `bun`, not `node` (`--ignore-scripts` npm installs leave `better-sqlite3` uncompiled, so `bun:sqlite`'s fallback also fails under Node).
83
87
 
84
88
  ## GM Plugin Autonomy Blocker
85
89
 
@@ -87,6 +87,9 @@ export function register(router, deps) {
87
87
  features: a.supportedFeatures || [],
88
88
  available: registry.isAvailable(a.id),
89
89
  npxInstallable: !!a.npxPackage,
90
+ // The CLI binary name a manual (non-npx) install would need - lets the
91
+ // settings panel say what to install instead of just "not installed".
92
+ cmd: a.command || null,
90
93
  }));
91
94
  return { agents };
92
95
  });
@@ -355,7 +358,11 @@ export function register(router, deps) {
355
358
  if (p?.file) args.push('--', assertSafeRelPath(p.file, 'file'));
356
359
  try {
357
360
  const { stdout } = await execFileP('git', args, { cwd });
358
- return { diff: stdout, file: p?.file || null, staged: !!p?.staged };
361
+ // git prints "Binary files a/x and b/x differ" for binary paths instead
362
+ // of a unified diff - an empty `diff` string then reads to the client as
363
+ // "no diff to show" with no explanation. Detect it so the UI can say why.
364
+ const binary = /^Binary files .* differ$/m.test(stdout);
365
+ return { diff: stdout, binary, file: p?.file || null, staged: !!p?.staged };
359
366
  } catch (e) { err(500, (e.stderr || e.message || 'git diff failed').trim()); }
360
367
  });
361
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.1115",
3
+ "version": "1.0.1117",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",
@@ -34,13 +34,11 @@
34
34
  "fsbrowse": "latest",
35
35
  "lru-cache": "^11.2.7",
36
36
  "opencode-ai": "^1.2.15",
37
- "puppeteer-core": "^24.37.5",
38
37
  "webjsx": "^0.0.73",
39
38
  "ws": "^8.14.2",
40
39
  "xstate": "^5.32.0"
41
40
  },
42
41
  "devDependencies": {
43
- "electron": "^35.0.0",
44
- "playwright": "^1.59.1"
42
+ "electron": "^35.0.0"
45
43
  }
46
44
  }
@@ -3,7 +3,7 @@ import * as B from './backend.js';
3
3
 
4
4
  installStyles().catch(() => {});
5
5
 
6
- const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, TextField, Select, Btn, Icon, IconButton, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox, ShortcutList, FocusTrap, AgentListSkeleton, flashComposerNote, toast, withBusy, GitStatusPanel, GitDiffView, WorktreeSwitcher, ModelsConfig, PluginsConfig } = C;
6
+ const { AppShell, WorkspaceShell, WorkspaceRail, Topbar, Crumb, Side, Status, Chat, ChatComposer, AgentChat, ConversationList, SessionDashboard, Row, Panel, PageHeader, TextField, Select, Btn, Icon, IconButton, EventList, Spinner, Alert, FileGrid, FileSkeleton, sortFiles, FileToolbar, RootsPicker, BreadcrumbPath, EmptyState, FileViewer, FilePreviewPane, FilePreviewCode, FilePreviewText, FilePreviewMedia, ThemeToggle, ContextPane, PromptDialog, ConfirmDialog, DropZone, UploadProgress, FilterPills, SessionMeta, BulkBar, Checkbox, ShortcutList, FocusTrap, AgentListSkeleton, flashComposerNote, toast, withBusy, GitStatusPanel, GitDiffView, WorktreeSwitcher, Badge, ModelsConfig, PluginsConfig } = C;
7
7
 
8
8
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
9
  // formatters (exported alongside the components), fall back to the local
@@ -23,7 +23,12 @@ const state = {
23
23
  chatCwd: lsGet('agentgui.cwd') || '',
24
24
  chat: { messages: [], busy: false, abort: null, draft: '', resumeSid: null, confirmingEdit: null, totalCost: 0 },
25
25
  agentsError: null,
26
- agentsLoading: false,
26
+ // true until the boot loadAgents() call resolves - starting false left a
27
+ // real window (mount -> boot's first render -> loadAgents() actually
28
+ // starting) where the picker rendered with an empty options list instead
29
+ // of the "loading agents…" placeholder, a transient state a toolbar button
30
+ // could source an undefined label/type from.
31
+ agentsLoading: true,
27
32
  settingsSection: null,
28
33
  eventFilter: 'all', // history event-type filter: all | text | tool | errors
29
34
  sessionSearchQ: null, // the query the selected session was opened from (search-hit highlight)
@@ -44,6 +49,9 @@ const state = {
44
49
  eventsLimit: 300, // how many of the most-recent events to render; grows via "load older"
45
50
  files: { path: '', segments: [], entries: [], roots: [], loading: false, error: null, preview: null, sort: 'name', sortDir: 'asc', filter: '' },
46
51
  git: { loading: false, error: null, diff: '', commits: [], worktrees: [], files: [], file: '', worktreeBusy: false },
52
+ // One-time welcome banner naming what each tab is for. Shown until
53
+ // dismissed once, ever - a returning user has already learned the tabs.
54
+ showOnboarding: lsGet('agentgui.onboarded') !== '1',
47
55
  // Models tab: composed agent/provider availability (models.availability WS
48
56
  // handler), fed into the design SDK's ModelsConfig component.
49
57
  models: { data: null, loading: false, error: null, selectedProviderId: null },
@@ -177,6 +185,12 @@ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null;
177
185
  function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
178
186
  function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
179
187
 
188
+ function dismissOnboarding() {
189
+ state.showOnboarding = false;
190
+ lsSet('agentgui.onboarded', '1');
191
+ render();
192
+ }
193
+
180
194
  // A single visually-hidden aria-live region for transient announcements (tab
181
195
  // changes, etc.) so screen-reader users hear context that's otherwise conveyed
182
196
  // only by focus movement or color.
@@ -627,13 +641,19 @@ const SHORTCUTS = [
627
641
 
628
642
  function view() {
629
643
  const ok = state.health.status === 'ok';
630
- const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
631
- const dotLabel = state.tab === 'history'
644
+ // history/live both read the SSE stream, not the REST health poll - a tab
645
+ // that shows its own "connecting to live stream" widget must agree with the
646
+ // header badge, since they are the same underlying connection. Showing the
647
+ // REST-derived "connected" here while the tab's own widget says otherwise
648
+ // is exactly the mismatch this guards against.
649
+ const streamTab = state.tab === 'history' || state.tab === 'live';
650
+ const liveActive = streamTab && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
651
+ const dotLabel = streamTab
632
652
  ? (state.live.error
633
653
  ? 'stream: ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
634
654
  : (liveActive ? 'stream: live · ' + state.live.eventCount : (state.live.connected ? 'stream: live' : 'stream: connecting…')))
635
655
  : (ok ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'connected') : 'offline');
636
- const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
656
+ const dotLive = streamTab ? (liveActive || state.live.connected) : ok;
637
657
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
638
658
  // colored disc, real product design, not a text glyph. State drives its colour
639
659
  // via the modifier class; the label carries only words so AT reads "live", and
@@ -659,7 +679,15 @@ function view() {
659
679
  ? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
660
680
  : 'all sessions';
661
681
  } else if (state.tab === 'chat') {
662
- crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
682
+ // A resumed conversation loses the agent picker's own visual weight (it
683
+ // collapses to two small <select>s once turns exist) - the only other
684
+ // place the bound agent showed was this same tiny crumb text, easy to
685
+ // miss. Render it as a real badge on resumed threads so "which agent is
686
+ // this" reads as a persistent, visible indicator, not header trivia.
687
+ const chatAgentName = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : null;
688
+ crumbLeaf = chatAgentName
689
+ ? (state.chat.resumeSid ? Badge({ children: 'agent: ' + chatAgentName, tone: 'neutral' }) : chatAgentName)
690
+ : 'no agent';
663
691
  } else if (state.tab === 'files') {
664
692
  // ONE breadcrumb owner: the in-page BreadcrumbPath is the interactive
665
693
  // navigator, so the top crumb names only the tab (mirroring live/settings).
@@ -684,8 +712,13 @@ function view() {
684
712
  : 'no agent';
685
713
  // The default (same-origin) backend is implementation detail, not status -
686
714
  // the footer names a backend only when the user pointed at a custom one.
715
+ // On history/live, the persistent footer chip must agree with the crumb dot
716
+ // and the tab's own stream widget - reporting REST-health "connected" here
717
+ // while the Live tab's widget says "connecting to live stream" indefinitely
718
+ // is the exact contradiction users can't resolve into a real signal.
719
+ const footerConnLabel = streamTab ? (dotLive ? 'connected' : 'connecting…') : (ok ? 'connected' : 'offline');
687
720
  const status = Status({
688
- left: [state.backend || null, ok ? 'connected' : 'offline'].filter(Boolean),
721
+ left: [state.backend || null, footerConnLabel].filter(Boolean),
689
722
  right: [agentLabel, 'press ? for shortcuts'],
690
723
  });
691
724
 
@@ -698,7 +731,18 @@ function view() {
698
731
  h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })),
699
732
  ] }))
700
733
  : null;
701
- const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, ...mainContent()].filter(Boolean));
734
+ // One-time welcome naming what each tab is for - a brand-new user's only
735
+ // other guidance is installHint (zero-agents case) or the calm chat empty
736
+ // state (agents-but-no-conversation case); neither explains History/Files/
737
+ // Live exist at all. Dismissed once, ever, via localStorage.
738
+ const onboardingBanner = state.showOnboarding
739
+ ? Alert({
740
+ key: 'onboarding', kind: 'info', title: 'Welcome to AgentGUI',
741
+ onDismiss: dismissOnboarding,
742
+ children: 'Chat with an agent here. History holds every past conversation, Files browses and edits project directories, Live shows every running session at once, and Settings covers connection, appearance, and keyboard shortcuts.',
743
+ })
744
+ : null;
745
+ const main = h('div', { id: 'agentgui-main', role: 'region', 'aria-label': 'main content', 'data-chat-scroll': '', class: 'agentgui-main agentgui-main-' + state.tab }, [shortcutsHint, onboardingBanner, ...mainContent()].filter(Boolean));
702
746
 
703
747
  // Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
704
748
  // optional sessions column (chat + history share the conversation list), the
@@ -993,8 +1037,9 @@ async function loadGitPanel() {
993
1037
  async function loadGitDiff(file) {
994
1038
  state.git.diffLoading = true; state.git.diffError = null; render();
995
1039
  try {
996
- const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
1040
+ const { diff, binary } = await B.gitDiff(state.backend, { file: file || undefined });
997
1041
  state.git.diff = diff || '(no changes)';
1042
+ state.git.diffBinary = !!binary;
998
1043
  state.git.file = file || '';
999
1044
  } catch (e) {
1000
1045
  state.git.diffError = e.message || 'Could not load diff.';
@@ -1054,7 +1099,7 @@ function gitMain() {
1054
1099
  GitStatusPanel({ files: g.files, active: g.file, onFileClick: (f) => loadGitDiff(f.path) }) }),
1055
1100
  Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
1056
1101
  g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
1057
- : GitDiffView({ diff: g.diff || '', filename: g.file }) }),
1102
+ : GitDiffView({ diff: g.diff || '', filename: g.file, binary: !!g.diffBinary }) }),
1058
1103
  Panel({ id: 'git-log', title: 'recent commits', children:
1059
1104
  !g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
1060
1105
  : h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
@@ -1303,7 +1348,9 @@ async function runBulkDelete() {
1303
1348
  state.files.dialog = null;
1304
1349
  restoreFileDialogFocus(d._trigger);
1305
1350
  marked.clear(); state.files._lastMarkIdx = null;
1306
- announce('deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1351
+ const successMsg = 'deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
1352
+ announce(successMsg);
1353
+ toast({ message: successMsg, kind: 'success' });
1307
1354
  // Patch the visible list immediately instead of waiting on a second full
1308
1355
  // directory round-trip - the server already confirmed every deletion.
1309
1356
  const gone = new Set(targets.map((e) => e.path || e.name));
@@ -1353,7 +1400,9 @@ async function runBulkMove(destDir) {
1353
1400
  state.files.dialog = null;
1354
1401
  restoreFileDialogFocus(d._trigger);
1355
1402
  marked.clear(); state.files._lastMarkIdx = null;
1356
- announce('moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1403
+ const successMsg = 'moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
1404
+ announce(successMsg);
1405
+ toast({ message: successMsg, kind: 'success' });
1357
1406
  // Moved entries leave the current dir - patch them out immediately rather
1358
1407
  // than waiting on a second full directory round-trip.
1359
1408
  const gone = new Set(targets.map((e) => e.path || e.name));
@@ -1389,8 +1438,11 @@ async function runFileMutation(fn, doneMsg, patch) {
1389
1438
  restoreFileDialogFocus(d._trigger);
1390
1439
  announce(doneMsg);
1391
1440
  // A soft-delete's trashId (if this mutation was a delete) rides the
1392
- // return value straight to the undo-toast caller.
1441
+ // return value straight to the undo-toast caller - that banner is its own
1442
+ // dedicated real-undo-action UI, not a generic dismiss-only confirmation,
1443
+ // so it stays separate from toast() rather than being replaced by it.
1393
1444
  if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
1445
+ else toast({ message: doneMsg, kind: 'success' });
1394
1446
  if (patch) {
1395
1447
  // Patch the visible list immediately, matching the bulk-delete/move
1396
1448
  // pattern, instead of stalling the dialog on a second full round-trip.
@@ -2585,7 +2637,7 @@ function chatMain() {
2585
2637
  state.selectedModel || null,
2586
2638
  {
2587
2639
  label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
2588
- title: 'change working directory',
2640
+ title: state.chatCwd ? 'change working directory' : ('change working directory (default: ' + (state.serverHome?.cwd || '…') + ')'),
2589
2641
  onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; state.cwdError = null; render(); requestAnimationFrame(() => { const inp = document.querySelector('.chat-cwd-input, .agentchat-cwd-input'); if (inp) inp.focus(); }); },
2590
2642
  },
2591
2643
  userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
@@ -2625,6 +2677,7 @@ function chatMain() {
2625
2677
  onArmEdit: (m) => armEditAndResend(m),
2626
2678
  onEditMessage: (m) => armEditAndResend(m),
2627
2679
  cwd: state.chatCwd,
2680
+ defaultCwd: state.serverHome?.cwd || null,
2628
2681
  cwdEditing: !!state.cwdEditing,
2629
2682
  cwdDraft: state.cwdDraft,
2630
2683
  cwdError: state.cwdError || null,
@@ -3619,18 +3672,51 @@ function resumeInChat(sess, { fromHash = false } = {}) {
3619
3672
  B.getSessionEvents(state.backend, sidToLoad).then(evs => {
3620
3673
  // Only populate if still on the same resume (user may have switched).
3621
3674
  if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
3675
+ // Replay the FULL event history (not a fixed-size slice of raw events -
3676
+ // a slice taken before filtering to human/assistant turns can leave only
3677
+ // a couple of visible messages on a long thread). The kit's own
3678
+ // shownMessages/onShowEarlier windowing (wired above) handles the
3679
+ // "load earlier" pagination the same way History's eventsLimit does.
3622
3680
  const msgs = [];
3623
- for (const e of (evs || []).slice(-50)) {
3681
+ let cur = null; // the in-progress assistant message, so interleaved
3682
+ // tool_use/tool_result/text events land on one bubble
3683
+ // instead of one bubble per event.
3684
+ let totalCost = 0; // ccsniff's stored 'result' events already carry the
3685
+ // real total_cost_usd (as e.cost) - the live path
3686
+ // sums these as they stream in, but a resumed
3687
+ // historical session never re-derived it, leaving
3688
+ // totalCost stuck at 0 regardless of turn/tool count.
3689
+ for (const e of (evs || [])) {
3624
3690
  if (e.type === 'human' || e.role === 'user') {
3691
+ cur = null;
3625
3692
  const text = e.text || e.content || '';
3626
3693
  if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
3694
+ } else if (e.type === 'tool_use') {
3695
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3696
+ cur.parts.push(toolPart({ name: e.tool, input: e.toolInput, id: e.toolUseId || e.id }));
3697
+ } else if (e.type === 'tool_result') {
3698
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3699
+ applyToolResult(cur.parts, { tool_use_id: e.toolUseId || e.id, content: e.text, is_error: e.isError });
3700
+ } else if (e.type === 'result') {
3701
+ if (typeof e.cost === 'number') totalCost += e.cost;
3627
3702
  } else if (e.type === 'assistant' || e.role === 'assistant') {
3628
3703
  const text = e.text || '';
3629
- if (text) msgs.push({ id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [{ kind: 'md', text }], historical: true });
3704
+ if (!text) continue;
3705
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3706
+ appendText(cur.parts, text);
3630
3707
  }
3631
3708
  }
3632
3709
  if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
3710
+ // Attach the summed cost to the last message's costUsd (rather than a
3711
+ // side-channel field) so computeTotalCost() - which every subsequent
3712
+ // send/discard recomputes from message.costUsd - naturally includes
3713
+ // it instead of silently resetting it back to 0 on the next turn.
3714
+ if (totalCost > 0) {
3715
+ const last = msgs[msgs.length - 1];
3716
+ if (last) last.costUsd = (last.costUsd || 0) + totalCost;
3717
+ }
3633
3718
  state.chat.messages = msgs;
3719
+ state.chat.totalCost = computeTotalCost();
3634
3720
  }
3635
3721
  }).catch(() => {}) // history may not be available; silent fail
3636
3722
  .finally(() => {
@@ -3888,9 +3974,9 @@ function settingsMain() {
3888
3974
  render();
3889
3975
  },
3890
3976
  }),
3891
- state.backendStatus === 'connecting' ? h('p', { key: 'bst-connecting', class: 't-meta', role: 'status' }, 'connecting…') : null,
3892
- state.backendStatus === 'ok' ? h('p', { key: 'bst-ok', class: 't-meta', role: 'status' }, 'connected') : null,
3893
- state.backendStatus === 'failed' ? h('p', { key: 'bst-failed', class: 't-meta field-error', role: 'alert' }, 'connection failed - check the URL') : null,
3977
+ state.backendStatus === 'connecting' ? h('span', { key: 'bst-connecting', class: 'ds-status-chip', role: 'status' }, 'connecting…') : null,
3978
+ state.backendStatus === 'ok' ? h('span', { key: 'bst-ok', class: 'ds-status-chip ds-status-chip-ok', role: 'status' }, 'connected') : null,
3979
+ state.backendStatus === 'failed' ? h('span', { key: 'bst-failed', class: 'ds-status-chip ds-status-chip-error', role: 'alert' }, 'connection failed - check the URL') : null,
3894
3980
  (state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
3895
3981
  ? h('p', { key: 'bcw', class: 't-meta field-error', role: 'alert' }, 'changing backend discards this browser\'s chat transcript - press save again to confirm') : null,
3896
3982
  healthSummary(),
@@ -4067,6 +4153,15 @@ function agentsPanel() {
4067
4153
  const title = (state.agentsLoading && !state.agents.length)
4068
4154
  ? 'agents · loading…'
4069
4155
  : 'agents · ' + installed.length + '/' + state.agents.length + ' installed';
4156
+ // A 16-agent list with only ~4 installed is dominated by disabled-looking
4157
+ // rows; default to hiding not-installed/non-npx-installable agents so the
4158
+ // list reads as "what can I use" rather than a wall of grey entries. The
4159
+ // toggle stays visible (and the count in its label) so nothing is hidden
4160
+ // silently.
4161
+ const hiddenCount = state.agents.filter(a => a.available === false && !a.npxInstallable).length;
4162
+ const visibleAgents = state.hideUnavailableAgents
4163
+ ? state.agents.filter(a => a.available !== false || a.npxInstallable)
4164
+ : state.agents;
4070
4165
  return Panel({
4071
4166
  id: 'agents',
4072
4167
  title,
@@ -4076,14 +4171,22 @@ function agentsPanel() {
4076
4171
  // manual start/stop controls by design. This note makes that explicit so
4077
4172
  // a 'stopped' row doesn't read as a missing action.
4078
4173
  hasAcp ? h('p', { key: 'acpnote', class: 't-meta agentgui-field-mb' }, 'ACP agents start on demand and restart automatically; selecting one launches it.') : null,
4079
- h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, Btn({ key: 'agrefresh', onClick: () => loadAgents(), disabled: state.agentsLoading, children: state.agentsLoading ? 'refreshing…' : 'refresh' })),
4080
- ...(state.agents.length
4081
- ? state.agents.map((a, i) => {
4174
+ h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, [
4175
+ Btn({ key: 'agrefresh', onClick: () => loadAgents(), disabled: state.agentsLoading, children: state.agentsLoading ? 'refreshing…' : 'refresh' }),
4176
+ hiddenCount > 0 ? Checkbox({
4177
+ key: 'aghidetoggle',
4178
+ checked: !!state.hideUnavailableAgents,
4179
+ label: 'hide unavailable (' + hiddenCount + ')',
4180
+ onChange: (v) => { state.hideUnavailableAgents = v; render(); },
4181
+ }) : null,
4182
+ ].filter(Boolean)),
4183
+ ...(visibleAgents.length
4184
+ ? visibleAgents.map((a, i) => {
4082
4185
  const acp = acpStatusFor(a.id);
4083
4186
  const avail = a.available !== false;
4084
4187
  const usable = avail || a.npxInstallable; // selectable from this row
4085
4188
  const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
4086
- if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used' : 'not installed');
4189
+ if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used (via npx)' : ('not installed' + (a.cmd ? ' - install the "' + a.cmd + '" CLI to enable' : '')));
4087
4190
  if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'offline'));
4088
4191
  if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
4089
4192
  if (acp && !acp.healthy && acp.providerInfo?.error) {
@@ -4100,9 +4203,13 @@ function agentsPanel() {
4100
4203
  // Rail tone keeps its GUI-wide meaning: green=ok/selected,
4101
4204
  // flame=error/unavailable. Selection is shown via `active`, not by
4102
4205
  // borrowing purple (purple is reserved for subagents).
4103
- // Flame also covers ACP agents that are installed but running-unhealthy
4104
- // (provider auth error, etc.) so the error state is visually distinct.
4105
- rail: (!avail || (acp && !acp.healthy)) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
4206
+ // flame is a REAL error signal (Row's shared rail renders it as an
4207
+ // sr-only "error" a11y label) - "not installed" is the normal,
4208
+ // expected state for most of the 16-agent list and must not carry
4209
+ // that label. Only an ACP agent that is actually running-unhealthy
4210
+ // (started, but connection/auth failed) is a genuine error; a
4211
+ // merely-absent binary with no restart in flight is not.
4212
+ rail: (acp && !acp.healthy) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
4106
4213
  active: a.id === state.selectedAgent,
4107
4214
  // Non-installable agents are genuinely inert: mark them disabled (no
4108
4215
  // click, no button role) instead of looking clickable but doing nothing.
@@ -4117,15 +4224,20 @@ function agentsPanel() {
4117
4224
  : (!avail ? [Btn({ key: 'agrecheck', onClick: (e) => { e.stopPropagation(); withBusy(e.currentTarget, () => loadAgents(), 'checking…'); }, children: 're-check' })] : undefined),
4118
4225
  });
4119
4226
  })
4120
- // The empty array means one of three things; never let an in-flight load
4121
- // read as a broken registry.
4227
+ // The empty array means one of several things; never let an in-flight
4228
+ // load read as a broken registry, and never let the hide-unavailable
4229
+ // filter read as "no agents" either.
4122
4230
  : [state.agentsLoading
4123
4231
  ? AgentListSkeleton({ rows: 5 })
4124
4232
  : (state.agentsError
4125
4233
  ? h('div', { key: 'agfail', class: 't-meta empty-state' },
4126
4234
  h('span', { key: 'agfailtxt' }, 'the agent list failed to load'),
4127
4235
  Btn({ key: 'agretry2', onClick: (e) => withBusy(e.currentTarget, () => loadAgents(), 'retrying…'), children: 'retry' }))
4128
- : h('p', { key: 'none', class: 't-meta' }, 'no agents loaded'))]),
4236
+ : (state.agents.length
4237
+ ? h('div', { key: 'agallhidden', class: 't-meta empty-state' },
4238
+ h('span', { key: 'agallhiddentxt' }, 'all agents are hidden by the unavailable filter'),
4239
+ Btn({ key: 'agshowall', onClick: () => { state.hideUnavailableAgents = false; render(); }, children: 'show all' }))
4240
+ : h('p', { key: 'none', class: 't-meta' }, 'no agents loaded')))]),
4129
4241
  ].filter(Boolean),
4130
4242
  });
4131
4243
  }
@@ -4440,6 +4552,9 @@ async function init() {
4440
4552
  render();
4441
4553
  if (!(await loadAgents())) retryLoadAgents();
4442
4554
  startBuildFreshnessPoll();
4555
+ // Fetch once: lets the cwd "use default" button show what it resolves to
4556
+ // via a tooltip, instead of only revealing it after the click.
4557
+ B.getHome(state.backend).then(h => { state.serverHome = h; render(); }).catch(() => {});
4443
4558
 
4444
4559
  const hp = readHash();
4445
4560
  const bootTab = hp.tab || (hp.sid ? 'history' : 'chat');
@@ -398,6 +398,13 @@ export async function listAgents(base) {
398
398
  return agents || [];
399
399
  }
400
400
 
401
+ // Server's home/STARTUP_CWD - fetched once so "use default" can show what it
402
+ // actually resolves to (a tooltip) before the user clicks it, instead of only
403
+ // after.
404
+ export async function getHome(base) {
405
+ return wsCall(base, 'home', {});
406
+ }
407
+
401
408
  export async function listActiveChats(base) {
402
409
  try { const { sessions } = await wsCall(base, 'chat.active', {}); return sessions || []; }
403
410
  catch { return []; }