synabun 2.0.5 → 2.0.7

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 (233) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/shared.mjs +6 -3
  6. package/lib/child-path.js +303 -0
  7. package/lib/claude-hooks.js +2 -1
  8. package/lib/data-home-migration.js +2 -0
  9. package/lib/desktop-app.js +265 -0
  10. package/lib/external-tools.js +7 -3
  11. package/lib/log-redact.js +102 -0
  12. package/lib/packaged-runtime.js +138 -11
  13. package/mcp-server/dist/http.d.ts +3 -1
  14. package/mcp-server/dist/http.d.ts.map +1 -1
  15. package/mcp-server/dist/http.js +15 -4
  16. package/mcp-server/dist/http.js.map +1 -1
  17. package/mcp-server/dist/index.d.ts.map +1 -1
  18. package/mcp-server/dist/index.js +9 -6
  19. package/mcp-server/dist/index.js.map +1 -1
  20. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  21. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  22. package/mcp-server/dist/services/budget-judgments.js +153 -0
  23. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  24. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  25. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  26. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  27. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  28. package/mcp-server/dist/services/identity.d.ts +11 -0
  29. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  30. package/mcp-server/dist/services/identity.js +24 -0
  31. package/mcp-server/dist/services/identity.js.map +1 -1
  32. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  33. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  34. package/mcp-server/dist/services/neural-interface.js +34 -2
  35. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  36. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  37. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  38. package/mcp-server/dist/services/typesafe-config.js +4 -1
  39. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  40. package/mcp-server/dist/tools/agents.d.ts +10 -7
  41. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  42. package/mcp-server/dist/tools/agents.js +34 -13
  43. package/mcp-server/dist/tools/agents.js.map +1 -1
  44. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  45. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  46. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  47. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  48. package/mcp-server/dist/tui.js +2 -1
  49. package/mcp-server/dist/tui.js.map +1 -1
  50. package/mcp-server/dist/version.d.ts +5 -0
  51. package/mcp-server/dist/version.d.ts.map +1 -0
  52. package/mcp-server/dist/version.js +21 -0
  53. package/mcp-server/dist/version.js.map +1 -0
  54. package/mcp-server/package-lock.json +2 -2
  55. package/mcp-server/package.json +1 -1
  56. package/mcp-server/run.mjs +15 -9
  57. package/mcp-server/src/http.ts +18 -3
  58. package/mcp-server/src/index.ts +9 -6
  59. package/mcp-server/src/services/budget-judgments.ts +183 -0
  60. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  61. package/mcp-server/src/services/identity.ts +27 -0
  62. package/mcp-server/src/services/neural-interface.ts +34 -2
  63. package/mcp-server/src/services/typesafe-config.ts +4 -1
  64. package/mcp-server/src/tools/agents.ts +33 -14
  65. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  66. package/mcp-server/src/tui.ts +2 -1
  67. package/mcp-server/src/version.ts +22 -0
  68. package/neural-interface/i18n/en.json +627 -57
  69. package/neural-interface/i18n/pt-BR.json +626 -56
  70. package/neural-interface/lib/account-usage.js +163 -0
  71. package/neural-interface/lib/assistant-api.js +255 -52
  72. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  73. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  74. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  75. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  76. package/neural-interface/lib/assistant-budget-split.js +408 -0
  77. package/neural-interface/lib/assistant-budget.js +94 -95
  78. package/neural-interface/lib/assistant-config.js +62 -14
  79. package/neural-interface/lib/assistant-dispatch.js +432 -125
  80. package/neural-interface/lib/assistant-jev.js +26 -3
  81. package/neural-interface/lib/assistant-persona.js +68 -11
  82. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  83. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  84. package/neural-interface/lib/assistant-route-gate.js +19 -3
  85. package/neural-interface/lib/assistant-router.js +373 -42
  86. package/neural-interface/lib/assistant-runtime.js +307 -47
  87. package/neural-interface/lib/assistant-user-writer.js +4 -2
  88. package/neural-interface/lib/augmented-path.js +317 -53
  89. package/neural-interface/lib/claude-accounts.js +342 -31
  90. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  91. package/neural-interface/lib/claude-executable.js +6 -1
  92. package/neural-interface/lib/claude-model-catalog.js +3 -3
  93. package/neural-interface/lib/claude-panel-auth.js +221 -0
  94. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  95. package/neural-interface/lib/cli-install-api.js +137 -0
  96. package/neural-interface/lib/cli-install.js +797 -0
  97. package/neural-interface/lib/cli-path.js +130 -0
  98. package/neural-interface/lib/cli-resolver.js +744 -0
  99. package/neural-interface/lib/cli-update-planner.js +143 -7
  100. package/neural-interface/lib/codex-account-auth.js +178 -0
  101. package/neural-interface/lib/codex-accounts.js +489 -0
  102. package/neural-interface/lib/codex-capabilities.js +2 -1
  103. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  104. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  105. package/neural-interface/lib/codex-panel-auth.js +62 -0
  106. package/neural-interface/lib/codex-session-settings.js +1 -1
  107. package/neural-interface/lib/codex-settings-schema.js +120 -0
  108. package/neural-interface/lib/desktop/config.js +3 -0
  109. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  110. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  111. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  112. package/neural-interface/lib/git-bash/install.js +529 -0
  113. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  114. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  115. package/neural-interface/lib/git-bash/manager.js +268 -0
  116. package/neural-interface/lib/git-bash/pins.js +206 -0
  117. package/neural-interface/lib/git-bash/pins.json +34 -0
  118. package/neural-interface/lib/git-bash/resolver.js +194 -0
  119. package/neural-interface/lib/git-read.js +34 -2
  120. package/neural-interface/lib/install-details.js +145 -0
  121. package/neural-interface/lib/mcp-installer.js +14 -2
  122. package/neural-interface/lib/native-loop-providers.js +16 -3
  123. package/neural-interface/lib/native-loop-runtime.js +9 -4
  124. package/neural-interface/lib/os-path.js +319 -0
  125. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  126. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  127. package/neural-interface/lib/pty-host/core.js +2 -1
  128. package/neural-interface/lib/pty-host/manager.js +27 -2
  129. package/neural-interface/lib/remote-policy.js +20 -11
  130. package/neural-interface/lib/session-title-generator.js +4 -3
  131. package/neural-interface/lib/system-about.js +340 -0
  132. package/neural-interface/lib/system-activity.js +66 -0
  133. package/neural-interface/lib/terminal-env.js +11 -3
  134. package/neural-interface/lib/terminal-path.js +55 -0
  135. package/neural-interface/lib/virtual-socket.js +12 -1
  136. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  137. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  138. package/neural-interface/lib/whatsapp/commands.js +13 -14
  139. package/neural-interface/lib/whatsapp/config.js +134 -20
  140. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  141. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  142. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  143. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  144. package/neural-interface/lib/whatsapp/manager.js +92 -32
  145. package/neural-interface/lib/whatsapp/paths.js +11 -1
  146. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  147. package/neural-interface/lib/whatsapp/redact.js +63 -0
  148. package/neural-interface/lib/whatsapp/service.js +343 -91
  149. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  150. package/neural-interface/package-lock.json +2 -2
  151. package/neural-interface/package.json +1 -1
  152. package/neural-interface/public/index.html +2 -0
  153. package/neural-interface/public/index2d.html +2 -0
  154. package/neural-interface/public/offline.html +16 -1
  155. package/neural-interface/public/onboarding.html +96 -2
  156. package/neural-interface/public/shared/account-i18n.js +7 -0
  157. package/neural-interface/public/shared/account-menu.js +539 -0
  158. package/neural-interface/public/shared/account-usage.js +19 -0
  159. package/neural-interface/public/shared/account-view.js +335 -0
  160. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  161. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  162. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  163. package/neural-interface/public/shared/assistant/asst-brain-picker.js +124 -25
  164. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  165. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  166. package/neural-interface/public/shared/assistant/asst-menu.js +15 -3
  167. package/neural-interface/public/shared/assistant/asst-panel.js +48 -14
  168. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  169. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  170. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  171. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  172. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  173. package/neural-interface/public/shared/assistant/asst-styles.js +99 -17
  174. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  175. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  176. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  177. package/neural-interface/public/shared/cdx/cdx-panel.js +48 -107
  178. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  179. package/neural-interface/public/shared/cdx/cdx-styles.js +128 -107
  180. package/neural-interface/public/shared/cdx/cdx-tabs.js +261 -664
  181. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  182. package/neural-interface/public/shared/claude-account-view.js +3 -0
  183. package/neural-interface/public/shared/cli-install-view.js +392 -0
  184. package/neural-interface/public/shared/cli-status.js +282 -22
  185. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  186. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  187. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  188. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  189. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  190. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  191. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  192. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  193. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  194. package/neural-interface/public/shared/git-bash-view.js +145 -0
  195. package/neural-interface/public/shared/html-shell.js +1 -0
  196. package/neural-interface/public/shared/i18n.js +19 -1
  197. package/neural-interface/public/shared/install-details-view.js +86 -0
  198. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +32 -0
  199. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  200. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +118 -0
  201. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  202. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  203. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  204. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  205. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  206. package/neural-interface/public/shared/storage.js +30 -3
  207. package/neural-interface/public/shared/styles.css +278 -31
  208. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  209. package/neural-interface/public/shared/ui-claude-panel.js +212 -213
  210. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  211. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  212. package/neural-interface/public/shared/ui-judgments.js +10 -1
  213. package/neural-interface/public/shared/ui-loading.js +30 -6
  214. package/neural-interface/public/shared/ui-menubar.js +13 -3
  215. package/neural-interface/public/shared/ui-navbar.js +3 -1
  216. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  217. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  218. package/neural-interface/public/shared/ui-sessions.js +9 -3
  219. package/neural-interface/public/shared/ui-settings.js +55 -3
  220. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  221. package/neural-interface/public/shared/ui-sync.js +6 -0
  222. package/neural-interface/public/shared/ui-terminal.js +17 -0
  223. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  224. package/neural-interface/public/shared/ui-update.js +594 -5
  225. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  226. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  227. package/neural-interface/public/shared/worker-end.js +2 -0
  228. package/neural-interface/public/sw.js +1 -1
  229. package/neural-interface/public/variant/2d/main.js +10 -1
  230. package/neural-interface/public/variant/3d/main.js +10 -1
  231. package/neural-interface/server.js +1219 -608
  232. package/package.json +4 -1
  233. package/setup.js +9 -8
@@ -0,0 +1,462 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — the desktop app's updates: what the page shows
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Inside the SynaBun desktop app the updater is the app's own (packaging/desktop):
6
+ // it reads the release feed, downloads at once, and installs by itself when
7
+ // SynaBun is idle, after a one-minute notice the user can postpone. The page
8
+ // only shows what the updater reports through window.synabunDesktop.updates
9
+ // (desktop-bridge.js): the top-right badge, the Notifications drawer, the toast
10
+ // of Apps > Check for updates, the update dialog and the notice (countdown,
11
+ // "updated", a failed install) in ui-update.js. One reading of a report for all
12
+ // of them, so they never disagree.
13
+ //
14
+ // No imports and no DOM: `node --test` loads this file as it is
15
+ // (tests/desktop-update-view.test.mjs). DESKTOP_COPY is the English of every
16
+ // sentence; `desktop` in i18n/en.json must equal it and i18n/pt-BR.json must
17
+ // have the same keys with the same {values} (the test checks both). The
18
+ // offline page, which can load nothing, writes DESKTOP_COPY.offline.hint as it
19
+ // is.
20
+
21
+ export const DESKTOP_COPY = Object.freeze({
22
+ update: Object.freeze({
23
+ source: 'SynaBun app',
24
+ tooltip: 'SynaBun update available: v{current} → v{latest}',
25
+ tooltipDownloading: 'Downloading SynaBun v{latest}',
26
+ tooltipReady: 'SynaBun v{latest} is ready: it installs when SynaBun is idle',
27
+ titleAvailable: 'Update available',
28
+ titleDownloading: 'Downloading the update',
29
+ titleDownloaded: 'Update ready',
30
+ titleInstalling: 'Installing the update',
31
+ titleMove: 'Move SynaBun to Applications',
32
+ titleManual: 'Install the update yourself',
33
+ titleError: 'The update did not finish',
34
+ notesLabel: 'What\'s new',
35
+ notesEmpty: 'This release has no notes.',
36
+ snapshot: 'Before v{version} starts for the first time, SynaBun takes a verified snapshot of your data.',
37
+ backup: 'Download full backup',
38
+ backupWorking: 'Creating backup...',
39
+ backupDone: '{file} ({size} MB)',
40
+ backupFailed: 'Backup failed: {reason}',
41
+ later: 'Later',
42
+ download: 'Download update',
43
+ downloadStarting: 'Starting the download.',
44
+ progress: '{transferred} of {total} MB',
45
+ progressUnknown: '{transferred} MB downloaded',
46
+ hide: 'Hide',
47
+ hideHint: 'The download continues while this is hidden. SynaBun installs the update by itself when nothing is running.',
48
+ readyBody: 'SynaBun v{version} is downloaded. It installs by itself once nothing is running: SynaBun shows a one-minute notice, then restarts into it. It also installs when you quit SynaBun.',
49
+ waitingBody: 'Waiting for: {list}.',
50
+ countdownBody: 'SynaBun restarts into v{version} in {seconds} s.',
51
+ postponedBody: 'Postponed until {time}. It also installs when you quit SynaBun.',
52
+ nowBody: 'SynaBun restarts into v{version} as soon as nothing is running.',
53
+ installNow: 'Install now',
54
+ installingBody: 'SynaBun is stopping its server and installing v{version}. It closes and opens again by itself.',
55
+ moveBody: 'macOS is running SynaBun from a disk image or a temporary location, where it cannot update itself. Move it to the Applications folder; SynaBun opens again from there and can update.',
56
+ move: 'Move to Applications',
57
+ manualBody: 'SynaBun cannot replace itself in its current folder. Download the disk image of v{version}, open it and drag SynaBun to the Applications folder.',
58
+ manualReady: 'The disk image of v{version} is downloaded. Open it and drag SynaBun to the Applications folder to replace the old copy.',
59
+ manualFile: 'File: {path}',
60
+ manualDownload: 'Download disk image',
61
+ showFile: 'Show disk image',
62
+ errorFallback: 'SynaBun could not check for updates. Check the connection and try again.',
63
+ errorNext: 'SynaBun keeps the version it runs and tries again at its next check. You can try now, or download the new version yourself.',
64
+ manualLink: 'Download it yourself',
65
+ close: 'Close',
66
+ retry: 'Try again',
67
+ testFeed: 'test feed',
68
+ metaAvailable: 'From the SynaBun app',
69
+ metaDownloading: 'Downloading: {percent}%',
70
+ metaDownloaded: 'Ready: installs when SynaBun is idle',
71
+ metaCountdown: 'Restarting to install it',
72
+ metaPostponed: 'Postponed: installs on quit or later',
73
+ }),
74
+ // What an update waits for (the app's server names it, GET /api/system/activity).
75
+ busy: Object.freeze({
76
+ assistant: 'an Assistant run or dispatched agent',
77
+ 'claude-panel': 'a Claude Code panel reply',
78
+ 'codex-panel': 'a Codex panel reply',
79
+ 'opencode-panel': 'an OpenCode panel reply',
80
+ 'claude-cli': 'a Claude Code session in use',
81
+ loop: 'a loop',
82
+ schedule: 'a schedule',
83
+ backup: 'a backup',
84
+ maintenance: 'indexing or an install',
85
+ unknown: 'work SynaBun cannot see the end of',
86
+ test: 'the test switch',
87
+ }),
88
+ // The notice at the bottom right: the countdown before the restart, "updated", a failed install.
89
+ notice: Object.freeze({
90
+ countdownTitle: 'Updating to v{version}',
91
+ countdownLine: 'SynaBun restarts in {seconds} s.',
92
+ later: 'Later',
93
+ postponed: 'SynaBun asks again in an hour, or installs the update when you quit it.',
94
+ updatedTitle: 'SynaBun is updated',
95
+ updatedLine: 'Now v{version}, updated from v{previous}.',
96
+ updatedLineShort: 'Now v{version}.',
97
+ failedTitle: 'The update did not finish',
98
+ details: 'Details',
99
+ close: 'Close',
100
+ }),
101
+ menu: Object.freeze({
102
+ quit: 'Quit SynaBun',
103
+ }),
104
+ offline: Object.freeze({
105
+ hint: 'The SynaBun app restarts its server by itself. If it stays offline, quit SynaBun and open it again.',
106
+ stalled: 'The server has not come back. Quit SynaBun and open it again.',
107
+ failedHint: 'Quit SynaBun and open it again. If the start fails, the start screen says why.',
108
+ }),
109
+ // The window's title bar (desktop-titlebar.js): Windows and Linux open the app's menu from a button in it.
110
+ titleBar: Object.freeze({
111
+ appMenu: 'SynaBun menu',
112
+ }),
113
+ });
114
+
115
+ /** Where SynaBun is downloaded by hand, when the app names nothing better. */
116
+ export const DESKTOP_RELEASES_URL = 'https://github.com/danilokhury/Synabun/releases/latest';
117
+
118
+ /** A DESKTOP_COPY sentence (`update.download`, `offline.hint`) with its `{params}` filled in: the translator when the page gives none. */
119
+ export function fillDesktopCopy(key, params = {}) {
120
+ const value = String(key ?? '').split('.').reduce((node, part) => (node && typeof node === 'object' ? node[part] : undefined), DESKTOP_COPY);
121
+ if (typeof value !== 'string') return String(key ?? '');
122
+ return value.replace(/\{(\w+)\}/g, (all, name) => (params && params[name] != null ? String(params[name]) : all));
123
+ }
124
+
125
+ const STATUSES = new Set(['idle', 'checking', 'not-available', 'available', 'downloading', 'downloaded', 'installing', 'error']);
126
+ const INSTALL_MODES = new Set(['restart', 'move-to-applications', 'manual-download']);
127
+ const PHASES = new Set(['waiting', 'countdown', 'postponed']);
128
+ const BUSY_KINDS = Object.keys(DESKTOP_COPY.busy);
129
+ // The statuses in which a version is on offer, between the check that found it and the install.
130
+ const OFFERED = new Set(['available', 'downloading', 'downloaded', 'installing']);
131
+
132
+ const text = (value, max) => (typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null);
133
+ const amount = (value) => {
134
+ const number = Number(value);
135
+ return Number.isFinite(number) && number > 0 ? number : 0;
136
+ };
137
+ const instant = (value) => (typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value)) ? value : null);
138
+
139
+ function readProgress(value) {
140
+ if (!value || typeof value !== 'object') return null;
141
+ const total = amount(value.total);
142
+ const transferred = total > 0 ? Math.min(amount(value.transferred), total) : amount(value.transferred);
143
+ const given = value.percent === null || value.percent === undefined || value.percent === '' ? NaN : Number(value.percent);
144
+ const percent = Number.isFinite(given) ? given : total > 0 ? (transferred / total) * 100 : 0;
145
+ return { percent: Math.max(0, Math.min(100, percent)), transferred, total, bytesPerSecond: amount(value.bytesPerSecond) };
146
+ }
147
+
148
+ /** The automatic install's moment: what it waits for, when it restarts, until when it is postponed. */
149
+ function readAuto(value) {
150
+ if (!value || typeof value !== 'object' || !PHASES.has(value.phase)) return null;
151
+ const busy = (Array.isArray(value.busy) ? value.busy : [])
152
+ .filter((item) => item && typeof item === 'object')
153
+ .map((item) => ({ kind: BUSY_KINDS.includes(item.kind) ? item.kind : 'unknown', count: Math.max(1, Math.floor(Number(item.count)) || 1) }))
154
+ .slice(0, 12);
155
+ return {
156
+ phase: value.phase,
157
+ busy,
158
+ installAt: value.phase === 'countdown' ? instant(value.installAt) : null,
159
+ postponedUntil: value.phase === 'postponed' ? instant(value.postponedUntil) : null,
160
+ now: value.now === true,
161
+ };
162
+ }
163
+
164
+ function readUpdated(value) {
165
+ if (!value || typeof value !== 'object') return null;
166
+ const version = text(value.version, 64);
167
+ return version ? { version, previous: text(value.previous, 64) } : null;
168
+ }
169
+
170
+ /** Only SynaBun's own releases page: a report cannot point the page anywhere else. */
171
+ function readReleasesUrl(value) {
172
+ return typeof value === 'string' && /^https:\/\/github\.com\/danilokhury\/Synabun\/releases(?:\/[\w./-]*)?$/.test(value) ? value : DESKTOP_RELEASES_URL;
173
+ }
174
+
175
+ /**
176
+ * One report of the app's updater, checked field by field: whatever is missing
177
+ * or of the wrong kind reads as nothing (an unknown status is 'idle', an
178
+ * unknown install mode is 'restart'), so a page never draws a half answer.
179
+ */
180
+ export function readDesktopUpdateState(state) {
181
+ const s = state && typeof state === 'object' ? state : {};
182
+ return {
183
+ supported: s.supported === true,
184
+ status: STATUSES.has(s.status) ? s.status : 'idle',
185
+ current: text(s.current, 64) || '',
186
+ latest: text(s.latest, 64),
187
+ notes: text(s.notes, 20000),
188
+ releaseDate: text(s.releaseDate, 64),
189
+ progress: readProgress(s.progress),
190
+ error: text(s.error, 1000),
191
+ checkedAt: text(s.checkedAt, 64),
192
+ install: INSTALL_MODES.has(s.install) ? s.install : 'restart',
193
+ manualFile: text(s.manualFile, 4096),
194
+ testFeed: s.testFeed === true,
195
+ auto: readAuto(s.auto),
196
+ updated: readUpdated(s.updated),
197
+ releasesUrl: readReleasesUrl(s.releasesUrl),
198
+ };
199
+ }
200
+
201
+ /** True when the report offers a version: it is supported, says which one, and is between the check and the install. */
202
+ function offers(s) {
203
+ return s.supported && OFFERED.has(s.status) && !!s.latest;
204
+ }
205
+
206
+ /** Whole seconds until `at` (an ISO time), never below 0. */
207
+ export function secondsUntil(at, now = Date.now()) {
208
+ const target = Date.parse(at);
209
+ return Number.isFinite(target) ? Math.max(0, Math.ceil((target - now) / 1000)) : 0;
210
+ }
211
+
212
+ /**
213
+ * What the rest of the page reads as `_versionData` (ui-update.js), in the
214
+ * shape the npm / Git check gives it: `updateAvailable`, `current`, `latest`,
215
+ * `source`. `desktop: true` says where it came from; the report's own fields
216
+ * ride along for the dialog.
217
+ */
218
+ export function versionDataFromDesktop(state) {
219
+ const s = readDesktopUpdateState(state);
220
+ const offered = offers(s);
221
+ return {
222
+ desktop: true,
223
+ supported: s.supported,
224
+ updateAvailable: offered,
225
+ current: s.current,
226
+ latest: offered ? s.latest : null,
227
+ source: 'app',
228
+ installedChannel: 'stable',
229
+ installPlan: null,
230
+ status: s.status,
231
+ progress: s.progress,
232
+ error: s.error,
233
+ notes: s.notes,
234
+ install: s.install,
235
+ manualFile: s.manualFile,
236
+ testFeed: s.testFeed,
237
+ checkedAt: s.checkedAt,
238
+ auto: s.auto,
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Which page of the dialog a report is: 'available', 'downloading',
244
+ * 'downloaded', 'installing', 'move' (macOS runs this copy where it cannot be
245
+ * replaced: move it to Applications first), 'manual' (a folder SynaBun cannot
246
+ * write: the disk image, by hand), 'error', or 'none' (nothing to show: no
247
+ * update, a check under way, an unsupported run).
248
+ */
249
+ export function desktopUpdateStep(state) {
250
+ const s = readDesktopUpdateState(state);
251
+ if (!s.supported) return 'none';
252
+ if (s.status === 'error') return 'error';
253
+ if (!offers(s)) return 'none';
254
+ if (s.status === 'downloading') return 'downloading';
255
+ if (s.status === 'installing') return 'installing';
256
+ if (s.install === 'move-to-applications') return 'move';
257
+ if (s.install === 'manual-download') return 'manual';
258
+ return s.status === 'downloaded' ? 'downloaded' : 'available';
259
+ }
260
+
261
+ /** The bar and its words: whole percent (never 100 before it is), megabytes with one decimal. */
262
+ function progressOf(progress, say, number) {
263
+ if (!progress || (!progress.total && !progress.transferred)) return { percent: 0, text: say('downloadStarting') };
264
+ const megabytes = (bytes) => number(bytes / 1048576, 1);
265
+ if (progress.total > 0) {
266
+ return { percent: Math.floor(progress.percent), text: say('progress', { transferred: megabytes(progress.transferred), total: megabytes(progress.total) }) };
267
+ }
268
+ return { percent: null, text: say('progressUnknown', { transferred: megabytes(progress.transferred) }) };
269
+ }
270
+
271
+ /** What an update waits for, as words: "a loop, a Codex panel reply". */
272
+ export function desktopBusyList(busy = [], { t = fillDesktopCopy } = {}) {
273
+ return (Array.isArray(busy) ? busy : []).map((item) => t(`busy.${BUSY_KINDS.includes(item?.kind) ? item.kind : 'unknown'}`)).join(', ');
274
+ }
275
+
276
+ /**
277
+ * Everything the update dialog draws for one report, as data:
278
+ * step desktopUpdateStep()
279
+ * dot 1 (offered) · 2 (downloading) · 3 (ready, installing) · 0 (no dots)
280
+ * title the heading
281
+ * body sentences, in order
282
+ * notesLabel, notes the release notes (plain text: drawn as text, line breaks kept)
283
+ * progress { percent (whole, or null when the size is not known), text } while downloading
284
+ * file where the disk image was saved (manual, once it is downloaded)
285
+ * link { href, label }: SynaBun's releases page, after an update that did not finish
286
+ * backup { id: 'backup', label } next to the offer: a full backup, if the user wants one first
287
+ * actions [{ id, kind: 'ghost' | 'primary', label }] in the order they are drawn
288
+ * ids: later, download, hide, install-now, move, manual-download, show-file, close, retry
289
+ * retry what Try again does after an error: 'download' (a version was on offer) or 'check'
290
+ * busy the step is the app working (downloading, installing)
291
+ * ticking a sentence counts down: the page draws it again every second
292
+ * `t` translates `update.<key>` (and `busy.<kind>`) with its params; DESKTOP_COPY
293
+ * is the default. `now` is the time the countdown counts from; `time` writes an
294
+ * ISO time the way the page's language writes a time of day.
295
+ */
296
+ export function desktopUpdateView(state, {
297
+ t = fillDesktopCopy, number = (value, digits) => value.toFixed(digits), now = Date.now(),
298
+ time = (iso) => new Date(iso).toISOString().slice(11, 16),
299
+ } = {}) {
300
+ const s = readDesktopUpdateState(state);
301
+ const step = desktopUpdateStep(s);
302
+ const say = (key, params) => t(`update.${key}`, params);
303
+ const version = s.latest || '';
304
+ const action = (id, kind, key) => ({ id, kind, label: say(key) });
305
+ const view = {
306
+ step, dot: 0, title: '', body: [], notesLabel: '', notes: null, progress: null, file: null, link: null,
307
+ backup: null, actions: [], retry: null, busy: false, ticking: false,
308
+ current: s.current, latest: s.latest, testFeed: s.testFeed,
309
+ };
310
+ switch (step) {
311
+ case 'available':
312
+ view.dot = 1;
313
+ view.title = say('titleAvailable');
314
+ view.body = [say('snapshot', { version })];
315
+ view.notesLabel = say('notesLabel');
316
+ view.notes = s.notes || say('notesEmpty');
317
+ view.backup = { id: 'backup', label: say('backup') };
318
+ view.actions = [action('later', 'ghost', 'later'), action('download', 'primary', 'download')];
319
+ break;
320
+ case 'downloading':
321
+ view.dot = 2;
322
+ view.title = say('titleDownloading');
323
+ view.progress = progressOf(s.progress, say, number);
324
+ view.body = [say('hideHint')];
325
+ view.actions = [action('hide', 'ghost', 'hide')];
326
+ view.busy = true;
327
+ break;
328
+ case 'downloaded': {
329
+ view.dot = 3;
330
+ view.title = say('titleDownloaded');
331
+ view.body = [say('readyBody', { version })];
332
+ const auto = s.auto;
333
+ if (auto?.phase === 'countdown' && auto.installAt) {
334
+ view.body.push(say('countdownBody', { version, seconds: secondsUntil(auto.installAt, now) }));
335
+ view.ticking = true;
336
+ } else if (auto?.phase === 'postponed' && auto.postponedUntil) {
337
+ view.body.push(say('postponedBody', { time: time(auto.postponedUntil) }));
338
+ } else if (auto?.now) {
339
+ view.body.push(say('nowBody', { version }));
340
+ }
341
+ if (auto?.phase === 'waiting' && auto.busy.length) view.body.push(say('waitingBody', { list: desktopBusyList(auto.busy, { t }) }));
342
+ view.actions = [
343
+ auto?.phase === 'countdown' ? action('later', 'ghost', 'later') : action('close', 'ghost', 'close'),
344
+ action('install-now', 'primary', 'installNow'),
345
+ ];
346
+ break;
347
+ }
348
+ case 'installing':
349
+ view.dot = 3;
350
+ view.title = say('titleInstalling');
351
+ view.body = [say('installingBody', { version })];
352
+ view.busy = true;
353
+ break;
354
+ case 'move':
355
+ view.dot = 1;
356
+ view.title = say('titleMove');
357
+ view.body = [say('moveBody')];
358
+ view.actions = [action('later', 'ghost', 'later'), action('move', 'primary', 'move')];
359
+ break;
360
+ case 'manual':
361
+ view.title = say('titleManual');
362
+ if (s.status === 'downloaded') {
363
+ view.dot = 3;
364
+ view.body = [say('manualReady', { version })];
365
+ view.file = s.manualFile ? say('manualFile', { path: s.manualFile }) : null;
366
+ view.actions = [action('close', 'ghost', 'close'), action('show-file', 'primary', 'showFile')];
367
+ } else {
368
+ view.dot = 1;
369
+ view.body = [say('manualBody', { version })];
370
+ view.actions = [action('later', 'ghost', 'later'), action('manual-download', 'primary', 'manualDownload')];
371
+ }
372
+ break;
373
+ case 'error':
374
+ view.title = say('titleError');
375
+ view.body = [s.error || say('errorFallback')];
376
+ view.retry = s.latest ? 'download' : 'check';
377
+ if (s.latest) {
378
+ // A version was on offer and did not arrive or install: the app keeps the old one, and a person can fetch the new one.
379
+ view.body.push(say('errorNext'));
380
+ view.link = { href: s.releasesUrl, label: say('manualLink') };
381
+ }
382
+ view.actions = [action('close', 'ghost', 'close'), action('retry', 'primary', 'retry')];
383
+ break;
384
+ default:
385
+ break;
386
+ }
387
+ return view;
388
+ }
389
+
390
+ /** The line under the SynaBun item of Notifications > Updates, or '' when there is nothing on offer. */
391
+ export function desktopUpdateMeta(state, { t = fillDesktopCopy } = {}) {
392
+ const s = readDesktopUpdateState(state);
393
+ switch (desktopUpdateStep(s)) {
394
+ case 'downloading': return t('update.metaDownloading', { percent: Math.floor(s.progress?.percent || 0) });
395
+ case 'downloaded':
396
+ if (s.auto?.phase === 'countdown') return t('update.metaCountdown');
397
+ if (s.auto?.phase === 'postponed') return t('update.metaPostponed');
398
+ return t('update.metaDownloaded');
399
+ case 'installing': return t('update.titleInstalling');
400
+ case 'available': case 'move': case 'manual': return t('update.metaAvailable');
401
+ default: return '';
402
+ }
403
+ }
404
+
405
+ /** The badge's tooltip: what the update is doing, never only that one exists. */
406
+ export function desktopUpdateTooltip(state, { t = fillDesktopCopy } = {}) {
407
+ const s = readDesktopUpdateState(state);
408
+ const params = { current: s.current, latest: s.latest || '' };
409
+ const step = desktopUpdateStep(s);
410
+ if (step === 'downloading') return t('update.tooltipDownloading', params);
411
+ if (step === 'downloaded') return t('update.tooltipReady', params);
412
+ return t('update.tooltip', params);
413
+ }
414
+
415
+ /**
416
+ * The notice at the bottom right, or null:
417
+ * countdown SynaBun is idle and restarts into the update: the seconds left, and Later
418
+ * updated this copy runs after an update (once: the page acknowledges it)
419
+ * failed a version was on offer and did not download or install: why, and the releases page
420
+ * `key` changes only when the notice is another one (a countdown that restarts is another one).
421
+ */
422
+ export function desktopUpdateNotice(state, { t = fillDesktopCopy, now = Date.now() } = {}) {
423
+ const s = readDesktopUpdateState(state);
424
+ if (!s.supported) return null;
425
+ if (s.status === 'downloaded' && s.latest && s.auto?.phase === 'countdown' && s.auto.installAt) {
426
+ const seconds = secondsUntil(s.auto.installAt, now);
427
+ return {
428
+ kind: 'countdown', key: `countdown:${s.latest}:${s.auto.installAt}`, tone: 'up', seconds,
429
+ title: t('notice.countdownTitle', { version: s.latest }), line: t('notice.countdownLine', { seconds }),
430
+ actions: [{ id: 'later', label: t('notice.later') }], link: null, ticking: true,
431
+ };
432
+ }
433
+ if (s.updated) {
434
+ return {
435
+ kind: 'updated', key: `updated:${s.updated.version}`, tone: 'ok', seconds: null,
436
+ title: t('notice.updatedTitle'),
437
+ line: s.updated.previous ? t('notice.updatedLine', { version: s.updated.version, previous: s.updated.previous }) : t('notice.updatedLineShort', { version: s.updated.version }),
438
+ actions: [{ id: 'dismiss', label: t('notice.close') }], link: null, ticking: false,
439
+ };
440
+ }
441
+ if (s.status === 'error' && s.latest && s.error) {
442
+ return {
443
+ kind: 'failed', key: `failed:${s.latest}:${s.error}`, tone: 'err', seconds: null,
444
+ title: t('notice.failedTitle'), line: s.error,
445
+ actions: [{ id: 'details', label: t('notice.details') }], link: { href: s.releasesUrl, label: t('update.manualLink') }, ticking: false,
446
+ };
447
+ }
448
+ return null;
449
+ }
450
+
451
+ /**
452
+ * What a finished check ends in, for whoever asked for it (the native menu, the
453
+ * tray, Apps > Check for updates): 'offer' (open the dialog), 'up-to-date',
454
+ * 'error', or 'none' (an unsupported run, or a check still under way).
455
+ */
456
+ export function desktopCheckOutcome(state) {
457
+ const s = readDesktopUpdateState(state);
458
+ if (!s.supported || s.status === 'checking') return 'none';
459
+ if (offers(s)) return 'offer';
460
+ if (s.status === 'error') return 'error';
461
+ return 'up-to-date';
462
+ }
@@ -2078,7 +2078,7 @@ export function initEditorPanel(host = {}) {
2078
2078
  // listeners run in the order they were added): what was just written follows by itself.
2079
2079
  try {
2080
2080
  const value = kept ? storage.getItem(KEYS.EDITOR_TABS) : null;
2081
- if (value) navigator.sendBeacon?.('/api/ui-state', new Blob([JSON.stringify({ [KEYS.EDITOR_TABS]: JSON.parse(value) })], { type: 'application/json' }));
2081
+ if (value && storage.persists !== false) navigator.sendBeacon?.('/api/ui-state', new Blob([JSON.stringify({ [KEYS.EDITOR_TABS]: JSON.parse(value) })], { type: 'application/json' }));
2082
2082
  } catch { /* the debounced write is what is left */ }
2083
2083
  _backups?.flush({ leaving: true });
2084
2084
  };
@@ -488,7 +488,7 @@ export function createExplorer(host) {
488
488
  if (due) {
489
489
  try {
490
490
  const value = storage.getItem(treeKey);
491
- if (value) navigator.sendBeacon?.('/api/ui-state', new Blob([JSON.stringify({ [treeKey]: JSON.parse(value) })], { type: 'application/json' }));
491
+ if (value && storage.persists !== false) navigator.sendBeacon?.('/api/ui-state', new Blob([JSON.stringify({ [treeKey]: JSON.parse(value) })], { type: 'application/json' }));
492
492
  } catch { /* the debounced write is what is left */ }
493
493
  }
494
494
  stopWatch();
@@ -0,0 +1,145 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Git Bash for Claude Code: what Settings says
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // Settings → AI connections → Executable paths has a row for the Git Bash
6
+ // that every Claude Code started by SynaBun gets on Windows (the server's
7
+ // lib/git-bash/, GET /api/cli/git-bash): the one on the PC, SynaBun's own copy
8
+ // of Git for Windows (installed with the app, or downloaded on an npm
9
+ // install), its download while it runs, why it failed, and the button that
10
+ // tries again. This module reads an answer of the server into what the row
11
+ // shows; ui-git-bash.js draws it.
12
+ //
13
+ // No imports and no DOM: `node --test` loads this file as it is
14
+ // (tests/git-bash-view.test.mjs). GIT_BASH_COPY is the English of every
15
+ // sentence; `gitBash.*` in i18n/en.json must equal it and i18n/pt-BR.json must
16
+ // have the same keys.
17
+ //
18
+ // macOS and Linux have nothing to show (Claude Code runs the system's shell
19
+ // there): the row stays hidden, as it does for a server from before this,
20
+ // which has no such route.
21
+
22
+ export const GIT_BASH_COPY = Object.freeze({
23
+ label: 'Git Bash for Claude Code (Windows)',
24
+ help: 'Claude Code on Windows runs its Bash tool and its hooks in Git Bash. SynaBun uses the Git for Windows of this PC, or its own copy when the PC has none.',
25
+ badgeSystem: 'On this PC',
26
+ badgeReady: 'SynaBun\'s copy',
27
+ badgeMissing: 'Not found',
28
+ badgeDownloading: 'Downloading',
29
+ badgeFailed: 'Not installed',
30
+ badgeOff: 'Off',
31
+ sourceEnv: 'CLAUDE_CODE_GIT_BASH_PATH names {path}.',
32
+ sourceSettings: 'Your Claude Code settings name {path}.',
33
+ sourceSystem: 'Git for Windows on this PC: {path}',
34
+ sourceSystemPassed: 'Git for Windows on this PC: {path}. Claude Code would not find it by itself, so SynaBun names it.',
35
+ sourceBundled: 'Git for Windows {version}, installed with SynaBun: {path}',
36
+ sourceDownloaded: 'Git for Windows {version}, downloaded by SynaBun: {path}',
37
+ missing: 'No Git Bash on this PC. SynaBun downloads its own copy of Git for Windows {version} ({size} MB) and checks it against its published SHA-256.',
38
+ stepDownload: 'Downloading Git for Windows {version}: {done} of {total} MB',
39
+ stepExtract: 'Unpacking Git for Windows {version}: {percent}%',
40
+ stepVerify: 'Checking every file against the pinned release…',
41
+ stepPlace: 'Putting it in place…',
42
+ failed: 'Git for Windows could not be put in place. {reason}',
43
+ off: 'Turned off: SYNABUN_GIT_BASH=off is set, so SynaBun downloads nothing and names no Git Bash.',
44
+ unsupported: 'No Git for Windows release is pinned for this processor. Install Git for Windows from git-scm.com.',
45
+ settingsNamed: 'A claude you start in another terminal uses it too: your Claude Code settings name it.',
46
+ settingsKept: 'Your Claude Code settings name a Git Bash that is not there ({value}). SynaBun left that value as it is.',
47
+ settingsError: 'Your Claude Code settings could not be updated: {message}',
48
+ download: 'Download',
49
+ retry: 'Try again',
50
+ recheck: 'Re-check',
51
+ checking: 'Checking…',
52
+ errorOffline: 'The download server could not be reached. Check the connection and try again.',
53
+ errorHttp: 'The download server answered with an error. Try again in a moment.',
54
+ errorInterrupted: 'The download stopped before it was complete. Nothing was kept.',
55
+ errorTimeout: 'Nothing arrived from the download server for too long.',
56
+ errorChecksum: 'What arrived is not the published release, so it was removed and never run.',
57
+ errorUnpack: 'The download could not be unpacked, so it was removed.',
58
+ errorDisk: 'The files could not be written. Check free space (about 550 MB) and permissions.',
59
+ errorCancelled: 'The download was stopped.',
60
+ errorUnknown: 'The download did not finish.',
61
+ errorRequest: 'SynaBun did not answer: {message}',
62
+ });
63
+
64
+ const ERROR_COPY = Object.freeze({
65
+ offline: 'errorOffline', http: 'errorHttp', interrupted: 'errorInterrupted', timeout: 'errorTimeout',
66
+ checksum: 'errorChecksum', unpack: 'errorUnpack', disk: 'errorDisk', cancelled: 'errorCancelled',
67
+ });
68
+
69
+ const STEP_COPY = Object.freeze({ download: 'stepDownload', extract: 'stepExtract', verify: 'stepVerify', place: 'stepPlace' });
70
+
71
+ /** A GIT_BASH_COPY sentence with its `{params}` filled in: the translator when the page gives none. */
72
+ export function fillGitBashCopy(key, params = {}) {
73
+ return String(GIT_BASH_COPY[key] ?? key).replace(/\{(\w+)\}/g, (whole, name) => (params[name] != null ? params[name] : whole));
74
+ }
75
+
76
+ let _translate = null;
77
+
78
+ /** The page's translator: `(key, params) => t('gitBash.' + key, params)`; an empty answer or the key itself falls back to the English. */
79
+ export function setGitBashTranslator(fn) {
80
+ _translate = typeof fn === 'function' ? fn : null;
81
+ }
82
+
83
+ /** A sentence in the page's language. */
84
+ export function gitBashText(key, params = {}) {
85
+ if (_translate) {
86
+ try {
87
+ const text = _translate(key, params);
88
+ if (typeof text === 'string' && text && text !== key && text !== `gitBash.${key}`) return text;
89
+ } catch { /* the English below */ }
90
+ }
91
+ return fillGitBashCopy(key, params);
92
+ }
93
+
94
+ /** Whole megabytes (the release file is about a hundred of them). */
95
+ const mb = (bytes) => String(Number(bytes) > 0 ? Math.round(Number(bytes) / 1024 / 1024) : 0);
96
+
97
+ /** What the user's settings note adds to a row that shows SynaBun's copy in use, or null. */
98
+ function settingsNote(status) {
99
+ const result = status.settings;
100
+ if (!result || typeof result !== 'object') return null;
101
+ if (status.state === 'ready' && (result.state === 'written' || result.state === 'unchanged') && result.value === status.path) return gitBashText('settingsNamed');
102
+ if (result.state === 'kept-user-value' && result.value) return gitBashText('settingsKept', { value: result.value });
103
+ if (result.state === 'error') return gitBashText('settingsError', { message: result.message || '' });
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * The row for one answer of GET /api/cli/git-bash.
109
+ * @returns {{ visible: boolean, badge: { text: string, tone: 'detected' | 'not-found' | 'custom' | null } | null,
110
+ * line: string, note: string | null, action: 'download' | 'retry' | null, busy: boolean, recheck: boolean }}
111
+ */
112
+ export function gitBashView(status) {
113
+ const hidden = { visible: false, badge: null, line: '', note: null, action: null, busy: false, recheck: false };
114
+ if (!status || typeof status !== 'object' || status.supported !== true) return hidden;
115
+ const version = status.pinned?.version || '';
116
+ const path = status.path || '';
117
+ const row = (badge, tone, line, extra = {}) => ({ ...hidden, visible: true, badge: { text: gitBashText(badge), tone }, line, recheck: true, ...extra });
118
+ switch (status.state) {
119
+ case 'off':
120
+ return row('badgeOff', null, gitBashText('off'), { recheck: false });
121
+ case 'unsupported':
122
+ return row('badgeMissing', 'not-found', gitBashText('unsupported'));
123
+ case 'system': {
124
+ const key = status.source === 'env' ? 'sourceEnv' : status.source === 'settings' ? 'sourceSettings' : status.passToClaude ? 'sourceSystemPassed' : 'sourceSystem';
125
+ return row('badgeSystem', 'detected', gitBashText(key, { path }), { note: settingsNote(status) });
126
+ }
127
+ case 'ready':
128
+ return row('badgeReady', 'detected', gitBashText(status.source === 'bundled' ? 'sourceBundled' : 'sourceDownloaded', { version, path }), { note: settingsNote(status) });
129
+ case 'missing':
130
+ return row('badgeMissing', 'not-found', gitBashText('missing', { version, size: mb(status.pinned?.size) }), { action: 'download' });
131
+ case 'downloading': {
132
+ const step = STEP_COPY[status.step] || 'stepDownload';
133
+ const progress = status.progress || {};
134
+ const params = { version, done: mb(progress.done), total: mb(progress.total || status.pinned?.size), percent: Number(progress.percent) || 0 };
135
+ return row('badgeDownloading', 'custom', gitBashText(step, params), { busy: true, recheck: false });
136
+ }
137
+ case 'failed': {
138
+ const code = status.error?.code;
139
+ const reason = ERROR_COPY[code] ? gitBashText(ERROR_COPY[code]) : (status.error?.message || gitBashText('errorUnknown'));
140
+ return row('badgeFailed', 'not-found', gitBashText('failed', { reason }), { action: 'retry', note: settingsNote(status) });
141
+ }
142
+ default:
143
+ return hidden;
144
+ }
145
+ }
@@ -470,6 +470,7 @@ ${is3D ? `
470
470
 
471
471
  <div class="bar-center" id="search-container">
472
472
  <div id="search-wrapper">
473
+ <svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
473
474
  <input type="text" id="search-input" placeholder="${t('search.placeholder')}" autocomplete="off" spellcheck="false">
474
475
  <span id="search-badge"></span>
475
476
  <button id="search-clear">&times;</button>