@miphamai/cli 0.26.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.
@@ -1,53 +1,62 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
1
3
  import type { ToolDefinition } from '../../shared/index.ts'
2
4
 
3
5
  export const exitPlanModeTool: ToolDefinition = {
4
6
  name: 'ExitPlanMode',
5
7
  description:
6
- 'Exit plan mode and submit your plan for approval. ' +
7
- 'Set approved: true to switch to acceptEdits mode (code changes allowed). ' +
8
- 'Set approved: false to revert to default mode. ' +
9
- 'Use this after you have finished designing your approach in plan mode.',
8
+ 'Exit plan mode and present your plan for user approval. ' +
9
+ 'This tool does NOT switch to implementation mode — the user must explicitly approve first. ' +
10
+ 'After calling this, present your plan and ask the user to confirm. ' +
11
+ 'The user can approve by saying "approved" or "/approve", or by cycling to acceptEdits mode with Shift+Tab.',
10
12
  category: 'agent',
11
13
  permission: 'auto',
12
14
  parameters: {
13
15
  type: 'object',
14
16
  properties: {
15
- approved: {
16
- type: 'boolean',
17
+ planFile: {
18
+ type: 'string',
17
19
  description:
18
- 'Whether the user approved the plan. true → switch to acceptEdits mode. false → revert to default.',
20
+ 'Path to the plan file you wrote (e.g., .mipham/plans/plan-2026-08-10T12-00-00.md). If omitted, the most recent plan file is used.',
19
21
  },
20
22
  },
21
- required: ['approved'],
23
+ required: [],
22
24
  },
23
- async execute(params, _ctx) {
24
- const approved = params.approved === true
25
+ async execute(params, ctx) {
26
+ const planDir = join(ctx.cwd, '.mipham', 'plans')
27
+ const planFile = (params.planFile as string) || ''
25
28
 
26
- if (approved) {
27
- return {
28
- success: true,
29
- content: [
30
- '── Plan Approved ──',
31
- '',
32
- '✓ Exiting plan mode.',
33
- '✓ Switching to acceptEdits mode — reads and file edits are auto-approved.',
34
- '',
35
- 'You can now implement the plan. The plan file is in .mipham/plans/.',
36
- '',
37
- 'Use Shift+Tab to cycle permission modes if you need to change.',
38
- ].join('\n'),
29
+ // Try to read the plan to confirm it exists
30
+ let planContent = ''
31
+ try {
32
+ const planPath = planFile || ''
33
+ if (planPath) {
34
+ planContent = readFileSync(planPath, 'utf-8')
39
35
  }
36
+ } catch {
37
+ // Plan file not found — still exit plan mode
40
38
  }
41
39
 
42
40
  return {
43
41
  success: true,
44
42
  content: [
45
- '── Plan Mode Exited ──',
43
+ '── Plan Ready for Review ──',
46
44
  '',
47
- '✓ Returning to default permission mode.',
45
+ '✓ Exiting plan mode.',
46
+ '✓ Plan file saved. Present your plan to the user now.',
48
47
  '',
49
- 'No code changes were made. The plan file is preserved in .mipham/plans/.',
50
- 'Use EnterPlanMode again when ready to resume planning.',
48
+ '⚠️ IMPORTANT: You are still in limited permission mode.',
49
+ ' The user must explicitly approve before you can make changes.',
50
+ '',
51
+ 'Next steps:',
52
+ ' 1. Present your plan to the user (summarize key decisions)',
53
+ ' 2. Ask: "Does this plan look good? Reply approved to begin."',
54
+ ' 3. Wait for the user to explicitly say "approved" or "/approve"',
55
+ ' 4. Only then switch to acceptEdits mode (Shift+Tab or user action)',
56
+ '',
57
+ 'DO NOT start implementing until the user explicitly approves.',
58
+ 'DO NOT call ExitPlanMode with approved:true — that parameter no longer exists.',
59
+ planContent ? `\n── Plan Content (for reference) ──\n\n${planContent.slice(0, 3000)}` : '',
51
60
  ].join('\n'),
52
61
  }
53
62
  },
@@ -185,6 +185,105 @@ export function detectViolations(stderr: string): string[] {
185
185
  return violations
186
186
  }
187
187
 
188
+ /**
189
+ * Vibe coding: Parse stderr output for error locations (file path + line + column).
190
+ * Supports common tool formats: TypeScript, ESLint, pytest, Rust, Go, Prettier, etc.
191
+ * Returns up to 10 unique locations sorted by file then line.
192
+ */
193
+ interface ErrorLocation {
194
+ file: string
195
+ line: number
196
+ col?: number
197
+ message?: string
198
+ }
199
+
200
+ function parseErrorLocations(stderr: string): ErrorLocation[] {
201
+ const locations: ErrorLocation[] = []
202
+
203
+ // TypeScript / ESLint / Prettier: path(line,col): message
204
+ // e.g., src/foo.ts(42,10): error TS2304: Cannot find name 'foo'
205
+ const tsPattern = /([^\s(]+)\((\d+),(\d+)\):\s*(.+)/g
206
+ let match: RegExpExecArray | null
207
+ let m: RegExpExecArray | null
208
+ while ((m = tsPattern.exec(stderr)) !== null) {
209
+ locations.push({
210
+ file: m[1]!,
211
+ line: parseInt(m[2]!),
212
+ col: parseInt(m[3]!),
213
+ message: (m[4] || '').slice(0, 120),
214
+ })
215
+ }
216
+
217
+ // pytest / Python: path:line: message
218
+ // e.g., tests/test_foo.py:42: AssertionError: ...
219
+ const pyPattern = /([^\s:]+\.py):(\d+):\s*(.+)/g
220
+ while ((match = pyPattern.exec(stderr)) !== null) {
221
+ const pm = match
222
+ locations.push({
223
+ file: pm[1]!,
224
+ line: parseInt(pm[2]!),
225
+ message: (pm[3] || '').slice(0, 120),
226
+ })
227
+ }
228
+
229
+ // Rust: --> path:line:col
230
+ // e.g., --> src/main.rs:42:10
231
+ const rustPattern = /-->\s*([^\s:]+):(\d+):(\d+)/g
232
+ while ((match = rustPattern.exec(stderr)) !== null) {
233
+ const rm = match
234
+ locations.push({
235
+ file: rm[1]!,
236
+ line: parseInt(rm[2]!),
237
+ col: parseInt(rm[3]!),
238
+ })
239
+ }
240
+
241
+ // Go: path:line:col: message
242
+ // e.g., ./main.go:42:10: undefined: foo
243
+ const goPattern = /([^\s:]+\.go):(\d+):(\d+):\s*(.+)/g
244
+ while ((match = goPattern.exec(stderr)) !== null) {
245
+ const gm2 = match
246
+ locations.push({
247
+ file: gm2[1]!,
248
+ line: parseInt(gm2[2]!),
249
+ col: parseInt(gm2[3]!),
250
+ message: (gm2[4] || '').slice(0, 120),
251
+ })
252
+ }
253
+
254
+ // Generic: path:line (any file extension)
255
+ // e.g., src/foo.ts:42
256
+ const genericPattern = /([^\s:]+\.[a-zA-Z]{1,6}):(\d+)\b/g
257
+ while ((match = genericPattern.exec(stderr)) !== null) {
258
+ const gm = match
259
+ const file = gm[1]!
260
+ // Skip if we already have this exact location from a more specific pattern
261
+ const alreadyHave = locations.some((l) => l.file === file && l.line === parseInt(gm[2]!))
262
+ if (!alreadyHave) {
263
+ locations.push({ file, line: parseInt(match[2]!) })
264
+ }
265
+ }
266
+
267
+ // Deduplicate and sort: same file+line → keep first
268
+ const seen = new Set<string>()
269
+ const unique: ErrorLocation[] = []
270
+ for (const loc of locations) {
271
+ const key = `${loc.file}:${loc.line}`
272
+ if (!seen.has(key)) {
273
+ seen.add(key)
274
+ unique.push(loc)
275
+ }
276
+ }
277
+
278
+ // Sort by file path then line number
279
+ unique.sort((a, b) => {
280
+ const fileCmp = a.file.localeCompare(b.file)
281
+ return fileCmp !== 0 ? fileCmp : a.line - b.line
282
+ })
283
+
284
+ return unique.slice(0, 10)
285
+ }
286
+
188
287
  export const bashTool: ToolDefinition = {
189
288
  name: 'Bash',
190
289
  description:
@@ -283,6 +382,20 @@ export const bashTool: ToolDefinition = {
283
382
  if (violations.length > 0) {
284
383
  errorContent += '\n\n── Sandbox Violations ──\n' + violations.join('\n')
285
384
  }
385
+ // Vibe coding fix: auto-parse error locations from stderr
386
+ const errorLocations = parseErrorLocations(rawStderr)
387
+ if (errorLocations.length > 0) {
388
+ errorContent +=
389
+ '\n\n── Error Locations (for quick fix) ──\n' +
390
+ errorLocations
391
+ .map(
392
+ (l) =>
393
+ ` ${l.file}:${l.line}` +
394
+ (l.col ? `:${l.col}` : '') +
395
+ (l.message ? ` — ${l.message}` : ''),
396
+ )
397
+ .join('\n')
398
+ }
286
399
  return {
287
400
  success: false,
288
401
  content: errorContent,
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}