ethagent 2.2.0 → 2.4.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 (168) hide show
  1. package/README.md +11 -0
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +3 -7
  4. package/src/app/FirstRunTimeline.tsx +1 -1
  5. package/src/chat/ChatBottomPane.tsx +29 -11
  6. package/src/chat/ChatScreen.tsx +169 -38
  7. package/src/chat/ConversationStack.tsx +1 -1
  8. package/src/chat/MessageList.tsx +185 -72
  9. package/src/chat/SessionStatus.tsx +3 -1
  10. package/src/chat/chatScreenUtils.ts +11 -15
  11. package/src/chat/chatSessionState.ts +5 -2
  12. package/src/chat/chatTurnOrchestrator.ts +7 -9
  13. package/src/chat/commands.ts +26 -26
  14. package/src/chat/display/DiffView.tsx +193 -0
  15. package/src/chat/display/SyntaxText.tsx +192 -0
  16. package/src/chat/display/toolCallDisplay.ts +103 -0
  17. package/src/chat/display/toolResultDisplay.ts +19 -0
  18. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
  19. package/src/chat/input/imageRefs.ts +30 -0
  20. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  21. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  22. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  23. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  24. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  25. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  26. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  27. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  28. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
  29. package/src/chat/views/RewindView.tsx +410 -0
  30. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  31. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  32. package/src/identity/hub/Routes.tsx +13 -13
  33. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  34. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  35. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  36. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  37. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  38. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  39. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  40. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  41. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  42. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  43. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  44. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  45. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  46. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  47. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  48. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  49. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  50. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  51. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  57. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  58. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  59. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  60. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  61. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  62. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  63. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  64. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  65. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  66. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  67. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  68. package/src/identity/hub/identityHubReducer.ts +3 -3
  69. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  70. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  71. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  72. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  73. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  74. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  75. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  76. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  77. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  78. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  79. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  80. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  81. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  82. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  83. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  84. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  85. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  87. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  88. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  89. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  90. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  91. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  92. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  93. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  94. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  95. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  96. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  97. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  98. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  99. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  100. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  101. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  102. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  103. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  105. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  106. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  107. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  108. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  109. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  110. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  111. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  112. package/src/identity/hub/useIdentityHubController.ts +11 -11
  113. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  114. package/src/models/ModelPicker.tsx +143 -9
  115. package/src/models/catalog.ts +2 -1
  116. package/src/models/huggingface.ts +180 -2
  117. package/src/models/llamacpp.ts +110 -15
  118. package/src/models/llamacppPreflight.ts +30 -11
  119. package/src/models/modelPickerOptions.ts +16 -15
  120. package/src/models/providerDisplay.ts +16 -0
  121. package/src/providers/anthropic.ts +36 -5
  122. package/src/providers/contracts.ts +9 -1
  123. package/src/providers/errors.ts +6 -4
  124. package/src/providers/gemini.ts +29 -3
  125. package/src/providers/openai-chat.ts +83 -3
  126. package/src/providers/openai-responses-format.ts +29 -8
  127. package/src/providers/openai-responses.ts +22 -7
  128. package/src/providers/registry.ts +1 -0
  129. package/src/runtime/sessionMode.ts +1 -1
  130. package/src/runtime/systemPrompt.ts +3 -1
  131. package/src/runtime/toolExecution.ts +9 -6
  132. package/src/runtime/turn.ts +29 -0
  133. package/src/storage/config.ts +1 -0
  134. package/src/storage/rewind.ts +20 -0
  135. package/src/storage/sessions.ts +16 -3
  136. package/src/tools/bashSafety.ts +7 -3
  137. package/src/tools/bashTool.ts +1 -1
  138. package/src/tools/contracts.ts +3 -0
  139. package/src/tools/deleteFileTool.ts +8 -3
  140. package/src/tools/editTool.ts +10 -5
  141. package/src/tools/fileDiff.ts +261 -0
  142. package/src/tools/privateContinuityEditTool.ts +5 -1
  143. package/src/tools/writeFileTool.ts +8 -3
  144. package/src/ui/Spinner.tsx +39 -5
  145. package/src/ui/TextInput.tsx +2 -2
  146. package/src/ui/theme.ts +19 -0
  147. package/src/utils/clipboard.ts +10 -7
  148. package/src/utils/images.ts +140 -0
  149. package/src/utils/messages.ts +2 -0
  150. package/src/chat/RewindView.tsx +0 -386
  151. package/src/chat/toolResultDisplay.ts +0 -8
  152. package/src/identity/hub/effects/index.ts +0 -73
  153. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  154. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  155. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  156. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  157. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  158. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  159. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  160. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  161. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  162. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  163. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  164. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  165. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  166. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  167. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  168. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -132,9 +132,9 @@ const COMMANDS: CommandSpec[] = [
132
132
  try {
133
133
  const next = setCwd(target, ctx.cwd)
134
134
  ctx.onChangeCwd(next)
135
- return { kind: 'note', text: `cwd: ${next}`, variant: 'dim' }
135
+ return { kind: 'note', text: `Cwd: ${next}`, variant: 'dim' }
136
136
  } catch (err: unknown) {
137
- 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}` }
138
138
  }
139
139
  },
140
140
  },
@@ -200,7 +200,7 @@ const COMMANDS: CommandSpec[] = [
200
200
  return {
201
201
  kind: 'note',
202
202
  variant: 'error',
203
- 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.`,
204
204
  }
205
205
  }
206
206
  }
@@ -244,12 +244,12 @@ const COMMANDS: CommandSpec[] = [
244
244
  }
245
245
  const result = await rewindWorkspaceEdits(ctx.cwd, steps)
246
246
  if (result.reverted === 0) {
247
- 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.' }
248
248
  }
249
249
  const files = result.files.map(file => path.relative(ctx.cwd, file) || path.basename(file))
250
250
  return {
251
251
  kind: 'note',
252
- 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')}`,
253
253
  variant: 'dim',
254
254
  }
255
255
  },
@@ -276,7 +276,7 @@ const COMMANDS: CommandSpec[] = [
276
276
  run: async (args, ctx) => {
277
277
  const assistant = ctx.assistantTurns()
278
278
  if (assistant.length === 0) {
279
- return { kind: 'note', variant: 'error', text: 'nothing to copy yet.' }
279
+ return { kind: 'note', variant: 'error', text: 'Nothing to copy yet.' }
280
280
  }
281
281
  let offset = 1
282
282
  const trimmed = args.trim()
@@ -289,17 +289,17 @@ const COMMANDS: CommandSpec[] = [
289
289
  }
290
290
  const index = assistant.length - offset
291
291
  if (index < 0) {
292
- 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.` }
293
293
  }
294
294
  const text = assistant[index] ?? ''
295
- const label = offset === 1 ? 'latest reply' : `reply #${offset} back`
295
+ const label = offset === 1 ? 'Latest reply' : `Reply #${offset} back`
296
296
  const segments = parseSegments(text)
297
297
  if (segments.length <= 1) {
298
298
  const result = await copyToClipboard(text)
299
299
  if (!result.ok) {
300
- return { kind: 'note', variant: 'error', text: `copy failed: ${result.error}` }
300
+ return { kind: 'note', variant: 'error', text: `Copy failed: ${result.error}` }
301
301
  }
302
- 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' }
303
303
  }
304
304
  ctx.onCopyPickerRequest(text, label)
305
305
  return { kind: 'handled' }
@@ -312,16 +312,16 @@ const COMMANDS: CommandSpec[] = [
312
312
  run: async (_args, ctx) => {
313
313
  const messages = ctx.sessionMessages()
314
314
  if (messages.length === 0) {
315
- return { kind: 'note', variant: 'error', text: 'nothing to export yet.' }
315
+ return { kind: 'note', variant: 'error', text: 'Nothing to export yet.' }
316
316
  }
317
317
  try {
318
318
  const file = await exportSessionMarkdown(ctx.sessionId, messages, {
319
319
  model: ctx.config.model,
320
320
  provider: ctx.config.provider,
321
321
  })
322
- return { kind: 'note', text: `exported to ${file}` }
322
+ return { kind: 'note', text: `Exported to ${file}` }
323
323
  } catch (err: unknown) {
324
- 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}` }
325
325
  }
326
326
  },
327
327
  },
@@ -437,7 +437,7 @@ async function runMcp(args: string, ctx: SlashContext): Promise<SlashResult> {
437
437
  return { kind: 'note', text: await ctx.mcp.addJson(name, json, project ? 'project' : 'user'), variant: 'dim' }
438
438
  }
439
439
  } catch (err: unknown) {
440
- 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}` }
441
441
  }
442
442
 
443
443
  return {
@@ -463,7 +463,7 @@ async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult
463
463
  return {
464
464
  kind: 'note',
465
465
  variant: 'dim',
466
- text: 'no Ethereum identity set. run /identity create to make one.',
466
+ text: 'No Ethereum identity set. Run /identity create to make one.',
467
467
  }
468
468
  }
469
469
  const lines = [
@@ -491,16 +491,16 @@ async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult
491
491
  return {
492
492
  kind: 'note',
493
493
  variant: 'error',
494
- 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',
495
495
  }
496
496
  }
497
497
  const status = await getIdentityStatus(ctx.config)
498
498
  if (!status) {
499
- return { kind: 'note', variant: 'dim', text: 'no Ethereum identity to remove.' }
499
+ return { kind: 'note', variant: 'dim', text: 'No Ethereum identity to remove.' }
500
500
  }
501
501
  const next = await clearIdentity(ctx.config)
502
502
  ctx.onReplaceConfig(next)
503
- return { kind: 'note', text: `removed identity ${status.address}.`, variant: 'dim' }
503
+ return { kind: 'note', text: `Removed identity ${status.address}.`, variant: 'dim' }
504
504
  }
505
505
 
506
506
  return {
@@ -537,7 +537,7 @@ function renderStatus(ctx: SlashContext): string {
537
537
  const elapsed = minutes > 0 ? `${minutes}m${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
538
538
  const displayModel = formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })
539
539
  return [
540
- `provider ${ctx.config.provider}`,
540
+ `provider ${providerDisplayName(ctx.config.provider)}`,
541
541
  `model ${displayModel}`,
542
542
  `cwd ${ctx.cwd}`,
543
543
  `session ${ctx.sessionId.slice(0, 8)}`,
@@ -560,7 +560,7 @@ function renderContext(ctx: SlashContext): string {
560
560
  : 'Context has comfortable room.'
561
561
  return [
562
562
  'context usage:',
563
- ` 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 })}`,
564
564
  ` used ~${usage.usedTokens} / ${usage.windowTokens} tokens (${usage.percent}%)`,
565
565
  ` free ~${free} tokens`,
566
566
  ` estimate ${usage.confidence} (${usage.source})`,
@@ -585,7 +585,7 @@ function renderDoctor(
585
585
  lines.push(` hf models ${hfModelCount} downloaded`)
586
586
  lines.push('')
587
587
  lines.push('config:')
588
- lines.push(` provider ${ctx.config.provider}`)
588
+ lines.push(` provider ${providerDisplayName(ctx.config.provider)}`)
589
589
  lines.push(` model ${formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })}`)
590
590
  if (ctx.config.baseUrl) lines.push(` baseUrl ${ctx.config.baseUrl}`)
591
591
  if (ctx.config.provider === 'llamacpp') lines.push(` hf cache ${getLocalHfCacheDir()}`)
@@ -593,7 +593,7 @@ function renderDoctor(
593
593
  lines.push('')
594
594
  lines.push('keys:')
595
595
  for (const [provider, present] of keys) {
596
- lines.push(` ${provider.padEnd(9)} ${present ? 'set' : 'not set'}`)
596
+ lines.push(` ${providerDisplayName(provider).padEnd(9)} ${present ? 'set' : 'not set'}`)
597
597
  }
598
598
  lines.push('')
599
599
  lines.push('identity:')
@@ -610,8 +610,8 @@ function renderDoctor(
610
610
 
611
611
  function renderModelCatalog(catalog: ModelCatalogResult, currentModel: string): string {
612
612
  const title = catalog.status === 'fallback'
613
- ? `${catalog.provider} models (fallback${catalog.error ? `: ${catalog.error}` : ''}):`
614
- : `${catalog.provider} models:`
613
+ ? `${providerDisplayName(catalog.provider)} models (fallback${catalog.error ? `: ${catalog.error}` : ''}):`
614
+ : `${providerDisplayName(catalog.provider)} models:`
615
615
  const lines = catalog.entries.map(entry => {
616
616
  const marker = entry.id === currentModel ? '*' : ' '
617
617
  const suffix = entry.source === 'fallback' ? ' fallback' : ''
@@ -649,9 +649,9 @@ export async function dispatchSlash(input: string, ctx: SlashContext): Promise<S
649
649
  const promptText = await ctx.mcp?.runPromptSlash(parsed.name, parsed.args)
650
650
  if (promptText !== null && promptText !== undefined) return { kind: 'submit', text: promptText }
651
651
  } catch (err: unknown) {
652
- 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}` }
653
653
  }
654
- 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` }
655
655
  }
656
656
  if (ctx.mode === 'plan' && cmd.blockedInPlan) {
657
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
+ }
@@ -0,0 +1,19 @@
1
+ import { splitFileChangeResult } from '../../tools/fileDiff.js'
2
+
3
+ const COMPACT_SUCCESS_TOOL_RESULTS = new Set([
4
+ 'read_file',
5
+ 'read_private_continuity_file',
6
+ ])
7
+
8
+ export function hidesSuccessfulToolResultContent(name: string, isError?: boolean): boolean {
9
+ return !isError && COMPACT_SUCCESS_TOOL_RESULTS.has(name)
10
+ }
11
+
12
+ export function toolResultDiffContent(content: string, isError?: boolean): string | undefined {
13
+ if (isError) return undefined
14
+ return splitFileChangeResult(content).diff
15
+ }
16
+
17
+ export function toolResultTextContent(content: string): string {
18
+ return splitFileChangeResult(content).content
19
+ }