ethagent 2.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +18 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +157 -15
  4. package/src/app/FirstRunTimeline.tsx +4 -0
  5. package/src/app/input/AppInputProvider.tsx +19 -0
  6. package/src/app/input/appInputParser.ts +19 -4
  7. package/src/chat/ChatBottomPane.tsx +12 -1
  8. package/src/chat/ChatScreen.tsx +17 -5
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +4 -1
  12. package/src/chat/chatTurnOrchestrator.ts +65 -2
  13. package/src/chat/input/ChatInput.tsx +28 -2
  14. package/src/chat/input/imageRefs.ts +30 -0
  15. package/src/chat/input/textCursor.ts +13 -3
  16. package/src/chat/transcript/TranscriptView.tsx +7 -5
  17. package/src/chat/transcript/transcriptViewport.ts +88 -17
  18. package/src/chat/views/PermissionPrompt.tsx +26 -26
  19. package/src/chat/views/PermissionsView.tsx +18 -12
  20. package/src/chat/views/ResumeView.tsx +16 -7
  21. package/src/chat/views/RewindView.tsx +3 -1
  22. package/src/cli/ResetConfirmView.tsx +24 -9
  23. package/src/identity/continuity/editor.ts +27 -2
  24. package/src/identity/continuity/envelope.ts +125 -0
  25. package/src/identity/continuity/publicSkills.ts +37 -1
  26. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  27. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  28. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  29. package/src/identity/continuity/skills/scaffold.ts +52 -0
  30. package/src/identity/continuity/skills/types.ts +30 -0
  31. package/src/identity/continuity/storage/defaults.ts +28 -47
  32. package/src/identity/continuity/storage/files.ts +1 -0
  33. package/src/identity/continuity/storage/paths.ts +1 -0
  34. package/src/identity/continuity/storage/scaffold.ts +25 -23
  35. package/src/identity/continuity/storage/status.ts +34 -5
  36. package/src/identity/continuity/storage/types.ts +3 -2
  37. package/src/identity/continuity/storage.ts +3 -0
  38. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  39. package/src/identity/hub/Routes.tsx +5 -3
  40. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  41. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  42. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  43. package/src/identity/hub/continuity/effects.ts +36 -5
  44. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  45. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  46. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  47. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  48. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  49. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  50. package/src/identity/hub/continuity/snapshot.ts +3 -0
  51. package/src/identity/hub/continuity/state.ts +3 -2
  52. package/src/identity/hub/continuity/vault.ts +42 -10
  53. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  54. package/src/identity/hub/identityHubReducer.ts +21 -0
  55. package/src/identity/hub/profile/effects.ts +16 -3
  56. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  57. package/src/identity/hub/restore/apply.ts +12 -1
  58. package/src/identity/hub/restore/recovery.ts +11 -1
  59. package/src/identity/hub/restore/resolve.ts +1 -1
  60. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  61. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  62. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  63. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  64. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  65. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  66. package/src/identity/hub/shared/effects/sync.ts +16 -3
  67. package/src/identity/hub/shared/model/copy.ts +2 -4
  68. package/src/identity/hub/transfer/effects.ts +15 -2
  69. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  70. package/src/identity/hub/useIdentityHubController.ts +5 -1
  71. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  72. package/src/mcp/manager.ts +1 -1
  73. package/src/models/ModelPicker.tsx +211 -74
  74. package/src/models/huggingface.ts +180 -2
  75. package/src/models/llamacpp.ts +261 -17
  76. package/src/models/llamacppPreflight.ts +16 -12
  77. package/src/models/modelPickerOptions.ts +57 -38
  78. package/src/providers/anthropic.ts +36 -5
  79. package/src/providers/contracts.ts +10 -1
  80. package/src/providers/gemini.ts +29 -3
  81. package/src/providers/openai-chat.ts +131 -11
  82. package/src/providers/openai-responses-format.ts +29 -8
  83. package/src/providers/openai-responses.ts +41 -11
  84. package/src/providers/registry.ts +1 -0
  85. package/src/runtime/toolExecution.ts +4 -3
  86. package/src/runtime/turn.ts +61 -30
  87. package/src/storage/config.ts +1 -0
  88. package/src/storage/sessions.ts +14 -2
  89. package/src/tools/changeDirectoryTool.ts +1 -1
  90. package/src/tools/contracts.ts +10 -0
  91. package/src/tools/deleteFileTool.ts +1 -1
  92. package/src/tools/editTool.ts +1 -1
  93. package/src/tools/listDirectoryTool.ts +1 -1
  94. package/src/tools/listSkillFilesTool.ts +77 -0
  95. package/src/tools/listSkillsTool.ts +68 -0
  96. package/src/tools/mcpResourceTools.ts +2 -2
  97. package/src/tools/privateContinuityReadTool.ts +1 -1
  98. package/src/tools/readSkillTool.ts +107 -0
  99. package/src/tools/readTool.ts +1 -1
  100. package/src/tools/registry.ts +6 -0
  101. package/src/tools/writeFileTool.ts +22 -2
  102. package/src/ui/Spinner.tsx +15 -3
  103. package/src/ui/theme.ts +2 -0
  104. package/src/utils/images.ts +140 -0
  105. package/src/utils/messages.ts +2 -0
  106. package/src/identity/continuity/localBackup.ts +0 -249
  107. package/src/identity/continuity/zipWriter.ts +0 -95
  108. package/src/identity/hub/continuity/index.ts +0 -7
  109. package/src/identity/hub/ens/index.ts +0 -11
  110. package/src/identity/hub/restore/index.ts +0 -22
@@ -7,6 +7,7 @@ import { DiffView } from './display/DiffView.js'
7
7
  import { SyntaxLine } from './display/SyntaxText.js'
8
8
  import { formatToolCall } from './display/toolCallDisplay.js'
9
9
  import { BrandSplash } from '../ui/BrandSplash.js'
10
+ import type { RowSlice } from './transcript/transcriptViewport.js'
10
11
 
11
12
  export type ToolCallResult = {
12
13
  content: string
@@ -48,7 +49,11 @@ export type MessageRow =
48
49
  }
49
50
 
50
51
  type MessageListProps = {
51
- rows: MessageRow[]
52
+ slices: Array<RowSlice<MessageRow>>
53
+ }
54
+
55
+ export function rowsToFullSlices(rows: MessageRow[]): Array<RowSlice<MessageRow>> {
56
+ return rows.map(row => ({ row, clipStart: 0, clipEnd: Number.MAX_SAFE_INTEGER, rowHeight: Number.MAX_SAFE_INTEGER }))
52
57
  }
53
58
 
54
59
  type MarkdownBlock =
@@ -70,13 +75,13 @@ const ASSISTANT_ACCENT = theme.accentPeriwinkle
70
75
  const ASSISTANT_MARKER = '• '
71
76
  const UNREADABLE_REASONING_TEXT = 'reasoning output was not readable text'
72
77
 
73
- const MessageListInner: React.FC<MessageListProps> = ({ rows }) => (
78
+ const MessageListInner: React.FC<MessageListProps> = ({ slices }) => (
74
79
  <Box flexDirection="column">
75
- {rows.map((row, index) => (
80
+ {slices.map((slice, index) => (
76
81
  <RowView
77
- key={row.id}
78
- row={row}
79
- tightTop={row.role === 'tool_call' && rows[index - 1]?.role === 'tool_call'}
82
+ key={slice.row.id}
83
+ slice={slice}
84
+ tightTop={slice.row.role === 'tool_call' && slices[index - 1]?.row.role === 'tool_call'}
80
85
  />
81
86
  ))}
82
87
  </Box>
@@ -121,7 +126,8 @@ export function toggleInspectableRow(rows: MessageRow[], rowId?: string): Messag
121
126
  return rows
122
127
  }
123
128
 
124
- const RowViewInner: React.FC<{ row: MessageRow; tightTop?: boolean }> = ({ row, tightTop }) => {
129
+ const RowViewInner: React.FC<{ slice: RowSlice<MessageRow>; tightTop?: boolean }> = ({ slice, tightTop }) => {
130
+ const { row, clipStart, clipEnd, rowHeight } = slice
125
131
  if (row.role === 'user') {
126
132
  const display = clipTextForDisplay(row.content, MAX_RENDERED_MESSAGE_CHARS)
127
133
  const lines = display.text.length === 0 ? [''] : display.text.split('\n')
@@ -141,9 +147,18 @@ const RowViewInner: React.FC<{ row: MessageRow; tightTop?: boolean }> = ({ row,
141
147
  }
142
148
 
143
149
  if (row.role === 'assistant') {
150
+ const showTopMargin = clipStart === 0
151
+ const bodyClipStart = showTopMargin ? 0 : clipStart - 1
152
+ const bodyClipEnd = clipEnd !== undefined ? Math.max(0, clipEnd - 1) : undefined
144
153
  return (
145
- <Box flexDirection="column" marginTop={1}>
146
- <AssistantBody content={row.content} liveTail={row.liveTail} streaming={row.streaming} />
154
+ <Box flexDirection="column" marginTop={showTopMargin ? 1 : 0}>
155
+ <AssistantBody
156
+ content={row.content}
157
+ liveTail={row.liveTail}
158
+ streaming={row.streaming}
159
+ clipStart={bodyClipStart}
160
+ clipEnd={bodyClipEnd}
161
+ />
147
162
  </Box>
148
163
  )
149
164
  }
@@ -161,6 +176,9 @@ const RowViewInner: React.FC<{ row: MessageRow; tightTop?: boolean }> = ({ row,
161
176
  expanded
162
177
  active={active}
163
178
  showCursor={showCursor}
179
+ clipStart={clipStart}
180
+ clipEnd={clipEnd}
181
+ rowHeight={rowHeight}
164
182
  />
165
183
  )
166
184
  }
@@ -170,6 +188,9 @@ const RowViewInner: React.FC<{ row: MessageRow; tightTop?: boolean }> = ({ row,
170
188
  detail="alt+t inspect"
171
189
  active={active}
172
190
  showCursor={showCursor}
191
+ clipStart={clipStart}
192
+ clipEnd={clipEnd}
193
+ rowHeight={rowHeight}
173
194
  />
174
195
  )
175
196
  }
@@ -307,7 +328,10 @@ const ReasoningBlock: React.FC<{
307
328
  active?: boolean
308
329
  expanded?: boolean
309
330
  showCursor?: boolean
310
- }> = ({ content, detail, active = false, expanded = false, showCursor }) => {
331
+ clipStart?: number
332
+ clipEnd?: number
333
+ rowHeight?: number
334
+ }> = ({ content, detail, active = false, expanded = false, showCursor, clipStart = 0, clipEnd, rowHeight }) => {
311
335
  const display = useMemo(
312
336
  () => clipTextForDisplay(content, MAX_RENDERED_REASONING_CHARS),
313
337
  [content],
@@ -317,31 +341,54 @@ const ReasoningBlock: React.FC<{
317
341
  return normalized.length === 0 ? [''] : normalized.split('\n')
318
342
  }, [display.text])
319
343
 
344
+ const omittedVisible = display.omittedChars > 0
345
+ const totalLines = rowHeight ?? (1 + (omittedVisible ? 1 : 0) + 1 + (expanded ? lines.length : 0))
346
+ const effClipEnd = clipEnd ?? totalLines
347
+
348
+ const lineInClip = (n: number) => n >= clipStart && n < effClipEnd
349
+ const showMargin = lineInClip(0)
350
+ const omittedLine = 1
351
+ const headerLine = omittedVisible ? 2 : 1
352
+ const bodyStartLine = headerLine + 1
353
+
354
+ const lastBodyIndex = lines.length - 1
355
+ const visibleBody: Array<{ index: number; text: string }> = []
356
+ if (expanded) {
357
+ for (let i = 0; i < lines.length; i += 1) {
358
+ const ln = bodyStartLine + i
359
+ if (ln >= effClipEnd) break
360
+ if (ln < clipStart) continue
361
+ visibleBody.push({ index: i, text: lines[i] ?? '' })
362
+ }
363
+ }
364
+
320
365
  return (
321
- <Box flexDirection="column" marginTop={1}>
322
- {display.omittedChars > 0 ? (
366
+ <Box flexDirection="column" marginTop={showMargin ? 1 : 0}>
367
+ {omittedVisible && lineInClip(omittedLine) ? (
323
368
  <Text color={theme.dim}>{`${display.omittedChars} earlier reasoning characters omitted`}</Text>
324
369
  ) : null}
325
- <Text>
326
- <AssistantMarker />
327
- <ShimmerText
328
- text={expanded ? 'Thinking…' : 'Thinking'}
329
- color={theme.accentPeriwinkle}
330
- active={active}
331
- bold
332
- italic
333
- />
334
- <Text color={theme.dim}>{` · ${detail}`}</Text>
335
- {!expanded && showCursor ? <ThinkingCursor active hasPreview /> : null}
336
- </Text>
337
- {expanded ? (
370
+ {lineInClip(headerLine) ? (
371
+ <Text>
372
+ <AssistantMarker />
373
+ <ShimmerText
374
+ text={expanded ? 'Thinking…' : 'Thinking'}
375
+ color={theme.accentPeriwinkle}
376
+ active={active}
377
+ bold
378
+ italic
379
+ />
380
+ <Text color={theme.dim}>{` · ${detail}`}</Text>
381
+ {!expanded && showCursor ? <ThinkingCursor active hasPreview /> : null}
382
+ </Text>
383
+ ) : null}
384
+ {expanded && visibleBody.length > 0 ? (
338
385
  <Box flexDirection="column" marginLeft={2}>
339
- {lines.map((line, index) => (
386
+ {visibleBody.map(({ index, text }) => (
340
387
  <Box key={index}>
341
388
  <Text color={theme.textSubtle}>
342
389
  <Text color={theme.dim}>{' '}</Text>
343
- {line || ' '}
344
- {showCursor && index === lines.length - 1 ? <ThinkingCursor active hasPreview={line.length > 0} /> : null}
390
+ {text || ' '}
391
+ {showCursor && index === lastBodyIndex ? <ThinkingCursor active hasPreview={text.length > 0} /> : null}
345
392
  </Text>
346
393
  </Box>
347
394
  ))}
@@ -351,39 +398,133 @@ const ReasoningBlock: React.FC<{
351
398
  )
352
399
  }
353
400
 
354
- const AssistantBody: React.FC<{ content: string; liveTail?: string; streaming?: boolean }> = ({
355
- content,
356
- liveTail,
357
- streaming,
358
- }) => {
401
+ const AssistantBody: React.FC<{
402
+ content: string
403
+ liveTail?: string
404
+ streaming?: boolean
405
+ clipStart?: number
406
+ clipEnd?: number
407
+ }> = ({ content, liveTail, streaming, clipStart = 0, clipEnd }) => {
359
408
  const fullText = liveTail ? content + liveTail : content
360
- const display = useMemo(
361
- () => clipTextForDisplay(fullText, MAX_RENDERED_MESSAGE_CHARS),
362
- [fullText],
409
+ const nodes = useMemo(
410
+ () => flattenAssistantBody(fullText, Boolean(streaming)),
411
+ [fullText, streaming],
363
412
  )
364
- const blocks = useMemo(() => parseMarkdownBlocks(display.text), [display.text])
365
-
413
+ const effClipEnd = clipEnd ?? nodes.length
414
+ const visible = nodes.slice(clipStart, effClipEnd)
366
415
  return (
367
416
  <Box flexDirection="column">
368
- {display.omittedChars > 0 ? (
369
- <Text color={theme.dim}>{`${display.omittedChars} earlier characters omitted`}</Text>
370
- ) : null}
371
- {blocks.map((block, index) => (
372
- <MarkdownBlockView
373
- key={index}
374
- block={block}
375
- streaming={streaming && index === blocks.length - 1}
376
- prefix={index === 0 || block.kind === 'code' ? <AssistantMarker /> : null}
377
- />
417
+ {visible.map((node, i) => (
418
+ <React.Fragment key={clipStart + i}>{node}</React.Fragment>
378
419
  ))}
379
- {streaming && blocks.length === 0 ? (
420
+ </Box>
421
+ )
422
+ }
423
+
424
+ export function flattenAssistantBody(fullText: string, streaming: boolean): React.ReactNode[] {
425
+ const display = clipTextForDisplay(fullText, MAX_RENDERED_MESSAGE_CHARS)
426
+ const blocks = parseMarkdownBlocks(display.text)
427
+ const nodes: React.ReactNode[] = []
428
+
429
+ if (display.omittedChars > 0) {
430
+ nodes.push(
431
+ <Text color={theme.dim}>{`${display.omittedChars} earlier characters omitted`}</Text>,
432
+ )
433
+ }
434
+
435
+ if (blocks.length === 0) {
436
+ if (streaming) {
437
+ nodes.push(
380
438
  <Text>
381
439
  <AssistantMarker />
382
440
  <Text color={ASSISTANT_ACCENT}><StreamCursor active /></Text>
383
- </Text>
384
- ) : null}
385
- </Box>
386
- )
441
+ </Text>,
442
+ )
443
+ }
444
+ return nodes
445
+ }
446
+
447
+ for (let bi = 0; bi < blocks.length; bi += 1) {
448
+ const block = blocks[bi]!
449
+ const isLastBlock = bi === blocks.length - 1
450
+ const prefix = bi === 0 || block.kind === 'code' ? <AssistantMarker /> : null
451
+ const streamingLast = streaming && isLastBlock
452
+
453
+ nodes.push(<Text> </Text>)
454
+
455
+ if (block.kind === 'heading') {
456
+ nodes.push(
457
+ <Text>
458
+ {prefix}
459
+ <InlineText text={block.text} color={ASSISTANT_ACCENT} bold />
460
+ </Text>,
461
+ )
462
+ continue
463
+ }
464
+
465
+ if (block.kind === 'quote') {
466
+ block.lines.forEach((line, li) => {
467
+ nodes.push(
468
+ <Text>
469
+ {li === 0 ? prefix : null}
470
+ <Text color={ASSISTANT_ACCENT}>| </Text>
471
+ <InlineText text={line} color={theme.dim} />
472
+ </Text>,
473
+ )
474
+ })
475
+ continue
476
+ }
477
+
478
+ if (block.kind === 'list') {
479
+ block.items.forEach((item, li) => {
480
+ nodes.push(
481
+ <Text>
482
+ {li === 0 ? prefix : null}
483
+ <Text color={ASSISTANT_ACCENT}>{block.ordered ? `${li + 1}. ` : '- '}</Text>
484
+ <InlineText text={item} color={theme.text} />
485
+ </Text>,
486
+ )
487
+ })
488
+ continue
489
+ }
490
+
491
+ if (block.kind === 'code') {
492
+ nodes.push(
493
+ <Text>
494
+ {prefix}
495
+ <Text color={theme.accentPeriwinkle} bold>{block.lang ?? 'code'}</Text>
496
+ {block.open ? <Text color={theme.dim}> streaming</Text> : null}
497
+ </Text>,
498
+ )
499
+ const codeLines = block.code.length === 0 ? [''] : block.code.split('\n')
500
+ const isShell = block.lang === 'bash' || block.lang === 'sh'
501
+ codeLines.forEach((line, li) => {
502
+ const isLastCode = li === codeLines.length - 1
503
+ nodes.push(
504
+ <Text>
505
+ <Text color={theme.dim}>{` ${isShell ? '$ ' : ' '}`}</Text>
506
+ <SyntaxLine line={line} lang={block.lang} fallbackColor={theme.textSubtle} />
507
+ {block.open && isLastCode ? <Text color={ASSISTANT_ACCENT}> <StreamCursor active /></Text> : null}
508
+ </Text>,
509
+ )
510
+ })
511
+ continue
512
+ }
513
+
514
+ const paragraphLines = block.text.split('\n')
515
+ paragraphLines.forEach((line, li) => {
516
+ const isLastLine = li === paragraphLines.length - 1
517
+ nodes.push(
518
+ <Text>
519
+ {li === 0 ? prefix : null}
520
+ <InlineText text={line} color={theme.text} />
521
+ {streamingLast && isLastLine ? <Text color={ASSISTANT_ACCENT}> <StreamCursor active /></Text> : null}
522
+ </Text>,
523
+ )
524
+ })
525
+ }
526
+
527
+ return nodes
387
528
  }
388
529
 
389
530
  const AssistantMarker: React.FC = () => (
@@ -39,6 +39,7 @@ export function resolveModelSelection(
39
39
  selection.model === currentConfig.model
40
40
  && currentConfig.provider === 'llamacpp'
41
41
  && currentConfig.baseUrl === baseUrl
42
+ && currentConfig.localMmprojPath === selection.mmprojPath
42
43
  ) {
43
44
  return { kind: 'noop' }
44
45
  }
@@ -49,8 +50,9 @@ export function resolveModelSelection(
49
50
  provider: 'llamacpp',
50
51
  model: selection.model,
51
52
  baseUrl,
53
+ localMmprojPath: selection.mmprojPath,
52
54
  },
53
- notice: `Local Hugging Face model ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}.`,
55
+ notice: `llama.cpp ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}${selection.mmprojPath ? ' · vision' : ''}.`,
54
56
  tone: 'info',
55
57
  }
56
58
  }
@@ -65,6 +67,7 @@ export function resolveModelSelection(
65
67
  provider: nextProvider,
66
68
  model: selection.model,
67
69
  baseUrl: nextBaseUrl,
70
+ localMmprojPath: undefined,
68
71
  }
69
72
 
70
73
  return {
@@ -9,12 +9,15 @@ import type { EthagentConfig } from '../storage/config.js'
9
9
  import type { SessionMessage } from '../storage/sessions.js'
10
10
  import type { SessionPermissionRule, ToolResult } from '../tools/contracts.js'
11
11
  import { readContinuityFiles } from '../identity/continuity/storage.js'
12
+ import { listSkillsTree } from '../identity/continuity/skills/loadSkills.js'
13
+ import { isDraftScaffold } from '../identity/continuity/skills/scaffold.js'
12
14
  import type { MessageRow } from './MessageList.js'
13
15
  import {
14
16
  buildBaseMessages,
15
17
  createTurnCheckpoint,
16
18
  type TurnCheckpoint,
17
19
  } from './chatScreenUtils.js'
20
+ import { collapseImagePathsToRefs, userTextToContentBlocks } from '../utils/images.js'
18
21
 
19
22
  type MutableRef<T> = { current: T }
20
23
 
@@ -101,10 +104,13 @@ export async function runStreamingTurn(
101
104
  const activeCheckpoint = createTurnCheckpoint(sessionId, userText)
102
105
  setActiveCheckpoint(activeCheckpoint)
103
106
 
104
- updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: userText }])
107
+ const userContent = userTextToContentBlocks(userText)
108
+ const displayText = collapseImagePathsToRefs(userText)
109
+ updateRows(prev => [...prev, { role: 'user', id: nextRowId(), content: displayText }])
105
110
  await persistTurnMessage({
106
111
  role: 'user',
107
- content: userText,
112
+ content: displayText,
113
+ providerContent: typeof userContent === 'string' ? undefined : userContent,
108
114
  createdAt: nowIso(),
109
115
  turnId: activeCheckpoint.turnId,
110
116
  })
@@ -117,6 +123,7 @@ export async function runStreamingTurn(
117
123
  return [
118
124
  ...(baseSystem ? [baseSystem] : []),
119
125
  ...await buildIdentityContinuityContextMessages(getConfig()),
126
+ ...await buildSkillsIndexMessage(getConfig()),
120
127
  ...conversationMessages,
121
128
  ...mentionContextMessages,
122
129
  ]
@@ -395,6 +402,11 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
395
402
  ctx.flushStreamRows()
396
403
  return
397
404
  }
405
+ case 'thinking_end': {
406
+ ctx.flushStreamRows(true)
407
+ ctx.stopThinkingCursor()
408
+ return
409
+ }
398
410
  case 'retry': {
399
411
  return
400
412
  }
@@ -561,6 +573,57 @@ async function buildFileMentionContextMessages(
561
573
  ]
562
574
  }
563
575
 
576
+ const PRIVATE_SKILLS_INDEX_BUDGET = 2048
577
+
578
+ export async function buildSkillsIndexMessage(
579
+ config: EthagentConfig,
580
+ ): Promise<Message[]> {
581
+ const identity = config.identity
582
+ if (!identity) return []
583
+ try {
584
+ const { skills, supportingCounts } = await listSkillsTree(identity)
585
+ const entries = skills.filter(entry => !isDraftScaffold(entry))
586
+ if (entries.length === 0) return []
587
+ const header = [
588
+ '<private_skills index="true" visibility="private">',
589
+ 'Private skills are owner-authored content packs available for this active identity.',
590
+ 'Each line shows skill_name — description. When an entry ends with (+N supporting files), call list_private_skill_files to see relative + absolute paths for each file. Read text content via read_private_skill with file:; run executable supporting scripts with run_bash using the absolute path returned by list_private_skill_files. Call list_private_skills for the full index.',
591
+ ]
592
+ const lines: string[] = []
593
+ let charCount = header.reduce((sum, line) => sum + line.length + 1, 0)
594
+ let included = 0
595
+ let dropped = 0
596
+ for (const entry of entries) {
597
+ const displayName = entry.displayName ?? entry.name
598
+ const desc = entry.description ? ` — ${entry.description}` : ''
599
+ const hint = entry.whenToUse ? ` (when: ${entry.whenToUse})` : ''
600
+ const supporting = supportingCounts[entry.name] ?? 0
601
+ const trailer = supporting > 0
602
+ ? ` (+${supporting} supporting file${supporting === 1 ? '' : 's'})`
603
+ : ''
604
+ const line = `- ${displayName}${desc}${hint}${trailer}`
605
+ if (charCount + line.length + 1 > PRIVATE_SKILLS_INDEX_BUDGET && included > 0) {
606
+ dropped = entries.length - included
607
+ break
608
+ }
609
+ lines.push(line)
610
+ charCount += line.length + 1
611
+ included++
612
+ }
613
+ const footer: string[] = []
614
+ if (dropped > 0) {
615
+ footer.push(`(${dropped} more — call list_private_skills to enumerate)`)
616
+ }
617
+ footer.push('</private_skills>')
618
+ return [{
619
+ role: 'system',
620
+ content: [...header, ...lines, ...footer].join('\n'),
621
+ }]
622
+ } catch {
623
+ return []
624
+ }
625
+ }
626
+
564
627
  export async function buildIdentityContinuityContextMessages(
565
628
  config: EthagentConfig,
566
629
  ): Promise<Message[]> {
@@ -33,6 +33,12 @@ import {
33
33
  shouldCollapsePastedText,
34
34
  type PastedTextRef,
35
35
  } from './chatPaste.js'
36
+ import {
37
+ expandImageRefs,
38
+ formatImageRefMarker,
39
+ pruneImageRefs,
40
+ type ImageRef,
41
+ } from './imageRefs.js'
36
42
 
37
43
  type PromptInputProps = {
38
44
  onSubmit: (value: string) => void
@@ -48,6 +54,7 @@ type PromptInputProps = {
48
54
  cwd?: string
49
55
  seedText?: string | null
50
56
  onSeedConsumed?: () => void
57
+ onImagePaste?: (path: string) => void
51
58
  }
52
59
 
53
60
  const MAX_LENGTH = 32_768
@@ -76,6 +83,7 @@ export const ChatInput: React.FC<PromptInputProps> = ({
76
83
  cwd,
77
84
  seedText,
78
85
  onSeedConsumed,
86
+ onImagePaste,
79
87
  }) => {
80
88
  const { stdout } = useStdout()
81
89
  const [buffer, setBuffer] = useState<ChatBuffer>(emptyBuffer)
@@ -100,6 +108,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
100
108
  const pasteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
101
109
  const pastedTextRefsRef = useRef<Map<number, PastedTextRef>>(new Map())
102
110
  const nextPastedTextRefIdRef = useRef(1)
111
+ const imageRefsRef = useRef<Map<number, ImageRef>>(new Map())
112
+ const nextImageRefIdRef = useRef(1)
103
113
 
104
114
  useEffect(() => { bufferRef.current = buffer }, [buffer])
105
115
  useEffect(() => { historyIndexRef.current = historyIndex }, [historyIndex])
@@ -107,6 +117,11 @@ export const ChatInput: React.FC<PromptInputProps> = ({
107
117
  useEffect(() => { historyPreviewActiveRef.current = historyPreviewActive }, [historyPreviewActive])
108
118
  useEffect(() => { preferredColumnRef.current = preferredColumn }, [preferredColumn])
109
119
 
120
+ useEffect(() => {
121
+ pruneImageRefs(imageRefsRef.current, value)
122
+ if (imageRefsRef.current.size === 0) nextImageRefIdRef.current = 1
123
+ }, [value])
124
+
110
125
  useEffect(() => {
111
126
  const handleResize = () => {
112
127
  setColumns(stdout.columns ?? process.stdout.columns ?? 80)
@@ -217,6 +232,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
217
232
  })
218
233
  pastedTextRefsRef.current.clear()
219
234
  nextPastedTextRefIdRef.current = 1
235
+ imageRefsRef.current.clear()
236
+ nextImageRefIdRef.current = 1
220
237
  }, [applyBuffer, applyHistoryState])
221
238
 
222
239
  const handlePaste = useCallback((text: string) => {
@@ -257,7 +274,8 @@ export const ChatInput: React.FC<PromptInputProps> = ({
257
274
  const submit = useCallback(() => {
258
275
  const trimmed = value.trim()
259
276
  if (!trimmed) return
260
- onSubmit(expandPastedTextRefs(trimmed, pastedTextRefsRef.current))
277
+ const withText = expandPastedTextRefs(trimmed, pastedTextRefsRef.current)
278
+ onSubmit(expandImageRefs(withText, imageRefsRef.current))
261
279
  resetBuffer()
262
280
  }, [value, onSubmit, resetBuffer])
263
281
 
@@ -388,7 +406,12 @@ export const ChatInput: React.FC<PromptInputProps> = ({
388
406
  if (key.meta && inputText === 'v') {
389
407
  void (async () => {
390
408
  const image = await readClipboardImage()
391
- if (image.ok) insertText(`[image: ${image.path}]`)
409
+ if (image.ok) {
410
+ const id = nextImageRefIdRef.current++
411
+ imageRefsRef.current.set(id, { path: image.path })
412
+ insertText(formatImageRefMarker(id))
413
+ onImagePaste?.(image.path)
414
+ }
392
415
  })()
393
416
  return
394
417
  }
@@ -499,6 +522,9 @@ export const ChatInput: React.FC<PromptInputProps> = ({
499
522
  }
500
523
  if (key.tab || key.escape) return
501
524
  if (key.ctrl || key.meta) return
525
+ if (key.return || key.backspace || key.delete) return
526
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return
527
+ if (key.pageUp || key.pageDown || key.home || key.end) return
502
528
 
503
529
  if (inputText) {
504
530
  insertText(inputText)
@@ -0,0 +1,30 @@
1
+ export type ImageRef = { path: string; mimeType?: string }
2
+
3
+ const IMAGE_REF_MARKER_RE = /\[Image\s+#(\d+)\]/g
4
+
5
+ export function expandImageRefs(text: string, refs: Map<number, ImageRef>): string {
6
+ return text.replace(IMAGE_REF_MARKER_RE, (full, raw: string) => {
7
+ const ref = refs.get(Number(raw))
8
+ return ref ? `[image: ${ref.path}]` : full
9
+ })
10
+ }
11
+
12
+ export function referencedImageIds(text: string): Set<number> {
13
+ const out = new Set<number>()
14
+ for (const match of text.matchAll(IMAGE_REF_MARKER_RE)) {
15
+ const id = Number(match[1])
16
+ if (Number.isFinite(id)) out.add(id)
17
+ }
18
+ return out
19
+ }
20
+
21
+ export function pruneImageRefs(refs: Map<number, ImageRef>, text: string): void {
22
+ const referenced = referencedImageIds(text)
23
+ for (const id of [...refs.keys()]) {
24
+ if (!referenced.has(id)) refs.delete(id)
25
+ }
26
+ }
27
+
28
+ export function formatImageRefMarker(id: number): string {
29
+ return `[Image #${id}]`
30
+ }
@@ -1,3 +1,5 @@
1
+ import wrapAnsi from 'wrap-ansi'
2
+
1
3
  export type TextCursor = {
2
4
  value: string
3
5
  offset: number
@@ -60,10 +62,18 @@ export function getVisualLines(value: string, wrapWidth: number): VisualLine[] {
60
62
  if (start === end) {
61
63
  lines.push({ start, end })
62
64
  } else {
63
- for (let chunkStart = start; chunkStart < end; chunkStart += safeWrapWidth) {
64
- lines.push({ start: chunkStart, end: Math.min(chunkStart + safeWrapWidth, end) })
65
+ const logical = value.slice(start, end)
66
+ const wrapped = wrapAnsi(logical, safeWrapWidth, { trim: false, hard: true, wordWrap: true })
67
+ const wrappedLines = wrapped.split('\n')
68
+ let cursor = start
69
+ let lastEnd = start
70
+ for (const line of wrappedLines) {
71
+ const lineEnd = cursor + line.length
72
+ lines.push({ start: cursor, end: lineEnd })
73
+ cursor = lineEnd
74
+ lastEnd = lineEnd
65
75
  }
66
- if (end === value.length && (end - start) % safeWrapWidth === 0) {
76
+ if (end === value.length && lastEnd === end && wrappedLines[wrappedLines.length - 1]!.length === safeWrapWidth) {
67
77
  lines.push({ start: end, end })
68
78
  }
69
79
  }
@@ -23,8 +23,8 @@ type TranscriptViewProps = {
23
23
  onScrollabilityChange?: (canScroll: boolean) => void
24
24
  }
25
25
 
26
- const PROMPT_RESERVED_LINES = 7
27
- const OVERLAY_RESERVED_LINES = 12
26
+ const PROMPT_RESERVED_LINES = 12
27
+ const OVERLAY_RESERVED_LINES = 16
28
28
  const MIN_TRANSCRIPT_LINES = 6
29
29
  const MAX_TRANSCRIPT_LINES = 240
30
30
 
@@ -73,8 +73,10 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
73
73
  )
74
74
  const visibleReasoningIds = useMemo(
75
75
  () => selection.rows
76
- .filter((row): row is Extract<MessageRow, { role: 'thinking' }> => row.role === 'thinking')
77
- .map(row => row.id),
76
+ .filter((slice): slice is { row: Extract<MessageRow, { role: 'thinking' }>; clipStart: number; clipEnd: number; rowHeight: number } =>
77
+ slice.row.role === 'thinking',
78
+ )
79
+ .map(slice => slice.row.id),
78
80
  [selection.rows],
79
81
  )
80
82
 
@@ -129,7 +131,7 @@ export const TranscriptView: React.FC<TranscriptViewProps> = ({
129
131
  {` saves the full transcript`}
130
132
  </Text>
131
133
  ) : null}
132
- <MessageList rows={selection.rows} />
134
+ <MessageList slices={selection.rows} />
133
135
  {selection.hiddenAfter > 0 ? (
134
136
  <Text color={theme.dim}>
135
137
  {` ${selection.hiddenAfter} later message${selection.hiddenAfter === 1 ? '' : 's'} below · `}