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 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 model setup, then identity setup. You can use a local GGUF model through llama.cpp, or connect OpenAI, Anthropic, or Gemini.
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.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
- "agent",
27
- "ai",
28
- "privacy-first",
26
+ "erc-8004",
27
+ "ai-agent",
28
+ "cli",
29
+ "privacy",
29
30
  "ipfs",
30
- "ERC-8004",
31
- "onchain",
32
- "offline",
33
- "ens"
31
+ "wallet",
32
+ "llm",
33
+ "gguf",
34
+ "mcp",
35
+ "identity"
34
36
  ],
35
37
  "repository": {
36
38
  "type": "git",
@@ -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({
@@ -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(
@@ -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 `update available: ethagent ${currentVersion} -> ${latestVersion}; run npm i -g ethagent`
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={[
@@ -120,6 +120,7 @@ export const EditProfileFlow: React.FC<EditProfileFlowProps> = ({
120
120
  initialValue={currentDescription}
121
121
  placeholder="description"
122
122
  allowEmpty
123
+ multiline
123
124
  onSubmit={value => onDescriptionSubmit(value.trim())}
124
125
  onCancel={onBack}
125
126
  />
@@ -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 { identitySummaryRows, lastBackupLabel } from '../identityHubModel.js'
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 needsBackup = workingStatus?.publishState === 'local-changes' || workingStatus?.publishState === 'not-published' || workingStatus?.publishState === 'verify-needed'
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 lastSavedRow = needsBackup
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' ? 'red' : (row.tone === 'ok' ? theme.text : theme.dim)
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 needsBackup = workingStatus?.publishState === 'local-changes' || workingStatus?.publishState === 'not-published' || workingStatus?.publishState === 'verify-needed'
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 && changedFiles.length > 0 && (
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
  })
@@ -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 ? (
@@ -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 && value.trim().length === 0) {
72
+ if (!allowEmpty && val.trim().length === 0) {
35
73
  setError('value cannot be empty')
36
74
  return
37
75
  }
38
- const validationError = validate?.(value) ?? null
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(value)
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
- setValue(v => v.slice(0, -1))
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.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.tab) {
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
- setValue(v => (v + clean).slice(0, maxLength))
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
- <Box flexDirection="row">
80
- <Text color={theme.accentPrimary}>{'> '}</Text>
81
- {showPlaceholder ? (
82
- <>
83
- <Text color={theme.accentPrimary}>|</Text>
84
- <Text color={theme.dim}>{placeholder}</Text>
85
- </>
86
- ) : (
87
- <>
88
- <Text color={theme.text}>{display}</Text>
89
- <Text color={theme.accentPrimary}>|</Text>
90
- </>
91
- )}
92
- </Box>
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
+ }