ethagent 1.1.1 → 1.1.2
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/README.md +3 -1
- package/package.json +10 -8
- package/src/app/FirstRun.tsx +2 -0
- package/src/chat/ChatScreen.tsx +8 -3
- package/src/chat/ContinuityEditReviewView.tsx +6 -0
- package/src/chat/ConversationStack.tsx +3 -0
- package/src/chat/TranscriptView.tsx +6 -0
- package/src/cli/updateNotice.ts +1 -1
- package/src/identity/hub/IdentityHub.tsx +4 -2
- package/src/identity/hub/identityHubModel.ts +80 -0
- package/src/identity/hub/identityHubReducer.ts +2 -2
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +13 -1
- package/src/identity/hub/screens/EditProfileFlow.tsx +1 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +36 -20
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +24 -16
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +3 -0
- package/src/ui/Select.tsx +1 -1
- package/src/ui/Surface.tsx +1 -1
- package/src/ui/TextInput.tsx +142 -23
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ ethagent requires Node.js 20 or newer. Install it from npm with `npm install -g
|
|
|
11
11
|
|
|
12
12
|
## First Run
|
|
13
13
|
|
|
14
|
-
First run walks
|
|
14
|
+
First run walks identity setup, then model setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
|
|
15
15
|
|
|
16
16
|
Create a new ERC-8004 agent with a browser wallet, load an agent token you already own, or skip identity setup and add it later from the Identity Hub.
|
|
17
17
|
|
|
@@ -31,6 +31,8 @@ The Identity Hub manages the portable identity. Each identity gets a local conti
|
|
|
31
31
|
|
|
32
32
|
**Save Snapshot Now** encrypts the current private continuity, pins the public discovery files, writes registration metadata, and updates the ERC-8004 tokenURI. **Refetch Latest Snapshot** reads the current tokenURI from chain, asks the owner wallet to sign the decrypt challenge, and restores local files from the published state. The current tokenURI is the source of truth.
|
|
33
33
|
|
|
34
|
+
Agents can also be looked up by token ID on [8004scan](https://8004scan.io/).
|
|
35
|
+
|
|
34
36
|
## Models
|
|
35
37
|
|
|
36
38
|
ethagent works with OpenAI, Anthropic, Gemini, and local GGUF models served through a llama.cpp-compatible endpoint.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ethagent",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "A privacy-first AI agent with a portable Ethereum identity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "bin/ethagent.js",
|
|
@@ -23,14 +23,16 @@
|
|
|
23
23
|
},
|
|
24
24
|
"keywords": [
|
|
25
25
|
"ethereum",
|
|
26
|
-
"
|
|
27
|
-
"ai",
|
|
28
|
-
"
|
|
26
|
+
"erc-8004",
|
|
27
|
+
"ai-agent",
|
|
28
|
+
"cli",
|
|
29
|
+
"privacy",
|
|
29
30
|
"ipfs",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
31
|
+
"wallet",
|
|
32
|
+
"llm",
|
|
33
|
+
"gguf",
|
|
34
|
+
"mcp",
|
|
35
|
+
"identity"
|
|
34
36
|
],
|
|
35
37
|
"repository": {
|
|
36
38
|
"type": "git",
|
package/src/app/FirstRun.tsx
CHANGED
|
@@ -289,6 +289,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
|
|
|
289
289
|
<TextInput
|
|
290
290
|
isSecret
|
|
291
291
|
placeholder={provider === 'openai' ? 'sk-...' : 'paste key and press enter'}
|
|
292
|
+
chromeWidth={4}
|
|
292
293
|
validate={v => v.trim().length >= 8 ? null : 'key looks too short'}
|
|
293
294
|
onSubmit={async value => {
|
|
294
295
|
const trimmed = value.trim()
|
|
@@ -326,6 +327,7 @@ export const FirstRun: React.FC<FirstRunProps> = ({ onComplete, onCancel }) => {
|
|
|
326
327
|
<TextInput
|
|
327
328
|
initialValue={defaultModel}
|
|
328
329
|
placeholder={defaultModel}
|
|
330
|
+
chromeWidth={4}
|
|
329
331
|
onSubmit={model => goTo({
|
|
330
332
|
kind: 'saving',
|
|
331
333
|
config: withFirstRunIdentity({
|
package/src/chat/ChatScreen.tsx
CHANGED
|
@@ -143,6 +143,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
143
143
|
const [mode, setMode] = useState<SessionMode>('chat')
|
|
144
144
|
const [pendingPlan, setPendingPlan] = useState<PendingPlan | null>(null)
|
|
145
145
|
const [compactionUi, setCompactionUi] = useState<CompactionUiState | null>(null)
|
|
146
|
+
const [canScrollTranscript, setCanScrollTranscript] = useState(false)
|
|
146
147
|
const [sessionId, setSessionId] = useState<string>(() => newSessionId())
|
|
147
148
|
const [sessionKey, setSessionKey] = useState<number>(0)
|
|
148
149
|
const [cwd, setCwd] = useState<string>(() => syncCwdFromProcess())
|
|
@@ -1094,6 +1095,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1094
1095
|
: `open failed: ${result.error}`,
|
|
1095
1096
|
result.ok ? 'dim' : 'error',
|
|
1096
1097
|
)
|
|
1098
|
+
if (result.ok) setContinuityEditReview(prev => prev ? { ...prev, editorOpened: true } : null)
|
|
1097
1099
|
return
|
|
1098
1100
|
}
|
|
1099
1101
|
setContinuityEditReview(null)
|
|
@@ -1398,9 +1400,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1398
1400
|
<Text color={theme.dim}> · </Text>
|
|
1399
1401
|
</>
|
|
1400
1402
|
)}
|
|
1401
|
-
<Text color={theme.dim}>
|
|
1402
|
-
{'pgup/pgdn scroll · alt+p model · alt+i identity'}
|
|
1403
|
-
</Text>
|
|
1403
|
+
<Text color={theme.dim}>{chatFooterShortcutText(canScrollTranscript)}</Text>
|
|
1404
1404
|
</Box>
|
|
1405
1405
|
)
|
|
1406
1406
|
const header = <BrandSplash contextLine={contextLine} tipLine={tipLine} updateNotice={updateNotice ?? null} />
|
|
@@ -1466,10 +1466,15 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({ config: initialConfig, o
|
|
|
1466
1466
|
)}
|
|
1467
1467
|
sessionKey={sessionKey}
|
|
1468
1468
|
onVisibleReasoningIdsChange={updateVisibleReasoningIds}
|
|
1469
|
+
onTranscriptScrollabilityChange={setCanScrollTranscript}
|
|
1469
1470
|
/>
|
|
1470
1471
|
)
|
|
1471
1472
|
}
|
|
1472
1473
|
|
|
1474
|
+
export function chatFooterShortcutText(canScrollTranscript: boolean): string {
|
|
1475
|
+
return `${canScrollTranscript ? 'pgup/pgdn scroll · ' : ''}alt+p model · alt+i identity`
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1473
1478
|
function formatContextLabel(usage: ContextUsage): string {
|
|
1474
1479
|
if (!Number.isFinite(usage.usedTokens) || usage.usedTokens <= 0) return 'Estimated context: empty'
|
|
1475
1480
|
return `Estimated context: ${usage.percent}% used`
|
|
@@ -8,6 +8,7 @@ export type ContinuityEditReviewState = {
|
|
|
8
8
|
file: 'SOUL.md' | 'MEMORY.md'
|
|
9
9
|
filePath: string
|
|
10
10
|
summary: string
|
|
11
|
+
editorOpened?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export type ContinuityEditReviewAction = 'open' | 'save-publish' | 'later'
|
|
@@ -28,6 +29,11 @@ export const ContinuityEditReviewView: React.FC<{
|
|
|
28
29
|
<Text color={theme.textSubtle}>review file</Text>
|
|
29
30
|
<Text color={theme.text}>{review.filePath}</Text>
|
|
30
31
|
</Box>
|
|
32
|
+
{review.editorOpened && (
|
|
33
|
+
<Box marginTop={1}>
|
|
34
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
)}
|
|
31
37
|
<Box marginTop={1} flexDirection="column">
|
|
32
38
|
<Text color={theme.textSubtle}>saved locally</Text>
|
|
33
39
|
<Text color={theme.dim}>Previous version saved in identity history. /rewind does not restore identity continuity.</Text>
|
|
@@ -12,6 +12,7 @@ type ConversationStackProps = {
|
|
|
12
12
|
status?: React.ReactNode
|
|
13
13
|
sessionKey: number
|
|
14
14
|
onVisibleReasoningIdsChange?: (ids: string[]) => void
|
|
15
|
+
onTranscriptScrollabilityChange?: (canScroll: boolean) => void
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
@@ -23,6 +24,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
|
23
24
|
status,
|
|
24
25
|
sessionKey,
|
|
25
26
|
onVisibleReasoningIdsChange,
|
|
27
|
+
onTranscriptScrollabilityChange,
|
|
26
28
|
}) => {
|
|
27
29
|
return (
|
|
28
30
|
<Box flexDirection="column" padding={1}>
|
|
@@ -33,6 +35,7 @@ export const ConversationStack: React.FC<ConversationStackProps> = ({
|
|
|
33
35
|
active={transcriptActive}
|
|
34
36
|
bottomVariant={bottomVariant}
|
|
35
37
|
onVisibleReasoningIdsChange={onVisibleReasoningIdsChange}
|
|
38
|
+
onScrollabilityChange={onTranscriptScrollabilityChange}
|
|
36
39
|
/>
|
|
37
40
|
<Box marginTop={1} width="100%">
|
|
38
41
|
{bottom}
|
|
@@ -21,6 +21,7 @@ type TranscriptViewProps = {
|
|
|
21
21
|
active?: boolean
|
|
22
22
|
bottomVariant?: 'prompt' | 'overlay'
|
|
23
23
|
onVisibleReasoningIdsChange?: (ids: string[]) => void
|
|
24
|
+
onScrollabilityChange?: (canScroll: boolean) => void
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
const PROMPT_RESERVED_LINES = 11
|
|
@@ -33,6 +34,7 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
|
|
|
33
34
|
active = true,
|
|
34
35
|
bottomVariant = 'prompt',
|
|
35
36
|
onVisibleReasoningIdsChange,
|
|
37
|
+
onScrollabilityChange,
|
|
36
38
|
}) => {
|
|
37
39
|
const { stdout } = useStdout()
|
|
38
40
|
const columns = stdout.columns ?? process.stdout.columns ?? 80
|
|
@@ -89,6 +91,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
|
|
|
89
91
|
onVisibleReasoningIdsChange?.(visibleReasoningIds)
|
|
90
92
|
}, [onVisibleReasoningIdsChange, visibleReasoningIds])
|
|
91
93
|
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
onScrollabilityChange?.(metrics.maxScrollTop > 0)
|
|
96
|
+
}, [metrics.maxScrollTop, onScrollabilityChange])
|
|
97
|
+
|
|
92
98
|
useAppInput((_input, key) => {
|
|
93
99
|
if (key.pageUp) {
|
|
94
100
|
const target = promptScrollTopForPageUp(
|
package/src/cli/updateNotice.ts
CHANGED
|
@@ -24,7 +24,7 @@ export function isNewerVersion(candidate: string, current: string): boolean {
|
|
|
24
24
|
|
|
25
25
|
export function formatUpdateNotice(currentVersion: string, latestVersion: string): string | null {
|
|
26
26
|
if (!isNewerVersion(latestVersion, currentVersion)) return null
|
|
27
|
-
return
|
|
27
|
+
return `✨ update available: ethagent ${currentVersion} -> ${latestVersion} · run npm i -g ethagent@latest`
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export async function checkForUpdates(
|
|
@@ -381,8 +381,8 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
381
381
|
? `opened ${kind === 'soul' ? 'SOUL.md' : kind === 'memory' ? 'MEMORY.md' : 'skills.json'} with ${result.method}.`
|
|
382
382
|
: `open failed: ${result.error}`
|
|
383
383
|
setStep(kind === 'skills'
|
|
384
|
-
? { kind: 'continuity-public', notice: message }
|
|
385
|
-
: { kind: 'continuity-private', notice: message })
|
|
384
|
+
? { kind: 'continuity-public', notice: message, editorOpened: result.ok }
|
|
385
|
+
: { kind: 'continuity-private', notice: message, editorOpened: result.ok })
|
|
386
386
|
} catch (err: unknown) {
|
|
387
387
|
errorStep(err, kind === 'skills' ? { kind: 'continuity-public' } : { kind: 'continuity-private' })
|
|
388
388
|
}
|
|
@@ -609,6 +609,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
609
609
|
ready={continuityReady}
|
|
610
610
|
notice={step.notice}
|
|
611
611
|
footer={footer}
|
|
612
|
+
editorOpened={step.editorOpened}
|
|
612
613
|
onOpenSoul={() => { void openContinuityFile('soul') }}
|
|
613
614
|
onOpenMemory={() => { void openContinuityFile('memory') }}
|
|
614
615
|
onBack={back}
|
|
@@ -625,6 +626,7 @@ export const IdentityHub: React.FC<IdentityHubProps> = ({ mode, config, initialA
|
|
|
625
626
|
ready={continuityReady}
|
|
626
627
|
notice={step.notice}
|
|
627
628
|
footer={footer}
|
|
629
|
+
editorOpened={step.editorOpened}
|
|
628
630
|
onEditProfile={() => openPublicProfileEdit({ kind: 'continuity-public' })}
|
|
629
631
|
onOpenSkills={() => { void openContinuityFile('skills') }}
|
|
630
632
|
onBack={back}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../registry/erc8004.js'
|
|
7
7
|
import { AgentStateOwnerMismatchError } from '../crypto/backupEnvelope.js'
|
|
8
8
|
import { ContinuitySnapshotOwnerMismatchError } from '../continuity/envelope.js'
|
|
9
|
+
import type { ContinuityWorkingTreeStatus } from '../continuity/storage.js'
|
|
9
10
|
import { resolveSelectedNetwork } from '../registry/registryConfig.js'
|
|
10
11
|
|
|
11
12
|
export const PREFLIGHT_AGENT_URI = 'ipfs://bafybeigdyrztma2dbfczw7q6ooozbxlqzyw5r7w4f3qw2axvvxqg3w6y7q'
|
|
@@ -214,6 +215,85 @@ export function identitySummaryRows(
|
|
|
214
215
|
]
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
export type LocalChangeStatusView = {
|
|
219
|
+
label: string
|
|
220
|
+
detail: string
|
|
221
|
+
tone: 'ok' | 'warn' | 'dim'
|
|
222
|
+
files: string[]
|
|
223
|
+
hasLocalChanges: boolean
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function changedContinuitySnapshotFiles(
|
|
227
|
+
workingStatus?: ContinuityWorkingTreeStatus | null,
|
|
228
|
+
): string[] {
|
|
229
|
+
if (!workingStatus?.localContentHashes || !workingStatus.publishedContentHashes) return []
|
|
230
|
+
const files: Array<keyof typeof workingStatus.localContentHashes> = ['SOUL.md', 'MEMORY.md', 'skills.json']
|
|
231
|
+
return files.filter(file => workingStatus.localContentHashes?.[file] !== workingStatus.publishedContentHashes?.[file])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function localChangeStatusView(
|
|
235
|
+
workingStatus?: ContinuityWorkingTreeStatus | null,
|
|
236
|
+
): LocalChangeStatusView {
|
|
237
|
+
if (!workingStatus) {
|
|
238
|
+
return {
|
|
239
|
+
label: 'Local Changes',
|
|
240
|
+
detail: 'Checking status...',
|
|
241
|
+
tone: 'dim',
|
|
242
|
+
files: [],
|
|
243
|
+
hasLocalChanges: false,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (workingStatus.publishState === 'published') {
|
|
248
|
+
return {
|
|
249
|
+
label: 'Local Changes',
|
|
250
|
+
detail: 'None detected',
|
|
251
|
+
tone: 'ok',
|
|
252
|
+
files: [],
|
|
253
|
+
hasLocalChanges: false,
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (workingStatus.publishState === 'local-changes') {
|
|
258
|
+
const files = changedContinuitySnapshotFiles(workingStatus)
|
|
259
|
+
return {
|
|
260
|
+
label: 'Local Changes',
|
|
261
|
+
detail: files.length > 0 ? `Detected: ${files.join(', ')}` : 'Detected: local files differ from saved snapshot',
|
|
262
|
+
tone: 'warn',
|
|
263
|
+
files,
|
|
264
|
+
hasLocalChanges: true,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (workingStatus.publishState === 'not-published') {
|
|
269
|
+
return {
|
|
270
|
+
label: 'Local Changes',
|
|
271
|
+
detail: 'Snapshot not saved yet',
|
|
272
|
+
tone: 'warn',
|
|
273
|
+
files: [],
|
|
274
|
+
hasLocalChanges: false,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (workingStatus.publishState === 'verify-needed') {
|
|
279
|
+
return {
|
|
280
|
+
label: 'Local Changes',
|
|
281
|
+
detail: 'Unable to verify saved snapshot',
|
|
282
|
+
tone: 'warn',
|
|
283
|
+
files: [],
|
|
284
|
+
hasLocalChanges: false,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
label: 'Local Changes',
|
|
290
|
+
detail: 'Local files not restored',
|
|
291
|
+
tone: 'warn',
|
|
292
|
+
files: [],
|
|
293
|
+
hasLocalChanges: false,
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
217
297
|
export type IdentityDetailSection = {
|
|
218
298
|
title: string
|
|
219
299
|
rows: Array<{
|
|
@@ -31,8 +31,8 @@ export type Step =
|
|
|
31
31
|
| { kind: 'restore-authorizing'; cid: string; apiUrl: string; envelope: RestorableBackupEnvelope; candidate: Erc8004AgentCandidate; purpose?: RestorePurpose }
|
|
32
32
|
| { kind: 'rebackup-signing'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
|
|
33
33
|
| { kind: 'rebackup-storage'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; error?: string; pinataJwt?: string; profileUpdates?: ProfileUpdates; returnTo?: Step }
|
|
34
|
-
| { kind: 'continuity-private'; notice?: string }
|
|
35
|
-
| { kind: 'continuity-public'; notice?: string }
|
|
34
|
+
| { kind: 'continuity-private'; notice?: string; editorOpened?: boolean }
|
|
35
|
+
| { kind: 'continuity-public'; notice?: string; editorOpened?: boolean }
|
|
36
36
|
| { kind: 'rebackup-confirm'; back: Step }
|
|
37
37
|
| { kind: 'recovery-refetch-confirm'; back: Step }
|
|
38
38
|
| { kind: 'recovery-refetching'; identity: EthagentIdentity; registry: Erc8004RegistryConfig; back: Step }
|
|
@@ -17,6 +17,7 @@ type CommonProps = {
|
|
|
17
17
|
workingStatus?: ContinuityWorkingTreeStatus | null
|
|
18
18
|
ready: boolean
|
|
19
19
|
notice?: string
|
|
20
|
+
editorOpened?: boolean
|
|
20
21
|
footer: React.ReactNode
|
|
21
22
|
onBack: () => void
|
|
22
23
|
}
|
|
@@ -42,6 +43,7 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
42
43
|
workingStatus,
|
|
43
44
|
ready,
|
|
44
45
|
notice,
|
|
46
|
+
editorOpened,
|
|
45
47
|
footer,
|
|
46
48
|
onOpenSoul,
|
|
47
49
|
onOpenMemory,
|
|
@@ -51,6 +53,11 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
51
53
|
<IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
|
|
52
54
|
<PrivateRows identity={identity} ready={ready} />
|
|
53
55
|
<SaveFromHubHint workingStatus={workingStatus} />
|
|
56
|
+
{editorOpened && (
|
|
57
|
+
<Box marginTop={1}>
|
|
58
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
59
|
+
</Box>
|
|
60
|
+
)}
|
|
54
61
|
<Box marginTop={1}>
|
|
55
62
|
<Select<PrivateAction>
|
|
56
63
|
options={[
|
|
@@ -75,11 +82,16 @@ export const PrivateContinuityScreen: React.FC<CommonProps & {
|
|
|
75
82
|
export const PublicSkillsScreen: React.FC<CommonProps & {
|
|
76
83
|
onEditProfile: () => void
|
|
77
84
|
onOpenSkills: () => void
|
|
78
|
-
}> = ({ identity, config, workingStatus, notice, footer, onEditProfile, onOpenSkills, onBack }) => (
|
|
85
|
+
}> = ({ identity, config, workingStatus, notice, editorOpened, footer, onEditProfile, onOpenSkills, onBack }) => (
|
|
79
86
|
<Surface title="Public Profile" subtitle={notice ?? 'Manage public metadata, skills.json, and the agent card.'} footer={footer}>
|
|
80
87
|
<IdentitySummary identity={identity} config={config} workingStatus={workingStatus} compact />
|
|
81
88
|
<PublicProfileRows identity={identity} />
|
|
82
89
|
<SaveFromHubHint workingStatus={workingStatus} />
|
|
90
|
+
{editorOpened && (
|
|
91
|
+
<Box marginTop={1}>
|
|
92
|
+
<Text color={theme.accentPeach}>Save with ctrl+s in your editor</Text>
|
|
93
|
+
</Box>
|
|
94
|
+
)}
|
|
83
95
|
<Box marginTop={1}>
|
|
84
96
|
<Select<PublicAction>
|
|
85
97
|
options={[
|
|
@@ -2,10 +2,22 @@ import React from 'react'
|
|
|
2
2
|
import { Box, Text } from 'ink'
|
|
3
3
|
import { theme } from '../../../ui/theme.js'
|
|
4
4
|
import type { EthagentConfig, EthagentIdentity } from '../../../storage/config.js'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
identitySummaryRows,
|
|
7
|
+
lastBackupLabel,
|
|
8
|
+
localChangeStatusView,
|
|
9
|
+
type LocalChangeStatusView,
|
|
10
|
+
} from '../identityHubModel.js'
|
|
6
11
|
|
|
7
12
|
import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
|
|
8
13
|
|
|
14
|
+
type SummaryRow = {
|
|
15
|
+
label: string
|
|
16
|
+
value: string
|
|
17
|
+
tone: 'ok' | 'dim' | 'warn'
|
|
18
|
+
highlight?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
9
21
|
export const IdentitySummary: React.FC<{
|
|
10
22
|
identity?: EthagentIdentity
|
|
11
23
|
config?: EthagentConfig
|
|
@@ -25,24 +37,10 @@ export const IdentitySummary: React.FC<{
|
|
|
25
37
|
: ''
|
|
26
38
|
|
|
27
39
|
const row = (label: string) => rows.find(item => item.label === label)
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
let changedFiles: string[] = []
|
|
31
|
-
if (needsBackup) {
|
|
32
|
-
if (workingStatus?.localContentHashes && workingStatus?.publishedContentHashes) {
|
|
33
|
-
if (workingStatus.localContentHashes['SOUL.md'] !== workingStatus.publishedContentHashes['SOUL.md']) changedFiles.push('SOUL.md')
|
|
34
|
-
if (workingStatus.localContentHashes['MEMORY.md'] !== workingStatus.publishedContentHashes['MEMORY.md']) changedFiles.push('MEMORY.md')
|
|
35
|
-
if (workingStatus.localContentHashes['skills.json'] !== workingStatus.publishedContentHashes['skills.json']) changedFiles.push('skills.json')
|
|
36
|
-
} else {
|
|
37
|
-
changedFiles = ['SOUL.md', 'MEMORY.md', 'skills.json']
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
+
const localChangeStatus = localChangeStatusView(workingStatus)
|
|
41
|
+
const lastSavedRow: SummaryRow = { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' : 'ok' }
|
|
40
42
|
|
|
41
|
-
const
|
|
42
|
-
? { label: 'Unsaved', value: changedFiles.length > 0 ? changedFiles.join(', ') : 'Markdown files', tone: 'warn' as const, highlight: true }
|
|
43
|
-
: { label: 'Last Saved', value: lastBackup, tone: lastBackup === 'never' ? 'dim' as const : 'ok' as const }
|
|
44
|
-
|
|
45
|
-
const summaryRows = [
|
|
43
|
+
const summaryRows: SummaryRow[] = [
|
|
46
44
|
{ label: 'Token', value: row('token')?.value ?? 'Not Created', tone: row('token')?.tone ?? 'dim', highlight: true },
|
|
47
45
|
{ label: 'Network', value: row('network')?.value ?? 'Unknown', tone: row('network')?.tone ?? 'dim' },
|
|
48
46
|
{ label: 'Owner', value: row('owner')?.value ?? 'Not Connected', tone: row('owner')?.tone ?? 'dim' },
|
|
@@ -57,18 +55,36 @@ export const IdentitySummary: React.FC<{
|
|
|
57
55
|
<Box flexDirection="column">
|
|
58
56
|
<Text color={theme.accentPrimary} bold>{stateName || 'Active Agent'}</Text>
|
|
59
57
|
{summaryRows.map(row => {
|
|
60
|
-
const valueColor = row.tone === 'warn' ? '
|
|
58
|
+
const valueColor = row.tone === 'warn' ? '#e87070' : (row.tone === 'ok' ? theme.text : theme.dim)
|
|
61
59
|
return (
|
|
62
60
|
<Text key={row.label}>
|
|
63
61
|
<Text color={theme.dim}>{row.label.padEnd(12)}</Text>
|
|
64
|
-
<Text color={valueColor} bold={row.highlight}>{displayValue(row.value)}</Text>
|
|
62
|
+
<Text color={valueColor} bold={row.highlight ?? false}>{displayValue(row.value)}</Text>
|
|
65
63
|
</Text>
|
|
66
64
|
)
|
|
67
65
|
})}
|
|
66
|
+
<Box marginTop={1}>
|
|
67
|
+
<LocalChangeStatusLine status={localChangeStatus} />
|
|
68
|
+
</Box>
|
|
68
69
|
</Box>
|
|
69
70
|
)
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
const LocalChangeStatusLine: React.FC<{ status: LocalChangeStatusView }> = ({ status }) => {
|
|
74
|
+
if (status.hasLocalChanges) {
|
|
75
|
+
return (
|
|
76
|
+
<Text color="#e87070" bold>
|
|
77
|
+
Local changes detected
|
|
78
|
+
{status.files.length > 0 ? `: ${status.files.join(', ')}` : ''}
|
|
79
|
+
</Text>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const color = status.tone === 'ok' ? theme.accentMint : status.tone === 'warn' ? theme.accentPeach : theme.dim
|
|
84
|
+
const label = status.detail === 'None detected' ? 'No local changes detected' : status.detail
|
|
85
|
+
return <Text color={color}>{label}</Text>
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
function displayValue(value: string): string {
|
|
73
89
|
const mapped = DISPLAY_VALUES[value]
|
|
74
90
|
return mapped ?? value
|
|
@@ -3,6 +3,7 @@ import { Box, Text } from 'ink'
|
|
|
3
3
|
import { Surface } from '../../../ui/Surface.js'
|
|
4
4
|
import { Select } from '../../../ui/Select.js'
|
|
5
5
|
import { theme } from '../../../ui/theme.js'
|
|
6
|
+
import { localChangeStatusView, type LocalChangeStatusView } from '../identityHubModel.js'
|
|
6
7
|
|
|
7
8
|
import type { ContinuityWorkingTreeStatus } from '../../continuity/storage.js'
|
|
8
9
|
|
|
@@ -31,29 +32,21 @@ export const RecoveryConfirmScreen: React.FC<RecoveryConfirmScreenProps> = ({ mo
|
|
|
31
32
|
? 'Any local edits to SOUL.md, MEMORY.md, skills.json, and public profile become the saved state. The previous snapshot pointer is overwritten.'
|
|
32
33
|
: 'Unsaved local edits will be lost. Use this when local files are missing or out of sync with the latest saved snapshot.'
|
|
33
34
|
|
|
34
|
-
const
|
|
35
|
-
let changedFiles: string[] = []
|
|
36
|
-
if (isPublish && needsBackup) {
|
|
37
|
-
if (workingStatus?.localContentHashes && workingStatus?.publishedContentHashes) {
|
|
38
|
-
if (workingStatus.localContentHashes['SOUL.md'] !== workingStatus.publishedContentHashes['SOUL.md']) changedFiles.push('SOUL.md')
|
|
39
|
-
if (workingStatus.localContentHashes['MEMORY.md'] !== workingStatus.publishedContentHashes['MEMORY.md']) changedFiles.push('MEMORY.md')
|
|
40
|
-
if (workingStatus.localContentHashes['skills.json'] !== workingStatus.publishedContentHashes['skills.json']) changedFiles.push('skills.json')
|
|
41
|
-
} else {
|
|
42
|
-
changedFiles = ['SOUL.md', 'MEMORY.md', 'skills.json']
|
|
43
|
-
}
|
|
44
|
-
}
|
|
35
|
+
const localChangeStatus = localChangeStatusView(workingStatus)
|
|
45
36
|
|
|
46
37
|
return (
|
|
47
38
|
<Surface title={title} subtitle={subtitle} footer={footer} tone="primary">
|
|
48
39
|
<Box flexDirection="column">
|
|
49
40
|
<Text color={headlineColor}>{headline}</Text>
|
|
50
41
|
<Text color={theme.textSubtle}>{detail}</Text>
|
|
51
|
-
{isPublish &&
|
|
42
|
+
{isPublish && (
|
|
43
|
+
<Box marginTop={1}>
|
|
44
|
+
<SaveSnapshotStatusLine status={localChangeStatus} />
|
|
45
|
+
</Box>
|
|
46
|
+
)}
|
|
47
|
+
{!isPublish && (
|
|
52
48
|
<Box marginTop={1}>
|
|
53
|
-
<Text>
|
|
54
|
-
<Text color={theme.textSubtle}>ready to save: </Text>
|
|
55
|
-
<Text color={theme.accentMint} bold>{changedFiles.join(', ')}</Text>
|
|
56
|
-
</Text>
|
|
49
|
+
<Text color={theme.accentPeach}>Overwrite your local files?</Text>
|
|
57
50
|
</Box>
|
|
58
51
|
)}
|
|
59
52
|
</Box>
|
|
@@ -85,3 +78,18 @@ export const RecoveryConfirmScreen: React.FC<RecoveryConfirmScreenProps> = ({ mo
|
|
|
85
78
|
</Surface>
|
|
86
79
|
)
|
|
87
80
|
}
|
|
81
|
+
|
|
82
|
+
const SaveSnapshotStatusLine: React.FC<{ status: LocalChangeStatusView }> = ({ status }) => {
|
|
83
|
+
if (status.hasLocalChanges) {
|
|
84
|
+
return (
|
|
85
|
+
<Text>
|
|
86
|
+
<Text color={theme.textSubtle}>Local changes detected: </Text>
|
|
87
|
+
<Text color="#e87070" bold>{status.files.length > 0 ? status.files.join(', ') : 'local files differ from saved snapshot'}</Text>
|
|
88
|
+
</Text>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const color = status.tone === 'ok' ? theme.accentMint : status.tone === 'warn' ? theme.accentPeach : theme.dim
|
|
93
|
+
const label = status.detail === 'None detected' ? 'No local changes detected.' : status.detail
|
|
94
|
+
return <Text color={color}>{label}</Text>
|
|
95
|
+
}
|
|
@@ -82,6 +82,9 @@ export const StorageCredentialScreen: React.FC<StorageCredentialScreenProps> = (
|
|
|
82
82
|
<Text key={line} color={theme.dim}>- {line}</Text>
|
|
83
83
|
))}
|
|
84
84
|
</Box>
|
|
85
|
+
<Box marginTop={1}>
|
|
86
|
+
<Text color={theme.accentPeach}>Remove the token from this machine?</Text>
|
|
87
|
+
</Box>
|
|
85
88
|
<Box marginTop={1}>
|
|
86
89
|
<Select<StorageCredentialAction>
|
|
87
90
|
options={[
|
package/src/ui/Select.tsx
CHANGED
|
@@ -73,7 +73,7 @@ export function Select<T>({
|
|
|
73
73
|
else if (key.return) {
|
|
74
74
|
const selected = options[index]
|
|
75
75
|
if (isSelectableOption(selected)) onSubmit(selected.value)
|
|
76
|
-
} else if (key.escape) {
|
|
76
|
+
} else if (key.escape || (key.ctrl && input === 'c')) {
|
|
77
77
|
onCancel?.()
|
|
78
78
|
}
|
|
79
79
|
})
|
package/src/ui/Surface.tsx
CHANGED
|
@@ -27,7 +27,7 @@ export const Surface: React.FC<SurfaceProps> = ({
|
|
|
27
27
|
}) => {
|
|
28
28
|
const borderColor = toneColor[tone]
|
|
29
29
|
return (
|
|
30
|
-
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={2} paddingY={0}>
|
|
30
|
+
<Box flexDirection="column" borderStyle="round" borderColor={borderColor} paddingX={2} paddingY={0} width="100%">
|
|
31
31
|
<Box flexDirection="column">
|
|
32
32
|
<Text color={borderColor} bold>{title}</Text>
|
|
33
33
|
{subtitle ? (
|
package/src/ui/TextInput.tsx
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
2
|
-
import { Box, Text } from 'ink'
|
|
1
|
+
import React, { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { Box, Text, useStdout } from 'ink'
|
|
3
3
|
import { theme } from './theme.js'
|
|
4
4
|
import { useAppInput } from '../app/input/AppInputProvider.js'
|
|
5
|
+
import { moveVerticalVisual } from '../chat/chatInputState.js'
|
|
6
|
+
import {
|
|
7
|
+
getVisualLineIndex,
|
|
8
|
+
getVisualLines,
|
|
9
|
+
} from '../chat/textCursor.js'
|
|
10
|
+
|
|
11
|
+
// ConversationStack padding=1 (2) + Surface border (2) + Surface paddingX=2 (4) + '> ' prefix (2) = 10
|
|
12
|
+
const DEFAULT_CHROME_WIDTH = 10
|
|
5
13
|
|
|
6
14
|
type TextInputProps = {
|
|
7
15
|
label?: string
|
|
@@ -9,62 +17,116 @@ type TextInputProps = {
|
|
|
9
17
|
isSecret?: boolean
|
|
10
18
|
initialValue?: string
|
|
11
19
|
allowEmpty?: boolean
|
|
20
|
+
multiline?: boolean
|
|
21
|
+
chromeWidth?: number
|
|
12
22
|
maxLength?: number
|
|
13
23
|
validate?: (value: string) => string | null
|
|
14
24
|
onSubmit: (value: string) => void
|
|
15
25
|
onCancel?: () => void
|
|
16
26
|
}
|
|
17
27
|
|
|
28
|
+
type RenderedTextInputLine = {
|
|
29
|
+
visualLineIndex: number
|
|
30
|
+
node: React.ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
18
33
|
export function TextInput({
|
|
19
34
|
label,
|
|
20
35
|
placeholder,
|
|
21
36
|
isSecret,
|
|
22
37
|
initialValue = '',
|
|
23
38
|
allowEmpty = false,
|
|
39
|
+
multiline = false,
|
|
40
|
+
chromeWidth = DEFAULT_CHROME_WIDTH,
|
|
24
41
|
maxLength = 4096,
|
|
25
42
|
validate,
|
|
26
43
|
onSubmit,
|
|
27
44
|
onCancel,
|
|
28
45
|
}: TextInputProps) {
|
|
46
|
+
const { stdout } = useStdout()
|
|
29
47
|
const [value, setValue] = useState(initialValue)
|
|
48
|
+
const [cursor, setCursor] = useState(initialValue.length)
|
|
49
|
+
const [preferredColumn, setPreferredColumn] = useState<number | null>(null)
|
|
30
50
|
const [error, setError] = useState<string | null>(null)
|
|
31
51
|
|
|
52
|
+
// Keep a columns state updated via resize, matching ChatInput's pattern exactly
|
|
53
|
+
const [columns, setColumns] = useState<number>(() => Math.floor(stdout?.columns ?? 80))
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!stdout) return
|
|
56
|
+
const handleResize = () => setColumns(Math.floor(stdout.columns ?? 80))
|
|
57
|
+
stdout.on('resize', handleResize)
|
|
58
|
+
return () => { stdout.off('resize', handleResize) }
|
|
59
|
+
}, [stdout])
|
|
60
|
+
|
|
61
|
+
const wrapWidth = textInputWrapWidth(columns, chromeWidth)
|
|
62
|
+
|
|
63
|
+
// Sync refs during render so the input handler always reads fresh values,
|
|
64
|
+
// even if AppInputProvider fires before the next useEffect cycle updates handlerRef.
|
|
65
|
+
const stateRef = useRef({ value, cursor, preferredColumn, wrapWidth })
|
|
66
|
+
stateRef.current = { value, cursor, preferredColumn, wrapWidth }
|
|
67
|
+
|
|
32
68
|
useAppInput((input, key) => {
|
|
69
|
+
const { value: val, cursor: cur, preferredColumn: prefCol, wrapWidth: ww } = stateRef.current
|
|
70
|
+
|
|
33
71
|
if (key.return) {
|
|
34
|
-
if (!allowEmpty &&
|
|
72
|
+
if (!allowEmpty && val.trim().length === 0) {
|
|
35
73
|
setError('value cannot be empty')
|
|
36
74
|
return
|
|
37
75
|
}
|
|
38
|
-
const validationError = validate?.(
|
|
76
|
+
const validationError = validate?.(val) ?? null
|
|
39
77
|
if (validationError) {
|
|
40
78
|
setError(validationError)
|
|
41
79
|
return
|
|
42
80
|
}
|
|
43
81
|
setError(null)
|
|
44
|
-
onSubmit(
|
|
82
|
+
onSubmit(val)
|
|
45
83
|
return
|
|
46
84
|
}
|
|
47
|
-
if (key.escape) {
|
|
85
|
+
if (key.escape || (key.ctrl && input === 'c')) {
|
|
48
86
|
onCancel?.()
|
|
49
87
|
return
|
|
50
88
|
}
|
|
89
|
+
if (key.leftArrow) {
|
|
90
|
+
setCursor(Math.max(0, cur - 1))
|
|
91
|
+
setPreferredColumn(null)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (key.rightArrow) {
|
|
95
|
+
setCursor(Math.min(val.length, cur + 1))
|
|
96
|
+
setPreferredColumn(null)
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
if (multiline && (key.upArrow || key.downArrow)) {
|
|
100
|
+
const result = moveVerticalVisual(val, cur, key.upArrow ? -1 : 1, ww, prefCol)
|
|
101
|
+
if (result.kind === 'moved') setCursor(result.cursor)
|
|
102
|
+
setPreferredColumn(result.preferredColumn)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
51
105
|
if (key.backspace || key.delete) {
|
|
52
|
-
|
|
106
|
+
if (cur === 0) return
|
|
107
|
+
setValue(val.slice(0, cur - 1) + val.slice(cur))
|
|
108
|
+
setCursor(cur - 1)
|
|
109
|
+
setPreferredColumn(null)
|
|
53
110
|
if (error) setError(null)
|
|
54
111
|
return
|
|
55
112
|
}
|
|
56
113
|
if (key.ctrl && input === 'u') {
|
|
57
114
|
setValue('')
|
|
115
|
+
setCursor(0)
|
|
116
|
+
setPreferredColumn(null)
|
|
58
117
|
if (error) setError(null)
|
|
59
118
|
return
|
|
60
119
|
}
|
|
61
|
-
if (key.ctrl || key.meta || key.
|
|
120
|
+
if (key.ctrl || key.meta || key.upArrow || key.downArrow || key.tab) {
|
|
62
121
|
return
|
|
63
122
|
}
|
|
64
123
|
if (input) {
|
|
65
124
|
const clean = input.replace(/[\r\n]/g, '')
|
|
66
125
|
if (clean) {
|
|
67
|
-
|
|
126
|
+
const next = (val.slice(0, cur) + clean + val.slice(cur)).slice(0, maxLength)
|
|
127
|
+
setValue(next)
|
|
128
|
+
setCursor(Math.min(cur + clean.length, maxLength))
|
|
129
|
+
setPreferredColumn(null)
|
|
68
130
|
if (error) setError(null)
|
|
69
131
|
}
|
|
70
132
|
}
|
|
@@ -72,26 +134,83 @@ export function TextInput({
|
|
|
72
134
|
|
|
73
135
|
const display = isSecret ? '*'.repeat(value.length) : value
|
|
74
136
|
const showPlaceholder = value.length === 0 && placeholder
|
|
137
|
+
const renderedLines = multiline
|
|
138
|
+
? renderTextInputLines(display, cursor, true, wrapWidth)
|
|
139
|
+
: []
|
|
75
140
|
|
|
76
141
|
return (
|
|
77
142
|
<Box flexDirection="column">
|
|
78
143
|
{label ? <Text color={theme.dim}>{label}</Text> : null}
|
|
79
|
-
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
144
|
+
{multiline && !showPlaceholder ? (
|
|
145
|
+
<Box flexDirection="column">
|
|
146
|
+
{renderedLines.map(line => (
|
|
147
|
+
<Box key={line.visualLineIndex} flexDirection="row">
|
|
148
|
+
<Text color={line.visualLineIndex === 0 ? theme.accentPrimary : theme.dim}>
|
|
149
|
+
{line.visualLineIndex === 0 ? '> ' : ' '}
|
|
150
|
+
</Text>
|
|
151
|
+
<Box width={wrapWidth}>{line.node}</Box>
|
|
152
|
+
</Box>
|
|
153
|
+
))}
|
|
154
|
+
</Box>
|
|
155
|
+
) : (
|
|
156
|
+
<Box flexDirection="row">
|
|
157
|
+
<Text color={theme.accentPrimary}>{'> '}</Text>
|
|
158
|
+
<Box width={wrapWidth}>
|
|
159
|
+
{showPlaceholder ? (
|
|
160
|
+
<Text wrap={multiline ? 'wrap' : 'truncate-end'}>
|
|
161
|
+
<Text backgroundColor={theme.accentMint} color="#08110c">{' '}</Text>
|
|
162
|
+
<Text color={theme.dim}>{placeholder}</Text>
|
|
163
|
+
</Text>
|
|
164
|
+
) : (
|
|
165
|
+
<Text color={theme.text} wrap="truncate-end">
|
|
166
|
+
{display.slice(0, cursor)}
|
|
167
|
+
<Text backgroundColor={theme.accentMint} color="#08110c">{display[cursor] ?? ' '}</Text>
|
|
168
|
+
{display.slice(cursor + 1)}
|
|
169
|
+
</Text>
|
|
170
|
+
)}
|
|
171
|
+
</Box>
|
|
172
|
+
</Box>
|
|
173
|
+
)}
|
|
93
174
|
{error ? <Text color="#e87070">{error}</Text> : null}
|
|
94
175
|
</Box>
|
|
95
176
|
)
|
|
96
177
|
}
|
|
97
178
|
|
|
179
|
+
export function textInputWrapWidth(columns: number, chromeWidth = DEFAULT_CHROME_WIDTH): number {
|
|
180
|
+
return Math.max(1, Math.floor(columns) - Math.max(0, Math.floor(chromeWidth)))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function renderTextInputLines(
|
|
184
|
+
value: string,
|
|
185
|
+
cursor: number,
|
|
186
|
+
showCursor: boolean,
|
|
187
|
+
wrapWidth: number,
|
|
188
|
+
): RenderedTextInputLine[] {
|
|
189
|
+
const lines = getVisualLines(value, wrapWidth)
|
|
190
|
+
const cursorLine = getVisualLineIndex(lines, cursor)
|
|
191
|
+
|
|
192
|
+
return lines.map((line, visualLineIndex) => {
|
|
193
|
+
const text = value.slice(line.start, line.end)
|
|
194
|
+
if (!showCursor || visualLineIndex !== cursorLine) {
|
|
195
|
+
return {
|
|
196
|
+
visualLineIndex,
|
|
197
|
+
node: <Text color={theme.text} wrap="wrap">{text || ' '}</Text>,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const column = Math.max(0, Math.min(cursor - line.start, text.length))
|
|
202
|
+
const before = text.slice(0, column)
|
|
203
|
+
const atChar = text[column] ?? ' '
|
|
204
|
+
const after = text.slice(column + 1)
|
|
205
|
+
return {
|
|
206
|
+
visualLineIndex,
|
|
207
|
+
node: (
|
|
208
|
+
<Text color={theme.text} wrap="wrap">
|
|
209
|
+
{before}
|
|
210
|
+
<Text backgroundColor={theme.accentMint} color="#08110c">{atChar}</Text>
|
|
211
|
+
{after}
|
|
212
|
+
</Text>
|
|
213
|
+
),
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}
|