ethagent 3.0.2 → 3.1.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 +6 -1
- package/package.json +3 -1
- package/src/app/FirstRun.tsx +1 -24
- package/src/app/firstRunConfig.ts +26 -0
- package/src/auth/openaiOAuth/landingPage.ts +2 -11
- package/src/chat/ChatScreen.tsx +15 -116
- package/src/chat/MessageList.tsx +18 -260
- package/src/chat/chatEnvironment.ts +16 -0
- package/src/chat/chatTurnContext.ts +50 -0
- package/src/chat/chatTurnOrchestrator.ts +5 -112
- package/src/chat/chatTurnRows.ts +64 -0
- package/src/chat/commands.ts +3 -178
- package/src/chat/continuityEditReview.ts +42 -0
- package/src/chat/input/ChatInput.tsx +10 -144
- package/src/chat/input/chatInputHelpers.ts +62 -0
- package/src/chat/input/inputRendering.tsx +93 -0
- package/src/chat/messageMarkdown.ts +220 -0
- package/src/chat/messageRows.ts +43 -0
- package/src/chat/planImplementation.ts +62 -0
- package/src/chat/slashCommandHandlers.ts +165 -0
- package/src/chat/slashCommandViews.ts +120 -0
- package/src/identity/continuity/challenges.ts +123 -0
- package/src/identity/continuity/envelope.ts +49 -1484
- package/src/identity/continuity/envelopeCreate.ts +322 -0
- package/src/identity/continuity/envelopeCrypto.ts +182 -0
- package/src/identity/continuity/envelopeParse.ts +441 -0
- package/src/identity/continuity/envelopeTypes.ts +204 -0
- package/src/identity/continuity/envelopeVersion.ts +1 -0
- package/src/identity/continuity/payloadNormalization.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +12 -69
- package/src/identity/continuity/skills/skillPaths.ts +76 -0
- package/src/identity/continuity/skillsNormalization.ts +119 -0
- package/src/identity/continuity/snapshotToken.ts +28 -0
- package/src/identity/hub/continuity/completion.ts +67 -0
- package/src/identity/hub/continuity/effects.ts +5 -62
- package/src/identity/hub/profile/effects.ts +6 -170
- package/src/identity/hub/profile/operatorSave.ts +202 -0
- package/src/identity/wallet/browserWallet/html.ts +1 -57
- package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
- package/src/identity/wallet/page/controller.ts +1 -1
- package/src/identity/wallet/page/errorView.ts +122 -0
- package/src/identity/wallet/page/view.ts +3 -114
- package/src/mcp/manager.ts +8 -66
- package/src/mcp/managerHelpers.ts +70 -0
- package/src/models/ModelPicker.tsx +69 -889
- package/src/models/huggingface.ts +20 -137
- package/src/models/huggingfaceStorage.ts +136 -0
- package/src/models/llamacpp.ts +37 -303
- package/src/models/llamacppCommands.ts +44 -0
- package/src/models/llamacppConfig.ts +34 -0
- package/src/models/llamacppDiscovery.ts +176 -0
- package/src/models/llamacppOutput.ts +65 -0
- package/src/models/modelPickerCatalogFlow.ts +56 -0
- package/src/models/modelPickerCredentials.ts +166 -0
- package/src/models/modelPickerData.ts +41 -0
- package/src/models/modelPickerDisplay.tsx +132 -0
- package/src/models/modelPickerHfFlow.ts +192 -0
- package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
- package/src/models/modelPickerTypes.ts +69 -0
- package/src/models/modelPickerUninstallFlow.ts +48 -0
- package/src/models/modelPickerViewHelpers.ts +174 -0
- package/src/providers/openai-chat.ts +5 -124
- package/src/providers/openaiChatWire.ts +124 -0
- package/src/runtime/providerTurn.ts +38 -0
- package/src/runtime/textToolParser.ts +161 -0
- package/src/runtime/toolIntent.ts +1 -1
- package/src/runtime/turn.ts +43 -499
- package/src/runtime/turnNudges.ts +223 -0
- package/src/runtime/turnTypes.ts +86 -0
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
1
|
import type { Message, Provider } from '../providers/contracts.js'
|
|
4
2
|
import { directToolUsesForUserText } from '../runtime/toolIntent.js'
|
|
5
3
|
import { toPermissionMode, type SessionMode } from '../runtime/sessionMode.js'
|
|
@@ -18,6 +16,11 @@ import {
|
|
|
18
16
|
type TurnCheckpoint,
|
|
19
17
|
} from './chatScreenUtils.js'
|
|
20
18
|
import { collapseImagePathsToRefs, userTextToContentBlocks } from '../utils/images.js'
|
|
19
|
+
import { buildFileMentionContextMessages } from './chatTurnContext.js'
|
|
20
|
+
import {
|
|
21
|
+
finalizeStreamingRowsById,
|
|
22
|
+
updateStreamingRows,
|
|
23
|
+
} from './chatTurnRows.js'
|
|
21
24
|
|
|
22
25
|
type MutableRef<T> = { current: T }
|
|
23
26
|
|
|
@@ -473,106 +476,6 @@ async function handleEvent(ev: TurnEvent, ctx: EventHandlerContext): Promise<voi
|
|
|
473
476
|
}
|
|
474
477
|
}
|
|
475
478
|
|
|
476
|
-
function updateStreamingRows(
|
|
477
|
-
rows: MessageRow[],
|
|
478
|
-
assistantId: string | null,
|
|
479
|
-
thinkingRowId: string | null,
|
|
480
|
-
assistantText: string | null,
|
|
481
|
-
thinkingText: string | null,
|
|
482
|
-
): MessageRow[] {
|
|
483
|
-
let next: MessageRow[] | null = null
|
|
484
|
-
if (assistantId && assistantText !== null) {
|
|
485
|
-
const index = findRowIndexById(rows, assistantId)
|
|
486
|
-
const row = rows[index]
|
|
487
|
-
if (row?.role === 'assistant') {
|
|
488
|
-
next = next ?? rows.slice()
|
|
489
|
-
next[index] = { ...row, content: assistantText, liveTail: '' }
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
const source = next ?? rows
|
|
493
|
-
if (thinkingRowId && thinkingText !== null) {
|
|
494
|
-
const index = findRowIndexById(source, thinkingRowId)
|
|
495
|
-
const row = source[index]
|
|
496
|
-
if (row?.role === 'thinking') {
|
|
497
|
-
next = next ?? rows.slice()
|
|
498
|
-
next[index] = { ...row, content: thinkingText, liveTail: '' }
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return next ?? rows
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function finalizeStreamingRowsById(
|
|
505
|
-
rows: MessageRow[],
|
|
506
|
-
assistantId: string | null,
|
|
507
|
-
thinkingRowId: string | null,
|
|
508
|
-
assistantText: string,
|
|
509
|
-
thinkingText: string,
|
|
510
|
-
): MessageRow[] {
|
|
511
|
-
let next: MessageRow[] | null = null
|
|
512
|
-
if (assistantId) {
|
|
513
|
-
const index = findRowIndexById(rows, assistantId)
|
|
514
|
-
const row = rows[index]
|
|
515
|
-
if (row?.role === 'assistant') {
|
|
516
|
-
next = next ?? rows.slice()
|
|
517
|
-
next[index] = { ...row, content: assistantText || row.content, liveTail: undefined, streaming: false }
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
const source = next ?? rows
|
|
521
|
-
if (thinkingRowId) {
|
|
522
|
-
const index = findRowIndexById(source, thinkingRowId)
|
|
523
|
-
const row = source[index]
|
|
524
|
-
if (row?.role === 'thinking') {
|
|
525
|
-
next = next ?? rows.slice()
|
|
526
|
-
next[index] = { ...row, content: thinkingText || row.content, liveTail: undefined, streaming: false, showCursor: false }
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
return next ?? rows
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function findRowIndexById(rows: MessageRow[], id: string): number {
|
|
533
|
-
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
534
|
-
if (rows[index]?.id === id) return index
|
|
535
|
-
}
|
|
536
|
-
return -1
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
async function buildFileMentionContextMessages(
|
|
540
|
-
userText: string,
|
|
541
|
-
cwd: string,
|
|
542
|
-
): Promise<Message[]> {
|
|
543
|
-
const mentions = extractFileMentions(userText)
|
|
544
|
-
if (mentions.length === 0) return []
|
|
545
|
-
|
|
546
|
-
const lines: string[] = []
|
|
547
|
-
for (const mention of mentions) {
|
|
548
|
-
const resolved = path.resolve(cwd, mention)
|
|
549
|
-
const rel = path.relative(cwd, resolved)
|
|
550
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
551
|
-
lines.push(
|
|
552
|
-
`@${mention} -> outside current workspace; do not use unless the user changes directory or names an allowed path.`,
|
|
553
|
-
)
|
|
554
|
-
continue
|
|
555
|
-
}
|
|
556
|
-
try {
|
|
557
|
-
const stats = await fs.stat(resolved)
|
|
558
|
-
lines.push(`@${mention} -> ${mention} (${stats.isDirectory() ? 'directory' : 'file'})`)
|
|
559
|
-
} catch {
|
|
560
|
-
lines.push(`@${mention} -> unresolved`)
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
return [
|
|
565
|
-
{
|
|
566
|
-
role: 'user',
|
|
567
|
-
content: [
|
|
568
|
-
'Resolved file mentions for this request:',
|
|
569
|
-
...lines,
|
|
570
|
-
'Treat these mentions as authoritative filenames from the user request. Read referenced context files when needed, and edit only the file requested by the user or the target file you have inspected.',
|
|
571
|
-
].join('\n'),
|
|
572
|
-
},
|
|
573
|
-
]
|
|
574
|
-
}
|
|
575
|
-
|
|
576
479
|
const PRIVATE_SKILLS_INDEX_BUDGET = 2048
|
|
577
480
|
|
|
578
481
|
export async function buildSkillsIndexMessage(
|
|
@@ -664,16 +567,6 @@ export async function buildIdentityContinuityContextMessages(
|
|
|
664
567
|
}
|
|
665
568
|
}
|
|
666
569
|
|
|
667
|
-
function extractFileMentions(text: string): string[] {
|
|
668
|
-
const mentions = new Set<string>()
|
|
669
|
-
for (const match of text.matchAll(/@([^\s]+)/g)) {
|
|
670
|
-
const raw = match[1]?.replace(/[),.;:!?]+$/g, '')
|
|
671
|
-
if (!raw || raw.length === 0) continue
|
|
672
|
-
mentions.add(raw.replace(/\\/g, '/'))
|
|
673
|
-
}
|
|
674
|
-
return [...mentions]
|
|
675
|
-
}
|
|
676
|
-
|
|
677
570
|
function buildWorkingMessages(
|
|
678
571
|
context: Pick<
|
|
679
572
|
TurnOrchestratorContext,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MessageRow } from './MessageList.js'
|
|
2
|
+
|
|
3
|
+
export function updateStreamingRows(
|
|
4
|
+
rows: MessageRow[],
|
|
5
|
+
assistantId: string | null,
|
|
6
|
+
thinkingRowId: string | null,
|
|
7
|
+
assistantText: string | null,
|
|
8
|
+
thinkingText: string | null,
|
|
9
|
+
): MessageRow[] {
|
|
10
|
+
let next: MessageRow[] | null = null
|
|
11
|
+
if (assistantId && assistantText !== null) {
|
|
12
|
+
const index = findRowIndexById(rows, assistantId)
|
|
13
|
+
const row = rows[index]
|
|
14
|
+
if (row?.role === 'assistant') {
|
|
15
|
+
next = next ?? rows.slice()
|
|
16
|
+
next[index] = { ...row, content: assistantText, liveTail: '' }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const source = next ?? rows
|
|
20
|
+
if (thinkingRowId && thinkingText !== null) {
|
|
21
|
+
const index = findRowIndexById(source, thinkingRowId)
|
|
22
|
+
const row = source[index]
|
|
23
|
+
if (row?.role === 'thinking') {
|
|
24
|
+
next = next ?? rows.slice()
|
|
25
|
+
next[index] = { ...row, content: thinkingText, liveTail: '' }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return next ?? rows
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function finalizeStreamingRowsById(
|
|
32
|
+
rows: MessageRow[],
|
|
33
|
+
assistantId: string | null,
|
|
34
|
+
thinkingRowId: string | null,
|
|
35
|
+
assistantText: string,
|
|
36
|
+
thinkingText: string,
|
|
37
|
+
): MessageRow[] {
|
|
38
|
+
let next: MessageRow[] | null = null
|
|
39
|
+
if (assistantId) {
|
|
40
|
+
const index = findRowIndexById(rows, assistantId)
|
|
41
|
+
const row = rows[index]
|
|
42
|
+
if (row?.role === 'assistant') {
|
|
43
|
+
next = next ?? rows.slice()
|
|
44
|
+
next[index] = { ...row, content: assistantText || row.content, liveTail: undefined, streaming: false }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const source = next ?? rows
|
|
48
|
+
if (thinkingRowId) {
|
|
49
|
+
const index = findRowIndexById(source, thinkingRowId)
|
|
50
|
+
const row = source[index]
|
|
51
|
+
if (row?.role === 'thinking') {
|
|
52
|
+
next = next ?? rows.slice()
|
|
53
|
+
next[index] = { ...row, content: thinkingText || row.content, liveTail: undefined, streaming: false, showCursor: false }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return next ?? rows
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findRowIndexById(rows: MessageRow[], id: string): number {
|
|
60
|
+
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
61
|
+
if (rows[index]?.id === id) return index
|
|
62
|
+
}
|
|
63
|
+
return -1
|
|
64
|
+
}
|
package/src/chat/commands.ts
CHANGED
|
@@ -3,10 +3,7 @@ import { getConfigPath, localProviderBaseUrlFor, saveConfig } from '../storage/c
|
|
|
3
3
|
import { detectLlamaCpp } from '../models/llamacpp.js'
|
|
4
4
|
import { detectSpec } from '../models/runtimeDetection.js'
|
|
5
5
|
import { hasKey } from '../storage/secrets.js'
|
|
6
|
-
import {
|
|
7
|
-
clearIdentity,
|
|
8
|
-
getIdentityStatus,
|
|
9
|
-
} from '../storage/identity.js'
|
|
6
|
+
import { getIdentityStatus } from '../storage/identity.js'
|
|
10
7
|
import { discoverProviderModels, type ModelCatalogResult } from '../models/catalog.js'
|
|
11
8
|
import { getLocalHfCacheDir, loadLocalHfModels } from '../models/huggingface.js'
|
|
12
9
|
import { copyToClipboard } from '../utils/clipboard.js'
|
|
@@ -21,6 +18,8 @@ import type { ContextUsage } from '../runtime/compaction.js'
|
|
|
21
18
|
import { formatModelDisplayName } from '../models/modelDisplay.js'
|
|
22
19
|
import { providerDisplayName } from '../models/modelPickerOptions.js'
|
|
23
20
|
import type { McpManager } from '../mcp/manager.js'
|
|
21
|
+
import { runHuggingFace, runIdentity, runMcp } from './slashCommandHandlers.js'
|
|
22
|
+
import { renderStatus } from './slashCommandViews.js'
|
|
24
23
|
|
|
25
24
|
export type IdentityRequestAction =
|
|
26
25
|
| 'manage'
|
|
@@ -355,161 +354,6 @@ const COMMANDS: CommandSpec[] = [
|
|
|
355
354
|
},
|
|
356
355
|
]
|
|
357
356
|
|
|
358
|
-
async function runHuggingFace(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
359
|
-
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
360
|
-
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
361
|
-
|
|
362
|
-
if (!sub || sub === 'installed') {
|
|
363
|
-
const installed = await loadLocalHfModels()
|
|
364
|
-
if (installed.length === 0) {
|
|
365
|
-
return {
|
|
366
|
-
kind: 'note',
|
|
367
|
-
variant: 'dim',
|
|
368
|
-
text: 'No local model files downloaded. Press Alt+P and choose "Add Local Model File".',
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
const lines = installed.map(model => {
|
|
372
|
-
const marker = model.id === ctx.config.model && ctx.config.provider === 'llamacpp' ? '*' : ' '
|
|
373
|
-
const displayName = formatModelDisplayName('llamacpp', model.id, { displayName: model.displayName, maxLength: 64 })
|
|
374
|
-
return `${marker} ${displayName} ${formatBytes(model.sizeBytes)} ${model.risk}`
|
|
375
|
-
})
|
|
376
|
-
return { kind: 'note', text: ['installed Hugging Face models:', ...lines].join('\n') }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (sub === 'download' || sub === 'model') {
|
|
380
|
-
const link = tokens.slice(1).join(' ')
|
|
381
|
-
ctx.onModelPickerRequest()
|
|
382
|
-
return {
|
|
383
|
-
kind: 'note',
|
|
384
|
-
variant: 'dim',
|
|
385
|
-
text: link
|
|
386
|
-
? `Alt+P opened. Choose "Add Local Model File" and paste: ${link}`
|
|
387
|
-
: 'Alt+P opened. Choose "Add Local Model File" and paste the model URL or repo ID.',
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
return {
|
|
392
|
-
kind: 'note',
|
|
393
|
-
variant: 'error',
|
|
394
|
-
text: 'usage: /hf [installed|download <huggingface.co link or repo id>]',
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function runMcp(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
399
|
-
if (!ctx.mcp) {
|
|
400
|
-
return { kind: 'note', variant: 'error', text: 'MCP runtime is not available in this session.' }
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
404
|
-
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
405
|
-
if (!sub || sub === 'status' || sub === 'list') {
|
|
406
|
-
return { kind: 'note', text: ctx.mcp.renderStatus(), variant: 'info' }
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
try {
|
|
410
|
-
if (sub === 'approve') {
|
|
411
|
-
const name = tokens.slice(1).join(' ')
|
|
412
|
-
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp approve <server>' }
|
|
413
|
-
return { kind: 'note', text: await ctx.mcp.approveServer(name), variant: 'dim' }
|
|
414
|
-
}
|
|
415
|
-
if (sub === 'reject') {
|
|
416
|
-
const name = tokens.slice(1).join(' ')
|
|
417
|
-
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp reject <server>' }
|
|
418
|
-
return { kind: 'note', text: await ctx.mcp.rejectServer(name), variant: 'dim' }
|
|
419
|
-
}
|
|
420
|
-
if (sub === 'reconnect') {
|
|
421
|
-
const name = tokens.slice(1).join(' ') || undefined
|
|
422
|
-
return { kind: 'note', text: await ctx.mcp.reconnect(name), variant: 'dim' }
|
|
423
|
-
}
|
|
424
|
-
if (sub === 'enable' || sub === 'disable') {
|
|
425
|
-
const name = tokens.slice(1).join(' ')
|
|
426
|
-
if (!name) return { kind: 'note', variant: 'error', text: `usage: /mcp ${sub} <server>` }
|
|
427
|
-
return { kind: 'note', text: await ctx.mcp.setEnabled(name, sub === 'enable'), variant: 'dim' }
|
|
428
|
-
}
|
|
429
|
-
if (sub === 'add-json') {
|
|
430
|
-
const project = tokens[1] === '--project'
|
|
431
|
-
const nameIndex = project ? 2 : 1
|
|
432
|
-
const name = tokens[nameIndex]
|
|
433
|
-
if (!name) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
434
|
-
const jsonStart = nthTokenStart(args, nameIndex + 1)
|
|
435
|
-
const json = jsonStart >= 0 ? args.slice(jsonStart).trim() : ''
|
|
436
|
-
if (!json) return { kind: 'note', variant: 'error', text: 'usage: /mcp add-json [--project] <name> <json>' }
|
|
437
|
-
return { kind: 'note', text: await ctx.mcp.addJson(name, json, project ? 'project' : 'user'), variant: 'dim' }
|
|
438
|
-
}
|
|
439
|
-
} catch (err: unknown) {
|
|
440
|
-
return { kind: 'note', variant: 'error', text: `MCP failed: ${(err as Error).message}` }
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return {
|
|
444
|
-
kind: 'note',
|
|
445
|
-
variant: 'error',
|
|
446
|
-
text: 'usage: /mcp [status|approve <server>|reject <server>|reconnect [server]|enable <server>|disable <server>|add-json [--project] <name> <json>]',
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
async function runIdentity(args: string, ctx: SlashContext): Promise<SlashResult> {
|
|
451
|
-
const tokens = args.trim().split(/\s+/).filter(Boolean)
|
|
452
|
-
const sub = tokens[0]?.toLowerCase() ?? ''
|
|
453
|
-
const rest = tokens.slice(1)
|
|
454
|
-
|
|
455
|
-
if (!sub) {
|
|
456
|
-
ctx.onIdentityRequest('manage')
|
|
457
|
-
return { kind: 'handled' }
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
if (sub === 'status') {
|
|
461
|
-
const status = await getIdentityStatus(ctx.config)
|
|
462
|
-
if (!status) {
|
|
463
|
-
return {
|
|
464
|
-
kind: 'note',
|
|
465
|
-
variant: 'dim',
|
|
466
|
-
text: 'No Ethereum identity set. Run /identity create to make one.',
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
const lines = [
|
|
470
|
-
`address ${status.address}`,
|
|
471
|
-
`created ${status.createdAt}`,
|
|
472
|
-
`backend ${status.backend}`,
|
|
473
|
-
]
|
|
474
|
-
if (status.source) lines.push(`source ${status.source}`)
|
|
475
|
-
if (status.agentId) lines.push(`token #${status.agentId}`)
|
|
476
|
-
return { kind: 'note', text: lines.join('\n') }
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (sub === 'create') {
|
|
480
|
-
ctx.onIdentityRequest('create')
|
|
481
|
-
return { kind: 'handled' }
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (sub === 'load') {
|
|
485
|
-
ctx.onIdentityRequest('load')
|
|
486
|
-
return { kind: 'handled' }
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (sub === 'remove') {
|
|
490
|
-
if (rest[0] !== 'confirm') {
|
|
491
|
-
return {
|
|
492
|
-
kind: 'note',
|
|
493
|
-
variant: 'error',
|
|
494
|
-
text: 'Remove deletes local identity metadata and any legacy stored key. Re-run with: /identity remove confirm',
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const status = await getIdentityStatus(ctx.config)
|
|
498
|
-
if (!status) {
|
|
499
|
-
return { kind: 'note', variant: 'dim', text: 'No Ethereum identity to remove.' }
|
|
500
|
-
}
|
|
501
|
-
const next = await clearIdentity(ctx.config)
|
|
502
|
-
ctx.onReplaceConfig(next)
|
|
503
|
-
return { kind: 'note', text: `Removed identity ${status.address}.`, variant: 'dim' }
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return {
|
|
507
|
-
kind: 'note',
|
|
508
|
-
variant: 'error',
|
|
509
|
-
text: 'usage: /identity [status|create|load|remove confirm]',
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
357
|
function renderHelp(): string {
|
|
514
358
|
const visibleCommands = COMMANDS.filter(c => !c.hidden)
|
|
515
359
|
const maxName = Math.max(...visibleCommands.map(c => commandLabel(c).length))
|
|
@@ -530,25 +374,6 @@ function commandLabel(cmd: CommandSpec): string {
|
|
|
530
374
|
return `/${cmd.name} (${cmd.aliases.map(a => `/${a}`).join(', ')})`
|
|
531
375
|
}
|
|
532
376
|
|
|
533
|
-
function renderStatus(ctx: SlashContext): string {
|
|
534
|
-
const elapsedMs = Date.now() - ctx.startedAt
|
|
535
|
-
const minutes = Math.floor(elapsedMs / 60000)
|
|
536
|
-
const seconds = Math.floor((elapsedMs % 60000) / 1000)
|
|
537
|
-
const elapsed = minutes > 0 ? `${minutes}m${seconds.toString().padStart(2, '0')}s` : `${seconds}s`
|
|
538
|
-
const displayModel = formatModelDisplayName(ctx.config.provider, ctx.config.model, { maxLength: 72 })
|
|
539
|
-
return [
|
|
540
|
-
`provider ${providerDisplayName(ctx.config.provider)}`,
|
|
541
|
-
`model ${displayModel}`,
|
|
542
|
-
`cwd ${ctx.cwd}`,
|
|
543
|
-
`session ${ctx.sessionId.slice(0, 8)}`,
|
|
544
|
-
'state active',
|
|
545
|
-
`turns ${ctx.turns}`,
|
|
546
|
-
`tokens ~${ctx.approxTokens}`,
|
|
547
|
-
`context ${ctx.contextUsage.percent}% (~${ctx.contextUsage.usedTokens}/${ctx.contextUsage.windowTokens}, ${ctx.contextUsage.source})`,
|
|
548
|
-
`elapsed ${elapsed}`,
|
|
549
|
-
].join('\n')
|
|
550
|
-
}
|
|
551
|
-
|
|
552
377
|
function renderContext(ctx: SlashContext): string {
|
|
553
378
|
const usage = ctx.contextUsage
|
|
554
379
|
const free = Math.max(0, usage.windowTokens - usage.usedTokens)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { splitFileChangeResult } from '../tools/fileDiff.js'
|
|
2
|
+
import type { ContinuityEditReviewState } from './views/ContinuityEditReviewView.js'
|
|
3
|
+
|
|
4
|
+
export function privateContinuityEditReviewFromToolResult(
|
|
5
|
+
name: string,
|
|
6
|
+
input: Record<string, unknown>,
|
|
7
|
+
result: { ok: boolean; summary: string; content: string },
|
|
8
|
+
): ContinuityEditReviewState | null {
|
|
9
|
+
if (name !== 'propose_private_continuity_edit' || !result.ok) return null
|
|
10
|
+
const file = normalizePrivateContinuityFile(input.file)
|
|
11
|
+
if (!file) return null
|
|
12
|
+
const parsed = splitFileChangeResult(result.content)
|
|
13
|
+
const filePath = extractReviewFilePath(parsed.content)
|
|
14
|
+
if (!filePath) return null
|
|
15
|
+
return {
|
|
16
|
+
file,
|
|
17
|
+
filePath,
|
|
18
|
+
summary: result.summary,
|
|
19
|
+
...(parsed.diff ? { diff: parsed.diff } : {}),
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizePrivateContinuityFile(value: unknown): ContinuityEditReviewState['file'] | null {
|
|
24
|
+
if (typeof value !== 'string') return null
|
|
25
|
+
if (/^soul\.md$/i.test(value.trim())) return 'SOUL.md'
|
|
26
|
+
if (/^memory\.md$/i.test(value.trim())) return 'MEMORY.md'
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractReviewFilePath(content: string): string | null {
|
|
31
|
+
for (const line of content.split(/\r?\n/)) {
|
|
32
|
+
const review = line.match(/^(?:[-*]\s+)?review file:\s*(.+)$/i)
|
|
33
|
+
if (review?.[1]?.trim()) return cleanReviewFilePath(review[1])
|
|
34
|
+
const updated = line.match(/^(?:[-*]\s+)?updated local private continuity file\s+(.+)$/i)
|
|
35
|
+
if (updated?.[1]?.trim()) return cleanReviewFilePath(updated[1])
|
|
36
|
+
}
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanReviewFilePath(value: string): string {
|
|
41
|
+
return value.trim().replace(/^`+|`+$/g, '').trim()
|
|
42
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
2
|
import { Box, Text, useStdout } from 'ink'
|
|
5
3
|
import { theme } from '../../ui/theme.js'
|
|
@@ -19,11 +17,6 @@ import {
|
|
|
19
17
|
type ChatBuffer,
|
|
20
18
|
type FileMentionToken,
|
|
21
19
|
} from './chatInputState.js'
|
|
22
|
-
import {
|
|
23
|
-
getVisibleVisualLineWindow,
|
|
24
|
-
getVisualLineIndex,
|
|
25
|
-
getVisualLines,
|
|
26
|
-
} from './textCursor.js'
|
|
27
20
|
import {
|
|
28
21
|
countPastedTextLineBreaks,
|
|
29
22
|
expandPastedTextRefs,
|
|
@@ -39,6 +32,16 @@ import {
|
|
|
39
32
|
pruneImageRefs,
|
|
40
33
|
type ImageRef,
|
|
41
34
|
} from './imageRefs.js'
|
|
35
|
+
import { inputWrapWidth, renderWithCursor } from './inputRendering.js'
|
|
36
|
+
import {
|
|
37
|
+
isFallbackPasteInput,
|
|
38
|
+
isSoftBreak,
|
|
39
|
+
listFileMentionSuggestions,
|
|
40
|
+
summarizeQueuedMessage,
|
|
41
|
+
type FileMentionSuggestion,
|
|
42
|
+
} from './chatInputHelpers.js'
|
|
43
|
+
|
|
44
|
+
export { inputWrapWidth, renderWithCursor } from './inputRendering.js'
|
|
42
45
|
|
|
43
46
|
type PromptInputProps = {
|
|
44
47
|
onSubmit: (value: string) => void
|
|
@@ -64,11 +67,6 @@ const PASTE_FLUSH_LIMIT = 4096
|
|
|
64
67
|
const MIN_INPUT_VIEWPORT_LINES = 3
|
|
65
68
|
const PROMPT_FOOTER_LINES = 5
|
|
66
69
|
const MAX_INLINE_PASTE_LINES = 2
|
|
67
|
-
const STACK_HORIZONTAL_PADDING = 2
|
|
68
|
-
const INPUT_BORDER_WIDTH = 2
|
|
69
|
-
const INPUT_HORIZONTAL_PADDING = 4
|
|
70
|
-
const PROMPT_PREFIX_WIDTH = 2
|
|
71
|
-
|
|
72
70
|
export const ChatInput: React.FC<PromptInputProps> = ({
|
|
73
71
|
onSubmit,
|
|
74
72
|
history,
|
|
@@ -627,135 +625,3 @@ export const ChatInput: React.FC<PromptInputProps> = ({
|
|
|
627
625
|
</Box>
|
|
628
626
|
)
|
|
629
627
|
}
|
|
630
|
-
|
|
631
|
-
function isSoftBreak(key: { return: boolean; meta?: boolean; shift?: boolean }): boolean {
|
|
632
|
-
return key.return && Boolean(key.meta || key.shift)
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
type RenderedVisualLine = {
|
|
636
|
-
visualLineIndex: number
|
|
637
|
-
node: React.ReactNode
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
type RenderedInputViewport = {
|
|
641
|
-
lines: RenderedVisualLine[]
|
|
642
|
-
hiddenAbove: number
|
|
643
|
-
hiddenBelow: number
|
|
644
|
-
visibleLineCount: number
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
export function renderWithCursor(
|
|
648
|
-
value: string,
|
|
649
|
-
cursor: number,
|
|
650
|
-
showCursor: boolean,
|
|
651
|
-
wrapWidth: number,
|
|
652
|
-
maxVisibleLines: number,
|
|
653
|
-
): RenderedInputViewport {
|
|
654
|
-
const lines = getVisualLines(value, wrapWidth)
|
|
655
|
-
const cursorLine = getVisualLineIndex(lines, cursor)
|
|
656
|
-
const window = getVisibleVisualLineWindow(lines.length, cursorLine, maxVisibleLines)
|
|
657
|
-
const visibleLines = lines.slice(window.start, window.end)
|
|
658
|
-
|
|
659
|
-
if (!showCursor) {
|
|
660
|
-
return {
|
|
661
|
-
lines: visibleLines.map((line, i) => ({
|
|
662
|
-
visualLineIndex: window.start + i,
|
|
663
|
-
node: (
|
|
664
|
-
<Text color={theme.text} wrap="wrap">
|
|
665
|
-
{value.slice(line.start, line.end) || ' '}
|
|
666
|
-
</Text>
|
|
667
|
-
),
|
|
668
|
-
})),
|
|
669
|
-
hiddenAbove: window.start,
|
|
670
|
-
hiddenBelow: lines.length - window.end,
|
|
671
|
-
visibleLineCount: Math.max(1, visibleLines.length),
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
return {
|
|
676
|
-
lines: visibleLines.map((line, i) => {
|
|
677
|
-
const visualLineIndex = window.start + i
|
|
678
|
-
const text = value.slice(line.start, line.end)
|
|
679
|
-
if (visualLineIndex !== cursorLine) {
|
|
680
|
-
return {
|
|
681
|
-
visualLineIndex,
|
|
682
|
-
node: <Text color={theme.text} wrap="wrap">{text || ' '}</Text>,
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
const column = Math.max(0, Math.min(cursor - line.start, text.length))
|
|
686
|
-
const before = text.slice(0, column)
|
|
687
|
-
const atChar = text[column] ?? ' '
|
|
688
|
-
const after = text.slice(column + 1)
|
|
689
|
-
return {
|
|
690
|
-
visualLineIndex,
|
|
691
|
-
node: (
|
|
692
|
-
<Text color={theme.text} wrap="wrap">
|
|
693
|
-
{before}
|
|
694
|
-
<Text backgroundColor={theme.accentPeriwinkle} color="#0c0c1f">{atChar}</Text>
|
|
695
|
-
{after}
|
|
696
|
-
</Text>
|
|
697
|
-
),
|
|
698
|
-
}
|
|
699
|
-
}),
|
|
700
|
-
hiddenAbove: window.start,
|
|
701
|
-
hiddenBelow: lines.length - window.end,
|
|
702
|
-
visibleLineCount: Math.max(1, visibleLines.length),
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
export function inputWrapWidth(columns: number): number {
|
|
707
|
-
const fixedChromeWidth =
|
|
708
|
-
STACK_HORIZONTAL_PADDING
|
|
709
|
-
+ INPUT_BORDER_WIDTH
|
|
710
|
-
+ INPUT_HORIZONTAL_PADDING
|
|
711
|
-
+ PROMPT_PREFIX_WIDTH
|
|
712
|
-
return Math.max(1, Math.floor(columns) - fixedChromeWidth)
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
function isFallbackPasteInput(input: string): boolean {
|
|
716
|
-
if (!input) return false
|
|
717
|
-
return input.length > LARGE_PASTE_THRESHOLD
|
|
718
|
-
|| countPastedTextLineBreaks(normalizePastedText(input)) > MAX_INLINE_PASTE_LINES
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
function summarizeQueuedMessage(text: string): string {
|
|
722
|
-
const normalized = text.replace(/\s+/g, ' ').trim()
|
|
723
|
-
if (!normalized) return ''
|
|
724
|
-
if (normalized.length <= 72) return normalized
|
|
725
|
-
return `${normalized.slice(0, 69)}...`
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
type FileMentionSuggestion = {
|
|
729
|
-
path: string
|
|
730
|
-
hint: string
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
async function listFileMentionSuggestions(
|
|
734
|
-
cwd: string,
|
|
735
|
-
mention: FileMentionToken,
|
|
736
|
-
): Promise<FileMentionSuggestion[]> {
|
|
737
|
-
const query = mention.query.replace(/\\/g, '/')
|
|
738
|
-
const lastSlash = query.lastIndexOf('/')
|
|
739
|
-
const queryDir = lastSlash >= 0 ? query.slice(0, lastSlash + 1) : ''
|
|
740
|
-
const basenameQuery = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : query.toLowerCase()
|
|
741
|
-
const baseDir = path.resolve(cwd, queryDir || '.')
|
|
742
|
-
|
|
743
|
-
let entries: Array<{ name: string; isFile: () => boolean }>
|
|
744
|
-
try {
|
|
745
|
-
entries = await fs.readdir(baseDir, { withFileTypes: true })
|
|
746
|
-
} catch {
|
|
747
|
-
return []
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
return entries
|
|
751
|
-
.filter(entry => entry.isFile() && entry.name.toLowerCase().startsWith(basenameQuery))
|
|
752
|
-
.sort((left, right) => left.name.localeCompare(right.name))
|
|
753
|
-
.slice(0, 32)
|
|
754
|
-
.map(entry => {
|
|
755
|
-
const relative = (queryDir + entry.name).replace(/\\/g, '/')
|
|
756
|
-
return {
|
|
757
|
-
path: relative,
|
|
758
|
-
hint: path.extname(entry.name).slice(1) || 'file',
|
|
759
|
-
}
|
|
760
|
-
})
|
|
761
|
-
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
countPastedTextLineBreaks,
|
|
5
|
+
LARGE_PASTE_THRESHOLD,
|
|
6
|
+
normalizePastedText,
|
|
7
|
+
} from './chatPaste.js'
|
|
8
|
+
import type { FileMentionToken } from './chatInputState.js'
|
|
9
|
+
|
|
10
|
+
const MAX_INLINE_PASTE_LINES = 2
|
|
11
|
+
|
|
12
|
+
export function isSoftBreak(key: { return: boolean; meta?: boolean; shift?: boolean }): boolean {
|
|
13
|
+
return key.return && Boolean(key.meta || key.shift)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isFallbackPasteInput(input: string): boolean {
|
|
17
|
+
if (!input) return false
|
|
18
|
+
return input.length > LARGE_PASTE_THRESHOLD
|
|
19
|
+
|| countPastedTextLineBreaks(normalizePastedText(input)) > MAX_INLINE_PASTE_LINES
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function summarizeQueuedMessage(text: string): string {
|
|
23
|
+
const normalized = text.replace(/\s+/g, ' ').trim()
|
|
24
|
+
if (!normalized) return ''
|
|
25
|
+
if (normalized.length <= 72) return normalized
|
|
26
|
+
return `${normalized.slice(0, 69)}...`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type FileMentionSuggestion = {
|
|
30
|
+
path: string
|
|
31
|
+
hint: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function listFileMentionSuggestions(
|
|
35
|
+
cwd: string,
|
|
36
|
+
mention: FileMentionToken,
|
|
37
|
+
): Promise<FileMentionSuggestion[]> {
|
|
38
|
+
const query = mention.query.replace(/\\/g, '/')
|
|
39
|
+
const lastSlash = query.lastIndexOf('/')
|
|
40
|
+
const queryDir = lastSlash >= 0 ? query.slice(0, lastSlash + 1) : ''
|
|
41
|
+
const basenameQuery = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : query.toLowerCase()
|
|
42
|
+
const baseDir = path.resolve(cwd, queryDir || '.')
|
|
43
|
+
|
|
44
|
+
let entries: Array<{ name: string; isFile: () => boolean }>
|
|
45
|
+
try {
|
|
46
|
+
entries = await fs.readdir(baseDir, { withFileTypes: true })
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return entries
|
|
52
|
+
.filter(entry => entry.isFile() && entry.name.toLowerCase().startsWith(basenameQuery))
|
|
53
|
+
.sort((left, right) => left.name.localeCompare(right.name))
|
|
54
|
+
.slice(0, 32)
|
|
55
|
+
.map(entry => {
|
|
56
|
+
const relative = (queryDir + entry.name).replace(/\\/g, '/')
|
|
57
|
+
return {
|
|
58
|
+
path: relative,
|
|
59
|
+
hint: path.extname(entry.name).slice(1) || 'file',
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
}
|