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
@@ -0,0 +1,19 @@
1
+ import { splitFileChangeResult } from '../../tools/fileDiff.js'
2
+
3
+ const COMPACT_SUCCESS_TOOL_RESULTS = new Set([
4
+ 'read_file',
5
+ 'read_private_continuity_file',
6
+ ])
7
+
8
+ export function hidesSuccessfulToolResultContent(name: string, isError?: boolean): boolean {
9
+ return !isError && COMPACT_SUCCESS_TOOL_RESULTS.has(name)
10
+ }
11
+
12
+ export function toolResultDiffContent(content: string, isError?: boolean): string | undefined {
13
+ if (isError) return undefined
14
+ return splitFileChangeResult(content).diff
15
+ }
16
+
17
+ export function toolResultTextContent(content: string): string {
18
+ return splitFileChangeResult(content).content
19
+ }
@@ -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,
@@ -46,6 +46,8 @@ type PromptInputProps = {
46
46
  onModeChange?: (mode: 'prompt' | 'bash') => void
47
47
  footerRight?: React.ReactNode
48
48
  cwd?: string
49
+ seedText?: string | null
50
+ onSeedConsumed?: () => void
49
51
  }
50
52
 
51
53
  const MAX_LENGTH = 32_768
@@ -66,12 +68,14 @@ export const ChatInput: React.FC<PromptInputProps> = ({
66
68
  disabled,
67
69
  placeholderHints,
68
70
  queuedMessages = [],
69
- prefix = '\u203a',
71
+ prefix = '',
70
72
  slashSuggestions = [],
71
73
  mode = 'prompt',
72
74
  onModeChange,
73
75
  footerRight,
74
76
  cwd,
77
+ seedText,
78
+ onSeedConsumed,
75
79
  }) => {
76
80
  const { stdout } = useStdout()
77
81
  const [buffer, setBuffer] = useState<ChatBuffer>(emptyBuffer)
@@ -141,6 +145,15 @@ export const ChatInput: React.FC<PromptInputProps> = ({
141
145
  applyHistoryState(exitHistoryPreview(next))
142
146
  }, [applyHistoryState])
143
147
 
148
+ useEffect(() => {
149
+ if (seedText == null) return
150
+ const next: ChatBuffer = { value: seedText, cursor: seedText.length }
151
+ bufferRef.current = next
152
+ setBuffer(next)
153
+ applyHistoryState(exitHistoryPreview(next))
154
+ onSeedConsumed?.()
155
+ }, [seedText, applyHistoryState, onSeedConsumed])
156
+
144
157
  useEffect(() => {
145
158
  if (!placeholderHints || placeholderHints.length < 2) return
146
159
  const timer = setInterval(() => {
@@ -506,6 +519,22 @@ export const ChatInput: React.FC<PromptInputProps> = ({
506
519
 
507
520
  return (
508
521
  <Box flexDirection="column" width="100%">
522
+ {queuedMessages.length > 0 ? (
523
+ <Box marginLeft={2} marginBottom={1} flexDirection="column">
524
+ <Text color={theme.dim}>
525
+ {queuedMessages.length === 1 ? '1 message queued for next turn' : `${queuedMessages.length} messages queued for next turns`}
526
+ </Text>
527
+ {queuedMessages.slice(0, 3).map((message, i) => (
528
+ <Text key={`${i}-${message.slice(0, 24)}`}>
529
+ <Text color={theme.accentPeriwinkle}>{i === 0 ? '» ' : ' '}</Text>
530
+ <Text color={theme.textSubtle}>{summarizeQueuedMessage(message)}</Text>
531
+ </Text>
532
+ ))}
533
+ {queuedMessages.length > 3 ? (
534
+ <Text color={theme.dim}>+{queuedMessages.length - 3} more</Text>
535
+ ) : null}
536
+ </Box>
537
+ ) : null}
509
538
  <Box
510
539
  borderStyle="round"
511
540
  borderColor={borderColor}
@@ -545,7 +574,7 @@ export const ChatInput: React.FC<PromptInputProps> = ({
545
574
  <Box marginLeft={2} flexDirection="column">
546
575
  {filteredSuggestions.map((s, i) => (
547
576
  <Text key={s.name} color={i === suggestionIdx ? theme.accentPeriwinkle : theme.dim}>
548
- {i === suggestionIdx ? '\u203a ' : ' '}/{s.name}
577
+ {i === suggestionIdx ? ' ' : ' '}/{s.name}
549
578
  <Text color={theme.dim}> {s.summary}{i === suggestionIdx ? (s.executeOnEnter ? ' · enter runs' : ' · enter fills') : ''}</Text>
550
579
  </Text>
551
580
  ))}
@@ -555,7 +584,7 @@ export const ChatInput: React.FC<PromptInputProps> = ({
555
584
  <Box marginLeft={2} flexDirection="column">
556
585
  {fileSuggestions.slice(0, 8).map((s, i) => (
557
586
  <Text key={s.path} color={i === fileSuggestionIdx ? theme.accentPeriwinkle : theme.dim}>
558
- {i === fileSuggestionIdx ? '\u203a ' : ' '}@{s.path}
587
+ {i === fileSuggestionIdx ? ' ' : ' '}@{s.path}
559
588
  <Text color={theme.dim}> {i === fileSuggestionIdx ? 'tab/enter completes' : s.hint}</Text>
560
589
  </Text>
561
590
  ))}
@@ -564,22 +593,6 @@ export const ChatInput: React.FC<PromptInputProps> = ({
564
593
  ) : null}
565
594
  </Box>
566
595
  ) : 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
596
  {footerRight ? (
584
597
  <Box marginLeft={2}>
585
598
  {footerRight}
@@ -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
  },
@@ -1,11 +1,11 @@
1
1
  import React, { useEffect, useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
- import { theme } from '../ui/theme.js'
4
- import { Select, type SelectOption } from '../ui/Select.js'
5
- import { Spinner } from '../ui/Spinner.js'
6
- import { Surface } from '../ui/Surface.js'
7
- import { listSessions, type SessionSummary } from '../storage/sessions.js'
8
- import { useAppInput } from '../app/input/AppInputProvider.js'
3
+ import { theme } from '../../ui/theme.js'
4
+ import { Select, type SelectOption } from '../../ui/Select.js'
5
+ import { Spinner } from '../../ui/Spinner.js'
6
+ import { Surface } from '../../ui/Surface.js'
7
+ import { listSessions, type SessionSummary } from '../../storage/sessions.js'
8
+ import { useAppInput } from '../../app/input/AppInputProvider.js'
9
9
 
10
10
  type ResumeViewProps = {
11
11
  currentSessionId: string
@@ -114,11 +114,11 @@ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResu
114
114
  return (
115
115
  <Surface
116
116
  title="Resume Session"
117
- subtitle="Grouped by project, then working directory."
117
+ subtitle="Grouped by working directory."
118
118
  footer="enter resumes · esc closes"
119
119
  >
120
120
  <Box flexDirection="column" marginBottom={1}>
121
- <Text color={theme.dim}>Recent projects</Text>
121
+ <Text color={theme.dim}>Recent directories</Text>
122
122
  </Box>
123
123
  <Select
124
124
  options={options}
@@ -143,7 +143,7 @@ export function buildResumeOptions(
143
143
  ): Array<SelectOption<string>> {
144
144
  const groups = new Map<string, SessionSummary[]>()
145
145
  for (const session of sessions) {
146
- const key = session.projectRoot
146
+ const key = session.lastCwd || session.workspaceRoot || session.projectRoot
147
147
  const existing = groups.get(key) ?? []
148
148
  existing.push(session)
149
149
  groups.set(key, existing)
@@ -158,36 +158,26 @@ export function buildResumeOptions(
158
158
 
159
159
  const clearOption: SelectOption<string> = {
160
160
  value: CLEAR_ALL_SESSIONS_VALUE,
161
- label: 'clear all chat logs',
161
+ label: 'Clear All Chat Logs',
162
162
  hint: 'removes saved chats and resume context',
163
163
  role: 'utility',
164
164
  }
165
165
 
166
- const orderedGroups = [...groups.values()].sort((left, right) => right[0]!.mtimeMs - left[0]!.mtimeMs)
166
+ const orderedGroups = [...groups.entries()].sort(([, left], [, right]) => right[0]!.mtimeMs - left[0]!.mtimeMs)
167
167
 
168
- for (const group of orderedGroups) {
169
- const head = group[0]!
168
+ for (const [directoryKey, group] of orderedGroups) {
169
+ const sorted = [...group].sort((left, right) => right.mtimeMs - left.mtimeMs)
170
+ const sessionCount = sorted.length
171
+ const lastActivity = formatRelative(sorted[0]!.mtimeMs)
170
172
  options.push({
171
- value: `header:${head.projectRoot}`,
172
- label: head.projectLabel,
173
- hint: compressProjectPath(head.projectRoot),
174
- disabled: true,
173
+ value: `directory:${directoryKey}`,
174
+ label: lastPathSegment(directoryKey) || compressProjectPath(directoryKey),
175
+ hint: `${compressProjectPath(directoryKey)} · ${sessionCount} session${sessionCount === 1 ? '' : 's'} · last ${lastActivity}`,
176
+ role: 'section',
177
+ bold: true,
175
178
  })
176
179
 
177
- const byDirectory = [...group].sort((left, right) => right.mtimeMs - left.mtimeMs)
178
- let lastDirectoryLabel: string | null = null
179
-
180
- for (const session of byDirectory) {
181
- if (session.directoryLabel !== lastDirectoryLabel) {
182
- lastDirectoryLabel = session.directoryLabel
183
- options.push({
184
- value: `directory:${head.projectRoot}:${session.directoryLabel}`,
185
- label: `in ${formatDirectoryDisplay(session.directoryLabel)}`,
186
- hint: undefined,
187
- disabled: true,
188
- })
189
- }
190
-
180
+ for (const session of sorted) {
191
181
  const baseLabel = formatFirstLine(session.firstUserMessage) || '(empty session)'
192
182
  const markers = [
193
183
  session.id === currentSessionId ? 'current' : '',
@@ -206,6 +196,7 @@ export function buildResumeOptions(
206
196
  value: session.id,
207
197
  label,
208
198
  hint: hintParts.join(' · '),
199
+ indent: 2,
209
200
  })
210
201
  }
211
202
  }
@@ -219,7 +210,15 @@ export function buildResumeOptions(
219
210
  function findInitialIndex(options: Array<SelectOption<string>>, currentSessionId: string): number {
220
211
  const currentIndex = options.findIndex(option => option.value === currentSessionId)
221
212
  if (currentIndex >= 0) return currentIndex
222
- return Math.max(0, options.findIndex(option => !option.disabled && option.value !== CLEAR_ALL_SESSIONS_VALUE))
213
+ return Math.max(
214
+ 0,
215
+ options.findIndex(option =>
216
+ !option.disabled
217
+ && option.role !== 'section'
218
+ && option.role !== 'group'
219
+ && option.value !== CLEAR_ALL_SESSIONS_VALUE,
220
+ ),
221
+ )
223
222
  }
224
223
 
225
224
  async function clearAll(
@@ -240,9 +239,10 @@ function compressProjectPath(input: string): string {
240
239
  return home && input.startsWith(home) ? `~${input.slice(home.length)}` : input
241
240
  }
242
241
 
243
- function formatDirectoryDisplay(input: string): string {
244
- if (input === '.' || input === '') return './'
245
- return input.startsWith('./') ? input : `./${input}`
242
+ function lastPathSegment(input: string): string {
243
+ const trimmed = input.replace(/[\\/]+$/, '')
244
+ const slash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
245
+ return slash >= 0 ? trimmed.slice(slash + 1) : trimmed
246
246
  }
247
247
 
248
248
  function formatFirstLine(text: string): string {