@miphamai/cli 0.2.4 ā 0.4.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/bin/mipham.ts +2 -0
- package/dist/mipham +0 -0
- package/package.json +10 -9
- package/skills/mipham/om-artifact.mipham-skill.md +69 -0
- package/skills/mipham/om-model-optimize.mipham-skill.md +58 -9
- package/skills/mipham/om-security.mipham-skill.md +85 -10
- package/skills/standard/doc-generator.SKILL.md +72 -11
- package/skills/standard/github-ops.SKILL.md +94 -13
- package/skills/standard/memory.SKILL.md +72 -11
- package/skills/standard/self-review.SKILL.md +50 -10
- package/skills/standard/superpower.SKILL.md +46 -8
- package/skills/standard/tdd.SKILL.md +85 -12
- package/skills/standard/web-search.SKILL.md +65 -13
- package/src/agent/sub-agent.ts +0 -1
- package/src/artifacts/manifest.ts +101 -0
- package/src/artifacts/server.ts +343 -0
- package/src/core/context.ts +73 -13
- package/src/core/engine.ts +194 -71
- package/src/core/instructions.ts +1 -1
- package/src/core/permission.ts +35 -4
- package/src/core/session-store.ts +5 -1
- package/src/index.tsx +35 -2
- package/src/mcp/transport.ts +42 -5
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/fetch-utils.ts +101 -0
- package/src/providers/openai-compat.ts +53 -15
- package/src/providers/registry.ts +1 -0
- package/src/shared/constants.ts +8 -1
- package/src/shared/types.ts +21 -1
- package/src/skills/loader.ts +2 -2
- package/src/tools/artifact/artifact.ts +144 -0
- package/src/tools/index.ts +3 -0
- package/src/ui/app.tsx +122 -15
- package/src/ui/chat.tsx +29 -7
- package/src/ui/commands.ts +117 -19
- package/src/ui/input.tsx +117 -35
- package/src/ui/picker.tsx +2 -5
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ProviderConfig,
|
|
3
|
-
ModelInfo,
|
|
4
|
-
Message,
|
|
5
|
-
StreamChunk,
|
|
6
|
-
ContentBlock,
|
|
7
|
-
} from '../shared/index.ts'
|
|
1
|
+
import type { ProviderConfig, ModelInfo, Message, StreamChunk } from '../shared/index.ts'
|
|
8
2
|
import type { ProviderInstance, ChatRequest } from './registry'
|
|
3
|
+
import { fetchWithRetry } from './fetch-utils'
|
|
9
4
|
|
|
10
5
|
export class OpenAICompatProvider implements ProviderInstance {
|
|
11
6
|
constructor(public config: ProviderConfig) {}
|
|
@@ -23,7 +18,7 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
23
18
|
tools: req.tools?.map((t) => ({ type: 'function', function: t })),
|
|
24
19
|
}
|
|
25
20
|
|
|
26
|
-
const response = await
|
|
21
|
+
const response = await fetchWithRetry(`${baseUrl}/chat/completions`, {
|
|
27
22
|
method: 'POST',
|
|
28
23
|
headers: {
|
|
29
24
|
'Content-Type': 'application/json',
|
|
@@ -47,6 +42,9 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
47
42
|
const decoder = new TextDecoder()
|
|
48
43
|
let buffer = ''
|
|
49
44
|
|
|
45
|
+
// Track incremental tool calls across streaming deltas
|
|
46
|
+
const pendingToolCalls = new Map<number, { id: string; name: string; arguments: string }>()
|
|
47
|
+
|
|
50
48
|
while (true) {
|
|
51
49
|
const { done, value } = await reader.read()
|
|
52
50
|
if (done) break
|
|
@@ -60,6 +58,18 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
60
58
|
if (!trimmed || !trimmed.startsWith('data: ')) continue
|
|
61
59
|
const data = trimmed.slice(6)
|
|
62
60
|
if (data === '[DONE]') {
|
|
61
|
+
// Emit any pending tool calls before stopping
|
|
62
|
+
for (const [, tc] of pendingToolCalls) {
|
|
63
|
+
yield {
|
|
64
|
+
type: 'tool_use',
|
|
65
|
+
toolUse: {
|
|
66
|
+
type: 'tool_use',
|
|
67
|
+
id: tc.id,
|
|
68
|
+
name: tc.name,
|
|
69
|
+
input: this.safeParseJson(tc.arguments),
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
63
73
|
yield { type: 'stop' }
|
|
64
74
|
return
|
|
65
75
|
}
|
|
@@ -73,21 +83,39 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
73
83
|
|
|
74
84
|
if (delta?.tool_calls) {
|
|
75
85
|
for (const tc of delta.tool_calls) {
|
|
86
|
+
const idx = tc.index ?? 0
|
|
87
|
+
const pending = pendingToolCalls.get(idx) || {
|
|
88
|
+
id: '',
|
|
89
|
+
name: '',
|
|
90
|
+
arguments: '',
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (tc.id) pending.id = tc.id
|
|
94
|
+
if (tc.function?.name) pending.name = tc.function.name
|
|
95
|
+
if (tc.function?.arguments) pending.arguments += tc.function.arguments
|
|
96
|
+
|
|
97
|
+
pendingToolCalls.set(idx, pending)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (delta?.content) {
|
|
102
|
+
yield { type: 'text', content: delta.content }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (choice.finish_reason === 'tool_calls') {
|
|
106
|
+
// Emit fully accumulated tool calls
|
|
107
|
+
for (const [, tc] of pendingToolCalls) {
|
|
76
108
|
yield {
|
|
77
109
|
type: 'tool_use',
|
|
78
110
|
toolUse: {
|
|
79
111
|
type: 'tool_use',
|
|
80
112
|
id: tc.id || `call_${Date.now()}`,
|
|
81
|
-
name: tc.
|
|
82
|
-
input:
|
|
113
|
+
name: tc.name,
|
|
114
|
+
input: this.safeParseJson(tc.arguments),
|
|
83
115
|
},
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (delta?.content) {
|
|
90
|
-
yield { type: 'text', content: delta.content }
|
|
118
|
+
pendingToolCalls.clear()
|
|
91
119
|
}
|
|
92
120
|
|
|
93
121
|
if (choice.finish_reason === 'stop') {
|
|
@@ -148,6 +176,16 @@ export class OpenAICompatProvider implements ProviderInstance {
|
|
|
148
176
|
return result
|
|
149
177
|
}
|
|
150
178
|
|
|
179
|
+
/** Safely parse JSON, returning an empty object on failure. */
|
|
180
|
+
private safeParseJson(raw: string): Record<string, unknown> {
|
|
181
|
+
if (!raw) return {}
|
|
182
|
+
try {
|
|
183
|
+
return JSON.parse(raw)
|
|
184
|
+
} catch {
|
|
185
|
+
return { _raw: raw }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
151
189
|
private resolveApiKey(keyTemplate: string): string {
|
|
152
190
|
const match = keyTemplate.match(/^\$\{(.+)\}$/)
|
|
153
191
|
if (match?.[1]) {
|
package/src/shared/constants.ts
CHANGED
|
@@ -361,8 +361,15 @@ export const PROTOCOL_LABELS: Record<string, string> = {
|
|
|
361
361
|
custom: 'Custom Protocol',
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
-
export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system'] as const
|
|
364
|
+
export const TOOL_CATEGORIES = ['file', 'exec', 'agent', 'network', 'system', 'artifact'] as const
|
|
365
365
|
export const CONFIG_FILE_NAME = 'config.yml'
|
|
366
366
|
export const MIPHAM_DIR = '.mipham'
|
|
367
367
|
export const USER_CONFIG_DIR = '.mipham'
|
|
368
368
|
export const MEMORY_DIR = 'memory'
|
|
369
|
+
|
|
370
|
+
// āā Artifact Constants āā
|
|
371
|
+
export const ARTIFACTS_DIR = 'artifacts'
|
|
372
|
+
export const ARTIFACT_PORT = 9876
|
|
373
|
+
export const ARTIFACT_MAX_PORT_TRIES = 10
|
|
374
|
+
export const ARTIFACT_MAX_SIZE = 5 * 1024 * 1024 // 5MB
|
|
375
|
+
export const ARTIFACT_ALLOWED_EXTENSIONS = ['.html', '.svg']
|
package/src/shared/types.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface Message {
|
|
|
50
50
|
|
|
51
51
|
// āā Tool Types āā
|
|
52
52
|
export type ToolPermission = 'auto' | 'ask' | 'bypass'
|
|
53
|
-
export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system'
|
|
53
|
+
export type ToolCategory = 'file' | 'exec' | 'agent' | 'network' | 'system' | 'artifact'
|
|
54
54
|
|
|
55
55
|
export interface ToolContext {
|
|
56
56
|
cwd: string
|
|
@@ -59,6 +59,26 @@ export interface ToolContext {
|
|
|
59
59
|
model: string
|
|
60
60
|
skillsLoader?: import('../skills/loader').SkillsLoader
|
|
61
61
|
registry?: import('../providers/registry').ProviderRegistry
|
|
62
|
+
artifactServer?: import('../artifacts/server').ArtifactServer
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// āā Artifact Types āā
|
|
66
|
+
export interface ArtifactEntry {
|
|
67
|
+
name: string
|
|
68
|
+
path: string
|
|
69
|
+
url: string
|
|
70
|
+
size: number
|
|
71
|
+
type: 'html' | 'svg'
|
|
72
|
+
createdAt: string
|
|
73
|
+
sessionId: string
|
|
74
|
+
versions?: string[] // version tags e.g. ['v1', 'v2']
|
|
75
|
+
versionCount?: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ArtifactManifest {
|
|
79
|
+
version: 1
|
|
80
|
+
artifacts: ArtifactEntry[]
|
|
81
|
+
port?: number
|
|
62
82
|
}
|
|
63
83
|
|
|
64
84
|
export interface ToolResult {
|
package/src/skills/loader.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { join
|
|
2
|
+
import { join } from 'node:path'
|
|
3
3
|
import { parse as parseYaml } from 'yaml'
|
|
4
4
|
import type { SkillDefinition } from '../shared/index.ts'
|
|
5
5
|
|
|
@@ -84,7 +84,7 @@ export class SkillsLoader {
|
|
|
84
84
|
private tryLoad(path: string, type: 'standard' | 'mipham'): void {
|
|
85
85
|
try {
|
|
86
86
|
const raw = readFileSync(path, 'utf-8')
|
|
87
|
-
const { data
|
|
87
|
+
const { data } = parseFrontmatter(raw)
|
|
88
88
|
|
|
89
89
|
const skill: SkillDefinition = {
|
|
90
90
|
name: (data.name as string) || this.nameFromPath(path),
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { writeFileSync, mkdirSync, existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ToolDefinition } from '../../shared/index.ts'
|
|
4
|
+
import { ARTIFACTS_DIR, ARTIFACT_MAX_SIZE } from '../../shared/constants'
|
|
5
|
+
import { addToManifest, readManifest, archiveVersion } from '../../artifacts/manifest'
|
|
6
|
+
|
|
7
|
+
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/
|
|
8
|
+
|
|
9
|
+
export const artifactTool: ToolDefinition = {
|
|
10
|
+
name: 'Artifact',
|
|
11
|
+
description:
|
|
12
|
+
'Create an interactive HTML or SVG artifact that opens in the browser. Use for dashboards, visualizations, reports, and interactive demos. Content must be self-contained ā no external CDN references.',
|
|
13
|
+
category: 'artifact',
|
|
14
|
+
permission: 'ask',
|
|
15
|
+
parameters: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
properties: {
|
|
18
|
+
name: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Short kebab-case name (e.g. "user-dashboard", "pipeline-diagram")',
|
|
21
|
+
},
|
|
22
|
+
type: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
enum: ['html', 'svg'],
|
|
25
|
+
description: 'Artifact type',
|
|
26
|
+
},
|
|
27
|
+
content: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
description: 'Full HTML or SVG content (self-contained, inline CSS/JS only)',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ['name', 'type', 'content'],
|
|
33
|
+
},
|
|
34
|
+
async execute(params, ctx) {
|
|
35
|
+
const name = params.name as string
|
|
36
|
+
const type = params.type as string
|
|
37
|
+
const content = params.content as string
|
|
38
|
+
|
|
39
|
+
// Validate name
|
|
40
|
+
if (!NAME_PATTERN.test(name) || name.length < 3) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
content: '',
|
|
44
|
+
error:
|
|
45
|
+
`Invalid artifact name "${name}". Use kebab-case (letters, numbers, hyphens), ` +
|
|
46
|
+
`at least 3 characters, e.g. "my-dashboard".`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate size
|
|
51
|
+
const size = Buffer.byteLength(content, 'utf-8')
|
|
52
|
+
if (size > ARTIFACT_MAX_SIZE) {
|
|
53
|
+
return {
|
|
54
|
+
success: false,
|
|
55
|
+
content: '',
|
|
56
|
+
error: `Content size ${(size / 1_048_576).toFixed(1)}MB exceeds ${ARTIFACT_MAX_SIZE / 1_048_576}MB limit.`,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Determine output paths
|
|
61
|
+
const baseDir = join(ctx.cwd, ARTIFACTS_DIR)
|
|
62
|
+
const sessionDir = join(baseDir, ctx.sessionId)
|
|
63
|
+
mkdirSync(sessionDir, { recursive: true })
|
|
64
|
+
|
|
65
|
+
const ext = type === 'svg' ? '.svg' : '.html'
|
|
66
|
+
const filename = `${name}${ext}`
|
|
67
|
+
const filepath = join(sessionDir, filename)
|
|
68
|
+
|
|
69
|
+
// Start server if not running
|
|
70
|
+
let port: number | undefined
|
|
71
|
+
if (ctx.artifactServer && !ctx.artifactServer.isRunning()) {
|
|
72
|
+
port = await ctx.artifactServer.start()
|
|
73
|
+
} else if (ctx.artifactServer) {
|
|
74
|
+
port = ctx.artifactServer.getPort()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Archive previous version if this artifact already exists
|
|
78
|
+
let archivedVersion: string | undefined
|
|
79
|
+
const isUpdate = existsSync(filepath)
|
|
80
|
+
if (isUpdate) {
|
|
81
|
+
const manifest = readManifest(baseDir)
|
|
82
|
+
const existing = manifest.artifacts.find(
|
|
83
|
+
(a) => a.name === name && a.sessionId === ctx.sessionId,
|
|
84
|
+
)
|
|
85
|
+
if (existing) {
|
|
86
|
+
archivedVersion = archiveVersion(baseDir, existing)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Write artifact
|
|
91
|
+
writeFileSync(filepath, content, 'utf-8')
|
|
92
|
+
|
|
93
|
+
// Build URL and manifest entry
|
|
94
|
+
const url = port
|
|
95
|
+
? `http://localhost:${port}/${ctx.sessionId}/${filename}`
|
|
96
|
+
: `file://${filepath}`
|
|
97
|
+
|
|
98
|
+
// Preserve existing version count
|
|
99
|
+
const manifestPre = readManifest(baseDir)
|
|
100
|
+
const prev = manifestPre.artifacts.find((a) => a.name === name && a.sessionId === ctx.sessionId)
|
|
101
|
+
const versionCount = prev?.versionCount || (isUpdate ? 1 : undefined)
|
|
102
|
+
|
|
103
|
+
addToManifest(
|
|
104
|
+
baseDir,
|
|
105
|
+
{
|
|
106
|
+
name,
|
|
107
|
+
path: filepath,
|
|
108
|
+
url,
|
|
109
|
+
size,
|
|
110
|
+
type: type as 'html' | 'svg',
|
|
111
|
+
createdAt: new Date().toISOString(),
|
|
112
|
+
sessionId: ctx.sessionId,
|
|
113
|
+
versions: prev?.versions,
|
|
114
|
+
versionCount,
|
|
115
|
+
},
|
|
116
|
+
port,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
// Notify SSE clients to refresh Gallery
|
|
120
|
+
if (ctx.artifactServer) {
|
|
121
|
+
ctx.artifactServer.notifyReload()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const galleryUrl = port ? `http://localhost:${port}` : undefined
|
|
125
|
+
const versionLine = archivedVersion ? ` Prev archived as: ${archivedVersion}` : ''
|
|
126
|
+
const galleryLine = galleryUrl ? `Gallery: ${galleryUrl}` : ''
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
content: [
|
|
131
|
+
`ā Artifact "${name}" saved${isUpdate ? ' (updated)' : ''}.`,
|
|
132
|
+
` URL: ${url}`,
|
|
133
|
+
` Size: ${size.toLocaleString()} bytes`,
|
|
134
|
+
versionLine,
|
|
135
|
+
galleryLine,
|
|
136
|
+
'',
|
|
137
|
+
`Open in browser: /artifact open ${name}`,
|
|
138
|
+
`List all: /artifact list`,
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join('\n'),
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
}
|
package/src/tools/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { webFetchTool } from './network/web-fetch'
|
|
|
15
15
|
import { webSearchTool } from './network/web-search'
|
|
16
16
|
import { configTool } from './system/config'
|
|
17
17
|
import { mcpTool } from './system/mcp'
|
|
18
|
+
import { artifactTool } from './artifact/artifact'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* Validate tool parameters against the tool's JSON Schema definition.
|
|
@@ -116,6 +117,8 @@ export function createToolRegistry(): Map<string, ToolDefinition> {
|
|
|
116
117
|
// System tools
|
|
117
118
|
withValidation(configTool),
|
|
118
119
|
withValidation(mcpTool),
|
|
120
|
+
// Artifact tools
|
|
121
|
+
withValidation(artifactTool),
|
|
119
122
|
]
|
|
120
123
|
|
|
121
124
|
const map = new Map<string, ToolDefinition>()
|
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
|
|
|
@@ -44,7 +58,7 @@ export function App({
|
|
|
44
58
|
config,
|
|
45
59
|
initialProvider,
|
|
46
60
|
initialModel,
|
|
47
|
-
lang,
|
|
61
|
+
lang: _lang,
|
|
48
62
|
skillsLoader,
|
|
49
63
|
}: AppProps) {
|
|
50
64
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
@@ -58,6 +72,21 @@ 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) {
|
|
82
|
+
setAgentElapsed(0)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const interval = setInterval(() => {
|
|
86
|
+
setAgentElapsed(Math.floor((Date.now() - agentProgress.startTime) / 1000))
|
|
87
|
+
}, 1000)
|
|
88
|
+
return () => clearInterval(interval)
|
|
89
|
+
}, [agentProgress])
|
|
61
90
|
|
|
62
91
|
const mkCtx = useCallback(
|
|
63
92
|
(): CommandContext => ({
|
|
@@ -161,10 +190,13 @@ export function App({
|
|
|
161
190
|
setMessages((prev) => [...prev, { role: 'user', content: input }])
|
|
162
191
|
setIsLoading(true)
|
|
163
192
|
|
|
193
|
+
const controller = new AbortController()
|
|
194
|
+
abortRef.current = controller
|
|
195
|
+
|
|
164
196
|
let assistantContent = ''
|
|
165
197
|
|
|
166
198
|
try {
|
|
167
|
-
for await (const chunk of engine.process(input)) {
|
|
199
|
+
for await (const chunk of engine.process(input, controller.signal)) {
|
|
168
200
|
if (chunk.type === 'text' && chunk.content) {
|
|
169
201
|
assistantContent += chunk.content
|
|
170
202
|
setMessages((prev) => {
|
|
@@ -180,24 +212,52 @@ export function App({
|
|
|
180
212
|
}
|
|
181
213
|
|
|
182
214
|
if (chunk.type === 'tool_use' && chunk.toolUse) {
|
|
215
|
+
const toolName = chunk.toolUse.name
|
|
216
|
+
const inputStr = JSON.stringify(chunk.toolUse.input)
|
|
217
|
+
const isAgent = toolName === 'Agent' || toolName === 'Task'
|
|
218
|
+
|
|
219
|
+
if (isAgent) {
|
|
220
|
+
setAgentProgress({
|
|
221
|
+
name: (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
|
|
222
|
+
description:
|
|
223
|
+
(chunk.toolUse.input.description as string) ||
|
|
224
|
+
(chunk.toolUse.input.prompt as string) ||
|
|
225
|
+
'',
|
|
226
|
+
startTime: Date.now(),
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const collapsed = toolName !== 'Agent' // agents always expanded
|
|
183
231
|
setMessages((prev) => [
|
|
184
232
|
...prev,
|
|
185
233
|
{
|
|
186
234
|
role: 'system',
|
|
187
|
-
content:
|
|
235
|
+
content: collapsed
|
|
236
|
+
? `āŗ ${toolName} Ā· ${inputStr.slice(0, 50)}${inputStr.length > 50 ? '...' : ''} (Ctrl+O to expand)`
|
|
237
|
+
: `š§ ${toolName}: ${inputStr.slice(0, 200)}`,
|
|
238
|
+
toolMeta: { name: toolName, input: inputStr, collapsed },
|
|
188
239
|
},
|
|
189
240
|
])
|
|
190
241
|
}
|
|
191
242
|
|
|
192
243
|
if (chunk.type === 'tool_result') {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
244
|
+
setAgentProgress(null)
|
|
245
|
+
setMessages((prev) => {
|
|
246
|
+
const updated = [...prev]
|
|
247
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
248
|
+
if (updated[i]?.toolMeta && !updated[i]!.toolMeta!.output) {
|
|
249
|
+
updated[i] = {
|
|
250
|
+
...updated[i]!,
|
|
251
|
+
content: updated[i]!.toolMeta!.collapsed
|
|
252
|
+
? updated[i]!.content
|
|
253
|
+
: `š ${updated[i]!.toolMeta!.name} result: ${(chunk.content || '(empty)').slice(0, 200)}`,
|
|
254
|
+
toolMeta: { ...updated[i]!.toolMeta!, output: chunk.content || '' },
|
|
255
|
+
}
|
|
256
|
+
break
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return updated
|
|
260
|
+
})
|
|
201
261
|
}
|
|
202
262
|
|
|
203
263
|
if (chunk.type === 'error') {
|
|
@@ -211,6 +271,7 @@ export function App({
|
|
|
211
271
|
setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
|
|
212
272
|
} finally {
|
|
213
273
|
setIsLoading(false)
|
|
274
|
+
abortRef.current = null
|
|
214
275
|
// Auto-save checkpoint after each AI response
|
|
215
276
|
if (assistantContent) {
|
|
216
277
|
engine.getContext().saveCheckpoint('post-turn')
|
|
@@ -227,6 +288,10 @@ export function App({
|
|
|
227
288
|
setPickerOpen(false)
|
|
228
289
|
return
|
|
229
290
|
}
|
|
291
|
+
if (isLoading && abortRef.current) {
|
|
292
|
+
abortRef.current.abort()
|
|
293
|
+
return
|
|
294
|
+
}
|
|
230
295
|
process.exit(0)
|
|
231
296
|
}
|
|
232
297
|
// Ctrl+P ā open model picker
|
|
@@ -234,6 +299,34 @@ export function App({
|
|
|
234
299
|
setPickerOpen((prev) => !prev)
|
|
235
300
|
return
|
|
236
301
|
}
|
|
302
|
+
// Ctrl+O ā toggle last tool call expand/collapse
|
|
303
|
+
if (_input === '\x0f') {
|
|
304
|
+
setMessages((prev) => {
|
|
305
|
+
const msgs = [...prev]
|
|
306
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
307
|
+
if (msgs[i]?.toolMeta) {
|
|
308
|
+
const meta = msgs[i]!.toolMeta!
|
|
309
|
+
if (meta.collapsed) {
|
|
310
|
+
msgs[i] = {
|
|
311
|
+
...msgs[i]!,
|
|
312
|
+
content: `š§ ${meta.name}: ${meta.input}\nš Result: ${meta.output || '(pending)'}`,
|
|
313
|
+
toolMeta: { ...meta, collapsed: false },
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
const short = meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
|
|
317
|
+
msgs[i] = {
|
|
318
|
+
...msgs[i]!,
|
|
319
|
+
content: `āŗ ${meta.name} Ā· ${short} (Ctrl+O to expand)`,
|
|
320
|
+
toolMeta: { ...meta, collapsed: true },
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
break
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return msgs
|
|
327
|
+
})
|
|
328
|
+
return
|
|
329
|
+
}
|
|
237
330
|
// Shift+Tab ā cycle permission mode (auto ā ask ā bypass ā auto)
|
|
238
331
|
if (key.shift && key.tab) {
|
|
239
332
|
setPermissionMode((prev) => {
|
|
@@ -255,7 +348,7 @@ export function App({
|
|
|
255
348
|
<Text bold color="cyan">
|
|
256
349
|
Mipham Code
|
|
257
350
|
</Text>
|
|
258
|
-
<Text dimColor> v0.
|
|
351
|
+
<Text dimColor> v0.3.0</Text>
|
|
259
352
|
{sessionTitle ? (
|
|
260
353
|
<Text color="yellow"> ā {sessionTitle}</Text>
|
|
261
354
|
) : (
|
|
@@ -278,7 +371,7 @@ export function App({
|
|
|
278
371
|
ā {PERMISSION_LABELS[permissionMode]}
|
|
279
372
|
</Text>
|
|
280
373
|
<Text dimColor> (Shift+Tab to cycle)</Text>
|
|
281
|
-
<Text dimColor> Ā· Ctrl+P pick Ā· /help Ā· Esc
|
|
374
|
+
<Text dimColor> Ā· Ctrl+P pick Ā· /help Ā· Esc cancel</Text>
|
|
282
375
|
</Box>
|
|
283
376
|
{goalText && (
|
|
284
377
|
<Box>
|
|
@@ -287,6 +380,20 @@ export function App({
|
|
|
287
380
|
)}
|
|
288
381
|
</Box>
|
|
289
382
|
|
|
383
|
+
{/* Agent progress banner */}
|
|
384
|
+
{agentProgress && (
|
|
385
|
+
<Box flexDirection="column" marginY={1}>
|
|
386
|
+
<Text color="cyan" bold>
|
|
387
|
+
āŗ {agentProgress.name} <Text color="yellow">{agentElapsed}s</Text>
|
|
388
|
+
</Text>
|
|
389
|
+
<Text dimColor>
|
|
390
|
+
{agentProgress.description.length > 100
|
|
391
|
+
? `"${agentProgress.description.slice(0, 100)}..."`
|
|
392
|
+
: `"${agentProgress.description}"`}
|
|
393
|
+
</Text>
|
|
394
|
+
</Box>
|
|
395
|
+
)}
|
|
396
|
+
|
|
290
397
|
{/* Chat panel */}
|
|
291
398
|
<ChatPanel messages={messages} focusMode={focusMode} />
|
|
292
399
|
|
package/src/ui/chat.tsx
CHANGED
|
@@ -37,17 +37,39 @@ 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 and
|
|
43
|
+
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 ? 'āŗ' : 'āŗ ā¼'} {msg.toolMeta.name}
|
|
54
|
+
</Text>
|
|
55
|
+
<Text dimColor>{msg.content}</Text>
|
|
56
|
+
</Box>
|
|
57
|
+
) : (
|
|
58
|
+
<>
|
|
59
|
+
<Text
|
|
60
|
+
bold
|
|
61
|
+
color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
|
|
62
|
+
>
|
|
63
|
+
{msg.role === 'user'
|
|
64
|
+
? 'āø You'
|
|
65
|
+
: msg.role === 'assistant'
|
|
66
|
+
? 'Mipham Code'
|
|
67
|
+
: 'ā System'}
|
|
68
|
+
:
|
|
69
|
+
</Text>
|
|
70
|
+
<Text>{msg.content}</Text>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
51
73
|
</Box>
|
|
52
74
|
))}
|
|
53
75
|
</Box>
|