ethagent 2.1.1 → 2.3.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 (177) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/auth/openaiOAuth/credentials.ts +47 -0
  5. package/src/auth/openaiOAuth/crypto.ts +23 -0
  6. package/src/auth/openaiOAuth/index.ts +238 -0
  7. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  8. package/src/auth/openaiOAuth/listener.ts +151 -0
  9. package/src/auth/openaiOAuth/refresh.ts +70 -0
  10. package/src/auth/openaiOAuth/shared.ts +115 -0
  11. package/src/chat/ChatBottomPane.tsx +20 -11
  12. package/src/chat/ChatScreen.tsx +160 -35
  13. package/src/chat/ConversationStack.tsx +1 -1
  14. package/src/chat/MessageList.tsx +185 -72
  15. package/src/chat/SessionStatus.tsx +3 -1
  16. package/src/chat/chatScreenUtils.ts +11 -15
  17. package/src/chat/chatSessionState.ts +3 -2
  18. package/src/chat/chatTurnOrchestrator.ts +1 -7
  19. package/src/chat/commands.ts +28 -27
  20. package/src/chat/display/DiffView.tsx +193 -0
  21. package/src/chat/display/SyntaxText.tsx +192 -0
  22. package/src/chat/display/toolCallDisplay.ts +103 -0
  23. package/src/chat/display/toolResultDisplay.ts +19 -0
  24. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  25. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  26. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  27. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  28. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  29. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  30. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  31. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  32. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  33. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  34. package/src/chat/views/RewindView.tsx +410 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  36. package/src/identity/ens/agentRecords.ts +5 -19
  37. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  38. package/src/identity/ens/ensAutomation/types.ts +0 -1
  39. package/src/identity/hub/OperationalRoutes.tsx +23 -32
  40. package/src/identity/hub/Routes.tsx +13 -13
  41. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  42. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  43. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  44. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  45. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +19 -19
  46. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  47. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  48. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  49. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  50. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  51. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +10 -48
  52. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +11 -9
  53. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  54. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  55. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  56. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  57. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +6 -6
  58. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  59. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  60. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +241 -0
  61. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +27 -82
  62. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +25 -65
  63. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -30
  64. package/src/identity/hub/ens/EnsEditRunners.tsx +62 -0
  65. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +15 -14
  66. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +68 -217
  67. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +18 -11
  68. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -48
  69. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  70. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +4 -4
  71. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  72. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  73. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  74. package/src/identity/hub/{effects/ens → ens}/transactions.ts +232 -232
  75. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +12 -26
  76. package/src/identity/hub/identityHubReducer.ts +3 -3
  77. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +17 -10
  78. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +55 -177
  79. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  80. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -173
  81. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +21 -21
  82. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  83. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  84. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  85. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  86. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  87. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  88. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  89. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  90. package/src/identity/hub/restore/restoreAdmin.ts +34 -0
  91. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  92. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  93. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  94. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  95. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  96. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  97. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +16 -11
  98. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +8 -9
  99. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  100. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  101. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  102. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  103. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +2 -4
  104. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  105. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  106. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +6 -47
  107. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  108. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  109. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  110. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  111. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  112. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -2
  113. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  114. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +7 -40
  115. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -4
  116. package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -7
  117. package/src/identity/hub/shared/reconciliation/walletSetup.ts +27 -0
  118. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  119. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  120. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  121. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  122. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  123. package/src/identity/hub/useIdentityHubController.ts +11 -11
  124. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  125. package/src/identity/wallet/browserWallet/types.ts +0 -5
  126. package/src/identity/wallet/page/copy.ts +1 -31
  127. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  128. package/src/models/ModelPicker.tsx +248 -8
  129. package/src/models/catalog.ts +29 -1
  130. package/src/models/modelPickerOptions.ts +12 -10
  131. package/src/models/providerDisplay.ts +16 -0
  132. package/src/providers/errors.ts +6 -4
  133. package/src/providers/openai-chat.ts +2 -1
  134. package/src/providers/openai-responses-format.ts +156 -0
  135. package/src/providers/openai-responses.ts +276 -0
  136. package/src/providers/registry.ts +85 -8
  137. package/src/runtime/sessionMode.ts +1 -1
  138. package/src/runtime/systemPrompt.ts +4 -2
  139. package/src/runtime/toolExecution.ts +9 -6
  140. package/src/runtime/turn.ts +29 -1
  141. package/src/storage/rewind.ts +20 -0
  142. package/src/storage/secrets.ts +4 -1
  143. package/src/storage/sessions.ts +2 -1
  144. package/src/tools/bashSafety.ts +7 -3
  145. package/src/tools/bashTool.ts +1 -1
  146. package/src/tools/contracts.ts +3 -0
  147. package/src/tools/deleteFileTool.ts +8 -3
  148. package/src/tools/editTool.ts +10 -5
  149. package/src/tools/fileDiff.ts +261 -0
  150. package/src/tools/privateContinuityEditTool.ts +11 -1
  151. package/src/tools/writeFileTool.ts +8 -3
  152. package/src/ui/Spinner.tsx +25 -3
  153. package/src/ui/TextInput.tsx +2 -2
  154. package/src/ui/theme.ts +17 -0
  155. package/src/utils/clipboard.ts +10 -7
  156. package/src/utils/openExternal.ts +20 -10
  157. package/src/chat/RewindView.tsx +0 -386
  158. package/src/chat/toolResultDisplay.ts +0 -8
  159. package/src/identity/ens/ensRegistration.ts +0 -199
  160. package/src/identity/hub/effects/index.ts +0 -74
  161. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  162. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  163. package/src/identity/hub/effects/restoreAdmin.ts +0 -93
  164. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  165. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +0 -336
  166. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -198
  167. package/src/identity/hub/reconciliation/walletSetup.ts +0 -220
  168. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  169. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  170. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  171. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  172. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  173. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  174. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  175. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  176. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  177. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -19,6 +19,7 @@ import { setCwd } from '../runtime/cwd.js'
19
19
  import type { SessionMode } from '../runtime/sessionMode.js'
20
20
  import type { ContextUsage } from '../runtime/compaction.js'
21
21
  import { formatModelDisplayName } from '../models/modelDisplay.js'
22
+ import { providerDisplayName } from '../models/modelPickerOptions.js'
22
23
  import type { McpManager } from '../mcp/manager.js'
23
24
 
24
25
  export type IdentityRequestAction =
@@ -131,9 +132,9 @@ const COMMANDS: CommandSpec[] = [
131
132
  try {
132
133
  const next = setCwd(target, ctx.cwd)
133
134
  ctx.onChangeCwd(next)
134
- return { kind: 'note', text: `cwd: ${next}`, variant: 'dim' }
135
+ return { kind: 'note', text: `Cwd: ${next}`, variant: 'dim' }
135
136
  } catch (err: unknown) {
136
- return { kind: 'note', variant: 'error', text: `cd failed: ${(err as Error).message}` }
137
+ return { kind: 'note', variant: 'error', text: `Cd failed: ${(err as Error).message}` }
137
138
  }
138
139
  },
139
140
  },
@@ -199,7 +200,7 @@ const COMMANDS: CommandSpec[] = [
199
200
  return {
200
201
  kind: 'note',
201
202
  variant: 'error',
202
- text: `'${name}' was not found for ${ctx.config.provider}. use /models to inspect available models.`,
203
+ text: `'${name}' was not found for ${providerDisplayName(ctx.config.provider)}. use /models to inspect available models.`,
203
204
  }
204
205
  }
205
206
  }
@@ -210,7 +211,7 @@ const COMMANDS: CommandSpec[] = [
210
211
  }
211
212
  await saveConfig(next)
212
213
  ctx.onReplaceConfig(next)
213
- return { kind: 'note', text: `Now using ${next.provider} · ${formatModelDisplayName(next.provider, name, { maxLength: 64 })}.` }
214
+ return { kind: 'note', text: `Now using ${providerDisplayName(next.provider)} · ${formatModelDisplayName(next.provider, name, { maxLength: 64 })}.` }
214
215
  },
215
216
  },
216
217
  {
@@ -243,12 +244,12 @@ const COMMANDS: CommandSpec[] = [
243
244
  }
244
245
  const result = await rewindWorkspaceEdits(ctx.cwd, steps)
245
246
  if (result.reverted === 0) {
246
- return { kind: 'note', variant: 'error', text: 'no managed edits available to rewind in this directory.' }
247
+ return { kind: 'note', variant: 'error', text: 'No managed edits available to rewind in this directory.' }
247
248
  }
248
249
  const files = result.files.map(file => path.relative(ctx.cwd, file) || path.basename(file))
249
250
  return {
250
251
  kind: 'note',
251
- text: `rewound ${result.reverted} edit${result.reverted === 1 ? '' : 's'}.\n${files.join('\n')}`,
252
+ text: `Rewound ${result.reverted} edit${result.reverted === 1 ? '' : 's'}.\n${files.join('\n')}`,
252
253
  variant: 'dim',
253
254
  }
254
255
  },
@@ -275,7 +276,7 @@ const COMMANDS: CommandSpec[] = [
275
276
  run: async (args, ctx) => {
276
277
  const assistant = ctx.assistantTurns()
277
278
  if (assistant.length === 0) {
278
- return { kind: 'note', variant: 'error', text: 'nothing to copy yet.' }
279
+ return { kind: 'note', variant: 'error', text: 'Nothing to copy yet.' }
279
280
  }
280
281
  let offset = 1
281
282
  const trimmed = args.trim()
@@ -288,17 +289,17 @@ const COMMANDS: CommandSpec[] = [
288
289
  }
289
290
  const index = assistant.length - offset
290
291
  if (index < 0) {
291
- return { kind: 'note', variant: 'error', text: `only ${assistant.length} assistant reply on record.` }
292
+ return { kind: 'note', variant: 'error', text: `Only ${assistant.length} assistant reply on record.` }
292
293
  }
293
294
  const text = assistant[index] ?? ''
294
- const label = offset === 1 ? 'latest reply' : `reply #${offset} back`
295
+ const label = offset === 1 ? 'Latest reply' : `Reply #${offset} back`
295
296
  const segments = parseSegments(text)
296
297
  if (segments.length <= 1) {
297
298
  const result = await copyToClipboard(text)
298
299
  if (!result.ok) {
299
- return { kind: 'note', variant: 'error', text: `copy failed: ${result.error}` }
300
+ return { kind: 'note', variant: 'error', text: `Copy failed: ${result.error}` }
300
301
  }
301
- return { kind: 'note', text: `copied ${text.length} chars via ${result.method}.`, variant: 'dim' }
302
+ return { kind: 'note', text: `${label} copied to clipboard · ${result.chars} chars`, variant: 'dim' }
302
303
  }
303
304
  ctx.onCopyPickerRequest(text, label)
304
305
  return { kind: 'handled' }
@@ -311,16 +312,16 @@ const COMMANDS: CommandSpec[] = [
311
312
  run: async (_args, ctx) => {
312
313
  const messages = ctx.sessionMessages()
313
314
  if (messages.length === 0) {
314
- return { kind: 'note', variant: 'error', text: 'nothing to export yet.' }
315
+ return { kind: 'note', variant: 'error', text: 'Nothing to export yet.' }
315
316
  }
316
317
  try {
317
318
  const file = await exportSessionMarkdown(ctx.sessionId, messages, {
318
319
  model: ctx.config.model,
319
320
  provider: ctx.config.provider,
320
321
  })
321
- return { kind: 'note', text: `exported to ${file}` }
322
+ return { kind: 'note', text: `Exported to ${file}` }
322
323
  } catch (err: unknown) {
323
- return { kind: 'note', variant: 'error', text: `export failed: ${(err as Error).message}` }
324
+ return { kind: 'note', variant: 'error', text: `Export failed: ${(err as Error).message}` }
324
325
  }
325
326
  },
326
327
  },
@@ -436,7 +437,7 @@ async function runMcp(args: string, ctx: SlashContext): Promise<SlashResult> {
436
437
  return { kind: 'note', text: await ctx.mcp.addJson(name, json, project ? 'project' : 'user'), variant: 'dim' }
437
438
  }
438
439
  } catch (err: unknown) {
439
- return { kind: 'note', variant: 'error', text: `mcp failed: ${(err as Error).message}` }
440
+ return { kind: 'note', variant: 'error', text: `MCP failed: ${(err as Error).message}` }
440
441
  }
441
442
 
442
443
  return {
@@ -462,7 +463,7 @@ async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult
462
463
  return {
463
464
  kind: 'note',
464
465
  variant: 'dim',
465
- text: 'no Ethereum identity set. run /identity create to make one.',
466
+ text: 'No Ethereum identity set. Run /identity create to make one.',
466
467
  }
467
468
  }
468
469
  const lines = [
@@ -490,16 +491,16 @@ async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult
490
491
  return {
491
492
  kind: 'note',
492
493
  variant: 'error',
493
- text: 'remove deletes local identity metadata and any legacy stored key. re-run with: /identity remove confirm',
494
+ text: 'Remove deletes local identity metadata and any legacy stored key. Re-run with: /identity remove confirm',
494
495
  }
495
496
  }
496
497
  const status = await getIdentityStatus(ctx.config)
497
498
  if (!status) {
498
- return { kind: 'note', variant: 'dim', text: 'no Ethereum identity to remove.' }
499
+ return { kind: 'note', variant: 'dim', text: 'No Ethereum identity to remove.' }
499
500
  }
500
501
  const next = await clearIdentity(ctx.config)
501
502
  ctx.onReplaceConfig(next)
502
- return { kind: 'note', text: `removed identity ${status.address}.`, variant: 'dim' }
503
+ return { kind: 'note', text: `Removed identity ${status.address}.`, variant: 'dim' }
503
504
  }
504
505
 
505
506
  return {
@@ -536,7 +537,7 @@ function renderStatus(ctx: SlashContext): string {
536
537
  const elapsed = minutes > 0 ? `${minutes}m${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
537
538
  const displayModel = formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })
538
539
  return [
539
- `provider ${ctx.config.provider}`,
540
+ `provider ${providerDisplayName(ctx.config.provider)}`,
540
541
  `model ${displayModel}`,
541
542
  `cwd ${ctx.cwd}`,
542
543
  `session ${ctx.sessionId.slice(0, 8)}`,
@@ -559,7 +560,7 @@ function renderContext(ctx: SlashContext): string {
559
560
  : 'Context has comfortable room.'
560
561
  return [
561
562
  'context usage:',
562
- ` model ${ctx.config.provider} · ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`,
563
+ ` model ${providerDisplayName(ctx.config.provider)} · ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`,
563
564
  ` used ~${usage.usedTokens} / ${usage.windowTokens} tokens (${usage.percent}%)`,
564
565
  ` free ~${free} tokens`,
565
566
  ` estimate ${usage.confidence} (${usage.source})`,
@@ -584,7 +585,7 @@ function renderDoctor(
584
585
  lines.push(` hf models ${hfModelCount} downloaded`)
585
586
  lines.push('')
586
587
  lines.push('config:')
587
- lines.push(` provider ${ctx.config.provider}`)
588
+ lines.push(` provider ${providerDisplayName(ctx.config.provider)}`)
588
589
  lines.push(` model ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`)
589
590
  if (ctx.config.baseUrl) lines.push(` baseUrl ${ctx.config.baseUrl}`)
590
591
  if (ctx.config.provider === 'llamacpp') lines.push(` hf cache ${getLocalHfCacheDir()}`)
@@ -592,7 +593,7 @@ function renderDoctor(
592
593
  lines.push('')
593
594
  lines.push('keys:')
594
595
  for (const [provider, present] of keys) {
595
- lines.push(` ${provider.padEnd(9)} ${present ? 'set' : 'not set'}`)
596
+ lines.push(` ${providerDisplayName(provider).padEnd(9)} ${present ? 'set' : 'not set'}`)
596
597
  }
597
598
  lines.push('')
598
599
  lines.push('identity:')
@@ -609,8 +610,8 @@ function renderDoctor(
609
610
 
610
611
  function renderModelCatalog(catalog: ModelCatalogResult, currentModel: string): string {
611
612
  const title = catalog.status === 'fallback'
612
- ? `${catalog.provider} models (fallback${catalog.error ? `: ${catalog.error}` : ''}):`
613
- : `${catalog.provider} models:`
613
+ ? `${providerDisplayName(catalog.provider)} models (fallback${catalog.error ? `: ${catalog.error}` : ''}):`
614
+ : `${providerDisplayName(catalog.provider)} models:`
614
615
  const lines = catalog.entries.map(entry => {
615
616
  const marker = entry.id === currentModel ? '*' : ' '
616
617
  const suffix = entry.source === 'fallback' ? ' fallback' : ''
@@ -648,9 +649,9 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<S
648
649
  const promptText = await ctx.mcp?.runPromptSlash(parsed.name, parsed.args)
649
650
  if (promptText !== null && promptText !== undefined) return { kind: 'submit', text: promptText }
650
651
  } catch (err: unknown) {
651
- return { kind: 'note', variant: 'error', text: `mcp prompt failed: ${(err as Error).message}` }
652
+ return { kind: 'note', variant: 'error', text: `MCP prompt failed: ${(err as Error).message}` }
652
653
  }
653
- return { kind: 'note', variant: 'error', text: `unknown command: /${parsed.name}. try /help` }
654
+ return { kind: 'note', variant: 'error', text: `Unknown command: /${parsed.name}. Try /help` }
654
655
  }
655
656
  if (ctx.mode === 'plan' && cmd.blockedInPlan) {
656
657
  return { kind: 'note', variant: 'error', text: `/${cmd.name} is blocked in plan mode.` }
@@ -0,0 +1,193 @@
1
+ import React from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { theme } from '../../ui/theme.js'
4
+ import { inferLanguageFromPath, SyntaxLine } from './SyntaxText.js'
5
+
6
+ const EMPTY_DIFF_LINE = ' '
7
+
8
+ type DiffLineTone = 'added' | 'removed' | 'added-header' | 'removed-header' | 'hunk' | 'context' | 'marker'
9
+
10
+ type DiffLineStyle = {
11
+ tone: DiffLineTone
12
+ color: string
13
+ backgroundColor?: string
14
+ }
15
+
16
+ type NumberedDiffLine = {
17
+ line: string
18
+ oldLine?: number
19
+ newLine?: number
20
+ }
21
+
22
+ export function diffLineColor(line: string): string {
23
+ return diffLineStyle(line).color
24
+ }
25
+
26
+ export function diffLineStyle(line: string): DiffLineStyle {
27
+ if (line.startsWith('+++')) return { tone: 'added-header', color: theme.diffAdded }
28
+ if (line.startsWith('---')) return { tone: 'removed-header', color: theme.diffRemoved }
29
+ if (line.startsWith('+')) {
30
+ return { tone: 'added', color: theme.text, backgroundColor: theme.diffAddedBackground }
31
+ }
32
+ if (line.startsWith('-')) {
33
+ return { tone: 'removed', color: theme.text, backgroundColor: theme.diffRemovedBackground }
34
+ }
35
+ if (line.startsWith('@@')) return { tone: 'hunk', color: theme.accentPeriwinkle }
36
+ if (line.startsWith('...')) return { tone: 'marker', color: theme.dim }
37
+ return { tone: 'context', color: theme.textSubtle }
38
+ }
39
+
40
+ export const DiffView: React.FC<{ diff: string }> = ({ diff }) => (
41
+ <DiffLines lines={visibleDiffLines(numberDiffLines(diff))} language={inferDiffLanguage(diff)} />
42
+ )
43
+
44
+ const DiffLines: React.FC<{ lines: NumberedDiffLine[]; language: string | null }> = ({ lines, language }) => {
45
+ const width = lineNumberWidth(lines)
46
+ return (
47
+ <Box flexDirection="column">
48
+ {lines.map((line, index) => (
49
+ <DiffLine
50
+ key={index}
51
+ line={line.line}
52
+ oldLine={line.oldLine}
53
+ newLine={line.newLine}
54
+ width={width}
55
+ language={language}
56
+ />
57
+ ))}
58
+ </Box>
59
+ )
60
+ }
61
+
62
+ const DiffLine: React.FC<{
63
+ line: string
64
+ oldLine?: number
65
+ newLine?: number
66
+ width: number
67
+ language: string | null
68
+ }> = ({
69
+ line,
70
+ oldLine,
71
+ newLine,
72
+ width,
73
+ language,
74
+ }) => {
75
+ const style = diffLineStyle(line)
76
+ const number = renderLineNumber(displayLineNumber(oldLine, newLine, style.tone), width)
77
+ if (line.length === 0) {
78
+ return <Text color={style.color}>{EMPTY_DIFF_LINE}</Text>
79
+ }
80
+
81
+ if (style.tone === 'added' || style.tone === 'removed') {
82
+ const prefixColor = style.tone === 'added' ? theme.diffAdded : theme.diffRemoved
83
+ return (
84
+ <Box width="100%" backgroundColor={style.backgroundColor}>
85
+ <Text>
86
+ {number}
87
+ <Text color={prefixColor}>{line.slice(0, 1)} </Text>
88
+ <SyntaxLine line={line.slice(1)} lang={language} fallbackColor={style.color} />
89
+ </Text>
90
+ </Box>
91
+ )
92
+ }
93
+
94
+ if (style.tone === 'context' && line.startsWith(' ')) {
95
+ return (
96
+ <Text color={style.color}>
97
+ {number}
98
+ <Text>{' '}</Text>
99
+ <SyntaxLine line={line.slice(1)} lang={language} fallbackColor={style.color} />
100
+ </Text>
101
+ )
102
+ }
103
+
104
+ return <Text color={style.color}>{line}</Text>
105
+ }
106
+
107
+ function lineNumberWidth(lines: NumberedDiffLine[]): number {
108
+ return Math.max(
109
+ 2,
110
+ ...lines.map(line => String(displayLineNumber(line.oldLine, line.newLine, diffLineStyle(line.line).tone) ?? '').length),
111
+ )
112
+ }
113
+
114
+ function displayLineNumber(
115
+ oldLine: number | undefined,
116
+ newLine: number | undefined,
117
+ tone: DiffLineTone,
118
+ ): number | undefined {
119
+ if (tone === 'removed') return oldLine
120
+ return newLine ?? oldLine
121
+ }
122
+
123
+ function renderLineNumber(line: number | undefined, width: number): React.ReactNode {
124
+ if (line === undefined) return null
125
+ return <Text color={theme.dim}>{String(line).padStart(width, ' ')} </Text>
126
+ }
127
+
128
+ function visibleDiffLines(lines: NumberedDiffLine[]): NumberedDiffLine[] {
129
+ return lines.filter(line => {
130
+ const tone = diffLineStyle(line.line).tone
131
+ return tone !== 'added-header' && tone !== 'removed-header' && tone !== 'hunk'
132
+ })
133
+ }
134
+
135
+ function inferDiffLanguage(diff: string): string | null {
136
+ for (const line of diff.split('\n')) {
137
+ if (!line.startsWith('+++ ') || line === '+++ /dev/null') continue
138
+ return inferLanguageFromPath(line.slice(4).trim())
139
+ }
140
+ for (const line of diff.split('\n')) {
141
+ if (!line.startsWith('--- ') || line === '--- /dev/null') continue
142
+ return inferLanguageFromPath(line.slice(4).trim())
143
+ }
144
+ return null
145
+ }
146
+
147
+ export function numberDiffLines(diff: string): NumberedDiffLine[] {
148
+ const out: NumberedDiffLine[] = []
149
+ let oldLine: number | undefined
150
+ let newLine: number | undefined
151
+
152
+ for (const line of diff.split('\n')) {
153
+ const hunk = parseHunkHeader(line)
154
+ if (hunk) {
155
+ oldLine = hunk.oldStart
156
+ newLine = hunk.newStart
157
+ out.push({ line })
158
+ continue
159
+ }
160
+
161
+ if (oldLine !== undefined && newLine !== undefined) {
162
+ if (line.startsWith(' ')) {
163
+ out.push({ line, oldLine, newLine })
164
+ oldLine += 1
165
+ newLine += 1
166
+ continue
167
+ }
168
+ if (line.startsWith('-') && !line.startsWith('---')) {
169
+ out.push({ line, oldLine })
170
+ oldLine += 1
171
+ continue
172
+ }
173
+ if (line.startsWith('+') && !line.startsWith('+++')) {
174
+ out.push({ line, newLine })
175
+ newLine += 1
176
+ continue
177
+ }
178
+ }
179
+
180
+ out.push({ line })
181
+ }
182
+
183
+ return out
184
+ }
185
+
186
+ function parseHunkHeader(line: string): { oldStart: number; newStart: number } | null {
187
+ const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
188
+ if (!match) return null
189
+ return {
190
+ oldStart: Number(match[1]),
191
+ newStart: Number(match[2]),
192
+ }
193
+ }
@@ -0,0 +1,192 @@
1
+ import React from 'react'
2
+ import { Text } from 'ink'
3
+ import { styledCharsFromTokens, tokenize } from '@alcalzone/ansi-tokenize'
4
+ import { highlight, supportsLanguage, type Theme } from 'cli-highlight'
5
+ import { theme } from '../../ui/theme.js'
6
+
7
+ type Span = {
8
+ text: string
9
+ color?: string
10
+ bold?: boolean
11
+ italic?: boolean
12
+ }
13
+
14
+ const ANSI_RESET_FG = '\x1B[39m'
15
+ const ANSI_RESET_BOLD = '\x1B[22m'
16
+ const ANSI_RESET_ITALIC = '\x1B[23m'
17
+
18
+ const HIGHLIGHT_THEME: Theme = {
19
+ keyword: style(theme.codeKeyword, { bold: true }),
20
+ built_in: style(theme.codeBuiltin),
21
+ type: style(theme.codeType),
22
+ literal: style(theme.codeKeyword),
23
+ number: style(theme.codeNumber),
24
+ regexp: style(theme.codeString),
25
+ string: style(theme.codeString),
26
+ class: style(theme.codeType),
27
+ function: style(theme.codeFunction),
28
+ title: style(theme.codeFunction),
29
+ comment: style(theme.codeComment, { italic: true }),
30
+ doctag: style(theme.codeComment, { italic: true }),
31
+ meta: style(theme.dim),
32
+ tag: style(theme.codeTag, { bold: true }),
33
+ name: style(theme.codeTag, { bold: true }),
34
+ attr: style(theme.codeAttribute),
35
+ attribute: style(theme.codeProperty),
36
+ variable: style(theme.codeProperty),
37
+ addition: style(theme.diffAdded),
38
+ deletion: style(theme.diffRemoved),
39
+ default: style(theme.textSubtle),
40
+ }
41
+
42
+ const PROGRAMMING_LANGUAGES = new Set([
43
+ 'bash',
44
+ 'css',
45
+ 'go',
46
+ 'java',
47
+ 'javascript',
48
+ 'json',
49
+ 'python',
50
+ 'rust',
51
+ 'solidity',
52
+ 'sql',
53
+ 'typescript',
54
+ 'xml',
55
+ 'yaml',
56
+ ])
57
+
58
+ const LANGUAGE_BY_EXTENSION: Record<string, string> = {
59
+ cjs: 'javascript',
60
+ css: 'css',
61
+ html: 'xml',
62
+ js: 'javascript',
63
+ json: 'json',
64
+ jsonc: 'json',
65
+ jsx: 'javascript',
66
+ md: 'markdown',
67
+ mjs: 'javascript',
68
+ py: 'python',
69
+ sh: 'bash',
70
+ ts: 'typescript',
71
+ tsx: 'typescript',
72
+ yml: 'yaml',
73
+ yaml: 'yaml',
74
+ }
75
+
76
+ export const SyntaxLine: React.FC<{ line: string; lang?: string | null; fallbackColor?: string }> = ({
77
+ line,
78
+ lang,
79
+ fallbackColor = theme.textSubtle,
80
+ }) => (
81
+ <>
82
+ {syntaxLineSpans(line, lang, fallbackColor).map((span, index) => (
83
+ <Text key={index} color={span.color} bold={span.bold} italic={span.italic}>
84
+ {span.text}
85
+ </Text>
86
+ ))}
87
+ </>
88
+ )
89
+
90
+ export function syntaxLineSpans(line: string, lang?: string | null, fallbackColor: string = theme.textSubtle): Span[] {
91
+ const code = line.length === 0 ? ' ' : line
92
+ const language = supportedLanguage(lang)
93
+ if (!language || !PROGRAMMING_LANGUAGES.has(language)) {
94
+ return [{ text: code, color: fallbackColor }]
95
+ }
96
+ const highlighted = highlight(code, {
97
+ language: language ?? undefined,
98
+ ignoreIllegals: true,
99
+ theme: HIGHLIGHT_THEME,
100
+ })
101
+ const spans = spansFromAnsi(highlighted)
102
+ return spans.length > 0 ? spans : [{ text: code, color: fallbackColor }]
103
+ }
104
+
105
+ export function inferLanguageFromPath(path: string): string | null {
106
+ const clean = path
107
+ .replace(/^["']|["']$/g, '')
108
+ .replace(/^[ab]\//, '')
109
+ .replace(/\\/g, '/')
110
+ .split('/')
111
+ .pop()
112
+ const extension = clean?.match(/\.([A-Za-z0-9]+)$/)?.[1]?.toLowerCase()
113
+ return extension ? LANGUAGE_BY_EXTENSION[extension] ?? null : null
114
+ }
115
+
116
+ function supportedLanguage(lang?: string | null): string | null {
117
+ if (!lang) return null
118
+ const normalized = LANGUAGE_BY_EXTENSION[lang.toLowerCase()] ?? lang.toLowerCase()
119
+ return supportsLanguage(normalized) ? normalized : null
120
+ }
121
+
122
+ function style(color: string, options: { bold?: boolean; italic?: boolean } = {}): (text: string) => string {
123
+ return text => [
124
+ colorCode(color),
125
+ options.bold ? '\x1B[1m' : '',
126
+ options.italic ? '\x1B[3m' : '',
127
+ text,
128
+ options.italic ? ANSI_RESET_ITALIC : '',
129
+ options.bold ? ANSI_RESET_BOLD : '',
130
+ ANSI_RESET_FG,
131
+ ].join('')
132
+ }
133
+
134
+ function colorCode(hex: string): string {
135
+ const [r, g, b] = parseHexColor(hex)
136
+ return `\x1B[38;2;${r};${g};${b}m`
137
+ }
138
+
139
+ function parseHexColor(hex: string): [number, number, number] {
140
+ const clean = hex.replace(/^#/, '')
141
+ return [
142
+ Number.parseInt(clean.slice(0, 2), 16),
143
+ Number.parseInt(clean.slice(2, 4), 16),
144
+ Number.parseInt(clean.slice(4, 6), 16),
145
+ ]
146
+ }
147
+
148
+ function spansFromAnsi(value: string): Span[] {
149
+ const chars = styledCharsFromTokens(tokenize(value))
150
+ const spans: Span[] = []
151
+ for (const char of chars) {
152
+ const span = spanFromCodes(char.value, char.styles.map(styleCode => styleCode.code))
153
+ const previous = spans[spans.length - 1]
154
+ if (previous && sameStyle(previous, span)) {
155
+ previous.text += span.text
156
+ } else {
157
+ spans.push(span)
158
+ }
159
+ }
160
+ return spans
161
+ }
162
+
163
+ function spanFromCodes(text: string, codes: string[]): Span {
164
+ return {
165
+ text,
166
+ color: colorFromCodes(codes),
167
+ bold: codes.includes('\x1B[1m') || undefined,
168
+ italic: codes.includes('\x1B[3m') || undefined,
169
+ }
170
+ }
171
+
172
+ function colorFromCodes(codes: string[]): string | undefined {
173
+ for (let index = codes.length - 1; index >= 0; index -= 1) {
174
+ const match = codes[index]?.match(/\x1B\[38;2;(\d+);(\d+);(\d+)m/)
175
+ if (match) {
176
+ return rgbToHex(Number(match[1]), Number(match[2]), Number(match[3]))
177
+ }
178
+ }
179
+ return undefined
180
+ }
181
+
182
+ function rgbToHex(r: number, g: number, b: number): string {
183
+ return `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`
184
+ }
185
+
186
+ function hexByte(value: number): string {
187
+ return Math.max(0, Math.min(255, value)).toString(16).padStart(2, '0')
188
+ }
189
+
190
+ function sameStyle(a: Span, b: Span): boolean {
191
+ return a.color === b.color && a.bold === b.bold && a.italic === b.italic
192
+ }
@@ -0,0 +1,103 @@
1
+ const MAX_ARG_SUMMARY = 80
2
+
3
+ export function formatToolCall(
4
+ name: string,
5
+ input: Record<string, unknown> | undefined,
6
+ ): { displayName: string; argSummary: string } {
7
+ const safeInput = isRecord(input) ? input : {}
8
+ switch (name) {
9
+ case 'read_file':
10
+ return { displayName: 'Read', argSummary: readArgs(safeInput) }
11
+ case 'list_directory':
12
+ return { displayName: 'List', argSummary: pathArg(safeInput, '.') }
13
+ case 'run_bash':
14
+ return { displayName: 'Bash', argSummary: bashArgs(safeInput) }
15
+ case 'edit_file':
16
+ return { displayName: 'Edit', argSummary: pathArg(safeInput) }
17
+ case 'write_file':
18
+ return { displayName: 'Write', argSummary: pathArg(safeInput) }
19
+ case 'delete_file':
20
+ return { displayName: 'Delete', argSummary: pathArg(safeInput) }
21
+ case 'change_directory':
22
+ return { displayName: 'Cd', argSummary: pathArg(safeInput) }
23
+ case 'read_private_continuity_file':
24
+ return { displayName: 'ReadPrivate', argSummary: privateArgs(safeInput) }
25
+ case 'propose_private_continuity_edit':
26
+ return { displayName: 'EditPrivate', argSummary: stringArg(safeInput, 'file') }
27
+ case 'list_mcp_resources':
28
+ return { displayName: 'ListMcp', argSummary: stringArg(safeInput, 'server') }
29
+ case 'read_mcp_resource':
30
+ return { displayName: 'ReadMcp', argSummary: mcpReadArgs(safeInput) }
31
+ default:
32
+ return { displayName: humanize(name), argSummary: '' }
33
+ }
34
+ }
35
+
36
+ function readArgs(input: Record<string, unknown>): string {
37
+ const filePath = pathArg(input)
38
+ const start = numberArg(input, 'startLine')
39
+ const end = numberArg(input, 'endLine')
40
+ if (start || end) {
41
+ const range = `lines ${start ?? 1}-${end ?? 'end'}`
42
+ return truncate(filePath ? `${filePath} · ${range}` : range)
43
+ }
44
+ return truncate(filePath)
45
+ }
46
+
47
+ function privateArgs(input: Record<string, unknown>): string {
48
+ const file = stringArg(input, 'file')
49
+ const start = numberArg(input, 'startLine')
50
+ const end = numberArg(input, 'endLine')
51
+ if (start || end) {
52
+ return truncate(`${file} · lines ${start ?? 1}-${end ?? 'end'}`)
53
+ }
54
+ return truncate(file)
55
+ }
56
+
57
+ function bashArgs(input: Record<string, unknown>): string {
58
+ const command = stringArg(input, 'command')
59
+ if (!command) return ''
60
+ const firstLine = command.split('\n')[0]?.trim() ?? ''
61
+ return truncate(firstLine)
62
+ }
63
+
64
+ function mcpReadArgs(input: Record<string, unknown>): string {
65
+ const server = stringArg(input, 'server')
66
+ const uri = stringArg(input, 'uri')
67
+ if (server && uri) return truncate(`${server} / ${uri}`)
68
+ return truncate(server || uri)
69
+ }
70
+
71
+ function pathArg(input: Record<string, unknown>, fallback = ''): string {
72
+ const raw = stringArg(input, 'path')
73
+ if (!raw) return fallback
74
+ return raw.replace(/\\/g, '/')
75
+ }
76
+
77
+ function stringArg(input: Record<string, unknown>, key: string): string {
78
+ const value = input[key]
79
+ return typeof value === 'string' ? value : ''
80
+ }
81
+
82
+ function numberArg(input: Record<string, unknown>, key: string): number | undefined {
83
+ const value = input[key]
84
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
85
+ }
86
+
87
+ function truncate(text: string): string {
88
+ const flat = text.replace(/\s+/g, ' ').trim()
89
+ if (flat.length <= MAX_ARG_SUMMARY) return flat
90
+ return `${flat.slice(0, MAX_ARG_SUMMARY - 1)}…`
91
+ }
92
+
93
+ function humanize(name: string): string {
94
+ return name
95
+ .split('_')
96
+ .filter(part => part.length > 0)
97
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
98
+ .join('')
99
+ }
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
103
+ }