agentgui 1.0.1116 → 1.0.1118

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.
Files changed (165) hide show
  1. package/.gm/.embed-generation.code_chunks +1 -0
  2. package/.gm/.embed-generation.git_commit_vectors +1 -0
  3. package/.gm/.embed-generation.memories +1 -0
  4. package/.gm/.embed-generation.rssearch_vectors +1 -0
  5. package/.gm/prd.yml +15 -3342
  6. package/AGENTS.md +1 -1
  7. package/lib/acp-sdk-manager.js +15 -2
  8. package/lib/claude-runner-agents.js +8 -1
  9. package/lib/http-handler.js +35 -566
  10. package/lib/http-routes/mutations.js +199 -0
  11. package/lib/http-routes/reads.js +212 -0
  12. package/lib/http-routes/shared.js +213 -0
  13. package/lib/ws-handlers/agents.js +137 -0
  14. package/lib/ws-handlers/chat.js +157 -0
  15. package/lib/ws-handlers/git.js +159 -0
  16. package/lib/ws-handlers/misc.js +36 -0
  17. package/lib/ws-handlers/shared.js +47 -0
  18. package/lib/ws-handlers/terminal-state.js +16 -0
  19. package/lib/ws-handlers-util.js +8 -463
  20. package/package.json +1 -1
  21. package/site/app/js/app.js +126 -104
  22. package/site/app/js/backend.js +19 -0
  23. package/site/app/js/chat-persistence.js +123 -0
  24. package/site/app/vendor/anentrypoint-design/247420.css +1614 -488
  25. package/site/app/vendor/anentrypoint-design/247420.js +363 -77
  26. package/.gm/browser-config.json +0 -1
  27. package/.gm/claim-audit-fired +0 -1
  28. package/.gm/disciplines/agentgui/mem-1779277005143-0-1352.json +0 -1
  29. package/.gm/disciplines/agentgui/memories/.flat-export-done +0 -0
  30. package/.gm/disciplines/agentgui/memories/mem-c07fa3b0836edbe2-1352.md +0 -8
  31. package/.gm/gm.db +0 -0
  32. package/.gm/memories/.flat-export-done +0 -1
  33. package/.gm/memories/mem-0131ff4869d86085-370.md +0 -10
  34. package/.gm/memories/mem-04049d11cb8c1661-2135.md +0 -8
  35. package/.gm/memories/mem-069ff10372ed1770-1375.md +0 -8
  36. package/.gm/memories/mem-08a4a9e050a4deff-3048.md +0 -8
  37. package/.gm/memories/mem-09597ee0df9c9224-965.md +0 -8
  38. package/.gm/memories/mem-0993d156f571b619-1059.md +0 -8
  39. package/.gm/memories/mem-0b681bf2340c8df1-3170.md +0 -8
  40. package/.gm/memories/mem-0c444abde86202f6-1681.md +0 -8
  41. package/.gm/memories/mem-0d1408fa048b9923-1159.md +0 -8
  42. package/.gm/memories/mem-0dad5d82fec829ba-887.md +0 -8
  43. package/.gm/memories/mem-10a776effb7ce3a6-935.md +0 -8
  44. package/.gm/memories/mem-10d264b4f8e66fb4-1492.md +0 -8
  45. package/.gm/memories/mem-118c35ac67541d98-2909.md +0 -8
  46. package/.gm/memories/mem-119cf70dd19aa0a5-2225.md +0 -8
  47. package/.gm/memories/mem-15a07cd761b337c4-4320.md +0 -8
  48. package/.gm/memories/mem-15c73aca528535cb-2183.md +0 -8
  49. package/.gm/memories/mem-166f52796185e0e7-1487.md +0 -8
  50. package/.gm/memories/mem-1a148cf9c3126df5-3963.md +0 -8
  51. package/.gm/memories/mem-1a459001058f618e-567.md +0 -8
  52. package/.gm/memories/mem-1af3e13f7618e668-2146.md +0 -8
  53. package/.gm/memories/mem-1fd31d6873e9c164-794.md +0 -8
  54. package/.gm/memories/mem-21f551be092fd77d-2340.md +0 -8
  55. package/.gm/memories/mem-231ff5de003f20f0-586.md +0 -8
  56. package/.gm/memories/mem-2567393d3adb03ab-347.md +0 -8
  57. package/.gm/memories/mem-27faf41cdd050c80-1261.md +0 -8
  58. package/.gm/memories/mem-28889e43e8ce64cc-3663.md +0 -8
  59. package/.gm/memories/mem-2f4fd06515abf439-2562.md +0 -8
  60. package/.gm/memories/mem-34a749430303c5f7-544.md +0 -8
  61. package/.gm/memories/mem-3602d11d7bce9446-1057.md +0 -8
  62. package/.gm/memories/mem-38b7e24dbcba2f3e-639.md +0 -8
  63. package/.gm/memories/mem-39a7cc2f887a90b1-1079.md +0 -8
  64. package/.gm/memories/mem-40d6169c8759061d-806.md +0 -8
  65. package/.gm/memories/mem-4420e6d00e5c8632-2318.md +0 -8
  66. package/.gm/memories/mem-447e5a54ddc9357a-654.md +0 -8
  67. package/.gm/memories/mem-455b3beb4afabe14-591.md +0 -8
  68. package/.gm/memories/mem-4845ee594759151c-560.md +0 -8
  69. package/.gm/memories/mem-4a1254c60b868b88-374.md +0 -8
  70. package/.gm/memories/mem-4bafafa47a0e4b6c-3237.md +0 -8
  71. package/.gm/memories/mem-4c06c45b4d31bc60-1202.md +0 -8
  72. package/.gm/memories/mem-4dfac801d0378448-3676.md +0 -8
  73. package/.gm/memories/mem-4ecd3b713e384170-122.md +0 -10
  74. package/.gm/memories/mem-51d8992a3c6f6c9c-1073.md +0 -8
  75. package/.gm/memories/mem-539eee7e8c6c10ee-2557.md +0 -8
  76. package/.gm/memories/mem-559b8b2abc4b1091-1665.md +0 -8
  77. package/.gm/memories/mem-59dd73a463e54948-871.md +0 -8
  78. package/.gm/memories/mem-5b99b882ffe1a3c2-2262.md +0 -8
  79. package/.gm/memories/mem-5feb252a5244149c-1791.md +0 -8
  80. package/.gm/memories/mem-6224f8d185797f2b-1047.md +0 -8
  81. package/.gm/memories/mem-6434ee8261a6a6a2-3126.md +0 -8
  82. package/.gm/memories/mem-66b0947b946f3437-1155.md +0 -8
  83. package/.gm/memories/mem-6bdf783a1f6be7c2-1062.md +0 -8
  84. package/.gm/memories/mem-6d49f08bebac54c9-744.md +0 -8
  85. package/.gm/memories/mem-70d96acb736af2d7-685.md +0 -8
  86. package/.gm/memories/mem-70de7d5e71fcd439-2680.md +0 -8
  87. package/.gm/memories/mem-713614f12d7029da-1577.md +0 -8
  88. package/.gm/memories/mem-720d76c88e64f6ee-1967.md +0 -8
  89. package/.gm/memories/mem-7535f34f430fc196-1340.md +0 -8
  90. package/.gm/memories/mem-861a588d2773681a-1311.md +0 -8
  91. package/.gm/memories/mem-8af7980a92e09959-891.md +0 -8
  92. package/.gm/memories/mem-8b438ef4c609b7af-401.md +0 -8
  93. package/.gm/memories/mem-8f59884039787ef3-743.md +0 -8
  94. package/.gm/memories/mem-92b199c2024ee1aa-2513.md +0 -8
  95. package/.gm/memories/mem-9329105b05b9c0f6-324.md +0 -10
  96. package/.gm/memories/mem-94f30cec3c283f97-782.md +0 -10
  97. package/.gm/memories/mem-956d4813319f65a3-1710.md +0 -8
  98. package/.gm/memories/mem-96fffbb6615e031b-1263.md +0 -8
  99. package/.gm/memories/mem-993ab31f7a10aefa-718.md +0 -8
  100. package/.gm/memories/mem-a3e440db285f655a-995.md +0 -8
  101. package/.gm/memories/mem-a52933cd6495e6f5-2624.md +0 -8
  102. package/.gm/memories/mem-a86426ac44138c96-1606.md +0 -8
  103. package/.gm/memories/mem-a95b1d49d71133ce-1007.md +0 -8
  104. package/.gm/memories/mem-a9968a186368b022-1150.md +0 -8
  105. package/.gm/memories/mem-aaeae38d8eb01809-1076.md +0 -8
  106. package/.gm/memories/mem-ade01d9c02677c55-1557.md +0 -8
  107. package/.gm/memories/mem-b06b8c85b81663e6-1250.md +0 -8
  108. package/.gm/memories/mem-b451e5e17e555202-1490.md +0 -8
  109. package/.gm/memories/mem-b64ad00dcb6f909e-937.md +0 -8
  110. package/.gm/memories/mem-b7d6018ceca55b6f-2220.md +0 -8
  111. package/.gm/memories/mem-bdf466854032cac9-1509.md +0 -8
  112. package/.gm/memories/mem-c0482039e6ab4c50-2047.md +0 -8
  113. package/.gm/memories/mem-c4aaec5c4e6f80c2-920.md +0 -8
  114. package/.gm/memories/mem-c66976ddacb4fbe8-1454.md +0 -8
  115. package/.gm/memories/mem-c7c5bc263850c332-1844.md +0 -8
  116. package/.gm/memories/mem-cda639547932c796-713.md +0 -8
  117. package/.gm/memories/mem-cdf04ff6a6a0aa6f-671.md +0 -8
  118. package/.gm/memories/mem-cf8472c84d9e04c4-1883.md +0 -8
  119. package/.gm/memories/mem-cfd4d894370c63c3-1288.md +0 -8
  120. package/.gm/memories/mem-d060480435f92eec-1146.md +0 -8
  121. package/.gm/memories/mem-d347c921b79e6d44-1099.md +0 -8
  122. package/.gm/memories/mem-d3c8ea6c861bfbb8-1457.md +0 -8
  123. package/.gm/memories/mem-d5e928c552cd87f9-214.md +0 -10
  124. package/.gm/memories/mem-d63bc3b8a0269844-406.md +0 -10
  125. package/.gm/memories/mem-d9c19257abc2fa5e-1061.md +0 -8
  126. package/.gm/memories/mem-d9f26358a7cd3bb8-866.md +0 -8
  127. package/.gm/memories/mem-da1c91acf63d81d3-863.md +0 -8
  128. package/.gm/memories/mem-dc1ac3b04d22317e-664.md +0 -8
  129. package/.gm/memories/mem-dceeae20bc56d407-711.md +0 -8
  130. package/.gm/memories/mem-dd98b21c0896dc04-1557.md +0 -8
  131. package/.gm/memories/mem-df74674eec7745ac-178.md +0 -10
  132. package/.gm/memories/mem-dfe3b466fd5d1199-802.md +0 -8
  133. package/.gm/memories/mem-e0f9286aed2d5a06-1116.md +0 -8
  134. package/.gm/memories/mem-e1950b064099b7be-1895.md +0 -8
  135. package/.gm/memories/mem-e1d5431ae2542872-961.md +0 -8
  136. package/.gm/memories/mem-e490a181de6469fc-487.md +0 -8
  137. package/.gm/memories/mem-e72c6efbb27326fb-392.md +0 -8
  138. package/.gm/memories/mem-e943b58115a02b4c-593.md +0 -8
  139. package/.gm/memories/mem-e9bc5f9ed6edbcf7-1580.md +0 -8
  140. package/.gm/memories/mem-eae1e383927854fb-2649.md +0 -8
  141. package/.gm/memories/mem-ebbe6c0694c526f0-1392.md +0 -8
  142. package/.gm/memories/mem-ebf85becc23410b0-1633.md +0 -8
  143. package/.gm/memories/mem-ee0596fa17c7102e-362.md +0 -10
  144. package/.gm/memories/mem-ee4450fbc311b583-1361.md +0 -8
  145. package/.gm/memories/mem-ee8858162518f144-1252.md +0 -8
  146. package/.gm/memories/mem-f3297e82df622649-1166.md +0 -8
  147. package/.gm/memories/mem-f3f317c083d979ee-1210.md +0 -8
  148. package/.gm/memories/mem-f714a86465fba158-2041.md +0 -8
  149. package/.gm/memories/mem-f786bc1626d92845-204.md +0 -10
  150. package/.gm/memories/mem-f9f0dc8895b21e18-1217.md +0 -8
  151. package/.gm/memories/mem-fb3b2a7395800041-525.md +0 -8
  152. package/.gm/memories/mem-fc5753f92ee7d654-966.md +0 -8
  153. package/.gm/memories/mem-fe3faa1652f07239-480.md +0 -8
  154. package/.gm/memories/mem-ff8669e527e5c427-2788.md +0 -8
  155. package/.gm/mutables.yml +0 -53
  156. package/UX_OPTIMIZATION_SUMMARY.md +0 -238
  157. package/agentgui-after.png +0 -0
  158. package/agentgui-current.png +0 -0
  159. package/agentgui-final.png +0 -0
  160. package/agentgui-nobrand.png +0 -0
  161. package/agentgui-now.png +0 -0
  162. package/agentgui-v2.png +0 -0
  163. package/agentgui-v4.png +0 -0
  164. package/bash.exe.stackdump +0 -28
  165. package/design-reference.png +0 -0
@@ -1,9 +1,10 @@
1
1
  import { h, mount, installStyles, components as C } from 'anentrypoint-design';
2
2
  import * as B from './backend.js';
3
+ import { createChatPersistence } from './chat-persistence.js';
3
4
 
4
5
  installStyles().catch(() => {});
5
6
 
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
+ 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
8
 
8
9
  // One duration/bytes vocabulary across every surface: prefer the kit's shared
9
10
  // formatters (exported alongside the components), fall back to the local
@@ -52,6 +53,13 @@ const state = {
52
53
  // One-time welcome banner naming what each tab is for. Shown until
53
54
  // dismissed once, ever - a returning user has already learned the tabs.
54
55
  showOnboarding: lsGet('agentgui.onboarded') !== '1',
56
+ // Models tab: composed agent/provider availability (models.availability WS
57
+ // handler), fed into the design SDK's ModelsConfig component.
58
+ models: { data: null, loading: false, error: null, selectedProviderId: null },
59
+ // Plugins tab: agentgui has no freddie-style plugin host - the closest real
60
+ // extensibility surface is the discovered agent-CLI registry (agents.list),
61
+ // adapted to PluginsConfig's {name,surfaces,requires,enabled,status} shape.
62
+ plugins: { selected: null },
55
63
  };
56
64
 
57
65
  // Two-step arm controls auto-reset after this delay so an accidental first click
@@ -331,6 +339,24 @@ function navTo(tab, { writeHash: doWriteHash = true, push = true } = {}) {
331
339
  // filesMain (which runs during render) - a render-time fetch is fragile under
332
340
  // a double-render and re-enters render() while building the tree.
333
341
  if (tab === 'files' && !state.files.path && !state.files.loading && !state.files.error) loadDir('');
342
+ // Chat's @-mention autocomplete (mentionFiles) reuses state.files.entries -
343
+ // seed it from the root listing on first chat visit so mentions work
344
+ // without requiring a prior Files-tab visit. Uses the same confined
345
+ // B.listDir the Files tab itself calls; failure is silent (mentionFiles
346
+ // just stays empty, same as never having visited Files).
347
+ if (tab === 'chat' && !state.files.entries.length && !state.files.loading && !state.files.error) {
348
+ B.listDir(state.backend, '').then((j) => {
349
+ if (!state.files.entries.length) state.files.entries = j.entries || [];
350
+ if (!state.files.roots.length) state.files.roots = j.roots || [];
351
+ render();
352
+ }).catch(() => {});
353
+ }
354
+ // Models tab: composed agent/provider availability, fetched on first visit
355
+ // (and via the ModelsConfig 'refresh' action thereafter).
356
+ if (tab === 'models' && !state.models.data && !state.models.loading && !state.models.error) loadModelsAvailability();
357
+ // Plugins tab reuses state.agents (agents.list) - same lazy load as chat's
358
+ // agent picker, so visiting Plugins directly (deep-link/reload) still works.
359
+ if (tab === 'plugins' && !state.agents.length && !state.agentsLoading && !state.agentsError) loadAgents();
334
360
  // popstate calls navTo with writeHash:false so it never replaceState-clobbers
335
361
  // the entry it popped; user-initiated navigation pushes so Back walks tabs.
336
362
  if (doWriteHash) writeHash({ push });
@@ -675,6 +701,10 @@ function view() {
675
701
  } else if (state.tab === 'settings') {
676
702
  // Same word as the rail item - location chrome must not fork vocabulary.
677
703
  crumbLeaf = 'settings';
704
+ } else if (state.tab === 'models') {
705
+ crumbLeaf = 'models';
706
+ } else if (state.tab === 'plugins') {
707
+ crumbLeaf = 'plugins';
678
708
  }
679
709
  const crumb = Crumb({ trail: crumbTrail, leaf: crumbLeaf, right: [dot] });
680
710
 
@@ -773,6 +803,8 @@ function workspaceRail() {
773
803
  { key: 'files', label: 'Files', icon: 'folder', active: state.tab === 'files', onClick: () => navTo('files') },
774
804
  { key: 'live', label: 'Live', icon: 'activity', active: state.tab === 'live', count: liveCount || null, rail: liveHasError ? 'flame' : undefined, onClick: () => navTo('live') },
775
805
  { key: 'git', label: 'Git', icon: 'branch', active: state.tab === 'git', onClick: () => navTo('git') },
806
+ { key: 'models', label: 'Models', icon: 'circle-dot', active: state.tab === 'models', onClick: () => navTo('models') },
807
+ { key: 'plugins', label: 'Plugins', icon: 'link', active: state.tab === 'plugins', onClick: () => navTo('plugins') },
776
808
  { key: 'settings', label: 'Settings', icon: 'settings', active: state.tab === 'settings', onClick: () => navTo('settings') },
777
809
  ];
778
810
  return WorkspaceRail({
@@ -977,6 +1009,8 @@ function mainContent() {
977
1009
  if (state.tab === 'files') return filesMain();
978
1010
  if (state.tab === 'live') return liveMain();
979
1011
  if (state.tab === 'git') return gitMain();
1012
+ if (state.tab === 'models') return modelsMain();
1013
+ if (state.tab === 'plugins') return pluginsMain();
980
1014
  return settingsMain();
981
1015
  }
982
1016
 
@@ -1080,6 +1114,61 @@ function gitMain() {
1080
1114
  ];
1081
1115
  }
1082
1116
 
1117
+ // Models tab: agentgui's real model surface composed by the models.availability
1118
+ // WS handler (agent-CLI registry availability + provider key presence), fed
1119
+ // into the design SDK's ModelsConfig component. Not freddie's probed per-mode
1120
+ // matrix - see lib/ws-handlers-util.js models.availability for the mapping.
1121
+ function modelsMain() {
1122
+ const m = state.models;
1123
+ return [
1124
+ PageHeader({ compact: true, dense: true, title: 'Models', lede: 'Discovered agent CLIs, their models, and provider key presence on this server.' }),
1125
+ ModelsConfig({
1126
+ data: m.data,
1127
+ loading: m.loading,
1128
+ error: m.error,
1129
+ selectedProviderId: m.selectedProviderId,
1130
+ onSelectProvider: (id) => { state.models.selectedProviderId = id; render(); },
1131
+ onRefresh: () => loadModelsAvailability(),
1132
+ }),
1133
+ ];
1134
+ }
1135
+
1136
+ // Plugins tab: agentgui has no freddie-style plugin host (~150 discoverable
1137
+ // plugin.js files) - its actual extensibility surface is the discovered
1138
+ // agent-CLI registry (lib/agent-discovery.js + lib/claude-runner-agents.js
1139
+ // AgentRegistry, the same data agents.list already exposes to the chat
1140
+ // picker). Adapted onto PluginsConfig's shape: name=agent id, surfaces=
1141
+ // protocol (cli|acp), requires=[] (agents have no dependency graph), enabled=
1142
+ // available (the CLI was actually found on this server), status=install hint
1143
+ // when missing. There is no enable/disable action (agentgui does not gate
1144
+ // which agent CLIs are usable) so onToggle re-checks availability instead of
1145
+ // pretending to flip a setting that doesn't exist.
1146
+ function pluginsMain() {
1147
+ const list = (state.agents || []).map((a) => ({
1148
+ name: a.id,
1149
+ version: undefined,
1150
+ surfaces: a.protocol || 'cli',
1151
+ requires: [],
1152
+ source: a.npxPackage ? ('npx ' + a.npxPackage) : undefined,
1153
+ enabled: a.available !== false,
1154
+ status: a.available !== false ? 'loaded' : (a.npxInstallable ? 'installable via npx' : 'not found'),
1155
+ }));
1156
+ return [
1157
+ PageHeader({ compact: true, dense: true, title: 'Plugins', lede: 'Discovered agent CLIs — agentgui\'s real extensibility surface (no freddie-style plugin host).' }),
1158
+ PluginsConfig({
1159
+ plugins: list,
1160
+ selected: state.plugins.selected,
1161
+ loading: !!state.agentsLoading,
1162
+ error: state.agentsError,
1163
+ onSelect: (name) => { state.plugins.selected = name; render(); },
1164
+ // No real enable/disable exists - re-run discovery so a just-installed
1165
+ // CLI's availability reflects immediately instead of a fake toggle.
1166
+ onToggle: () => { withBusy(null, () => loadAgents(), 'checking…'); },
1167
+ onReload: () => loadAgents(),
1168
+ }),
1169
+ ];
1170
+ }
1171
+
1083
1172
  // --- files (folder browser) ---
1084
1173
  async function loadDir(dirPath, { fromHash = false } = {}) {
1085
1174
  state.files = state.files || {};
@@ -2508,6 +2597,16 @@ function chatMain() {
2508
2597
  messages: state.chat.messages,
2509
2598
  busy: state.chat.busy,
2510
2599
  draft: state.chat.draft,
2600
+ // Scroll-position overview strip alongside the thread.
2601
+ showMinimap: true,
2602
+ // @-mention file autocomplete: reuse the Files tab's own data source
2603
+ // (state.files.entries, populated by loadDir/listDir) rather than a new
2604
+ // fetch. Scoped to whatever directory Files last listed - if the user
2605
+ // hasn't opened Files yet this is empty and the composer simply shows
2606
+ // no mention suggestions until they do (no fake/synthetic file list).
2607
+ mentionFiles: (state.files.entries || [])
2608
+ .filter((e) => e && e.path)
2609
+ .map((e) => ({ path: e.path, isDir: e.type === 'dir' })),
2511
2610
  // Idle never reads 'resuming…' (nothing is in flight - the continuation
2512
2611
  // fact lives in the composer context line and banner); a remotely-stopped
2513
2612
  // turn reads 'stopped', not a normal finish.
@@ -2818,109 +2917,13 @@ function cancelChat() {
2818
2917
  render();
2819
2918
  }
2820
2919
 
2821
- const CHAT_KEY = 'agentgui.chat';
2822
- // An assistant turn with no content AND no parts is an empty shell (an aborted
2823
- // turn that produced nothing). The view already renders it as nothing; drop it
2824
- // from persisted/in-memory state so a restored chat never carries blank bubbles.
2825
- function isEmptyTurn(m) {
2826
- return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
2827
- }
2828
- // Cap the persisted footprint: the last 100 messages, with any tool args/result
2829
- // payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
2830
- // full payload). A quota failure is surfaced once instead of silently dropping
2831
- // persistence forever.
2832
- const PERSIST_MSG_CAP = 100;
2833
- const PERSIST_PART_CAP = 4096;
2834
- let _persistFailedOnce = false;
2835
- let _lastPersistTs = 0;
2836
- function trimPartForStorage(p) {
2837
- if (!p || typeof p !== 'object') return p;
2838
- let out = p;
2839
- for (const k of ['result', 'args']) {
2840
- const v = out[k];
2841
- if (v == null) continue;
2842
- let s;
2843
- if (typeof v === 'string') s = v;
2844
- else { try { s = JSON.stringify(v); } catch { s = String(v); } }
2845
- if (s.length > PERSIST_PART_CAP) {
2846
- if (out === p) out = { ...p };
2847
- out[k] = s.slice(0, PERSIST_PART_CAP);
2848
- out.truncatedForStorage = true;
2849
- }
2850
- }
2851
- return out;
2852
- }
2853
- function persistChat() {
2854
- try {
2855
- const eligible = state.chat.messages.filter(m => !isEmptyTurn(m));
2856
- // On-screen transcript can exceed the cap; a reload only restores the last
2857
- // PERSIST_MSG_CAP turns - surface that once so it's not a silent surprise,
2858
- // same Alert pattern as the sibling quota-failure banner below.
2859
- const wasCapped = eligible.length > PERSIST_MSG_CAP;
2860
- if (wasCapped !== !!state.chat.persistTruncated) {
2861
- state.chat.persistTruncated = wasCapped;
2862
- scheduleRender();
2863
- }
2864
- const msgs = eligible
2865
- .slice(-PERSIST_MSG_CAP)
2866
- .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
2867
- const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
2868
- // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
2869
- // sent message must survive a reload too).
2870
- if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
2871
- _lastPersistTs = Date.now();
2872
- localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
2873
- } catch {
2874
- if (!_persistFailedOnce) {
2875
- _persistFailedOnce = true;
2876
- state.chat.persistError = true;
2877
- announce('chat too large to save locally - export it from settings');
2878
- scheduleRender();
2879
- }
2880
- }
2881
- }
2882
- const debouncedPersistDraft = debounce(persistChat, 500);
2883
- // Another GUI tab rewrote the shared chat key: never silently diverge - surface
2884
- // a banner offering to reload the newer copy (last-writer-wins with a ts guard).
2885
- window.addEventListener('storage', (e) => {
2886
- if (e.key !== CHAT_KEY || !e.newValue) return;
2887
- try {
2888
- const remote = JSON.parse(e.newValue);
2889
- if (remote && remote.ts && remote.ts > _lastPersistTs) {
2890
- state.chat.externalUpdate = true;
2891
- scheduleRender();
2892
- }
2893
- } catch {}
2894
- });
2895
- function restoreChat() {
2896
- try {
2897
- const raw = lsGet(CHAT_KEY);
2898
- if (!raw) return;
2899
- const saved = JSON.parse(raw);
2900
- if (typeof saved.draft === 'string' && saved.draft.trim()) {
2901
- state.chat.draft = saved.draft;
2902
- // A restored draft is set programmatically, so Ctrl+Z has nothing in the
2903
- // browser's native undo history to act on - a one-time note explains why
2904
- // undo appears to do nothing right after a reload.
2905
- announce('draft restored - note: undo history does not carry over a reload');
2906
- }
2907
- if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
2908
- if (Array.isArray(saved.messages) && saved.messages.length) {
2909
- state.chat.messages = saved.messages
2910
- .filter(m => !isEmptyTurn(m))
2911
- .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
2912
- // Prefer the per-message cost sum when present (it self-corrects after
2913
- // edit/retry truncation); the scalar is only the legacy fallback.
2914
- const derived = computeTotalCost();
2915
- if (derived) state.chat.totalCost = derived;
2916
- state.chat.resumeSid = saved.resumeSid || null;
2917
- // Restore the agent/model the transcript belongs to, so a restored chat
2918
- // isn't silently shown under whatever agent the picker defaulted to.
2919
- state.chat.restoredAgent = saved.agent || null;
2920
- state.chat.restoredModel = saved.model || null;
2921
- }
2922
- } catch {}
2923
- }
2920
+ // Chat transcript localStorage persistence - extracted to chat-persistence.js
2921
+ // (vertical slice per AGENTS.md SOLID/Clean-Architecture preferences). The
2922
+ // factory closes over the SAME live `state` object every other app.js concern
2923
+ // mutates by reference, so every call site below (persistChat/restoreChat/
2924
+ // debouncedPersistDraft/isEmptyTurn) keeps working unchanged.
2925
+ const { persistChat, restoreChat, debouncedPersistDraft, isEmptyTurn } =
2926
+ createChatPersistence(state, { announce, scheduleRender, lsGet, lsRemove, debounce });
2924
2927
 
2925
2928
  // Flatten a message's content (string content or md/text/tool parts) to plain
2926
2929
  // text for the copy action.
@@ -4351,6 +4354,25 @@ async function loadAgents() {
4351
4354
  }
4352
4355
  }
4353
4356
 
4357
+ // Models tab data load: models.availability WS handler composes agent
4358
+ // registry + provider key presence into ModelsConfig's expected shape. See
4359
+ // lib/ws-handlers-util.js for exactly what each field means in agentgui (no
4360
+ // freddie-style per-mode probe matrix - one real 'cli' mode per model).
4361
+ async function loadModelsAvailability() {
4362
+ state.models.loading = true;
4363
+ state.models.error = null;
4364
+ render();
4365
+ try {
4366
+ state.models.data = await B.getModelsAvailability(state.backend);
4367
+ state.models.loading = false;
4368
+ render();
4369
+ } catch (e) {
4370
+ state.models.loading = false;
4371
+ state.models.error = errText(e) || 'failed to load model availability';
4372
+ render();
4373
+ }
4374
+ }
4375
+
4354
4376
  // Boot-time automatic retries with backoff when the first agents fetch fails
4355
4377
  // (the server may still be warming up).
4356
4378
  async function retryLoadAgents() {
@@ -435,6 +435,25 @@ export async function listAgentModels(base, agentId) {
435
435
  } catch { return []; }
436
436
  }
437
437
 
438
+ // Composed model-availability view for the Models tab (ModelsConfig
439
+ // component): per-agent CLI availability + models, in the shape ModelsConfig
440
+ // expects ({timestamp, providers, sampler, summary}). See lib/ws-handlers-
441
+ // util.js models.availability for what each field really means in agentgui
442
+ // (no probed per-mode matrix like freddie - one real 'cli' mode per model).
443
+ export async function getModelsAvailability(base) {
444
+ return wsCall(base, 'models.availability', {});
445
+ }
446
+
447
+ // Provider API-key configs (masked) for the Models tab's provider auth info -
448
+ // backs the existing Settings 'keys' surface, reused here as-is.
449
+ export async function getAuthConfigs(base) {
450
+ return wsCall(base, 'auth.configs', {});
451
+ }
452
+
453
+ export async function saveAuthConfig(base, providerId, apiKey, defaultModel) {
454
+ return wsCall(base, 'auth.save', { providerId, apiKey, defaultModel });
455
+ }
456
+
438
457
  // ---------- Git / worktrees (WS) ----------
439
458
 
440
459
  export async function gitStatus(base, { cwd } = {}) {
@@ -0,0 +1,123 @@
1
+ const CHAT_KEY = 'agentgui.chat';
2
+ // Cap the persisted footprint: the last 100 messages, with any tool args/result
3
+ // payload over 4KB truncated in the SAVED copy only (in-memory state keeps the
4
+ // full payload). A quota failure is surfaced once instead of silently dropping
5
+ // persistence forever.
6
+ const PERSIST_MSG_CAP = 100;
7
+ const PERSIST_PART_CAP = 4096;
8
+
9
+ // An assistant turn with no content AND no parts is an empty shell (an aborted
10
+ // turn that produced nothing). The view already renders it as nothing; drop it
11
+ // from persisted/in-memory state so a restored chat never carries blank bubbles.
12
+ function isEmptyTurn(m) {
13
+ return m.role === 'assistant' && !m.content && !(Array.isArray(m.parts) && m.parts.length);
14
+ }
15
+
16
+ function trimPartForStorage(p) {
17
+ if (!p || typeof p !== 'object') return p;
18
+ let out = p;
19
+ for (const k of ['result', 'args']) {
20
+ const v = out[k];
21
+ if (v == null) continue;
22
+ let s;
23
+ if (typeof v === 'string') s = v;
24
+ else { try { s = JSON.stringify(v); } catch { s = String(v); } }
25
+ if (s.length > PERSIST_PART_CAP) {
26
+ if (out === p) out = { ...p };
27
+ out[k] = s.slice(0, PERSIST_PART_CAP);
28
+ out.truncatedForStorage = true;
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function computeTotalCost(state) {
35
+ return (state.chat.messages || []).reduce((s, m) => s + (typeof m.costUsd === 'number' ? m.costUsd : 0), 0);
36
+ }
37
+
38
+ // Factory: app.js calls this once at module load, passing the live `state`
39
+ // object (mutated by reference, same as every other app.js concern) and the
40
+ // shared helpers (announce/scheduleRender/lsGet/lsSet/lsRemove/debounce)
41
+ // already defined there. Returns the same function set app.js previously
42
+ // defined inline, wired to app.js's own state/render/announce.
43
+ export function createChatPersistence(state, { announce, scheduleRender, lsGet, lsRemove, debounce }) {
44
+ let _persistFailedOnce = false;
45
+ let _lastPersistTs = 0;
46
+
47
+ function persistChat() {
48
+ try {
49
+ const eligible = state.chat.messages.filter(m => !isEmptyTurn(m));
50
+ // On-screen transcript can exceed the cap; a reload only restores the last
51
+ // PERSIST_MSG_CAP turns - surface that once so it's not a silent surprise,
52
+ // same Alert pattern as the sibling quota-failure banner below.
53
+ const wasCapped = eligible.length > PERSIST_MSG_CAP;
54
+ if (wasCapped !== !!state.chat.persistTruncated) {
55
+ state.chat.persistTruncated = wasCapped;
56
+ scheduleRender();
57
+ }
58
+ const msgs = eligible
59
+ .slice(-PERSIST_MSG_CAP)
60
+ .map(m => ({ id: m.id, role: m.role, content: m.content, time: m.time, costUsd: m.costUsd, stopped: m.stopped || undefined, parts: Array.isArray(m.parts) ? m.parts.map(trimPartForStorage) : m.parts }));
61
+ const draft = (state.chat.draft || '').trim() ? state.chat.draft : '';
62
+ // Persist when there is a transcript OR a non-empty draft (a typed-but-not-
63
+ // sent message must survive a reload too).
64
+ if (!msgs.length && !draft) { lsRemove(CHAT_KEY); return; }
65
+ _lastPersistTs = Date.now();
66
+ localStorage.setItem(CHAT_KEY, JSON.stringify({ ts: _lastPersistTs, messages: msgs, draft, resumeSid: state.chat.resumeSid, totalCost: state.chat.totalCost || 0, agent: state.selectedAgent, model: state.selectedModel }));
67
+ } catch {
68
+ if (!_persistFailedOnce) {
69
+ _persistFailedOnce = true;
70
+ state.chat.persistError = true;
71
+ announce('chat too large to save locally - export it from settings');
72
+ scheduleRender();
73
+ }
74
+ }
75
+ }
76
+
77
+ const debouncedPersistDraft = debounce(persistChat, 500);
78
+
79
+ // Another GUI tab rewrote the shared chat key: never silently diverge - surface
80
+ // a banner offering to reload the newer copy (last-writer-wins with a ts guard).
81
+ window.addEventListener('storage', (e) => {
82
+ if (e.key !== CHAT_KEY || !e.newValue) return;
83
+ try {
84
+ const remote = JSON.parse(e.newValue);
85
+ if (remote && remote.ts && remote.ts > _lastPersistTs) {
86
+ state.chat.externalUpdate = true;
87
+ scheduleRender();
88
+ }
89
+ } catch {}
90
+ });
91
+
92
+ function restoreChat() {
93
+ try {
94
+ const raw = lsGet(CHAT_KEY);
95
+ if (!raw) return;
96
+ const saved = JSON.parse(raw);
97
+ if (typeof saved.draft === 'string' && saved.draft.trim()) {
98
+ state.chat.draft = saved.draft;
99
+ // A restored draft is set programmatically, so Ctrl+Z has nothing in the
100
+ // browser's native undo history to act on - a one-time note explains why
101
+ // undo appears to do nothing right after a reload.
102
+ announce('draft restored - note: undo history does not carry over a reload');
103
+ }
104
+ if (typeof saved.totalCost === 'number') state.chat.totalCost = saved.totalCost;
105
+ if (Array.isArray(saved.messages) && saved.messages.length) {
106
+ state.chat.messages = saved.messages
107
+ .filter(m => !isEmptyTurn(m))
108
+ .map(m => ({ ...m, parts: Array.isArray(m.parts) ? m.parts : [] }));
109
+ // Prefer the per-message cost sum when present (it self-corrects after
110
+ // edit/retry truncation); the scalar is only the legacy fallback.
111
+ const derived = computeTotalCost(state);
112
+ if (derived) state.chat.totalCost = derived;
113
+ state.chat.resumeSid = saved.resumeSid || null;
114
+ // Restore the agent/model the transcript belongs to, so a restored chat
115
+ // isn't silently shown under whatever agent the picker defaulted to.
116
+ state.chat.restoredAgent = saved.agent || null;
117
+ state.chat.restoredModel = saved.model || null;
118
+ }
119
+ } catch {}
120
+ }
121
+
122
+ return { persistChat, restoreChat, debouncedPersistDraft, isEmptyTurn, computeTotalCost: () => computeTotalCost(state) };
123
+ }