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
@@ -2,10 +2,10 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { Box, Text, useStdout } from 'ink'
5
- import { theme } from '../ui/theme.js'
6
- import { readClipboardImage } from '../utils/clipboard.js'
7
- import { useAppInput } from '../app/input/AppInputProvider.js'
8
- import type { SlashSuggestion } from './commands.js'
5
+ import { theme } from '../../ui/theme.js'
6
+ import { readClipboardImage } from '../../utils/clipboard.js'
7
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
8
+ import type { SlashSuggestion } from '../commands.js'
9
9
  import {
10
10
  beginHistoryPreview,
11
11
  canNavigateHistory as canNavigateHistoryState,
@@ -33,6 +33,12 @@ import {
33
33
  shouldCollapsePastedText,
34
34
  type PastedTextRef,
35
35
  } from './chatPaste.js'
36
+ import {
37
+ expandImageRefs,
38
+ formatImageRefMarker,
39
+ pruneImageRefs,
40
+ type ImageRef,
41
+ } from './imageRefs.js'
36
42
 
37
43
  type PromptInputProps = {
38
44
  onSubmit: (value: string) => void
@@ -46,6 +52,9 @@ type PromptInputProps = {
46
52
  onModeChange?: (mode: 'prompt' | 'bash') => void
47
53
  footerRight?: React.ReactNode
48
54
  cwd?: string
55
+ seedText?: string | null
56
+ onSeedConsumed?: () => void
57
+ onImagePaste?: (path: string) => void
49
58
  }
50
59
 
51
60
  const MAX_LENGTH = 32_768
@@ -66,12 +75,15 @@ export const ChatInput: React.FC<PromptInputProps> = ({
66
75
  disabled,
67
76
  placeholderHints,
68
77
  queuedMessages = [],
69
- prefix = '\u203a',
78
+ prefix = '',
70
79
  slashSuggestions = [],
71
80
  mode = 'prompt',
72
81
  onModeChange,
73
82
  footerRight,
74
83
  cwd,
84
+ seedText,
85
+ onSeedConsumed,
86
+ onImagePaste,
75
87
  }) => {
76
88
  const { stdout } = useStdout()
77
89
  const [buffer, setBuffer] = useState<ChatBuffer>(emptyBuffer)
@@ -96,6 +108,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
96
108
  const pasteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
97
109
  const pastedTextRefsRef = useRef<Map<number, PastedTextRef>>(new Map())
98
110
  const nextPastedTextRefIdRef = useRef(1)
111
+ const imageRefsRef = useRef<Map<number, ImageRef>>(new Map())
112
+ const nextImageRefIdRef = useRef(1)
99
113
 
100
114
  useEffect(() => { bufferRef.current = buffer }, [buffer])
101
115
  useEffect(() => { historyIndexRef.current = historyIndex }, [historyIndex])
@@ -103,6 +117,11 @@ export const ChatInput: React.FC<PromptInputProps> = ({
103
117
  useEffect(() => { historyPreviewActiveRef.current = historyPreviewActive }, [historyPreviewActive])
104
118
  useEffect(() => { preferredColumnRef.current = preferredColumn }, [preferredColumn])
105
119
 
120
+ useEffect(() => {
121
+ pruneImageRefs(imageRefsRef.current, value)
122
+ if (imageRefsRef.current.size === 0) nextImageRefIdRef.current = 1
123
+ }, [value])
124
+
106
125
  useEffect(() => {
107
126
  const handleResize = () => {
108
127
  setColumns(stdout.columns ?? process.stdout.columns ?? 80)
@@ -141,6 +160,15 @@ export const ChatInput: React.FC<PromptInputProps> = ({
141
160
  applyHistoryState(exitHistoryPreview(next))
142
161
  }, [applyHistoryState])
143
162
 
163
+ useEffect(() => {
164
+ if (seedText == null) return
165
+ const next: ChatBuffer = { value: seedText, cursor: seedText.length }
166
+ bufferRef.current = next
167
+ setBuffer(next)
168
+ applyHistoryState(exitHistoryPreview(next))
169
+ onSeedConsumed?.()
170
+ }, [seedText, applyHistoryState, onSeedConsumed])
171
+
144
172
  useEffect(() => {
145
173
  if (!placeholderHints || placeholderHints.length < 2) return
146
174
  const timer = setInterval(() => {
@@ -204,6 +232,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
204
232
  })
205
233
  pastedTextRefsRef.current.clear()
206
234
  nextPastedTextRefIdRef.current = 1
235
+ imageRefsRef.current.clear()
236
+ nextImageRefIdRef.current = 1
207
237
  }, [applyBuffer, applyHistoryState])
208
238
 
209
239
  const handlePaste = useCallback((text: string) => {
@@ -244,7 +274,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
244
274
  const submit = useCallback(() => {
245
275
  const trimmed = value.trim()
246
276
  if (!trimmed) return
247
- onSubmit(expandPastedTextRefs(trimmed, pastedTextRefsRef.current))
277
+ const withText = expandPastedTextRefs(trimmed, pastedTextRefsRef.current)
278
+ onSubmit(expandImageRefs(withText, imageRefsRef.current))
248
279
  resetBuffer()
249
280
  }, [value, onSubmit, resetBuffer])
250
281
 
@@ -375,7 +406,12 @@ export const ChatInput: React.FC<PromptInputProps> = ({
375
406
  if (key.meta && inputText === 'v') {
376
407
  void (async () => {
377
408
  const image = await readClipboardImage()
378
- if (image.ok) insertText(`[image: ${image.path}]`)
409
+ if (image.ok) {
410
+ const id = nextImageRefIdRef.current++
411
+ imageRefsRef.current.set(id, { path: image.path })
412
+ insertText(formatImageRefMarker(id))
413
+ onImagePaste?.(image.path)
414
+ }
379
415
  })()
380
416
  return
381
417
  }
@@ -506,6 +542,22 @@ export const ChatInput: React.FC<PromptInputProps> = ({
506
542
 
507
543
  return (
508
544
  <Box flexDirection="column" width="100%">
545
+ {queuedMessages.length > 0 ? (
546
+ <Box marginLeft={2} marginBottom={1} flexDirection="column">
547
+ <Text color={theme.dim}>
548
+ {queuedMessages.length === 1 ? '1 message queued for next turn' : `${queuedMessages.length} messages queued for next turns`}
549
+ </Text>
550
+ {queuedMessages.slice(0, 3).map((message, i) => (
551
+ <Text key={`${i}-${message.slice(0, 24)}`}>
552
+ <Text color={theme.accentPeriwinkle}>{i === 0 ? '» ' : ' '}</Text>
553
+ <Text color={theme.textSubtle}>{summarizeQueuedMessage(message)}</Text>
554
+ </Text>
555
+ ))}
556
+ {queuedMessages.length > 3 ? (
557
+ <Text color={theme.dim}>+{queuedMessages.length - 3} more</Text>
558
+ ) : null}
559
+ </Box>
560
+ ) : null}
509
561
  <Box
510
562
  borderStyle="round"
511
563
  borderColor={borderColor}
@@ -545,7 +597,7 @@ export const ChatInput: React.FC<PromptInputProps> = ({
545
597
  <Box marginLeft={2} flexDirection="column">
546
598
  {filteredSuggestions.map((s, i) => (
547
599
  <Text key={s.name} color={i === suggestionIdx ? theme.accentPeriwinkle : theme.dim}>
548
- {i === suggestionIdx ? '\u203a ' : ' '}/{s.name}
600
+ {i === suggestionIdx ? ' ' : ' '}/{s.name}
549
601
  <Text color={theme.dim}> {s.summary}{i === suggestionIdx ? (s.executeOnEnter ? ' · enter runs' : ' · enter fills') : ''}</Text>
550
602
  </Text>
551
603
  ))}
@@ -555,7 +607,7 @@ export const ChatInput: React.FC<PromptInputProps> = ({
555
607
  <Box marginLeft={2} flexDirection="column">
556
608
  {fileSuggestions.slice(0, 8).map((s, i) => (
557
609
  <Text key={s.path} color={i === fileSuggestionIdx ? theme.accentPeriwinkle : theme.dim}>
558
- {i === fileSuggestionIdx ? '\u203a ' : ' '}@{s.path}
610
+ {i === fileSuggestionIdx ? ' ' : ' '}@{s.path}
559
611
  <Text color={theme.dim}> {i === fileSuggestionIdx ? 'tab/enter completes' : s.hint}</Text>
560
612
  </Text>
561
613
  ))}
@@ -564,22 +616,6 @@ export const ChatInput: React.FC<PromptInputProps> = ({
564
616
  ) : null}
565
617
  </Box>
566
618
  ) : null}
567
- {queuedMessages.length > 0 ? (
568
- <Box marginLeft={2} flexDirection="column">
569
- <Text color={theme.dim}>
570
- {queuedMessages.length === 1 ? '1 message queued for next turn' : `${queuedMessages.length} messages queued for next turns`}
571
- </Text>
572
- {queuedMessages.slice(0, 3).map((message, i) => (
573
- <Text key={`${i}-${message.slice(0, 24)}`}>
574
- <Text color={theme.accentPeriwinkle}>{i === 0 ? '» ' : ' '}</Text>
575
- <Text color={theme.textSubtle}>{summarizeQueuedMessage(message)}</Text>
576
- </Text>
577
- ))}
578
- {queuedMessages.length > 3 ? (
579
- <Text color={theme.dim}>+{queuedMessages.length - 3} more</Text>
580
- ) : null}
581
- </Box>
582
- ) : null}
583
619
  {footerRight ? (
584
620
  <Box marginLeft={2}>
585
621
  {footerRight}
@@ -0,0 +1,30 @@
1
+ export type ImageRef = { path: string; mimeType?: string }
2
+
3
+ const IMAGE_REF_MARKER_RE = /\[Image\s+#(\d+)\]/g
4
+
5
+ export function expandImageRefs(text: string, refs: Map<number, ImageRef>): string {
6
+ return text.replace(IMAGE_REF_MARKER_RE, (full, raw: string) => {
7
+ const ref = refs.get(Number(raw))
8
+ return ref ? `[image: ${ref.path}]` : full
9
+ })
10
+ }
11
+
12
+ export function referencedImageIds(text: string): Set<number> {
13
+ const out = new Set<number>()
14
+ for (const match of text.matchAll(IMAGE_REF_MARKER_RE)) {
15
+ const id = Number(match[1])
16
+ if (Number.isFinite(id)) out.add(id)
17
+ }
18
+ return out
19
+ }
20
+
21
+ export function pruneImageRefs(refs: Map<number, ImageRef>, text: string): void {
22
+ const referenced = referencedImageIds(text)
23
+ for (const id of [...refs.keys()]) {
24
+ if (!referenced.has(id)) refs.delete(id)
25
+ }
26
+ }
27
+
28
+ export function formatImageRefMarker(id: number): string {
29
+ return `[Image #${id}]`
30
+ }
@@ -1,18 +1,17 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react'
2
2
  import { Box, Text, useStdout } from 'ink'
3
- import { useAppInput } from '../app/input/AppInputProvider.js'
4
- import { MessageList, type MessageRow } from './MessageList.js'
5
- import { theme } from '../ui/theme.js'
3
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
4
+ import { MessageList, type MessageRow } from '../MessageList.js'
5
+ import { theme } from '../../ui/theme.js'
6
6
  import {
7
7
  anchorForScrollTop,
8
8
  buildLineOffsets,
9
9
  clampLine,
10
10
  estimateMessageRowHeight,
11
- promptScrollTopForPageDown,
12
- promptScrollTopForPageUp,
13
11
  resolveScrollTopFromAnchor,
12
+ scrollTopForPageDown,
13
+ scrollTopForPageUp,
14
14
  selectRowsForScrollTop,
15
- type TranscriptWindowSelection,
16
15
  type TranscriptViewportState,
17
16
  } from './transcriptViewport.js'
18
17
 
@@ -24,10 +23,10 @@ type TranscriptViewProps = {
24
23
  onScrollabilityChange?: (canScroll: boolean) => void
25
24
  }
26
25
 
27
- const PROMPT_RESERVED_LINES = 11
28
- const OVERLAY_RESERVED_LINES = 16
26
+ const PROMPT_RESERVED_LINES = 7
27
+ const OVERLAY_RESERVED_LINES = 12
29
28
  const MIN_TRANSCRIPT_LINES = 6
30
- const MAX_TRANSCRIPT_LINES = 120
29
+ const MAX_TRANSCRIPT_LINES = 240
31
30
 
32
31
  export const TranscriptView: React.FC<TranscriptViewProps> = ({
33
32
  rows,
@@ -64,15 +63,11 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
64
63
  [metrics, viewportState],
65
64
  )
66
65
  const selection = useMemo(
67
- () => trimSelectionToFocusedTurn(
68
- selectRowsForScrollTop(
69
- rows,
70
- maxLines,
71
- resolvedViewportState.scrollTopLine,
72
- row => estimateMessageRowHeight(row, columns),
73
- ),
66
+ () => selectRowsForScrollTop(
74
67
  rows,
75
- resolvedViewportState,
68
+ maxLines,
69
+ resolvedViewportState.scrollTopLine,
70
+ row => estimateMessageRowHeight(row, columns),
76
71
  ),
77
72
  [columns, maxLines, resolvedViewportState, rows],
78
73
  )
@@ -97,12 +92,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
97
92
 
98
93
  useAppInput((_input, key) => {
99
94
  if (key.pageUp) {
100
- const target = promptScrollTopForPageUp(
101
- rows,
102
- metrics.offsets,
95
+ const target = scrollTopForPageUp(
103
96
  resolvedViewportState.scrollTopLine,
104
97
  metrics.maxScrollTop,
105
- resolvedViewportState.followTail,
98
+ maxLines,
106
99
  )
107
100
  setViewportState(viewportForScrollTop(
108
101
  target,
@@ -111,11 +104,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
111
104
  metrics.maxScrollTop,
112
105
  ))
113
106
  } else if (key.pageDown) {
114
- const target = promptScrollTopForPageDown(
115
- rows,
116
- metrics.offsets,
107
+ const target = scrollTopForPageDown(
117
108
  resolvedViewportState.scrollTopLine,
118
109
  metrics.maxScrollTop,
110
+ maxLines,
119
111
  )
120
112
  setViewportState(viewportForScrollTop(
121
113
  target,
@@ -128,18 +120,21 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
128
120
 
129
121
  return (
130
122
  <Box flexDirection="column">
131
- <Box marginBottom={1}>
132
- <Text> </Text>
133
- </Box>
134
123
  {selection.hiddenBefore > 0 ? (
135
124
  <Text color={theme.dim}>
136
- {` ${selection.hiddenBefore} earlier message${selection.hiddenBefore === 1 ? '' : 's'} above this view`}
125
+ {` ${selection.hiddenBefore} earlier message${selection.hiddenBefore === 1 ? '' : 's'} above · `}
126
+ <Text color={theme.accentPeriwinkle}>pgup</Text>
127
+ {` to scroll · `}
128
+ <Text color={theme.accentPeriwinkle}>/export</Text>
129
+ {` saves the full transcript`}
137
130
  </Text>
138
131
  ) : null}
139
132
  <MessageList rows={selection.rows} />
140
133
  {selection.hiddenAfter > 0 ? (
141
134
  <Text color={theme.dim}>
142
- {` ${selection.hiddenAfter} later message${selection.hiddenAfter === 1 ? '' : 's'} below · pgdn to return`}
135
+ {` ${selection.hiddenAfter} later message${selection.hiddenAfter === 1 ? '' : 's'} below · `}
136
+ <Text color={theme.accentPeriwinkle}>pgdn</Text>
137
+ {` to return`}
143
138
  </Text>
144
139
  ) : null}
145
140
  </Box>
@@ -179,27 +174,6 @@ function viewportForScrollTop(
179
174
  }
180
175
  }
181
176
 
182
- function trimSelectionToFocusedTurn(
183
- selection: TranscriptWindowSelection<MessageRow>,
184
- rows: MessageRow[],
185
- state: TranscriptViewportState,
186
- ): TranscriptWindowSelection<MessageRow> {
187
- if (state.followTail || state.anchor?.offset !== 0) return selection
188
- const focusedIndex = rows.findIndex(row => row.id === state.anchor?.rowId)
189
- if (focusedIndex === -1 || rows[focusedIndex]?.role !== 'user') return selection
190
- const firstSelected = selection.rows[0]
191
- if (!firstSelected || firstSelected.id !== state.anchor.rowId) return selection
192
-
193
- const nextPromptIndex = selection.rows.findIndex((row, index) => index > 0 && row.role === 'user')
194
- if (nextPromptIndex === -1) return selection
195
-
196
- return {
197
- ...selection,
198
- rows: selection.rows.slice(0, nextPromptIndex),
199
- hiddenAfter: selection.hiddenAfter + selection.rows.length - nextPromptIndex,
200
- }
201
- }
202
-
203
177
  function sameViewportState(left: TranscriptViewportState, right: TranscriptViewportState): boolean {
204
178
  return left.scrollTopLine === right.scrollTopLine
205
179
  && left.followTail === right.followTail
@@ -1,4 +1,4 @@
1
- import type { MessageRow } from './MessageList.js'
1
+ import type { MessageRow } from '../MessageList.js'
2
2
 
3
3
  export type TranscriptAnchor = {
4
4
  rowId: string
@@ -141,45 +141,25 @@ export function selectRowsForScrollTop<T>(
141
141
  return selectRowsForLineWindow(rows, offsets, budget, startLine, totalLines, maxScrollOffset)
142
142
  }
143
143
 
144
- export function promptScrollTopForPageUp(
145
- rows: MessageRow[],
146
- offsets: number[],
144
+ export function scrollTopForPageUp(
147
145
  scrollTopLine: number,
148
146
  maxScrollTop: number,
149
- followTail: boolean,
147
+ viewportLines: number,
150
148
  ): number {
151
- const promptStarts = promptScrollTops(rows, offsets)
152
- if (promptStarts.length === 0) return clampLine(scrollTopLine, maxScrollTop)
153
- if (followTail) return clampLine(promptStarts[promptStarts.length - 1]!, maxScrollTop)
154
-
155
- const currentLine = clampLine(scrollTopLine, maxScrollTop)
156
- for (let index = promptStarts.length - 1; index >= 0; index -= 1) {
157
- const line = promptStarts[index]!
158
- if (line < currentLine) return clampLine(line, maxScrollTop)
159
- }
160
- return 0
149
+ return clampLine(scrollTopLine - pageScrollDistance(viewportLines), maxScrollTop)
161
150
  }
162
151
 
163
- export function promptScrollTopForPageDown(
164
- rows: MessageRow[],
165
- offsets: number[],
152
+ export function scrollTopForPageDown(
166
153
  scrollTopLine: number,
167
154
  maxScrollTop: number,
155
+ viewportLines: number,
168
156
  ): number {
169
- const promptStarts = promptScrollTops(rows, offsets)
170
- if (promptStarts.length === 0) return clampLine(scrollTopLine, maxScrollTop)
171
-
172
- const currentLine = clampLine(scrollTopLine, maxScrollTop)
173
- const next = promptStarts.find(line => line > currentLine)
174
- return next === undefined ? maxScrollTop : clampLine(next, maxScrollTop)
157
+ return clampLine(scrollTopLine + pageScrollDistance(viewportLines), maxScrollTop)
175
158
  }
176
159
 
177
- function promptScrollTops(rows: MessageRow[], offsets: number[]): number[] {
178
- const starts: number[] = []
179
- for (let index = 0; index < rows.length; index += 1) {
180
- if (rows[index]?.role === 'user') starts.push(offsets[index] ?? 0)
181
- }
182
- return starts
160
+ function pageScrollDistance(viewportLines: number): number {
161
+ const viewport = Math.max(1, Math.floor(viewportLines))
162
+ return Math.max(1, Math.floor(viewport / 2))
183
163
  }
184
164
 
185
165
  function selectRowsForLineWindow<T>(
@@ -224,6 +204,8 @@ export function estimateMessageRowHeight(row: MessageRow, columns = 80): number
224
204
  return 1 + wrappedLineCount(row.content, contentWidth)
225
205
  case 'progress':
226
206
  return 4
207
+ case 'splash':
208
+ return 28
227
209
  }
228
210
  }
229
211
 
@@ -1,8 +1,8 @@
1
1
  import React, { useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import type { ContextUsage } from '../runtime/compaction.js'
4
- import { useAppInput } from '../app/input/AppInputProvider.js'
5
- import { theme } from '../ui/theme.js'
3
+ import type { ContextUsage } from '../../runtime/compaction.js'
4
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
5
+ import { theme } from '../../ui/theme.js'
6
6
 
7
7
  export type ContextLimitAction = 'compact' | 'switchModel' | 'send' | 'cancel'
8
8
 
@@ -1,13 +1,15 @@
1
1
  import React from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { Surface } from '../ui/Surface.js'
4
- import { Select } from '../ui/Select.js'
5
- import { theme } from '../ui/theme.js'
3
+ import { Surface } from '../../ui/Surface.js'
4
+ import { Select } from '../../ui/Select.js'
5
+ import { theme } from '../../ui/theme.js'
6
+ import { DiffView } from '../display/DiffView.js'
6
7
 
7
8
  export type ContinuityEditReviewState = {
8
9
  file: 'SOUL.md' | 'MEMORY.md'
9
10
  filePath: string
10
11
  summary: string
12
+ diff?: string
11
13
  editorOpened?: boolean
12
14
  }
13
15
 
@@ -23,6 +25,12 @@ export const ContinuityEditReviewView: React.FC<{
23
25
  footer="enter select · esc dismiss"
24
26
  >
25
27
  <Text color={theme.accentPeriwinkle}>{displayContinuityReviewText(review.summary)}</Text>
28
+ {review.diff ? (
29
+ <Box flexDirection="column" marginTop={1}>
30
+ <Text color={theme.accentPeriwinkle}>diff</Text>
31
+ <DiffView diff={review.diff} />
32
+ </Box>
33
+ ) : null}
26
34
  <Box marginTop={1}>
27
35
  <Select<ContinuityEditReviewAction>
28
36
  options={[
@@ -1,8 +1,8 @@
1
1
  import React from 'react'
2
- import { Surface } from '../ui/Surface.js'
3
- import { Select } from '../ui/Select.js'
4
- import { parseSegments, type Segment } from '../utils/markdownSegments.js'
5
- import { copyToClipboard, type CopyResult } from '../utils/clipboard.js'
2
+ import { Surface } from '../../ui/Surface.js'
3
+ import { Select } from '../../ui/Select.js'
4
+ import { parseSegments, type Segment } from '../../utils/markdownSegments.js'
5
+ import { copyToClipboard, type CopyResult } from '../../utils/clipboard.js'
6
6
 
7
7
  type CopyPickerProps = {
8
8
  turnText: string
@@ -48,4 +48,3 @@ export const CopyPicker: React.FC<CopyPickerProps> = ({ turnText, turnLabel, onD
48
48
  </Surface>
49
49
  )
50
50
  }
51
-
@@ -1,9 +1,12 @@
1
1
  import React, { useMemo } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { Surface } from '../ui/Surface.js'
4
- import { Select } from '../ui/Select.js'
5
- import { theme } from '../ui/theme.js'
6
- import type { PermissionDecision, PermissionRequest } from '../tools/contracts.js'
3
+ import { Surface } from '../../ui/Surface.js'
4
+ import { Select } from '../../ui/Select.js'
5
+ import { theme } from '../../ui/theme.js'
6
+ import { DiffView, diffLineColor } from '../display/DiffView.js'
7
+ import type { PermissionDecision, PermissionRequest } from '../../tools/contracts.js'
8
+
9
+ export { diffLineColor as permissionDiffLineColor }
7
10
 
8
11
  type PermissionPromptProps = {
9
12
  request: PermissionRequest
@@ -34,7 +37,7 @@ export const PermissionPrompt: React.FC<PermissionPromptProps> = ({ request, onD
34
37
  <Box marginTop={1}>
35
38
  <Text color={theme.accentPeriwinkle}>diff</Text>
36
39
  </Box>
37
- <Text color={theme.text}>{request.diff}</Text>
40
+ <DiffView diff={request.diff} />
38
41
  </Box>
39
42
  ) : null}
40
43
  {request.kind === 'private-continuity-read' ? (
@@ -51,13 +54,9 @@ export const PermissionPrompt: React.FC<PermissionPromptProps> = ({ request, onD
51
54
  <Box flexDirection="column" marginBottom={1}>
52
55
  <Text color={theme.accentPeriwinkle}>{displayPermissionText(request.changeSummary)}</Text>
53
56
  <Box marginTop={1}>
54
- <Text color={theme.textSubtle}>before</Text>
55
- </Box>
56
- <Text color={theme.textSubtle}>{request.before || '(empty)'}</Text>
57
- <Box marginTop={1}>
58
- <Text color={theme.accentPeriwinkle}>after</Text>
57
+ <Text color={theme.accentPeriwinkle}>diff</Text>
59
58
  </Box>
60
- <Text color={theme.text}>{request.after || '(empty)'}</Text>
59
+ <DiffView diff={request.diff} />
61
60
  </Box>
62
61
  ) : null}
63
62
  {request.kind === 'bash' && request.warning ? (
@@ -73,20 +72,20 @@ export const PermissionPrompt: React.FC<PermissionPromptProps> = ({ request, onD
73
72
  export function permissionOptionsForRequest(request: PermissionRequest): Array<{ value: PermissionDecision; label: string; hint?: string; disabled?: boolean }> {
74
73
  if (request.kind === 'bash') {
75
74
  return [
76
- { value: 'allow-once', label: 'allow once', hint: 'approve only this command execution' },
75
+ { value: 'allow-once', label: 'Allow Once', hint: 'Approve only this command execution' },
77
76
  {
78
77
  value: 'allow-command-project',
79
- label: 'always allow this exact command',
80
- hint: 'remember this command text for this project',
78
+ label: 'Allow Exact Command',
79
+ hint: 'Remember this exact command for this project',
81
80
  disabled: !request.canPersistExact,
82
81
  },
83
82
  {
84
83
  value: 'allow-command-prefix-project',
85
- label: request.commandPrefix ? `always allow ${request.commandPrefix} commands` : 'allow command prefix',
86
- hint: 'remember this base command in this working directory for this project',
84
+ label: request.commandPrefix ? `Allow ${request.commandPrefix} Commands` : 'Allow Command Family',
85
+ hint: 'Remember this base command in this working directory for this project',
87
86
  disabled: !request.canPersistPrefix,
88
87
  },
89
- { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
88
+ { value: 'deny', label: 'Deny', hint: 'Return a denial to the model' },
90
89
  ]
91
90
  }
92
91
 
@@ -1,11 +1,11 @@
1
1
  import React, { useEffect, useMemo, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { clearPermissionRules, deletePermissionRule, loadPermissionRules } from '../storage/permissions.js'
4
- import type { SessionPermissionRule } from '../tools/contracts.js'
5
- import { Select, type SelectOption } from '../ui/Select.js'
6
- import { Spinner } from '../ui/Spinner.js'
7
- import { Surface } from '../ui/Surface.js'
8
- import { theme } from '../ui/theme.js'
3
+ import { clearPermissionRules, deletePermissionRule, loadPermissionRules } from '../../storage/permissions.js'
4
+ import type { SessionPermissionRule } from '../../tools/contracts.js'
5
+ import { Select, type SelectOption } from '../../ui/Select.js'
6
+ import { Spinner } from '../../ui/Spinner.js'
7
+ import { Surface } from '../../ui/Surface.js'
8
+ import { theme } from '../../ui/theme.js'
9
9
 
10
10
  type PermissionsViewProps = {
11
11
  cwd: string
@@ -1,8 +1,8 @@
1
1
  import React, { useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { Surface } from '../ui/Surface.js'
4
- import { theme } from '../ui/theme.js'
5
- import { useAppInput } from '../app/input/AppInputProvider.js'
3
+ import { Surface } from '../../ui/Surface.js'
4
+ import { theme } from '../../ui/theme.js'
5
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
6
6
 
7
7
  export type PlanApprovalAction = 'apply' | 'apply-summary' | 'continue'
8
8
 
@@ -32,7 +32,7 @@ export const PLAN_APPROVAL_OPTIONS: Array<{
32
32
  },
33
33
  {
34
34
  value: 'continue',
35
- label: 'No, stay in Plan mode',
35
+ label: 'No, stay in plan mode',
36
36
  title: 'Continue planning with the model.',
37
37
  detail: () => 'No files will be changed.',
38
38
  },