@miphamai/cli 0.27.0 → 0.28.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.
@@ -0,0 +1,546 @@
1
+ /**
2
+ * CRSI Phase 3: Security Sandbox for Controlled Code Self-Modification
3
+ *
4
+ * Provides a safe execution environment where AI-generated code changes
5
+ * are:
6
+ * 1. Applied in an isolated git worktree (no effect on working tree)
7
+ * 2. Validated against the full test suite
8
+ * 3. Auto-rolled back on failure
9
+ * 4. Gated by human approval before merging
10
+ *
11
+ * Architecture:
12
+ * AI proposes change → CrsiSandbox applies in worktree → runs tests →
13
+ * if pass → presents diff for human approval → if approved → merges
14
+ * if fail → auto-discards worktree → logs failure for CRSI learning
15
+ */
16
+
17
+ import { execSync } from 'node:child_process'
18
+ import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
19
+ import { join, resolve } from 'node:path'
20
+ import { tmpdir, homedir } from 'node:os'
21
+ import { randomUUID } from 'node:crypto'
22
+
23
+ // ── Types ──
24
+
25
+ export interface CrsiModification {
26
+ /** Unique ID for this modification attempt */
27
+ id: string
28
+ /** Human-readable description of what this change does */
29
+ description: string
30
+ /** The file path (relative to repo root) to modify */
31
+ filePath: string
32
+ /** The file content after modification */
33
+ newContent: string
34
+ /** The original file content (for rollback verification) */
35
+ originalContent: string
36
+ /** CRSI insight that triggered this modification */
37
+ crsiInsightId?: string
38
+ /** CRSI rule that generated this modification */
39
+ crsiRuleId?: string
40
+ /** Timestamp */
41
+ timestamp: string
42
+ }
43
+
44
+ export interface CrsiModificationResult {
45
+ modification: CrsiModification
46
+ /** Whether the modification was applied successfully */
47
+ applied: boolean
48
+ /** Test results after applying the modification */
49
+ testResult?: CrsiTestResult
50
+ /** Git diff of the change */
51
+ diff?: string
52
+ /** Error message if something failed */
53
+ error?: string
54
+ /** Current phase of the sandbox pipeline */
55
+ phase: 'pending' | 'applied' | 'testing' | 'passed' | 'failed' | 'approved' | 'merged' | 'rolled-back'
56
+ }
57
+
58
+ export interface CrsiTestResult {
59
+ /** Whether all tests passed */
60
+ passed: boolean
61
+ /** Total test count */
62
+ totalTests: number
63
+ /** Failed test count */
64
+ failedTests: number
65
+ /** Test output (truncated) */
66
+ output: string
67
+ }
68
+
69
+ export interface CrsiSessionReport {
70
+ sessionId: string
71
+ modifications: CrsiModificationResult[]
72
+ summary: {
73
+ total: number
74
+ applied: number
75
+ passed: number
76
+ merged: number
77
+ rolledBack: number
78
+ }
79
+ startedAt: string
80
+ completedAt?: string
81
+ }
82
+
83
+ // ── Constants ──
84
+
85
+ const WORKTREE_PREFIX = 'crsi-sandbox-'
86
+ const MAX_WORKTREE_AGE_MS = 30 * 60 * 1000 // 30 minutes
87
+ const TEST_TIMEOUT_MS = 120_000 // 2 minutes
88
+ const REPORT_DIR = join(homedir(), '.mipham', 'crsi-sandbox')
89
+
90
+ // ── Sandbox ──
91
+
92
+ export class CrsiSandbox {
93
+ private repoRoot: string
94
+ private worktreePath?: string
95
+ private worktreeBranch?: string
96
+ private sessionReport: CrsiSessionReport
97
+ private currentModification?: CrsiModification
98
+
99
+ constructor(repoRoot: string = process.cwd()) {
100
+ this.repoRoot = resolve(repoRoot)
101
+ this.sessionReport = {
102
+ sessionId: `crsi-session-${Date.now().toString(36)}`,
103
+ modifications: [],
104
+ summary: { total: 0, applied: 0, passed: 0, merged: 0, rolledBack: 0 },
105
+ startedAt: new Date().toISOString(),
106
+ }
107
+
108
+ // Ensure report directory exists
109
+ mkdirSync(REPORT_DIR, { recursive: true })
110
+ }
111
+
112
+ // ── Public API ──
113
+
114
+ /**
115
+ * Stage 1: Create an isolated git worktree for safe modification.
116
+ *
117
+ * Creates a temporary worktree on a new branch. All subsequent
118
+ * modifications happen in this isolated environment — the user's
119
+ * working tree is never touched.
120
+ */
121
+ createWorktree(): { worktreePath: string; branch: string } {
122
+ // Clean up any stale worktrees from previous crashed sessions
123
+ this.cleanupStaleWorktrees()
124
+
125
+ const branchName = `${WORKTREE_PREFIX}${randomUUID().slice(0, 8)}`
126
+ const worktreeDir = join(tmpdir(), branchName)
127
+
128
+ // Ensure the dir doesn't already exist
129
+ if (existsSync(worktreeDir)) {
130
+ rmSync(worktreeDir, { recursive: true, force: true })
131
+ }
132
+
133
+ try {
134
+ execSync(`git worktree add -b "${branchName}" "${worktreeDir}" HEAD`, {
135
+ cwd: this.repoRoot,
136
+ timeout: 30_000,
137
+ encoding: 'utf-8',
138
+ })
139
+ } catch (err) {
140
+ throw new Error(`Failed to create CRSI worktree: ${String(err)}`)
141
+ }
142
+
143
+ this.worktreePath = worktreeDir
144
+ this.worktreeBranch = branchName
145
+
146
+ return { worktreePath: worktreeDir, branch: branchName }
147
+ }
148
+
149
+ /**
150
+ * Stage 2: Apply a modification in the sandbox worktree.
151
+ *
152
+ * Writes the new content to the target file inside the worktree.
153
+ * The modification is NOT applied to the user's working tree.
154
+ */
155
+ applyModification(mod: CrsiModification): CrsiModificationResult {
156
+ if (!this.worktreePath) {
157
+ return { modification: mod, applied: false, phase: 'pending', error: 'No worktree created. Call createWorktree() first.' }
158
+ }
159
+
160
+ this.sessionReport.summary.total++
161
+ this.currentModification = mod
162
+
163
+ const result: CrsiModificationResult = {
164
+ modification: mod,
165
+ applied: false,
166
+ phase: 'pending',
167
+ }
168
+
169
+ const targetPath = join(this.worktreePath, mod.filePath)
170
+
171
+ // Verify the file exists in the worktree
172
+ if (!existsSync(targetPath)) {
173
+ result.error = `File not found in worktree: ${mod.filePath}`
174
+ result.phase = 'failed'
175
+ this.sessionReport.modifications.push(result)
176
+ return result
177
+ }
178
+
179
+ // Verify original content matches (safety check)
180
+ try {
181
+ const currentContent = readFileSync(targetPath, 'utf-8')
182
+ if (currentContent !== mod.originalContent && mod.originalContent) {
183
+ result.error = 'Original content mismatch — file may have been modified since the audit. Aborting.'
184
+ result.phase = 'failed'
185
+ this.sessionReport.modifications.push(result)
186
+ return result
187
+ }
188
+ } catch {
189
+ // If we can't read the file, proceed anyway — it's a worktree
190
+ }
191
+
192
+ // Apply the modification
193
+ try {
194
+ writeFileSync(targetPath, mod.newContent, 'utf-8')
195
+ result.applied = true
196
+ result.phase = 'applied'
197
+ this.sessionReport.summary.applied++
198
+
199
+ // Generate diff
200
+ try {
201
+ result.diff = execSync(`git diff -- "${mod.filePath}"`, {
202
+ cwd: this.worktreePath,
203
+ timeout: 10_000,
204
+ encoding: 'utf-8',
205
+ })
206
+ } catch {
207
+ // Diff generation is best-effort
208
+ }
209
+ } catch (err) {
210
+ result.error = `Failed to apply modification: ${String(err)}`
211
+ result.phase = 'failed'
212
+ }
213
+
214
+ this.sessionReport.modifications.push(result)
215
+ return result
216
+ }
217
+
218
+ /**
219
+ * Stage 3: Run the test suite against the modified worktree.
220
+ *
221
+ * Only modifications that have been successfully applied can be tested.
222
+ * If tests fail, the modification is marked as 'failed' and will be
223
+ * rolled back.
224
+ */
225
+ runTests(): CrsiTestResult {
226
+ if (!this.worktreePath) {
227
+ return { passed: false, totalTests: 0, failedTests: 0, output: 'No worktree available.' }
228
+ }
229
+
230
+ const result: CrsiTestResult = { passed: false, totalTests: 0, failedTests: 0, output: '' }
231
+
232
+ try {
233
+ const output = execSync('pnpm test 2>&1', {
234
+ cwd: this.worktreePath,
235
+ timeout: TEST_TIMEOUT_MS,
236
+ encoding: 'utf-8',
237
+ })
238
+
239
+ result.output = output.slice(-5000) // Keep last 5000 chars
240
+
241
+ // Parse test results from vitest output
242
+ const testMatch = output.match(/Tests\s+(\d+)\s+passed/)
243
+ const failMatch = output.match(/(\d+)\s+failed/)
244
+ const totalMatch = output.match(/Tests\s+(\d+)\s+passed.*?(\d+)\s+total/)
245
+
246
+ if (totalMatch) {
247
+ result.totalTests = parseInt(totalMatch[2]!, 10)
248
+ }
249
+ if (testMatch && !failMatch) {
250
+ result.passed = true
251
+ result.failedTests = 0
252
+ } else if (failMatch) {
253
+ result.failedTests = parseInt(failMatch[1]!, 10)
254
+ }
255
+
256
+ // Update all pending modifications to 'testing' → 'passed'/'failed'
257
+ for (const modResult of this.sessionReport.modifications) {
258
+ if (modResult.phase === 'applied') {
259
+ modResult.phase = 'testing'
260
+ modResult.testResult = result
261
+ }
262
+ if (modResult.phase === 'testing') {
263
+ modResult.phase = result.passed ? 'passed' : 'failed'
264
+ if (result.passed) {
265
+ this.sessionReport.summary.passed++
266
+ }
267
+ }
268
+ }
269
+ } catch (err) {
270
+ result.output = String(err).slice(0, 5000)
271
+ result.failedTests = 1
272
+
273
+ // Mark all applied modifications as failed
274
+ for (const modResult of this.sessionReport.modifications) {
275
+ if (modResult.phase === 'applied' || modResult.phase === 'testing') {
276
+ modResult.phase = 'failed'
277
+ modResult.testResult = result
278
+ }
279
+ }
280
+ }
281
+
282
+ return result
283
+ }
284
+
285
+ /**
286
+ * Stage 4: Get the human-approvable diff of all modifications.
287
+ *
288
+ * Returns the full git diff so a human can review before approving.
289
+ */
290
+ getDiff(): string {
291
+ if (!this.worktreePath) return ''
292
+
293
+ try {
294
+ return execSync('git diff', {
295
+ cwd: this.worktreePath,
296
+ timeout: 10_000,
297
+ encoding: 'utf-8',
298
+ })
299
+ } catch {
300
+ return '(diff unavailable)'
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Stage 5: Merge modifications back to the main repo.
306
+ *
307
+ * ONLY call this after human approval and successful test run.
308
+ * Cherry-picks the changes from the worktree branch to the current branch.
309
+ */
310
+ merge(): { success: boolean; message: string } {
311
+ if (!this.worktreePath || !this.worktreeBranch) {
312
+ return { success: false, message: 'No worktree to merge from.' }
313
+ }
314
+
315
+ // Check that all modifications passed tests
316
+ const failedCount = this.sessionReport.modifications.filter(
317
+ (m) => m.phase === 'failed',
318
+ ).length
319
+ if (failedCount > 0) {
320
+ return {
321
+ success: false,
322
+ message: `${failedCount} modification(s) failed tests. Cannot merge.`,
323
+ }
324
+ }
325
+
326
+ try {
327
+ // Commit in worktree
328
+ execSync('git add -A', { cwd: this.worktreePath, timeout: 10_000 })
329
+ execSync(
330
+ `git commit -m "CRSI auto-modification: ${this.sessionReport.sessionId}" --allow-empty`,
331
+ { cwd: this.worktreePath, timeout: 10_000 },
332
+ )
333
+
334
+ // Cherry-pick to main repo
335
+ const commitHash = execSync('git rev-parse HEAD', {
336
+ cwd: this.worktreePath,
337
+ timeout: 10_000,
338
+ encoding: 'utf-8',
339
+ }).trim()
340
+
341
+ execSync(`git cherry-pick "${commitHash}"`, {
342
+ cwd: this.repoRoot,
343
+ timeout: 30_000,
344
+ })
345
+
346
+ // Mark as merged
347
+ for (const modResult of this.sessionReport.modifications) {
348
+ if (modResult.phase === 'passed' || modResult.phase === 'approved') {
349
+ modResult.phase = 'merged'
350
+ }
351
+ }
352
+ this.sessionReport.summary.merged = this.sessionReport.modifications.filter(
353
+ (m) => m.phase === 'merged',
354
+ ).length
355
+
356
+ return {
357
+ success: true,
358
+ message: `Successfully merged ${this.sessionReport.summary.merged} modification(s).`,
359
+ }
360
+ } catch (err) {
361
+ return { success: false, message: `Merge failed: ${String(err)}` }
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Stage 5-alt: Roll back all modifications.
367
+ *
368
+ * Discards the worktree and all changes. Called automatically when
369
+ * tests fail, or manually by the user if they reject the diff.
370
+ */
371
+ rollback(): { success: boolean; message: string } {
372
+ if (!this.worktreePath || !this.worktreeBranch) {
373
+ return { success: false, message: 'No worktree to roll back.' }
374
+ }
375
+
376
+ // Mark all modifications as rolled-back
377
+ for (const modResult of this.sessionReport.modifications) {
378
+ if (modResult.phase !== 'merged') {
379
+ modResult.phase = 'rolled-back'
380
+ }
381
+ }
382
+ this.sessionReport.summary.rolledBack = this.sessionReport.modifications.filter(
383
+ (m) => m.phase === 'rolled-back',
384
+ ).length
385
+
386
+ // Remove the worktree
387
+ this.removeWorktree()
388
+
389
+ return {
390
+ success: true,
391
+ message: `Rolled back ${this.sessionReport.summary.rolledBack} modification(s).`,
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Clean up: remove the worktree and its branch.
397
+ * Safe to call at any time — does nothing if no worktree exists.
398
+ */
399
+ removeWorktree(): void {
400
+ if (!this.worktreePath || !this.worktreeBranch) return
401
+
402
+ try {
403
+ // Remove worktree
404
+ execSync(`git worktree remove --force "${this.worktreePath}"`, {
405
+ cwd: this.repoRoot,
406
+ timeout: 15_000,
407
+ })
408
+ } catch {
409
+ // Best-effort: if worktree remove fails, try manual cleanup
410
+ try {
411
+ if (existsSync(this.worktreePath)) {
412
+ rmSync(this.worktreePath, { recursive: true, force: true })
413
+ }
414
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 10_000 })
415
+ } catch {
416
+ // Give up — stale worktrees will be cleaned up on next createWorktree()
417
+ }
418
+ }
419
+
420
+ // Delete the branch
421
+ try {
422
+ execSync(`git branch -D "${this.worktreeBranch}"`, {
423
+ cwd: this.repoRoot,
424
+ timeout: 10_000,
425
+ })
426
+ } catch {
427
+ // Branch may already be gone
428
+ }
429
+
430
+ this.worktreePath = undefined
431
+ this.worktreeBranch = undefined
432
+ }
433
+
434
+ /**
435
+ * Generate and persist the session report.
436
+ */
437
+ finalize(): CrsiSessionReport {
438
+ this.sessionReport.completedAt = new Date().toISOString()
439
+
440
+ // Persist to ~/.mipham/crsi-sandbox/
441
+ const reportPath = join(REPORT_DIR, `${this.sessionReport.sessionId}.json`)
442
+ try {
443
+ writeFileSync(reportPath, JSON.stringify(this.sessionReport, null, 2), 'utf-8')
444
+ } catch {
445
+ // Best-effort persistence
446
+ }
447
+
448
+ // Clean up worktree
449
+ this.removeWorktree()
450
+
451
+ return this.sessionReport
452
+ }
453
+
454
+ /**
455
+ * Get the current session report (for live status checks).
456
+ */
457
+ getReport(): CrsiSessionReport {
458
+ return this.sessionReport
459
+ }
460
+
461
+ /**
462
+ * Load a historical session report.
463
+ */
464
+ static loadReport(sessionId: string): CrsiSessionReport | null {
465
+ const reportPath = join(REPORT_DIR, `${sessionId}.json`)
466
+ if (!existsSync(reportPath)) return null
467
+ try {
468
+ return JSON.parse(readFileSync(reportPath, 'utf-8'))
469
+ } catch {
470
+ return null
471
+ }
472
+ }
473
+
474
+ /**
475
+ * List all session reports.
476
+ */
477
+ static listReports(): string[] {
478
+ if (!existsSync(REPORT_DIR)) return []
479
+ try {
480
+ return readdirSync(REPORT_DIR)
481
+ .filter((f: string) => f.endsWith('.json'))
482
+ .map((f: string) => f.replace('.json', ''))
483
+ } catch {
484
+ return []
485
+ }
486
+ }
487
+
488
+ // ── Private Helpers ──
489
+
490
+ /**
491
+ * Remove stale worktrees from previous sessions that may have crashed
492
+ * before cleanup.
493
+ */
494
+ private cleanupStaleWorktrees(): void {
495
+ try {
496
+ const listOutput = execSync('git worktree list --porcelain', {
497
+ cwd: this.repoRoot,
498
+ timeout: 10_000,
499
+ encoding: 'utf-8',
500
+ })
501
+
502
+ const worktrees = this.parseWorktreeList(listOutput)
503
+
504
+ for (const wt of worktrees) {
505
+ // Only clean up CRSI sandbox worktrees
506
+ if (!wt.branch.includes(WORKTREE_PREFIX)) continue
507
+
508
+ // Check if the worktree path still exists
509
+ if (!existsSync(wt.path)) {
510
+ // Stale entry — prune it
511
+ try {
512
+ execSync(`git worktree prune`, { cwd: this.repoRoot, timeout: 5_000 })
513
+ execSync(`git branch -D "${wt.branch}"`, { cwd: this.repoRoot, timeout: 5_000 })
514
+ } catch {
515
+ // Best-effort cleanup
516
+ }
517
+ }
518
+ }
519
+ } catch {
520
+ // Not a git repo or worktree command failed — skip cleanup
521
+ }
522
+ }
523
+
524
+ private parseWorktreeList(output: string): Array<{ path: string; branch: string }> {
525
+ const worktrees: Array<{ path: string; branch: string }> = []
526
+ let current: { path?: string; branch?: string } = {}
527
+
528
+ for (const line of output.split('\n')) {
529
+ if (line.startsWith('worktree ')) {
530
+ if (current.path && current.branch) {
531
+ worktrees.push({ path: current.path, branch: current.branch })
532
+ }
533
+ current = { path: line.slice('worktree '.length) }
534
+ }
535
+ if (line.startsWith('branch ')) {
536
+ current.branch = line.slice('branch '.length).replace('refs/heads/', '')
537
+ }
538
+ }
539
+
540
+ if (current.path && current.branch) {
541
+ worktrees.push({ path: current.path, branch: current.branch })
542
+ }
543
+
544
+ return worktrees
545
+ }
546
+ }
@@ -14,6 +14,7 @@ import type { ArtifactServer } from '../artifacts/server'
14
14
  import type { AgentRegistry } from '../agent/agent-registry'
15
15
  import { analyzeForMemory } from './memory/memory-writer'
16
16
  import { getMemoryManager } from './memory/memory-loader'
17
+ import { AutoMemoryEngine } from './auto-memory.js'
17
18
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
18
19
  import type { SkillsLoader } from '../skills/loader'
19
20
  import { getBackgroundAgentRegistry } from '../agent/background-registry'
@@ -45,6 +46,7 @@ export class QueryEngine {
45
46
  private ruleEngine?: ExperienceRuleEngine
46
47
  private _patternAnalyzer?: PatternAnalyzer
47
48
  private _effectivenessTracker?: EffectivenessTracker
49
+ private _autoMemory?: AutoMemoryEngine
48
50
  private goal?: string
49
51
  private maxGoalLoops = 20
50
52
  private lastAssistantContent?: string
@@ -1017,6 +1019,24 @@ export class QueryEngine {
1017
1019
  return this._effectivenessTracker
1018
1020
  }
1019
1021
 
1022
+ /**
1023
+ * CRSI Phase 2: Lazily-initialized AutoMemoryEngine singleton.
1024
+ * Wires the CRSI Phase 1 pipeline (PatternAnalyzer, RuleEngine, EffectivenessTracker)
1025
+ * into the auto-reflection engine so that post-turn insights can feed rule generation.
1026
+ */
1027
+ getAutoMemory(): AutoMemoryEngine {
1028
+ if (!this._autoMemory) {
1029
+ this._autoMemory = new AutoMemoryEngine()
1030
+ // Wire CRSI Phase 1 → Phase 2 bridge
1031
+ this._autoMemory.setCrsiPipeline(
1032
+ this.getPatternAnalyzer(),
1033
+ this.getRuleEngine() || new ExperienceRuleEngine(),
1034
+ this.getEffectivenessTracker(),
1035
+ )
1036
+ }
1037
+ return this._autoMemory
1038
+ }
1039
+
1020
1040
  /** Register a tool dynamically (used by MCP auto-registration). */
1021
1041
  registerTool(tool: ToolDefinition): void {
1022
1042
  if (this.tools.has(tool.name)) {
package/src/ui/input.tsx CHANGED
@@ -83,6 +83,16 @@ export function InputBar({
83
83
  const [completionVerb, setCompletionVerb] = useState<string | null>(null)
84
84
  const prevLoading = useRef(isLoading)
85
85
 
86
+ // ── Message history for arrow-key navigation (Claude Code parity) ──
87
+ const [submittedHistory, setSubmittedHistory] = useState<string[]>([])
88
+ const historyIndexRef = useRef(-1) // -1 = not browsing history
89
+ const savedDraftRef = useRef('') // saved user draft before browsing history
90
+
91
+ // Stabilize t ref — prevents stale closures in intervals and avoids
92
+ // unnecessary effect re-runs when the i18n context value object changes.
93
+ const tRef = useRef(t)
94
+ tRef.current = t
95
+
86
96
  // ── Slash command hints ──
87
97
  const allCommands = useMemo(() => getCommandList(), [])
88
98
  const slashHints = useMemo(() => {
@@ -92,32 +102,33 @@ export function InputBar({
92
102
  return allCommands.filter((c) => c.name.toLowerCase().includes(filter)).slice(0, 8)
93
103
  }, [value, allCommands])
94
104
 
95
- // Rotate gerunds while loading
105
+ // Rotate gerunds while loading — use tRef to avoid re-running when i18n context re-renders
96
106
  useEffect(() => {
97
107
  if (!isLoading) return
98
108
  const interval = setInterval(() => {
99
- setVerb(t(pick(LOADING_KEYS)))
100
- }, 1200)
109
+ setVerb(tRef.current(pick(LOADING_KEYS)))
110
+ }, 2000)
101
111
  return () => clearInterval(interval)
102
- }, [isLoading, t])
112
+ }, [isLoading])
103
113
 
104
114
  // Pick a fresh gerund when loading starts
105
115
  useEffect(() => {
106
116
  if (isLoading) {
107
- setVerb(t(pick(LOADING_KEYS)))
117
+ setVerb(tRef.current(pick(LOADING_KEYS)))
108
118
  setCompletionVerb(null)
109
119
  }
110
- }, [isLoading, t])
120
+ }, [isLoading])
111
121
 
112
122
  // Flash a past participle when loading stops
113
123
  useEffect(() => {
114
124
  if (prevLoading.current === true && isLoading === false) {
115
- setCompletionVerb(t(pick(COMPLETED_KEYS)))
125
+ setCompletionVerb(tRef.current(pick(COMPLETED_KEYS)))
116
126
  const timer = setTimeout(() => setCompletionVerb(null), 1500)
127
+ prevLoading.current = isLoading
117
128
  return () => clearTimeout(timer)
118
129
  }
119
130
  prevLoading.current = isLoading
120
- }, [isLoading, t])
131
+ }, [isLoading])
121
132
 
122
133
  const [vimMode, setVimMode] = useState<VimMode>('insert')
123
134
  // NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
@@ -195,6 +206,39 @@ export function InputBar({
195
206
  return
196
207
  }
197
208
 
209
+ // ── Arrow-key history navigation (insert mode only, Claude Code parity) ──
210
+ if (vimEngine.current.mode === 'insert' && !searchMode) {
211
+ if (key.upArrow || key.downArrow) {
212
+ // Ignore if picker is active (command picker handles its own arrows)
213
+ if (value.startsWith('/')) return
214
+
215
+ if (key.upArrow) {
216
+ if (submittedHistory.length === 0) return
217
+ // Save current draft the first time we enter history browsing
218
+ if (historyIndexRef.current === -1) {
219
+ savedDraftRef.current = value
220
+ }
221
+ const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
222
+ historyIndexRef.current = newIndex
223
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
224
+ return
225
+ }
226
+ if (key.downArrow) {
227
+ if (historyIndexRef.current === -1) return
228
+ const newIndex = historyIndexRef.current - 1
229
+ historyIndexRef.current = newIndex
230
+ if (newIndex === -1) {
231
+ // Back to the original draft
232
+ setValue(savedDraftRef.current)
233
+ savedDraftRef.current = ''
234
+ } else {
235
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
236
+ }
237
+ return
238
+ }
239
+ }
240
+ }
241
+
198
242
  if (vimEngine.current.mode !== 'normal') return
199
243
 
200
244
  // Handle search mode — collect query characters
@@ -288,6 +332,10 @@ export function InputBar({
288
332
 
289
333
  const handleSubmit = (val: string) => {
290
334
  if (!val.trim() || isLoading) return
335
+ // Save to message history for arrow-key navigation
336
+ setSubmittedHistory((prev) => [...prev, val])
337
+ historyIndexRef.current = -1
338
+ savedDraftRef.current = ''
291
339
  onSubmit(val)
292
340
  setValue('')
293
341
  setPickerActive(false)
@@ -327,6 +375,11 @@ export function InputBar({
327
375
  onChange={(val) => {
328
376
  // Block text changes during search mode — keys go to search query
329
377
  if (searchMode) return
378
+ // Reset history browsing when user starts typing
379
+ if (historyIndexRef.current !== -1) {
380
+ historyIndexRef.current = -1
381
+ savedDraftRef.current = ''
382
+ }
330
383
  setValue(val)
331
384
  }}
332
385
  onSubmit={handleSubmit}