agentgui 1.0.1114 → 1.0.1116

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.
@@ -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 } = 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,13 +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 },
47
- // Models tab: composed agent/provider availability (models.availability WS
48
- // handler), fed into the design SDK's ModelsConfig component.
49
- models: { data: null, loading: false, error: null, selectedProviderId: null },
50
- // Plugins tab: agentgui has no freddie-style plugin host - the closest real
51
- // extensibility surface is the discovered agent-CLI registry (agents.list),
52
- // adapted to PluginsConfig's {name,surfaces,requires,enabled,status} shape.
53
- plugins: { selected: null },
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',
54
55
  };
55
56
 
56
57
  // Two-step arm controls auto-reset after this delay so an accidental first click
@@ -177,6 +178,12 @@ function lsGet(k) { try { return localStorage.getItem(k); } catch { return null;
177
178
  function lsSet(k, v) { try { localStorage.setItem(k, v); } catch {} }
178
179
  function lsRemove(k) { try { localStorage.removeItem(k); } catch {} }
179
180
 
181
+ function dismissOnboarding() {
182
+ state.showOnboarding = false;
183
+ lsSet('agentgui.onboarded', '1');
184
+ render();
185
+ }
186
+
180
187
  // A single visually-hidden aria-live region for transient announcements (tab
181
188
  // changes, etc.) so screen-reader users hear context that's otherwise conveyed
182
189
  // only by focus movement or color.
@@ -324,24 +331,6 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
324
331
  // filesMain (which runs during render) - a render-time fetch is fragile under
325
332
  // a double-render and re-enters render() while building the tree.
326
333
  if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
327
- // Chat's @-mention autocomplete (mentionFiles) reuses state.files.entries -
328
- // seed it from the root listing on first chat visit so mentions work
329
- // without requiring a prior Files-tab visit. Uses the same confined
330
- // B.listDir the Files tab itself calls; failure is silent (mentionFiles
331
- // just stays empty, same as never having visited Files).
332
- if (tab === 'chat' && !state.files.entries.length && !state.files.loading && !state.files.error) {
333
- B.listDir(state.backend, '').then((j) => {
334
- if (!state.files.entries.length) state.files.entries = j.entries || [];
335
- if (!state.files.roots.length) state.files.roots = j.roots || [];
336
- render();
337
- }).catch(() => {});
338
- }
339
- // Models tab: composed agent/provider availability, fetched on first visit
340
- // (and via the ModelsConfig 'refresh' action thereafter).
341
- if (tab === 'models' && !state.models.data && !state.models.loading && !state.models.error) loadModelsAvailability();
342
- // Plugins tab reuses state.agents (agents.list) - same lazy load as chat's
343
- // agent picker, so visiting Plugins directly (deep-link/reload) still works.
344
- if (tab === 'plugins' && !state.agents.length && !state.agentsLoading && !state.agentsError) loadAgents();
345
334
  // popstate calls navTo with writeHash:false so it never replaceState-clobbers
346
335
  // the entry it popped; user-initiated navigation pushes so Back walks tabs.
347
336
  if (doWriteHash) writeHash({ push });
@@ -627,13 +616,19 @@ const SHORTCUTS = [
627
616
 
628
617
  function view() {
629
618
  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'
619
+ // history/live both read the SSE stream, not the REST health poll - a tab
620
+ // that shows its own "connecting to live stream" widget must agree with the
621
+ // header badge, since they are the same underlying connection. Showing the
622
+ // REST-derived "connected" here while the tab's own widget says otherwise
623
+ // is exactly the mismatch this guards against.
624
+ const streamTab = state.tab === 'history' || state.tab === 'live';
625
+ const liveActive = streamTab && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
626
+ const dotLabel = streamTab
632
627
  ? (state.live.error
633
628
  ? 'stream: ' + state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
634
629
  : (liveActive ? 'stream: live · ' + state.live.eventCount : (state.live.connected ? 'stream: live' : 'stream: connecting…')))
635
630
  : (ok ? (state.health.ws === 'reconnecting' ? 'connecting…' : 'connected') : 'offline');
636
- const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
631
+ const dotLive = streamTab ? (liveActive || state.live.connected) : ok;
637
632
  // The status dot is drawn entirely by CSS (.status-dot::before) - a small
638
633
  // colored disc, real product design, not a text glyph. State drives its colour
639
634
  // via the modifier class; the label carries only words so AT reads "live", and
@@ -659,7 +654,15 @@ function view() {
659
654
  ? truncate(projectLabel(sel?.title) || projectLabel(sel?.project) || state.selectedSid, 24, 48)
660
655
  : 'all sessions';
661
656
  } else if (state.tab === 'chat') {
662
- crumbLeaf = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : 'no agent';
657
+ // A resumed conversation loses the agent picker's own visual weight (it
658
+ // collapses to two small <select>s once turns exist) - the only other
659
+ // place the bound agent showed was this same tiny crumb text, easy to
660
+ // miss. Render it as a real badge on resumed threads so "which agent is
661
+ // this" reads as a persistent, visible indicator, not header trivia.
662
+ const chatAgentName = state.selectedAgent ? (agentById(state.selectedAgent)?.name || state.selectedAgent) : null;
663
+ crumbLeaf = chatAgentName
664
+ ? (state.chat.resumeSid ? Badge({ children: 'agent: ' + chatAgentName, tone: 'neutral' }) : chatAgentName)
665
+ : 'no agent';
663
666
  } else if (state.tab === 'files') {
664
667
  // ONE breadcrumb owner: the in-page BreadcrumbPath is the interactive
665
668
  // navigator, so the top crumb names only the tab (mirroring live/settings).
@@ -672,10 +675,6 @@ function view() {
672
675
  } else if (state.tab === 'settings') {
673
676
  // Same word as the rail item - location chrome must not fork vocabulary.
674
677
  crumbLeaf = 'settings';
675
- } else if (state.tab === 'models') {
676
- crumbLeaf = 'models';
677
- } else if (state.tab === 'plugins') {
678
- crumbLeaf = 'plugins';
679
678
  }
680
679
  const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
681
680
 
@@ -684,8 +683,13 @@ function view() {
684
683
  : 'no agent';
685
684
  // The default (same-origin) backend is implementation detail, not status -
686
685
  // the footer names a backend only when the user pointed at a custom one.
686
+ // On history/live, the persistent footer chip must agree with the crumb dot
687
+ // and the tab's own stream widget - reporting REST-health "connected" here
688
+ // while the Live tab's widget says "connecting to live stream" indefinitely
689
+ // is the exact contradiction users can't resolve into a real signal.
690
+ const footerConnLabel = streamTab ? (dotLive ? 'connected' : 'connecting…') : (ok ? 'connected' : 'offline');
687
691
  const status = Status({
688
- left: [state.backend || null, ok ? 'connected' : 'offline'].filter(Boolean),
692
+ left: [state.backend || null, footerConnLabel].filter(Boolean),
689
693
  right: [agentLabel, 'press ? for shortcuts'],
690
694
  });
691
695
 
@@ -698,7 +702,18 @@ function view() {
698
702
  h('div', { key: 'sc-body', class: 'ds-alert-body' }, ShortcutList({ shortcuts: SHORTCUTS })),
699
703
  ] }))
700
704
  : 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));
705
+ // One-time welcome naming what each tab is for - a brand-new user's only
706
+ // other guidance is installHint (zero-agents case) or the calm chat empty
707
+ // state (agents-but-no-conversation case); neither explains History/Files/
708
+ // Live exist at all. Dismissed once, ever, via localStorage.
709
+ const onboardingBanner = state.showOnboarding
710
+ ? Alert({
711
+ key: 'onboarding', kind: 'info', title: 'Welcome to AgentGUI',
712
+ onDismiss: dismissOnboarding,
713
+ 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.',
714
+ })
715
+ : null;
716
+ 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
717
 
703
718
  // Claude-Desktop three-column shell: a persistent left rail (workspace nav), an
704
719
  // optional sessions column (chat + history share the conversation list), the
@@ -758,8 +773,6 @@ function workspaceRail() {
758
773
  { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
759
774
  { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
760
775
  { key: 'git', label: 'Git', icon: 'branch', active: state.tab === 'git', onClick: () => navTo('git') },
761
- { key: 'models', label: 'Models', icon: 'circle-dot', active: state.tab === 'models', onClick: () => navTo('models') },
762
- { key: 'plugins', label: 'Plugins', icon: 'link', active: state.tab === 'plugins', onClick: () => navTo('plugins') },
763
776
  { key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
764
777
  ];
765
778
  return WorkspaceRail({
@@ -964,8 +977,6 @@ function mainContent() {
964
977
  if (state.tab === 'files') return filesMain();
965
978
  if (state.tab === 'live') return liveMain();
966
979
  if (state.tab === 'git') return gitMain();
967
- if (state.tab === 'models') return modelsMain();
968
- if (state.tab === 'plugins') return pluginsMain();
969
980
  return settingsMain();
970
981
  }
971
982
 
@@ -993,8 +1004,9 @@ async function loadGitPanel() {
993
1004
  async function loadGitDiff(file) {
994
1005
  state.git.diffLoading = true; state.git.diffError = null; render();
995
1006
  try {
996
- const { diff } = await B.gitDiff(state.backend, { file: file || undefined });
1007
+ const { diff, binary } = await B.gitDiff(state.backend, { file: file || undefined });
997
1008
  state.git.diff = diff || '(no changes)';
1009
+ state.git.diffBinary = !!binary;
998
1010
  state.git.file = file || '';
999
1011
  } catch (e) {
1000
1012
  state.git.diffError = e.message || 'Could not load diff.';
@@ -1054,7 +1066,7 @@ function gitMain() {
1054
1066
  GitStatusPanel({ files: g.files, active: g.file, onFileClick: (f) => loadGitDiff(f.path) }) }),
1055
1067
  Panel({ id: 'git-diff', title: g.file ? ('diff: ' + g.file) : 'diff', children:
1056
1068
  g.diffError ? h('p', { key: 'de', class: 't-meta field-error' }, g.diffError)
1057
- : GitDiffView({ diff: g.diff || '', filename: g.file }) }),
1069
+ : GitDiffView({ diff: g.diff || '', filename: g.file, binary: !!g.diffBinary }) }),
1058
1070
  Panel({ id: 'git-log', title: 'recent commits', children:
1059
1071
  !g.commits.length ? h('p', { key: 'nc', class: 't-meta' }, g.loading ? 'loading…' : 'no commits')
1060
1072
  : h('ul', { key: 'cl', class: 'git-commit-list' }, g.commits.map(c =>
@@ -1068,61 +1080,6 @@ function gitMain() {
1068
1080
  ];
1069
1081
  }
1070
1082
 
1071
- // Models tab: agentgui's real model surface composed by the models.availability
1072
- // WS handler (agent-CLI registry availability + provider key presence), fed
1073
- // into the design SDK's ModelsConfig component. Not freddie's probed per-mode
1074
- // matrix - see lib/ws-handlers-util.js models.availability for the mapping.
1075
- function modelsMain() {
1076
- const m = state.models;
1077
- return [
1078
- PageHeader({ compact: true, dense: true, title: 'Models', lede: 'Discovered agent CLIs, their models, and provider key presence on this server.' }),
1079
- ModelsConfig({
1080
- data: m.data,
1081
- loading: m.loading,
1082
- error: m.error,
1083
- selectedProviderId: m.selectedProviderId,
1084
- onSelectProvider: (id) => { state.models.selectedProviderId = id; render(); },
1085
- onRefresh: () => loadModelsAvailability(),
1086
- }),
1087
- ];
1088
- }
1089
-
1090
- // Plugins tab: agentgui has no freddie-style plugin host (~150 discoverable
1091
- // plugin.js files) - its actual extensibility surface is the discovered
1092
- // agent-CLI registry (lib/agent-discovery.js + lib/claude-runner-agents.js
1093
- // AgentRegistry, the same data agents.list already exposes to the chat
1094
- // picker). Adapted onto PluginsConfig's shape: name=agent id, surfaces=
1095
- // protocol (cli|acp), requires=[] (agents have no dependency graph), enabled=
1096
- // available (the CLI was actually found on this server), status=install hint
1097
- // when missing. There is no enable/disable action (agentgui does not gate
1098
- // which agent CLIs are usable) so onToggle re-checks availability instead of
1099
- // pretending to flip a setting that doesn't exist.
1100
- function pluginsMain() {
1101
- const list = (state.agents || []).map((a) => ({
1102
- name: a.id,
1103
- version: undefined,
1104
- surfaces: a.protocol || 'cli',
1105
- requires: [],
1106
- source: a.npxPackage ? ('npx ' + a.npxPackage) : undefined,
1107
- enabled: a.available !== false,
1108
- status: a.available !== false ? 'loaded' : (a.npxInstallable ? 'installable via npx' : 'not found'),
1109
- }));
1110
- return [
1111
- PageHeader({ compact: true, dense: true, title: 'Plugins', lede: 'Discovered agent CLIs — agentgui\'s real extensibility surface (no freddie-style plugin host).' }),
1112
- PluginsConfig({
1113
- plugins: list,
1114
- selected: state.plugins.selected,
1115
- loading: !!state.agentsLoading,
1116
- error: state.agentsError,
1117
- onSelect: (name) => { state.plugins.selected = name; render(); },
1118
- // No real enable/disable exists - re-run discovery so a just-installed
1119
- // CLI's availability reflects immediately instead of a fake toggle.
1120
- onToggle: () => { withBusy(null, () => loadAgents(), 'checking…'); },
1121
- onReload: () => loadAgents(),
1122
- }),
1123
- ];
1124
- }
1125
-
1126
1083
  // --- files (folder browser) ---
1127
1084
  async function loadDir(dirPath, { fromHash = false } = {}) {
1128
1085
  state.files = state.files || {};
@@ -1303,7 +1260,9 @@ async function runBulkDelete() {
1303
1260
  state.files.dialog = null;
1304
1261
  restoreFileDialogFocus(d._trigger);
1305
1262
  marked.clear(); state.files._lastMarkIdx = null;
1306
- announce('deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1263
+ const successMsg = 'deleted ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
1264
+ announce(successMsg);
1265
+ toast({ message: successMsg, kind: 'success' });
1307
1266
  // Patch the visible list immediately instead of waiting on a second full
1308
1267
  // directory round-trip - the server already confirmed every deletion.
1309
1268
  const gone = new Set(targets.map((e) => e.path || e.name));
@@ -1353,7 +1312,9 @@ async function runBulkMove(destDir) {
1353
1312
  state.files.dialog = null;
1354
1313
  restoreFileDialogFocus(d._trigger);
1355
1314
  marked.clear(); state.files._lastMarkIdx = null;
1356
- announce('moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries'));
1315
+ const successMsg = 'moved ' + okCount + ' ' + (okCount === 1 ? 'entry' : 'entries');
1316
+ announce(successMsg);
1317
+ toast({ message: successMsg, kind: 'success' });
1357
1318
  // Moved entries leave the current dir - patch them out immediately rather
1358
1319
  // than waiting on a second full directory round-trip.
1359
1320
  const gone = new Set(targets.map((e) => e.path || e.name));
@@ -1389,8 +1350,11 @@ async function runFileMutation(fn, doneMsg, patch) {
1389
1350
  restoreFileDialogFocus(d._trigger);
1390
1351
  announce(doneMsg);
1391
1352
  // A soft-delete's trashId (if this mutation was a delete) rides the
1392
- // return value straight to the undo-toast caller.
1353
+ // return value straight to the undo-toast caller - that banner is its own
1354
+ // dedicated real-undo-action UI, not a generic dismiss-only confirmation,
1355
+ // so it stays separate from toast() rather than being replaced by it.
1393
1356
  if (result && result.trashId) offerUndoDelete(result.trashId, doneMsg);
1357
+ else toast({ message: doneMsg, kind: 'success' });
1394
1358
  if (patch) {
1395
1359
  // Patch the visible list immediately, matching the bulk-delete/move
1396
1360
  // pattern, instead of stalling the dialog on a second full round-trip.
@@ -2544,16 +2508,6 @@ function chatMain() {
2544
2508
  messages: state.chat.messages,
2545
2509
  busy: state.chat.busy,
2546
2510
  draft: state.chat.draft,
2547
- // Scroll-position overview strip alongside the thread.
2548
- showMinimap: true,
2549
- // @-mention file autocomplete: reuse the Files tab's own data source
2550
- // (state.files.entries, populated by loadDir/listDir) rather than a new
2551
- // fetch. Scoped to whatever directory Files last listed - if the user
2552
- // hasn't opened Files yet this is empty and the composer simply shows
2553
- // no mention suggestions until they do (no fake/synthetic file list).
2554
- mentionFiles: (state.files.entries || [])
2555
- .filter((e) => e && e.path)
2556
- .map((e) => ({ path: e.path, isDir: e.type === 'dir' })),
2557
2511
  // Idle never reads 'resuming…' (nothing is in flight - the continuation
2558
2512
  // fact lives in the composer context line and banner); a remotely-stopped
2559
2513
  // turn reads 'stopped', not a normal finish.
@@ -2585,7 +2539,7 @@ function chatMain() {
2585
2539
  state.selectedModel || null,
2586
2540
  {
2587
2541
  label: state.chatCwd ? pathBasename(state.chatCwd) : 'server default',
2588
- title: 'change working directory',
2542
+ title: state.chatCwd ? 'change working directory' : ('change working directory (default: ' + (state.serverHome?.cwd || '…') + ')'),
2589
2543
  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
2544
  },
2591
2545
  userTurnCount > 0 ? plural(userTurnCount, 'turn') : null,
@@ -2625,6 +2579,7 @@ function chatMain() {
2625
2579
  onArmEdit: (m) => armEditAndResend(m),
2626
2580
  onEditMessage: (m) => armEditAndResend(m),
2627
2581
  cwd: state.chatCwd,
2582
+ defaultCwd: state.serverHome?.cwd || null,
2628
2583
  cwdEditing: !!state.cwdEditing,
2629
2584
  cwdDraft: state.cwdDraft,
2630
2585
  cwdError: state.cwdError || null,
@@ -3619,18 +3574,51 @@ function resumeInChat(sess, { fromHash = false } = {}) {
3619
3574
  B.getSessionEvents(state.backend, sidToLoad).then(evs => {
3620
3575
  // Only populate if still on the same resume (user may have switched).
3621
3576
  if (state.chat.resumeSid !== sidToLoad || state.chat.messages.length) return;
3577
+ // Replay the FULL event history (not a fixed-size slice of raw events -
3578
+ // a slice taken before filtering to human/assistant turns can leave only
3579
+ // a couple of visible messages on a long thread). The kit's own
3580
+ // shownMessages/onShowEarlier windowing (wired above) handles the
3581
+ // "load earlier" pagination the same way History's eventsLimit does.
3622
3582
  const msgs = [];
3623
- for (const e of (evs || []).slice(-50)) {
3583
+ let cur = null; // the in-progress assistant message, so interleaved
3584
+ // tool_use/tool_result/text events land on one bubble
3585
+ // instead of one bubble per event.
3586
+ let totalCost = 0; // ccsniff's stored 'result' events already carry the
3587
+ // real total_cost_usd (as e.cost) - the live path
3588
+ // sums these as they stream in, but a resumed
3589
+ // historical session never re-derived it, leaving
3590
+ // totalCost stuck at 0 regardless of turn/tool count.
3591
+ for (const e of (evs || [])) {
3624
3592
  if (e.type === 'human' || e.role === 'user') {
3593
+ cur = null;
3625
3594
  const text = e.text || e.content || '';
3626
3595
  if (text) msgs.push({ id: 'rh' + e.ts, role: 'user', content: text, time: e.ts, historical: true });
3596
+ } else if (e.type === 'tool_use') {
3597
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3598
+ cur.parts.push(toolPart({ name: e.tool, input: e.toolInput, id: e.toolUseId || e.id }));
3599
+ } else if (e.type === 'tool_result') {
3600
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3601
+ applyToolResult(cur.parts, { tool_use_id: e.toolUseId || e.id, content: e.text, is_error: e.isError });
3602
+ } else if (e.type === 'result') {
3603
+ if (typeof e.cost === 'number') totalCost += e.cost;
3627
3604
  } else if (e.type === 'assistant' || e.role === 'assistant') {
3628
3605
  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 });
3606
+ if (!text) continue;
3607
+ if (!cur) { cur = { id: 'ra' + e.ts, role: 'assistant', content: '', time: e.ts, parts: [], historical: true }; msgs.push(cur); }
3608
+ appendText(cur.parts, text);
3630
3609
  }
3631
3610
  }
3632
3611
  if (state.chat.resumeSid === sidToLoad && !state.chat.messages.length) {
3612
+ // Attach the summed cost to the last message's costUsd (rather than a
3613
+ // side-channel field) so computeTotalCost() - which every subsequent
3614
+ // send/discard recomputes from message.costUsd - naturally includes
3615
+ // it instead of silently resetting it back to 0 on the next turn.
3616
+ if (totalCost > 0) {
3617
+ const last = msgs[msgs.length - 1];
3618
+ if (last) last.costUsd = (last.costUsd || 0) + totalCost;
3619
+ }
3633
3620
  state.chat.messages = msgs;
3621
+ state.chat.totalCost = computeTotalCost();
3634
3622
  }
3635
3623
  }).catch(() => {}) // history may not be available; silent fail
3636
3624
  .finally(() => {
@@ -3888,9 +3876,9 @@ function settingsMain() {
3888
3876
  render();
3889
3877
  },
3890
3878
  }),
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,
3879
+ state.backendStatus === 'connecting' ? h('span', { key: 'bst-connecting', class: 'ds-status-chip', role: 'status' }, 'connecting…') : null,
3880
+ state.backendStatus === 'ok' ? h('span', { key: 'bst-ok', class: 'ds-status-chip ds-status-chip-ok', role: 'status' }, 'connected') : null,
3881
+ 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
3882
  (state.confirmingBackend !== undefined && state.confirmingBackend === state.backendDraft && isValid && state.backendDraft !== state.backend)
3895
3883
  ? 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
3884
  healthSummary(),
@@ -4067,6 +4055,15 @@ function agentsPanel() {
4067
4055
  const title = (state.agentsLoading && !state.agents.length)
4068
4056
  ? 'agents · loading…'
4069
4057
  : 'agents · ' + installed.length + '/' + state.agents.length + ' installed';
4058
+ // A 16-agent list with only ~4 installed is dominated by disabled-looking
4059
+ // rows; default to hiding not-installed/non-npx-installable agents so the
4060
+ // list reads as "what can I use" rather than a wall of grey entries. The
4061
+ // toggle stays visible (and the count in its label) so nothing is hidden
4062
+ // silently.
4063
+ const hiddenCount = state.agents.filter(a => a.available === false && !a.npxInstallable).length;
4064
+ const visibleAgents = state.hideUnavailableAgents
4065
+ ? state.agents.filter(a => a.available !== false || a.npxInstallable)
4066
+ : state.agents;
4070
4067
  return Panel({
4071
4068
  id: 'agents',
4072
4069
  title,
@@ -4076,14 +4073,22 @@ function agentsPanel() {
4076
4073
  // manual start/stop controls by design. This note makes that explicit so
4077
4074
  // a 'stopped' row doesn't read as a missing action.
4078
4075
  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) => {
4076
+ h('div', { key: 'agrefreshrow', class: 'agentgui-field-mb' }, [
4077
+ Btn({ key: 'agrefresh', onClick: () => loadAgents(), disabled: state.agentsLoading, children: state.agentsLoading ? 'refreshing…' : 'refresh' }),
4078
+ hiddenCount > 0 ? Checkbox({
4079
+ key: 'aghidetoggle',
4080
+ checked: !!state.hideUnavailableAgents,
4081
+ label: 'hide unavailable (' + hiddenCount + ')',
4082
+ onChange: (v) => { state.hideUnavailableAgents = v; render(); },
4083
+ }) : null,
4084
+ ].filter(Boolean)),
4085
+ ...(visibleAgents.length
4086
+ ? visibleAgents.map((a, i) => {
4082
4087
  const acp = acpStatusFor(a.id);
4083
4088
  const avail = a.available !== false;
4084
4089
  const usable = avail || a.npxInstallable; // selectable from this row
4085
4090
  const bits = [PROTOCOL_WORDS[a.protocol] || 'agent'];
4086
- if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used' : 'not installed');
4091
+ if (!avail) bits.push(a.npxInstallable ? 'installs automatically when used (via npx)' : ('not installed' + (a.cmd ? ' - install the "' + a.cmd + '" CLI to enable' : '')));
4087
4092
  if (acp) bits.push(acp.healthy ? 'connected' : (acp.running ? 'connecting' : 'offline'));
4088
4093
  if (acp && acp.restartCount >= 1) bits.push('restarted ' + acp.restartCount + (acp.restartCount === 1 ? ' time' : ' times'));
4089
4094
  if (acp && !acp.healthy && acp.providerInfo?.error) {
@@ -4100,9 +4105,13 @@ function agentsPanel() {
4100
4105
  // Rail tone keeps its GUI-wide meaning: green=ok/selected,
4101
4106
  // flame=error/unavailable. Selection is shown via `active`, not by
4102
4107
  // 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),
4108
+ // flame is a REAL error signal (Row's shared rail renders it as an
4109
+ // sr-only "error" a11y label) - "not installed" is the normal,
4110
+ // expected state for most of the 16-agent list and must not carry
4111
+ // that label. Only an ACP agent that is actually running-unhealthy
4112
+ // (started, but connection/auth failed) is a genuine error; a
4113
+ // merely-absent binary with no restart in flight is not.
4114
+ rail: (acp && !acp.healthy) ? 'flame' : (a.id === state.selectedAgent ? 'green' : undefined),
4106
4115
  active: a.id === state.selectedAgent,
4107
4116
  // Non-installable agents are genuinely inert: mark them disabled (no
4108
4117
  // click, no button role) instead of looking clickable but doing nothing.
@@ -4117,15 +4126,20 @@ function agentsPanel() {
4117
4126
  : (!avail ? [Btn({ key: 'agrecheck', onClick: (e) => { e.stopPropagation(); withBusy(e.currentTarget, () => loadAgents(), 'checking…'); }, children: 're-check' })] : undefined),
4118
4127
  });
4119
4128
  })
4120
- // The empty array means one of three things; never let an in-flight load
4121
- // read as a broken registry.
4129
+ // The empty array means one of several things; never let an in-flight
4130
+ // load read as a broken registry, and never let the hide-unavailable
4131
+ // filter read as "no agents" either.
4122
4132
  : [state.agentsLoading
4123
4133
  ? AgentListSkeleton({ rows: 5 })
4124
4134
  : (state.agentsError
4125
4135
  ? h('div', { key: 'agfail', class: 't-meta empty-state' },
4126
4136
  h('span', { key: 'agfailtxt' }, 'the agent list failed to load'),
4127
4137
  Btn({ key: 'agretry2', onClick: (e) => withBusy(e.currentTarget, () => loadAgents(), 'retrying…'), children: 'retry' }))
4128
- : h('p', { key: 'none', class: 't-meta' }, 'no agents loaded'))]),
4138
+ : (state.agents.length
4139
+ ? h('div', { key: 'agallhidden', class: 't-meta empty-state' },
4140
+ h('span', { key: 'agallhiddentxt' }, 'all agents are hidden by the unavailable filter'),
4141
+ Btn({ key: 'agshowall', onClick: () => { state.hideUnavailableAgents = false; render(); }, children: 'show all' }))
4142
+ : h('p', { key: 'none', class: 't-meta' }, 'no agents loaded')))]),
4129
4143
  ].filter(Boolean),
4130
4144
  });
4131
4145
  }
@@ -4337,25 +4351,6 @@ async function loadAgents() {
4337
4351
  }
4338
4352
  }
4339
4353
 
4340
- // Models tab data load: models.availability WS handler composes agent
4341
- // registry + provider key presence into ModelsConfig's expected shape. See
4342
- // lib/ws-handlers-util.js for exactly what each field means in agentgui (no
4343
- // freddie-style per-mode probe matrix - one real 'cli' mode per model).
4344
- async function loadModelsAvailability() {
4345
- state.models.loading = true;
4346
- state.models.error = null;
4347
- render();
4348
- try {
4349
- state.models.data = await B.getModelsAvailability(state.backend);
4350
- state.models.loading = false;
4351
- render();
4352
- } catch (e) {
4353
- state.models.loading = false;
4354
- state.models.error = errText(e) || 'failed to load model availability';
4355
- render();
4356
- }
4357
- }
4358
-
4359
4354
  // Boot-time automatic retries with backoff when the first agents fetch fails
4360
4355
  // (the server may still be warming up).
4361
4356
  async function retryLoadAgents() {
@@ -4440,6 +4435,9 @@ async function init() {
4440
4435
  render();
4441
4436
  if (!(await loadAgents())) retryLoadAgents();
4442
4437
  startBuildFreshnessPoll();
4438
+ // Fetch once: lets the cwd "use default" button show what it resolves to
4439
+ // via a tooltip, instead of only revealing it after the click.
4440
+ B.getHome(state.backend).then(h => { state.serverHome = h; render(); }).catch(() => {});
4443
4441
 
4444
4442
  const hp = readHash();
4445
4443
  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 []; }
@@ -428,25 +435,6 @@ export async function listAgentModels(base, agentId) {
428
435
  } catch { return []; }
429
436
  }
430
437
 
431
- // Composed model-availability view for the Models tab (ModelsConfig
432
- // component): per-agent CLI availability + models, in the shape ModelsConfig
433
- // expects ({timestamp, providers, sampler, summary}). See lib/ws-handlers-
434
- // util.js models.availability for what each field really means in agentgui
435
- // (no probed per-mode matrix like freddie - one real 'cli' mode per model).
436
- export async function getModelsAvailability(base) {
437
- return wsCall(base, 'models.availability', {});
438
- }
439
-
440
- // Provider API-key configs (masked) for the Models tab's provider auth info -
441
- // backs the existing Settings 'keys' surface, reused here as-is.
442
- export async function getAuthConfigs(base) {
443
- return wsCall(base, 'auth.configs', {});
444
- }
445
-
446
- export async function saveAuthConfig(base, providerId, apiKey, defaultModel) {
447
- return wsCall(base, 'auth.save', { providerId, apiKey, defaultModel });
448
- }
449
-
450
438
  // ---------- Git / worktrees (WS) ----------
451
439
 
452
440
  export async function gitStatus(base, { cwd } = {}) {