diff --git a/vscode/src/cc/claudeProgress.test.ts b/vscode/src/cc/claudeProgress.test.ts new file mode 100644 index 0000000..86ee2fd --- /dev/null +++ b/vscode/src/cc/claudeProgress.test.ts @@ -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']) + }) +}) diff --git a/vscode/src/cc/claudeProgress.ts b/vscode/src/cc/claudeProgress.ts new file mode 100644 index 0000000..d9556d0 --- /dev/null +++ b/vscode/src/cc/claudeProgress.ts @@ -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 { + 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 + : {} + 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 +} diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index 2a50c2a..73effa2 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -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 "" --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> = [ { type: 'text', text: prompt }, @@ -246,14 +270,16 @@ export async function spawnClaude(opts: { bare?: boolean /** 续会话:传入则 `--resume `,让本次 `-p` 接在该会话之后(复用上下文,省 token)。 */ resumeSessionId?: string + /** 实时进度:每识别出一个工具调用 / 一句回复就回调一次可读标签。 */ + onProgress?: ClaudeProgressListener }): Promise { 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 { + // `--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 { // 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) => { diff --git a/vscode/src/git/identity.test.ts b/vscode/src/git/identity.test.ts new file mode 100644 index 0000000..e0258c5 --- /dev/null +++ b/vscode/src/git/identity.test.ts @@ -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']) + }) +}) diff --git a/vscode/src/git/identity.ts b/vscode/src/git/identity.ts new file mode 100644 index 0000000..e3fe730 --- /dev/null +++ b/vscode/src/git/identity.ts @@ -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 { + return new Promise((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 { + const [name, email] = await Promise.all([ + readGitConfig(cwd, 'user.name'), + readGitConfig(cwd, 'user.email'), + ]) + return { name, email } +} diff --git a/vscode/src/panel/handlers/toolbar.ts b/vscode/src/panel/handlers/toolbar.ts index 9003da4..b96c810 100644 --- a/vscode/src/panel/handlers/toolbar.ts +++ b/vscode/src/panel/handlers/toolbar.ts @@ -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 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 { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const s = getSettings(panel.context)