ethagent 2.4.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 (98) hide show
  1. package/README.md +7 -4
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +155 -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 +3 -1
  8. package/src/chat/ChatScreen.tsx +7 -1
  9. package/src/chat/ConversationStack.tsx +25 -19
  10. package/src/chat/MessageList.tsx +194 -53
  11. package/src/chat/chatSessionState.ts +1 -1
  12. package/src/chat/chatTurnOrchestrator.ts +59 -0
  13. package/src/chat/input/ChatInput.tsx +3 -0
  14. package/src/chat/input/textCursor.ts +13 -3
  15. package/src/chat/transcript/TranscriptView.tsx +7 -5
  16. package/src/chat/transcript/transcriptViewport.ts +88 -17
  17. package/src/chat/views/PermissionPrompt.tsx +26 -26
  18. package/src/chat/views/PermissionsView.tsx +18 -12
  19. package/src/chat/views/RewindView.tsx +3 -1
  20. package/src/cli/ResetConfirmView.tsx +24 -9
  21. package/src/identity/continuity/editor.ts +27 -2
  22. package/src/identity/continuity/envelope.ts +125 -0
  23. package/src/identity/continuity/publicSkills.ts +37 -1
  24. package/src/identity/continuity/skills/frontmatter.ts +183 -0
  25. package/src/identity/continuity/skills/loadSkills.ts +609 -0
  26. package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
  27. package/src/identity/continuity/skills/scaffold.ts +52 -0
  28. package/src/identity/continuity/skills/types.ts +30 -0
  29. package/src/identity/continuity/storage/defaults.ts +28 -47
  30. package/src/identity/continuity/storage/files.ts +1 -0
  31. package/src/identity/continuity/storage/paths.ts +1 -0
  32. package/src/identity/continuity/storage/scaffold.ts +25 -23
  33. package/src/identity/continuity/storage/status.ts +34 -5
  34. package/src/identity/continuity/storage/types.ts +3 -2
  35. package/src/identity/continuity/storage.ts +3 -0
  36. package/src/identity/hub/OperationalRoutes.tsx +105 -3
  37. package/src/identity/hub/Routes.tsx +5 -3
  38. package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
  39. package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
  40. package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
  41. package/src/identity/hub/continuity/effects.ts +36 -5
  42. package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
  43. package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
  44. package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
  45. package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
  46. package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
  47. package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
  48. package/src/identity/hub/continuity/snapshot.ts +3 -0
  49. package/src/identity/hub/continuity/state.ts +3 -2
  50. package/src/identity/hub/continuity/vault.ts +42 -10
  51. package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
  52. package/src/identity/hub/identityHubReducer.ts +21 -0
  53. package/src/identity/hub/profile/effects.ts +16 -3
  54. package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
  55. package/src/identity/hub/restore/apply.ts +12 -1
  56. package/src/identity/hub/restore/recovery.ts +11 -1
  57. package/src/identity/hub/restore/resolve.ts +1 -1
  58. package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
  59. package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
  60. package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
  61. package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
  62. package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
  63. package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
  64. package/src/identity/hub/shared/effects/sync.ts +16 -3
  65. package/src/identity/hub/shared/model/copy.ts +2 -4
  66. package/src/identity/hub/transfer/effects.ts +15 -2
  67. package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
  68. package/src/identity/hub/useIdentityHubController.ts +5 -1
  69. package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
  70. package/src/mcp/manager.ts +1 -1
  71. package/src/models/ModelPicker.tsx +89 -84
  72. package/src/models/llamacpp.ts +160 -11
  73. package/src/models/llamacppPreflight.ts +1 -16
  74. package/src/models/modelPickerOptions.ts +43 -37
  75. package/src/providers/contracts.ts +1 -0
  76. package/src/providers/openai-chat.ts +50 -9
  77. package/src/providers/openai-responses.ts +19 -4
  78. package/src/runtime/toolExecution.ts +4 -3
  79. package/src/runtime/turn.ts +61 -30
  80. package/src/tools/changeDirectoryTool.ts +1 -1
  81. package/src/tools/contracts.ts +10 -0
  82. package/src/tools/deleteFileTool.ts +1 -1
  83. package/src/tools/editTool.ts +1 -1
  84. package/src/tools/listDirectoryTool.ts +1 -1
  85. package/src/tools/listSkillFilesTool.ts +77 -0
  86. package/src/tools/listSkillsTool.ts +68 -0
  87. package/src/tools/mcpResourceTools.ts +2 -2
  88. package/src/tools/privateContinuityReadTool.ts +1 -1
  89. package/src/tools/readSkillTool.ts +107 -0
  90. package/src/tools/readTool.ts +1 -1
  91. package/src/tools/registry.ts +6 -0
  92. package/src/tools/writeFileTool.ts +22 -2
  93. package/src/ui/Spinner.tsx +1 -1
  94. package/src/identity/continuity/localBackup.ts +0 -249
  95. package/src/identity/continuity/zipWriter.ts +0 -95
  96. package/src/identity/hub/continuity/index.ts +0 -7
  97. package/src/identity/hub/ens/index.ts +0 -11
  98. 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 = () => (
@@ -52,7 +52,7 @@ export function resolveModelSelection(
52
52
  baseUrl,
53
53
  localMmprojPath: selection.mmprojPath,
54
54
  },
55
- notice: `Local Hugging Face model ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}${selection.mmprojPath ? ' with vision encoder' : ''}.`,
55
+ notice: `llama.cpp ready. Now using ${formatModelDisplayName('llamacpp', selection.model, { maxLength: 64 })}${selection.mmprojPath ? ' · vision' : ''}.`,
56
56
  tone: 'info',
57
57
  }
58
58
  }
@@ -9,6 +9,8 @@ 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,
@@ -121,6 +123,7 @@ export async function runStreamingTurn(
121
123
  return [
122
124
  ...(baseSystem ? [baseSystem] : []),
123
125
  ...await buildIdentityContinuityContextMessages(getConfig()),
126
+ ...await buildSkillsIndexMessage(getConfig()),
124
127
  ...conversationMessages,
125
128
  ...mentionContextMessages,
126
129
  ]
@@ -399,6 +402,11 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
399
402
  ctx.flushStreamRows()
400
403
  return
401
404
  }
405
+ case 'thinking_end': {
406
+ ctx.flushStreamRows(true)
407
+ ctx.stopThinkingCursor()
408
+ return
409
+ }
402
410
  case 'retry': {
403
411
  return
404
412
  }
@@ -565,6 +573,57 @@ async function buildFileMentionContextMessages(
565
573
  ]
566
574
  }
567
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
+
568
627
  export async function buildIdentityContinuityContextMessages(
569
628
  config: EthagentConfig,
570
629
  ): Promise<Message[]> {
@@ -522,6 +522,9 @@ export const ChatInput: React.FC<PromptInputProps> = ({
522
522
  }
523
523
  if (key.tab || key.escape) return
524
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
525
528
 
526
529
  if (inputText) {
527
530
  insertText(inputText)
@@ -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 · `}
@@ -1,4 +1,5 @@
1
1
  import type { MessageRow } from '../MessageList.js'
2
+ import { flattenAssistantBody } from '../MessageList.js'
2
3
 
3
4
  export type TranscriptAnchor = {
4
5
  rowId: string
@@ -11,6 +12,13 @@ export type TranscriptViewportState = {
11
12
  anchor: TranscriptAnchor | null
12
13
  }
13
14
 
15
+ export type RowSlice<T> = {
16
+ row: T
17
+ clipStart: number
18
+ clipEnd: number
19
+ rowHeight: number
20
+ }
21
+
14
22
  export function buildLineOffsets(rowHeights: number[]): number[] {
15
23
  const out = new Array<number>(rowHeights.length + 1).fill(0)
16
24
  for (let i = 0; i < rowHeights.length; i += 1) {
@@ -62,12 +70,12 @@ export function clampLine(line: number, maxScrollTop: number): number {
62
70
  }
63
71
 
64
72
  export type TranscriptTailSelection<T> = {
65
- rows: T[]
73
+ rows: Array<RowSlice<T>>
66
74
  hiddenCount: number
67
75
  }
68
76
 
69
77
  export type TranscriptWindowSelection<T> = {
70
- rows: T[]
78
+ rows: Array<RowSlice<T>>
71
79
  hiddenBefore: number
72
80
  hiddenAfter: number
73
81
  totalLines: number
@@ -94,8 +102,12 @@ export function selectTailRowsForViewport<T>(
94
102
  }
95
103
 
96
104
  const firstVisible = Math.max(0, start + 1)
105
+ const slice = rows.slice(firstVisible).map(row => {
106
+ const height = Math.max(1, estimateHeight(row))
107
+ return { row, clipStart: 0, clipEnd: height, rowHeight: height }
108
+ })
97
109
  return {
98
- rows: rows.slice(firstVisible),
110
+ rows: slice,
99
111
  hiddenCount: firstVisible,
100
112
  }
101
113
  }
@@ -118,7 +130,7 @@ export function selectRowsForScrollOffset<T>(
118
130
  const scrollOffset = clampLine(scrollOffsetFromTail, maxScrollOffset)
119
131
  const startLine = Math.max(0, totalLines - budget - scrollOffset)
120
132
 
121
- return selectRowsForLineWindow(rows, offsets, budget, startLine, totalLines, maxScrollOffset)
133
+ return selectRowsForLineWindow(rows, heights, offsets, budget, startLine, totalLines, maxScrollOffset)
122
134
  }
123
135
 
124
136
  export function selectRowsForScrollTop<T>(
@@ -138,7 +150,7 @@ export function selectRowsForScrollTop<T>(
138
150
  const maxScrollOffset = Math.max(0, totalLines - budget)
139
151
  const startLine = clampLine(scrollTopLine, maxScrollOffset)
140
152
 
141
- return selectRowsForLineWindow(rows, offsets, budget, startLine, totalLines, maxScrollOffset)
153
+ return selectRowsForLineWindow(rows, heights, offsets, budget, startLine, totalLines, maxScrollOffset)
142
154
  }
143
155
 
144
156
  export function scrollTopForPageUp(
@@ -159,11 +171,12 @@ export function scrollTopForPageDown(
159
171
 
160
172
  function pageScrollDistance(viewportLines: number): number {
161
173
  const viewport = Math.max(1, Math.floor(viewportLines))
162
- return Math.max(1, Math.floor(viewport / 2))
174
+ return Math.max(1, viewport - 2)
163
175
  }
164
176
 
165
177
  function selectRowsForLineWindow<T>(
166
178
  rows: T[],
179
+ heights: number[],
167
180
  offsets: number[],
168
181
  budget: number,
169
182
  startLine: number,
@@ -178,8 +191,20 @@ function selectRowsForLineWindow<T>(
178
191
  ? rows.length
179
192
  : Math.min(rows.length, findRowIndexAtLine(offsets, lastVisibleLine) + 1)
180
193
 
194
+ const slices: Array<RowSlice<T>> = []
195
+ for (let i = startIndex; i < endIndex; i += 1) {
196
+ const row = rows[i]
197
+ if (!row) continue
198
+ const rowTop = offsets[i] ?? 0
199
+ const height = heights[i] ?? 1
200
+ const clipStart = Math.max(0, startLine - rowTop)
201
+ const clipEnd = Math.min(height, endLine - rowTop)
202
+ if (clipEnd <= clipStart) continue
203
+ slices.push({ row, clipStart, clipEnd, rowHeight: height })
204
+ }
205
+
181
206
  return {
182
- rows: rows.slice(startIndex, endIndex),
207
+ rows: slices,
183
208
  hiddenBefore: startIndex,
184
209
  hiddenAfter: rows.length - endIndex,
185
210
  totalLines,
@@ -191,13 +216,11 @@ export function estimateMessageRowHeight(row: MessageRow, columns = 80): number
191
216
  const contentWidth = Math.max(20, columns - 8)
192
217
  switch (row.role) {
193
218
  case 'user':
194
- return 1 + wrappedLineCount(row.content, contentWidth)
219
+ return userRowLineCount(row.content, contentWidth)
195
220
  case 'assistant':
196
- return 1 + wrappedLineCount([row.content, row.liveTail ?? ''].filter(Boolean).join('\n'), contentWidth)
221
+ return assistantRowLineCount(row.content, row.liveTail ?? '', contentWidth, Boolean(row.streaming))
197
222
  case 'thinking':
198
- return row.expanded
199
- ? 3 + wrappedLineCount([row.content, row.liveTail ?? ''].filter(Boolean).join('\n'), contentWidth)
200
- : 3 + wrappedLineCount(reasoningPreview(row), contentWidth)
223
+ return thinkingRowLineCount(row, contentWidth)
201
224
  case 'tool_call':
202
225
  return 1
203
226
  case 'note':
@@ -209,11 +232,59 @@ export function estimateMessageRowHeight(row: MessageRow, columns = 80): number
209
232
  }
210
233
  }
211
234
 
212
- function reasoningPreview(row: Extract<MessageRow, { role: 'thinking' }>): string {
213
- const normalized = [row.content, row.liveTail ?? ''].filter(Boolean).join('').replace(/\s+/g, ' ').trim()
214
- if (!normalized) return 'thinking...'
215
- if (normalized.length <= 120) return normalized
216
- return `${normalized.slice(0, 117)}...`
235
+ export function userRowLineCount(content: string, contentWidth: number): number {
236
+ const lines = splitLines(content)
237
+ return 1 + lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / contentWidth)), 0)
238
+ }
239
+
240
+ export function assistantRowLineCount(content: string, liveTail: string, _contentWidth: number, streaming = false): number {
241
+ const fullText = liveTail ? content + liveTail : content
242
+ return 1 + flattenAssistantBody(fullText, streaming).length
243
+ }
244
+
245
+ export function thinkingRowLineCount(
246
+ row: Extract<MessageRow, { role: 'thinking' }>,
247
+ _contentWidth: number,
248
+ ): number {
249
+ const omitted = thinkingDisplayOmittedChars(row)
250
+ const overhead = 1 + (omitted > 0 ? 1 : 0) + 1
251
+ if (!row.expanded) return overhead
252
+ const body = thinkingDisplayBody(row)
253
+ const lines = splitLines(body)
254
+ return overhead + lines.length
255
+ }
256
+
257
+ export function thinkingDisplayBody(row: Extract<MessageRow, { role: 'thinking' }>): string {
258
+ const text = row.liveTail ? row.content + row.liveTail : row.content
259
+ return clipReasoningForDisplayText(text)
260
+ }
261
+
262
+ export function thinkingDisplayOmittedChars(row: Extract<MessageRow, { role: 'thinking' }>): number {
263
+ const text = row.liveTail ? row.content + row.liveTail : row.content
264
+ return clipReasoningForDisplayOmitted(text)
265
+ }
266
+
267
+ const MAX_RENDERED_REASONING_CHARS = 10_000
268
+
269
+ function clipReasoningForDisplayText(text: string): string {
270
+ if (text.length <= MAX_RENDERED_REASONING_CHARS) return text
271
+ const rawStart = Math.max(0, text.length - MAX_RENDERED_REASONING_CHARS)
272
+ const newline = text.indexOf('\n', rawStart)
273
+ const start = newline >= 0 && newline - rawStart <= 240 ? newline + 1 : rawStart
274
+ return text.slice(start)
275
+ }
276
+
277
+ function clipReasoningForDisplayOmitted(text: string): number {
278
+ if (text.length <= MAX_RENDERED_REASONING_CHARS) return 0
279
+ const rawStart = Math.max(0, text.length - MAX_RENDERED_REASONING_CHARS)
280
+ const newline = text.indexOf('\n', rawStart)
281
+ return newline >= 0 && newline - rawStart <= 240 ? newline + 1 : rawStart
282
+ }
283
+
284
+ export function splitLines(text: string): string[] {
285
+ if (!text) return ['']
286
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
287
+ return normalized.split('\n')
217
288
  }
218
289
 
219
290
  function wrappedLineCount(text: string, width: number): number {