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.
Files changed (69) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +15 -116
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/identity/continuity/challenges.ts +123 -0
  23. package/src/identity/continuity/envelope.ts +49 -1484
  24. package/src/identity/continuity/envelopeCreate.ts +322 -0
  25. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  26. package/src/identity/continuity/envelopeParse.ts +441 -0
  27. package/src/identity/continuity/envelopeTypes.ts +204 -0
  28. package/src/identity/continuity/envelopeVersion.ts +1 -0
  29. package/src/identity/continuity/payloadNormalization.ts +183 -0
  30. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  31. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  32. package/src/identity/continuity/skillsNormalization.ts +119 -0
  33. package/src/identity/continuity/snapshotToken.ts +28 -0
  34. package/src/identity/hub/continuity/completion.ts +67 -0
  35. package/src/identity/hub/continuity/effects.ts +5 -62
  36. package/src/identity/hub/profile/effects.ts +6 -170
  37. package/src/identity/hub/profile/operatorSave.ts +202 -0
  38. package/src/identity/wallet/browserWallet/html.ts +1 -57
  39. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  40. package/src/identity/wallet/page/controller.ts +1 -1
  41. package/src/identity/wallet/page/errorView.ts +122 -0
  42. package/src/identity/wallet/page/view.ts +3 -114
  43. package/src/mcp/manager.ts +8 -66
  44. package/src/mcp/managerHelpers.ts +70 -0
  45. package/src/models/ModelPicker.tsx +69 -889
  46. package/src/models/huggingface.ts +20 -137
  47. package/src/models/huggingfaceStorage.ts +136 -0
  48. package/src/models/llamacpp.ts +37 -303
  49. package/src/models/llamacppCommands.ts +44 -0
  50. package/src/models/llamacppConfig.ts +34 -0
  51. package/src/models/llamacppDiscovery.ts +176 -0
  52. package/src/models/llamacppOutput.ts +65 -0
  53. package/src/models/modelPickerCatalogFlow.ts +56 -0
  54. package/src/models/modelPickerCredentials.ts +166 -0
  55. package/src/models/modelPickerData.ts +41 -0
  56. package/src/models/modelPickerDisplay.tsx +132 -0
  57. package/src/models/modelPickerHfFlow.ts +192 -0
  58. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  59. package/src/models/modelPickerTypes.ts +69 -0
  60. package/src/models/modelPickerUninstallFlow.ts +48 -0
  61. package/src/models/modelPickerViewHelpers.ts +174 -0
  62. package/src/providers/openai-chat.ts +5 -124
  63. package/src/providers/openaiChatWire.ts +124 -0
  64. package/src/runtime/providerTurn.ts +38 -0
  65. package/src/runtime/textToolParser.ts +161 -0
  66. package/src/runtime/toolIntent.ts +1 -1
  67. package/src/runtime/turn.ts +43 -499
  68. package/src/runtime/turnNudges.ts +223 -0
  69. 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
+ }
@@ -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
+ }