ethagent 0.2.0 → 1.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/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { theme } from './theme.js'
|
|
4
|
+
import { useAppInput } from '../app/input/AppInputProvider.js'
|
|
5
|
+
|
|
6
|
+
type TextInputProps = {
|
|
7
|
+
label?: string
|
|
8
|
+
placeholder?: string
|
|
9
|
+
isSecret?: boolean
|
|
10
|
+
initialValue?: string
|
|
11
|
+
allowEmpty?: boolean
|
|
12
|
+
maxLength?: number
|
|
13
|
+
validate?: (value: string) => string | null
|
|
14
|
+
onSubmit: (value: string) => void
|
|
15
|
+
onCancel?: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function TextInput({
|
|
19
|
+
label,
|
|
20
|
+
placeholder,
|
|
21
|
+
isSecret,
|
|
22
|
+
initialValue = '',
|
|
23
|
+
allowEmpty = false,
|
|
24
|
+
maxLength = 4096,
|
|
25
|
+
validate,
|
|
26
|
+
onSubmit,
|
|
27
|
+
onCancel,
|
|
28
|
+
}: TextInputProps) {
|
|
29
|
+
const [value, setValue] = useState(initialValue)
|
|
30
|
+
const [error, setError] = useState<string | null>(null)
|
|
31
|
+
|
|
32
|
+
useAppInput((input, key) => {
|
|
33
|
+
if (key.return) {
|
|
34
|
+
if (!allowEmpty && value.trim().length === 0) {
|
|
35
|
+
setError('value cannot be empty')
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
const validationError = validate?.(value) ?? null
|
|
39
|
+
if (validationError) {
|
|
40
|
+
setError(validationError)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
setError(null)
|
|
44
|
+
onSubmit(value)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
if (key.escape) {
|
|
48
|
+
onCancel?.()
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (key.backspace || key.delete) {
|
|
52
|
+
setValue(v => v.slice(0, -1))
|
|
53
|
+
if (error) setError(null)
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
if (key.ctrl && input === 'u') {
|
|
57
|
+
setValue('')
|
|
58
|
+
if (error) setError(null)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (key.ctrl || key.meta || key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.tab) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (input) {
|
|
65
|
+
const clean = input.replace(/[\r\n]/g, '')
|
|
66
|
+
if (clean) {
|
|
67
|
+
setValue(v => (v + clean).slice(0, maxLength))
|
|
68
|
+
if (error) setError(null)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const display = isSecret ? '*'.repeat(value.length) : value
|
|
74
|
+
const showPlaceholder = value.length === 0 && placeholder
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Box flexDirection="column">
|
|
78
|
+
{label ? <Text color={theme.dim}>{label}</Text> : null}
|
|
79
|
+
<Box flexDirection="row">
|
|
80
|
+
<Text color={theme.accentPrimary}>{'> '}</Text>
|
|
81
|
+
{showPlaceholder ? (
|
|
82
|
+
<>
|
|
83
|
+
<Text color={theme.accentPrimary}>|</Text>
|
|
84
|
+
<Text color={theme.dim}>{placeholder}</Text>
|
|
85
|
+
</>
|
|
86
|
+
) : (
|
|
87
|
+
<>
|
|
88
|
+
<Text color={theme.text}>{display}</Text>
|
|
89
|
+
<Text color={theme.accentPrimary}>|</Text>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</Box>
|
|
93
|
+
{error ? <Text color="#e87070">{error}</Text> : null}
|
|
94
|
+
</Box>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export const palette: Array<[number, number, number]> = [
|
|
2
|
+
[0xff, 0xff, 0xff],
|
|
3
|
+
[0xf2, 0xf9, 0xf4],
|
|
4
|
+
[0xe7, 0xf4, 0xec],
|
|
5
|
+
[0xd4, 0xee, 0xdd],
|
|
6
|
+
[0xe7, 0xf4, 0xec],
|
|
7
|
+
[0xff, 0xff, 0xff],
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
export const eyePalette: Array<[number, number, number]> = [
|
|
11
|
+
[0xf5, 0xd8, 0xd8],
|
|
12
|
+
[0xf5, 0xe7, 0xcf],
|
|
13
|
+
[0xf5, 0xf0, 0xd4],
|
|
14
|
+
[0xd4, 0xee, 0xdd],
|
|
15
|
+
[0xd4, 0xe6, 0xf5],
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
export const theme = {
|
|
19
|
+
accentPrimary: '#d4eedd',
|
|
20
|
+
accentWarm: '#d8cda8',
|
|
21
|
+
accentNeutral: '#e4e3b5',
|
|
22
|
+
accentSecondary: '#c0e3cb',
|
|
23
|
+
accentMint: '#e7f4ec',
|
|
24
|
+
accentPeach: '#e7cdb7',
|
|
25
|
+
accentLavender: '#d9cae8',
|
|
26
|
+
accentInfo: '#90b8e8',
|
|
27
|
+
border: '#555555',
|
|
28
|
+
dim: '#777777',
|
|
29
|
+
text: '#f1f1f1',
|
|
30
|
+
textSubtle: '#9b9b9b',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
export function gradientColor(t: number): string {
|
|
34
|
+
const s = Math.max(0, Math.min(1, t)) * (palette.length - 1)
|
|
35
|
+
const i = Math.min(Math.floor(s), palette.length - 2)
|
|
36
|
+
const f = s - i
|
|
37
|
+
const lo = palette[i] ?? palette[0]!
|
|
38
|
+
const hi = palette[i + 1] ?? palette[palette.length - 1]!
|
|
39
|
+
const [r1, g1, b1] = lo
|
|
40
|
+
const [r2, g2, b2] = hi
|
|
41
|
+
const r = Math.round(r1 + (r2 - r1) * f)
|
|
42
|
+
const g = Math.round(g1 + (g2 - g1) * f)
|
|
43
|
+
const b = Math.round(b1 + (b2 - b1) * f)
|
|
44
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function eyeGradientColor(t: number): string {
|
|
48
|
+
const s = Math.max(0, Math.min(1, t)) * (eyePalette.length - 1)
|
|
49
|
+
const i = Math.min(Math.floor(s), eyePalette.length - 2)
|
|
50
|
+
const f = s - i
|
|
51
|
+
const lo = eyePalette[i] ?? eyePalette[0]!
|
|
52
|
+
const hi = eyePalette[i + 1] ?? eyePalette[eyePalette.length - 1]!
|
|
53
|
+
const [r1, g1, b1] = lo
|
|
54
|
+
const [r2, g2, b2] = hi
|
|
55
|
+
const r = Math.round(r1 + (r2 - r1) * f)
|
|
56
|
+
const g = Math.round(g1 + (g2 - g1) * f)
|
|
57
|
+
const b = Math.round(b1 + (b2 - b1) * f)
|
|
58
|
+
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
|
59
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { mkdir, stat } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
export type CopyResult = { ok: true; method: string } | { ok: false; error: string }
|
|
7
|
+
export type ReadResult = { ok: true; text: string; method: string } | { ok: false; error: string }
|
|
8
|
+
export type ReadImageResult = { ok: true; path: string; method: string } | { ok: false; error: string }
|
|
9
|
+
|
|
10
|
+
export async function copyToClipboard(text: string): Promise<CopyResult> {
|
|
11
|
+
const native = await tryNative(text)
|
|
12
|
+
if (native.ok) return native
|
|
13
|
+
|
|
14
|
+
const tmux = await tryTmux(text)
|
|
15
|
+
if (tmux.ok) return tmux
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
process.stdout.write(osc52(text))
|
|
19
|
+
return { ok: true, method: 'osc52' }
|
|
20
|
+
} catch (err: unknown) {
|
|
21
|
+
return { ok: false, error: (err as Error).message || 'osc52 write failed' }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function tryNative(text: string): Promise<CopyResult> {
|
|
26
|
+
if (process.platform === 'darwin') {
|
|
27
|
+
return pipeTo('pbcopy', [], text, 'pbcopy')
|
|
28
|
+
}
|
|
29
|
+
if (process.platform === 'win32') {
|
|
30
|
+
return pipeTo('clip', [], text, 'clip.exe')
|
|
31
|
+
}
|
|
32
|
+
const wl = await probe('wl-copy', ['--version'])
|
|
33
|
+
if (wl) return pipeTo('wl-copy', [], text, 'wl-copy')
|
|
34
|
+
const xclip = await probe('xclip', ['-version'])
|
|
35
|
+
if (xclip) return pipeTo('xclip', ['-selection', 'clipboard'], text, 'xclip')
|
|
36
|
+
const xsel = await probe('xsel', ['--version'])
|
|
37
|
+
if (xsel) return pipeTo('xsel', ['--clipboard', '--input'], text, 'xsel')
|
|
38
|
+
return { ok: false, error: 'no native clipboard tool found' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function tryTmux(text: string): Promise<CopyResult> {
|
|
42
|
+
if (!process.env['TMUX']) return { ok: false, error: 'not in tmux' }
|
|
43
|
+
return pipeTo('tmux', ['load-buffer', '-w', '-'], text, 'tmux load-buffer')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pipeTo(cmd: string, args: string[], text: string, method: string): Promise<CopyResult> {
|
|
47
|
+
return new Promise(resolve => {
|
|
48
|
+
let child
|
|
49
|
+
try {
|
|
50
|
+
child = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] })
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
resolve({ ok: false, error: (err as Error).message })
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
child.on('error', err => resolve({ ok: false, error: err.message }))
|
|
56
|
+
child.on('close', code => {
|
|
57
|
+
if (code === 0) resolve({ ok: true, method })
|
|
58
|
+
else resolve({ ok: false, error: `${cmd} exited ${code}` })
|
|
59
|
+
})
|
|
60
|
+
child.stdin?.end(text, 'utf8')
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function probe(cmd: string, args: string[]): Promise<boolean> {
|
|
65
|
+
return new Promise(resolve => {
|
|
66
|
+
let child
|
|
67
|
+
try {
|
|
68
|
+
child = spawn(cmd, args, { stdio: 'ignore' })
|
|
69
|
+
} catch {
|
|
70
|
+
resolve(false)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
child.on('error', () => resolve(false))
|
|
74
|
+
child.on('close', code => resolve(code === 0))
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function osc52(text: string): string {
|
|
79
|
+
const b64 = Buffer.from(text, 'utf8').toString('base64')
|
|
80
|
+
return `\x1b]52;c;${b64}\x07`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function readClipboard(): Promise<ReadResult> {
|
|
84
|
+
const native = await tryReadNative()
|
|
85
|
+
if (native.ok) return native
|
|
86
|
+
const tmux = await tryReadTmux()
|
|
87
|
+
if (tmux.ok) return tmux
|
|
88
|
+
return { ok: false, error: native.error || tmux.error || 'no clipboard tool available' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function tryReadNative(): Promise<ReadResult> {
|
|
92
|
+
if (process.platform === 'darwin') {
|
|
93
|
+
return readFrom('pbpaste', [], 'pbpaste')
|
|
94
|
+
}
|
|
95
|
+
if (process.platform === 'win32') {
|
|
96
|
+
return readFrom('powershell', ['-NoProfile', '-Command', 'Get-Clipboard -Raw'], 'powershell Get-Clipboard')
|
|
97
|
+
}
|
|
98
|
+
if (process.env['WAYLAND_DISPLAY']) {
|
|
99
|
+
const wl = await probe('wl-paste', ['--version'])
|
|
100
|
+
if (wl) return readFrom('wl-paste', ['--no-newline'], 'wl-paste')
|
|
101
|
+
}
|
|
102
|
+
const xclip = await probe('xclip', ['-version'])
|
|
103
|
+
if (xclip) return readFrom('xclip', ['-o', '-selection', 'clipboard'], 'xclip')
|
|
104
|
+
const xsel = await probe('xsel', ['--version'])
|
|
105
|
+
if (xsel) return readFrom('xsel', ['--output', '--clipboard'], 'xsel')
|
|
106
|
+
return { ok: false, error: 'no native clipboard tool found' }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function tryReadTmux(): Promise<ReadResult> {
|
|
110
|
+
if (!process.env['TMUX']) return { ok: false, error: 'not in tmux' }
|
|
111
|
+
return readFrom('tmux', ['save-buffer', '-'], 'tmux save-buffer')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readFrom(cmd: string, args: string[], method: string): Promise<ReadResult> {
|
|
115
|
+
return new Promise(resolve => {
|
|
116
|
+
let child
|
|
117
|
+
try {
|
|
118
|
+
child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
119
|
+
} catch (err: unknown) {
|
|
120
|
+
resolve({ ok: false, error: (err as Error).message })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const chunks: Buffer[] = []
|
|
124
|
+
child.stdout?.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
125
|
+
child.on('error', err => resolve({ ok: false, error: err.message }))
|
|
126
|
+
child.on('close', code => {
|
|
127
|
+
if (code === 0) {
|
|
128
|
+
const text = Buffer.concat(chunks).toString('utf8').replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
129
|
+
resolve({ ok: true, text: text.replace(/\n$/, ''), method })
|
|
130
|
+
} else {
|
|
131
|
+
resolve({ ok: false, error: `${cmd} exited ${code}` })
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function readClipboardImage(): Promise<ReadImageResult> {
|
|
138
|
+
const dir = path.join(os.homedir(), '.ethagent', 'pastes')
|
|
139
|
+
try {
|
|
140
|
+
await mkdir(dir, { recursive: true })
|
|
141
|
+
} catch (err: unknown) {
|
|
142
|
+
return { ok: false, error: (err as Error).message }
|
|
143
|
+
}
|
|
144
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
145
|
+
const dest = path.join(dir, `paste-${stamp}.png`)
|
|
146
|
+
|
|
147
|
+
if (process.platform === 'win32') return readImageWindows(dest)
|
|
148
|
+
if (process.platform === 'darwin') return readImageMac(dest)
|
|
149
|
+
return readImageLinux(dest)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readImageWindows(dest: string): Promise<ReadImageResult> {
|
|
153
|
+
const script = [
|
|
154
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
155
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
156
|
+
"if ($null -eq $img) { exit 2 }",
|
|
157
|
+
"$img.Save($env:ETHAGENT_PASTE_PATH, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
158
|
+
].join('; ')
|
|
159
|
+
return spawnImage(
|
|
160
|
+
'powershell',
|
|
161
|
+
['-NoProfile', '-Sta', '-Command', script],
|
|
162
|
+
{ ETHAGENT_PASTE_PATH: dest },
|
|
163
|
+
dest,
|
|
164
|
+
'powershell',
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function readImageMac(dest: string): Promise<ReadImageResult> {
|
|
169
|
+
const has = await probe('pngpaste', ['-v'])
|
|
170
|
+
if (!has) return { ok: false, error: 'pngpaste not installed (brew install pngpaste)' }
|
|
171
|
+
return spawnImage('pngpaste', [dest], {}, dest, 'pngpaste')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function readImageLinux(dest: string): Promise<ReadImageResult> {
|
|
175
|
+
const hasXclip = await probe('xclip', ['-version'])
|
|
176
|
+
if (!hasXclip) return { ok: false, error: 'xclip not available' }
|
|
177
|
+
return spawnImage('sh', ['-c', `xclip -selection clipboard -t image/png -o > "${dest}"`], {}, dest, 'xclip')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function spawnImage(
|
|
181
|
+
cmd: string,
|
|
182
|
+
args: string[],
|
|
183
|
+
env: Record<string, string>,
|
|
184
|
+
destPath: string,
|
|
185
|
+
method: string,
|
|
186
|
+
): Promise<ReadImageResult> {
|
|
187
|
+
return new Promise(resolve => {
|
|
188
|
+
let child
|
|
189
|
+
try {
|
|
190
|
+
child = spawn(cmd, args, {
|
|
191
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
192
|
+
env: { ...process.env, ...env },
|
|
193
|
+
})
|
|
194
|
+
} catch (err: unknown) {
|
|
195
|
+
resolve({ ok: false, error: (err as Error).message })
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
child.on('error', err => resolve({ ok: false, error: err.message }))
|
|
199
|
+
child.on('close', code => {
|
|
200
|
+
if (code === 2) {
|
|
201
|
+
resolve({ ok: false, error: 'no image on clipboard' })
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
if (code !== 0) {
|
|
205
|
+
resolve({ ok: false, error: `${cmd} exited ${code}` })
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
void stat(destPath)
|
|
209
|
+
.then(s => {
|
|
210
|
+
if (s.size === 0) resolve({ ok: false, error: 'no image saved' })
|
|
211
|
+
else resolve({ ok: true, path: destPath, method })
|
|
212
|
+
})
|
|
213
|
+
.catch(() => resolve({ ok: false, error: 'image file not created' }))
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type Segment =
|
|
2
|
+
| { kind: 'text'; content: string; preview: string }
|
|
3
|
+
| { kind: 'code'; lang: string | null; content: string; preview: string }
|
|
4
|
+
|
|
5
|
+
const FENCE = /^[ \t]{0,3}```([\w+-]*)[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]{0,3}```[ \t]*(?:\r?\n|$)/gm
|
|
6
|
+
|
|
7
|
+
export function parseSegments(markdown: string): Segment[] {
|
|
8
|
+
if (!markdown) return []
|
|
9
|
+
const segments: Segment[] = []
|
|
10
|
+
let lastIndex = 0
|
|
11
|
+
FENCE.lastIndex = 0
|
|
12
|
+
let match: RegExpExecArray | null
|
|
13
|
+
while ((match = FENCE.exec(markdown)) !== null) {
|
|
14
|
+
const before = markdown.slice(lastIndex, match.index)
|
|
15
|
+
const textSeg = toTextSegment(before)
|
|
16
|
+
if (textSeg) segments.push(textSeg)
|
|
17
|
+
const lang = match[1] && match[1].length > 0 ? match[1] : null
|
|
18
|
+
const body = match[2] ?? ''
|
|
19
|
+
segments.push({
|
|
20
|
+
kind: 'code',
|
|
21
|
+
lang,
|
|
22
|
+
content: body,
|
|
23
|
+
preview: codePreview(lang, body),
|
|
24
|
+
})
|
|
25
|
+
lastIndex = match.index + match[0].length
|
|
26
|
+
}
|
|
27
|
+
const tail = markdown.slice(lastIndex)
|
|
28
|
+
const tailSeg = toTextSegment(tail)
|
|
29
|
+
if (tailSeg) segments.push(tailSeg)
|
|
30
|
+
return segments
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function toTextSegment(raw: string): Segment | null {
|
|
34
|
+
const cleaned = raw.replace(/\r\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
|
35
|
+
if (!cleaned) return null
|
|
36
|
+
return { kind: 'text', content: cleaned, preview: textPreview(cleaned) }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function textPreview(text: string): string {
|
|
40
|
+
const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? text
|
|
41
|
+
const trimmed = firstLine.trim()
|
|
42
|
+
const chars = text.length
|
|
43
|
+
const snippet = trimmed.length > 48 ? trimmed.slice(0, 47) + '…' : trimmed
|
|
44
|
+
return `text · ${chars} chars · ${snippet}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function codePreview(lang: string | null, body: string): string {
|
|
48
|
+
const lineCount = body.length === 0 ? 0 : body.split('\n').length
|
|
49
|
+
const label = lang ?? 'code'
|
|
50
|
+
return `code · ${label} · ${lineCount} line${lineCount === 1 ? '' : 's'}`
|
|
51
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Message, MessageContentBlock } from '../providers/contracts.js'
|
|
2
|
+
|
|
3
|
+
export function systemMessage(content: string): Message {
|
|
4
|
+
return { role: 'system', content }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function userMessage(content: string | MessageContentBlock[]): Message {
|
|
8
|
+
return { role: 'user', content }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assistantMessage(content: string | MessageContentBlock[]): Message {
|
|
12
|
+
return { role: 'assistant', content }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function messageTextContent(message: Message): string {
|
|
16
|
+
return typeof message.content === 'string' ? message.content : blocksToText(message.content)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function blocksToText(blocks: MessageContentBlock[]): string {
|
|
20
|
+
return blocks
|
|
21
|
+
.map(block => {
|
|
22
|
+
if (block.type === 'text') return block.text
|
|
23
|
+
if (block.type === 'tool_use') return `[tool use: ${block.name}]`
|
|
24
|
+
return block.isError
|
|
25
|
+
? `[tool error: ${block.content}]`
|
|
26
|
+
: `[tool result: ${block.content}]`
|
|
27
|
+
})
|
|
28
|
+
.join('\n')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function approximateTokens(messages: Message[]): number {
|
|
32
|
+
let chars = 0
|
|
33
|
+
for (const m of messages) chars += messageTextContent(m).length
|
|
34
|
+
return Math.ceil(chars / 4)
|
|
35
|
+
}
|