ethagent 0.2.1 → 1.0.1
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 +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -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,1575 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { Box, Text, useApp } from 'ink'
|
|
5
|
+
import type { EthagentConfig } from '../storage/config.js'
|
|
6
|
+
import type { Provider, Message } from '../providers/contracts.js'
|
|
7
|
+
import { createProvider } from '../providers/registry.js'
|
|
8
|
+
import { approximateTokens } from '../utils/messages.js'
|
|
9
|
+
import {
|
|
10
|
+
dispatchSlash,
|
|
11
|
+
parseSlash,
|
|
12
|
+
getSlashSuggestions,
|
|
13
|
+
type SlashContext,
|
|
14
|
+
} from './commands.js'
|
|
15
|
+
import { theme } from '../ui/theme.js'
|
|
16
|
+
import { BrandSplash } from '../ui/BrandSplash.js'
|
|
17
|
+
import { SessionStatus, formatTokens } from './SessionStatus.js'
|
|
18
|
+
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
19
|
+
import { toggleReasoningRow, type MessageRow } from './MessageList.js'
|
|
20
|
+
import { ConversationStack } from './ConversationStack.js'
|
|
21
|
+
import { ModelPicker, type ModelPickerSelection } from '../models/ModelPicker.js'
|
|
22
|
+
import type { ModelPickerContextFit } from '../models/modelPickerOptions.js'
|
|
23
|
+
import type { CopyResult } from '../utils/clipboard.js'
|
|
24
|
+
import { useKeybinding, useRegisterKeybindingContext } from '../app/keybindings/KeybindingProvider.js'
|
|
25
|
+
import { useCancelRequest } from '../app/hooks/useCancelRequest.js'
|
|
26
|
+
import { useExitOnCtrlC } from '../app/hooks/useExitOnCtrlC.js'
|
|
27
|
+
import {
|
|
28
|
+
appendSessionMessage,
|
|
29
|
+
clearAllSessions,
|
|
30
|
+
ensureSessionMetadata,
|
|
31
|
+
loadSession,
|
|
32
|
+
loadSessionMetadata,
|
|
33
|
+
newSessionId,
|
|
34
|
+
updateSessionActivity,
|
|
35
|
+
} from '../storage/sessions.js'
|
|
36
|
+
import type { SessionMessage } from '../storage/sessions.js'
|
|
37
|
+
import { loadPermissionRules, savePermissionRule } from '../storage/permissions.js'
|
|
38
|
+
import { appendHistory, readHistory } from '../storage/history.js'
|
|
39
|
+
import {
|
|
40
|
+
compactTranscript,
|
|
41
|
+
contextUsage,
|
|
42
|
+
contextUsageFromTokens,
|
|
43
|
+
summarizeTranscriptLocally,
|
|
44
|
+
shouldConfirmContextUsage,
|
|
45
|
+
type ContextUsage,
|
|
46
|
+
} from '../runtime/compaction.js'
|
|
47
|
+
import { saveConfig } from '../storage/config.js'
|
|
48
|
+
import { getCwd as getRuntimeCwd, setCwd as setRuntimeCwd, syncCwdFromProcess } from '../runtime/cwd.js'
|
|
49
|
+
import { executeToolWithPermissions } from '../runtime/toolExecution.js'
|
|
50
|
+
import { nextSessionMode, sessionModeLabel, type PermissionMode, type SessionMode } from '../runtime/sessionMode.js'
|
|
51
|
+
import type {
|
|
52
|
+
PermissionDecision,
|
|
53
|
+
PermissionRequest,
|
|
54
|
+
SessionPermissionRule,
|
|
55
|
+
} from '../tools/contracts.js'
|
|
56
|
+
import {
|
|
57
|
+
buildBaseMessages,
|
|
58
|
+
formatBytes,
|
|
59
|
+
sessionMessagesToRows,
|
|
60
|
+
type TurnCheckpoint,
|
|
61
|
+
} from './chatScreenUtils.js'
|
|
62
|
+
import { ChatBottomPane, type ContextLimitState, type CopyPickerState, type IdentityOverlayState, type Overlay } from './ChatBottomPane.js'
|
|
63
|
+
import { setTokenIdentity, getIdentityStatus } from '../storage/identity.js'
|
|
64
|
+
import type { IdentityHubResult } from '../identity/hub/IdentityHub.js'
|
|
65
|
+
import {
|
|
66
|
+
buildResumedSessionState,
|
|
67
|
+
promptHistoryFromSessionMessages,
|
|
68
|
+
resolveModelSelection,
|
|
69
|
+
restoreConversationState,
|
|
70
|
+
} from './chatSessionState.js'
|
|
71
|
+
import { runStreamingTurn } from './chatTurnOrchestrator.js'
|
|
72
|
+
import { ensureLlamaCppRunnerReady } from '../models/llamacppPreflight.js'
|
|
73
|
+
import type { PlanApprovalAction } from './PlanApprovalView.js'
|
|
74
|
+
import type { ContextLimitAction } from './ContextLimitView.js'
|
|
75
|
+
import type { ContinuityEditReviewAction, ContinuityEditReviewState } from './ContinuityEditReviewView.js'
|
|
76
|
+
import { openFileInEditor } from '../identity/continuity/editor.js'
|
|
77
|
+
import { EMPTY_MCP_SNAPSHOT, McpManager, type McpSnapshot } from '../mcp/manager.js'
|
|
78
|
+
|
|
79
|
+
type ChatScreenProps = {
|
|
80
|
+
config: EthagentConfig
|
|
81
|
+
onReplaceConfig?: (next: EthagentConfig) => void
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type PendingPlan = {
|
|
85
|
+
text: string
|
|
86
|
+
cwd: string
|
|
87
|
+
sessionId: string
|
|
88
|
+
provider: string
|
|
89
|
+
model: string
|
|
90
|
+
contextLabel: string
|
|
91
|
+
awaitingApproval: boolean
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type CompactionKind = 'conversation' | 'plan'
|
|
95
|
+
|
|
96
|
+
type CompactionUiState = {
|
|
97
|
+
kind: CompactionKind
|
|
98
|
+
progressRowId: string
|
|
99
|
+
sourceSessionId: string
|
|
100
|
+
startedAt: number
|
|
101
|
+
stage: string
|
|
102
|
+
controller: AbortController
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let rowIdSeq = 0
|
|
106
|
+
const nextRowId = (): string => `row-${++rowIdSeq}`
|
|
107
|
+
const nowIso = (): string => new Date().toISOString()
|
|
108
|
+
const STREAM_FLUSH_MS = 120
|
|
109
|
+
const CONTEXT_CONFIRM_PERCENT = 90
|
|
110
|
+
const MAX_PROMPT_HISTORY = 500
|
|
111
|
+
const MAX_HANDOFF_SUMMARY_CHARS = 12_000
|
|
112
|
+
|
|
113
|
+
function compressHome(cwd: string): string {
|
|
114
|
+
const home = os.homedir()
|
|
115
|
+
if (cwd === home) return '~'
|
|
116
|
+
if (cwd.startsWith(home + path.sep)) return '~' + cwd.slice(home.length).replace(/\\/g, '/')
|
|
117
|
+
return cwd.replace(/\\/g, '/')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function ensureLocalProviderReady(config: EthagentConfig): Promise<{ ok: true } | { ok: false; message: string }> {
|
|
121
|
+
if (config.provider === 'llamacpp') return ensureLlamaCppRunnerReady(config)
|
|
122
|
+
return { ok: true }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, onReplaceConfig }) => {
|
|
126
|
+
useRegisterKeybindingContext('Chat')
|
|
127
|
+
const { exit } = useApp()
|
|
128
|
+
const [config, setConfig] = useState<EthagentConfig>(initialConfig)
|
|
129
|
+
const [rows, setRows] = useState<MessageRow[]>([])
|
|
130
|
+
const [history, setHistory] = useState<string[]>([])
|
|
131
|
+
const [streaming, setStreaming] = useState(false)
|
|
132
|
+
const [queuedInputs, setQueuedInputs] = useState<string[]>([])
|
|
133
|
+
const [turns, setTurns] = useState(0)
|
|
134
|
+
const [approxTokens, setApproxTokens] = useState(0)
|
|
135
|
+
const [overlay, setOverlay] = useState<Overlay>('none')
|
|
136
|
+
const [copyPickerState, setCopyPickerState] = useState<CopyPickerState>(null)
|
|
137
|
+
const [contextLimitState, setContextLimitState] = useState<ContextLimitState>(null)
|
|
138
|
+
const [continuityEditReview, setContinuityEditReview] = useState<ContinuityEditReviewState | null>(null)
|
|
139
|
+
const [modelPickerContextFit, setModelPickerContextFit] = useState<ModelPickerContextFit | null>(null)
|
|
140
|
+
const [identityOverlay, setIdentityOverlay] = useState<IdentityOverlayState | null>(null)
|
|
141
|
+
const [permissionRequest, setPermissionRequest] = useState<PermissionRequest | null>(null)
|
|
142
|
+
const [mode, setMode] = useState<SessionMode>('chat')
|
|
143
|
+
const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
|
|
144
|
+
const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
|
|
145
|
+
const [sessionId, setSessionId] = useState<string>(() => newSessionId())
|
|
146
|
+
const [sessionKey, setSessionKey] = useState<number>(0)
|
|
147
|
+
const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
|
|
148
|
+
const [statusStartedAt, setStatusStartedAt] = useState<number>(() => Date.now())
|
|
149
|
+
const [activeContextUsage, setActiveContextUsage] = useState<ContextUsage>(() =>
|
|
150
|
+
contextUsageFromTokens(0, initialConfig.provider, initialConfig.model),
|
|
151
|
+
)
|
|
152
|
+
const [mcpSnapshot, setMcpSnapshot] = useState<McpSnapshot>(EMPTY_MCP_SNAPSHOT)
|
|
153
|
+
|
|
154
|
+
const rowsRef = useRef<MessageRow[]>([])
|
|
155
|
+
const visibleReasoningIdsRef = useRef<string[]>([])
|
|
156
|
+
const sessionMessagesRef = useRef<SessionMessage[]>([])
|
|
157
|
+
const sessionIdRef = useRef<string>(sessionId)
|
|
158
|
+
const globalHistoryRef = useRef<string[]>([])
|
|
159
|
+
const historyScopeRef = useRef<'global' | 'session'>('global')
|
|
160
|
+
const cwdRef = useRef<string>(getRuntimeCwd())
|
|
161
|
+
const overlayRef = useRef<Overlay>(overlay)
|
|
162
|
+
const modeRef = useRef<SessionMode>(mode)
|
|
163
|
+
const streamAbortRef = useRef<AbortController | null>(null)
|
|
164
|
+
const providerRef = useRef<Provider>(createProvider(initialConfig))
|
|
165
|
+
const configRef = useRef<EthagentConfig>(initialConfig)
|
|
166
|
+
const prevConfigRef = useRef<EthagentConfig>(initialConfig)
|
|
167
|
+
const compactingRef = useRef<boolean>(false)
|
|
168
|
+
const pendingAssistantTextRef = useRef<string | null>(null)
|
|
169
|
+
const pendingThinkingTextRef = useRef<string | null>(null)
|
|
170
|
+
const streamFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
171
|
+
const drainingQueueRef = useRef<boolean>(false)
|
|
172
|
+
const permissionResolveRef = useRef<((decision: PermissionDecision) => void) | null>(null)
|
|
173
|
+
const permissionRulesRef = useRef<SessionPermissionRule[]>([])
|
|
174
|
+
const activeCheckpointRef = useRef<TurnCheckpoint | undefined>(undefined)
|
|
175
|
+
const statsSegmentStartRef = useRef<number>(0)
|
|
176
|
+
const pendingPlanRef = useRef<PendingPlan | null>(null)
|
|
177
|
+
const compactionUiRef = useRef<CompactionUiState | null>(null)
|
|
178
|
+
const contextLimitStateRef = useRef<ContextLimitState>(null)
|
|
179
|
+
const pendingContinuityEditReviewRef = useRef<ContinuityEditReviewState | null>(null)
|
|
180
|
+
const contextModelSwitchPromptRef = useRef<string | null>(null)
|
|
181
|
+
const mcpManagerRef = useRef<McpManager | null>(null)
|
|
182
|
+
|
|
183
|
+
useEffect(() => { rowsRef.current = rows }, [rows])
|
|
184
|
+
useEffect(() => { overlayRef.current = overlay }, [overlay])
|
|
185
|
+
useEffect(() => { sessionIdRef.current = sessionId }, [sessionId])
|
|
186
|
+
useEffect(() => { cwdRef.current = cwd }, [cwd])
|
|
187
|
+
useEffect(() => { modeRef.current = mode }, [mode])
|
|
188
|
+
useEffect(() => { pendingPlanRef.current = pendingPlan }, [pendingPlan])
|
|
189
|
+
useEffect(() => { compactionUiRef.current = compactionUi }, [compactionUi])
|
|
190
|
+
useEffect(() => { contextLimitStateRef.current = contextLimitState }, [contextLimitState])
|
|
191
|
+
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (prevConfigRef.current === config) return
|
|
194
|
+
prevConfigRef.current = config
|
|
195
|
+
configRef.current = config
|
|
196
|
+
providerRef.current = createProvider(config)
|
|
197
|
+
}, [config])
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
void (async () => {
|
|
201
|
+
const loaded = await readHistory()
|
|
202
|
+
globalHistoryRef.current = loaded
|
|
203
|
+
if (historyScopeRef.current === 'global') setHistory(loaded)
|
|
204
|
+
})()
|
|
205
|
+
}, [])
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
void (async () => {
|
|
209
|
+
try {
|
|
210
|
+
permissionRulesRef.current = await loadPermissionRules(cwd)
|
|
211
|
+
} catch {
|
|
212
|
+
permissionRulesRef.current = []
|
|
213
|
+
}
|
|
214
|
+
})()
|
|
215
|
+
}, [cwd])
|
|
216
|
+
|
|
217
|
+
useEffect(() => {
|
|
218
|
+
void ensureSessionMetadata(sessionId, {
|
|
219
|
+
cwd,
|
|
220
|
+
provider: config.provider,
|
|
221
|
+
model: config.model,
|
|
222
|
+
mode,
|
|
223
|
+
})
|
|
224
|
+
}, [config.model, config.provider, cwd, mode, sessionId])
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
void updateSessionActivity(
|
|
228
|
+
sessionId,
|
|
229
|
+
{ cwd, provider: config.provider, model: config.model, mode },
|
|
230
|
+
{ lastCwd: cwd, provider: config.provider, model: config.model, mode },
|
|
231
|
+
).catch(() => {})
|
|
232
|
+
}, [config.model, config.provider, cwd, mode, sessionId])
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
return () => {
|
|
236
|
+
streamAbortRef.current?.abort()
|
|
237
|
+
compactionUiRef.current?.controller.abort()
|
|
238
|
+
if (streamFlushTimerRef.current) clearTimeout(streamFlushTimerRef.current)
|
|
239
|
+
permissionResolveRef.current?.('deny')
|
|
240
|
+
void mcpManagerRef.current?.close()
|
|
241
|
+
}
|
|
242
|
+
}, [])
|
|
243
|
+
|
|
244
|
+
const updateRows = useCallback((updater: (prev: MessageRow[]) => MessageRow[]) => {
|
|
245
|
+
setRows(prev => updater(prev))
|
|
246
|
+
}, [])
|
|
247
|
+
|
|
248
|
+
const pushNote = useCallback(
|
|
249
|
+
(text: string, kind: 'info' | 'error' | 'dim' = 'info') => {
|
|
250
|
+
updateRows(prev => [...prev, { role: 'note', id: nextRowId(), kind, content: text }])
|
|
251
|
+
},
|
|
252
|
+
[updateRows],
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (!mcpManagerRef.current) {
|
|
257
|
+
mcpManagerRef.current = new McpManager(cwd, setMcpSnapshot)
|
|
258
|
+
}
|
|
259
|
+
void mcpManagerRef.current.refresh(cwd).catch((err: unknown) => {
|
|
260
|
+
pushNote(`MCP refresh failed: ${(err as Error).message}`, 'error')
|
|
261
|
+
})
|
|
262
|
+
}, [cwd, pushNote])
|
|
263
|
+
|
|
264
|
+
const beginCompactionUi = useCallback((kind: CompactionKind, sourceSessionId: string): CompactionUiState => {
|
|
265
|
+
const progressRowId = nextRowId()
|
|
266
|
+
const state: CompactionUiState = {
|
|
267
|
+
kind,
|
|
268
|
+
progressRowId,
|
|
269
|
+
sourceSessionId,
|
|
270
|
+
startedAt: Date.now(),
|
|
271
|
+
stage: 'preparing transcript',
|
|
272
|
+
controller: new AbortController(),
|
|
273
|
+
}
|
|
274
|
+
compactionUiRef.current = state
|
|
275
|
+
setCompactionUi(state)
|
|
276
|
+
updateRows(prev => [
|
|
277
|
+
...prev,
|
|
278
|
+
{
|
|
279
|
+
role: 'progress',
|
|
280
|
+
id: progressRowId,
|
|
281
|
+
title: kind === 'plan' ? 'summarizing plan context' : 'compacting conversation',
|
|
282
|
+
progress: 0,
|
|
283
|
+
status: state.stage,
|
|
284
|
+
suffix: 'esc to cancel',
|
|
285
|
+
indeterminate: true,
|
|
286
|
+
startedAt: state.startedAt,
|
|
287
|
+
},
|
|
288
|
+
])
|
|
289
|
+
return state
|
|
290
|
+
}, [updateRows])
|
|
291
|
+
|
|
292
|
+
const updateCompactionStage = useCallback((state: CompactionUiState, stage: string) => {
|
|
293
|
+
setCompactionUi(prev => prev?.progressRowId === state.progressRowId ? { ...prev, stage } : prev)
|
|
294
|
+
updateRows(prev => prev.map(row =>
|
|
295
|
+
row.id === state.progressRowId && row.role === 'progress'
|
|
296
|
+
? { ...row, status: stage }
|
|
297
|
+
: row,
|
|
298
|
+
))
|
|
299
|
+
}, [updateRows])
|
|
300
|
+
|
|
301
|
+
const removeCompactionProgress = useCallback((state: CompactionUiState) => {
|
|
302
|
+
updateRows(prev => prev.filter(row => row.id !== state.progressRowId))
|
|
303
|
+
}, [updateRows])
|
|
304
|
+
|
|
305
|
+
const toggleLatestReasoning = useCallback(() => {
|
|
306
|
+
const ids = visibleReasoningIdsRef.current
|
|
307
|
+
updateRows(rows => toggleReasoningRow(rows, ids[ids.length - 1]))
|
|
308
|
+
}, [updateRows])
|
|
309
|
+
|
|
310
|
+
const updateVisibleReasoningIds = useCallback((ids: string[]) => {
|
|
311
|
+
visibleReasoningIdsRef.current = ids
|
|
312
|
+
}, [])
|
|
313
|
+
|
|
314
|
+
const replaceConfig = useCallback(
|
|
315
|
+
(next: EthagentConfig) => {
|
|
316
|
+
configRef.current = next
|
|
317
|
+
providerRef.current = createProvider(next)
|
|
318
|
+
setConfig(next)
|
|
319
|
+
onReplaceConfig?.(next)
|
|
320
|
+
},
|
|
321
|
+
[onReplaceConfig],
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const clearPendingPlan = useCallback(() => {
|
|
325
|
+
pendingPlanRef.current = null
|
|
326
|
+
setPendingPlan(null)
|
|
327
|
+
if (overlayRef.current === 'planApproval') {
|
|
328
|
+
overlayRef.current = 'none'
|
|
329
|
+
setOverlay('none')
|
|
330
|
+
}
|
|
331
|
+
}, [])
|
|
332
|
+
|
|
333
|
+
const clearContextLimit = useCallback(() => {
|
|
334
|
+
contextLimitStateRef.current = null
|
|
335
|
+
setContextLimitState(null)
|
|
336
|
+
if (overlayRef.current === 'contextLimit') {
|
|
337
|
+
overlayRef.current = 'none'
|
|
338
|
+
setOverlay('none')
|
|
339
|
+
}
|
|
340
|
+
}, [])
|
|
341
|
+
|
|
342
|
+
const openModelPicker = useCallback((contextFit?: ModelPickerContextFit | null, pendingPrompt?: string | null) => {
|
|
343
|
+
contextModelSwitchPromptRef.current = pendingPrompt ?? null
|
|
344
|
+
setModelPickerContextFit(contextFit ?? null)
|
|
345
|
+
overlayRef.current = 'modelPicker'
|
|
346
|
+
setOverlay('modelPicker')
|
|
347
|
+
}, [])
|
|
348
|
+
|
|
349
|
+
const handleModelPickerCancel = useCallback(() => {
|
|
350
|
+
const hadPendingPrompt = contextModelSwitchPromptRef.current !== null
|
|
351
|
+
contextModelSwitchPromptRef.current = null
|
|
352
|
+
setModelPickerContextFit(null)
|
|
353
|
+
overlayRef.current = 'none'
|
|
354
|
+
setOverlay('none')
|
|
355
|
+
if (hadPendingPrompt) pushNote('pending message cancelled.', 'dim')
|
|
356
|
+
}, [pushNote])
|
|
357
|
+
|
|
358
|
+
const changeCwd = useCallback((next: string) => {
|
|
359
|
+
const updated = next === getRuntimeCwd() ? next : setRuntimeCwd(next, cwdRef.current)
|
|
360
|
+
cwdRef.current = updated
|
|
361
|
+
setCwd(updated)
|
|
362
|
+
clearPendingPlan()
|
|
363
|
+
setSessionKey(k => k + 1)
|
|
364
|
+
}, [clearPendingPlan])
|
|
365
|
+
|
|
366
|
+
const clearTranscript = useCallback(() => {
|
|
367
|
+
setRows([])
|
|
368
|
+
setTurns(0)
|
|
369
|
+
setApproxTokens(0)
|
|
370
|
+
setActiveContextUsage(contextUsageFromTokens(0, configRef.current.provider, configRef.current.model))
|
|
371
|
+
setQueuedInputs([])
|
|
372
|
+
clearPendingPlan()
|
|
373
|
+
clearContextLimit()
|
|
374
|
+
contextModelSwitchPromptRef.current = null
|
|
375
|
+
setModelPickerContextFit(null)
|
|
376
|
+
sessionMessagesRef.current = []
|
|
377
|
+
statsSegmentStartRef.current = 0
|
|
378
|
+
historyScopeRef.current = 'global'
|
|
379
|
+
setHistory(globalHistoryRef.current)
|
|
380
|
+
setStatusStartedAt(Date.now())
|
|
381
|
+
const nextId = newSessionId()
|
|
382
|
+
sessionIdRef.current = nextId
|
|
383
|
+
setSessionId(nextId)
|
|
384
|
+
setSessionKey(k => k + 1)
|
|
385
|
+
}, [clearContextLimit, clearPendingPlan])
|
|
386
|
+
|
|
387
|
+
const doExit = useCallback(() => {
|
|
388
|
+
streamAbortRef.current?.abort()
|
|
389
|
+
exit()
|
|
390
|
+
}, [exit])
|
|
391
|
+
|
|
392
|
+
const persistSessionMessage = useCallback(
|
|
393
|
+
async (msg: SessionMessage) => {
|
|
394
|
+
sessionMessagesRef.current = [...sessionMessagesRef.current, msg]
|
|
395
|
+
try {
|
|
396
|
+
await appendSessionMessage(sessionIdRef.current, msg, {
|
|
397
|
+
cwd: cwdRef.current,
|
|
398
|
+
provider: configRef.current.provider,
|
|
399
|
+
model: configRef.current.model,
|
|
400
|
+
mode: modeRef.current,
|
|
401
|
+
})
|
|
402
|
+
} catch {
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
[],
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
const refreshVisibleStats = useCallback(
|
|
409
|
+
(messages: SessionMessage[], providerSupportsTools: boolean, cwdForStats: string, configForStats: EthagentConfig, modeForStats: SessionMode): ContextUsage => {
|
|
410
|
+
const built = buildBaseMessages(messages, configForStats, providerSupportsTools, cwdForStats, modeForStats)
|
|
411
|
+
const tokens = approximateTokens(built)
|
|
412
|
+
const usage = contextUsageFromTokens(tokens, configForStats.provider, configForStats.model)
|
|
413
|
+
setTurns(messages.filter(message => message.role === 'user').length)
|
|
414
|
+
setApproxTokens(tokens)
|
|
415
|
+
setActiveContextUsage(usage)
|
|
416
|
+
return usage
|
|
417
|
+
},
|
|
418
|
+
[],
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
const warnIfContextPressure = useCallback(
|
|
422
|
+
(usage: ContextUsage, configForUsage: EthagentConfig) => {
|
|
423
|
+
if (!shouldConfirmContextUsage(usage, CONTEXT_CONFIRM_PERCENT)) return
|
|
424
|
+
const action = usage.percent >= 100
|
|
425
|
+
? 'New requests will ask you to summarize into a new conversation, switch models, ignore and send, or cancel.'
|
|
426
|
+
: 'Run /compact before continuing, keep the next prompt short, switch models, or choose to send despite the warning.'
|
|
427
|
+
pushNote(
|
|
428
|
+
`current transcript is ${usage.percent}% of ${configForUsage.model}'s context (~${formatTokens(usage.usedTokens)} / ${formatTokens(usage.windowTokens)}). ${action}`,
|
|
429
|
+
usage.percent >= 100 ? 'error' : 'dim',
|
|
430
|
+
)
|
|
431
|
+
},
|
|
432
|
+
[pushNote],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
const applyConfigChange = useCallback(
|
|
436
|
+
(next: EthagentConfig): ContextUsage => {
|
|
437
|
+
replaceConfig(next)
|
|
438
|
+
const usage = refreshVisibleStats(sessionMessagesRef.current, providerRef.current.supportsTools, cwdRef.current, next, modeRef.current)
|
|
439
|
+
warnIfContextPressure(usage, next)
|
|
440
|
+
return usage
|
|
441
|
+
},
|
|
442
|
+
[refreshVisibleStats, replaceConfig, warnIfContextPressure],
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
const attachActiveTurn = useCallback(<T extends SessionMessage>(message: T): T => {
|
|
446
|
+
const turnId = activeCheckpointRef.current?.turnId
|
|
447
|
+
if (!turnId) return message
|
|
448
|
+
return { ...message, turnId } as T
|
|
449
|
+
}, [])
|
|
450
|
+
|
|
451
|
+
const runCompaction = useCallback(
|
|
452
|
+
async (): Promise<boolean> => {
|
|
453
|
+
if (compactingRef.current) return false
|
|
454
|
+
const sourceSessionId = sessionIdRef.current
|
|
455
|
+
const sourceMessages = sessionMessagesRef.current
|
|
456
|
+
const priorMessages: Message[] = buildBaseMessages(
|
|
457
|
+
sourceMessages,
|
|
458
|
+
configRef.current,
|
|
459
|
+
providerRef.current.supportsTools,
|
|
460
|
+
cwdRef.current,
|
|
461
|
+
modeRef.current,
|
|
462
|
+
)
|
|
463
|
+
if (priorMessages.length <= 5) {
|
|
464
|
+
pushNote('not enough turns to compact yet.', 'dim')
|
|
465
|
+
return false
|
|
466
|
+
}
|
|
467
|
+
compactingRef.current = true
|
|
468
|
+
const compaction = beginCompactionUi('conversation', sourceSessionId)
|
|
469
|
+
try {
|
|
470
|
+
const result = await compactTranscript(providerRef.current, priorMessages, {
|
|
471
|
+
signal: compaction.controller.signal,
|
|
472
|
+
onStage: stage => updateCompactionStage(compaction, stage),
|
|
473
|
+
})
|
|
474
|
+
if (!result.ok && result.cancelled) {
|
|
475
|
+
removeCompactionProgress(compaction)
|
|
476
|
+
pushNote('compaction cancelled.', 'dim')
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
const summary = result.ok
|
|
480
|
+
? normalizeHandoffSummary(result.summary)
|
|
481
|
+
: normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
|
|
482
|
+
if (!result.ok) {
|
|
483
|
+
pushNote(`provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
updateCompactionStage(compaction, 'saving summarized conversation')
|
|
487
|
+
const nextSessionId = newSessionId()
|
|
488
|
+
const createdAt = nowIso()
|
|
489
|
+
const summaryMessage: SessionMessage = {
|
|
490
|
+
role: 'user',
|
|
491
|
+
synthetic: true,
|
|
492
|
+
content: [
|
|
493
|
+
`Conversation handoff from ${sourceSessionId.slice(0, 8)}:`,
|
|
494
|
+
'',
|
|
495
|
+
summary,
|
|
496
|
+
].join('\n'),
|
|
497
|
+
createdAt,
|
|
498
|
+
}
|
|
499
|
+
const acknowledgement: SessionMessage = {
|
|
500
|
+
role: 'assistant',
|
|
501
|
+
content: 'Ready to continue from this summary.',
|
|
502
|
+
createdAt: nowIso(),
|
|
503
|
+
model: configRef.current.model,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const context = {
|
|
507
|
+
cwd: cwdRef.current,
|
|
508
|
+
provider: configRef.current.provider,
|
|
509
|
+
model: configRef.current.model,
|
|
510
|
+
mode: modeRef.current,
|
|
511
|
+
}
|
|
512
|
+
await ensureSessionMetadata(nextSessionId, context)
|
|
513
|
+
await updateSessionActivity(
|
|
514
|
+
nextSessionId,
|
|
515
|
+
context,
|
|
516
|
+
{ compactedFromSessionId: sourceSessionId },
|
|
517
|
+
)
|
|
518
|
+
await appendSessionMessage(nextSessionId, summaryMessage, context)
|
|
519
|
+
await appendSessionMessage(nextSessionId, acknowledgement, context)
|
|
520
|
+
|
|
521
|
+
updateCompactionStage(compaction, 'opening summarized conversation')
|
|
522
|
+
const nextMessages = [summaryMessage, acknowledgement]
|
|
523
|
+
compactionUiRef.current = null
|
|
524
|
+
setCompactionUi(null)
|
|
525
|
+
sessionIdRef.current = nextSessionId
|
|
526
|
+
setSessionId(nextSessionId)
|
|
527
|
+
sessionMessagesRef.current = nextMessages
|
|
528
|
+
historyScopeRef.current = 'session'
|
|
529
|
+
setHistory(promptHistoryFromSessionMessages(nextMessages))
|
|
530
|
+
statsSegmentStartRef.current = 0
|
|
531
|
+
setRows([
|
|
532
|
+
{
|
|
533
|
+
role: 'note',
|
|
534
|
+
id: nextRowId(),
|
|
535
|
+
kind: 'dim',
|
|
536
|
+
content: `kept ${sourceSessionId.slice(0, 8)} saved; summarized into ${nextSessionId.slice(0, 8)}.`,
|
|
537
|
+
},
|
|
538
|
+
...sessionMessagesToRows(nextMessages, nextRowId),
|
|
539
|
+
])
|
|
540
|
+
setQueuedInputs([])
|
|
541
|
+
setStatusStartedAt(Date.now())
|
|
542
|
+
refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
|
|
543
|
+
setSessionKey(key => key + 1)
|
|
544
|
+
return true
|
|
545
|
+
} catch (err: unknown) {
|
|
546
|
+
removeCompactionProgress(compaction)
|
|
547
|
+
if (compaction.controller.signal.aborted) {
|
|
548
|
+
pushNote('compaction cancelled.', 'dim')
|
|
549
|
+
} else {
|
|
550
|
+
pushNote(`compact error: ${(err as Error).message}`, 'error')
|
|
551
|
+
}
|
|
552
|
+
return false
|
|
553
|
+
} finally {
|
|
554
|
+
compactingRef.current = false
|
|
555
|
+
compactionUiRef.current = null
|
|
556
|
+
setCompactionUi(null)
|
|
557
|
+
}
|
|
558
|
+
},
|
|
559
|
+
[beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, updateCompactionStage],
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
const assistantTurns = useCallback((): string[] => {
|
|
563
|
+
const out: string[] = []
|
|
564
|
+
for (const message of sessionMessagesRef.current) {
|
|
565
|
+
if (message.role === 'assistant' && message.content) out.push(message.content)
|
|
566
|
+
}
|
|
567
|
+
return out
|
|
568
|
+
}, [])
|
|
569
|
+
|
|
570
|
+
const buildSlashContext = useCallback(
|
|
571
|
+
(): SlashContext => ({
|
|
572
|
+
config: configRef.current,
|
|
573
|
+
turns,
|
|
574
|
+
approxTokens,
|
|
575
|
+
contextUsage: activeContextUsage,
|
|
576
|
+
startedAt: statusStartedAt,
|
|
577
|
+
sessionId: sessionIdRef.current,
|
|
578
|
+
cwd,
|
|
579
|
+
sessionMessages: () => sessionMessagesRef.current,
|
|
580
|
+
mode,
|
|
581
|
+
assistantTurns,
|
|
582
|
+
onReplaceConfig: applyConfigChange,
|
|
583
|
+
onChangeCwd: changeCwd,
|
|
584
|
+
onClear: clearTranscript,
|
|
585
|
+
onExit: doExit,
|
|
586
|
+
onResumeRequest: () => setOverlay('resume'),
|
|
587
|
+
onModelPickerRequest: () => openModelPicker(),
|
|
588
|
+
onRewindRequest: () => setOverlay('rewind'),
|
|
589
|
+
onPermissionsRequest: () => setOverlay('permissions'),
|
|
590
|
+
onCompactRequest: () => { void runCompaction() },
|
|
591
|
+
onIdentityRequest: action => {
|
|
592
|
+
void (async () => {
|
|
593
|
+
const status = await getIdentityStatus(configRef.current)
|
|
594
|
+
const initialAction = action === 'create' || action === 'load' ? action : undefined
|
|
595
|
+
setIdentityOverlay({
|
|
596
|
+
initialAction,
|
|
597
|
+
existing: status ? { address: status.address } : null,
|
|
598
|
+
})
|
|
599
|
+
setOverlay('identity')
|
|
600
|
+
})()
|
|
601
|
+
},
|
|
602
|
+
onCopyPickerRequest: (turnText, turnLabel) => {
|
|
603
|
+
setCopyPickerState({ turnText, turnLabel })
|
|
604
|
+
setOverlay('copyPicker')
|
|
605
|
+
},
|
|
606
|
+
mcp: mcpManagerRef.current ?? undefined,
|
|
607
|
+
}),
|
|
608
|
+
[
|
|
609
|
+
turns,
|
|
610
|
+
approxTokens,
|
|
611
|
+
statusStartedAt,
|
|
612
|
+
assistantTurns,
|
|
613
|
+
applyConfigChange,
|
|
614
|
+
changeCwd,
|
|
615
|
+
clearTranscript,
|
|
616
|
+
doExit,
|
|
617
|
+
openModelPicker,
|
|
618
|
+
runCompaction,
|
|
619
|
+
cwd,
|
|
620
|
+
mode,
|
|
621
|
+
activeContextUsage,
|
|
622
|
+
],
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
const requestPermission = useCallback(
|
|
626
|
+
async (request: PermissionRequest): Promise<PermissionDecision> => {
|
|
627
|
+
setPermissionRequest(request)
|
|
628
|
+
setOverlay('permission')
|
|
629
|
+
return await new Promise<PermissionDecision>(resolve => {
|
|
630
|
+
permissionResolveRef.current = resolve
|
|
631
|
+
})
|
|
632
|
+
},
|
|
633
|
+
[],
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
const resolvePermission = useCallback((decision: PermissionDecision) => {
|
|
637
|
+
const resolve = permissionResolveRef.current
|
|
638
|
+
permissionResolveRef.current = null
|
|
639
|
+
setPermissionRequest(null)
|
|
640
|
+
setOverlay('none')
|
|
641
|
+
resolve?.(decision)
|
|
642
|
+
}, [])
|
|
643
|
+
|
|
644
|
+
const executeTool = useCallback(
|
|
645
|
+
async (
|
|
646
|
+
name: string,
|
|
647
|
+
input: Record<string, unknown>,
|
|
648
|
+
permissionMode: PermissionMode,
|
|
649
|
+
): Promise<{ result: { ok: boolean; summary: string; content: string }; sessionRule?: SessionPermissionRule; persistRule?: boolean }> => {
|
|
650
|
+
const outcome = await executeToolWithPermissions({
|
|
651
|
+
name,
|
|
652
|
+
input,
|
|
653
|
+
permissionMode,
|
|
654
|
+
cwd: cwdRef.current,
|
|
655
|
+
config: configRef.current,
|
|
656
|
+
checkpoint: activeCheckpointRef.current,
|
|
657
|
+
abortSignal: streamAbortRef.current?.signal,
|
|
658
|
+
dynamicTools: mcpManagerRef.current?.getTools() ?? [],
|
|
659
|
+
mcp: mcpManagerRef.current ?? undefined,
|
|
660
|
+
getPermissionRules: () => permissionRulesRef.current,
|
|
661
|
+
requestPermission,
|
|
662
|
+
onDirectoryChange: next => {
|
|
663
|
+
cwdRef.current = next
|
|
664
|
+
setCwd(next)
|
|
665
|
+
},
|
|
666
|
+
})
|
|
667
|
+
const review = privateContinuityEditReviewFromToolResult(name, input, outcome.result)
|
|
668
|
+
if (review) pendingContinuityEditReviewRef.current = review
|
|
669
|
+
return outcome
|
|
670
|
+
},
|
|
671
|
+
[requestPermission],
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
const applySessionRule = useCallback(
|
|
675
|
+
async (sessionRule?: SessionPermissionRule, persistRule?: boolean) => {
|
|
676
|
+
if (!sessionRule) return
|
|
677
|
+
permissionRulesRef.current = [...permissionRulesRef.current, sessionRule]
|
|
678
|
+
if (!persistRule) return
|
|
679
|
+
try {
|
|
680
|
+
await savePermissionRule(cwdRef.current, sessionRule)
|
|
681
|
+
} catch (error: unknown) {
|
|
682
|
+
pushNote(`failed to save permission rule: ${(error as Error).message}`, 'error')
|
|
683
|
+
}
|
|
684
|
+
},
|
|
685
|
+
[pushNote],
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
const runStream = useCallback(
|
|
689
|
+
async (userText: string, modeOverride?: SessionMode) => {
|
|
690
|
+
const activeMode = modeOverride ?? mode
|
|
691
|
+
const turnProvider = createProvider(configRef.current, {
|
|
692
|
+
mode: activeMode,
|
|
693
|
+
dynamicTools: mcpManagerRef.current?.getTools() ?? [],
|
|
694
|
+
})
|
|
695
|
+
const controller = new AbortController()
|
|
696
|
+
streamAbortRef.current = controller
|
|
697
|
+
let planCandidate: PendingPlan | null = null
|
|
698
|
+
const result = await runStreamingTurn({
|
|
699
|
+
provider: turnProvider,
|
|
700
|
+
mode: activeMode,
|
|
701
|
+
sessionId: sessionIdRef.current,
|
|
702
|
+
userText,
|
|
703
|
+
streamFlushMs: STREAM_FLUSH_MS,
|
|
704
|
+
controller,
|
|
705
|
+
nextRowId,
|
|
706
|
+
nowIso,
|
|
707
|
+
getConfig: () => configRef.current,
|
|
708
|
+
getCwd: () => cwdRef.current,
|
|
709
|
+
getDisplayCwd: () => compressHome(cwdRef.current),
|
|
710
|
+
getSessionMessages: () => sessionMessagesRef.current,
|
|
711
|
+
setActiveCheckpoint: checkpoint => { activeCheckpointRef.current = checkpoint },
|
|
712
|
+
setStreaming,
|
|
713
|
+
updateRows,
|
|
714
|
+
pushNote,
|
|
715
|
+
persistTurnMessage: message => persistSessionMessage(attachActiveTurn(message)),
|
|
716
|
+
executeTool,
|
|
717
|
+
applySessionRule,
|
|
718
|
+
preflightProvider: () => ensureLocalProviderReady(configRef.current),
|
|
719
|
+
onPlanReady: plan => {
|
|
720
|
+
planCandidate = {
|
|
721
|
+
text: plan,
|
|
722
|
+
cwd: cwdRef.current,
|
|
723
|
+
sessionId: sessionIdRef.current,
|
|
724
|
+
provider: configRef.current.provider,
|
|
725
|
+
model: configRef.current.model,
|
|
726
|
+
contextLabel: formatContextLabel(
|
|
727
|
+
contextUsage(buildBaseMessages(sessionMessagesRef.current, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode), configRef.current.provider, configRef.current.model),
|
|
728
|
+
),
|
|
729
|
+
awaitingApproval: true,
|
|
730
|
+
}
|
|
731
|
+
pendingPlanRef.current = planCandidate
|
|
732
|
+
setPendingPlan(planCandidate)
|
|
733
|
+
},
|
|
734
|
+
pendingAssistantTextRef,
|
|
735
|
+
pendingThinkingTextRef,
|
|
736
|
+
streamFlushTimerRef,
|
|
737
|
+
})
|
|
738
|
+
refreshVisibleStats(sessionMessagesRef.current, turnProvider.supportsTools, cwdRef.current, configRef.current, activeMode)
|
|
739
|
+
streamAbortRef.current = null
|
|
740
|
+
if (
|
|
741
|
+
result.finishedNormally &&
|
|
742
|
+
activeMode === 'plan' &&
|
|
743
|
+
planCandidate &&
|
|
744
|
+
pendingPlanRef.current === planCandidate &&
|
|
745
|
+
overlayRef.current === 'none'
|
|
746
|
+
) {
|
|
747
|
+
overlayRef.current = 'planApproval'
|
|
748
|
+
setOverlay('planApproval')
|
|
749
|
+
}
|
|
750
|
+
},
|
|
751
|
+
[applySessionRule, attachActiveTurn, executeTool, mode, persistSessionMessage, pushNote, refreshVisibleStats, updateRows],
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
const pullInFlight = false
|
|
755
|
+
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (overlay !== 'none' || streaming || pullInFlight || compactionUi) return
|
|
758
|
+
const pending = pendingContinuityEditReviewRef.current
|
|
759
|
+
if (!pending) return
|
|
760
|
+
pendingContinuityEditReviewRef.current = null
|
|
761
|
+
setContinuityEditReview(pending)
|
|
762
|
+
overlayRef.current = 'continuityEditReview'
|
|
763
|
+
setOverlay('continuityEditReview')
|
|
764
|
+
}, [compactionUi, overlay, pullInFlight, streaming])
|
|
765
|
+
|
|
766
|
+
const projectedUsageForInput = useCallback((userText: string, modeOverride?: SessionMode): ContextUsage => {
|
|
767
|
+
const activeMode = modeOverride ?? modeRef.current
|
|
768
|
+
const turnProvider = createProvider(configRef.current, {
|
|
769
|
+
mode: activeMode,
|
|
770
|
+
dynamicTools: mcpManagerRef.current?.getTools() ?? [],
|
|
771
|
+
})
|
|
772
|
+
const projectedMessages: SessionMessage[] = [
|
|
773
|
+
...sessionMessagesRef.current,
|
|
774
|
+
{ role: 'user', content: userText, createdAt: nowIso() },
|
|
775
|
+
]
|
|
776
|
+
return contextUsage(
|
|
777
|
+
buildBaseMessages(projectedMessages, configRef.current, turnProvider.supportsTools, cwdRef.current, activeMode),
|
|
778
|
+
configRef.current.provider,
|
|
779
|
+
configRef.current.model,
|
|
780
|
+
)
|
|
781
|
+
}, [])
|
|
782
|
+
|
|
783
|
+
const showContextLimitForPrompt = useCallback((prompt: string): ContextUsage => {
|
|
784
|
+
contextModelSwitchPromptRef.current = null
|
|
785
|
+
setModelPickerContextFit(null)
|
|
786
|
+
const projected = projectedUsageForInput(prompt)
|
|
787
|
+
contextLimitStateRef.current = { usage: projected, prompt }
|
|
788
|
+
setContextLimitState(contextLimitStateRef.current)
|
|
789
|
+
overlayRef.current = 'contextLimit'
|
|
790
|
+
setOverlay('contextLimit')
|
|
791
|
+
return projected
|
|
792
|
+
}, [projectedUsageForInput])
|
|
793
|
+
|
|
794
|
+
const continuePendingPromptAfterModelSwitch = useCallback(
|
|
795
|
+
async (prompt: string | null) => {
|
|
796
|
+
if (!prompt) {
|
|
797
|
+
setModelPickerContextFit(null)
|
|
798
|
+
return
|
|
799
|
+
}
|
|
800
|
+
contextModelSwitchPromptRef.current = null
|
|
801
|
+
setModelPickerContextFit(null)
|
|
802
|
+
const projected = projectedUsageForInput(prompt)
|
|
803
|
+
if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
|
|
804
|
+
contextLimitStateRef.current = { usage: projected, prompt }
|
|
805
|
+
setContextLimitState(contextLimitStateRef.current)
|
|
806
|
+
overlayRef.current = 'contextLimit'
|
|
807
|
+
setOverlay('contextLimit')
|
|
808
|
+
pushNote(
|
|
809
|
+
`selected model is still ${projected.percent}% of its context (~${formatTokens(projected.usedTokens)} / ${formatTokens(projected.windowTokens)}).`,
|
|
810
|
+
projected.percent >= 100 ? 'error' : 'dim',
|
|
811
|
+
)
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
await runStream(prompt)
|
|
815
|
+
},
|
|
816
|
+
[projectedUsageForInput, pushNote, runStream],
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
const handleSubmit = useCallback(
|
|
820
|
+
async (value: string) => {
|
|
821
|
+
const trimmed = value.trim()
|
|
822
|
+
if (!trimmed) return
|
|
823
|
+
|
|
824
|
+
setHistory(h => appendPromptHistoryEntry(h, value))
|
|
825
|
+
globalHistoryRef.current = appendPromptHistoryEntry(globalHistoryRef.current, value)
|
|
826
|
+
void appendHistory(value)
|
|
827
|
+
|
|
828
|
+
if (streaming || pullInFlight || compactionUiRef.current) {
|
|
829
|
+
if (parseSlash(value)) {
|
|
830
|
+
pushNote('slash commands cannot be queued. wait for the current task to finish.', 'dim')
|
|
831
|
+
return
|
|
832
|
+
}
|
|
833
|
+
setQueuedInputs(prev => [...prev, value])
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (parseSlash(value)) {
|
|
838
|
+
const ctx = buildSlashContext()
|
|
839
|
+
const result = await dispatchSlash(value, ctx)
|
|
840
|
+
if (result && result.kind === 'note') {
|
|
841
|
+
pushNote(result.text, result.variant ?? 'info')
|
|
842
|
+
}
|
|
843
|
+
if (result && result.kind === 'submit') {
|
|
844
|
+
const projected = projectedUsageForInput(result.text)
|
|
845
|
+
if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
|
|
846
|
+
showContextLimitForPrompt(result.text)
|
|
847
|
+
return
|
|
848
|
+
}
|
|
849
|
+
await runStream(result.text)
|
|
850
|
+
}
|
|
851
|
+
return
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const projected = projectedUsageForInput(value)
|
|
855
|
+
if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
|
|
856
|
+
showContextLimitForPrompt(value)
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
await runStream(value)
|
|
861
|
+
},
|
|
862
|
+
[buildSlashContext, pullInFlight, projectedUsageForInput, pushNote, runStream, showContextLimitForPrompt, streaming],
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
const handleContextLimitCancel = useCallback(() => {
|
|
866
|
+
clearContextLimit()
|
|
867
|
+
pushNote('pending message cancelled.', 'dim')
|
|
868
|
+
}, [clearContextLimit, pushNote])
|
|
869
|
+
|
|
870
|
+
const handleContextLimitAction = useCallback(
|
|
871
|
+
async (action: ContextLimitAction) => {
|
|
872
|
+
const state = contextLimitStateRef.current
|
|
873
|
+
if (!state) {
|
|
874
|
+
clearContextLimit()
|
|
875
|
+
return
|
|
876
|
+
}
|
|
877
|
+
const prompt = state.prompt
|
|
878
|
+
clearContextLimit()
|
|
879
|
+
if (action === 'cancel') {
|
|
880
|
+
pushNote('pending message cancelled.', 'dim')
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
if (action === 'switchModel') {
|
|
884
|
+
openModelPicker(
|
|
885
|
+
{ usedTokens: state.usage.usedTokens, thresholdPercent: CONTEXT_CONFIRM_PERCENT },
|
|
886
|
+
prompt,
|
|
887
|
+
)
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
if (action === 'compact') {
|
|
891
|
+
const compacted = await runCompaction()
|
|
892
|
+
if (!compacted) return
|
|
893
|
+
setHistory(h => appendPromptHistoryEntry(h, prompt))
|
|
894
|
+
}
|
|
895
|
+
if (action === 'send') {
|
|
896
|
+
pushNote(
|
|
897
|
+
'sending despite context warning; this may hit provider rate/context limits faster or degrade model/tool behavior.',
|
|
898
|
+
'dim',
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
await runStream(prompt)
|
|
902
|
+
},
|
|
903
|
+
[clearContextLimit, openModelPicker, pushNote, runCompaction, runStream],
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
const handleCancelActive = useCallback(() => {
|
|
907
|
+
if (streaming && streamAbortRef.current) {
|
|
908
|
+
streamAbortRef.current.abort()
|
|
909
|
+
return
|
|
910
|
+
}
|
|
911
|
+
compactionUiRef.current?.controller.abort()
|
|
912
|
+
}, [streaming])
|
|
913
|
+
|
|
914
|
+
useCancelRequest({
|
|
915
|
+
abortSignal: streaming ? streamAbortRef.current?.signal : compactionUi?.controller.signal,
|
|
916
|
+
onCancel: handleCancelActive,
|
|
917
|
+
isActive: overlay === 'none',
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
const exitState = useExitOnCtrlC({
|
|
921
|
+
isActive: overlay === 'none',
|
|
922
|
+
onInterrupt: () => {
|
|
923
|
+
if (streaming && streamAbortRef.current) {
|
|
924
|
+
streamAbortRef.current.abort()
|
|
925
|
+
return true
|
|
926
|
+
}
|
|
927
|
+
if (compactionUiRef.current) {
|
|
928
|
+
compactionUiRef.current.controller.abort()
|
|
929
|
+
return true
|
|
930
|
+
}
|
|
931
|
+
return false
|
|
932
|
+
},
|
|
933
|
+
onExit: doExit,
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
useKeybinding(
|
|
937
|
+
'chat:modelPicker',
|
|
938
|
+
() => { if (overlay === 'none') openModelPicker() },
|
|
939
|
+
{ context: 'Chat', isActive: overlay === 'none' },
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
useKeybinding(
|
|
943
|
+
'chat:identityHub',
|
|
944
|
+
() => {
|
|
945
|
+
if (overlay !== 'none') return
|
|
946
|
+
setIdentityOverlay({
|
|
947
|
+
initialAction: undefined,
|
|
948
|
+
existing: configRef.current.identity ? { address: configRef.current.identity.address } : null,
|
|
949
|
+
})
|
|
950
|
+
setOverlay('identity')
|
|
951
|
+
},
|
|
952
|
+
{ context: 'Chat', isActive: overlay === 'none' },
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
useKeybinding(
|
|
956
|
+
'chat:toggleReasoning',
|
|
957
|
+
() => { if (overlay === 'none') toggleLatestReasoning() },
|
|
958
|
+
{ context: 'Chat', isActive: overlay === 'none' },
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
useKeybinding(
|
|
962
|
+
'chat:cycleMode',
|
|
963
|
+
() => {
|
|
964
|
+
if (overlay !== 'none') return
|
|
965
|
+
const nextMode = nextSessionMode(mode)
|
|
966
|
+
modeRef.current = nextMode
|
|
967
|
+
setMode(nextMode)
|
|
968
|
+
if (nextMode !== 'plan') clearPendingPlan()
|
|
969
|
+
},
|
|
970
|
+
{ context: 'Chat', isActive: overlay === 'none' },
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
useKeybinding(
|
|
974
|
+
'app:redraw',
|
|
975
|
+
() => setSessionKey(k => k + 1),
|
|
976
|
+
{ context: 'Global' },
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
const handleModelPick = useCallback(
|
|
980
|
+
async (sel: ModelPickerSelection) => {
|
|
981
|
+
const pendingPrompt = contextModelSwitchPromptRef.current
|
|
982
|
+
overlayRef.current = 'none'
|
|
983
|
+
setOverlay('none')
|
|
984
|
+
const resolution = resolveModelSelection(sel, configRef.current)
|
|
985
|
+
if (resolution.kind === 'noop') {
|
|
986
|
+
if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
await saveConfig(resolution.config)
|
|
991
|
+
applyConfigChange(resolution.config)
|
|
992
|
+
pushNote(resolution.notice, resolution.tone)
|
|
993
|
+
await continuePendingPromptAfterModelSwitch(pendingPrompt)
|
|
994
|
+
} catch (err: unknown) {
|
|
995
|
+
pushNote(`provider switch failed: ${(err as Error).message}`, 'error')
|
|
996
|
+
if (pendingPrompt) showContextLimitForPrompt(pendingPrompt)
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
[applyConfigChange, continuePendingPromptAfterModelSwitch, pushNote, showContextLimitForPrompt],
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
const handleResumePick = useCallback(
|
|
1003
|
+
async (id: string) => {
|
|
1004
|
+
setOverlay('none')
|
|
1005
|
+
try {
|
|
1006
|
+
const [loaded, metadata] = await Promise.all([loadSession(id), loadSessionMetadata(id)])
|
|
1007
|
+
if (loaded.length === 0) {
|
|
1008
|
+
pushNote('session was empty.', 'error')
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
const resumed = buildResumedSessionState({
|
|
1012
|
+
messages: loaded,
|
|
1013
|
+
metadata,
|
|
1014
|
+
fallbackCwd: cwd,
|
|
1015
|
+
nextRowId,
|
|
1016
|
+
})
|
|
1017
|
+
const resumedCwd = resumed.cwd
|
|
1018
|
+
if (resumedCwd) {
|
|
1019
|
+
try {
|
|
1020
|
+
const updated = setRuntimeCwd(resumedCwd)
|
|
1021
|
+
cwdRef.current = updated
|
|
1022
|
+
setCwd(updated)
|
|
1023
|
+
} catch {
|
|
1024
|
+
cwdRef.current = resumedCwd
|
|
1025
|
+
setCwd(resumedCwd)
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
clearPendingPlan()
|
|
1029
|
+
clearContextLimit()
|
|
1030
|
+
modeRef.current = resumed.mode
|
|
1031
|
+
setMode(resumed.mode)
|
|
1032
|
+
sessionIdRef.current = id
|
|
1033
|
+
setSessionId(id)
|
|
1034
|
+
sessionMessagesRef.current = loaded
|
|
1035
|
+
historyScopeRef.current = 'session'
|
|
1036
|
+
setHistory(resumed.promptHistory)
|
|
1037
|
+
statsSegmentStartRef.current = 0
|
|
1038
|
+
setStatusStartedAt(resumed.statusStartedAt)
|
|
1039
|
+
setRows(resumed.rows)
|
|
1040
|
+
refreshVisibleStats(loaded, providerRef.current.supportsTools, resumedCwd, configRef.current, resumed.mode)
|
|
1041
|
+
setSessionKey(k => k + 1)
|
|
1042
|
+
} catch (err: unknown) {
|
|
1043
|
+
pushNote(`resume failed: ${(err as Error).message}`, 'error')
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
1046
|
+
[clearContextLimit, clearPendingPlan, cwd, pushNote, refreshVisibleStats],
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
const handleResumeClearAll = useCallback(
|
|
1050
|
+
async () => {
|
|
1051
|
+
await clearAllSessions()
|
|
1052
|
+
clearTranscript()
|
|
1053
|
+
overlayRef.current = 'none'
|
|
1054
|
+
setOverlay('none')
|
|
1055
|
+
pushNote('cleared saved chat logs and resume context from this machine.', 'dim')
|
|
1056
|
+
},
|
|
1057
|
+
[clearTranscript, pushNote],
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
const handleIdentityResult = useCallback(
|
|
1061
|
+
(result: IdentityHubResult) => {
|
|
1062
|
+
setOverlay('none')
|
|
1063
|
+
setIdentityOverlay(null)
|
|
1064
|
+
if (result.kind === 'updated') {
|
|
1065
|
+
applyConfigChange(result.config)
|
|
1066
|
+
pushNote(result.message, 'info')
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
1069
|
+
if (result.kind === 'token') {
|
|
1070
|
+
void (async () => {
|
|
1071
|
+
try {
|
|
1072
|
+
const nextConfig = await setTokenIdentity(configRef.current, result.identity)
|
|
1073
|
+
applyConfigChange(nextConfig)
|
|
1074
|
+
pushNote(`identity saved · ERC-8004 #${result.identity.agentId}`, 'info')
|
|
1075
|
+
} catch (err: unknown) {
|
|
1076
|
+
pushNote(`identity save failed: ${(err as Error).message}`, 'error')
|
|
1077
|
+
}
|
|
1078
|
+
})()
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
[applyConfigChange, pushNote],
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
const handleContinuityEditReviewAction = useCallback(
|
|
1085
|
+
async (action: ContinuityEditReviewAction) => {
|
|
1086
|
+
const review = continuityEditReview
|
|
1087
|
+
if (!review) return
|
|
1088
|
+
if (action === 'open') {
|
|
1089
|
+
const result = await openFileInEditor(review.filePath)
|
|
1090
|
+
pushNote(
|
|
1091
|
+
result.ok
|
|
1092
|
+
? `opened ${review.file} with ${result.method}.`
|
|
1093
|
+
: `open failed: ${result.error}`,
|
|
1094
|
+
result.ok ? 'dim' : 'error',
|
|
1095
|
+
)
|
|
1096
|
+
return
|
|
1097
|
+
}
|
|
1098
|
+
setContinuityEditReview(null)
|
|
1099
|
+
if (action === 'save-publish') {
|
|
1100
|
+
const status = await getIdentityStatus(configRef.current)
|
|
1101
|
+
setIdentityOverlay({
|
|
1102
|
+
initialAction: 'save-snapshot',
|
|
1103
|
+
existing: status ? { address: status.address } : null,
|
|
1104
|
+
})
|
|
1105
|
+
overlayRef.current = 'identity'
|
|
1106
|
+
setOverlay('identity')
|
|
1107
|
+
pushNote('opening snapshot approval.', 'dim')
|
|
1108
|
+
return
|
|
1109
|
+
}
|
|
1110
|
+
overlayRef.current = 'none'
|
|
1111
|
+
setOverlay('none')
|
|
1112
|
+
pushNote('snapshot not published yet.', 'dim')
|
|
1113
|
+
},
|
|
1114
|
+
[continuityEditReview, pushNote],
|
|
1115
|
+
)
|
|
1116
|
+
|
|
1117
|
+
const handleContinuityEditReviewCancel = useCallback(() => {
|
|
1118
|
+
setContinuityEditReview(null)
|
|
1119
|
+
overlayRef.current = 'none'
|
|
1120
|
+
setOverlay('none')
|
|
1121
|
+
pushNote('snapshot not published yet.', 'dim')
|
|
1122
|
+
}, [pushNote])
|
|
1123
|
+
|
|
1124
|
+
const handleCopyDone = useCallback(
|
|
1125
|
+
(result: CopyResult, label: string) => {
|
|
1126
|
+
setOverlay('none')
|
|
1127
|
+
setCopyPickerState(null)
|
|
1128
|
+
if (result.ok) {
|
|
1129
|
+
pushNote(`copied ${label} via ${result.method}.`, 'dim')
|
|
1130
|
+
} else {
|
|
1131
|
+
pushNote(`copy failed: ${result.error}`, 'error')
|
|
1132
|
+
}
|
|
1133
|
+
},
|
|
1134
|
+
[pushNote],
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
const handleCopyCancel = useCallback(() => {
|
|
1138
|
+
setOverlay('none')
|
|
1139
|
+
setCopyPickerState(null)
|
|
1140
|
+
pushNote('copy cancelled.', 'dim')
|
|
1141
|
+
}, [pushNote])
|
|
1142
|
+
|
|
1143
|
+
const handleRestoreConversation = useCallback((turnId: string) => {
|
|
1144
|
+
const restored = restoreConversationState(sessionMessagesRef.current, turnId, nextRowId)
|
|
1145
|
+
sessionMessagesRef.current = restored.messages
|
|
1146
|
+
setRows(restored.rows)
|
|
1147
|
+
historyScopeRef.current = 'session'
|
|
1148
|
+
setHistory(restored.promptHistory)
|
|
1149
|
+
if (restored.truncated) {
|
|
1150
|
+
setQueuedInputs([])
|
|
1151
|
+
statsSegmentStartRef.current = Math.min(statsSegmentStartRef.current, restored.messages.length)
|
|
1152
|
+
refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
|
|
1153
|
+
setSessionKey(key => key + 1)
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
refreshVisibleStats(restored.messages, providerRef.current.supportsTools, cwdRef.current, configRef.current, mode)
|
|
1157
|
+
}, [mode, refreshVisibleStats])
|
|
1158
|
+
|
|
1159
|
+
const startFreshImplementationContext = useCallback(() => {
|
|
1160
|
+
const nextSessionId = newSessionId()
|
|
1161
|
+
sessionMessagesRef.current = []
|
|
1162
|
+
statsSegmentStartRef.current = 0
|
|
1163
|
+
sessionIdRef.current = nextSessionId
|
|
1164
|
+
historyScopeRef.current = 'global'
|
|
1165
|
+
setHistory(globalHistoryRef.current)
|
|
1166
|
+
setSessionId(nextSessionId)
|
|
1167
|
+
setRows([])
|
|
1168
|
+
setTurns(0)
|
|
1169
|
+
setApproxTokens(0)
|
|
1170
|
+
setQueuedInputs([])
|
|
1171
|
+
setStatusStartedAt(Date.now())
|
|
1172
|
+
setSessionKey(key => key + 1)
|
|
1173
|
+
}, [])
|
|
1174
|
+
|
|
1175
|
+
const startSummarizedPlanImplementationContext = useCallback(
|
|
1176
|
+
async (plan: string): Promise<boolean> => {
|
|
1177
|
+
if (compactingRef.current) return false
|
|
1178
|
+
|
|
1179
|
+
const sourceSessionId = sessionIdRef.current
|
|
1180
|
+
const priorMessages = buildBaseMessages(
|
|
1181
|
+
sessionMessagesRef.current,
|
|
1182
|
+
configRef.current,
|
|
1183
|
+
providerRef.current.supportsTools,
|
|
1184
|
+
cwdRef.current,
|
|
1185
|
+
modeRef.current,
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
if (priorMessages.length <= 5) {
|
|
1189
|
+
startFreshImplementationContext()
|
|
1190
|
+
pushNote('not enough planning context to summarize; starting a plan-only implementation conversation.', 'dim')
|
|
1191
|
+
return true
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
compactingRef.current = true
|
|
1195
|
+
const compaction = beginCompactionUi('plan', sourceSessionId)
|
|
1196
|
+
try {
|
|
1197
|
+
const result = await compactTranscript(providerRef.current, priorMessages, {
|
|
1198
|
+
signal: compaction.controller.signal,
|
|
1199
|
+
onStage: stage => updateCompactionStage(compaction, stage),
|
|
1200
|
+
})
|
|
1201
|
+
if (!result.ok && result.cancelled) {
|
|
1202
|
+
removeCompactionProgress(compaction)
|
|
1203
|
+
pushNote('plan context summary cancelled.', 'dim')
|
|
1204
|
+
return false
|
|
1205
|
+
}
|
|
1206
|
+
const summary = result.ok
|
|
1207
|
+
? normalizeHandoffSummary(result.summary)
|
|
1208
|
+
: normalizeHandoffSummary(summarizeTranscriptLocally(priorMessages, result.reason))
|
|
1209
|
+
if (!result.ok) {
|
|
1210
|
+
pushNote(`provider summary failed; created a local summary instead: ${result.reason}`, 'dim')
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
updateCompactionStage(compaction, 'saving summarized conversation')
|
|
1214
|
+
const nextSessionId = newSessionId()
|
|
1215
|
+
const createdAt = nowIso()
|
|
1216
|
+
const nextMessages = buildPlanTransferSeedMessages({
|
|
1217
|
+
sourceSessionId,
|
|
1218
|
+
summary,
|
|
1219
|
+
plan,
|
|
1220
|
+
createdAt,
|
|
1221
|
+
})
|
|
1222
|
+
const context = {
|
|
1223
|
+
cwd: cwdRef.current,
|
|
1224
|
+
provider: configRef.current.provider,
|
|
1225
|
+
model: configRef.current.model,
|
|
1226
|
+
mode: modeRef.current,
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
await ensureSessionMetadata(nextSessionId, context)
|
|
1230
|
+
await updateSessionActivity(nextSessionId, context, { compactedFromSessionId: sourceSessionId })
|
|
1231
|
+
for (const message of nextMessages) {
|
|
1232
|
+
await appendSessionMessage(nextSessionId, message, context)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
updateCompactionStage(compaction, 'opening summarized conversation')
|
|
1236
|
+
compactionUiRef.current = null
|
|
1237
|
+
setCompactionUi(null)
|
|
1238
|
+
sessionIdRef.current = nextSessionId
|
|
1239
|
+
setSessionId(nextSessionId)
|
|
1240
|
+
sessionMessagesRef.current = nextMessages
|
|
1241
|
+
historyScopeRef.current = 'session'
|
|
1242
|
+
setHistory(promptHistoryFromSessionMessages(nextMessages))
|
|
1243
|
+
statsSegmentStartRef.current = 0
|
|
1244
|
+
setRows([
|
|
1245
|
+
{
|
|
1246
|
+
role: 'note',
|
|
1247
|
+
id: nextRowId(),
|
|
1248
|
+
kind: 'dim',
|
|
1249
|
+
content: `kept ${sourceSessionId.slice(0, 8)} saved; transferred plan into ${nextSessionId.slice(0, 8)}.`,
|
|
1250
|
+
},
|
|
1251
|
+
...sessionMessagesToRows(nextMessages, nextRowId),
|
|
1252
|
+
])
|
|
1253
|
+
setQueuedInputs([])
|
|
1254
|
+
setStatusStartedAt(Date.now())
|
|
1255
|
+
refreshVisibleStats(nextMessages, providerRef.current.supportsTools, cwdRef.current, configRef.current, modeRef.current)
|
|
1256
|
+
setSessionKey(key => key + 1)
|
|
1257
|
+
return true
|
|
1258
|
+
} catch (err: unknown) {
|
|
1259
|
+
removeCompactionProgress(compaction)
|
|
1260
|
+
if (compaction.controller.signal.aborted) {
|
|
1261
|
+
pushNote('plan context summary cancelled.', 'dim')
|
|
1262
|
+
} else {
|
|
1263
|
+
pushNote(`context summary error: ${(err as Error).message}`, 'error')
|
|
1264
|
+
}
|
|
1265
|
+
return false
|
|
1266
|
+
} finally {
|
|
1267
|
+
compactingRef.current = false
|
|
1268
|
+
compactionUiRef.current = null
|
|
1269
|
+
setCompactionUi(null)
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
[beginCompactionUi, pushNote, refreshVisibleStats, removeCompactionProgress, startFreshImplementationContext, updateCompactionStage],
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
const handlePlanApprovalCancel = useCallback(() => {
|
|
1276
|
+
const plan = pendingPlanRef.current
|
|
1277
|
+
if (plan) {
|
|
1278
|
+
const next = { ...plan, awaitingApproval: false }
|
|
1279
|
+
pendingPlanRef.current = next
|
|
1280
|
+
setPendingPlan(next)
|
|
1281
|
+
}
|
|
1282
|
+
if (overlayRef.current === 'planApproval') {
|
|
1283
|
+
overlayRef.current = 'none'
|
|
1284
|
+
setOverlay('none')
|
|
1285
|
+
}
|
|
1286
|
+
}, [])
|
|
1287
|
+
|
|
1288
|
+
const handlePlanApproval = useCallback(
|
|
1289
|
+
async (action: PlanApprovalAction) => {
|
|
1290
|
+
const plan = pendingPlanRef.current
|
|
1291
|
+
if (!plan) {
|
|
1292
|
+
handlePlanApprovalCancel()
|
|
1293
|
+
return
|
|
1294
|
+
}
|
|
1295
|
+
if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
|
|
1296
|
+
clearPendingPlan()
|
|
1297
|
+
pushNote('dismissed stale plan approval because the workspace changed.', 'dim')
|
|
1298
|
+
return
|
|
1299
|
+
}
|
|
1300
|
+
if (action === 'continue') {
|
|
1301
|
+
handlePlanApprovalCancel()
|
|
1302
|
+
return
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const nextMode: SessionMode = 'accept-edits'
|
|
1306
|
+
if (action === 'apply-summary') {
|
|
1307
|
+
const transferred = await startSummarizedPlanImplementationContext(plan.text)
|
|
1308
|
+
if (!transferred) return
|
|
1309
|
+
}
|
|
1310
|
+
clearPendingPlan()
|
|
1311
|
+
modeRef.current = nextMode
|
|
1312
|
+
setMode(nextMode)
|
|
1313
|
+
await runStream(buildPlanImplementationPrompt(plan.text), nextMode)
|
|
1314
|
+
},
|
|
1315
|
+
[
|
|
1316
|
+
clearPendingPlan,
|
|
1317
|
+
handlePlanApprovalCancel,
|
|
1318
|
+
pushNote,
|
|
1319
|
+
runStream,
|
|
1320
|
+
startSummarizedPlanImplementationContext,
|
|
1321
|
+
],
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
const busy = pullInFlight || Boolean(compactionUi)
|
|
1325
|
+
const slashSuggestions = useMemo(
|
|
1326
|
+
() => getSlashSuggestions(mcpManagerRef.current?.getPromptSuggestions() ?? []),
|
|
1327
|
+
[mcpSnapshot],
|
|
1328
|
+
)
|
|
1329
|
+
|
|
1330
|
+
useEffect(() => {
|
|
1331
|
+
const plan = pendingPlanRef.current
|
|
1332
|
+
if (!plan?.awaitingApproval) return
|
|
1333
|
+
if (mode !== 'plan' || overlay !== 'none' || streaming || pullInFlight || compactionUi) return
|
|
1334
|
+
if (plan.cwd !== cwdRef.current || plan.sessionId !== sessionIdRef.current) {
|
|
1335
|
+
clearPendingPlan()
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
overlayRef.current = 'planApproval'
|
|
1339
|
+
setOverlay('planApproval')
|
|
1340
|
+
}, [clearPendingPlan, compactionUi, mode, overlay, pullInFlight, streaming])
|
|
1341
|
+
|
|
1342
|
+
useEffect(() => {
|
|
1343
|
+
if (overlay !== 'none') return
|
|
1344
|
+
if (streaming || pullInFlight || compactionUi || queuedInputs.length === 0 || drainingQueueRef.current) return
|
|
1345
|
+
drainingQueueRef.current = true
|
|
1346
|
+
const next = queuedInputs[0]
|
|
1347
|
+
setQueuedInputs(prev => prev.slice(1))
|
|
1348
|
+
void (async () => {
|
|
1349
|
+
if (!next) return
|
|
1350
|
+
const projected = projectedUsageForInput(next)
|
|
1351
|
+
if (shouldConfirmContextUsage(projected, CONTEXT_CONFIRM_PERCENT)) {
|
|
1352
|
+
showContextLimitForPrompt(next)
|
|
1353
|
+
return
|
|
1354
|
+
}
|
|
1355
|
+
await runStream(next)
|
|
1356
|
+
})().finally(() => {
|
|
1357
|
+
drainingQueueRef.current = false
|
|
1358
|
+
})
|
|
1359
|
+
}, [compactionUi, overlay, projectedUsageForInput, pullInFlight, pushNote, queuedInputs, runStream, showContextLimitForPrompt, streaming])
|
|
1360
|
+
|
|
1361
|
+
const contextLine = `${config.provider} · ${formatModelDisplayName(config.provider, config.model, { maxLength: 24 })} · ${compressHome(cwd)}`
|
|
1362
|
+
const tipLine = streaming
|
|
1363
|
+
? 'tip: you can keep typing and press enter to queue the next message · shift+enter for newline'
|
|
1364
|
+
: 'tip: type /help to get started · shift+enter for newline'
|
|
1365
|
+
|
|
1366
|
+
const placeholderHints = useMemo(() => {
|
|
1367
|
+
if (compactionUi) return ['compaction in progress · esc to cancel']
|
|
1368
|
+
return []
|
|
1369
|
+
}, [compactionUi])
|
|
1370
|
+
|
|
1371
|
+
const exitHint = exitState.pending ? 'ctrl+c again to quit' : null
|
|
1372
|
+
const runtimeModeLabel = sessionModeLabel(mode)
|
|
1373
|
+
const modeColor =
|
|
1374
|
+
mode === 'plan'
|
|
1375
|
+
? theme.accentLavender
|
|
1376
|
+
: mode === 'accept-edits'
|
|
1377
|
+
? theme.accentPeach
|
|
1378
|
+
: theme.accentMint
|
|
1379
|
+
const footerRight = (
|
|
1380
|
+
<Box flexDirection="row">
|
|
1381
|
+
{exitHint ? (
|
|
1382
|
+
<>
|
|
1383
|
+
<Text color={theme.accentPrimary}>{exitHint}</Text>
|
|
1384
|
+
<Text color={theme.dim}> · </Text>
|
|
1385
|
+
</>
|
|
1386
|
+
) : null}
|
|
1387
|
+
{runtimeModeLabel ? (
|
|
1388
|
+
<>
|
|
1389
|
+
<Text color={modeColor}>{runtimeModeLabel}</Text>
|
|
1390
|
+
<Text color={theme.dim}> (</Text>
|
|
1391
|
+
<Text color={theme.accentMint}>shift+tab to cycle</Text>
|
|
1392
|
+
<Text color={theme.dim}>) · </Text>
|
|
1393
|
+
</>
|
|
1394
|
+
) : (
|
|
1395
|
+
<>
|
|
1396
|
+
<Text color={theme.accentMint}>shift+tab to cycle</Text>
|
|
1397
|
+
<Text color={theme.dim}> · </Text>
|
|
1398
|
+
</>
|
|
1399
|
+
)}
|
|
1400
|
+
<Text color={theme.dim}>
|
|
1401
|
+
{'pgup/pgdn scroll · alt+p model · alt+i identity'}
|
|
1402
|
+
</Text>
|
|
1403
|
+
</Box>
|
|
1404
|
+
)
|
|
1405
|
+
const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} />
|
|
1406
|
+
return (
|
|
1407
|
+
<ConversationStack
|
|
1408
|
+
header={header}
|
|
1409
|
+
rows={rows}
|
|
1410
|
+
transcriptActive={overlay === 'none'}
|
|
1411
|
+
bottomVariant={overlay === 'none' ? 'prompt' : 'overlay'}
|
|
1412
|
+
bottom={(
|
|
1413
|
+
<ChatBottomPane
|
|
1414
|
+
overlay={overlay}
|
|
1415
|
+
config={config}
|
|
1416
|
+
sessionId={sessionId}
|
|
1417
|
+
cwd={cwd}
|
|
1418
|
+
currentSessionId={sessionId}
|
|
1419
|
+
copyPickerState={copyPickerState}
|
|
1420
|
+
contextLimitState={contextLimitState}
|
|
1421
|
+
continuityEditReview={continuityEditReview}
|
|
1422
|
+
modelPickerContextFit={modelPickerContextFit}
|
|
1423
|
+
permissionRequest={permissionRequest}
|
|
1424
|
+
history={history}
|
|
1425
|
+
busy={busy}
|
|
1426
|
+
streaming={streaming}
|
|
1427
|
+
activity={null}
|
|
1428
|
+
placeholderHints={placeholderHints}
|
|
1429
|
+
queuedInputs={queuedInputs}
|
|
1430
|
+
slashSuggestions={slashSuggestions}
|
|
1431
|
+
planApprovalContextLabel={pendingPlan?.contextLabel ?? formatContextLabel(activeContextUsage)}
|
|
1432
|
+
footerRight={footerRight}
|
|
1433
|
+
handleModelPick={handleModelPick}
|
|
1434
|
+
handleModelPickerCancel={handleModelPickerCancel}
|
|
1435
|
+
handleResumePick={handleResumePick}
|
|
1436
|
+
handleResumeClearAll={handleResumeClearAll}
|
|
1437
|
+
identityOverlay={identityOverlay}
|
|
1438
|
+
handleIdentityResult={handleIdentityResult}
|
|
1439
|
+
handleRestoreConversation={handleRestoreConversation}
|
|
1440
|
+
handleCopyDone={handleCopyDone}
|
|
1441
|
+
handleCopyCancel={handleCopyCancel}
|
|
1442
|
+
resolvePermission={resolvePermission}
|
|
1443
|
+
handlePlanApproval={handlePlanApproval}
|
|
1444
|
+
handlePlanApprovalCancel={handlePlanApprovalCancel}
|
|
1445
|
+
handleContextLimitAction={handleContextLimitAction}
|
|
1446
|
+
handleContextLimitCancel={handleContextLimitCancel}
|
|
1447
|
+
handleContinuityEditReviewAction={handleContinuityEditReviewAction}
|
|
1448
|
+
handleContinuityEditReviewCancel={handleContinuityEditReviewCancel}
|
|
1449
|
+
onPermissionRulesChanged={rules => { permissionRulesRef.current = rules }}
|
|
1450
|
+
onConfigChange={replaceConfig}
|
|
1451
|
+
handleSubmit={handleSubmit}
|
|
1452
|
+
setOverlay={setOverlay}
|
|
1453
|
+
pushNote={pushNote}
|
|
1454
|
+
/>
|
|
1455
|
+
)}
|
|
1456
|
+
status={(
|
|
1457
|
+
<SessionStatus
|
|
1458
|
+
provider={config.provider}
|
|
1459
|
+
model={config.model}
|
|
1460
|
+
turns={turns}
|
|
1461
|
+
approxTokens={approxTokens}
|
|
1462
|
+
startedAt={statusStartedAt}
|
|
1463
|
+
contextUsage={activeContextUsage}
|
|
1464
|
+
/>
|
|
1465
|
+
)}
|
|
1466
|
+
sessionKey={sessionKey}
|
|
1467
|
+
onVisibleReasoningIdsChange={updateVisibleReasoningIds}
|
|
1468
|
+
/>
|
|
1469
|
+
)
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function formatContextLabel(usage: ContextUsage): string {
|
|
1473
|
+
if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
|
|
1474
|
+
return `Estimated context: ${usage.percent}% used`
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
export function buildPlanImplementationPrompt(plan: string): string {
|
|
1478
|
+
return [
|
|
1479
|
+
'Implement the approved plan below.',
|
|
1480
|
+
'',
|
|
1481
|
+
'Use native ethagent tools directly. Do not translate tool names into shell commands.',
|
|
1482
|
+
'For workspace inspection, call list_directory and read_file directly.',
|
|
1483
|
+
'For file creation or edits, call edit_file directly.',
|
|
1484
|
+
'Use run_bash only for an actual shell command that cannot be performed by a narrower native tool, such as starting a local server after files exist.',
|
|
1485
|
+
'Ignore any plan wording that says to execute file work as a Bash script or directly in the terminal; the native tools above are authoritative.',
|
|
1486
|
+
'Read the relevant files before editing, make the required changes, and verify the result when possible.',
|
|
1487
|
+
'',
|
|
1488
|
+
plan,
|
|
1489
|
+
].join('\n')
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
export function buildPlanTransferSeedMessages(args: {
|
|
1493
|
+
sourceSessionId: string
|
|
1494
|
+
summary: string
|
|
1495
|
+
plan: string
|
|
1496
|
+
createdAt: string
|
|
1497
|
+
}): SessionMessage[] {
|
|
1498
|
+
return [
|
|
1499
|
+
{
|
|
1500
|
+
role: 'user',
|
|
1501
|
+
synthetic: true,
|
|
1502
|
+
content: [
|
|
1503
|
+
`Planning handoff from ${args.sourceSessionId.slice(0, 8)}:`,
|
|
1504
|
+
'',
|
|
1505
|
+
args.summary.trim(),
|
|
1506
|
+
].join('\n'),
|
|
1507
|
+
createdAt: args.createdAt,
|
|
1508
|
+
},
|
|
1509
|
+
{
|
|
1510
|
+
role: 'user',
|
|
1511
|
+
synthetic: true,
|
|
1512
|
+
content: [
|
|
1513
|
+
'Approved plan to implement:',
|
|
1514
|
+
'',
|
|
1515
|
+
args.plan.trim(),
|
|
1516
|
+
].join('\n'),
|
|
1517
|
+
createdAt: args.createdAt,
|
|
1518
|
+
},
|
|
1519
|
+
]
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
function appendPromptHistoryEntry(history: string[], value: string): string[] {
|
|
1523
|
+
const prompt = value.trim()
|
|
1524
|
+
if (!prompt) return history
|
|
1525
|
+
const next = history[history.length - 1] === prompt ? history : [...history, prompt]
|
|
1526
|
+
return next.length > MAX_PROMPT_HISTORY ? next.slice(-MAX_PROMPT_HISTORY) : next
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
function normalizeHandoffSummary(summary: string): string {
|
|
1530
|
+
const trimmed = summary.trim()
|
|
1531
|
+
if (trimmed.length <= MAX_HANDOFF_SUMMARY_CHARS) return trimmed
|
|
1532
|
+
return [
|
|
1533
|
+
trimmed.slice(0, MAX_HANDOFF_SUMMARY_CHARS - 96).trimEnd(),
|
|
1534
|
+
'',
|
|
1535
|
+
'[handoff truncated to keep the resumed conversation responsive]',
|
|
1536
|
+
].join('\n')
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
export function privateContinuityEditReviewFromToolResult(
|
|
1540
|
+
name: string,
|
|
1541
|
+
input: Record<string, unknown>,
|
|
1542
|
+
result: { ok: boolean; summary: string; content: string },
|
|
1543
|
+
): ContinuityEditReviewState | null {
|
|
1544
|
+
if (name !== 'propose_private_continuity_edit' || !result.ok) return null
|
|
1545
|
+
const file = normalizePrivateContinuityFile(input.file)
|
|
1546
|
+
if (!file) return null
|
|
1547
|
+
const filePath = extractReviewFilePath(result.content)
|
|
1548
|
+
if (!filePath) return null
|
|
1549
|
+
return {
|
|
1550
|
+
file,
|
|
1551
|
+
filePath,
|
|
1552
|
+
summary: result.summary,
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function normalizePrivateContinuityFile(value: unknown): ContinuityEditReviewState['file'] | null {
|
|
1557
|
+
if (typeof value !== 'string') return null
|
|
1558
|
+
if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
|
|
1559
|
+
if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
|
|
1560
|
+
return null
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
function extractReviewFilePath(content: string): string | null {
|
|
1564
|
+
for (const line of content.split(/\r?\n/)) {
|
|
1565
|
+
const review = line.match(/^(?:[-*]\s+)?review file:\s*(.+)$/i)
|
|
1566
|
+
if (review?.[1]?.trim()) return cleanReviewFilePath(review[1])
|
|
1567
|
+
const updated = line.match(/^(?:[-*]\s+)?updated local private continuity file\s+(.+)$/i)
|
|
1568
|
+
if (updated?.[1]?.trim()) return cleanReviewFilePath(updated[1])
|
|
1569
|
+
}
|
|
1570
|
+
return null
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function cleanReviewFilePath(value: string): string {
|
|
1574
|
+
return value.trim().replace(/^`+|`+$/g, '').trim()
|
|
1575
|
+
}
|