@miphamai/cli 0.33.0 → 0.33.2

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/src/ui/app.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
+ import { ErrorBoundary } from './error-boundary'
4
5
  import type { QueryEngine } from '../core/engine'
5
6
  import type { RemoteEngine } from '../daemon/remote-engine'
6
7
  import type { MiphamConfig } from '../shared/index.ts'
@@ -410,7 +411,17 @@ export function App({
410
411
  role: msg.role,
411
412
  content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
412
413
  }))
413
- setMessages((prev) => [...prev, ...restored])
414
+ if (result.resumeWarning) {
415
+ // Fix 7: Session isolation — add a visual separator between current session
416
+ // and restored history to prevent accidental leakage/confusion
417
+ const separator: ChatMessage = {
418
+ role: 'assistant' as const,
419
+ content: '── Restored session history below ──',
420
+ }
421
+ setMessages((prev) => [...prev, separator, ...restored])
422
+ } else {
423
+ setMessages((prev) => [...prev, ...restored])
424
+ }
414
425
  }
415
426
  if (result.copyContent) {
416
427
  // Copy to clipboard via pbcopy (macOS) or clip (Windows)
@@ -743,140 +754,142 @@ export function App({
743
754
  }
744
755
 
745
756
  return (
746
- <Box flexDirection="column" padding={1} height="100%">
747
- {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
748
- <WorkflowProgress />
749
-
750
- {/* Agent View Dashboard — Ctrl+G overlay (replaces chat + input) */}
751
- {agentViewOpen && agentViewManager ? (
752
- <AgentViewDashboard
753
- manager={agentViewManager}
754
- onAttach={() => {}}
755
- onExit={() => setAgentViewOpen(false)}
756
- />
757
- ) : (
758
- <>
759
- {/* Chat panel */}
760
- <ChatPanel messages={messages} focusMode={focusMode} />
761
-
762
- {/* Input with separator lines */}
763
- {pickerOpen ? (
764
- <ModelPicker
765
- config={config}
766
- currentProvider={providerId}
767
- currentModel={modelId}
768
- onSelect={(newProvider, newModel) => {
769
- engine.switchProvider(newProvider, newModel)
770
- setProviderId(newProvider)
771
- setModelId(newModel)
772
- setPickerOpen(false)
773
- setMessages((prev) => [
774
- ...prev,
775
- { role: 'system', content: `✓ Switched to ${newProvider}/${newModel}` },
776
- ])
777
- }}
778
- onNeedsApiKey={(providerId, modelId, providerName) => {
779
- setApiKeyPrompt({ providerId, modelId, providerName })
780
- setApiKeyInput('')
781
- setPickerOpen(false)
782
- setMessages((prev) => [
783
- ...prev,
784
- {
785
- role: 'system',
786
- content: t('ui.picker.needs_api_key', { provider: providerName }),
787
- },
788
- ])
789
- }}
790
- onClose={() => setPickerOpen(false)}
791
- />
792
- ) : (
793
- /* Input bar (hidden when picker is open) */
794
- <Box flexDirection="column">
795
- <Text dimColor>──────────────────────────────</Text>
796
- <InputBar
797
- onSubmit={handleSubmit}
798
- isLoading={isLoading}
799
- onTogglePicker={() => setPickerOpen((prev) => !prev)}
800
- onToggleFocus={() => setFocusMode((prev) => !prev)}
801
- onToggleExpand={() => {
802
- setMessages((prev) => {
803
- const msgs = [...prev]
804
- for (let i = msgs.length - 1; i >= 0; i--) {
805
- if (msgs[i]?.toolMeta) {
806
- const meta = msgs[i]!.toolMeta!
807
- if (meta.collapsed) {
808
- msgs[i] = {
809
- ...msgs[i]!,
810
- content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
811
- toolMeta: { ...meta, collapsed: false },
812
- }
813
- } else {
814
- const short =
815
- meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
816
- msgs[i] = {
817
- ...msgs[i]!,
818
- content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
819
- toolMeta: { ...meta, collapsed: true },
757
+ <ErrorBoundary>
758
+ <Box flexDirection="column" padding={1} height="100%">
759
+ {/* Workflow progress — auto-detects active workflows, renders nothing when idle */}
760
+ <WorkflowProgress />
761
+
762
+ {/* Agent View Dashboard — Ctrl+G overlay (replaces chat + input) */}
763
+ {agentViewOpen && agentViewManager ? (
764
+ <AgentViewDashboard
765
+ manager={agentViewManager}
766
+ onAttach={() => {}}
767
+ onExit={() => setAgentViewOpen(false)}
768
+ />
769
+ ) : (
770
+ <>
771
+ {/* Chat panel */}
772
+ <ChatPanel messages={messages} focusMode={focusMode} />
773
+
774
+ {/* Input with separator lines */}
775
+ {pickerOpen ? (
776
+ <ModelPicker
777
+ config={config}
778
+ currentProvider={providerId}
779
+ currentModel={modelId}
780
+ onSelect={(newProvider, newModel) => {
781
+ engine.switchProvider(newProvider, newModel)
782
+ setProviderId(newProvider)
783
+ setModelId(newModel)
784
+ setPickerOpen(false)
785
+ setMessages((prev) => [
786
+ ...prev,
787
+ { role: 'system', content: `✓ Switched to ${newProvider}/${newModel}` },
788
+ ])
789
+ }}
790
+ onNeedsApiKey={(providerId, modelId, providerName) => {
791
+ setApiKeyPrompt({ providerId, modelId, providerName })
792
+ setApiKeyInput('')
793
+ setPickerOpen(false)
794
+ setMessages((prev) => [
795
+ ...prev,
796
+ {
797
+ role: 'system',
798
+ content: t('ui.picker.needs_api_key', { provider: providerName }),
799
+ },
800
+ ])
801
+ }}
802
+ onClose={() => setPickerOpen(false)}
803
+ />
804
+ ) : (
805
+ /* Input bar (hidden when picker is open) */
806
+ <Box flexDirection="column">
807
+ <Text dimColor>──────────────────────────────</Text>
808
+ <InputBar
809
+ onSubmit={handleSubmit}
810
+ isLoading={isLoading}
811
+ onTogglePicker={() => setPickerOpen((prev) => !prev)}
812
+ onToggleFocus={() => setFocusMode((prev) => !prev)}
813
+ onToggleExpand={() => {
814
+ setMessages((prev) => {
815
+ const msgs = [...prev]
816
+ for (let i = msgs.length - 1; i >= 0; i--) {
817
+ if (msgs[i]?.toolMeta) {
818
+ const meta = msgs[i]!.toolMeta!
819
+ if (meta.collapsed) {
820
+ msgs[i] = {
821
+ ...msgs[i]!,
822
+ content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
823
+ toolMeta: { ...meta, collapsed: false },
824
+ }
825
+ } else {
826
+ const short =
827
+ meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
828
+ msgs[i] = {
829
+ ...msgs[i]!,
830
+ content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
831
+ toolMeta: { ...meta, collapsed: true },
832
+ }
820
833
  }
834
+ break
821
835
  }
822
- break
823
836
  }
837
+ return msgs
838
+ })
839
+ }}
840
+ onCyclePermission={() => {
841
+ setPermissionMode((prev) => {
842
+ const idx = PERMISSION_MODES.indexOf(prev)
843
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
844
+ engine.getPermission().setMode(next)
845
+ return next
846
+ })
847
+ }}
848
+ onCancel={() => {
849
+ if (abortRef.current) {
850
+ abortRef.current.abort()
824
851
  }
825
- return msgs
826
- })
827
- }}
828
- onCyclePermission={() => {
829
- setPermissionMode((prev) => {
830
- const idx = PERMISSION_MODES.indexOf(prev)
831
- const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
832
- engine.getPermission().setMode(next)
833
- return next
834
- })
835
- }}
836
- onCancel={() => {
837
- if (abortRef.current) {
838
- abortRef.current.abort()
839
- }
840
- }}
841
- onToggleAgentView={() => setAgentViewOpen((prev) => !prev)}
842
- />
843
- <Text dimColor>──────────────────────────────</Text>
844
- </Box>
845
- )}
846
-
847
- {/* Agent status footer — shows active tool indicator + inline agent progress + running background agents */}
848
- <AgentFooter
849
- agents={Object.values(runningAgents)}
850
- tick={agentTick}
851
- activeTool={activeTool}
852
- agentProgress={agentProgress}
853
- />
854
-
855
- {/* Status line — Claude Code style */}
856
- <Box marginTop={1} flexDirection="column">
857
- {goalText && (
858
- <Box>
859
- <Text color="green">🎯 Goal: {goalText}</Text>
852
+ }}
853
+ onToggleAgentView={() => setAgentViewOpen((prev) => !prev)}
854
+ />
855
+ <Text dimColor>──────────────────────────────</Text>
860
856
  </Box>
861
857
  )}
862
- <Box flexDirection="row">
863
- <Text color={PERMISSION_COLORS[permissionMode]}>
864
- ⏵⏵ {PERMISSION_LABELS[permissionMode]}
865
- </Text>
866
- <Text dimColor>
867
- {' '}
868
- ({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
869
- {PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
870
- {PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
871
- {' · '}
872
- {t('ui.status.esc_to_interrupt')}
873
- {' · '}
874
- {t('ui.status.left_for_agents')}
875
- </Text>
858
+
859
+ {/* Agent status footer — shows active tool indicator + inline agent progress + running background agents */}
860
+ <AgentFooter
861
+ agents={Object.values(runningAgents)}
862
+ tick={agentTick}
863
+ activeTool={activeTool}
864
+ agentProgress={agentProgress}
865
+ />
866
+
867
+ {/* Status line — Claude Code style */}
868
+ <Box marginTop={1} flexDirection="column">
869
+ {goalText && (
870
+ <Box>
871
+ <Text color="green">🎯 Goal: {goalText}</Text>
872
+ </Box>
873
+ )}
874
+ <Box flexDirection="row">
875
+ <Text color={PERMISSION_COLORS[permissionMode]}>
876
+ ⏵⏵ {PERMISSION_LABELS[permissionMode]}
877
+ </Text>
878
+ <Text dimColor>
879
+ {' '}
880
+ ({t('ui.status.shift_tab_cycle')}: {PERMISSION_LABELS.default} ·{' '}
881
+ {PERMISSION_LABELS.acceptEdits} · {PERMISSION_LABELS.bypassPermissions} ·{' '}
882
+ {PERMISSION_LABELS.plan} · {PERMISSION_LABELS.auto} · {PERMISSION_LABELS.dontAsk})
883
+ {' · '}
884
+ {t('ui.status.esc_to_interrupt')}
885
+ {' · '}
886
+ {t('ui.status.left_for_agents')}
887
+ </Text>
888
+ </Box>
876
889
  </Box>
877
- </Box>
878
- </>
879
- )}
880
- </Box>
890
+ </>
891
+ )}
892
+ </Box>
893
+ </ErrorBoundary>
881
894
  )
882
895
  }
@@ -83,6 +83,8 @@ export interface CommandResult {
83
83
  forwardToAI?: string
84
84
  /** If set, restored messages to load into the session (used by /resume last). */
85
85
  forwardedMessages?: Message[]
86
+ /** If true, resume into current non-empty session — app should warn before loading */
87
+ resumeWarning?: boolean
86
88
  /** API Key 补录请求:切换前需用户输入 Key */
87
89
  needsApiKey?: {
88
90
  providerId: string
@@ -269,11 +271,31 @@ const exitCmd: CommandHandler = () => ({ content: '', exit: true })
269
271
  const compactCmd: CommandHandler = async (ctx) => {
270
272
  const t = resolveT(ctx)
271
273
  const context = ctx.engine.getContext()
272
- const before = context.getEstimatedTokens()
273
- await context.compact('user requested compaction')
274
- const after = context.getEstimatedTokens()
274
+ const msgsBefore = context.getMessageCount()
275
+ const tokensBefore = context.getEstimatedTokens()
276
+
277
+ // Show start progress
278
+ process.stderr.write(
279
+ `🔄 Compacting: ${msgsBefore} messages, ${tokensBefore.toLocaleString()} tokens...\n`,
280
+ )
281
+
282
+ const startTime = Date.now()
283
+ const result = await context.compact('user requested compaction')
284
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
285
+ const msgsAfter = context.getMessageCount()
286
+ const saved = result.before > 0 ? ((1 - result.after / result.before) * 100).toFixed(0) : '0'
287
+
275
288
  return {
276
- content: `${t('commands.compact.confirmed')}\n${t('commands.compact.tokens', { before: before.toLocaleString(), after: after.toLocaleString(), saved: ((1 - after / before) * 100).toFixed(0) })}`,
289
+ content: [
290
+ t('commands.compact.confirmed'),
291
+ `Messages: ${msgsBefore} → ${msgsAfter} | Tokens: ${result.before.toLocaleString()} → ${result.after.toLocaleString()} (${saved}% saved)`,
292
+ `Duration: ${elapsed}s`,
293
+ msgsBefore > msgsAfter
294
+ ? `💡 Tip: Use /compact early to prevent automatic compaction delays.`
295
+ : '',
296
+ ]
297
+ .filter(Boolean)
298
+ .join('\n'),
277
299
  }
278
300
  }
279
301
 
@@ -2256,6 +2278,46 @@ const hooksCmd: CommandHandler = async (ctx) => {
2256
2278
  }
2257
2279
  }
2258
2280
 
2281
+ const hooksHealthCmd: CommandHandler = (ctx) => {
2282
+ const hookEngine = ctx.engine.getHookEngine?.()
2283
+ if (!hookEngine) {
2284
+ return { content: 'Hook engine not available.' }
2285
+ }
2286
+
2287
+ const health = hookEngine.getHookHealth()
2288
+ if (health.length === 0) {
2289
+ return { content: '✅ All hooks are healthy — no failures recorded.' }
2290
+ }
2291
+
2292
+ const lines: string[] = ['## 🪝 Hook Health Status', '']
2293
+ for (const { key, health: h } of health) {
2294
+ const status = h.disabled ? '🔴 DISABLED' : h.failures > 0 ? '🟡 DEGRADED' : '🟢 HEALTHY'
2295
+ lines.push(`### ${status} \`${key}\``)
2296
+ lines.push(`| 连续失败 | ${h.failures} (threshold: 5) |`)
2297
+ lines.push(`| 总失败次数 | ${h.totalFailures} |`)
2298
+ if (h.disabled) {
2299
+ const remaining = Math.max(0, 5 - (Date.now() - h.disabledAt) / 60_000)
2300
+ lines.push(`| 自动恢复 | ${remaining.toFixed(0)} min remaining |`)
2301
+ lines.push('')
2302
+ lines.push(`Use \`/hooks enable "${key}"\` to re-enable manually.`)
2303
+ }
2304
+ lines.push('')
2305
+ }
2306
+ return { content: lines.join('\n') }
2307
+ }
2308
+
2309
+ const hooksEnableCmd: CommandHandler = (ctx, args) => {
2310
+ const hookEngine = ctx.engine.getHookEngine?.()
2311
+ if (!hookEngine) return { content: 'Hook engine not available.' }
2312
+ const keyArg = args[0]?.trim().replace(/"/g, '')
2313
+ if (!keyArg) {
2314
+ return { content: 'Usage: /hooks enable "<hook-key>"\n\nUse /hooks health to see hook keys.' }
2315
+ }
2316
+ return hookEngine.reEnableHook(keyArg)
2317
+ ? { content: `✅ Hook "${keyArg}" re-enabled.` }
2318
+ : { content: `Hook "${keyArg}" not found or not disabled. Use /hooks health to check status.` }
2319
+ }
2320
+
2259
2321
  const batchCmd: CommandHandler = async (ctx) => {
2260
2322
  const t = resolveT(ctx)
2261
2323
  return { content: t('commands.batch.title') }
@@ -2426,6 +2488,7 @@ const resumeLastCmd: CommandHandler = async (ctx) => {
2426
2488
  : t('commands.resume.restored_full_footer', { loaded: String(messages.length) }),
2427
2489
  ].join('\n'),
2428
2490
  forwardedMessages: messages,
2491
+ resumeWarning: true,
2429
2492
  }
2430
2493
  }
2431
2494
 
@@ -3776,6 +3839,8 @@ registry.set('/stats', statsCmd)
3776
3839
  registry.set('/summary', summaryCmd)
3777
3840
  registry.set('/cd', cdCmd)
3778
3841
  registry.set('/hooks', hooksCmd)
3842
+ registry.set('/hooks health', hooksHealthCmd)
3843
+ registry.set('/hooks enable', hooksEnableCmd)
3779
3844
  registry.set('/batch', batchCmd)
3780
3845
 
3781
3846
  // ═══════════════════════════════════════════════════════════════
@@ -3909,6 +3974,8 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
3909
3974
  '/summary': 'Generate session summary',
3910
3975
  '/cd': 'Change session working directory',
3911
3976
  '/hooks': 'Manage lifecycle hook scripts',
3977
+ '/hooks health': 'Check hook health — see failures, disabled hooks, recovery status',
3978
+ '/hooks enable': 'Manually re-enable a hook that was auto-disabled after repeated failures',
3912
3979
  '/batch': 'Apply changes across multiple files',
3913
3980
  }
3914
3981
 
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Fix 8: Error Boundary for UI Rendering Protection
3
+ *
4
+ * Catches rendering errors in the React/Ink component tree,
5
+ * preventing the "layout freeze" bug where the interactive session
6
+ * stops redrawing while the process stays alive.
7
+ *
8
+ * Recovery path: renders a fallback message with a hint that the
9
+ * user can press Enter to attempt recovery (re-render).
10
+ */
11
+
12
+ import React, { Component } from 'react'
13
+ import { Text, Box } from 'ink'
14
+
15
+ interface Props {
16
+ children: React.ReactNode
17
+ /** Called when a render error is caught */
18
+ onError?: (error: Error, errorInfo: React.ErrorInfo) => void
19
+ }
20
+
21
+ interface State {
22
+ hasError: boolean
23
+ error: Error | null
24
+ errorCount: number
25
+ }
26
+
27
+ export class ErrorBoundary extends Component<Props, State> {
28
+ private resetTimer: ReturnType<typeof setTimeout> | null = null
29
+
30
+ constructor(props: Props) {
31
+ super(props)
32
+ this.state = { hasError: false, error: null, errorCount: 0 }
33
+ }
34
+
35
+ static getDerivedStateFromError(error: Error): Partial<State> {
36
+ return { hasError: true, error }
37
+ }
38
+
39
+ override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
40
+ // Log to stderr for diagnostics
41
+ process.stderr.write(
42
+ `\n⚠️ UI Error Boundary caught: ${error.message}\n` +
43
+ ` Component: ${errorInfo.componentStack?.slice(0, 200) || 'unknown'}\n`,
44
+ )
45
+
46
+ this.props.onError?.(error, errorInfo)
47
+
48
+ // Auto-recovery: reset after 5 seconds to attempt re-render
49
+ this.resetTimer = setTimeout(() => {
50
+ this.setState((prev) => ({
51
+ hasError: false,
52
+ error: null,
53
+ errorCount: prev.errorCount,
54
+ }))
55
+ }, 5_000)
56
+ }
57
+
58
+ override componentWillUnmount(): void {
59
+ if (this.resetTimer) {
60
+ clearTimeout(this.resetTimer)
61
+ }
62
+ }
63
+
64
+ override render(): React.ReactNode {
65
+ if (this.state.hasError) {
66
+ return (
67
+ <Box flexDirection="column" padding={1}>
68
+ <Text color="red" bold>
69
+ ⚠️ UI Render Error
70
+ </Text>
71
+ <Text dimColor>{this.state.error?.message || 'Unknown rendering error'}</Text>
72
+ <Text> </Text>
73
+ <Text dimColor>
74
+ The terminal interface encountered a rendering issue. Auto-recovery in 5 seconds...
75
+ Press Enter to attempt immediate recovery.
76
+ </Text>
77
+ <Text> </Text>
78
+ <Text color="yellow">Your session data and conversation history are preserved.</Text>
79
+ </Box>
80
+ )
81
+ }
82
+
83
+ return this.props.children
84
+ }
85
+ }