@miphamai/cli 0.2.3 → 0.3.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 +1 -1
- package/bin/mipham.ts +26 -2
- package/package.json +4 -3
- package/skills/mipham/om-model-optimize.mipham-skill.md +55 -9
- package/skills/mipham/om-security.mipham-skill.md +82 -10
- package/skills/standard/doc-generator.SKILL.md +67 -11
- package/skills/standard/github-ops.SKILL.md +89 -13
- package/skills/standard/memory.SKILL.md +71 -11
- package/skills/standard/mipham-code-setup.SKILL.md +1 -1
- package/skills/standard/self-review.SKILL.md +49 -10
- package/skills/standard/superpower.SKILL.md +46 -8
- package/skills/standard/tdd.SKILL.md +85 -12
- package/skills/standard/web-search.SKILL.md +62 -13
- package/src/core/context.ts +73 -13
- package/src/core/engine.ts +159 -44
- package/src/core/permission.ts +31 -5
- package/src/core/session-store.ts +5 -1
- package/src/index.tsx +25 -0
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/fetch-utils.ts +103 -0
- package/src/providers/openai-compat.ts +55 -8
- package/src/providers/registry.ts +1 -0
- package/src/ui/app.tsx +117 -14
- package/src/ui/chat.tsx +25 -7
- package/src/ui/commands.ts +43 -13
- package/src/ui/input.tsx +56 -35
package/src/core/permission.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
|
|
1
|
+
import type { ToolDefinition, ToolPermission, PermissionLevel, PermissionRule } from '../shared/index.ts'
|
|
2
2
|
|
|
3
3
|
export class PermissionSystem {
|
|
4
4
|
private rules = new Map<string, PermissionLevel>()
|
|
5
|
+
private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
|
|
5
6
|
|
|
6
7
|
constructor(private defaultLevel: ToolPermission = 'auto') {}
|
|
7
8
|
|
|
@@ -13,18 +14,43 @@ export class PermissionSystem {
|
|
|
13
14
|
return this.defaultLevel
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
setRule(
|
|
17
|
-
|
|
17
|
+
setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
|
|
18
|
+
if (typeof toolNameOrRule === 'string') {
|
|
19
|
+
if (level) this.rules.set(toolNameOrRule, level)
|
|
20
|
+
} else {
|
|
21
|
+
const rule = toolNameOrRule
|
|
22
|
+
if (rule.pattern) {
|
|
23
|
+
this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
|
|
24
|
+
} else {
|
|
25
|
+
this.rules.set(rule.toolName, rule.level)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
18
28
|
}
|
|
19
29
|
|
|
20
30
|
removeRule(toolName: string): void {
|
|
21
31
|
this.rules.delete(toolName)
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Resolve permission level for a tool call.
|
|
36
|
+
*
|
|
37
|
+
* Fallback chain: exact rule → pattern rule → tool permission → system default
|
|
38
|
+
*/
|
|
39
|
+
check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
|
|
40
|
+
// 1. Exact-name rule wins
|
|
25
41
|
const ruleLevel = this.rules.get(tool.name)
|
|
26
42
|
if (ruleLevel) return ruleLevel
|
|
27
|
-
|
|
43
|
+
|
|
44
|
+
// 2. Pattern-based rules (e.g. "file_*" → bypass)
|
|
45
|
+
for (const { pattern, level } of this.patternRules) {
|
|
46
|
+
if (pattern.test(tool.name)) return level
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Tool's own permission level
|
|
50
|
+
if (tool.permission) return tool.permission
|
|
51
|
+
|
|
52
|
+
// 4. System default
|
|
53
|
+
return this.defaultLevel
|
|
28
54
|
}
|
|
29
55
|
|
|
30
56
|
needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
readFileSync,
|
|
5
5
|
writeFileSync,
|
|
6
6
|
unlinkSync,
|
|
7
|
+
renameSync,
|
|
7
8
|
existsSync,
|
|
8
9
|
} from 'node:fs'
|
|
9
10
|
import { join } from 'node:path'
|
|
@@ -60,7 +61,10 @@ export class SessionStore {
|
|
|
60
61
|
messages,
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// Atomic write: write to temp file, then rename (same-fs rename is atomic)
|
|
65
|
+
const tmp = path + '.tmp'
|
|
66
|
+
writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
|
|
67
|
+
renameSync(tmp, path)
|
|
64
68
|
}
|
|
65
69
|
|
|
66
70
|
/**
|
package/src/index.tsx
CHANGED
|
@@ -8,6 +8,8 @@ import { QueryEngine } from './core/engine'
|
|
|
8
8
|
import { SessionStore } from './core/session-store'
|
|
9
9
|
import { SkillsLoader } from './skills/loader'
|
|
10
10
|
import { createToolRegistry } from './tools'
|
|
11
|
+
import { McpClient } from './mcp/client'
|
|
12
|
+
import { HookEngine } from './core/hooks'
|
|
11
13
|
|
|
12
14
|
interface RunOptions {
|
|
13
15
|
model?: string
|
|
@@ -24,6 +26,17 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
24
26
|
// Load configuration
|
|
25
27
|
const config = loadConfig()
|
|
26
28
|
|
|
29
|
+
// Connect MCP servers (fire-and-forget — do not block startup)
|
|
30
|
+
const mcpServers = config.skills?.mcpServers ?? []
|
|
31
|
+
if (mcpServers.length > 0) {
|
|
32
|
+
const mcp = McpClient.getInstance()
|
|
33
|
+
for (const server of mcpServers) {
|
|
34
|
+
mcp.connect(server).catch((err) => {
|
|
35
|
+
process.stderr.write(`[mcp] Failed to connect "${server.name}": ${String(err)}\n`)
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// Bootstrap providers
|
|
28
41
|
const defaultProvider = options.provider || config.defaultProvider
|
|
29
42
|
const defaultModel = options.model || config.defaultModel
|
|
@@ -60,8 +73,20 @@ export async function runApp(options: RunOptions): Promise<void> {
|
|
|
60
73
|
// Create tool registry with all 16 tools
|
|
61
74
|
const tools = createToolRegistry()
|
|
62
75
|
|
|
76
|
+
// Initialize hook engine — register skill-defined hooks
|
|
77
|
+
const hookEngine = new HookEngine()
|
|
78
|
+
for (const skill of skillsLoader.list()) {
|
|
79
|
+
if (skill.hooks) {
|
|
80
|
+
for (const hook of skill.hooks) {
|
|
81
|
+
hookEngine.register(hook)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
63
86
|
// Create query engine
|
|
64
87
|
const engine = new QueryEngine(registry, context, tools)
|
|
88
|
+
engine.setHookEngine(hookEngine)
|
|
89
|
+
engine.setupContextSummarizer()
|
|
65
90
|
|
|
66
91
|
// Auto-save session on exit
|
|
67
92
|
let autoSaveName: string | undefined
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ContentBlock,
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
9
|
+
import { fetchWithRetry } from './fetch-utils'
|
|
9
10
|
|
|
10
11
|
interface AnthropicContentBlock {
|
|
11
12
|
type: string
|
|
@@ -72,7 +73,7 @@ export class AnthropicProvider implements ProviderInstance {
|
|
|
72
73
|
}))
|
|
73
74
|
}
|
|
74
75
|
|
|
75
|
-
const response = await
|
|
76
|
+
const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
|
|
76
77
|
method: 'POST',
|
|
77
78
|
headers: {
|
|
78
79
|
'Content-Type': 'application/json',
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fetch utilities — retry, timeout, backoff.
|
|
3
|
+
*
|
|
4
|
+
* Both AnthropicProvider and OpenAICompatProvider use these for
|
|
5
|
+
* resilient API communication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FetchWithRetryOptions {
|
|
9
|
+
/** Request timeout in ms (applied via AbortController). */
|
|
10
|
+
timeout?: number
|
|
11
|
+
/** Maximum retry attempts (default 0 = no retry). */
|
|
12
|
+
maxRetries?: number
|
|
13
|
+
/** Base delay for exponential backoff in ms (default 1000). */
|
|
14
|
+
baseDelay?: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
|
|
18
|
+
|
|
19
|
+
function isRetryableError(err: unknown): boolean {
|
|
20
|
+
if (err instanceof DOMException && err.name === 'AbortError') return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Fetch with optional timeout and retry with exponential backoff.
|
|
26
|
+
*
|
|
27
|
+
* Retries on: network errors, 5xx, 429.
|
|
28
|
+
* Does NOT retry on: timeout (AbortError), 4xx (except 429).
|
|
29
|
+
*/
|
|
30
|
+
export async function fetchWithRetry(
|
|
31
|
+
url: string,
|
|
32
|
+
init: RequestInit,
|
|
33
|
+
options: FetchWithRetryOptions = {},
|
|
34
|
+
): Promise<Response> {
|
|
35
|
+
const { timeout = 60_000, maxRetries = 2, baseDelay = 1000 } = options
|
|
36
|
+
|
|
37
|
+
let lastErr: unknown
|
|
38
|
+
|
|
39
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
40
|
+
const controller = new AbortController()
|
|
41
|
+
const timer = setTimeout(() => controller.abort(), timeout)
|
|
42
|
+
const signal = init.signal
|
|
43
|
+
? anySignal([init.signal, controller.signal])
|
|
44
|
+
: controller.signal
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const response = await fetch(url, { ...init, signal })
|
|
48
|
+
|
|
49
|
+
// 429 / 5xx → retry
|
|
50
|
+
if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
|
|
51
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
52
|
+
const delay = retryAfter
|
|
53
|
+
? parseInt(retryAfter, 10) * 1000
|
|
54
|
+
: baseDelay * Math.pow(2, attempt)
|
|
55
|
+
await sleep(delay)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return response
|
|
60
|
+
} catch (err) {
|
|
61
|
+
lastErr = err
|
|
62
|
+
if (!isRetryableError(err) || attempt >= maxRetries) throw err
|
|
63
|
+
await sleep(baseDelay * Math.pow(2, attempt))
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
// Clean up combined signal if we created one
|
|
67
|
+
if (init.signal) {
|
|
68
|
+
try {
|
|
69
|
+
controller.abort()
|
|
70
|
+
} catch {
|
|
71
|
+
/* best effort */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
throw lastErr
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Simple promise-based sleep. */
|
|
81
|
+
function sleep(ms: number): Promise<void> {
|
|
82
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Combine multiple AbortSignals into one — any signal aborting
|
|
87
|
+
* triggers the combined signal.
|
|
88
|
+
*/
|
|
89
|
+
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
90
|
+
const controller = new AbortController()
|
|
91
|
+
const onAbort = () => {
|
|
92
|
+
controller.abort()
|
|
93
|
+
for (const s of signals) s.removeEventListener('abort', onAbort)
|
|
94
|
+
}
|
|
95
|
+
for (const s of signals) {
|
|
96
|
+
if (s.aborted) {
|
|
97
|
+
controller.abort()
|
|
98
|
+
return controller.signal
|
|
99
|
+
}
|
|
100
|
+
s.addEventListener('abort', onAbort)
|
|
101
|
+
}
|
|
102
|
+
return controller.signal
|
|
103
|
+
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
ContentBlock,
|
|
7
7
|
} from '../shared/index.ts'
|
|
8
8
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
9
|
+
import { fetchWithRetry } from './fetch-utils'
|
|
9
10
|
|
|
10
11
|
export class OpenAICompatProvider implements ProviderInstance {
|
|
11
12
|
constructor(public config: ProviderConfig) {}
|
|
@@ -23,7 +24,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
23
24
|
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
const response = await
|
|
27
|
+
const response = await fetchWithRetry(`${baseUrl}/chat/completions`, {
|
|
27
28
|
method: 'POST',
|
|
28
29
|
headers: {
|
|
29
30
|
'Content-Type': 'application/json',
|
|
@@ -47,6 +48,12 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
47
48
|
const decoder = new TextDecoder()
|
|
48
49
|
let buffer = ''
|
|
49
50
|
|
|
51
|
+
// Track incremental tool calls across streaming deltas
|
|
52
|
+
const pendingToolCalls = new Map<
|
|
53
|
+
number,
|
|
54
|
+
{ id: string; name: string; arguments: string }
|
|
55
|
+
>()
|
|
56
|
+
|
|
50
57
|
while (true) {
|
|
51
58
|
const { done, value } = await reader.read()
|
|
52
59
|
if (done) break
|
|
@@ -60,6 +67,18 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
60
67
|
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
61
68
|
const data = trimmed.slice(6)
|
|
62
69
|
if (data === '[DONE]') {
|
|
70
|
+
// Emit any pending tool calls before stopping
|
|
71
|
+
for (const [, tc] of pendingToolCalls) {
|
|
72
|
+
yield {
|
|
73
|
+
type: 'tool_use',
|
|
74
|
+
toolUse: {
|
|
75
|
+
type: 'tool_use',
|
|
76
|
+
id: tc.id,
|
|
77
|
+
name: tc.name,
|
|
78
|
+
input: this.safeParseJson(tc.arguments),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
63
82
|
yield { type: 'stop' }
|
|
64
83
|
return
|
|
65
84
|
}
|
|
@@ -73,21 +92,39 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
73
92
|
|
|
74
93
|
if (delta?.tool_calls) {
|
|
75
94
|
for (const tc of delta.tool_calls) {
|
|
95
|
+
const idx = tc.index ?? 0
|
|
96
|
+
const pending = pendingToolCalls.get(idx) || {
|
|
97
|
+
id: '',
|
|
98
|
+
name: '',
|
|
99
|
+
arguments: '',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (tc.id) pending.id = tc.id
|
|
103
|
+
if (tc.function?.name) pending.name = tc.function.name
|
|
104
|
+
if (tc.function?.arguments) pending.arguments += tc.function.arguments
|
|
105
|
+
|
|
106
|
+
pendingToolCalls.set(idx, pending)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (delta?.content) {
|
|
111
|
+
yield { type: 'text', content: delta.content }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
115
|
+
// Emit fully accumulated tool calls
|
|
116
|
+
for (const [, tc] of pendingToolCalls) {
|
|
76
117
|
yield {
|
|
77
118
|
type: 'tool_use',
|
|
78
119
|
toolUse: {
|
|
79
120
|
type: 'tool_use',
|
|
80
121
|
id: tc.id || `call_${Date.now()}`,
|
|
81
|
-
name: tc.
|
|
82
|
-
input:
|
|
122
|
+
name: tc.name,
|
|
123
|
+
input: this.safeParseJson(tc.arguments),
|
|
83
124
|
},
|
|
84
125
|
}
|
|
85
126
|
}
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (delta?.content) {
|
|
90
|
-
yield { type: 'text', content: delta.content }
|
|
127
|
+
pendingToolCalls.clear()
|
|
91
128
|
}
|
|
92
129
|
|
|
93
130
|
if (choice.finish_reason === 'stop') {
|
|
@@ -148,6 +185,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
148
185
|
return result
|
|
149
186
|
}
|
|
150
187
|
|
|
188
|
+
/** Safely parse JSON, returning an empty object on failure. */
|
|
189
|
+
private safeParseJson(raw: string): Record<string, unknown> {
|
|
190
|
+
if (!raw) return {}
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(raw)
|
|
193
|
+
} catch {
|
|
194
|
+
return { _raw: raw }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
151
198
|
private resolveApiKey(keyTemplate: string): string {
|
|
152
199
|
const match = keyTemplate.match(/^\$\{(.+)\}$/)
|
|
153
200
|
if (match?.[1]) {
|
package/src/ui/app.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useCallback } from 'react'
|
|
1
|
+
import React, { useState, useCallback, useRef } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import type { QueryEngine } from '../core/engine'
|
|
4
4
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
@@ -23,12 +23,26 @@ interface AppProps {
|
|
|
23
23
|
skillsLoader?: SkillsLoader
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface ToolMeta {
|
|
27
|
+
name: string
|
|
28
|
+
input: string
|
|
29
|
+
output?: string
|
|
30
|
+
collapsed: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export interface ChatMessage {
|
|
27
34
|
role: 'user' | 'assistant' | 'system'
|
|
28
35
|
content: string
|
|
36
|
+
toolMeta?: ToolMeta
|
|
29
37
|
}
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
interface AgentProgress {
|
|
40
|
+
name: string
|
|
41
|
+
description: string
|
|
42
|
+
startTime: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const VERSION = '0.3.0'
|
|
32
46
|
|
|
33
47
|
type PermissionMode = 'auto' | 'ask' | 'bypass'
|
|
34
48
|
|
|
@@ -58,6 +72,18 @@ export function App({
|
|
|
58
72
|
const [focusMode, setFocusMode] = useState(false)
|
|
59
73
|
const [goalText, setGoalText] = useState('')
|
|
60
74
|
const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
|
|
75
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
76
|
+
const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
|
|
77
|
+
const [agentElapsed, setAgentElapsed] = useState(0)
|
|
78
|
+
|
|
79
|
+
// Agent elapsed timer
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (!agentProgress) { setAgentElapsed(0); return }
|
|
82
|
+
const interval = setInterval(() => {
|
|
83
|
+
setAgentElapsed(Math.floor((Date.now() - agentProgress.startTime) / 1000))
|
|
84
|
+
}, 1000)
|
|
85
|
+
return () => clearInterval(interval)
|
|
86
|
+
}, [agentProgress])
|
|
61
87
|
|
|
62
88
|
const mkCtx = useCallback(
|
|
63
89
|
(): CommandContext => ({
|
|
@@ -161,10 +187,13 @@ export function App({
|
|
|
161
187
|
setMessages((prev) => [...prev, { role: 'user', content: input }])
|
|
162
188
|
setIsLoading(true)
|
|
163
189
|
|
|
190
|
+
const controller = new AbortController()
|
|
191
|
+
abortRef.current = controller
|
|
192
|
+
|
|
164
193
|
let assistantContent = ''
|
|
165
194
|
|
|
166
195
|
try {
|
|
167
|
-
for await (const chunk of engine.process(input)) {
|
|
196
|
+
for await (const chunk of engine.process(input, controller.signal)) {
|
|
168
197
|
if (chunk.type === 'text' && chunk.content) {
|
|
169
198
|
assistantContent += chunk.content
|
|
170
199
|
setMessages((prev) => {
|
|
@@ -180,24 +209,50 @@ export function App({
|
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
212
|
+
const toolName = chunk.toolUse.name
|
|
213
|
+
const inputStr = JSON.stringify(chunk.toolUse.input)
|
|
214
|
+
const isAgent = toolName === 'Agent' || toolName === 'Task'
|
|
215
|
+
|
|
216
|
+
if (isAgent) {
|
|
217
|
+
setAgentProgress({
|
|
218
|
+
name: (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
|
|
219
|
+
description: (chunk.toolUse.input.description as string) ||
|
|
220
|
+
(chunk.toolUse.input.prompt as string) || '',
|
|
221
|
+
startTime: Date.now(),
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const collapsed = toolName !== 'Agent' // agents always expanded
|
|
183
226
|
setMessages((prev) => [
|
|
184
227
|
...prev,
|
|
185
228
|
{
|
|
186
229
|
role: 'system',
|
|
187
|
-
content:
|
|
230
|
+
content: collapsed
|
|
231
|
+
? `⏺ ${toolName} · ${inputStr.slice(0, 50)}${inputStr.length > 50 ? '...' : ''} (Ctrl+O to expand)`
|
|
232
|
+
: `🔧 ${toolName}: ${inputStr.slice(0, 200)}`,
|
|
233
|
+
toolMeta: { name: toolName, input: inputStr, collapsed },
|
|
188
234
|
},
|
|
189
235
|
])
|
|
190
236
|
}
|
|
191
237
|
|
|
192
238
|
if (chunk.type === 'tool_result') {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
239
|
+
setAgentProgress(null)
|
|
240
|
+
setMessages((prev) => {
|
|
241
|
+
const updated = [...prev]
|
|
242
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
243
|
+
if (updated[i]?.toolMeta && !updated[i]!.toolMeta!.output) {
|
|
244
|
+
updated[i] = {
|
|
245
|
+
...updated[i]!,
|
|
246
|
+
content: updated[i]!.toolMeta!.collapsed
|
|
247
|
+
? updated[i]!.content
|
|
248
|
+
: `📋 ${updated[i]!.toolMeta!.name} result: ${(chunk.content || '(empty)').slice(0, 200)}`,
|
|
249
|
+
toolMeta: { ...updated[i]!.toolMeta!, output: chunk.content || '' },
|
|
250
|
+
}
|
|
251
|
+
break
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return updated
|
|
255
|
+
})
|
|
201
256
|
}
|
|
202
257
|
|
|
203
258
|
if (chunk.type === 'error') {
|
|
@@ -211,6 +266,7 @@ export function App({
|
|
|
211
266
|
setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
|
|
212
267
|
} finally {
|
|
213
268
|
setIsLoading(false)
|
|
269
|
+
abortRef.current = null
|
|
214
270
|
// Auto-save checkpoint after each AI response
|
|
215
271
|
if (assistantContent) {
|
|
216
272
|
engine.getContext().saveCheckpoint('post-turn')
|
|
@@ -227,6 +283,10 @@ export function App({
|
|
|
227
283
|
setPickerOpen(false)
|
|
228
284
|
return
|
|
229
285
|
}
|
|
286
|
+
if (isLoading && abortRef.current) {
|
|
287
|
+
abortRef.current.abort()
|
|
288
|
+
return
|
|
289
|
+
}
|
|
230
290
|
process.exit(0)
|
|
231
291
|
}
|
|
232
292
|
// Ctrl+P → open model picker
|
|
@@ -234,6 +294,34 @@ export function App({
|
|
|
234
294
|
setPickerOpen((prev) => !prev)
|
|
235
295
|
return
|
|
236
296
|
}
|
|
297
|
+
// Ctrl+O → toggle last tool call expand/collapse
|
|
298
|
+
if (_input === '\x0f') {
|
|
299
|
+
setMessages((prev) => {
|
|
300
|
+
const msgs = [...prev]
|
|
301
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
302
|
+
if (msgs[i]?.toolMeta) {
|
|
303
|
+
const meta = msgs[i]!.toolMeta!
|
|
304
|
+
if (meta.collapsed) {
|
|
305
|
+
msgs[i] = {
|
|
306
|
+
...msgs[i]!,
|
|
307
|
+
content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
|
|
308
|
+
toolMeta: { ...meta, collapsed: false },
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
const short = meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
|
|
312
|
+
msgs[i] = {
|
|
313
|
+
...msgs[i]!,
|
|
314
|
+
content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
|
|
315
|
+
toolMeta: { ...meta, collapsed: true },
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
break
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return msgs
|
|
322
|
+
})
|
|
323
|
+
return
|
|
324
|
+
}
|
|
237
325
|
// Shift+Tab → cycle permission mode (auto → ask → bypass → auto)
|
|
238
326
|
if (key.shift && key.tab) {
|
|
239
327
|
setPermissionMode((prev) => {
|
|
@@ -255,7 +343,7 @@ export function App({
|
|
|
255
343
|
<Text bold color="cyan">
|
|
256
344
|
Mipham Code
|
|
257
345
|
</Text>
|
|
258
|
-
<Text dimColor> v0.
|
|
346
|
+
<Text dimColor> v0.3.0</Text>
|
|
259
347
|
{sessionTitle ? (
|
|
260
348
|
<Text color="yellow"> — {sessionTitle}</Text>
|
|
261
349
|
) : (
|
|
@@ -278,7 +366,7 @@ export function App({
|
|
|
278
366
|
● {PERMISSION_LABELS[permissionMode]}
|
|
279
367
|
</Text>
|
|
280
368
|
<Text dimColor> (Shift+Tab to cycle)</Text>
|
|
281
|
-
<Text dimColor> · Ctrl+P pick · /help · Esc
|
|
369
|
+
<Text dimColor> · Ctrl+P pick · /help · Esc cancel</Text>
|
|
282
370
|
</Box>
|
|
283
371
|
{goalText && (
|
|
284
372
|
<Box>
|
|
@@ -287,6 +375,21 @@ export function App({
|
|
|
287
375
|
)}
|
|
288
376
|
</Box>
|
|
289
377
|
|
|
378
|
+
{/* Agent progress banner */}
|
|
379
|
+
{agentProgress && (
|
|
380
|
+
<Box flexDirection="column" marginY={1}>
|
|
381
|
+
<Text color="cyan" bold>
|
|
382
|
+
⏺ {agentProgress.name}{' '}
|
|
383
|
+
<Text color="yellow">{agentElapsed}s</Text>
|
|
384
|
+
</Text>
|
|
385
|
+
<Text dimColor>
|
|
386
|
+
{agentProgress.description.length > 100
|
|
387
|
+
? `"${agentProgress.description.slice(0, 100)}..."`
|
|
388
|
+
: `"${agentProgress.description}"`}
|
|
389
|
+
</Text>
|
|
390
|
+
</Box>
|
|
391
|
+
)}
|
|
392
|
+
|
|
290
393
|
{/* Chat panel */}
|
|
291
394
|
<ChatPanel messages={messages} focusMode={focusMode} />
|
|
292
395
|
|
package/src/ui/chat.tsx
CHANGED
|
@@ -37,17 +37,35 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
|
|
|
37
37
|
exit
|
|
38
38
|
</Text>
|
|
39
39
|
</Box>
|
|
40
|
+
<Box marginTop={1}>
|
|
41
|
+
<Text dimColor>
|
|
42
|
+
Tip: Use <Text color="yellow">/clear</Text> to start fresh when switching topics
|
|
43
|
+
and free up context
|
|
44
|
+
</Text>
|
|
45
|
+
</Box>
|
|
40
46
|
</Box>
|
|
41
47
|
)}
|
|
42
48
|
{displayMessages.map((msg, i) => (
|
|
43
49
|
<Box key={i} flexDirection="column" marginY={1}>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
{msg.toolMeta ? (
|
|
51
|
+
<Box flexDirection="column">
|
|
52
|
+
<Text color="yellow">
|
|
53
|
+
{msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'}{' '}
|
|
54
|
+
{msg.toolMeta.name}
|
|
55
|
+
</Text>
|
|
56
|
+
<Text dimColor>{msg.content}</Text>
|
|
57
|
+
</Box>
|
|
58
|
+
) : (
|
|
59
|
+
<>
|
|
60
|
+
<Text
|
|
61
|
+
bold
|
|
62
|
+
color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
|
|
63
|
+
>
|
|
64
|
+
{msg.role === 'user' ? '▸ You' : msg.role === 'assistant' ? 'Mipham Code' : '⚠ System'}:
|
|
65
|
+
</Text>
|
|
66
|
+
<Text>{msg.content}</Text>
|
|
67
|
+
</>
|
|
68
|
+
)}
|
|
51
69
|
</Box>
|
|
52
70
|
))}
|
|
53
71
|
</Box>
|