ethagent 0.2.1 → 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.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- 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
|
+
}
|