ethagent 0.2.0 → 1.0.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 (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +30 -8
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +868 -0
  52. package/src/identity/hub/identityHubEffects.ts +1146 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +212 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,153 @@
1
+ import React, { useMemo } from 'react'
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'
7
+
8
+ type PermissionPromptProps = {
9
+ request: PermissionRequest
10
+ onDecision: (decision: PermissionDecision) => void
11
+ onCancel: () => void
12
+ }
13
+
14
+ export const PermissionPrompt: React.FC<PermissionPromptProps> = ({ request, onDecision, onCancel }) => {
15
+ const options = useMemo(() => permissionOptionsForRequest(request), [request])
16
+
17
+ return (
18
+ <Surface
19
+ title={request.title}
20
+ subtitle={request.subtitle}
21
+ tone={request.kind === 'bash' && request.warning ? 'error' : 'primary'}
22
+ footer="enter confirms · esc denies"
23
+ >
24
+ {request.kind === 'private-continuity-edit' ? (
25
+ <Box flexDirection="column" marginBottom={1}>
26
+ <Text color={theme.accentPeach}>{request.changeSummary}</Text>
27
+ <Text color={theme.textSubtle}>
28
+ Not reversible by /rewind. A private identity-history snapshot is saved before the edit is applied.
29
+ </Text>
30
+ <Box marginTop={1}>
31
+ <Text color={theme.textSubtle}>target</Text>
32
+ </Box>
33
+ <Text color={theme.text}>{request.file}</Text>
34
+ <Box marginTop={1}>
35
+ <Text color={theme.accentPrimary}>diff</Text>
36
+ </Box>
37
+ <Text color={theme.text}>{request.diff}</Text>
38
+ </Box>
39
+ ) : null}
40
+ {request.kind === 'private-continuity-read' ? (
41
+ <Box flexDirection="column" marginBottom={1}>
42
+ <Text color={theme.accentPeach}>read private {request.file}</Text>
43
+ <Text color={theme.textSubtle}>This reveals private identity continuity to the model for this turn.</Text>
44
+ <Box marginTop={1}>
45
+ <Text color={theme.textSubtle}>range</Text>
46
+ </Box>
47
+ <Text color={theme.text}>{request.range}</Text>
48
+ </Box>
49
+ ) : null}
50
+ {request.kind === 'edit' || request.kind === 'write' || request.kind === 'delete' ? (
51
+ <Box flexDirection="column" marginBottom={1}>
52
+ <Text color={theme.accentPeach}>{request.changeSummary}</Text>
53
+ <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.accentPrimary}>after</Text>
59
+ </Box>
60
+ <Text color={theme.text}>{request.after || '(empty)'}</Text>
61
+ </Box>
62
+ ) : null}
63
+ {request.kind === 'bash' && request.warning ? (
64
+ <Box marginBottom={1}>
65
+ <Text color="#e87070">{request.warning}</Text>
66
+ </Box>
67
+ ) : null}
68
+ <Select options={options} onSubmit={onDecision} onCancel={onCancel} />
69
+ </Surface>
70
+ )
71
+ }
72
+
73
+ export function permissionOptionsForRequest(request: PermissionRequest): Array<{ value: PermissionDecision; label: string; hint?: string; disabled?: boolean }> {
74
+ if (request.kind === 'bash') {
75
+ return [
76
+ { value: 'allow-once', label: 'allow once', hint: 'approve only this command execution' },
77
+ {
78
+ value: 'allow-command-project',
79
+ label: 'always allow this exact command',
80
+ hint: 'remember this command text for this project',
81
+ disabled: !request.canPersistExact,
82
+ },
83
+ {
84
+ 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',
87
+ disabled: !request.canPersistPrefix,
88
+ },
89
+ { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
90
+ ]
91
+ }
92
+
93
+ if (request.kind === 'mcp') {
94
+ const risk = request.destructive
95
+ ? 'server marks this tool as destructive'
96
+ : request.openWorld
97
+ ? 'server marks this tool as open-world'
98
+ : request.readOnly
99
+ ? 'server marks this tool as read-only'
100
+ : 'server did not mark this tool read-only'
101
+ return [
102
+ { value: 'allow-once', label: 'allow once', hint: risk },
103
+ { value: 'allow-mcp-tool-project', label: 'always allow this MCP tool', hint: request.toolKey },
104
+ {
105
+ value: 'allow-mcp-server-project',
106
+ label: `always allow ${request.serverName}`,
107
+ hint: 'remember all tools from this MCP server for this project',
108
+ disabled: !request.canPersistServer,
109
+ },
110
+ { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
111
+ ]
112
+ }
113
+
114
+ if (request.kind === 'delete') {
115
+ return [
116
+ { value: 'allow-once', label: 'delete this file', hint: 'approve this deletion only' },
117
+ { value: 'deny', label: 'deny', hint: 'keep the file unchanged' },
118
+ ]
119
+ }
120
+
121
+ if (request.kind === 'private-continuity-read') {
122
+ return [
123
+ { value: 'allow-once', label: 'allow once', hint: `read ${request.file}` },
124
+ { value: 'deny', label: 'deny', hint: 'keep private continuity hidden' },
125
+ ]
126
+ }
127
+
128
+ if (request.kind === 'private-continuity-edit') {
129
+ return [
130
+ { value: 'allow-once', label: 'approve once', hint: `apply this edit to ${request.file}` },
131
+ { value: 'deny', label: 'deny', hint: 'keep private continuity unchanged' },
132
+ ]
133
+ }
134
+
135
+ return [
136
+ { value: 'allow-once', label: 'allow once', hint: 'approve only this action' },
137
+ { value: 'allow-path-project', label: 'always allow this file', hint: request.relativePath },
138
+ { value: 'allow-directory-project', label: 'always allow this folder', hint: request.directoryPath },
139
+ {
140
+ value: 'allow-kind-project',
141
+ label:
142
+ request.kind === 'edit'
143
+ ? 'always allow edits'
144
+ : request.kind === 'write'
145
+ ? 'always allow writes'
146
+ : request.kind === 'cd'
147
+ ? 'always allow directory changes'
148
+ : 'always allow reads',
149
+ hint: 'remember this tool kind for this project',
150
+ },
151
+ { value: 'deny', label: 'deny', hint: 'return a denial back to the model' },
152
+ ]
153
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
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'
9
+
10
+ type PermissionsViewProps = {
11
+ cwd: string
12
+ onRulesChanged: (rules: SessionPermissionRule[]) => void
13
+ onNotice: (message: string, variant?: 'info' | 'error' | 'dim') => void
14
+ onCancel: () => void
15
+ }
16
+
17
+ type State =
18
+ | { kind: 'loading' }
19
+ | { kind: 'error'; message: string }
20
+ | { kind: 'ready'; rules: SessionPermissionRule[] }
21
+
22
+ const CLEAR_ALL_VALUE = '__clear_all__'
23
+
24
+ export const PermissionsView: React.FC<PermissionsViewProps> = ({
25
+ cwd,
26
+ onRulesChanged,
27
+ onNotice,
28
+ onCancel,
29
+ }) => {
30
+ const [state, setState] = useState<State>({ kind: 'loading' })
31
+
32
+ useEffect(() => {
33
+ let cancelled = false
34
+ void (async () => {
35
+ try {
36
+ const rules = await loadPermissionRules(cwd)
37
+ if (!cancelled) setState({ kind: 'ready', rules })
38
+ } catch (err: unknown) {
39
+ if (!cancelled) setState({ kind: 'error', message: (err as Error).message })
40
+ }
41
+ })()
42
+ return () => { cancelled = true }
43
+ }, [cwd])
44
+
45
+ const refreshRules = async () => {
46
+ const rules = await loadPermissionRules(cwd)
47
+ setState({ kind: 'ready', rules })
48
+ onRulesChanged(rules)
49
+ return rules
50
+ }
51
+
52
+ const options = useMemo(
53
+ () => state.kind === 'ready' ? buildOptions(state.rules) : [],
54
+ [state],
55
+ )
56
+
57
+ if (state.kind === 'loading') {
58
+ return (
59
+ <Surface title="Permissions" subtitle="Loading saved project rules...">
60
+ <Spinner label="loading permission rules..." />
61
+ </Surface>
62
+ )
63
+ }
64
+
65
+ if (state.kind === 'error') {
66
+ return (
67
+ <Surface title="Permissions" tone="muted" footer="esc closes">
68
+ <Text color={theme.dim}>{state.message}</Text>
69
+ </Surface>
70
+ )
71
+ }
72
+
73
+ if (state.rules.length === 0) {
74
+ return (
75
+ <Surface title="Permissions" tone="muted" footer="esc closes">
76
+ <Text color={theme.dim}>No saved permission rules for this project.</Text>
77
+ </Surface>
78
+ )
79
+ }
80
+
81
+ return (
82
+ <Surface
83
+ title="Permissions"
84
+ subtitle="Saved rules for this project. Enter removes the selected rule."
85
+ footer="enter removes · esc closes"
86
+ >
87
+ <Select
88
+ options={options}
89
+ onSubmit={async value => {
90
+ try {
91
+ if (value === CLEAR_ALL_VALUE) {
92
+ await clearPermissionRules(cwd)
93
+ onRulesChanged([])
94
+ onCancel()
95
+ onNotice('cleared saved permission rules for this project.', 'dim')
96
+ return
97
+ }
98
+
99
+ await deletePermissionRule(cwd, value)
100
+ const remaining = await refreshRules()
101
+ if (remaining.length === 0) {
102
+ onCancel()
103
+ onNotice('removed the last saved permission rule for this project.', 'dim')
104
+ return
105
+ }
106
+ onNotice(`removed permission rule: ${describeRule(value)}`, 'dim')
107
+ } catch (err: unknown) {
108
+ onNotice(`failed to update permission rules: ${(err as Error).message}`, 'error')
109
+ }
110
+ }}
111
+ onCancel={onCancel}
112
+ />
113
+ <Box marginTop={1} flexDirection="column">
114
+ <Text color={theme.dim}>Rules apply only within the current project root.</Text>
115
+ </Box>
116
+ </Surface>
117
+ )
118
+ }
119
+
120
+ function buildOptions(rules: SessionPermissionRule[]): Array<SelectOption<SessionPermissionRule | typeof CLEAR_ALL_VALUE>> {
121
+ return [
122
+ ...rules.map(rule => ({
123
+ value: rule,
124
+ label: describeRule(rule),
125
+ hint: describeRuleScope(rule),
126
+ })),
127
+ {
128
+ value: CLEAR_ALL_VALUE,
129
+ label: 'Remove all saved rules',
130
+ hint: 'Clear all remembered permissions for this project',
131
+ },
132
+ ]
133
+ }
134
+
135
+ function describeRule(rule: SessionPermissionRule): string {
136
+ if (rule.kind === 'bash') {
137
+ if (rule.scope === 'command') return `bash exact: ${rule.command}`
138
+ return `bash prefix: ${rule.commandPrefix}`
139
+ }
140
+ if (rule.kind === 'mcp') {
141
+ if (rule.scope === 'tool') return `mcp tool: ${rule.toolKey}`
142
+ return `mcp server: ${rule.normalizedServerName}`
143
+ }
144
+ if (rule.scope === 'kind') {
145
+ return rule.kind === 'read'
146
+ ? 'allow all reads'
147
+ : rule.kind === 'edit'
148
+ ? 'allow all edits'
149
+ : 'allow all directory changes'
150
+ }
151
+ if (rule.scope === 'path') return `${rule.kind} file: ${rule.path}`
152
+ return `${rule.kind} folder: ${rule.path}`
153
+ }
154
+
155
+ function describeRuleScope(rule: SessionPermissionRule): string {
156
+ if (rule.kind === 'bash') return `cwd ${rule.cwd}`
157
+ if (rule.kind === 'mcp') return 'MCP project permission'
158
+ return rule.scope === 'kind' ? 'whole project' : rule.path
159
+ }
@@ -0,0 +1,91 @@
1
+ import React, { useState } from 'react'
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'
6
+
7
+ export type PlanApprovalAction = 'apply' | 'apply-summary' | 'continue'
8
+
9
+ type PlanApprovalViewProps = {
10
+ contextLabel: string
11
+ onSelect: (action: PlanApprovalAction) => void
12
+ onCancel: () => void
13
+ }
14
+
15
+ export const PLAN_APPROVAL_OPTIONS: Array<{
16
+ value: PlanApprovalAction
17
+ label: string
18
+ title: string
19
+ detail: (contextLabel: string) => string
20
+ }> = [
21
+ {
22
+ value: 'apply',
23
+ label: 'Yes, implement this plan',
24
+ title: 'Switch to Accept Edits and start coding.',
25
+ detail: contextLabel => `Same conversation. ${contextLabel}.`,
26
+ },
27
+ {
28
+ value: 'apply-summary',
29
+ label: 'Yes, start a new conversation',
30
+ title: 'Summarize context and start coding.',
31
+ detail: () => 'Keeps this conversation active and carries summary plus plan.',
32
+ },
33
+ {
34
+ value: 'continue',
35
+ label: 'No, stay in Plan mode',
36
+ title: 'Continue planning with the model.',
37
+ detail: () => 'No files will be changed.',
38
+ },
39
+ ]
40
+
41
+ export const PlanApprovalView: React.FC<PlanApprovalViewProps> = ({
42
+ contextLabel,
43
+ onSelect,
44
+ onCancel,
45
+ }) => {
46
+ const [index, setIndex] = useState(0)
47
+ const selected = PLAN_APPROVAL_OPTIONS[index] ?? PLAN_APPROVAL_OPTIONS[0]!
48
+
49
+ useAppInput((input, key) => {
50
+ if (key.upArrow || input === 'k') {
51
+ setIndex(current => (current - 1 + PLAN_APPROVAL_OPTIONS.length) % PLAN_APPROVAL_OPTIONS.length)
52
+ } else if (key.downArrow || input === 'j') {
53
+ setIndex(current => (current + 1) % PLAN_APPROVAL_OPTIONS.length)
54
+ } else if (key.return) {
55
+ onSelect(selected.value)
56
+ } else if (key.escape) {
57
+ onCancel()
58
+ }
59
+ })
60
+
61
+ return (
62
+ <Surface
63
+ title="Implement this plan?"
64
+ tone="muted"
65
+ footer="Press enter to confirm or esc to go back"
66
+ >
67
+ <Box flexDirection="row">
68
+ <Box flexDirection="column" minWidth={36}>
69
+ {PLAN_APPROVAL_OPTIONS.map((option, optionIndex) => {
70
+ const active = optionIndex === index
71
+ return (
72
+ <Box key={option.value} flexDirection="row">
73
+ <Text color={active ? theme.accentMint : theme.dim}>
74
+ {active ? '> ' : ' '}
75
+ {optionIndex + 1}.{' '}
76
+ </Text>
77
+ <Text color={active ? theme.accentMint : theme.text} bold={active}>
78
+ {option.label}
79
+ </Text>
80
+ </Box>
81
+ )
82
+ })}
83
+ </Box>
84
+ <Box flexDirection="column" marginLeft={4} flexShrink={1}>
85
+ <Text color={theme.accentMint} bold>{selected.title}</Text>
86
+ <Text color={theme.dim}>{selected.detail(contextLabel)}</Text>
87
+ </Box>
88
+ </Box>
89
+ </Surface>
90
+ )
91
+ }
@@ -0,0 +1,267 @@
1
+ import React, { useEffect, useState } from 'react'
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'
9
+
10
+ type ResumeViewProps = {
11
+ currentSessionId: string
12
+ onResume: (sessionId: string) => void
13
+ onClearAll: () => void | Promise<void>
14
+ onCancel: () => void
15
+ }
16
+
17
+ type State =
18
+ | { kind: 'loading' }
19
+ | { kind: 'error'; message: string }
20
+ | { kind: 'ready'; sessions: SessionSummary[] }
21
+ | { kind: 'confirmClear'; sessions: SessionSummary[]; error?: string }
22
+ | { kind: 'clearing'; sessions: SessionSummary[] }
23
+
24
+ export const CLEAR_ALL_SESSIONS_VALUE = '__clear_all_sessions__'
25
+
26
+ export const ResumeView: React.FC<ResumeViewProps> = ({ currentSessionId, onResume, onClearAll, onCancel }) => {
27
+ const [state, setState] = useState<State>({ kind: 'loading' })
28
+
29
+ // Allow ESC to close the view during loading / error states
30
+ // (Select handles ESC only when it's rendered in the 'ready' state)
31
+ const escActive = state.kind === 'loading' || state.kind === 'error' || (state.kind === 'ready' && state.sessions.length === 0)
32
+ useAppInput((_input, key) => {
33
+ if (key.escape) onCancel()
34
+ }, { isActive: escActive })
35
+
36
+ useEffect(() => {
37
+ let cancelled = false
38
+ void (async () => {
39
+ try {
40
+ const all = await listSessions(50)
41
+ if (cancelled) return
42
+ setState({ kind: 'ready', sessions: all })
43
+ } catch (err: unknown) {
44
+ if (cancelled) return
45
+ setState({ kind: 'error', message: (err as Error).message })
46
+ }
47
+ })()
48
+ return () => { cancelled = true }
49
+ }, [currentSessionId])
50
+
51
+ if (state.kind === 'loading') {
52
+ return (
53
+ <Surface title="Resume Session" subtitle="Loading projects and directories...">
54
+ <Spinner label="loading sessions..." />
55
+ </Surface>
56
+ )
57
+ }
58
+
59
+ if (state.kind === 'error') {
60
+ return (
61
+ <Surface title="Resume Session" tone="muted" footer="esc closes">
62
+ <Text color={theme.dim}>{state.message}</Text>
63
+ </Surface>
64
+ )
65
+ }
66
+
67
+ if (state.kind === 'confirmClear') {
68
+ return (
69
+ <Surface
70
+ title="Clear All Chat Logs?"
71
+ subtitle={`${state.sessions.length} saved session${state.sessions.length === 1 ? '' : 's'} will be removed.`}
72
+ tone="error"
73
+ footer="enter selects · esc returns to resume"
74
+ >
75
+ <Box flexDirection="column" marginBottom={1}>
76
+ <Text color={theme.dim}>Removes saved chats and resume context from this machine.</Text>
77
+ <Text color={theme.dim}>Config, identities, keys, and local models stay.</Text>
78
+ {state.error ? <Text color="#e87070">{state.error}</Text> : null}
79
+ </Box>
80
+ <Select<'back' | 'clear'>
81
+ options={[
82
+ { value: 'back', label: 'back to sessions' },
83
+ { value: 'clear', label: 'clear all chat logs', hint: 'cannot be undone' },
84
+ ]}
85
+ onSubmit={choice => {
86
+ if (choice === 'back') {
87
+ setState({ kind: 'ready', sessions: state.sessions })
88
+ return
89
+ }
90
+ void clearAll(state.sessions, onClearAll, setState)
91
+ }}
92
+ onCancel={() => setState({ kind: 'ready', sessions: state.sessions })}
93
+ />
94
+ </Surface>
95
+ )
96
+ }
97
+
98
+ if (state.kind === 'clearing') {
99
+ return (
100
+ <Surface title="Clearing Chat Logs" subtitle="Removing saved chats and resume context.">
101
+ <Spinner label="clearing sessions..." />
102
+ </Surface>
103
+ )
104
+ }
105
+
106
+ if (state.sessions.length === 0) {
107
+ return (
108
+ <Surface title="Resume Session" tone="muted" footer="esc closes">
109
+ <Text color={theme.dim}>No prior sessions to resume.</Text>
110
+ </Surface>
111
+ )
112
+ }
113
+
114
+ const options = buildResumeOptions(state.sessions, currentSessionId)
115
+
116
+ return (
117
+ <Surface
118
+ title="Resume Session"
119
+ subtitle="Grouped by project, then working directory."
120
+ footer="enter resumes · esc closes"
121
+ >
122
+ <Box flexDirection="column" marginBottom={1}>
123
+ <Text color={theme.dim}>Recent projects</Text>
124
+ </Box>
125
+ <Select
126
+ options={options}
127
+ initialIndex={findInitialIndex(options, currentSessionId)}
128
+ maxVisible={14}
129
+ onSubmit={value => {
130
+ if (value === CLEAR_ALL_SESSIONS_VALUE) {
131
+ setState({ kind: 'confirmClear', sessions: state.sessions })
132
+ return
133
+ }
134
+ onResume(value)
135
+ }}
136
+ onCancel={onCancel}
137
+ />
138
+ </Surface>
139
+ )
140
+ }
141
+
142
+ export function buildResumeOptions(
143
+ sessions: SessionSummary[],
144
+ currentSessionId: string,
145
+ ): Array<SelectOption<string>> {
146
+ const groups = new Map<string, SessionSummary[]>()
147
+ for (const session of sessions) {
148
+ const key = session.projectRoot
149
+ const existing = groups.get(key) ?? []
150
+ existing.push(session)
151
+ groups.set(key, existing)
152
+ }
153
+
154
+ const options: Array<SelectOption<string>> = []
155
+ const manageSpacer: SelectOption<string> = {
156
+ value: 'separator:spacer',
157
+ label: '',
158
+ disabled: true,
159
+ }
160
+
161
+ const clearOption: SelectOption<string> = {
162
+ value: CLEAR_ALL_SESSIONS_VALUE,
163
+ label: 'clear all chat logs',
164
+ hint: 'removes saved chats and resume context',
165
+ role: 'utility',
166
+ }
167
+
168
+ const orderedGroups = [...groups.values()].sort((left, right) => right[0]!.mtimeMs - left[0]!.mtimeMs)
169
+
170
+ for (const group of orderedGroups) {
171
+ const head = group[0]!
172
+ options.push({
173
+ value: `header:${head.projectRoot}`,
174
+ label: head.projectLabel,
175
+ hint: compressProjectPath(head.projectRoot),
176
+ disabled: true,
177
+ })
178
+
179
+ const byDirectory = [...group].sort((left, right) => right.mtimeMs - left.mtimeMs)
180
+ let lastDirectoryLabel: string | null = null
181
+
182
+ for (const session of byDirectory) {
183
+ if (session.directoryLabel !== lastDirectoryLabel) {
184
+ lastDirectoryLabel = session.directoryLabel
185
+ options.push({
186
+ value: `directory:${head.projectRoot}:${session.directoryLabel}`,
187
+ label: `in ${formatDirectoryDisplay(session.directoryLabel)}`,
188
+ hint: undefined,
189
+ disabled: true,
190
+ })
191
+ }
192
+
193
+ const baseLabel = formatFirstLine(session.firstUserMessage) || '(empty session)'
194
+ const markers = [
195
+ session.id === currentSessionId ? 'current' : '',
196
+ ].filter(Boolean)
197
+ const label = markers.length > 0 ? `${baseLabel} (${markers.join(', ')})` : baseLabel
198
+ const summaryHint = session.compactedFromSessionId
199
+ ? `summary from ${session.compactedFromSessionId.slice(0, 8)}`
200
+ : null
201
+ const hintParts = [
202
+ `${session.turnCount} turn${session.turnCount === 1 ? '' : 's'}`,
203
+ formatRelative(session.mtimeMs),
204
+ session.id.slice(0, 8),
205
+ summaryHint,
206
+ ].filter(Boolean)
207
+ options.push({
208
+ value: session.id,
209
+ label,
210
+ hint: hintParts.join(' · '),
211
+ })
212
+ }
213
+ }
214
+
215
+ // Utility section sits below all session groups with a visual gap
216
+ options.push(manageSpacer)
217
+ options.push(clearOption)
218
+
219
+ return options
220
+ }
221
+
222
+ function findInitialIndex(options: Array<SelectOption<string>>, currentSessionId: string): number {
223
+ const currentIndex = options.findIndex(option => option.value === currentSessionId)
224
+ if (currentIndex >= 0) return currentIndex
225
+ return Math.max(0, options.findIndex(option => !option.disabled && option.value !== CLEAR_ALL_SESSIONS_VALUE))
226
+ }
227
+
228
+ async function clearAll(
229
+ sessions: SessionSummary[],
230
+ onClearAll: () => void | Promise<void>,
231
+ setState: (state: State) => void,
232
+ ): Promise<void> {
233
+ setState({ kind: 'clearing', sessions })
234
+ try {
235
+ await onClearAll()
236
+ } catch (err: unknown) {
237
+ setState({ kind: 'confirmClear', sessions, error: (err as Error).message })
238
+ }
239
+ }
240
+
241
+ function compressProjectPath(input: string): string {
242
+ const home = process.env.USERPROFILE || process.env.HOME || ''
243
+ return home && input.startsWith(home) ? `~${input.slice(home.length)}` : input
244
+ }
245
+
246
+ function formatDirectoryDisplay(input: string): string {
247
+ if (input === '.' || input === '') return './'
248
+ return input.startsWith('./') ? input : `./${input}`
249
+ }
250
+
251
+ function formatFirstLine(text: string): string {
252
+ const firstLine = text.split('\n', 1)[0] ?? ''
253
+ if (firstLine.length <= 56) return firstLine
254
+ return `${firstLine.slice(0, 53)}...`
255
+ }
256
+
257
+ function formatRelative(ms: number): string {
258
+ const diffMs = Date.now() - ms
259
+ const minutes = Math.floor(diffMs / 60_000)
260
+ if (minutes < 1) return 'just now'
261
+ if (minutes < 60) return `${minutes}m ago`
262
+ const hours = Math.floor(minutes / 60)
263
+ if (hours < 24) return `${hours}h ago`
264
+ const days = Math.floor(hours / 24)
265
+ if (days < 7) return `${days}d ago`
266
+ return new Date(ms).toISOString().slice(0, 10)
267
+ }