✨ 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) => {
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import { resolveProfilePath } from '../../cc/profiles'
|
||||
import { spawnClaude } from '../../cc/spawnClaude'
|
||||
import { findEnvFiles, lockEnvFiles, unlockEnvFiles } from '../../files/envLock'
|
||||
import { checkBranchSync, runBranchSync } from '../../git/branchSync'
|
||||
import { missingGitIdentityKeys, readGitIdentity } from '../../git/identity'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { getEffectiveCommitProfilePath, getSettings } from '../../settings/store'
|
||||
import { makeNonce } from '../KanbanPanel'
|
||||
import { dismiss, toast } from './handoffFlow'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 身份缺失时 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.postMessage({ type: 'commit/state', running: true })
|
||||
|
||||
// 同一个 id 反复发 toast 会原地替换,正好用来滚动显示实时进度
|
||||
const progressToastId = toast(panel, 'info', '提交中:正在启动 claude…', {
|
||||
spinner: true,
|
||||
dismissOnTimer: 31 * 60 * 1000,
|
||||
})
|
||||
|
||||
try {
|
||||
// claude -p 提交流程可能跑得比较久(要 git add / 编 commit message / git
|
||||
// commit),所以给一个比较宽松的超时(30 min)。
|
||||
await spawnClaude({
|
||||
const { resultText } = await spawnClaude({
|
||||
prompt: '提交下代码',
|
||||
cwd: workspaceRoot,
|
||||
profilePath: resolution.profilePath,
|
||||
timeoutMs: 30 * 60 * 1000,
|
||||
bare: true,
|
||||
onProgress: (label) => {
|
||||
toast(panel, 'info', `提交中:${label}`, {
|
||||
id: progressToastId,
|
||||
spinner: true,
|
||||
dismissOnTimer: 31 * 60 * 1000,
|
||||
})
|
||||
},
|
||||
})
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'success',
|
||||
message: '提交完成',
|
||||
dismissOnTimer: 5000,
|
||||
})
|
||||
toast(panel, 'success', formatCommitDone(resultText), { dismissOnTimer: 5000 })
|
||||
}
|
||||
catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
panel.postMessage({
|
||||
type: 'toast/show',
|
||||
id: makeNonce(),
|
||||
level: 'error',
|
||||
message: `提交失败: ${msg}`,
|
||||
dismissOnTimer: 8000,
|
||||
})
|
||||
toast(panel, 'error', `提交失败: ${msg}`, { dismissOnTimer: 8000 })
|
||||
}
|
||||
finally {
|
||||
dismiss(panel, progressToastId)
|
||||
panel.commitRunning = 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> {
|
||||
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
const s = getSettings(panel.context)
|
||||
|
||||
Reference in new issue
Block a user