✨ feat(vscode): 提交代码按钮实时显示进度并预检 git 身份
- spawnClaude 新增 onProgress 回调,给了回调就改走 stream-json 逐行输出,按行解析出工具调用 / 回复文本 - 新增 claudeProgress 把每行 NDJSON 压成可读进度短语 - 提交前先读 git user.name / user.email,缺失直接提示,不再让 claude 白跑 - 运行期间用带转圈的 toast 滚动显示进度,完成后带上 claude 的结果摘要 Claude-Session: https://claude.ai/code/session_01HTMgwaE3woqM5yvsHiuSAa
This commit is contained in:
1 parent
443a8e472a
commit
73a0c46f9f
6 files changed
+322
-25
No files matched your search
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { summarizeClaudeStreamLine } from './claudeProgress'
|
||||
|
||||
function assistantLine(content: unknown[]): string {
|
||||
return JSON.stringify({ type: 'assistant', message: { role: 'assistant', content } })
|
||||
}
|
||||
|
||||
describe('summarizeClaudeStreamLine', () => {
|
||||
it('工具 Bash 有 description 时优先用 description', () => {
|
||||
const line = assistantLine([
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'pnpm test', description: '跑全量测试' } },
|
||||
])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['执行命令:跑全量测试'])
|
||||
})
|
||||
|
||||
it('工具 Bash 没 description 时回退到 command,超长截断', () => {
|
||||
const command = 'x'.repeat(80)
|
||||
const line = assistantLine([{ type: 'tool_use', name: 'Bash', input: { command } }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual([`执行命令:${'x'.repeat(60)}…`])
|
||||
})
|
||||
|
||||
it('工具 Read 只显示文件名', () => {
|
||||
const line = assistantLine([{ type: 'tool_use', name: 'Read', input: { file_path: '/abs/dir/x.py' } }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['读取 x.py'])
|
||||
})
|
||||
|
||||
it('工具 Edit / Write / MultiEdit 显示为修改', () => {
|
||||
for (const name of ['Edit', 'Write', 'MultiEdit']) {
|
||||
const line = assistantLine([{ type: 'tool_use', name, input: { file_path: '/a/b/c.ts' } }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['修改 c.ts'])
|
||||
}
|
||||
})
|
||||
|
||||
it('工具 Grep / Glob 显示搜索模式', () => {
|
||||
const line = assistantLine([{ type: 'tool_use', name: 'Grep', input: { pattern: 'foo.*bar' } }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['搜索 foo.*bar'])
|
||||
})
|
||||
|
||||
it('未知工具显示调用 + 工具名', () => {
|
||||
const line = assistantLine([{ type: 'tool_use', name: 'TodoWrite', input: {} }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['调用 TodoWrite'])
|
||||
})
|
||||
|
||||
it('text 块取第一行并截断', () => {
|
||||
const line = assistantLine([{ type: 'text', text: `${'好'.repeat(70)}\n第二行` }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual([`${'好'.repeat(60)}…`])
|
||||
})
|
||||
|
||||
it('空白 text 块忽略', () => {
|
||||
const line = assistantLine([{ type: 'text', text: ' \n\n' }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual([])
|
||||
})
|
||||
|
||||
it('thinking 块忽略', () => {
|
||||
const line = assistantLine([{ type: 'thinking', thinking: '让我想想' }])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual([])
|
||||
})
|
||||
|
||||
it('system / user / result 行忽略', () => {
|
||||
expect(summarizeClaudeStreamLine(JSON.stringify({ type: 'system', subtype: 'init' }))).toEqual([])
|
||||
expect(summarizeClaudeStreamLine(JSON.stringify({
|
||||
type: 'user',
|
||||
message: { content: [{ type: 'tool_result', content: 'ok' }] },
|
||||
}))).toEqual([])
|
||||
expect(summarizeClaudeStreamLine(JSON.stringify({ type: 'result', result: 'done', session_id: 's' }))).toEqual([])
|
||||
})
|
||||
|
||||
it('坏 JSON 返回空数组', () => {
|
||||
expect(summarizeClaudeStreamLine('{not json')).toEqual([])
|
||||
expect(summarizeClaudeStreamLine('')).toEqual([])
|
||||
expect(summarizeClaudeStreamLine('null')).toEqual([])
|
||||
})
|
||||
|
||||
it('多个 block 按顺序输出', () => {
|
||||
const line = assistantLine([
|
||||
{ type: 'thinking', thinking: '...' },
|
||||
{ type: 'text', text: '先看看代码' },
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/p/a.ts' } },
|
||||
{ type: 'tool_use', name: 'Bash', input: { command: 'git status' } },
|
||||
])
|
||||
expect(summarizeClaudeStreamLine(line)).toEqual(['先看看代码', '读取 a.ts', '执行命令:git status'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 把 `claude -p --output-format stream-json --verbose` 吐出的每行 NDJSON
|
||||
* 压缩成人能看懂的进度短语,供长任务(如提交代码)实时展示。
|
||||
*
|
||||
* 只关心 assistant 消息里的 `tool_use` 和 `text` 块,其它行(system / user /
|
||||
* result / thinking 块 / 坏 JSON)一律返回空数组,调用方无需再做判断。
|
||||
*/
|
||||
|
||||
import * as path from 'node:path'
|
||||
|
||||
const LABEL_MAX_CHARS = 60
|
||||
|
||||
interface ContentBlock {
|
||||
type?: unknown
|
||||
text?: unknown
|
||||
name?: unknown
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
function truncate(text: string): string {
|
||||
if (text.length <= LABEL_MAX_CHARS)
|
||||
return text
|
||||
return `${text.slice(0, LABEL_MAX_CHARS)}…`
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function describeToolUse(name: string, input: Record<string, unknown>): string {
|
||||
switch (name) {
|
||||
case 'Bash':
|
||||
return `执行命令:${truncate(asString(input.description) || asString(input.command))}`
|
||||
case 'Read':
|
||||
return `读取 ${path.basename(asString(input.file_path))}`
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
case 'MultiEdit':
|
||||
return `修改 ${path.basename(asString(input.file_path))}`
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return `搜索 ${truncate(asString(input.pattern))}`
|
||||
default:
|
||||
return `调用 ${name}`
|
||||
}
|
||||
}
|
||||
|
||||
function describeBlock(block: ContentBlock): string | null {
|
||||
if (block.type === 'tool_use' && typeof block.name === 'string') {
|
||||
const input = block.input && typeof block.input === 'object'
|
||||
? block.input as Record<string, unknown>
|
||||
: {}
|
||||
return describeToolUse(block.name, input)
|
||||
}
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
const firstLine = block.text.split('\n').map(l => l.trim()).find(l => l.length > 0)
|
||||
return firstLine ? truncate(firstLine) : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function summarizeClaudeStreamLine(line: string): string[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(line)
|
||||
}
|
||||
catch {
|
||||
return []
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object')
|
||||
return []
|
||||
const obj = parsed as { type?: unknown, message?: { content?: unknown } }
|
||||
if (obj.type !== 'assistant' || !Array.isArray(obj.message?.content))
|
||||
return []
|
||||
|
||||
const labels: string[] = []
|
||||
for (const block of obj.message.content as ContentBlock[]) {
|
||||
if (!block || typeof block !== 'object')
|
||||
continue
|
||||
const label = describeBlock(block)
|
||||
if (label)
|
||||
labels.push(label)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with
|
||||
* `--output-format json`. We rely on `claude` being available on PATH;
|
||||
* callers should surface the install-hint error message to the user when
|
||||
* ENOENT is raised.
|
||||
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode. We rely on
|
||||
* `claude` being available on PATH; callers should surface the install-hint
|
||||
* error message to the user when ENOENT is raised.
|
||||
*
|
||||
* Two execution paths share the same output-parsing logic:
|
||||
*
|
||||
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json`
|
||||
* via `spawn` with stdin ignored so the CLI does not wait for piped input.
|
||||
* When the caller passes `onProgress`, this path switches to
|
||||
* `--output-format stream-json --verbose` so tool calls can be reported
|
||||
* live instead of only when the process exits.
|
||||
*
|
||||
* - **With images**: switch to `claude -p --input-format stream-json
|
||||
* --output-format json` via `spawn`, then feed a single NDJSON line on
|
||||
* --output-format stream-json` via `spawn`, then feed a single NDJSON line on
|
||||
* stdin containing the user message with text + image content blocks
|
||||
* (Anthropic Messages API shape). The official docs state single-message
|
||||
* input does NOT support image attachments — stream-json is the only
|
||||
@@ -21,6 +23,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
import { resolveClaudeBin } from './claudeBin'
|
||||
import { summarizeClaudeStreamLine } from './claudeProgress'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 300_000
|
||||
const MAX_BUFFER_BYTES = 10 * 1024 * 1024
|
||||
@@ -177,6 +180,27 @@ function buildClaudeExitDiagnostic(stderr: string, stdout: string): string {
|
||||
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)'
|
||||
}
|
||||
|
||||
export type ClaudeProgressListener = (label: string) => void
|
||||
|
||||
/**
|
||||
* 按行切 stdout:stream-json 每行一个对象,但 data 事件的 chunk 边界和行边界
|
||||
* 无关,一行常被拆进两个 chunk,所以末尾那段不完整的残行要留到下次拼上。
|
||||
*/
|
||||
function createProgressFeeder(onProgress?: ClaudeProgressListener): (chunk: string) => void {
|
||||
if (!onProgress)
|
||||
return () => {}
|
||||
let pending = ''
|
||||
return (chunk) => {
|
||||
pending += chunk
|
||||
const lines = pending.split('\n')
|
||||
pending = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
for (const label of summarizeClaudeStreamLine(line))
|
||||
onProgress(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
|
||||
const content: Array<Record<string, unknown>> = [
|
||||
{ type: 'text', text: prompt },
|
||||
@@ -246,14 +270,16 @@ export async function spawnClaude(opts: {
|
||||
bare?: boolean
|
||||
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
|
||||
resumeSessionId?: string
|
||||
/** 实时进度:每识别出一个工具调用 / 一句回复就回调一次可读标签。 */
|
||||
onProgress?: ClaudeProgressListener
|
||||
}): Promise<ClaudeResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
||||
const hasImages = !!opts.images && opts.images.length > 0
|
||||
|
||||
if (!hasImages) {
|
||||
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId)
|
||||
return spawnClaudeText(opts.prompt, opts.cwd, timeoutMs, opts.profilePath, opts.bare, opts.resumeSessionId, opts.onProgress)
|
||||
}
|
||||
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId)
|
||||
return spawnClaudeStreamed(opts.prompt, opts.cwd, timeoutMs, opts.images!, opts.profilePath, opts.bare, opts.resumeSessionId, opts.onProgress)
|
||||
}
|
||||
|
||||
function spawnClaudeText(
|
||||
@@ -263,7 +289,9 @@ function spawnClaudeText(
|
||||
profilePath?: string,
|
||||
bare?: boolean,
|
||||
resumeSessionId?: string,
|
||||
onProgress?: ClaudeProgressListener,
|
||||
): Promise<ClaudeResult> {
|
||||
// `--output-format json` 要到进程结束才有输出;要报进度就得换成逐行吐的 stream-json
|
||||
const args = [
|
||||
...(bare ? ['--bare'] : []),
|
||||
'--dangerously-skip-permissions',
|
||||
@@ -272,7 +300,7 @@ function spawnClaudeText(
|
||||
'-p',
|
||||
prompt,
|
||||
'--output-format',
|
||||
'json',
|
||||
...(onProgress ? ['stream-json', '--verbose'] : ['json']),
|
||||
]
|
||||
|
||||
const bin = resolveClaudeBin()
|
||||
@@ -284,6 +312,7 @@ function spawnClaudeText(
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let settled = false
|
||||
const feedProgress = createProgressFeeder(onProgress)
|
||||
|
||||
const child = spawn(bin, args, {
|
||||
cwd,
|
||||
@@ -332,7 +361,9 @@ function spawnClaudeText(
|
||||
))
|
||||
return
|
||||
}
|
||||
stdout += chunk.toString('utf8')
|
||||
const text = chunk.toString('utf8')
|
||||
stdout += text
|
||||
feedProgress(text)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
@@ -377,6 +408,7 @@ function spawnClaudeStreamed(
|
||||
profilePath?: string,
|
||||
bare?: boolean,
|
||||
resumeSessionId?: string,
|
||||
onProgress?: ClaudeProgressListener,
|
||||
): Promise<ClaudeResult> {
|
||||
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must
|
||||
// be paired with --output-format=stream-json (the friendlier `--output-format
|
||||
@@ -405,6 +437,7 @@ function spawnClaudeStreamed(
|
||||
let stderr = ''
|
||||
let stdoutBytes = 0
|
||||
let settled = false
|
||||
const feedProgress = createProgressFeeder(onProgress)
|
||||
|
||||
const child = spawn(bin, args, {
|
||||
cwd,
|
||||
@@ -453,7 +486,9 @@ function spawnClaudeStreamed(
|
||||
))
|
||||
return
|
||||
}
|
||||
stdout += chunk.toString('utf8')
|
||||
const text = chunk.toString('utf8')
|
||||
stdout += text
|
||||
feedProgress(text)
|
||||
})
|
||||
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
|
||||
Reference in new issue
Block a user