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.
- package/README.md +18 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +157 -15
- package/src/app/FirstRunTimeline.tsx +4 -0
- package/src/app/input/AppInputProvider.tsx +19 -0
- package/src/app/input/appInputParser.ts +19 -4
- package/src/chat/ChatBottomPane.tsx +12 -1
- package/src/chat/ChatScreen.tsx +17 -5
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +4 -1
- package/src/chat/chatTurnOrchestrator.ts +65 -2
- package/src/chat/input/ChatInput.tsx +28 -2
- package/src/chat/input/imageRefs.ts +30 -0
- package/src/chat/input/textCursor.ts +13 -3
- package/src/chat/transcript/TranscriptView.tsx +7 -5
- package/src/chat/transcript/transcriptViewport.ts +88 -17
- package/src/chat/views/PermissionPrompt.tsx +26 -26
- package/src/chat/views/PermissionsView.tsx +18 -12
- package/src/chat/views/ResumeView.tsx +16 -7
- package/src/chat/views/RewindView.tsx +3 -1
- package/src/cli/ResetConfirmView.tsx +24 -9
- package/src/identity/continuity/editor.ts +27 -2
- package/src/identity/continuity/envelope.ts +125 -0
- package/src/identity/continuity/publicSkills.ts +37 -1
- package/src/identity/continuity/skills/frontmatter.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +609 -0
- package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
- package/src/identity/continuity/skills/scaffold.ts +52 -0
- package/src/identity/continuity/skills/types.ts +30 -0
- package/src/identity/continuity/storage/defaults.ts +28 -47
- package/src/identity/continuity/storage/files.ts +1 -0
- package/src/identity/continuity/storage/paths.ts +1 -0
- package/src/identity/continuity/storage/scaffold.ts +25 -23
- package/src/identity/continuity/storage/status.ts +34 -5
- package/src/identity/continuity/storage/types.ts +3 -2
- package/src/identity/continuity/storage.ts +3 -0
- package/src/identity/hub/OperationalRoutes.tsx +105 -3
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +5 -51
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +1 -1
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -0
- package/src/identity/hub/continuity/effects.ts +36 -5
- package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
- package/src/identity/hub/continuity/skills/DeleteSkillScreen.tsx +123 -0
- package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
- package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
- package/src/identity/hub/continuity/skills/SkillVisibilityScreen.tsx +171 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +213 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +3 -2
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/identityHubReducer.ts +21 -0
- package/src/identity/hub/profile/effects.ts +16 -3
- package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
- package/src/identity/hub/restore/apply.ts +12 -1
- package/src/identity/hub/restore/recovery.ts +11 -1
- package/src/identity/hub/restore/resolve.ts +1 -1
- package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
- package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
- package/src/identity/hub/shared/components/IdentitySummary.tsx +97 -53
- package/src/identity/hub/shared/components/MenuScreen.tsx +18 -15
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +1 -1
- package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
- package/src/identity/hub/shared/effects/sync.ts +16 -3
- package/src/identity/hub/shared/model/copy.ts +2 -4
- package/src/identity/hub/transfer/effects.ts +15 -2
- package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
- package/src/identity/hub/useIdentityHubController.ts +5 -1
- package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
- package/src/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +211 -74
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +261 -17
- package/src/models/llamacppPreflight.ts +16 -12
- package/src/models/modelPickerOptions.ts +57 -38
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +10 -1
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +131 -11
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +41 -11
- package/src/providers/registry.ts +1 -0
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/storage/config.ts +1 -0
- package/src/storage/sessions.ts +14 -2
- package/src/tools/changeDirectoryTool.ts +1 -1
- package/src/tools/contracts.ts +10 -0
- package/src/tools/deleteFileTool.ts +1 -1
- package/src/tools/editTool.ts +1 -1
- package/src/tools/listDirectoryTool.ts +1 -1
- package/src/tools/listSkillFilesTool.ts +77 -0
- package/src/tools/listSkillsTool.ts +68 -0
- package/src/tools/mcpResourceTools.ts +2 -2
- package/src/tools/privateContinuityReadTool.ts +1 -1
- package/src/tools/readSkillTool.ts +107 -0
- package/src/tools/readTool.ts +1 -1
- package/src/tools/registry.ts +6 -0
- package/src/tools/writeFileTool.ts +22 -2
- package/src/ui/Spinner.tsx +15 -3
- package/src/ui/theme.ts +2 -0
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
- package/src/identity/continuity/localBackup.ts +0 -249
- package/src/identity/continuity/zipWriter.ts +0 -95
- package/src/identity/hub/continuity/index.ts +0 -7
- package/src/identity/hub/ens/index.ts +0 -11
- package/src/identity/hub/restore/index.ts +0 -22
package/src/chat/MessageList.tsx
CHANGED
|
@@ -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
|
-
|
|
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> = ({
|
|
78
|
+
const MessageListInner: React.FC<MessageListProps> = ({ slices }) => (
|
|
74
79
|
<Box flexDirection="column">
|
|
75
|
-
{
|
|
80
|
+
{slices.map((slice, index) => (
|
|
76
81
|
<RowView
|
|
77
|
-
key={row.id}
|
|
78
|
-
|
|
79
|
-
tightTop={row.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<{
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
{
|
|
386
|
+
{visibleBody.map(({ index, text }) => (
|
|
340
387
|
<Box key={index}>
|
|
341
388
|
<Text color={theme.textSubtle}>
|
|
342
389
|
<Text color={theme.dim}>{' '}</Text>
|
|
343
|
-
{
|
|
344
|
-
{showCursor && index ===
|
|
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<{
|
|
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
|
|
361
|
-
() =>
|
|
362
|
-
[fullText],
|
|
409
|
+
const nodes = useMemo(
|
|
410
|
+
() => flattenAssistantBody(fullText, Boolean(streaming)),
|
|
411
|
+
[fullText, streaming],
|
|
363
412
|
)
|
|
364
|
-
const
|
|
365
|
-
|
|
413
|
+
const effClipEnd = clipEnd ?? nodes.length
|
|
414
|
+
const visible = nodes.slice(clipStart, effClipEnd)
|
|
366
415
|
return (
|
|
367
416
|
<Box flexDirection="column">
|
|
368
|
-
{
|
|
369
|
-
<
|
|
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
|
-
|
|
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
|
-
)
|
|
385
|
-
|
|
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: `
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
64
|
-
|
|
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 &&
|
|
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 =
|
|
27
|
-
const OVERLAY_RESERVED_LINES =
|
|
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((
|
|
77
|
-
|
|
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
|
|
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 · `}
|