✨ 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:
cruldra committed 2026-08-31 22:11:09 +08:00
1 parent 443a8e472a
commit 73a0c46f9f
6 files changed
+322 -25

No files matched your search

+83
View File
@@ -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'])
})
})
+85
View File
@@ -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
}
+45 -10
View File
@@ -1,16 +1,18 @@
/** /**
* Typed wrapper around the `claude` CLI in `-p` (one-shot) mode with * Typed wrapper around the `claude` CLI in `-p` (one-shot) mode. We rely on
* `--output-format json`. We rely on `claude` being available on PATH; * `claude` being available on PATH; callers should surface the install-hint
* callers should surface the install-hint error message to the user when * error message to the user when ENOENT is raised.
* ENOENT is raised.
* *
* Two execution paths share the same output-parsing logic: * Two execution paths share the same output-parsing logic:
* *
* - **Text-only** (no images): plain `claude -p "<prompt>" --output-format json` * - **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. * 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 * - **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 * stdin containing the user message with text + image content blocks
* (Anthropic Messages API shape). The official docs state single-message * (Anthropic Messages API shape). The official docs state single-message
* input does NOT support image attachments — stream-json is the only * input does NOT support image attachments — stream-json is the only
@@ -21,6 +23,7 @@
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import * as path from 'node:path' import * as path from 'node:path'
import { resolveClaudeBin } from './claudeBin' import { resolveClaudeBin } from './claudeBin'
import { summarizeClaudeStreamLine } from './claudeProgress'
const DEFAULT_TIMEOUT_MS = 300_000 const DEFAULT_TIMEOUT_MS = 300_000
const MAX_BUFFER_BYTES = 10 * 1024 * 1024 const MAX_BUFFER_BYTES = 10 * 1024 * 1024
@@ -177,6 +180,27 @@ function buildClaudeExitDiagnostic(stderr: string, stdout: string): string {
return extractStdoutDiagnostic(stdout) ?? '(无 stderr/stdout)' 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 { function buildStreamJsonLine(prompt: string, images: ClaudeImage[]): string {
const content: Array<Record<string, unknown>> = [ const content: Array<Record<string, unknown>> = [
{ type: 'text', text: prompt }, { type: 'text', text: prompt },
@@ -246,14 +270,16 @@ export async function spawnClaude(opts: {
bare?: boolean bare?: boolean
/** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */ /** 续会话:传入则 `--resume <id>`,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */
resumeSessionId?: string resumeSessionId?: string
/** 实时进度:每识别出一个工具调用 / 一句回复就回调一次可读标签。 */
onProgress?: ClaudeProgressListener
}): Promise<ClaudeResult> { }): Promise<ClaudeResult> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const hasImages = !!opts.images && opts.images.length > 0 const hasImages = !!opts.images && opts.images.length > 0
if (!hasImages) { 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( function spawnClaudeText(
@@ -263,7 +289,9 @@ function spawnClaudeText(
profilePath?: string, profilePath?: string,
bare?: boolean, bare?: boolean,
resumeSessionId?: string, resumeSessionId?: string,
onProgress?: ClaudeProgressListener,
): Promise<ClaudeResult> { ): Promise<ClaudeResult> {
// `--output-format json` 要到进程结束才有输出;要报进度就得换成逐行吐的 stream-json
const args = [ const args = [
...(bare ? ['--bare'] : []), ...(bare ? ['--bare'] : []),
'--dangerously-skip-permissions', '--dangerously-skip-permissions',
@@ -272,7 +300,7 @@ function spawnClaudeText(
'-p', '-p',
prompt, prompt,
'--output-format', '--output-format',
'json', ...(onProgress ? ['stream-json', '--verbose'] : ['json']),
] ]
const bin = resolveClaudeBin() const bin = resolveClaudeBin()
@@ -284,6 +312,7 @@ function spawnClaudeText(
let stderr = '' let stderr = ''
let stdoutBytes = 0 let stdoutBytes = 0
let settled = false let settled = false
const feedProgress = createProgressFeeder(onProgress)
const child = spawn(bin, args, { const child = spawn(bin, args, {
cwd, cwd,
@@ -332,7 +361,9 @@ function spawnClaudeText(
)) ))
return return
} }
stdout += chunk.toString('utf8') const text = chunk.toString('utf8')
stdout += text
feedProgress(text)
}) })
child.stderr.on('data', (chunk: Buffer) => { child.stderr.on('data', (chunk: Buffer) => {
@@ -377,6 +408,7 @@ function spawnClaudeStreamed(
profilePath?: string, profilePath?: string,
bare?: boolean, bare?: boolean,
resumeSessionId?: string, resumeSessionId?: string,
onProgress?: ClaudeProgressListener,
): Promise<ClaudeResult> { ): Promise<ClaudeResult> {
// Local Claude Code v2.1.143 enforces that --input-format=stream-json must // Local Claude Code v2.1.143 enforces that --input-format=stream-json must
// be paired with --output-format=stream-json (the friendlier `--output-format // be paired with --output-format=stream-json (the friendlier `--output-format
@@ -405,6 +437,7 @@ function spawnClaudeStreamed(
let stderr = '' let stderr = ''
let stdoutBytes = 0 let stdoutBytes = 0
let settled = false let settled = false
const feedProgress = createProgressFeeder(onProgress)
const child = spawn(bin, args, { const child = spawn(bin, args, {
cwd, cwd,
@@ -453,7 +486,9 @@ function spawnClaudeStreamed(
)) ))
return return
} }
stdout += chunk.toString('utf8') const text = chunk.toString('utf8')
stdout += text
feedProgress(text)
}) })
child.stderr.on('data', (chunk: Buffer) => { child.stderr.on('data', (chunk: Buffer) => {
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { missingGitIdentityKeys } from './identity'
describe('missingGitIdentityKeys', () => {
it('两项都有时返回空', () => {
expect(missingGitIdentityKeys({ name: 'cruldra', email: 'a@b.c' })).toEqual([])
})
it('缺 name', () => {
expect(missingGitIdentityKeys({ name: '', email: 'a@b.c' })).toEqual(['user.name'])
})
it('缺 email', () => {
expect(missingGitIdentityKeys({ name: 'cruldra', email: '' })).toEqual(['user.email'])
})
it('两项都缺,顺序固定', () => {
expect(missingGitIdentityKeys({ name: '', email: '' })).toEqual(['user.name', 'user.email'])
})
it('只有空白也算缺失', () => {
expect(missingGitIdentityKeys({ name: ' \n', email: '\t' })).toEqual(['user.name', 'user.email'])
})
})
+46
View File
@@ -0,0 +1,46 @@
/**
* 读取当前仓库生效的 git 提交者身份(user.name / user.email)。
*
* 提交代码前先查一遍:身份缺失时 git commit 会直接失败,与其让后台 claude
* 跑十几分钟后才报错,不如一开始就把话说清楚。
*/
import { execFile } from 'node:child_process'
export interface GitIdentity {
name: string
email: string
}
export type GitIdentityKey = 'user.name' | 'user.email'
export function missingGitIdentityKeys(identity: GitIdentity): GitIdentityKey[] {
const missing: GitIdentityKey[] = []
if (!identity.name.trim())
missing.push('user.name')
if (!identity.email.trim())
missing.push('user.email')
return missing
}
function readGitConfig(cwd: string, key: GitIdentityKey): Promise<string> {
return new Promise<string>((resolve) => {
execFile(
'git',
['-C', cwd, 'config', '--get', key],
{ timeout: 10_000, encoding: 'utf8' },
(err, stdout) => {
// 未设置该项时 git 以退出码 1 结束且无输出,视同空串即可
resolve(err ? '' : (stdout ?? '').trim())
},
)
})
}
export async function readGitIdentity(cwd: string): Promise<GitIdentity> {
const [name, email] = await Promise.all([
readGitConfig(cwd, 'user.name'),
readGitConfig(cwd, 'user.email'),
])
return { name, email }
}
+39 -15
View File
@@ -8,9 +8,11 @@ import { resolveProfilePath } from '../../cc/profiles'
import { spawnClaude } from '../../cc/spawnClaude' import { spawnClaude } from '../../cc/spawnClaude'
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock' import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
import { checkBranchSync, runBranchSync } from '../../git/branchSync' import { checkBranchSync, runBranchSync } from '../../git/branchSync'
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
import { logger } from '../../logging/logger' import { logger } from '../../logging/logger'
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store' import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
import { makeNonce } from '../KanbanPanel' import { makeNonce } from '../KanbanPanel'
import { dismiss, toast } from './handoffFlow'
/** /**
* Minimal structural typing for the bits of the built-in `vscode.git` * Minimal structural typing for the bits of the built-in `vscode.git`
@@ -86,43 +88,65 @@ export async function handleCommitRun(panel: KanbanWebviewPanel): Promise<void>
return return
} }
// 身份缺失时 git commit 必然失败,提前拦下,别让 claude 白跑十几分钟
const missingIdentity = missingGitIdentityKeys(await readGitIdentity(workspaceRoot))
if (missingIdentity.length > 0) {
toast(
panel,
'error',
`git 未配置 ${missingIdentity.join(' 和 ')},无法提交。请先执行:git config --global user.name "你的名字" && git config --global user.email "你的邮箱"`,
{ dismissOnTimer: 15000 },
)
return
}
panel.commitRunning = true panel.commitRunning = true
panel.postMessage({ type: 'commit/state', running: true }) panel.postMessage({ type: 'commit/state', running: true })
// 同一个 id 反复发 toast 会原地替换,正好用来滚动显示实时进度
const progressToastId = toast(panel, 'info', '提交中:正在启动 claude…', {
spinner: true,
dismissOnTimer: 31 * 60 * 1000,
})
try { try {
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git // claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
// commit),所以给一个比较宽松的超时(30 min)。 // commit),所以给一个比较宽松的超时(30 min)。
await spawnClaude({ const { resultText } = await spawnClaude({
prompt: '提交下代码', prompt: '提交下代码',
cwd: workspaceRoot, cwd: workspaceRoot,
profilePath: resolution.profilePath, profilePath: resolution.profilePath,
timeoutMs: 30 * 60 * 1000, timeoutMs: 30 * 60 * 1000,
bare: true, bare: true,
onProgress: (label) => {
toast(panel, 'info', `提交中:${label}`, {
id: progressToastId,
spinner: true,
dismissOnTimer: 31 * 60 * 1000,
})
},
}) })
panel.postMessage({ toast(panel, 'success', formatCommitDone(resultText), { dismissOnTimer: 5000 })
type: 'toast/show',
id: makeNonce(),
level: 'success',
message: '提交完成',
dismissOnTimer: 5000,
})
} }
catch (err) { catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
panel.postMessage({ toast(panel, 'error', `提交失败: ${msg}`, { dismissOnTimer: 8000 })
type: 'toast/show',
id: makeNonce(),
level: 'error',
message: `提交失败: ${msg}`,
dismissOnTimer: 8000,
})
} }
finally { finally {
dismiss(panel, progressToastId)
panel.commitRunning = false panel.commitRunning = false
panel.postMessage({ type: 'commit/state', running: false }) panel.postMessage({ type: 'commit/state', running: false })
} }
} }
function formatCommitDone(resultText: string): string {
const firstLine = resultText.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''
if (!firstLine)
return '提交完成'
const summary = firstLine.length > 100 ? `${firstLine.slice(0, 100)}…` : firstLine
return `提交完成:${summary}`
}
export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise<void> { export async function handleBranchSyncCheck(panel: KanbanWebviewPanel): Promise<void> {
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
const s = getSettings(panel.context) const s = getSettings(panel.context)