ethagent 3.3.4 → 4.0.0

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 (322) hide show
  1. package/.claude-plugin/marketplace.json +11 -0
  2. package/.claude-plugin/plugin.json +35 -0
  3. package/LICENSE +1 -1
  4. package/README.md +64 -104
  5. package/commands/ethagent.md +40 -0
  6. package/package.json +16 -16
  7. package/src/app/keybindings/KeybindingProvider.tsx +1 -6
  8. package/src/app/keybindings/types.ts +1 -6
  9. package/src/cli/ResetConfirmView.tsx +54 -53
  10. package/src/cli/demo.ts +86 -0
  11. package/src/cli/hookIo.ts +45 -0
  12. package/src/cli/main.tsx +94 -123
  13. package/src/cli/memoryGuard.ts +49 -0
  14. package/src/cli/reset.ts +28 -70
  15. package/src/cli/sessionStart.ts +33 -0
  16. package/src/cli/status.ts +46 -0
  17. package/src/cli/sync.ts +167 -0
  18. package/src/cli/syncAdapters/claude-code.ts +86 -0
  19. package/src/cli/syncAdapters/codex.ts +66 -0
  20. package/src/cli/syncAdapters/index.ts +45 -0
  21. package/src/cli/syncAdapters/managedBlock.ts +175 -0
  22. package/src/cli/syncAdapters/shared.ts +63 -0
  23. package/src/identity/continuity/envelopeParse.ts +20 -1
  24. package/src/identity/continuity/publicSkills.ts +3 -1
  25. package/src/identity/continuity/skills/publicSkillsSync.ts +2 -1
  26. package/src/identity/continuity/skills/scaffold.ts +5 -2
  27. package/src/identity/continuity/snapshots.ts +12 -5
  28. package/src/identity/continuity/storage/defaults.ts +20 -19
  29. package/src/identity/continuity/storage/status.ts +1 -1
  30. package/src/identity/ens/ensLookup/constants.ts +1 -1
  31. package/src/identity/manager/IdentityManager.tsx +33 -0
  32. package/src/identity/{hub → manager}/OperationalRoutes.tsx +37 -18
  33. package/src/identity/{hub → manager}/Routes.tsx +48 -34
  34. package/src/identity/{hub → manager}/continuity/ContinuityDashboardScreen.tsx +9 -19
  35. package/src/identity/{hub → manager}/continuity/RebackupStorageScreen.tsx +3 -3
  36. package/src/identity/manager/continuity/RecoveryConfirmScreen.tsx +102 -0
  37. package/src/identity/{hub → manager}/continuity/SavePromptScreen.tsx +2 -3
  38. package/src/identity/{hub → manager}/continuity/completion.ts +1 -1
  39. package/src/identity/{hub → manager}/continuity/effects.ts +1 -1
  40. package/src/identity/{hub → manager}/continuity/skills/DeleteSkillConfirmScreen.tsx +2 -2
  41. package/src/identity/{hub → manager}/continuity/skills/NewSkillScreen.tsx +0 -5
  42. package/src/identity/{hub → manager}/continuity/skills/NewSkillVisibilityScreen.tsx +4 -4
  43. package/src/identity/{hub → manager}/continuity/skills/SkillActionsScreen.tsx +6 -22
  44. package/src/identity/{hub → manager}/continuity/skills/SkillsTreeScreen.tsx +5 -17
  45. package/src/identity/{hub → manager}/continuity/snapshot.ts +1 -1
  46. package/src/identity/{hub → manager}/continuity/vault.ts +1 -1
  47. package/src/identity/{hub → manager}/create/CreateFlow.tsx +59 -32
  48. package/src/identity/{hub → manager}/create/effects.ts +19 -10
  49. package/src/identity/manager/create/importScan.ts +122 -0
  50. package/src/identity/{hub → manager}/custody/CustodyEditFlow.tsx +17 -61
  51. package/src/identity/{hub → manager}/custody/actions.ts +1 -15
  52. package/src/identity/{hub → manager}/custody/routes.tsx +20 -40
  53. package/src/identity/{hub → manager}/custody/transactions.ts +1 -0
  54. package/src/identity/{hub → manager}/custody/types.ts +1 -2
  55. package/src/identity/{hub → manager}/custody/useCustodyEffects.ts +1 -1
  56. package/src/identity/{hub → manager}/ens/EnsEditAdvancedScreens.tsx +2 -2
  57. package/src/identity/{hub → manager}/ens/EnsEditMaintenanceScreens.tsx +12 -23
  58. package/src/identity/{hub → manager}/ens/EnsEditReviewScreens.tsx +18 -42
  59. package/src/identity/{hub → manager}/ens/EnsEditRunners.tsx +1 -1
  60. package/src/identity/{hub → manager}/ens/EnsEditShared.tsx +0 -2
  61. package/src/identity/{hub → manager}/ens/EnsEditSimpleScreens.tsx +10 -19
  62. package/src/identity/{hub → manager}/ens/EnsFlow.tsx +133 -41
  63. package/src/identity/{hub → manager}/ens/EnsOperatorWalletsScreen.tsx +14 -19
  64. package/src/identity/{hub → manager}/ens/editCopy.ts +1 -14
  65. package/src/identity/{hub → manager}/profile/EditProfileFlow.tsx +99 -66
  66. package/src/identity/{hub → manager}/profile/effects.ts +1 -3
  67. package/src/identity/{hub → manager}/profile/operatorSave.ts +1 -1
  68. package/src/identity/{hub → manager}/profile/state.ts +1 -1
  69. package/src/identity/{hub/identityHubReducer.ts → manager/reducer.ts} +25 -26
  70. package/src/identity/{hub → manager}/restore/RestoreFlow.tsx +16 -24
  71. package/src/identity/{hub → manager}/restore/apply.ts +1 -1
  72. package/src/identity/{hub → manager}/restore/auth.ts +1 -1
  73. package/src/identity/{hub → manager}/restore/discover.ts +1 -1
  74. package/src/identity/{hub → manager}/restore/fetch.ts +1 -1
  75. package/src/identity/{hub → manager}/restore/restoreAdmin.ts +1 -1
  76. package/src/identity/{hub → manager}/restore/useRestoreEffects.ts +2 -9
  77. package/src/identity/{hub → manager}/settings/StorageCredentialScreen.tsx +10 -25
  78. package/src/identity/{hub → manager}/shared/components/DetailsScreen.tsx +5 -7
  79. package/src/identity/{hub → manager}/shared/components/ErrorScreen.tsx +6 -10
  80. package/src/identity/{hub → manager}/shared/components/FlowTimeline.tsx +4 -3
  81. package/src/identity/{hub → manager}/shared/components/IdentitySummary.tsx +19 -59
  82. package/src/identity/manager/shared/components/LazyMenu.tsx +147 -0
  83. package/src/identity/manager/shared/components/MenuScreen.tsx +220 -0
  84. package/src/identity/manager/shared/components/OperationCompleteScreen.tsx +28 -0
  85. package/src/identity/{hub → manager}/shared/components/UnlinkedIdentityScreen.tsx +9 -10
  86. package/src/identity/{hub → manager}/shared/components/WalletApprovalScreen.tsx +1 -2
  87. package/src/identity/manager/shared/components/Wordmark.tsx +54 -0
  88. package/src/identity/{hub → manager}/shared/components/menuFlagsFromReconciliation.ts +39 -15
  89. package/src/identity/{hub → manager}/shared/effects/profilePrep.ts +1 -1
  90. package/src/identity/manager/shared/effects/types.ts +30 -0
  91. package/src/identity/{hub → manager}/shared/model/copy.ts +0 -4
  92. package/src/identity/{hub → manager}/shared/model/errors.ts +32 -3
  93. package/src/identity/{hub → manager}/shared/model/network.ts +2 -2
  94. package/src/identity/{hub → manager}/shared/reconciliation/agentReconciliation/hook.ts +5 -0
  95. package/src/identity/{hub → manager}/shared/reconciliation/agentReconciliation/run.ts +1 -1
  96. package/src/identity/{hub/shared/reconciliation/useAgentReconciliation.ts → manager/shared/reconciliation/index.ts} +6 -0
  97. package/src/identity/{hub → manager}/shared/utils.ts +6 -10
  98. package/src/identity/{hub → manager}/transfer/TokenTransferFlow.tsx +3 -3
  99. package/src/identity/{hub → manager}/transfer/TokenTransferScreens.tsx +4 -10
  100. package/src/identity/{hub → manager}/transfer/effects.ts +1 -1
  101. package/src/identity/{hub → manager}/types.ts +5 -6
  102. package/src/identity/{hub/useIdentityHubContinuity.ts → manager/useContinuity.ts} +59 -27
  103. package/src/identity/{hub/useIdentityHubController.ts → manager/useController.ts} +38 -35
  104. package/src/identity/{hub/useIdentityHubSideEffects.ts → manager/useSideEffects.ts} +40 -4
  105. package/src/identity/registry/erc8004/discovery.ts +3 -17
  106. package/src/identity/registry/erc8004/utils.ts +1 -1
  107. package/src/identity/storage/ipfs.ts +21 -1
  108. package/src/identity/wallet/browserWallet/html.ts +10 -2
  109. package/src/identity/wallet/browserWallet/http.ts +18 -0
  110. package/src/identity/wallet/browserWallet/requestServer.ts +5 -1
  111. package/src/identity/wallet/browserWallet/requests.ts +10 -28
  112. package/src/identity/wallet/browserWallet/session.ts +26 -33
  113. package/src/identity/wallet/browserWallet/validation.ts +14 -0
  114. package/src/identity/wallet/browserWallet/walletPageSource.ts +22 -40
  115. package/src/identity/wallet/page/boot.ts +43 -0
  116. package/src/identity/wallet/page/config.ts +59 -0
  117. package/src/identity/wallet/page/constants.ts +12 -0
  118. package/src/identity/wallet/page/copy.ts +47 -68
  119. package/src/identity/wallet/page/css.ts +638 -0
  120. package/src/identity/wallet/page/{errorView.ts → errors.ts} +5 -14
  121. package/src/identity/wallet/page/{controller.ts → flow.ts} +4 -71
  122. package/src/identity/wallet/page/markup.ts +44 -34
  123. package/src/identity/wallet/page/{walletProvider.ts → provider.ts} +0 -3
  124. package/src/identity/wallet/page/resize.ts +95 -0
  125. package/src/identity/wallet/page/state.ts +135 -8
  126. package/src/identity/wallet/page/timeline.ts +161 -0
  127. package/src/identity/wallet/page/view.ts +22 -302
  128. package/src/storage/config.ts +30 -80
  129. package/src/storage/reset.ts +31 -0
  130. package/src/storage/secrets.ts +1 -16
  131. package/src/ui/Select.tsx +27 -5
  132. package/src/ui/Spinner.tsx +16 -15
  133. package/src/ui/Surface.tsx +21 -17
  134. package/src/ui/TextArea.tsx +173 -0
  135. package/src/ui/TextInput.tsx +31 -133
  136. package/src/ui/theme.ts +22 -13
  137. package/src/utils/clipboard.ts +0 -140
  138. package/src/app/FirstRun.tsx +0 -577
  139. package/src/app/FirstRunTimeline.tsx +0 -51
  140. package/src/app/firstRunConfig.ts +0 -26
  141. package/src/app/hooks/useCancelRequest.ts +0 -22
  142. package/src/app/hooks/useDoublePress.ts +0 -46
  143. package/src/app/hooks/useExitOnCtrlC.ts +0 -36
  144. package/src/auth/openaiOAuth/credentials.ts +0 -47
  145. package/src/auth/openaiOAuth/crypto.ts +0 -23
  146. package/src/auth/openaiOAuth/index.ts +0 -238
  147. package/src/auth/openaiOAuth/landingPage.ts +0 -116
  148. package/src/auth/openaiOAuth/listener.ts +0 -151
  149. package/src/auth/openaiOAuth/refresh.ts +0 -70
  150. package/src/auth/openaiOAuth/shared.ts +0 -115
  151. package/src/chat/ChatBottomPane.tsx +0 -296
  152. package/src/chat/ChatScreen.tsx +0 -1685
  153. package/src/chat/ConversationStack.tsx +0 -56
  154. package/src/chat/MessageList.tsx +0 -638
  155. package/src/chat/SessionStatus.tsx +0 -53
  156. package/src/chat/chatEnvironment.ts +0 -16
  157. package/src/chat/chatScreenUtils.ts +0 -194
  158. package/src/chat/chatSessionState.ts +0 -146
  159. package/src/chat/chatTurnContext.ts +0 -50
  160. package/src/chat/chatTurnOrchestrator.ts +0 -603
  161. package/src/chat/chatTurnRows.ts +0 -64
  162. package/src/chat/commands.ts +0 -494
  163. package/src/chat/continuityEditReview.ts +0 -42
  164. package/src/chat/display/DiffView.tsx +0 -193
  165. package/src/chat/display/SyntaxText.tsx +0 -192
  166. package/src/chat/display/toolCallDisplay.ts +0 -103
  167. package/src/chat/display/toolResultDisplay.ts +0 -19
  168. package/src/chat/input/ChatInput.tsx +0 -625
  169. package/src/chat/input/chatInputHelpers.ts +0 -62
  170. package/src/chat/input/chatInputState.ts +0 -247
  171. package/src/chat/input/chatPaste.ts +0 -49
  172. package/src/chat/input/imageRefs.ts +0 -30
  173. package/src/chat/input/inputRendering.tsx +0 -93
  174. package/src/chat/input/textCursor.ts +0 -212
  175. package/src/chat/messageMarkdown.ts +0 -220
  176. package/src/chat/messageRows.ts +0 -43
  177. package/src/chat/planImplementation.ts +0 -62
  178. package/src/chat/slashCommandHandlers.ts +0 -122
  179. package/src/chat/slashCommandViews.ts +0 -120
  180. package/src/chat/transcript/TranscriptView.tsx +0 -184
  181. package/src/chat/transcript/transcriptViewport.ts +0 -295
  182. package/src/chat/views/ContextLimitView.tsx +0 -95
  183. package/src/chat/views/ContinuityEditReviewView.tsx +0 -50
  184. package/src/chat/views/CopyPicker.tsx +0 -50
  185. package/src/chat/views/PermissionPrompt.tsx +0 -156
  186. package/src/chat/views/PermissionsView.tsx +0 -165
  187. package/src/chat/views/PlanApprovalView.tsx +0 -91
  188. package/src/chat/views/ResumeView.tsx +0 -273
  189. package/src/chat/views/RewindView.tsx +0 -412
  190. package/src/cli/preview.tsx +0 -14
  191. package/src/cli/updateNotice.ts +0 -54
  192. package/src/identity/continuity/privateEdit/apply.ts +0 -170
  193. package/src/identity/continuity/privateEdit/diff.ts +0 -6
  194. package/src/identity/continuity/privateEdit/files.ts +0 -23
  195. package/src/identity/continuity/privateEdit/types.ts +0 -28
  196. package/src/identity/continuity/privateEdit.ts +0 -46
  197. package/src/identity/hub/IdentityHub.tsx +0 -14
  198. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +0 -104
  199. package/src/identity/hub/ens/effects.ts +0 -218
  200. package/src/identity/hub/shared/components/MenuScreen.tsx +0 -241
  201. package/src/identity/hub/shared/effects/types.ts +0 -53
  202. package/src/identity/hub/shared/reconciliation/index.ts +0 -14
  203. package/src/identity/wallet/page/grainient.ts +0 -278
  204. package/src/identity/wallet/page/html.ts +0 -28
  205. package/src/identity/wallet/page/styles/base.ts +0 -260
  206. package/src/identity/wallet/page/styles/components.ts +0 -262
  207. package/src/identity/wallet/page/styles/index.ts +0 -5
  208. package/src/identity/wallet/page/styles/responsive.ts +0 -247
  209. package/src/identity/wallet/page.tsx +0 -38
  210. package/src/mcp/approvals.ts +0 -113
  211. package/src/mcp/config.ts +0 -235
  212. package/src/mcp/manager.ts +0 -482
  213. package/src/mcp/managerHelpers.ts +0 -70
  214. package/src/mcp/names.ts +0 -19
  215. package/src/mcp/output.ts +0 -96
  216. package/src/models/ModelPicker.tsx +0 -1009
  217. package/src/models/catalog.ts +0 -327
  218. package/src/models/huggingface.ts +0 -712
  219. package/src/models/huggingfaceStorage.ts +0 -136
  220. package/src/models/llamacpp.ts +0 -848
  221. package/src/models/llamacppCommands.ts +0 -44
  222. package/src/models/llamacppConfig.ts +0 -34
  223. package/src/models/llamacppDiscovery.ts +0 -176
  224. package/src/models/llamacppOutput.ts +0 -65
  225. package/src/models/llamacppPreflight.ts +0 -158
  226. package/src/models/modelDisplay.ts +0 -180
  227. package/src/models/modelPickerCatalogFlow.ts +0 -56
  228. package/src/models/modelPickerCredentials.ts +0 -166
  229. package/src/models/modelPickerData.ts +0 -41
  230. package/src/models/modelPickerDisplay.tsx +0 -132
  231. package/src/models/modelPickerHfFlow.ts +0 -192
  232. package/src/models/modelPickerLocalRunnerFlow.ts +0 -115
  233. package/src/models/modelPickerOptions.ts +0 -457
  234. package/src/models/modelPickerTypes.ts +0 -69
  235. package/src/models/modelPickerUninstallFlow.ts +0 -48
  236. package/src/models/modelPickerViewHelpers.ts +0 -174
  237. package/src/models/modelRecommendation.ts +0 -139
  238. package/src/models/providerDisplay.ts +0 -16
  239. package/src/models/runtimeDetection.ts +0 -81
  240. package/src/models/uncensoredCatalog.ts +0 -86
  241. package/src/providers/anthropic.ts +0 -290
  242. package/src/providers/contracts.ts +0 -71
  243. package/src/providers/errors.ts +0 -80
  244. package/src/providers/gemini.ts +0 -391
  245. package/src/providers/openai-chat.ts +0 -474
  246. package/src/providers/openai-responses-format.ts +0 -177
  247. package/src/providers/openai-responses.ts +0 -306
  248. package/src/providers/openaiChatWire.ts +0 -124
  249. package/src/providers/registry.ts +0 -120
  250. package/src/providers/retry.ts +0 -58
  251. package/src/providers/sse.ts +0 -93
  252. package/src/runtime/compaction.ts +0 -395
  253. package/src/runtime/cwd.ts +0 -43
  254. package/src/runtime/providerTurn.ts +0 -38
  255. package/src/runtime/sessionMode.ts +0 -55
  256. package/src/runtime/systemPrompt.ts +0 -213
  257. package/src/runtime/textToolParser.ts +0 -161
  258. package/src/runtime/toolClaimGuards.ts +0 -143
  259. package/src/runtime/toolExecution.ts +0 -304
  260. package/src/runtime/toolIntent.ts +0 -143
  261. package/src/runtime/turn.ts +0 -369
  262. package/src/runtime/turnNudges.ts +0 -223
  263. package/src/runtime/turnTypes.ts +0 -86
  264. package/src/storage/factoryReset.ts +0 -127
  265. package/src/storage/history.ts +0 -58
  266. package/src/storage/permissions.ts +0 -76
  267. package/src/storage/rewind.ts +0 -266
  268. package/src/storage/sessionExport.ts +0 -49
  269. package/src/storage/sessions.ts +0 -495
  270. package/src/tools/bashSafety.ts +0 -186
  271. package/src/tools/bashTool.ts +0 -140
  272. package/src/tools/changeDirectoryTool.ts +0 -213
  273. package/src/tools/contracts.ts +0 -192
  274. package/src/tools/deleteFileTool.ts +0 -116
  275. package/src/tools/editTool.ts +0 -165
  276. package/src/tools/editUtils.ts +0 -170
  277. package/src/tools/fileDiff.ts +0 -261
  278. package/src/tools/listDirectoryTool.ts +0 -55
  279. package/src/tools/listSkillFilesTool.ts +0 -77
  280. package/src/tools/listSkillsTool.ts +0 -68
  281. package/src/tools/mcpResourceTools.ts +0 -95
  282. package/src/tools/permissionRules.ts +0 -85
  283. package/src/tools/privateContinuityEditTool.ts +0 -187
  284. package/src/tools/privateContinuityReadTool.ts +0 -106
  285. package/src/tools/readSkillTool.ts +0 -107
  286. package/src/tools/readTool.ts +0 -85
  287. package/src/tools/registry.ts +0 -103
  288. package/src/tools/writeFileTool.ts +0 -167
  289. package/src/ui/BrandSplash.tsx +0 -133
  290. package/src/ui/terminalTitle.ts +0 -30
  291. package/src/utils/images.ts +0 -140
  292. package/src/utils/markdownSegments.ts +0 -51
  293. package/src/utils/messages.ts +0 -37
  294. package/src/utils/withRetry.ts +0 -324
  295. /package/src/identity/{hub → manager}/continuity/state.ts +0 -0
  296. /package/src/identity/{hub → manager}/custody/helpers.ts +0 -0
  297. /package/src/identity/{hub → manager}/custody/preflight.ts +0 -0
  298. /package/src/identity/{hub → manager}/custody/state.ts +0 -0
  299. /package/src/identity/{hub → manager}/custody/useCustodyFlow.tsx +0 -0
  300. /package/src/identity/{hub → manager}/ens/EnsEditFlow.tsx +0 -0
  301. /package/src/identity/{hub → manager}/ens/advancedEnsValidation.ts +0 -0
  302. /package/src/identity/{hub → manager}/ens/state.ts +0 -0
  303. /package/src/identity/{hub → manager}/ens/transactions.ts +0 -0
  304. /package/src/identity/{hub → manager}/ens/types.ts +0 -0
  305. /package/src/identity/{hub → manager}/profile/identity.ts +0 -0
  306. /package/src/identity/{hub → manager}/restore/envelopes.ts +0 -0
  307. /package/src/identity/{hub → manager}/restore/helpers.ts +0 -0
  308. /package/src/identity/{hub → manager}/restore/recovery.ts +0 -0
  309. /package/src/identity/{hub → manager}/restore/resolve.ts +0 -0
  310. /package/src/identity/{hub → manager}/shared/components/BusyScreen.tsx +0 -0
  311. /package/src/identity/{hub → manager}/shared/components/NetworkScreen.tsx +0 -0
  312. /package/src/identity/{hub → manager}/shared/components/PinataJwtInput.tsx +0 -0
  313. /package/src/identity/{hub → manager}/shared/effects/receipts.ts +0 -0
  314. /package/src/identity/{hub → manager}/shared/effects/sync.ts +0 -0
  315. /package/src/identity/{hub → manager}/shared/model/format.ts +0 -0
  316. /package/src/identity/{hub → manager}/shared/operatorWallets.ts +0 -0
  317. /package/src/identity/{hub → manager}/shared/reconciliation/agentReconciliation/ownership.ts +0 -0
  318. /package/src/identity/{hub → manager}/shared/reconciliation/agentReconciliation/types.ts +0 -0
  319. /package/src/identity/{hub → manager}/shared/reconciliation/walletSetup.ts +0 -0
  320. /package/src/identity/{hub → manager}/shared/txGuard.ts +0 -0
  321. /package/src/identity/{hub → manager}/transfer/progress.ts +0 -0
  322. /package/src/identity/{hub → manager}/transfer/state.ts +0 -0
@@ -1,1685 +0,0 @@
1
- import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
- import { Box, Text, useApp } from 'ink'
3
- import type { EthagentConfig } from '../storage/config.js'
4
- import type { Provider, Message } from '../providers/contracts.js'
5
- import { createProvider } from '../providers/registry.js'
6
- import { approximateTokens } from '../utils/messages.js'
7
- import {
8
- dispatchSlash,
9
- parseSlash,
10
- getSlashSuggestions,
11
- type SlashContext,
12
- } from './commands.js'
13
- import { theme } from '../ui/theme.js'
14
- import { BrandSplash } from '../ui/BrandSplash.js'
15
- import { SessionStatus, formatTokens } from './SessionStatus.js'
16
- import { formatModelDisplayName } from '../models/modelDisplay.js'
17
- import { providerDisplayName } from '../models/providerDisplay.js'
18
- import { toggleReasoningRow, type MessageRow } from './MessageList.js'
19
- import { ConversationStack } from './ConversationStack.js'
20
- import type { ModelPickerSelection } from '../models/ModelPicker.js'
21
- import type { ModelPickerContextFit } from '../models/modelPickerOptions.js'
22
- import type { CopyResult } from '../utils/clipboard.js'
23
- import { useKeybinding, useRegisterKeybindingContext } from '../app/keybindings/KeybindingProvider.js'
24
- import { TITLE_ANIMATION_FRAMES, TITLE_ANIMATION_INTERVAL_MS, TITLE_STATIC, setTerminalTitle } from '../ui/terminalTitle.js'
25
- import { useCancelRequest } from '../app/hooks/useCancelRequest.js'
26
- import { useExitOnCtrlC } from '../app/hooks/useExitOnCtrlC.js'
27
- import {
28
- appendSessionMessage,
29
- clearAllSessions,
30
- ensureSessionMetadata,
31
- loadSession,
32
- loadSessionMetadata,
33
- newSessionId,
34
- updateSessionActivity,
35
- } from '../storage/sessions.js'
36
- import type { SessionMessage } from '../storage/sessions.js'
37
- import { loadPermissionRules, savePermissionRule } from '../storage/permissions.js'
38
- import { appendHistory, readHistory } from '../storage/history.js'
39
- import {
40
- compactTranscript,
41
- contextUsage,
42
- contextUsageFromTokens,
43
- summarizeTranscriptLocally,
44
- shouldConfirmContextUsage,
45
- type ContextUsage,
46
- } from '../runtime/compaction.js'
47
- import { fetchLlamaCppContextSize, onLlamaCppContextSizeChange, setCachedLlamaCppContextSize } from '../models/llamacpp.js'
48
- import { llamaCppServerHostFromBaseUrl } from '../models/llamacppPreflight.js'
49
- import { localProviderBaseUrlFor, saveConfig } from '../storage/config.js'
50
- import { getCwd as getRuntimeCwd, setCwd as setRuntimeCwd, syncCwdFromProcess } from '../runtime/cwd.js'
51
- import { executeToolWithPermissions } from '../runtime/toolExecution.js'
52
- import { nextSessionMode, sessionModeLabel, type PermissionMode, type SessionMode } from '../runtime/sessionMode.js'
53
- import type {
54
- PermissionDecision,
55
- PermissionRequest,
56
- SessionPermissionRule,
57
- } from '../tools/contracts.js'
58
- import {
59
- buildBaseMessages,
60
- sessionMessagesToRows,
61
- type TurnCheckpoint,
62
- } from './chatScreenUtils.js'
63
- import { ChatBottomPane, type ContextLimitState, type CopyPickerState, type IdentityOverlayState, type Overlay } from './ChatBottomPane.js'
64
- import { setTokenIdentity, getIdentityStatus } from '../storage/identity.js'
65
- import type { IdentityHubResult } from '../identity/hub/IdentityHub.js'
66
- import { continuityWorkingTreeStatus } from '../identity/continuity/storage.js'
67
- import { listPublishedContinuitySnapshots } from '../identity/continuity/snapshots.js'
68
- import { localChangeStatusView } from '../identity/hub/continuity/state.js'
69
- import {
70
- buildResumedSessionState,
71
- promptHistoryFromSessionMessages,
72
- resolveModelSelection,
73
- restoreConversationState,
74
- } from './chatSessionState.js'
75
- import { runStreamingTurn } from './chatTurnOrchestrator.js'
76
- import type { PlanApprovalAction } from './views/PlanApprovalView.js'
77
- import type { ContextLimitAction } from './views/ContextLimitView.js'
78
- import type { ContinuityEditReviewAction, ContinuityEditReviewState } from './views/ContinuityEditReviewView.js'
79
- import { openFileInEditor } from '../identity/continuity/editor.js'
80
- import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager.js'
81
- import { compressHome, ensureLocalProviderReady } from './chatEnvironment.js'
82
- import {
83
- buildPlanImplementationPrompt,
84
- buildPlanTransferSeedMessages,
85
- chatFooterShortcutText,
86
- normalizeHandoffSummary,
87
- } from './planImplementation.js'
88
- import { privateContinuityEditReviewFromToolResult } from './continuityEditReview.js'
89
-
90
- export {
91
- buildPlanImplementationPrompt,
92
- buildPlanTransferSeedMessages,
93
- chatFooterShortcutText,
94
- } from './planImplementation.js'
95
- export { privateContinuityEditReviewFromToolResult } from './continuityEditReview.js'
96
-
97
- type ChatScreenProps = {
98
- config: EthagentConfig
99
- onReplaceConfig?: (next: EthagentConfig) => void
100
- updateNotice?: string | null
101
- }
102
-
103
- type PendingPlan = {
104
- text: string
105
- cwd: string
106
- sessionId: string
107
- provider: string
108
- model: string
109
- contextLabel: string
110
- awaitingApproval: boolean
111
- }
112
-
113
- type CompactionKind = 'conversation' | 'plan'
114
-
115
- type CompactionUiState = {
116
- kind: CompactionKind
117
- progressRowId: string
118
- sourceSessionId: string
119
- startedAt: number
120
- stage: string
121
- controller: AbortController
122
- }
123
-
124
- let rowIdSeq = 0
125
- const nextRowId = (): string => `row-${++rowIdSeq}`
126
- const nowIso = (): string => new Date().toISOString()
127
- const STREAM_FLUSH_MS = 120
128
- const CONTEXT_CONFIRM_PERCENT = 90
129
- const MAX_PROMPT_HISTORY = 500
130
-
131
- export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig, updateNotice }) => {
132
- useRegisterKeybindingContext('Chat')
133
- const { exit } = useApp()
134
- const [config, setConfig] = useState<EthagentConfig>(initialConfig)
135
- const [rows, setRows] = useState<MessageRow[]>([])
136
- const [history, setHistory] = useState<string[]>([])
137
- const [streaming, setStreaming] = useState(false)
138
- const [streamingStartedAt, setStreamingStartedAt] = useState<number | null>(null)
139
- const [queuedInputs, setQueuedInputs] = useState<string[]>([])
140
- const [turns, setTurns] = useState(0)
141
- const [approxTokens, setApproxTokens] = useState(0)
142
- const [overlay, setOverlay] = useState<Overlay>('none')
143
- const [copyPickerState, setCopyPickerState] = useState<CopyPickerState>(null)
144
- const [contextLimitState, setContextLimitState] = useState<ContextLimitState>(null)
145
- const [continuityEditReview, setContinuityEditReview] = useState<ContinuityEditReviewState | null>(null)
146
- const [modelPickerContextFit, setModelPickerContextFit] = useState<ModelPickerContextFit | null>(null)
147
- const [identityOverlay, setIdentityOverlay] = useState<IdentityOverlayState | null>(null)
148
- const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null)
149
- const [mode, setMode] = useState<SessionMode>('chat')
150
- const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
151
- const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
152
- const [canScrollTranscript, setCanScrollTranscript] = useState(false)
153
- const [sessionId, setSessionId] = useState<string>(() => newSessionId())
154
- const [sessionKey, setSessionKey] = useState<number>(0)
155
- const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
156
- const [statusStartedAt, setStatusStartedAt] = useState<number>(() => Date.now())
157
- const [activeContextUsage, setActiveContextUsage] = useState<ContextUsage>(() =>
158
- contextUsageFromTokens(0, initialConfig.provider, initialConfig.model),
159
- )
160
- const [mcpSnapshot, setMcpSnapshot] = useState<McpSnapshot>(EMPTY_MCP_SNAPSHOT)
161
- const [pendingInputDraft, setPendingInputDraft] = useState<string | null>(null)
162
-
163
- const rowsRef = useRef<MessageRow[]>([])
164
- const visibleReasoningIdsRef = useRef<string[]>([])
165
- const sessionMessagesRef = useRef<SessionMessage[]>([])
166
- const sessionIdRef = useRef<string>(sessionId)
167
- const globalHistoryRef = useRef<string[]>([])
168
- const historyScopeRef = useRef<'global' | 'session'>('global')
169
- const cwdRef = useRef<string>(getRuntimeCwd())
170
- const overlayRef = useRef<Overlay>(overlay)
171
- const modeRef = useRef<SessionMode>(mode)
172
- const streamAbortRef = useRef<AbortController | null>(null)
173
- const providerRef = useRef<Provider>(createProvider(initialConfig))
174
- const configRef = useRef<EthagentConfig>(initialConfig)
175
- const prevConfigRef = useRef<EthagentConfig>(initialConfig)
176
- const compactingRef = useRef<boolean>(false)
177
- const pendingAssistantTextRef = useRef<string | null>(null)
178
- const pendingThinkingTextRef = useRef<string | null>(null)
179
- const streamFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
180
- const drainingQueueRef = useRef<boolean>(false)
181
- const permissionResolveRef = useRef<((decision: PermissionDecision) => void) | null>(null)
182
- const permissionRulesRef = useRef<SessionPermissionRule[]>([])
183
- const activeCheckpointRef = useRef<TurnCheckpoint | undefined>(undefined)
184
- const statsSegmentStartRef = useRef<number>(0)
185
- const pendingPlanRef = useRef<PendingPlan | null>(null)
186
- const compactionUiRef = useRef<CompactionUiState | null>(null)
187
- const contextLimitStateRef = useRef<ContextLimitState>(null)
188
- const pendingContinuityEditReviewRef = useRef<ContinuityEditReviewState | null>(null)
189
- const contextModelSwitchPromptRef = useRef<string | null>(null)
190
- const mcpManagerRef = useRef<McpManager | null>(null)
191
- const savePromptShownRef = useRef<boolean>(false)
192
-
193
- useEffect(() => { rowsRef.current = rows }, [rows])
194
- useEffect(() => { overlayRef.current = overlay }, [overlay])
195
- useEffect(() => { sessionIdRef.current = sessionId }, [sessionId])
196
- useEffect(() => { cwdRef.current = cwd }, [cwd])
197
- useEffect(() => { modeRef.current = mode }, [mode])
198
- useEffect(() => { pendingPlanRef.current = pendingPlan }, [pendingPlan])
199
- useEffect(() => { compactionUiRef.current = compactionUi }, [compactionUi])
200
- useEffect(() => { contextLimitStateRef.current = contextLimitState }, [contextLimitState])
201
-
202
- useEffect(() => {
203
- if (prevConfigRef.current === config) return
204
- prevConfigRef.current = config
205
- configRef.current = config
206
- providerRef.current = createProvider(config)
207
- }, [config])
208
-
209
- useEffect(() => {
210
- void (async () => {
211
- const loaded = await readHistory()
212
- globalHistoryRef.current = loaded
213
- if (historyScopeRef.current === 'global') setHistory(loaded)
214
- })()
215
- }, [])
216
-
217
- useEffect(() => {
218
- if (savePromptShownRef.current) return
219
- savePromptShownRef.current = true
220
- void (async () => {
221
- try {
222
- const identity = configRef.current.identity
223
- if (!identity) return
224
- const [latest] = await listPublishedContinuitySnapshots(identity, 1)
225
- const status = await continuityWorkingTreeStatus(identity, latest)
226
- if (!localChangeStatusView(status).hasLocalChanges) return
227
- if (overlayRef.current !== 'none') return
228
- setIdentityOverlay({
229
- initialAction: 'save-prompt',
230
- existing: { address: identity.address },
231
- })
232
- overlayRef.current = 'identity'
233
- setOverlay('identity')
234
- } catch {
235
- // best-effort; skip prompt on any error
236
- }
237
- })()
238
- }, [])
239
-
240
- useEffect(() => {
241
- void (async () => {
242
- try {
243
- permissionRulesRef.current = await loadPermissionRules(cwd)
244
- } catch {
245
- permissionRulesRef.current = []
246
- }
247
- })()
248
- }, [cwd])
249
-
250
- useEffect(() => {
251
- void ensureSessionMetadata(sessionId, {
252
- cwd,
253
- provider: config.provider,
254
- model: config.model,
255
- mode,
256
- })
257
- }, [config.model, config.provider, cwd, mode, sessionId])
258
-
259
- useEffect(() => {
260
- void updateSessionActivity(
261
- sessionId,
262
- { cwd, provider: config.provider, model: config.model, mode },
263
- { lastCwd: cwd, provider: config.provider, model: config.model, mode },
264
- ).catch(() => {})
265
- }, [config.model, config.provider, cwd, mode, sessionId])
266
-
267
- useEffect(() => {
268
- return () => {
269
- streamAbortRef.current?.abort()
270
- compactionUiRef.current?.controller.abort()
271
- if (streamFlushTimerRef.current) clearTimeout(streamFlushTimerRef.current)
272
- permissionResolveRef.current?.('deny')
273
- void mcpManagerRef.current?.close()
274
- }
275
- }, [])
276
-
277
- const updateRows = useCallback((updater: (prev: MessageRow[]) => MessageRow[]) => {
278
- setRows(prev => updater(prev))
279
- }, [])
280
-
281
- const pushNote = useCallback(
282
- (text: string, kind: 'info' | 'error' | 'dim' = 'info') => {
283
- updateRows(prev => [...prev, { role: 'note', id: nextRowId(), kind, content: text }])
284
- },
285
- [updateRows],
286
- )
287
-
288
- useEffect(() => {
289
- if (!mcpManagerRef.current) {
290
- mcpManagerRef.current = new McpManager(cwd, setMcpSnapshot)
291
- }
292
- void mcpManagerRef.current.refresh(cwd).catch((err: unknown) => {
293
- pushNote(`MCP refresh failed: ${(err as Error).message}`, 'error')
294
- })
295
- }, [cwd, pushNote])
296
-
297
- const beginCompactionUi = useCallback((kind: CompactionKind, sourceSessionId: string): CompactionUiState => {
298
- const progressRowId = nextRowId()
299
- const state: CompactionUiState = {
300
- kind,
301
- progressRowId,
302
- sourceSessionId,
303
- startedAt: Date.now(),
304
- stage: 'preparing transcript',
305
- controller: new AbortController(),
306
- }
307
- compactionUiRef.current = state
308
- setCompactionUi(state)
309
- updateRows(prev => [
310
- ...prev,
311
- {
312
- role: 'progress',
313
- id: progressRowId,
314
- title: kind === 'plan' ? 'summarizing plan context' : 'Compacting conversation',
315
- progress: 0,
316
- status: state.stage,
317
- suffix: 'esc to cancel',
318
- indeterminate: true,
319
- startedAt: state.startedAt,
320
- },
321
- ])
322
- return state
323
- }, [updateRows])
324
-
325
- const updateCompactionStage = useCallback((state: CompactionUiState, stage: string) => {
326
- setCompactionUi(prev => prev?.progressRowId === state.progressRowId ? { ...prev, stage } : prev)
327
- updateRows(prev => prev.map(row =>
328
- row.id === state.progressRowId && row.role === 'progress'
329
- ? { ...row, status: stage }
330
- : row,
331
- ))
332
- }, [updateRows])
333
-
334
- const removeCompactionProgress = useCallback((state: CompactionUiState) => {
335
- updateRows(prev => prev.filter(row => row.id !== state.progressRowId))
336
- }, [updateRows])
337
-
338
- const toggleLatestReasoning = useCallback(() => {
339
- const ids = visibleReasoningIdsRef.current
340
- updateRows(rows => toggleReasoningRow(rows, ids[ids.length - 1]))
341
- }, [updateRows])
342
-
343
- const updateVisibleReasoningIds = useCallback((ids: string[]) => {
344
- visibleReasoningIdsRef.current = ids
345
- }, [])
346
-
347
- const replaceConfig = useCallback(
348
- (next: EthagentConfig) => {
349
- configRef.current = next
350
- providerRef.current = createProvider(next)
351
- setConfig(next)
352
- onReplaceConfig?.(next)
353
- },
354
- [onReplaceConfig],
355
- )
356
-
357
- const clearPendingPlan = useCallback(() => {
358
- pendingPlanRef.current = null
359
- setPendingPlan(null)
360
- if (overlayRef.current === 'planApproval') {
361
- overlayRef.current = 'none'
362
- setOverlay('none')
363
- }
364
- }, [])
365
-
366
- const clearContextLimit = useCallback(() => {
367
- contextLimitStateRef.current = null
368
- setContextLimitState(null)
369
- if (overlayRef.current === 'contextLimit') {
370
- overlayRef.current = 'none'
371
- setOverlay('none')
372
- }
373
- }, [])
374
-
375
- const openModelPicker = useCallback((contextFit?: ModelPickerContextFit | null, pendingPrompt?: string | null) => {
376
- contextModelSwitchPromptRef.current = pendingPrompt ?? null
377
- setModelPickerContextFit(contextFit ?? null)
378
- overlayRef.current = 'modelPicker'
379
- setOverlay('modelPicker')
380
- }, [])
381
-
382
- const handleModelPickerCancel = useCallback(() => {
383
- const hadPendingPrompt = contextModelSwitchPromptRef.current !== null
384
- contextModelSwitchPromptRef.current = null
385
- setModelPickerContextFit(null)
386
- overlayRef.current = 'none'
387
- setOverlay('none')
388
- if (hadPendingPrompt) pushNote('Pending message cancelled.', 'dim')
389
- }, [pushNote])
390
-
391
- const changeCwd = useCallback((next: string) => {
392
- const updated = next === getRuntimeCwd() ? next : setRuntimeCwd(next, cwdRef.current)
393
- cwdRef.current = updated
394
- setCwd(updated)
395
- clearPendingPlan()
396
- setSessionKey(k => k + 1)
397
- }, [clearPendingPlan])
398
-
399
- const clearTranscript = useCallback(() => {
400
- setRows([])
401
- setTurns(0)
402
- setApproxTokens(0)
403
- setActiveContextUsage(contextUsageFromTokens(0, configRef.current.provider, configRef.current.model))
404
- setQueuedInputs([])
405
- clearPendingPlan()
406
- clearContextLimit()
407
- contextModelSwitchPromptRef.current = null
408
- setModelPickerContextFit(null)
409
- sessionMessagesRef.current = []
410
- statsSegmentStartRef.current = 0
411
- historyScopeRef.current = 'global'
412
- setHistory(globalHistoryRef.current)
413
- setStatusStartedAt(Date.now())
414
- const nextId = newSessionId()
415
- sessionIdRef.current = nextId
416
- setSessionId(nextId)
417
- setSessionKey(k => k + 1)
418
- }, [clearContextLimit, clearPendingPlan])
419
-
420
- const doExit = useCallback(() => {
421
- streamAbortRef.current?.abort()
422
- exit()
423
- }, [exit])
424
-
425
- const persistSessionMessage = useCallback(
426
- async (msg: SessionMessage) => {
427
- sessionMessagesRef.current = [...sessionMessagesRef.current, msg]
428
- try {
429
- await appendSessionMessage(sessionIdRef.current, msg, {
430
- cwd: cwdRef.current,
431
- provider: configRef.current.provider,
432
- model: configRef.current.model,
433
- mode: modeRef.current,
434
- })
435
- } catch {
436
- }
437
- },
438
- [],
439
- )
440
-
441
- const refreshVisibleStats = useCallback(
442
- (messages: SessionMessage[], providerSupportsTools: boolean, cwdForStats: string, configForStats: EthagentConfig, modeForStats: SessionMode): ContextUsage => {
443
- const built = buildBaseMessages(messages, configForStats, providerSupportsTools, cwdForStats, modeForStats)
444
- const tokens = approximateTokens(built)
445
- const usage = contextUsageFromTokens(tokens, configForStats.provider, configForStats.model)
446
- setTurns(messages.filter(message => message.role === 'user').length)
447
- setApproxTokens(tokens)
448
- setActiveContextUsage(usage)
449
- return usage
450
- },
451
- [],
452
- )
453
-
454
- useEffect(() => {
455
- if (config.provider !== 'llamacpp') return
456
- const host = llamaCppServerHostFromBaseUrl(localProviderBaseUrlFor('llamacpp', config.baseUrl))
457
- void fetchLlamaCppContextSize(host)
458
- const unsubscribe = onLlamaCppContextSizeChange(() => {
459
- refreshVisibleStats(
460
- sessionMessagesRef.current,
461
- providerRef.current.supportsTools,
462
- cwdRef.current,
463
- configRef.current,
464
- modeRef.current,
465
- )
466
- })
467
- return unsubscribe
468
- }, [config.provider, config.baseUrl, refreshVisibleStats])
469
-
470
- const warnIfContextPressure = useCallback(
471
- (usage: ContextUsage, configForUsage: EthagentConfig) => {
472
- if (!shouldConfirmContextUsage(usage, CONTEXT_CONFIRM_PERCENT)) return
473
- const action = usage.percent >= 100
474
- ? 'New requests will ask you to summarize into a new conversation, switch models, ignore and send, or cancel.'
475
- : 'Run /compact before continuing, keep the next prompt short, switch models, or choose to send despite the warning.'
476
- pushNote(
477
- `current transcript is ${usage.percent}% of ${configForUsage.model}'s context (~${formatTokens(usage.usedTokens)} / ${formatTokens(usage.windowTokens)}). ${action}`,
478
- usage.percent >= 100 ? 'error' : 'dim',
479
- )
480
- },
481
- [pushNote],
482
- )
483
-
484
- const applyConfigChange = useCallback(
485
- (next: EthagentConfig): ContextUsage => {
486
- replaceConfig(next)
487
- const usage = refreshVisibleStats(sessionMessagesRef.current, providerRef.current.supportsTools, cwdRef.current, next, modeRef.current)
488
- warnIfContextPressure(usage, next)
489
- return usage
490
- },
491
- [refreshVisibleStats, replaceConfig, warnIfContextPressure],
492
- )
493
-
494
- const attachActiveTurn = useCallback(<T extends SessionMessage>(message: T): T => {
495
- const turnId = activeCheckpointRef.current?.turnId
496
- if (!turnId) return message
497
- return { ...message, turnId } as T
498
- }, [])
499
-
500
- const runCompaction = useCallback(
501
- async (): Promise<boolean> => {
502
- if (compactingRef.current) return false
503
- const sourceSessionId = sessionIdRef.current
504
- const sourceMessages = sessionMessagesRef.current
505
- const priorMessages: Message[] = buildBaseMessages(
506
- sourceMessages,
507
- configRef.current,
508
- providerRef.current.supportsTools,
509
- cwdRef.current,
510
- modeRef.current,
511
- )
512
- if (priorMessages.length <= 5) {
513
- pushNote('Not enough turns to compact yet.', 'dim')
514
- return false
515
- }
516
- compactingRef.current = true
517
- const compaction = beginCompactionUi('conversation', sourceSessionId)
518
- try {
519
- const result = await compactTranscript(providerRef.current, priorMessages, {
520
- signal: compaction.controller.signal,
521
- onStage: stage => updateCompactionStage(compaction, stage),
522
- })
523
- if (!result.ok && result.cancelled) {
524
- removeCompactionProgress(compaction)
525
- pushNote('Compaction cancelled.', 'dim')
526
- return false
527
- }
528
- const summary = result.ok
529
- ? normalizeHandoffSummary(result.summary)
530
- : normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
531
- if (!result.ok) {
532
- pushNote(`Provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
533
- }
534
-
535
- updateCompactionStage(compaction, 'saving summarized conversation')
536
- const nextSessionId = newSessionId()
537
- const createdAt = nowIso()
538
- const summaryMessage: SessionMessage = {
539
- role: 'user',
540
- synthetic: true,
541
- content: [
542
- `Conversation handoff from ${sourceSessionId.slice(0, 8)}:`,
543
- '',
544
- summary,
545
- ].join('\n'),
546
- createdAt,
547
- }
548
- const acknowledgement: SessionMessage = {
549
- role: 'assistant',
550
- content: 'Ready to continue from this summary.',
551
- createdAt: nowIso(),
552
- model: configRef.current.model,
553
- }
554
-
555
- const context = {
556
- cwd: cwdRef.current,
557
- provider: configRef.current.provider,
558
- model: configRef.current.model,
559
- mode: modeRef.current,
560
- }
561
- await ensureSessionMetadata(nextSessionId, context)
562
- await updateSessionActivity(
563
- nextSessionId,
564
- context,
565
- { compactedFromSessionId: sourceSessionId },
566
- )
567
- await appendSessionMessage(nextSessionId, summaryMessage, context)
568
- await appendSessionMessage(nextSessionId, acknowledgement, context)
569
-
570
- updateCompactionStage(compaction, 'opening summarized conversation')
571
- const nextMessages = [summaryMessage, acknowledgement]
572
- compactionUiRef.current = null
573
- setCompactionUi(null)
574
- sessionIdRef.current = nextSessionId
575
- setSessionId(nextSessionId)
576
- sessionMessagesRef.current = nextMessages
577
- historyScopeRef.current = 'session'
578
- setHistory(promptHistoryFromSessionMessages(nextMessages))
579
- statsSegmentStartRef.current = 0
580
- setRows([
581
- {
582
- role: 'note',
583
- id: nextRowId(),
584
- kind: 'dim',
585
- content: `kept ${sourceSessionId.slice(0, 8)} saved; summarized into ${nextSessionId.slice(0, 8)}.`,
586
- },
587
- ...sessionMessagesToRows(nextMessages, nextRowId),
588
- ])
589
- setQueuedInputs([])
590
- setStatusStartedAt(Date.now())
591
- refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
592
- setSessionKey(key => key + 1)
593
- return true
594
- } catch (err: unknown) {
595
- removeCompactionProgress(compaction)
596
- if (compaction.controller.signal.aborted) {
597
- pushNote('Compaction cancelled.', 'dim')
598
- } else {
599
- pushNote(`Compact error: ${(err as Error).message}`, 'error')
600
- }
601
- return false
602
- } finally {
603
- compactingRef.current = false
604
- compactionUiRef.current = null
605
- setCompactionUi(null)
606
- }
607
- },
608
- [beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, updateCompactionStage],
609
- )
610
-
611
- const runCompactionFromTurn = useCallback(
612
- async (turnId: string): Promise<boolean> => {
613
- if (compactingRef.current) return false
614
- const sourceSessionId = sessionIdRef.current
615
- const all = sessionMessagesRef.current
616
- const splitIndex = all.findIndex(m => m.turnId === turnId)
617
- if (splitIndex < 0) {
618
- pushNote('Could not find that prompt to summarize from.', 'error')
619
- return false
620
- }
621
- const before = all.slice(0, splitIndex)
622
- const from = all.slice(splitIndex)
623
- const fromBase = buildBaseMessages(
624
- from,
625
- configRef.current,
626
- providerRef.current.supportsTools,
627
- cwdRef.current,
628
- modeRef.current,
629
- )
630
- if (fromBase.length <= 2) {
631
- pushNote('Not enough messages from that point to summarize.', 'dim')
632
- return false
633
- }
634
- compactingRef.current = true
635
- const compaction = beginCompactionUi('conversation', sourceSessionId)
636
- try {
637
- const result = await compactTranscript(providerRef.current, fromBase, {
638
- signal: compaction.controller.signal,
639
- onStage: stage => updateCompactionStage(compaction, stage),
640
- })
641
- if (!result.ok && result.cancelled) {
642
- removeCompactionProgress(compaction)
643
- pushNote('Compaction cancelled.', 'dim')
644
- return false
645
- }
646
- const summary = result.ok
647
- ? normalizeHandoffSummary(result.summary)
648
- : normalizeHandoffSummary(summarizeTranscriptLocally(fromBase, result.reason))
649
- if (!result.ok) {
650
- pushNote(`Provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
651
- }
652
- updateCompactionStage(compaction, 'saving summarized conversation')
653
- const nextSessionId = newSessionId()
654
- const summaryMessage: SessionMessage = {
655
- role: 'user',
656
- synthetic: true,
657
- content: [
658
- `Conversation handoff from ${sourceSessionId.slice(0, 8)} (summarized from a chosen prompt forward):`,
659
- '',
660
- summary,
661
- ].join('\n'),
662
- createdAt: nowIso(),
663
- }
664
- const acknowledgement: SessionMessage = {
665
- role: 'assistant',
666
- content: 'Ready to continue from this summary.',
667
- createdAt: nowIso(),
668
- model: configRef.current.model,
669
- }
670
- const context = {
671
- cwd: cwdRef.current,
672
- provider: configRef.current.provider,
673
- model: configRef.current.model,
674
- mode: modeRef.current,
675
- }
676
- await ensureSessionMetadata(nextSessionId, context)
677
- await updateSessionActivity(nextSessionId, context, { compactedFromSessionId: sourceSessionId })
678
- for (const msg of before) {
679
- await appendSessionMessage(nextSessionId, msg, context)
680
- }
681
- await appendSessionMessage(nextSessionId, summaryMessage, context)
682
- await appendSessionMessage(nextSessionId, acknowledgement, context)
683
-
684
- updateCompactionStage(compaction, 'opening summarized conversation')
685
- const nextMessages: SessionMessage[] = [...before, summaryMessage, acknowledgement]
686
- compactionUiRef.current = null
687
- setCompactionUi(null)
688
- sessionIdRef.current = nextSessionId
689
- setSessionId(nextSessionId)
690
- sessionMessagesRef.current = nextMessages
691
- historyScopeRef.current = 'session'
692
- setHistory(promptHistoryFromSessionMessages(nextMessages))
693
- statsSegmentStartRef.current = 0
694
- setRows([
695
- {
696
- role: 'note',
697
- id: nextRowId(),
698
- kind: 'dim',
699
- content: `kept ${sourceSessionId.slice(0, 8)} saved; summarized from a chosen prompt into ${nextSessionId.slice(0, 8)}.`,
700
- },
701
- ...sessionMessagesToRows(nextMessages, nextRowId),
702
- ])
703
- setQueuedInputs([])
704
- setStatusStartedAt(Date.now())
705
- refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
706
- setSessionKey(key => key + 1)
707
- return true
708
- } catch (err: unknown) {
709
- removeCompactionProgress(compaction)
710
- if (compaction.controller.signal.aborted) {
711
- pushNote('Compaction cancelled.', 'dim')
712
- } else {
713
- pushNote(`Compact error: ${(err as Error).message}`, 'error')
714
- }
715
- return false
716
- } finally {
717
- compactingRef.current = false
718
- compactionUiRef.current = null
719
- setCompactionUi(null)
720
- }
721
- },
722
- [beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, updateCompactionStage],
723
- )
724
-
725
- const assistantTurns = useCallback((): string[] => {
726
- const out: string[] = []
727
- for (const message of sessionMessagesRef.current) {
728
- if (message.role === 'assistant' && message.content) out.push(message.content)
729
- }
730
- return out
731
- }, [])
732
-
733
- const buildSlashContext = useCallback(
734
- (): SlashContext => ({
735
- config: configRef.current,
736
- turns,
737
- approxTokens,
738
- contextUsage: activeContextUsage,
739
- startedAt: statusStartedAt,
740
- sessionId: sessionIdRef.current,
741
- cwd,
742
- sessionMessages: () => sessionMessagesRef.current,
743
- mode,
744
- assistantTurns,
745
- onReplaceConfig: applyConfigChange,
746
- onChangeCwd: changeCwd,
747
- onClear: clearTranscript,
748
- onExit: doExit,
749
- onResumeRequest: () => setOverlay('resume'),
750
- onModelPickerRequest: () => openModelPicker(),
751
- onRewindRequest: () => setOverlay('rewind'),
752
- onPermissionsRequest: () => setOverlay('permissions'),
753
- onCompactRequest: () => { void runCompaction() },
754
- onIdentityRequest: action => {
755
- void (async () => {
756
- const status = await getIdentityStatus(configRef.current)
757
- const initialAction = action === 'create' || action === 'load' ? action : undefined
758
- setIdentityOverlay({
759
- initialAction,
760
- existing: status ? { address: status.address } : null,
761
- })
762
- setOverlay('identity')
763
- })()
764
- },
765
- onCopyPickerRequest: (turnText, turnLabel) => {
766
- setCopyPickerState({ turnText, turnLabel })
767
- setOverlay('copyPicker')
768
- },
769
- mcp: mcpManagerRef.current ?? undefined,
770
- }),
771
- [
772
- turns,
773
- approxTokens,
774
- statusStartedAt,
775
- assistantTurns,
776
- applyConfigChange,
777
- changeCwd,
778
- clearTranscript,
779
- doExit,
780
- openModelPicker,
781
- runCompaction,
782
- cwd,
783
- mode,
784
- activeContextUsage,
785
- ],
786
- )
787
-
788
- const requestPermission = useCallback(
789
- async (request: PermissionRequest): Promise<PermissionDecision> => {
790
- setPermissionRequest(request)
791
- setOverlay('permission')
792
- return await new Promise<PermissionDecision>(resolve => {
793
- permissionResolveRef.current = resolve
794
- })
795
- },
796
- [],
797
- )
798
-
799
- const resolvePermission = useCallback((decision: PermissionDecision) => {
800
- const resolve = permissionResolveRef.current
801
- permissionResolveRef.current = null
802
- setPermissionRequest(null)
803
- setOverlay('none')
804
- resolve?.(decision)
805
- }, [])
806
-
807
- const executeTool = useCallback(
808
- async (
809
- name: string,
810
- input: Record<string, unknown>,
811
- permissionMode: PermissionMode,
812
- ): Promise<{ result: { ok: boolean; summary: string; content: string }; sessionRule?: SessionPermissionRule; persistRule?: boolean }> => {
813
- const outcome = await executeToolWithPermissions({
814
- name,
815
- input,
816
- permissionMode,
817
- cwd: cwdRef.current,
818
- config: configRef.current,
819
- checkpoint: activeCheckpointRef.current,
820
- abortSignal: streamAbortRef.current?.signal,
821
- dynamicTools: mcpManagerRef.current?.getTools() ?? [],
822
- mcp: mcpManagerRef.current ?? undefined,
823
- getPermissionRules: () => permissionRulesRef.current,
824
- requestPermission,
825
- onDirectoryChange: next => {
826
- cwdRef.current = next
827
- setCwd(next)
828
- },
829
- })
830
- const review = privateContinuityEditReviewFromToolResult(name, input, outcome.result)
831
- if (review) pendingContinuityEditReviewRef.current = review
832
- return outcome
833
- },
834
- [requestPermission],
835
- )
836
-
837
- const applySessionRule = useCallback(
838
- async (sessionRule?: SessionPermissionRule, persistRule?: boolean) => {
839
- if (!sessionRule) return
840
- permissionRulesRef.current = [...permissionRulesRef.current, sessionRule]
841
- if (!persistRule) return
842
- try {
843
- await savePermissionRule(cwdRef.current, sessionRule)
844
- } catch (error: unknown) {
845
- pushNote(`Failed to save permission rule: ${(error as Error).message}`, 'error')
846
- }
847
- },
848
- [pushNote],
849
- )
850
-
851
- const runStream = useCallback(
852
- async (userText: string, modeOverride?: SessionMode) => {
853
- const activeMode = modeOverride ?? mode
854
- const turnProvider = createProvider(configRef.current, {
855
- mode: activeMode,
856
- dynamicTools: mcpManagerRef.current?.getTools() ?? [],
857
- })
858
- const controller = new AbortController()
859
- streamAbortRef.current = controller
860
- let planCandidate: PendingPlan | null = null
861
- const setStreamingWithStart = (value: boolean) => {
862
- setStreaming(value)
863
- setStreamingStartedAt(value ? Date.now() : null)
864
- }
865
- const result = await runStreamingTurn({
866
- provider: turnProvider,
867
- mode: activeMode,
868
- sessionId: sessionIdRef.current,
869
- userText,
870
- streamFlushMs: STREAM_FLUSH_MS,
871
- controller,
872
- nextRowId,
873
- nowIso,
874
- getConfig: () => configRef.current,
875
- getCwd: () => cwdRef.current,
876
- getDisplayCwd: () => compressHome(cwdRef.current),
877
- getSessionMessages: () => sessionMessagesRef.current,
878
- setActiveCheckpoint: checkpoint => { activeCheckpointRef.current = checkpoint },
879
- setStreaming: setStreamingWithStart,
880
- updateRows,
881
- pushNote,
882
- persistTurnMessage: message => persistSessionMessage(attachActiveTurn(message)),
883
- executeTool,
884
- applySessionRule,
885
- preflightProvider: () => ensureLocalProviderReady(configRef.current),
886
- onPlanReady: plan => {
887
- planCandidate = {
888
- text: plan,
889
- cwd: cwdRef.current,
890
- sessionId: sessionIdRef.current,
891
- provider: configRef.current.provider,
892
- model: configRef.current.model,
893
- contextLabel: formatContextLabel(
894
- contextUsage(buildBaseMessages(sessionMessagesRef.current, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode), configRef.current.provider, configRef.current.model),
895
- ),
896
- awaitingApproval: true,
897
- }
898
- pendingPlanRef.current = planCandidate
899
- setPendingPlan(planCandidate)
900
- },
901
- onContextExceeded: ({ contextLimit }) => {
902
- setCachedLlamaCppContextSize(contextLimit)
903
- pushNote('Context full. Compacting transcript. Re-send your message once compaction finishes.', 'dim')
904
- void runCompaction()
905
- },
906
- pendingAssistantTextRef,
907
- pendingThinkingTextRef,
908
- streamFlushTimerRef,
909
- })
910
- refreshVisibleStats(sessionMessagesRef.current, turnProvider.supportsTools, cwdRef.current, configRef.current, activeMode)
911
- streamAbortRef.current = null
912
- if (
913
- result.finishedNormally &&
914
- activeMode === 'plan' &&
915
- planCandidate &&
916
- pendingPlanRef.current === planCandidate &&
917
- overlayRef.current === 'none'
918
- ) {
919
- overlayRef.current = 'planApproval'
920
- setOverlay('planApproval')
921
- }
922
- },
923
- [applySessionRule, attachActiveTurn, executeTool, mode, persistSessionMessage, pushNote, refreshVisibleStats, updateRows],
924
- )
925
-
926
- const pullInFlight = false
927
-
928
- useEffect(() => {
929
- if (overlay !== 'none' || streaming || pullInFlight || compactionUi) return
930
- const pending = pendingContinuityEditReviewRef.current
931
- if (!pending) return
932
- pendingContinuityEditReviewRef.current = null
933
- setContinuityEditReview(pending)
934
- overlayRef.current = 'continuityEditReview'
935
- setOverlay('continuityEditReview')
936
- }, [compactionUi, overlay, pullInFlight, streaming])
937
-
938
- const projectedUsageForInput = useCallback((userText: string, modeOverride?: SessionMode): ContextUsage => {
939
- const activeMode = modeOverride ?? modeRef.current
940
- const turnProvider = createProvider(configRef.current, {
941
- mode: activeMode,
942
- dynamicTools: mcpManagerRef.current?.getTools() ?? [],
943
- })
944
- const projectedMessages: SessionMessage[] = [
945
- ...sessionMessagesRef.current,
946
- { role: 'user', content: userText, createdAt: nowIso() },
947
- ]
948
- return contextUsage(
949
- buildBaseMessages(projectedMessages, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode),
950
- configRef.current.provider,
951
- configRef.current.model,
952
- )
953
- }, [])
954
-
955
- const showContextLimitForPrompt = useCallback((prompt: string): ContextUsage => {
956
- contextModelSwitchPromptRef.current = null
957
- setModelPickerContextFit(null)
958
- const projected = projectedUsageForInput(prompt)
959
- contextLimitStateRef.current = { usage: projected, prompt }
960
- setContextLimitState(contextLimitStateRef.current)
961
- overlayRef.current = 'contextLimit'
962
- setOverlay('contextLimit')
963
- return projected
964
- }, [projectedUsageForInput])
965
-
966
- const continuePendingPromptAfterModelSwitch = useCallback(
967
- async (prompt: string | null) => {
968
- if (!prompt) {
969
- setModelPickerContextFit(null)
970
- return
971
- }
972
- contextModelSwitchPromptRef.current = null
973
- setModelPickerContextFit(null)
974
- const projected = projectedUsageForInput(prompt)
975
- if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
976
- contextLimitStateRef.current = { usage: projected, prompt }
977
- setContextLimitState(contextLimitStateRef.current)
978
- overlayRef.current = 'contextLimit'
979
- setOverlay('contextLimit')
980
- pushNote(
981
- `selected model is still ${projected.percent}% of its context (~${formatTokens(projected.usedTokens)} / ${formatTokens(projected.windowTokens)}).`,
982
- projected.percent >= 100 ? 'error' : 'dim',
983
- )
984
- return
985
- }
986
- await runStream(prompt)
987
- },
988
- [projectedUsageForInput, pushNote, runStream],
989
- )
990
-
991
- const handleSubmit = useCallback(
992
- async (value: string) => {
993
- const trimmed = value.trim()
994
- if (!trimmed) return
995
-
996
- setHistory(h => appendPromptHistoryEntry(h, value))
997
- globalHistoryRef.current = appendPromptHistoryEntry(globalHistoryRef.current, value)
998
- void appendHistory(value)
999
-
1000
- if (streaming || pullInFlight || compactionUiRef.current) {
1001
- setQueuedInputs(prev => [...prev, value])
1002
- return
1003
- }
1004
-
1005
- if (parseSlash(value)) {
1006
- const ctx = buildSlashContext()
1007
- const result = await dispatchSlash(value, ctx)
1008
- if (result && result.kind === 'note') {
1009
- pushNote(result.text, result.variant ?? 'info')
1010
- }
1011
- if (result && result.kind === 'submit') {
1012
- const projected = projectedUsageForInput(result.text)
1013
- if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
1014
- showContextLimitForPrompt(result.text)
1015
- return
1016
- }
1017
- await runStream(result.text)
1018
- }
1019
- return
1020
- }
1021
-
1022
- const projected = projectedUsageForInput(value)
1023
- if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
1024
- showContextLimitForPrompt(value)
1025
- return
1026
- }
1027
-
1028
- await runStream(value)
1029
- },
1030
- [buildSlashContext, pullInFlight, projectedUsageForInput, pushNote, runStream, showContextLimitForPrompt, streaming],
1031
- )
1032
-
1033
- const handleContextLimitCancel = useCallback(() => {
1034
- clearContextLimit()
1035
- pushNote('Pending message cancelled.', 'dim')
1036
- }, [clearContextLimit, pushNote])
1037
-
1038
- const handleContextLimitAction = useCallback(
1039
- async (action: ContextLimitAction) => {
1040
- const state = contextLimitStateRef.current
1041
- if (!state) {
1042
- clearContextLimit()
1043
- return
1044
- }
1045
- const prompt = state.prompt
1046
- clearContextLimit()
1047
- if (action === 'cancel') {
1048
- pushNote('Pending message cancelled.', 'dim')
1049
- return
1050
- }
1051
- if (action === 'switchModel') {
1052
- openModelPicker(
1053
- { usedTokens: state.usage.usedTokens, thresholdPercent: CONTEXT_CONFIRM_PERCENT },
1054
- prompt,
1055
- )
1056
- return
1057
- }
1058
- if (action === 'compact') {
1059
- const compacted = await runCompaction()
1060
- if (!compacted) return
1061
- setHistory(h => appendPromptHistoryEntry(h, prompt))
1062
- }
1063
- if (action === 'send') {
1064
- pushNote(
1065
- 'sending despite context warning; this may hit provider rate/context limits faster or degrade model/tool behavior.',
1066
- 'dim',
1067
- )
1068
- }
1069
- await runStream(prompt)
1070
- },
1071
- [clearContextLimit, openModelPicker, pushNote, runCompaction, runStream],
1072
- )
1073
-
1074
- const handleCancelActive = useCallback(() => {
1075
- if (streaming && streamAbortRef.current) {
1076
- streamAbortRef.current.abort()
1077
- return
1078
- }
1079
- compactionUiRef.current?.controller.abort()
1080
- }, [streaming])
1081
-
1082
- useCancelRequest({
1083
- abortSignal: streaming ? streamAbortRef.current?.signal : compactionUi?.controller.signal,
1084
- onCancel: handleCancelActive,
1085
- isActive: overlay === 'none',
1086
- })
1087
-
1088
- const exitState = useExitOnCtrlC({
1089
- isActive: overlay === 'none',
1090
- onInterrupt: () => {
1091
- if (streaming && streamAbortRef.current) {
1092
- streamAbortRef.current.abort()
1093
- return true
1094
- }
1095
- if (compactionUiRef.current) {
1096
- compactionUiRef.current.controller.abort()
1097
- return true
1098
- }
1099
- return false
1100
- },
1101
- onExit: doExit,
1102
- })
1103
-
1104
- useKeybinding(
1105
- 'chat:modelPicker',
1106
- () => { if (overlay === 'none') openModelPicker() },
1107
- { context: 'Chat', isActive: overlay === 'none' },
1108
- )
1109
-
1110
- useKeybinding(
1111
- 'chat:identityHub',
1112
- () => {
1113
- if (overlay !== 'none') return
1114
- setIdentityOverlay({
1115
- initialAction: undefined,
1116
- existing: configRef.current.identity ? { address: configRef.current.identity.address } : null,
1117
- })
1118
- setOverlay('identity')
1119
- },
1120
- { context: 'Chat', isActive: overlay === 'none' },
1121
- )
1122
-
1123
- useKeybinding(
1124
- 'chat:toggleReasoning',
1125
- () => { if (overlay === 'none') toggleLatestReasoning() },
1126
- { context: 'Chat', isActive: overlay === 'none' },
1127
- )
1128
-
1129
- useKeybinding(
1130
- 'chat:cycleMode',
1131
- () => {
1132
- if (overlay !== 'none') return
1133
- const nextMode = nextSessionMode(mode)
1134
- modeRef.current = nextMode
1135
- setMode(nextMode)
1136
- if (nextMode !== 'plan') clearPendingPlan()
1137
- },
1138
- { context: 'Chat', isActive: overlay === 'none' },
1139
- )
1140
-
1141
- useKeybinding(
1142
- 'app:redraw',
1143
- () => setSessionKey(k => k + 1),
1144
- { context: 'Global' },
1145
- )
1146
-
1147
- const handleModelPick = useCallback(
1148
- async (sel: ModelPickerSelection) => {
1149
- const pendingPrompt = contextModelSwitchPromptRef.current
1150
- overlayRef.current = 'none'
1151
- setOverlay('none')
1152
- const resolution = resolveModelSelection(sel, configRef.current)
1153
- if (resolution.kind === 'noop') {
1154
- if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
1155
- return
1156
- }
1157
- try {
1158
- await saveConfig(resolution.config)
1159
- applyConfigChange(resolution.config)
1160
- pushNote(resolution.notice, resolution.tone)
1161
- await continuePendingPromptAfterModelSwitch(pendingPrompt)
1162
- } catch (err: unknown) {
1163
- pushNote(`Provider switch failed: ${(err as Error).message}`, 'error')
1164
- if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
1165
- }
1166
- },
1167
- [applyConfigChange, continuePendingPromptAfterModelSwitch, pushNote, showContextLimitForPrompt],
1168
- )
1169
-
1170
- const handleResumePick = useCallback(
1171
- async (id: string) => {
1172
- setOverlay('none')
1173
- try {
1174
- const [loaded, metadata] = await Promise.all([loadSession(id), loadSessionMetadata(id)])
1175
- if (loaded.length === 0) {
1176
- pushNote('Session was empty.', 'error')
1177
- return
1178
- }
1179
- const resumed = buildResumedSessionState({
1180
- messages: loaded,
1181
- metadata,
1182
- fallbackCwd: cwd,
1183
- nextRowId,
1184
- })
1185
- const resumedCwd = resumed.cwd
1186
- if (resumedCwd) {
1187
- try {
1188
- const updated = setRuntimeCwd(resumedCwd)
1189
- cwdRef.current = updated
1190
- setCwd(updated)
1191
- } catch {
1192
- cwdRef.current = resumedCwd
1193
- setCwd(resumedCwd)
1194
- }
1195
- }
1196
- clearPendingPlan()
1197
- clearContextLimit()
1198
- modeRef.current = resumed.mode
1199
- setMode(resumed.mode)
1200
- sessionIdRef.current = id
1201
- setSessionId(id)
1202
- sessionMessagesRef.current = loaded
1203
- historyScopeRef.current = 'session'
1204
- setHistory(resumed.promptHistory)
1205
- statsSegmentStartRef.current = 0
1206
- setStatusStartedAt(resumed.statusStartedAt)
1207
- setRows(resumed.rows)
1208
- refreshVisibleStats(loaded, providerRef.current.supportsTools, resumedCwd, configRef.current, resumed.mode)
1209
- setSessionKey(k => k + 1)
1210
- } catch (err: unknown) {
1211
- pushNote(`Resume failed: ${(err as Error).message}`, 'error')
1212
- }
1213
- },
1214
- [clearContextLimit, clearPendingPlan, cwd, pushNote, refreshVisibleStats],
1215
- )
1216
-
1217
- const handleResumeClearAll = useCallback(
1218
- async () => {
1219
- await clearAllSessions()
1220
- clearTranscript()
1221
- overlayRef.current = 'none'
1222
- setOverlay('none')
1223
- pushNote('Cleared saved sessions and resume context from this machine.', 'dim')
1224
- },
1225
- [clearTranscript, pushNote],
1226
- )
1227
-
1228
- const handleIdentityResult = useCallback(
1229
- (result: IdentityHubResult) => {
1230
- setOverlay('none')
1231
- setIdentityOverlay(null)
1232
- if (result.kind === 'updated') {
1233
- applyConfigChange(result.config)
1234
- pushNote(result.message, 'info')
1235
- return
1236
- }
1237
- if (result.kind === 'token') {
1238
- void (async () => {
1239
- try {
1240
- const nextConfig = await setTokenIdentity(configRef.current, result.identity)
1241
- applyConfigChange(nextConfig)
1242
- pushNote(`Identity saved · ERC-8004 #${result.identity.agentId}`, 'info')
1243
- } catch (err: unknown) {
1244
- pushNote(`Identity save failed: ${(err as Error).message}`, 'error')
1245
- }
1246
- })()
1247
- }
1248
- },
1249
- [applyConfigChange, pushNote],
1250
- )
1251
-
1252
- const handleContinuityEditReviewAction = useCallback(
1253
- async (action: ContinuityEditReviewAction) => {
1254
- const review = continuityEditReview
1255
- if (!review) return
1256
- if (action === 'open') {
1257
- const result = await openFileInEditor(review.filePath)
1258
- pushNote(
1259
- result.ok
1260
- ? `opened ${review.file} with ${result.method}.`
1261
- : `open failed: ${result.error}`,
1262
- result.ok ? 'dim' : 'error',
1263
- )
1264
- if (result.ok) setContinuityEditReview(prev => prev ? { ...prev, editorOpened: true } : null)
1265
- return
1266
- }
1267
- setContinuityEditReview(null)
1268
- if (action === 'save-publish') {
1269
- const status = await getIdentityStatus(configRef.current)
1270
- setIdentityOverlay({
1271
- initialAction: 'save-snapshot',
1272
- existing: status ? { address: status.address } : null,
1273
- })
1274
- overlayRef.current = 'identity'
1275
- setOverlay('identity')
1276
- pushNote('Opening snapshot signature.', 'dim')
1277
- return
1278
- }
1279
- overlayRef.current = 'none'
1280
- setOverlay('none')
1281
- pushNote('Snapshot not saved yet.', 'dim')
1282
- },
1283
- [continuityEditReview, pushNote],
1284
- )
1285
-
1286
- const handleContinuityEditReviewCancel = useCallback(() => {
1287
- setContinuityEditReview(null)
1288
- overlayRef.current = 'none'
1289
- setOverlay('none')
1290
- pushNote('Snapshot not saved yet.', 'dim')
1291
- }, [pushNote])
1292
-
1293
- const handleCopyDone = useCallback(
1294
- (result: CopyResult, label: string) => {
1295
- setOverlay('none')
1296
- setCopyPickerState(null)
1297
- if (result.ok) {
1298
- pushNote(`${label} copied to clipboard · ${result.chars} chars`, 'dim')
1299
- } else {
1300
- pushNote(`Copy failed: ${result.error}`, 'error')
1301
- }
1302
- },
1303
- [pushNote],
1304
- )
1305
-
1306
- const handleCopyCancel = useCallback(() => {
1307
- setOverlay('none')
1308
- setCopyPickerState(null)
1309
- pushNote('Copy cancelled.', 'dim')
1310
- }, [pushNote])
1311
-
1312
- const handleRestoreConversation = useCallback((turnId: string, promptText?: string) => {
1313
- const restored = restoreConversationState(sessionMessagesRef.current, turnId, nextRowId)
1314
- sessionMessagesRef.current = restored.messages
1315
- setRows(restored.rows)
1316
- historyScopeRef.current = 'session'
1317
- setHistory(restored.promptHistory)
1318
- if (promptText != null && promptText.length > 0) {
1319
- setPendingInputDraft(promptText)
1320
- }
1321
- if (restored.truncated) {
1322
- setQueuedInputs([])
1323
- statsSegmentStartRef.current = Math.min(statsSegmentStartRef.current, restored.messages.length)
1324
- refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
1325
- setSessionKey(key => key + 1)
1326
- return
1327
- }
1328
- refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
1329
- }, [mode, refreshVisibleStats])
1330
-
1331
- const startFreshImplementationContext = useCallback(() => {
1332
- const nextSessionId = newSessionId()
1333
- sessionMessagesRef.current = []
1334
- statsSegmentStartRef.current = 0
1335
- sessionIdRef.current = nextSessionId
1336
- historyScopeRef.current = 'global'
1337
- setHistory(globalHistoryRef.current)
1338
- setSessionId(nextSessionId)
1339
- setRows([])
1340
- setTurns(0)
1341
- setApproxTokens(0)
1342
- setQueuedInputs([])
1343
- setStatusStartedAt(Date.now())
1344
- setSessionKey(key => key + 1)
1345
- }, [])
1346
-
1347
- const startSummarizedPlanImplementationContext = useCallback(
1348
- async (plan: string): Promise<boolean> => {
1349
- if (compactingRef.current) return false
1350
-
1351
- const sourceSessionId = sessionIdRef.current
1352
- const priorMessages = buildBaseMessages(
1353
- sessionMessagesRef.current,
1354
- configRef.current,
1355
- providerRef.current.supportsTools,
1356
- cwdRef.current,
1357
- modeRef.current,
1358
- )
1359
-
1360
- if (priorMessages.length <= 5) {
1361
- startFreshImplementationContext()
1362
- pushNote('Not enough planning context to summarize; starting a plan-only implementation conversation.', 'dim')
1363
- return true
1364
- }
1365
-
1366
- compactingRef.current = true
1367
- const compaction = beginCompactionUi('plan', sourceSessionId)
1368
- try {
1369
- const result = await compactTranscript(providerRef.current, priorMessages, {
1370
- signal: compaction.controller.signal,
1371
- onStage: stage => updateCompactionStage(compaction, stage),
1372
- })
1373
- if (!result.ok && result.cancelled) {
1374
- removeCompactionProgress(compaction)
1375
- pushNote('Plan context summary cancelled.', 'dim')
1376
- return false
1377
- }
1378
- const summary = result.ok
1379
- ? normalizeHandoffSummary(result.summary)
1380
- : normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
1381
- if (!result.ok) {
1382
- pushNote(`Provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
1383
- }
1384
-
1385
- updateCompactionStage(compaction, 'saving summarized conversation')
1386
- const nextSessionId = newSessionId()
1387
- const createdAt = nowIso()
1388
- const nextMessages = buildPlanTransferSeedMessages({
1389
- sourceSessionId,
1390
- summary,
1391
- plan,
1392
- createdAt,
1393
- })
1394
- const context = {
1395
- cwd: cwdRef.current,
1396
- provider: configRef.current.provider,
1397
- model: configRef.current.model,
1398
- mode: modeRef.current,
1399
- }
1400
-
1401
- await ensureSessionMetadata(nextSessionId, context)
1402
- await updateSessionActivity(nextSessionId, context, { compactedFromSessionId: sourceSessionId })
1403
- for (const message of nextMessages) {
1404
- await appendSessionMessage(nextSessionId, message, context)
1405
- }
1406
-
1407
- updateCompactionStage(compaction, 'opening summarized conversation')
1408
- compactionUiRef.current = null
1409
- setCompactionUi(null)
1410
- sessionIdRef.current = nextSessionId
1411
- setSessionId(nextSessionId)
1412
- sessionMessagesRef.current = nextMessages
1413
- historyScopeRef.current = 'session'
1414
- setHistory(promptHistoryFromSessionMessages(nextMessages))
1415
- statsSegmentStartRef.current = 0
1416
- setRows([
1417
- {
1418
- role: 'note',
1419
- id: nextRowId(),
1420
- kind: 'dim',
1421
- content: `kept ${sourceSessionId.slice(0, 8)} saved; transferred plan into ${nextSessionId.slice(0, 8)}.`,
1422
- },
1423
- ...sessionMessagesToRows(nextMessages, nextRowId),
1424
- ])
1425
- setQueuedInputs([])
1426
- setStatusStartedAt(Date.now())
1427
- refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
1428
- setSessionKey(key => key + 1)
1429
- return true
1430
- } catch (err: unknown) {
1431
- removeCompactionProgress(compaction)
1432
- if (compaction.controller.signal.aborted) {
1433
- pushNote('Plan context summary cancelled.', 'dim')
1434
- } else {
1435
- pushNote(`Context summary error: ${(err as Error).message}`, 'error')
1436
- }
1437
- return false
1438
- } finally {
1439
- compactingRef.current = false
1440
- compactionUiRef.current = null
1441
- setCompactionUi(null)
1442
- }
1443
- },
1444
- [beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, startFreshImplementationContext, updateCompactionStage],
1445
- )
1446
-
1447
- const handlePlanApprovalCancel = useCallback(() => {
1448
- const plan = pendingPlanRef.current
1449
- if (plan) {
1450
- const next = { ...plan, awaitingApproval: false }
1451
- pendingPlanRef.current = next
1452
- setPendingPlan(next)
1453
- }
1454
- if (overlayRef.current === 'planApproval') {
1455
- overlayRef.current = 'none'
1456
- setOverlay('none')
1457
- }
1458
- }, [])
1459
-
1460
- const handlePlanApproval = useCallback(
1461
- async (action: PlanApprovalAction) => {
1462
- const plan = pendingPlanRef.current
1463
- if (!plan) {
1464
- handlePlanApprovalCancel()
1465
- return
1466
- }
1467
- if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
1468
- clearPendingPlan()
1469
- pushNote('Dismissed stale plan approval because the workspace changed.', 'dim')
1470
- return
1471
- }
1472
- if (action === 'continue') {
1473
- handlePlanApprovalCancel()
1474
- return
1475
- }
1476
-
1477
- const nextMode: SessionMode = 'accept-edits'
1478
- if (action === 'apply-summary') {
1479
- const transferred = await startSummarizedPlanImplementationContext(plan.text)
1480
- if (!transferred) return
1481
- }
1482
- clearPendingPlan()
1483
- modeRef.current = nextMode
1484
- setMode(nextMode)
1485
- await runStream(buildPlanImplementationPrompt(plan.text), nextMode)
1486
- },
1487
- [
1488
- clearPendingPlan,
1489
- handlePlanApprovalCancel,
1490
- pushNote,
1491
- runStream,
1492
- startSummarizedPlanImplementationContext,
1493
- ],
1494
- )
1495
-
1496
- const busy = streaming || pullInFlight || Boolean(compactionUi)
1497
-
1498
- useEffect(() => {
1499
- if (!busy) {
1500
- setTerminalTitle(TITLE_STATIC)
1501
- return
1502
- }
1503
- let i = 0
1504
- setTerminalTitle(TITLE_ANIMATION_FRAMES[0])
1505
- const id = setInterval(() => {
1506
- i = (i + 1) % TITLE_ANIMATION_FRAMES.length
1507
- setTerminalTitle(TITLE_ANIMATION_FRAMES[i] ?? TITLE_STATIC)
1508
- }, TITLE_ANIMATION_INTERVAL_MS)
1509
- return () => clearInterval(id)
1510
- }, [busy])
1511
-
1512
- const slashSuggestions = useMemo(
1513
- () => getSlashSuggestions(mcpManagerRef.current?.getPromptSuggestions() ?? []),
1514
- [mcpSnapshot],
1515
- )
1516
-
1517
- useEffect(() => {
1518
- const plan = pendingPlanRef.current
1519
- if (!plan?.awaitingApproval) return
1520
- if (mode !== 'plan' || overlay !== 'none' || streaming || pullInFlight || compactionUi) return
1521
- if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
1522
- clearPendingPlan()
1523
- return
1524
- }
1525
- overlayRef.current = 'planApproval'
1526
- setOverlay('planApproval')
1527
- }, [clearPendingPlan, compactionUi, mode, overlay, pullInFlight, streaming])
1528
-
1529
- useEffect(() => {
1530
- if (overlay !== 'none') return
1531
- if (streaming || pullInFlight || compactionUi || queuedInputs.length === 0 || drainingQueueRef.current) return
1532
- drainingQueueRef.current = true
1533
- const next = queuedInputs[0]
1534
- setQueuedInputs(prev => prev.slice(1))
1535
- void (async () => {
1536
- if (!next) return
1537
- if (parseSlash(next)) {
1538
- const ctx = buildSlashContext()
1539
- const result = await dispatchSlash(next, ctx)
1540
- if (result && result.kind === 'note') {
1541
- pushNote(result.text, result.variant ?? 'info')
1542
- }
1543
- if (result && result.kind === 'submit') {
1544
- const projected = projectedUsageForInput(result.text)
1545
- if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
1546
- showContextLimitForPrompt(result.text)
1547
- return
1548
- }
1549
- await runStream(result.text)
1550
- }
1551
- return
1552
- }
1553
- const projected = projectedUsageForInput(next)
1554
- if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
1555
- showContextLimitForPrompt(next)
1556
- return
1557
- }
1558
- await runStream(next)
1559
- })().finally(() => {
1560
- drainingQueueRef.current = false
1561
- })
1562
- }, [compactionUi, overlay, projectedUsageForInput, pullInFlight, pushNote, queuedInputs, runStream, showContextLimitForPrompt, streaming])
1563
-
1564
- const contextLine = `${providerDisplayName(config.provider)} · ${formatModelDisplayName(config.provider, config.model, { maxLength: 24 })} · ${compressHome(cwd)}`
1565
- const tipLine = 'Tip: type /help to get started · shift+enter for newline'
1566
-
1567
- const placeholderHints = useMemo(() => {
1568
- if (compactionUi) return ['compaction in progress · esc to cancel']
1569
- return []
1570
- }, [compactionUi])
1571
-
1572
- const exitHint = exitState.pending ? 'ctrl+c again to quit' : null
1573
- const runtimeModeLabel = sessionModeLabel(mode)
1574
- const runtimeModeColor =
1575
- mode === 'plan'
1576
- ? theme.modePlan
1577
- : mode === 'accept-edits'
1578
- ? theme.modeAcceptEdits
1579
- : theme.text
1580
- const footerRight = (
1581
- <Box flexDirection="row">
1582
- {exitHint ? (
1583
- <>
1584
- <Text color={theme.accentPeriwinkle}>{exitHint}</Text>
1585
- <Text color={theme.dim}> · </Text>
1586
- </>
1587
- ) : null}
1588
- {runtimeModeLabel ? (
1589
- <>
1590
- <Text color={runtimeModeColor} bold>{runtimeModeLabel}</Text>
1591
- <Text color={theme.dim}> (</Text>
1592
- <Text color={theme.accentPeriwinkle}>shift+tab to cycle</Text>
1593
- <Text color={theme.dim}>) · </Text>
1594
- </>
1595
- ) : (
1596
- <>
1597
- <Text color={theme.accentPeriwinkle}>shift+tab to cycle</Text>
1598
- <Text color={theme.dim}> · </Text>
1599
- </>
1600
- )}
1601
- <Text color={theme.dim}>{chatFooterShortcutText(canScrollTranscript)}</Text>
1602
- </Box>
1603
- )
1604
- const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
1605
- return (
1606
- <ConversationStack
1607
- header={header}
1608
- rows={rows}
1609
- transcriptActive={overlay === 'none'}
1610
- bottomVariant={overlay === 'none' ? 'prompt' : 'overlay'}
1611
- bottom={(
1612
- <ChatBottomPane
1613
- overlay={overlay}
1614
- config={config}
1615
- sessionId={sessionId}
1616
- cwd={cwd}
1617
- currentSessionId={sessionId}
1618
- copyPickerState={copyPickerState}
1619
- contextLimitState={contextLimitState}
1620
- continuityEditReview={continuityEditReview}
1621
- modelPickerContextFit={modelPickerContextFit}
1622
- permissionRequest={permissionRequest}
1623
- history={history}
1624
- streaming={streaming}
1625
- streamingStartedAt={streamingStartedAt}
1626
- activity={null}
1627
- placeholderHints={placeholderHints}
1628
- queuedInputs={queuedInputs}
1629
- slashSuggestions={slashSuggestions}
1630
- planApprovalContextLabel={pendingPlan?.contextLabel ?? formatContextLabel(activeContextUsage)}
1631
- footerRight={footerRight}
1632
- handleModelPick={handleModelPick}
1633
- handleModelPickerCancel={handleModelPickerCancel}
1634
- handleResumePick={handleResumePick}
1635
- handleResumeClearAll={handleResumeClearAll}
1636
- identityOverlay={identityOverlay}
1637
- handleIdentityResult={handleIdentityResult}
1638
- handleRestoreConversation={handleRestoreConversation}
1639
- pendingInputDraft={pendingInputDraft}
1640
- onInputDraftConsumed={() => setPendingInputDraft(null)}
1641
- handleSummarizeFromTurn={runCompactionFromTurn}
1642
- handleCopyDone={handleCopyDone}
1643
- handleCopyCancel={handleCopyCancel}
1644
- resolvePermission={resolvePermission}
1645
- handlePlanApproval={handlePlanApproval}
1646
- handlePlanApprovalCancel={handlePlanApprovalCancel}
1647
- handleContextLimitAction={handleContextLimitAction}
1648
- handleContextLimitCancel={handleContextLimitCancel}
1649
- handleContinuityEditReviewAction={handleContinuityEditReviewAction}
1650
- handleContinuityEditReviewCancel={handleContinuityEditReviewCancel}
1651
- onPermissionRulesChanged={rules => { permissionRulesRef.current = rules }}
1652
- onConfigChange={replaceConfig}
1653
- handleSubmit={handleSubmit}
1654
- setOverlay={setOverlay}
1655
- pushNote={pushNote}
1656
- />
1657
- )}
1658
- status={(
1659
- <SessionStatus
1660
- provider={config.provider}
1661
- model={config.model}
1662
- turns={turns}
1663
- approxTokens={approxTokens}
1664
- startedAt={statusStartedAt}
1665
- contextUsage={activeContextUsage}
1666
- />
1667
- )}
1668
- sessionKey={sessionKey}
1669
- onVisibleReasoningIdsChange={updateVisibleReasoningIds}
1670
- onTranscriptScrollabilityChange={setCanScrollTranscript}
1671
- />
1672
- )
1673
- }
1674
-
1675
- function formatContextLabel(usage: ContextUsage): string {
1676
- if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
1677
- return `Estimated context: ${usage.percent}% used`
1678
- }
1679
-
1680
- function appendPromptHistoryEntry(history: string[], value: string): string[] {
1681
- const prompt = value.trim()
1682
- if (!prompt) return history
1683
- const next = history[history.length - 1] === prompt ? history : [...history, prompt]
1684
- return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
1685
- }