ethagent 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/package.json +2 -1
  2. package/src/app/FirstRun.tsx +1 -7
  3. package/src/app/FirstRunTimeline.tsx +1 -1
  4. package/src/auth/openaiOAuth/credentials.ts +47 -0
  5. package/src/auth/openaiOAuth/crypto.ts +23 -0
  6. package/src/auth/openaiOAuth/index.ts +238 -0
  7. package/src/auth/openaiOAuth/landingPage.ts +125 -0
  8. package/src/auth/openaiOAuth/listener.ts +151 -0
  9. package/src/auth/openaiOAuth/refresh.ts +70 -0
  10. package/src/auth/openaiOAuth/shared.ts +115 -0
  11. package/src/chat/ChatBottomPane.tsx +20 -11
  12. package/src/chat/ChatScreen.tsx +160 -35
  13. package/src/chat/ConversationStack.tsx +1 -1
  14. package/src/chat/MessageList.tsx +185 -72
  15. package/src/chat/SessionStatus.tsx +3 -1
  16. package/src/chat/chatScreenUtils.ts +11 -15
  17. package/src/chat/chatSessionState.ts +3 -2
  18. package/src/chat/chatTurnOrchestrator.ts +1 -7
  19. package/src/chat/commands.ts +28 -27
  20. package/src/chat/display/DiffView.tsx +193 -0
  21. package/src/chat/display/SyntaxText.tsx +192 -0
  22. package/src/chat/display/toolCallDisplay.ts +103 -0
  23. package/src/chat/display/toolResultDisplay.ts +19 -0
  24. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +36 -23
  25. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  26. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  27. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  28. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  29. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  30. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  31. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  32. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  33. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +35 -35
  34. package/src/chat/views/RewindView.tsx +410 -0
  35. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  36. package/src/identity/ens/agentRecords.ts +5 -19
  37. package/src/identity/ens/ensAutomation/setup.ts +0 -1
  38. package/src/identity/ens/ensAutomation/types.ts +0 -1
  39. package/src/identity/hub/OperationalRoutes.tsx +23 -32
  40. package/src/identity/hub/Routes.tsx +13 -13
  41. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  42. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  43. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  44. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  45. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +19 -19
  46. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  47. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  48. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  49. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  50. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  51. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +10 -48
  52. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +11 -9
  53. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  54. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  55. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  56. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  57. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +6 -6
  58. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  59. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  60. package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +241 -0
  61. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +27 -82
  62. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +25 -65
  63. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -30
  64. package/src/identity/hub/ens/EnsEditRunners.tsx +62 -0
  65. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +15 -14
  66. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +68 -217
  67. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +18 -11
  68. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -48
  69. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  70. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +4 -4
  71. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  72. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  73. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  74. package/src/identity/hub/{effects/ens → ens}/transactions.ts +232 -232
  75. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +12 -26
  76. package/src/identity/hub/identityHubReducer.ts +3 -3
  77. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +17 -10
  78. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +55 -177
  79. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  80. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -173
  81. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +21 -21
  82. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  83. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  84. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  85. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  86. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  87. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  88. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  89. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  90. package/src/identity/hub/restore/restoreAdmin.ts +34 -0
  91. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  92. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  93. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  94. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  95. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  96. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  97. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +16 -11
  98. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +8 -9
  99. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  100. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  101. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  102. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  103. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +2 -4
  104. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  105. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  106. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +6 -47
  107. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  108. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  109. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  110. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  111. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  112. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -2
  113. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  114. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +7 -40
  115. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -4
  116. package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -7
  117. package/src/identity/hub/shared/reconciliation/walletSetup.ts +27 -0
  118. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  119. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  120. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  121. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  122. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  123. package/src/identity/hub/useIdentityHubController.ts +11 -11
  124. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  125. package/src/identity/wallet/browserWallet/types.ts +0 -5
  126. package/src/identity/wallet/page/copy.ts +1 -31
  127. package/src/identity/wallet/walletPurposeCompat.ts +0 -2
  128. package/src/models/ModelPicker.tsx +248 -8
  129. package/src/models/catalog.ts +29 -1
  130. package/src/models/modelPickerOptions.ts +12 -10
  131. package/src/models/providerDisplay.ts +16 -0
  132. package/src/providers/errors.ts +6 -4
  133. package/src/providers/openai-chat.ts +2 -1
  134. package/src/providers/openai-responses-format.ts +156 -0
  135. package/src/providers/openai-responses.ts +276 -0
  136. package/src/providers/registry.ts +85 -8
  137. package/src/runtime/sessionMode.ts +1 -1
  138. package/src/runtime/systemPrompt.ts +4 -2
  139. package/src/runtime/toolExecution.ts +9 -6
  140. package/src/runtime/turn.ts +29 -1
  141. package/src/storage/rewind.ts +20 -0
  142. package/src/storage/secrets.ts +4 -1
  143. package/src/storage/sessions.ts +2 -1
  144. package/src/tools/bashSafety.ts +7 -3
  145. package/src/tools/bashTool.ts +1 -1
  146. package/src/tools/contracts.ts +3 -0
  147. package/src/tools/deleteFileTool.ts +8 -3
  148. package/src/tools/editTool.ts +10 -5
  149. package/src/tools/fileDiff.ts +261 -0
  150. package/src/tools/privateContinuityEditTool.ts +11 -1
  151. package/src/tools/writeFileTool.ts +8 -3
  152. package/src/ui/Spinner.tsx +25 -3
  153. package/src/ui/TextInput.tsx +2 -2
  154. package/src/ui/theme.ts +17 -0
  155. package/src/utils/clipboard.ts +10 -7
  156. package/src/utils/openExternal.ts +20 -10
  157. package/src/chat/RewindView.tsx +0 -386
  158. package/src/chat/toolResultDisplay.ts +0 -8
  159. package/src/identity/ens/ensRegistration.ts +0 -199
  160. package/src/identity/hub/effects/index.ts +0 -74
  161. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  162. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  163. package/src/identity/hub/effects/restoreAdmin.ts +0 -93
  164. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  165. package/src/identity/hub/flows/ens/EnsEditAdvancedScreens.tsx +0 -336
  166. package/src/identity/hub/flows/ens/EnsEditRunners.tsx +0 -198
  167. package/src/identity/hub/reconciliation/walletSetup.ts +0 -220
  168. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  169. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  170. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  171. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  172. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  173. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  174. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  175. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  176. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  177. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -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 } {
@@ -5,6 +5,7 @@ import type { MessageRow } from './MessageList.js'
5
5
  import type { ModelPickerSelection } from '../models/ModelPicker.js'
6
6
  import { sessionMessagesToRows } from './chatScreenUtils.js'
7
7
  import { formatModelDisplayName } from '../models/modelDisplay.js'
8
+ import { providerDisplayName } from '../models/modelPickerOptions.js'
8
9
 
9
10
  export type ModelSelectionResolution =
10
11
  | { kind: 'noop' }
@@ -69,7 +70,7 @@ export function resolveModelSelection(
69
70
  return {
70
71
  kind: 'switch',
71
72
  config: nextConfig,
72
- notice: `${selection.keyJustSet ? `${selection.provider} key saved.` : `${selection.provider} ready.`} Now using ${nextConfig.provider} · ${formatModelDisplayName(nextConfig.provider, nextConfig.model, { maxLength: 64 })}.`,
73
+ notice: `${selection.keyJustSet ? `${providerDisplayName(selection.provider)} key saved.` : `${providerDisplayName(selection.provider)} ready.`} Now using ${providerDisplayName(nextConfig.provider)} · ${formatModelDisplayName(nextConfig.provider, nextConfig.model, { maxLength: 64 })}.`,
73
74
  tone: 'dim',
74
75
  }
75
76
  }
@@ -101,7 +102,7 @@ export function buildResumedSessionState(args: {
101
102
  function formatResumeNote(metadata: SessionMetadata | null): string {
102
103
  const id = metadata?.id?.slice(0, 8) ?? ''
103
104
  const source = metadata?.compactedFromSessionId ? ` summarized from ${metadata.compactedFromSessionId.slice(0, 8)}` : ''
104
- return `resumed from session ${id}.${source}`.trim()
105
+ return `Resumed from session ${id}.${source}`.trim()
105
106
  }
106
107
 
107
108
  export function restoreConversationState(
@@ -127,7 +127,6 @@ export async function runStreamingTurn(
127
127
  let thinkingRowId: string | null = null
128
128
  let thinkingCursorActive = false
129
129
  let assistantId: string | null = null
130
- let hasPendingToolUse = false
131
130
 
132
131
  const resetIteration = () => {
133
132
  accumulated = ''
@@ -135,7 +134,6 @@ export async function runStreamingTurn(
135
134
  thinkingRowId = null
136
135
  thinkingCursorActive = false
137
136
  assistantId = null
138
- hasPendingToolUse = false
139
137
  }
140
138
 
141
139
  const stopThinkingCursor = () => {
@@ -192,7 +190,7 @@ export async function runStreamingTurn(
192
190
  flushStreamRows(true)
193
191
  updateRows(prev => {
194
192
  let next = finalizeStreamingRowsById(prev, assistantId, thinkingRowId, accumulated, thinkingContent)
195
- if (assistantId && (hasPendingToolUse || accumulated.length === 0)) {
193
+ if (assistantId && accumulated.length === 0) {
196
194
  next = next.filter(r => r.id !== assistantId)
197
195
  }
198
196
  return next
@@ -294,7 +292,6 @@ export async function runStreamingTurn(
294
292
  setThinkingRowId: id => { thinkingRowId = id },
295
293
  markThinkingCursorActive: () => { thinkingCursorActive = true },
296
294
  getThinkingRowId: () => thinkingRowId,
297
- markPendingToolUse: () => { hasPendingToolUse = true },
298
295
  updateRows,
299
296
  pushNote,
300
297
  nextRowId,
@@ -340,7 +337,6 @@ type EventHandlerContext = {
340
337
  setThinkingRowId: (id: string | null) => void
341
338
  getThinkingRowId: () => string | null
342
339
  markThinkingCursorActive: () => void
343
- markPendingToolUse: () => void
344
340
  updateRows: (updater: (prev: MessageRow[]) => MessageRow[]) => void
345
341
  pushNote: (text: string, kind?: 'info' | 'error' | 'dim') => void
346
342
  nextRowId: () => string
@@ -403,7 +399,6 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
403
399
  return
404
400
  }
405
401
  case 'tool_use_stop': {
406
- ctx.markPendingToolUse()
407
402
  ctx.finalizeStreamingRows()
408
403
  return
409
404
  }
@@ -426,7 +421,6 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
426
421
  }
427
422
  case 'local_tool_recovery': {
428
423
  ctx.discardStreamingRows()
429
- ctx.markPendingToolUse()
430
424
  return
431
425
  }
432
426
  case 'continuation_nudge': {