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
@@ -3,11 +3,16 @@ import { Box, Text } from 'ink'
3
3
  import { theme } from '../ui/theme.js'
4
4
  import { ProgressBar } from '../ui/ProgressBar.js'
5
5
  import { Spinner } from '../ui/Spinner.js'
6
+ import { DiffView } from './display/DiffView.js'
7
+ import { SyntaxLine } from './display/SyntaxText.js'
8
+ import { formatToolCall } from './display/toolCallDisplay.js'
9
+ import { BrandSplash } from '../ui/BrandSplash.js'
6
10
 
7
11
  export type ToolCallResult = {
8
12
  content: string
9
13
  summary: string
10
14
  isError: boolean
15
+ diff?: string
11
16
  }
12
17
 
13
18
  export type MessageRow =
@@ -19,7 +24,7 @@ export type MessageRow =
19
24
  id: string
20
25
  name: string
21
26
  summary: string
22
- input?: string
27
+ input?: Record<string, unknown>
23
28
  result?: ToolCallResult
24
29
  }
25
30
  | { role: 'note'; id: string; kind: 'info' | 'error' | 'dim'; content: string }
@@ -34,6 +39,13 @@ export type MessageRow =
34
39
  indeterminate?: boolean
35
40
  startedAt?: number
36
41
  }
42
+ | {
43
+ role: 'splash'
44
+ id: string
45
+ contextLine?: string
46
+ tipLine?: string
47
+ updateNotice?: string | null
48
+ }
37
49
 
38
50
  type MessageListProps = {
39
51
  rows: MessageRow[]
@@ -55,11 +67,18 @@ type InlineToken =
55
67
  const MAX_RENDERED_MESSAGE_CHARS = 12_000
56
68
  const MAX_RENDERED_REASONING_CHARS = 10_000
57
69
  const ASSISTANT_ACCENT = theme.accentPeriwinkle
70
+ const ASSISTANT_MARKER = '• '
58
71
  const UNREADABLE_REASONING_TEXT = 'reasoning output was not readable text'
59
72
 
60
73
  const MessageListInner: React.FC<MessageListProps> = ({ rows }) => (
61
74
  <Box flexDirection="column">
62
- {rows.map(row => <RowView key={row.id} row={row} />)}
75
+ {rows.map((row, index) => (
76
+ <RowView
77
+ key={row.id}
78
+ row={row}
79
+ tightTop={row.role === 'tool_call' && rows[index - 1]?.role === 'tool_call'}
80
+ />
81
+ ))}
63
82
  </Box>
64
83
  )
65
84
 
@@ -102,7 +121,7 @@ export function toggleInspectableRow(rows: MessageRow[], rowId?: string): Messag
102
121
  return rows
103
122
  }
104
123
 
105
- const RowViewInner: React.FC<{ row: MessageRow }> = ({ row }) => {
124
+ const RowViewInner: React.FC<{ row: MessageRow; tightTop?: boolean }> = ({ row, tightTop }) => {
106
125
  if (row.role === 'user') {
107
126
  const display = clipTextForDisplay(row.content, MAX_RENDERED_MESSAGE_CHARS)
108
127
  const lines = display.text.length === 0 ? [''] : display.text.split('\n')
@@ -132,48 +151,65 @@ const RowViewInner: React.FC<{ row: MessageRow }> = ({ row }) => {
132
151
  if (row.role === 'thinking') {
133
152
  const text = sanitizeReasoningForDisplay(reasoningText(row))
134
153
  const preview = summarizeThinking(text)
135
- const borderColor = reasoningBorderColor(row)
154
+ const active = Boolean(row.streaming)
136
155
  const showCursor = reasoningCursorVisible(row)
137
156
  if (row.expanded) {
138
157
  return (
139
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={borderColor} paddingX={1}>
140
- <Text>
141
- <Text color={theme.accentPeriwinkle} bold>reasoning</Text>
142
- <Text color={theme.dim}> · expanded · alt+t collapse</Text>
143
- </Text>
144
- <ReasoningBody content={text} showCursor={showCursor} />
145
- </Box>
158
+ <ReasoningBlock
159
+ content={text}
160
+ detail="alt+t collapse"
161
+ expanded
162
+ active={active}
163
+ showCursor={showCursor}
164
+ />
146
165
  )
147
166
  }
148
167
  return (
149
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={borderColor} paddingX={1}>
150
- <Text>
151
- <Text color={theme.accentPeriwinkle} bold>reasoning</Text>
152
- <Text color={theme.dim}> · collapsed · alt+t inspect</Text>
153
- </Text>
154
- <Text color={theme.textSubtle}>
155
- {preview || 'thinking...'}
156
- {showCursor ? <ThinkingCursor active hasPreview={Boolean(preview)} /> : null}
157
- </Text>
158
- </Box>
168
+ <ReasoningBlock
169
+ content={preview || 'thinking...'}
170
+ detail="alt+t inspect"
171
+ active={active}
172
+ showCursor={showCursor}
173
+ />
159
174
  )
160
175
  }
161
176
 
162
177
  if (row.role === 'tool_call') {
178
+ const { displayName, argSummary } = formatToolCall(row.name, row.input)
163
179
  const result = row.result
164
- const inputPreview = row.input ? truncateToolInputForLine(row.input, 60) : ''
180
+ const showResultLine = !result || result.isError
165
181
  return (
166
- <Box marginTop={1}>
182
+ <Box flexDirection="column" marginTop={tightTop ? 0 : 1}>
167
183
  <Text>
168
- <Text color={theme.dim}>{'· '}</Text>
169
- <Text color={theme.accentPeriwinkle} bold>{row.name}</Text>
170
- {inputPreview ? <Text color={theme.textSubtle}>{` ${inputPreview}`}</Text> : null}
171
- {result ? (
172
- <Text color={result.isError ? theme.accentError : theme.dim}>{` ${result.summary}`}</Text>
173
- ) : (
174
- <Text color={theme.dim}>{' running…'}</Text>
175
- )}
184
+ <Text color={theme.dim}>{' '}</Text>
185
+ <Text color={theme.accentPeriwinkle} bold>{displayName}</Text>
186
+ {row.name !== 'run_bash' && argSummary ? <Text color={theme.textSubtle}>{` ${argSummary}`}</Text> : null}
187
+ {result && !result.isError ? <Text color={theme.dim}>{' done'}</Text> : null}
176
188
  </Text>
189
+ {row.name === 'run_bash' && argSummary ? (
190
+ <Box marginLeft={2}>
191
+ <Text>
192
+ <Text color={theme.dim}>{'$ '}</Text>
193
+ <Text color={theme.text}>{argSummary}</Text>
194
+ </Text>
195
+ </Box>
196
+ ) : null}
197
+ {showResultLine ? (
198
+ result ? (
199
+ <Box marginLeft={2}>
200
+ <Text color={result.isError ? theme.accentError : theme.dim}>{result.summary}</Text>
201
+ </Box>
202
+ ) : (
203
+ <Box marginLeft={2}>
204
+ <Text color={theme.dim}>running…</Text>
205
+ </Box>
206
+ )
207
+ ) : null}
208
+ {result?.diff && !result.isError ? (
209
+ <Box flexDirection="column" marginLeft={2}>
210
+ <DiffView diff={result.diff} />
211
+ </Box>
212
+ ) : null}
177
213
  </Box>
178
214
  )
179
215
  }
@@ -187,6 +223,16 @@ const RowViewInner: React.FC<{ row: MessageRow }> = ({ row }) => {
187
223
  )
188
224
  }
189
225
 
226
+ if (row.role === 'splash') {
227
+ return (
228
+ <BrandSplash
229
+ contextLine={row.contextLine}
230
+ tipLine={row.tipLine}
231
+ updateNotice={row.updateNotice ?? null}
232
+ />
233
+ )
234
+ }
235
+
190
236
  return (
191
237
  <Box flexDirection="column" marginTop={1}>
192
238
  <Text color={theme.accentPeriwinkle} bold>{row.title}</Text>
@@ -208,21 +254,60 @@ const ProgressSpinner: React.FC<{ row: Extract<MessageRow, { role: 'progress' }>
208
254
  return <Spinner active label={row.status} hint={row.suffix} startedAt={row.startedAt} />
209
255
  }
210
256
 
211
- export function reasoningBorderColor(row: Extract<MessageRow, { role: 'thinking' }>): string {
212
- return row.streaming ? theme.accentPeriwinkle : theme.border
257
+ const ShimmerText: React.FC<{
258
+ text: string
259
+ color: string
260
+ active?: boolean
261
+ bold?: boolean
262
+ italic?: boolean
263
+ }> = ({ text, color, active = false, bold, italic }) => {
264
+ const [position, setPosition] = useState(0)
265
+
266
+ useEffect(() => {
267
+ if (!active) return
268
+ const period = text.length + 8
269
+ const timer = setInterval(() => {
270
+ setPosition(prev => (prev + 1) % period)
271
+ }, 90)
272
+ return () => clearInterval(timer)
273
+ }, [active, text.length])
274
+
275
+ if (!active) {
276
+ return <Text color={color} bold={bold} italic={italic}>{text}</Text>
277
+ }
278
+
279
+ const shimmerStart = position - 1
280
+ const shimmerEnd = position + 1
281
+ const visibleStart = Math.max(0, shimmerStart)
282
+ const visibleEnd = Math.min(text.length, shimmerEnd + 1)
283
+ const before = text.slice(0, visibleStart)
284
+ const shimmer = shimmerStart < text.length && shimmerEnd >= 0 ? text.slice(visibleStart, visibleEnd) : ''
285
+ const after = text.slice(visibleEnd)
286
+
287
+ return (
288
+ <>
289
+ {before ? <Text color={color} bold={bold} italic={italic}>{before}</Text> : null}
290
+ {shimmer ? <Text color={theme.accentWhite} bold italic={italic}>{shimmer}</Text> : null}
291
+ {after ? <Text color={color} bold={bold} italic={italic}>{after}</Text> : null}
292
+ </>
293
+ )
213
294
  }
214
295
 
215
- function truncateToolInputForLine(input: string, max: number): string {
216
- const flat = input.replace(/\s+/g, ' ').trim()
217
- if (flat.length <= max) return flat
218
- return `${flat.slice(0, Math.max(1, max - 1))}…`
296
+ export function reasoningBorderColor(row: Extract<MessageRow, { role: 'thinking' }>): string {
297
+ return row.streaming ? theme.accentPeriwinkle : theme.border
219
298
  }
220
299
 
221
300
  export function reasoningCursorVisible(row: Extract<MessageRow, { role: 'thinking' }>): boolean {
222
301
  return Boolean(row.streaming && row.showCursor)
223
302
  }
224
303
 
225
- const ReasoningBody: React.FC<{ content: string; showCursor?: boolean }> = ({ content, showCursor }) => {
304
+ const ReasoningBlock: React.FC<{
305
+ content: string
306
+ detail: string
307
+ active?: boolean
308
+ expanded?: boolean
309
+ showCursor?: boolean
310
+ }> = ({ content, detail, active = false, expanded = false, showCursor }) => {
226
311
  const display = useMemo(
227
312
  () => clipTextForDisplay(content, MAX_RENDERED_REASONING_CHARS),
228
313
  [content],
@@ -233,16 +318,35 @@ const ReasoningBody: React.FC<{ content: string; showCursor?: boolean }> = ({ co
233
318
  }, [display.text])
234
319
 
235
320
  return (
236
- <Box flexDirection="column">
321
+ <Box flexDirection="column" marginTop={1}>
237
322
  {display.omittedChars > 0 ? (
238
323
  <Text color={theme.dim}>{`${display.omittedChars} earlier reasoning characters omitted`}</Text>
239
324
  ) : null}
240
- {lines.map((line, index) => (
241
- <Text key={index} color={theme.textSubtle}>
242
- {line || ' '}
243
- {showCursor && index === lines.length - 1 ? <ThinkingCursor active hasPreview={line.length > 0} /> : null}
244
- </Text>
245
- ))}
325
+ <Text>
326
+ <AssistantMarker />
327
+ <ShimmerText
328
+ text={expanded ? 'Thinking…' : 'Thinking'}
329
+ color={theme.accentPeriwinkle}
330
+ active={active}
331
+ bold
332
+ italic
333
+ />
334
+ <Text color={theme.dim}>{` · ${detail}`}</Text>
335
+ {!expanded && showCursor ? <ThinkingCursor active hasPreview /> : null}
336
+ </Text>
337
+ {expanded ? (
338
+ <Box flexDirection="column" marginLeft={2}>
339
+ {lines.map((line, index) => (
340
+ <Box key={index}>
341
+ <Text color={theme.textSubtle}>
342
+ <Text color={theme.dim}>{' '}</Text>
343
+ {line || ' '}
344
+ {showCursor && index === lines.length - 1 ? <ThinkingCursor active hasPreview={line.length > 0} /> : null}
345
+ </Text>
346
+ </Box>
347
+ ))}
348
+ </Box>
349
+ ) : null}
246
350
  </Box>
247
351
  )
248
352
  }
@@ -269,22 +373,33 @@ const AssistantBody: React.FC<{ content: string; liveTail?: string; streaming?:
269
373
  key={index}
270
374
  block={block}
271
375
  streaming={streaming && index === blocks.length - 1}
376
+ prefix={index === 0 || block.kind === 'code' ? <AssistantMarker /> : null}
272
377
  />
273
378
  ))}
274
379
  {streaming && blocks.length === 0 ? (
275
- <Text color={ASSISTANT_ACCENT}>
276
- <StreamCursor active />
380
+ <Text>
381
+ <AssistantMarker />
382
+ <Text color={ASSISTANT_ACCENT}><StreamCursor active /></Text>
277
383
  </Text>
278
384
  ) : null}
279
385
  </Box>
280
386
  )
281
387
  }
282
388
 
283
- const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }> = ({ block, streaming = false }) => {
389
+ const AssistantMarker: React.FC = () => (
390
+ <Text color={theme.dim}>{ASSISTANT_MARKER}</Text>
391
+ )
392
+
393
+ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean; prefix?: React.ReactNode }> = ({
394
+ block,
395
+ streaming = false,
396
+ prefix = null,
397
+ }) => {
284
398
  if (block.kind === 'heading') {
285
399
  return (
286
400
  <Box flexDirection="column" marginTop={1}>
287
401
  <Text>
402
+ {prefix}
288
403
  <InlineText text={block.text} color={ASSISTANT_ACCENT} bold />
289
404
  </Text>
290
405
  </Box>
@@ -296,6 +411,7 @@ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }>
296
411
  <Box flexDirection="column" marginTop={1}>
297
412
  {block.lines.map((line, index) => (
298
413
  <Text key={index}>
414
+ {index === 0 ? prefix : null}
299
415
  <Text color={ASSISTANT_ACCENT}>| </Text>
300
416
  <InlineText text={line} color={theme.dim} />
301
417
  </Text>
@@ -309,6 +425,7 @@ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }>
309
425
  <Box flexDirection="column" marginTop={1}>
310
426
  {block.items.map((item, index) => (
311
427
  <Text key={index}>
428
+ {index === 0 ? prefix : null}
312
429
  <Text color={ASSISTANT_ACCENT}>{block.ordered ? `${index + 1}. ` : '- '}</Text>
313
430
  <InlineText text={item} color={theme.text} />
314
431
  </Text>
@@ -319,18 +436,20 @@ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }>
319
436
 
320
437
  if (block.kind === 'code') {
321
438
  const lines = block.code.length === 0 ? [''] : block.code.split('\n')
322
- const accent = codeAccent(block.lang)
439
+ const isShell = block.lang === 'bash' || block.lang === 'sh'
323
440
  return (
324
- <Box flexDirection="column" marginTop={1} borderStyle="round" borderColor={accent}>
325
- <Box paddingX={1}>
326
- <Text color={accent} bold>{block.lang ? block.lang : 'code'}</Text>
327
- <Text color={theme.dim}>{block.open ? ' · streaming' : ' · block'}</Text>
328
- </Box>
329
- <Box flexDirection="column" paddingX={1}>
441
+ <Box flexDirection="column" marginTop={1}>
442
+ <Text>
443
+ {prefix}
444
+ <Text color={theme.accentPeriwinkle} bold>{block.lang ?? 'code'}</Text>
445
+ {block.open ? <Text color={theme.dim}> streaming</Text> : null}
446
+ </Text>
447
+ <Box flexDirection="column" marginLeft={2}>
330
448
  {lines.map((line, index) => (
331
449
  <Text key={index}>
332
- <Text color={theme.dim}>{`${String(index + 1).padStart(2, '0')} `}</Text>
333
- <Text color={codeLineColor(block.lang, line)}>{line || ' '}</Text>
450
+ <Text color={theme.dim}>{isShell ? '$ ' : ' '}</Text>
451
+ <SyntaxLine line={line} lang={block.lang} fallbackColor={theme.textSubtle} />
452
+ {block.open && index === lines.length - 1 ? <Text color={ASSISTANT_ACCENT}> <StreamCursor active /></Text> : null}
334
453
  </Text>
335
454
  ))}
336
455
  </Box>
@@ -341,6 +460,7 @@ const MarkdownBlockView: React.FC<{ block: MarkdownBlock; streaming?: boolean }>
341
460
  return (
342
461
  <Box flexDirection="column" marginTop={1}>
343
462
  <Text>
463
+ {prefix}
344
464
  <InlineText text={block.text} color={theme.text} />
345
465
  {streaming ? <Text color={ASSISTANT_ACCENT}> <StreamCursor active /></Text> : null}
346
466
  </Text>
@@ -408,6 +528,14 @@ const StreamCursor: React.FC<{ active: boolean }> = ({ active }) => {
408
528
  return <>{visible ? '|' : ' '}</>
409
529
  }
410
530
 
531
+ function blockContentWidth(lines: string[]): number {
532
+ return Math.max(1, ...lines.map(displayWidth))
533
+ }
534
+
535
+ function displayWidth(line: string): number {
536
+ return (line || ' ').replace(/\t/g, ' ').length
537
+ }
538
+
411
539
  function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
412
540
  const text = markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
413
541
  if (!text.trim()) return []
@@ -505,21 +633,6 @@ function parseMarkdownBlocks(markdown: string): MarkdownBlock[] {
505
633
  return blocks
506
634
  }
507
635
 
508
- function codeAccent(_lang: string | null): string {
509
- return ASSISTANT_ACCENT
510
- }
511
-
512
- function codeLineColor(lang: string | null, line: string): string {
513
- const trimmed = line.trim()
514
- if (!trimmed) return theme.textSubtle
515
- if ((lang === 'json' || lang === 'jsonc') && /^["[{]/.test(trimmed)) return ASSISTANT_ACCENT
516
- if (/^(\/\/|#|\/\*|\*)/.test(trimmed)) return theme.dim
517
- if (/\b(function|const|let|return|if|else|class|export|import)\b/.test(trimmed)) return theme.text
518
- if (/<\/?[A-Za-z]/.test(trimmed)) return ASSISTANT_ACCENT
519
- if (/^[.#@]/.test(trimmed)) return ASSISTANT_ACCENT
520
- return theme.textSubtle
521
- }
522
-
523
636
  function parseInlineTokens(text: string): InlineToken[] {
524
637
  const tokens: InlineToken[] = []
525
638
  const source = normalizeInlineDisplayText(text)
@@ -3,6 +3,8 @@ import { Box, Text } from 'ink'
3
3
  import type { ContextUsage } from '../runtime/compaction.js'
4
4
  import { theme } from '../ui/theme.js'
5
5
  import { formatModelDisplayName } from '../models/modelDisplay.js'
6
+ import { providerDisplayName } from '../models/providerDisplay.js'
7
+ import type { ProviderId } from '../storage/config.js'
6
8
 
7
9
  type StatusBarProps = {
8
10
  provider: string
@@ -25,7 +27,7 @@ const SessionStatusInner: React.FC<StatusBarProps> = ({
25
27
  return (
26
28
  <Box flexDirection="row">
27
29
  <Text color={theme.dim}>
28
- {provider} · {displayModel} · {turns} {turns === 1 ? 'turn' : 'turns'} · ~{formatTokens(approxTokens)} tokens · Context {contextUsage.percent}% (~{formatTokens(contextUsage.usedTokens)} / {formatTokens(contextUsage.windowTokens)}) · {formatElapsed(Date.now() - startedAt)}
30
+ {providerDisplayName(provider as ProviderId)} · {displayModel} · {turns} {turns === 1 ? 'turn' : 'turns'} · ~{formatTokens(approxTokens)} tokens · Context {contextUsage.percent}% (~{formatTokens(contextUsage.usedTokens)} / {formatTokens(contextUsage.windowTokens)}) · {formatElapsed(Date.now() - startedAt)}
29
31
  </Text>
30
32
  </Box>
31
33
  )
@@ -11,7 +11,7 @@ import {
11
11
  type SessionMessage,
12
12
  } from '../storage/sessions.js'
13
13
  import type { MessageRow } from './MessageList.js'
14
- import { hidesSuccessfulToolResultContent } from './toolResultDisplay.js'
14
+ import { hidesSuccessfulToolResultContent, toolResultDiffContent, toolResultTextContent } from './display/toolResultDisplay.js'
15
15
 
16
16
  export type TurnCheckpoint = {
17
17
  sessionId: string
@@ -62,7 +62,7 @@ export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: ()
62
62
  id: nextRowId(),
63
63
  name: msg.name,
64
64
  summary: msg.name,
65
- input: summarizeToolInput(msg.input),
65
+ input: msg.input,
66
66
  }
67
67
  restored.push(row)
68
68
  toolCallByUseId.set(msg.toolUseId, row)
@@ -70,9 +70,10 @@ export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: ()
70
70
  const isError = Boolean(msg.isError)
71
71
  const summary = isError ? `${msg.name} failed` : `${msg.name} completed`
72
72
  const content = toolResultContentForRow(msg.name, msg.content, msg.isError)
73
+ const diff = toolResultDiffForRow(msg.content, msg.isError)
73
74
  const existing = toolCallByUseId.get(msg.toolUseId)
74
75
  if (existing) {
75
- existing.result = { content, summary, isError }
76
+ existing.result = diff ? { content, summary, isError, diff } : { content, summary, isError }
76
77
  existing.name = msg.name
77
78
  } else {
78
79
  restored.push({
@@ -80,7 +81,7 @@ export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: ()
80
81
  id: nextRowId(),
81
82
  name: msg.name,
82
83
  summary: msg.name,
83
- result: { content, summary, isError },
84
+ result: diff ? { content, summary, isError, diff } : { content, summary, isError },
84
85
  })
85
86
  }
86
87
  }
@@ -88,23 +89,18 @@ export function sessionMessagesToRows(messages: SessionMessage[], nextRowId: ()
88
89
  return restored
89
90
  }
90
91
 
91
- export function summarizeToolInput(input: Record<string, unknown>): string {
92
- try {
93
- const text = JSON.stringify(input)
94
- if (text.length <= 160) return text
95
- return `${text.slice(0, 157)}...`
96
- } catch {
97
- return '[unserializable input]'
98
- }
99
- }
100
-
101
92
  export function truncateForRow(text: string, max = 1200): string {
102
93
  if (text.length <= max) return text
103
94
  return `${text.slice(0, max - 3)}...`
104
95
  }
105
96
 
106
97
  export function toolResultContentForRow(name: string, content: string, isError?: boolean): string {
107
- return hidesSuccessfulToolResultContent(name, isError) ? '' : truncateForRow(content)
98
+ const textContent = toolResultTextContent(content)
99
+ return hidesSuccessfulToolResultContent(name, isError) ? '' : truncateForRow(textContent)
100
+ }
101
+
102
+ export function toolResultDiffForRow(content: string, isError?: boolean): string | undefined {
103
+ return toolResultDiffContent(content, isError)
108
104
  }
109
105
 
110
106
  export function splitStreamingContent(text: string): { committed: string; liveTail: string } {
@@ -39,6 +39,7 @@ export function resolveModelSelection(
39
39
  selection.model === currentConfig.model
40
40
  && currentConfig.provider === 'llamacpp'
41
41
  && currentConfig.baseUrl === baseUrl
42
+ && currentConfig.localMmprojPath === selection.mmprojPath
42
43
  ) {
43
44
  return { kind: 'noop' }
44
45
  }
@@ -49,8 +50,9 @@ export function resolveModelSelection(
49
50
  provider: 'llamacpp',
50
51
  model: selection.model,
51
52
  baseUrl,
53
+ localMmprojPath: selection.mmprojPath,
52
54
  },
53
- notice: `Local Hugging Face model ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}.`,
55
+ notice: `Local Hugging Face model ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}${selection.mmprojPath ? ' with vision encoder' : ''}.`,
54
56
  tone: 'info',
55
57
  }
56
58
  }
@@ -65,6 +67,7 @@ export function resolveModelSelection(
65
67
  provider: nextProvider,
66
68
  model: selection.model,
67
69
  baseUrl: nextBaseUrl,
70
+ localMmprojPath: undefined,
68
71
  }
69
72
 
70
73
  return {
@@ -102,7 +105,7 @@ export function buildResumedSessionState(args: {
102
105
  function formatResumeNote(metadata: SessionMetadata | null): string {
103
106
  const id = metadata?.id?.slice(0, 8) ?? ''
104
107
  const source = metadata?.compactedFromSessionId ? ` summarized from ${metadata.compactedFromSessionId.slice(0, 8)}` : ''
105
- return `resumed from session ${id}.${source}`.trim()
108
+ return `Resumed from session ${id}.${source}`.trim()
106
109
  }
107
110
 
108
111
  export function restoreConversationState(
@@ -15,6 +15,7 @@ import {
15
15
  createTurnCheckpoint,
16
16
  type TurnCheckpoint,
17
17
  } from './chatScreenUtils.js'
18
+ import { collapseImagePathsToRefs, userTextToContentBlocks } from '../utils/images.js'
18
19
 
19
20
  type MutableRef<T> = { current: T }
20
21
 
@@ -101,10 +102,13 @@ export async function runStreamingTurn(
101
102
  const activeCheckpoint = createTurnCheckpoint(sessionId, userText)
102
103
  setActiveCheckpoint(activeCheckpoint)
103
104
 
104
- updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: userText }])
105
+ const userContent = userTextToContentBlocks(userText)
106
+ const displayText = collapseImagePathsToRefs(userText)
107
+ updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: displayText }])
105
108
  await persistTurnMessage({
106
109
  role: 'user',
107
- content: userText,
110
+ content: displayText,
111
+ providerContent: typeof userContent === 'string' ? undefined : userContent,
108
112
  createdAt: nowIso(),
109
113
  turnId: activeCheckpoint.turnId,
110
114
  })
@@ -127,7 +131,6 @@ export async function runStreamingTurn(
127
131
  let thinkingRowId: string | null = null
128
132
  let thinkingCursorActive = false
129
133
  let assistantId: string | null = null
130
- let hasPendingToolUse = false
131
134
 
132
135
  const resetIteration = () => {
133
136
  accumulated = ''
@@ -135,7 +138,6 @@ export async function runStreamingTurn(
135
138
  thinkingRowId = null
136
139
  thinkingCursorActive = false
137
140
  assistantId = null
138
- hasPendingToolUse = false
139
141
  }
140
142
 
141
143
  const stopThinkingCursor = () => {
@@ -192,7 +194,7 @@ export async function runStreamingTurn(
192
194
  flushStreamRows(true)
193
195
  updateRows(prev => {
194
196
  let next = finalizeStreamingRowsById(prev, assistantId, thinkingRowId, accumulated, thinkingContent)
195
- if (assistantId && (hasPendingToolUse || accumulated.length === 0)) {
197
+ if (assistantId && accumulated.length === 0) {
196
198
  next = next.filter(r => r.id !== assistantId)
197
199
  }
198
200
  return next
@@ -294,7 +296,6 @@ export async function runStreamingTurn(
294
296
  setThinkingRowId: id => { thinkingRowId = id },
295
297
  markThinkingCursorActive: () => { thinkingCursorActive = true },
296
298
  getThinkingRowId: () => thinkingRowId,
297
- markPendingToolUse: () => { hasPendingToolUse = true },
298
299
  updateRows,
299
300
  pushNote,
300
301
  nextRowId,
@@ -340,7 +341,6 @@ type EventHandlerContext = {
340
341
  setThinkingRowId: (id: string | null) => void
341
342
  getThinkingRowId: () => string | null
342
343
  markThinkingCursorActive: () => void
343
- markPendingToolUse: () => void
344
344
  updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
345
345
  pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
346
346
  nextRowId: () => string
@@ -403,7 +403,6 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
403
403
  return
404
404
  }
405
405
  case 'tool_use_stop': {
406
- ctx.markPendingToolUse()
407
406
  ctx.finalizeStreamingRows()
408
407
  return
409
408
  }
@@ -426,7 +425,6 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
426
425
  }
427
426
  case 'local_tool_recovery': {
428
427
  ctx.discardStreamingRows()
429
- ctx.markPendingToolUse()
430
428
  return
431
429
  }
432
430
  case 'continuation_nudge': {