diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index c7a2c85..115f700 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -392,16 +392,57 @@ ### 21. 对话框 Git 分支检测与会话上下文信息 -- [ ] 通过 REA 拆解 WorkBuddy 对话框中 Git 分支和会话上下文信息的显示位置、图标、字体、间距、提示及交互。 -- [ ] 根据当前任务绑定的工作目录检测 Git 仓库和实际分支;核对原版对非 Git 目录、未产生首次提交、detached HEAD、检测失败等状态的处理。 -- [ ] 明确原版“上下文信息”的实际字段、数据来源、更新时机和展示方式,再接入 YCode 的真实会话数据;上下文用量等字段如存在,必须区分真实统计与估算。 -- [ ] 验收:新建任务、已有会话、切换空间/任务、外部切换 Git 分支后的信息归属与刷新正确;无仓库时不显示虚构分支;浅色/深色及窄窗口布局与原版对齐。 +- [x] 通过 REA 拆解 WorkBuddy 对话框中 Git 分支和会话上下文信息的显示位置、图标、字体、间距、提示及交互。 +- [x] 根据当前任务绑定的工作目录检测 Git 仓库和实际分支;核对原版对非 Git 目录、未产生首次提交、detached HEAD、检测失败等状态的处理。 +- [x] 明确原版“上下文信息”的实际字段、数据来源、更新时机和展示方式,再接入 YCode 的真实会话数据;上下文用量等字段如存在,必须区分真实统计与估算。 +- [x] 验收:新建任务、已有会话、切换空间/任务、外部切换 Git 分支后的信息归属与刷新正确;无仓库时不显示虚构分支;浅色/深色及窄窗口布局与原版对齐。 -状态:待 REA 拆解与现有能力盘点;本项仅加入 TODO,尚未实施。 +状态:已按回收几何/样式/图标/文案实现,并通过真实隔离 App 端到端回归(47 项断言 + 6 项 Git 检测断言);分支为真实 git 结果,用量为上游真实 token(不估算)。 + +原版证据: +- 运行时几何/结构 REA Evidence `ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a`(WorkBuddy 5.8.3 会话输入区,视口 1336×902,`.tmp/rea-debug-ready-20261017/layout.json`): + 左组 `[.cr-add-menu 431,32]` → `[._branchLabel_jdvlk_244 471,82×32]` → `[.cr-permission-setting 561,107×32]` → `[.cr-status-chips 676]`; + 右组 `[.cr-context-usage 903,32×32]` → `[.cr-model-selector 939,174×32]` → `[.cr-voice-trigger 1117]` → `[.cr-send-button 1157]`;两组 gap 分别为 8 / 4。 + 分支标签:radius 999、padding `5px 8px`、gap 8、max-width 188、字体 14/400/22、transition `background .15s`;图标按钮 16×16(`._branchLabelIconButton_jdvlk_279`),默认 SVG `viewBox 0 0 16 16` 分支字形、悬停 copy 字形(`lucide lucide-copy wb-icon`、`viewBox 0 0 24 24`、`stroke-width 1.5`、rect 14×14 + path),两个字形用 opacity 互换(1/0 → 0/1),`:hover`/`:focus-within` 背景 `var(--wb-bg-secondary)`; + 用量环:32×32 / radius 16 / transition `background-color .2s`、hover `var(--cr-context-usage-hover-bg,#0000000d)`、`role="button"`、`cursor:pointer`;内含 16×16 SVG,track `circle r=7 stroke-width 2 stroke rgba(128,128,128,.2)`、progress `circle r=7 stroke-width 2 stroke-linecap round transform rotate(-90 8 8)` + `transition:stroke-dashoffset .3s`;截图像素采样确认该实例用的就是这两个回退色(`rgb(230,230,230)` / `rgb(158,162,176)`)。 + 浮层:`.cr-context-usage-popover` 280 宽 / padding 16 / radius 16 / font 12、标题行(`__title` 12/400/20 + `__close` 16×16)、百分比 20/700/28、`已使用` 12/20、进度条 8 高 radius 3、五类图例(每行 22 高、8×8 圆点、名称 13/22、值 12/20)、`__window-control` 分隔区;另有 `.__requesting-tip`(`bottom:calc(100% + 8px); right:0`)。 +- 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`、`._branchLabelCompact_jdvlk_274`)、`src-CyXHA0IC.css`(`.cr-context-usage*`);文案取自已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 / tooltip `{percentage}% · {used} / {total} 上下文已使用` / requesting / needMessage / unavailable / 五个分类名)。 +- 探针(可复跑):`scripts/rea/probe-todo21-discovery.json`(当前窗口基线)、`probe-todo21-home.json`、`probe-todo21-conversation.json`;本轮采集 `ev_f11473e2…`(首页基线,首页输入区只有工作空间/权限两个入口,没有分支标签与用量环)、`ev_c0dbb550…`、`ev_9796071b…`。 +- 数据来源(原版):REA 只能拿到 DOM/样式/文案,浮层的定位与用量数值生成在 JS 内,本轮未取到;因此数值字段按已回收文案与类名结构对齐,YCode 侧只用自己能证明的真实值。 +- 观察范围:桌面端 5.8.3、浅色运行时 + 深色按 `.cr-theme` token;未覆盖:原版 detached HEAD/无首次提交/非仓库时的标签表现、悬浮提示的实际锚定与延迟、用量环颜色的分段规则、`__window-control` 的内容。 + +实现: +- `src/main/git.ts`(新增):`readGitBranch(cwd)` 真实执行 `git -C rev-parse --is-inside-work-tree` → `symbolic-ref --short -q HEAD` → 失败再 `rev-parse --short HEAD`;`windowsHide`、5s 超时、git 缺失/超时降级为 `unavailable:true`,全部异常都返回合法形状而不是抛出。 +- `src/main/ipc.ts`:新增 `git:branch(sessionId)`,用会话 `cwd`(无则回退工作空间根目录)调用上面的检测。 +- `src/renderer/src/workbuddy/composer/context-info/`(新增):`ComposerContextInfo.tsx`(`ComposerBranchLabel` / `ContextUsageRing`)、`icons.tsx`(分支字形 + lucide copy,路径逐字取自证据)、`source.css`(回收规则收敛在 `.cr-input-toolbar` 内)。 +- `src/renderer/src/workbuddy/composer/ComposerControls.tsx`:新增 `branchLabelSlot`(左组「+」之后)与 `contextUsageSlot`(右组最前)两个槽位,与证据里的顺序一致。 +- `src/renderer/src/components/ChatPane.tsx`:传入两个槽位;用量环的分母用所选模型真实的 `maxInputTokens`。 +- 用量真实性:`ChatEvent` 新增 `usage`;OMP 通路在 `message_end` 读取助手消息上的真实 usage(`contextTokens ?? input + cacheRead + cacheWrite`,字段语义取自内核自身),网关通路在 Responses `response.completed.response.usage` 透传;渲染进程只保存最近一次真实值(`stores/chat.ts` 的 `sessionUsage`),拿不到就显示原版「正在会话中 / 暂无用量明细 / 暂时无法获取」文案。 +- 原版浮层按五类拆分提示词构成,YCode 的提示词由内核拼装、拿不到该拆分,因此分类值显示「—」,不编造比例。 + +验证: +- `node scripts/verify-git-branch.cjs`(真实临时仓库 + esbuild 打包被测模块)6 项全 PASS:master 分支、`feature/x`、detached HEAD 短 SHA、空目录、包含不存在路径的非仓库目录、`git init` 无提交。 +- `node scripts/verify-composer-context-info.cjs`(真实隔离 App + 真实 OMP 内核 + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库)47 项全 PASS: + 分支标签:文案等于真实分支名、32 高 / `5px 8px` / radius 999 / gap 8 / max-width 188 / 14-400-22 省略号、图标按钮 16×16、默认字形与 copy 字形几何与 class 逐项一致、默认可见 copy 隐藏、与「+」和权限入口各相距 8px;悬停后字形 opacity 互换且背景等于同作用域 `--wb-bg-secondary` 解析值;点击图标把真实分支名写入系统剪贴板。 + 用量环:32×32 / radius 16 / `role=button`、为右组第一个元素、与模型选择器相距 4px、内部 16×16 SVG 与 track/progress 圆的 r/描边/线帽/rotate/过渡逐项一致;无用量时 dashoffset 等于整周长。 + 浮层与提示:未拿到用量时显示「暂时无法获取用量明细,请稍后重试」且不显示百分比;真实回合(上游 `prompt_tokens 12800`、窗口 64000)后 dashoffset = 周长 ×0.8、悬浮提示 `20% · 12800 / 64000 上下文已使用`(底色/文字/圆角/内边距/字号与 `--wb-tooltip-*` 解析值一致)、浮层显示 `20%` 与 `12800 / 64000`、进度条填充 20%、五类图例名与 8×8 圆点齐全且值为「—」;关闭按钮可关闭浮层。 + 主题:通过原生设置窗口切深色后 `.chat-pane[data-cr-theme]` 变为 dark,浮层底色 `rgb(36,38,41)`、进度条 `rgba(255,255,255,.1)`、分支标签文字 `rgb(255,255,255)`;再切回浅色恢复 `rgb(255,255,255)`/`rgb(0,0,0)`。 +- 回归(顺序执行,避免多进程 Electron 抢焦点):`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-sidebar-footer.cjs`、`node scripts/verify-account-menu.cjs`、`node scripts/verify-new-task-switch.cjs` 全部通过。 +- 报告:`.tmp/composer-context-lEIWRF/report.json`。 + +遗留: +1. 原版浮层/提示由 JS 定位(floating-ui),本轮只回收了 `.__requesting-tip{bottom:calc(100% + 8px);right:0}` 这一处锚定规则;YCode 按同向(上方 8px、右对齐到用量环)内联渲染,未逐像素比对原版浮层相对位置。 +2. 原版五类上下文占比无法复刻(提示词由内核拼装,YCode 拿不到分类统计),分类值固定显示「—」;原版 `__window-control`(上下文窗口相关控件)内容未采集,未实现。 +3. `._branchLabelCompact_jdvlk_274`(32 宽的紧凑形态)只回收了规则,未观察到运行时触发条件,尚未启用。 +4. 原版对 detached HEAD、无首次提交、非 Git 目录、检测失败的标签表现未采集;YCode 在这些情况下不渲染标签(无提交但有分支时按 git 真实结果仍显示分支名)。 +5. 用量环进度颜色:原版通过未回收的变量注入,本轮截图像素证明该实例用的是回退色 `#9ca3af`;是否随用量分段变色未验证。 +6. 刷新时机未采集:YCode 在切换会话、窗口重新聚焦以及每 20s 轮询时重新读取分支;用量只在收到真实 `usage` 时更新。 +7. 备用(非 OMP)内核走自建网关时用量来自 Responses `response.completed.usage`;Chat Completions 上游路径仍未解析 `usage`。 +8. 用量环只在会话输入区渲染(本轮首页基线的 REA 证据里首页输入区确实没有该入口),首页/新建任务时不显示。 ## 推进顺序 -1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21。 +1. 对话与侧栏:01 → 02 → 03 → 04 → 05 → 06 → 07 → 08 → 09 → 21(均已实现并留下验收记录,未完成验收点在各条目内标注)。 2. 设置:先落实 16、17 的入口范围,再依次推进 10 → 11 → 12 → 13 → 15 → 19。 3. 14、18、20 保留为待拆解,后续单独展开。 diff --git a/scripts/rea/probe-todo21-conversation.json b/scripts/rea/probe-todo21-conversation.json new file mode 100644 index 0000000..668039c --- /dev/null +++ b/scripts/rea/probe-todo21-conversation.json @@ -0,0 +1,9 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [".cr-input-footer", ".cr-status-chips"], + "actions": [ + { "kind": "click", "step_id": "open-conversation", "selector": ".conversation-item .cb-agent-card", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "conversation-settled", "duration_ms": 1200, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo21-discovery.json b/scripts/rea/probe-todo21-discovery.json new file mode 100644 index 0000000..1d33a4d --- /dev/null +++ b/scripts/rea/probe-todo21-discovery.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [".cr-input-container"], + "actions": [ + { "kind": "wait", "step_id": "settle", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo21-home.json b/scripts/rea/probe-todo21-home.json new file mode 100644 index 0000000..3702f71 --- /dev/null +++ b/scripts/rea/probe-todo21-home.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": true, + "track_selectors": [".cr-input-footer", ".cr-status-chips"], + "actions": [ + { "kind": "wait", "step_id": "settle", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/verify-composer-context-info.cjs b/scripts/verify-composer-context-info.cjs new file mode 100644 index 0000000..04bb748 --- /dev/null +++ b/scripts/verify-composer-context-info.cjs @@ -0,0 +1,368 @@ +// TODO 21 验收:会话输入区的 Git 分支标签 + 上下文用量环。 +// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游 + 真实 Git 仓库。 +// +// 原版依据(固定 REA 归档): +// - 运行时几何/结构:ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a +// (WorkBuddy 5.8.3 会话输入区,视口 1336×902): +// 左组 `.cr-add-menu`(431,32) → `._branchLabel_jdvlk_244`(471,82×32) → `.cr-permission-setting`(561) +// 右组 `.cr-context-usage`(903,32×32) → `.cr-model-selector`(939) → 语音(1117) → 发送(1157) +// 分支标签:radius 999 / padding 5px 8px / gap 8 / max-width 188 / 字体 14/400/22; +// 图标按钮 16×16,默认分支字形,悬停换成 copy(opacity 互换);hover 背景 `--wb-bg-secondary`。 +// 用量环:32×32 / radius 16;内部 16×16 SVG,track circle r=7 stroke-width 2、 +// progress circle 带 `stroke-dashoffset .3s` 过渡;hover 背景 `--cr-context-usage-hover-bg,#0000000d`。 +// 浮层:280 宽 / padding 16 / radius 16 / 标题「上下文用量」/ 关闭按钮 / 百分比 20/700/28 / +// 「已使用」12/20 / 进度条 8 高 / 五类图例。 +// - 回收样式表:`contexts-BkjwYSZT.css`(`._branchLabel_*`)、`src-CyXHA0IC.css`(`.cr-context-usage*`)。 +// - 文案:已回收 i18n 表 `chatInput.contextUsage.*`(title 上下文用量 / used 已使用 / +// tooltip "{percentage}% · {used} / {total} 上下文已使用" / needMessage / requesting / unavailable)。 +// +// YCode 侧数据真实性:分支名由主进程真实执行 git;用量只用上游真实 usage(经 OMP 归一化)+ +// 模型自己的真实上下文窗口,拿不到就显示原版的「无法获取」文案,不填 0、不估算。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') + +const QA_BRANCH = 'qa-branch' +/** 受控上游返回的真实用量(provider prompt_tokens 含缓存,本例无缓存)。 */ +const QA_PROMPT_TOKENS = 12800 +const QA_COMPLETION_TOKENS = 40 +const QA_CONTEXT_WINDOW = 64000 +const QA_EXPECTED_PERCENT = 20 +const RING_CIRCUMFERENCE = 2 * Math.PI * 7 + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/composer-context-')) + const data = path.join(directory, 'data') + const home = path.join(directory, 'home') + const repo = path.join(directory, 'fixture-repo') + await fs.mkdir(data, { recursive: true }) + await fs.mkdir(home, { recursive: true }) + await fs.mkdir(repo, { recursive: true }) + // 真实 Git 仓库:分支名固定,供分支标签读取。 + const git = (...args) => execFileSync('git', ['-C', repo, ...args], { stdio: 'pipe' }) + git('init', '-b', QA_BRANCH) + git('config', 'user.email', 'qa@example.com') + git('config', 'user.name', 'QA') + await fs.writeFile(path.join(repo, 'README.md'), '# qa\n') + git('add', '.') + git('-c', 'commit.gpgsign=false', 'commit', '-m', 'init') + const branch = git('rev-parse', '--abbrev-ref', 'HEAD').toString().trim() + assert.equal(branch, QA_BRANCH) + const now = Date.now() + const sessions = [{ id: 'context-info-session', title: 'Context Info', cwd: repo, modelId: 'local-usage', createdAt: now, updatedAt: now + 1, messages: [ + { id: 'context-user', role: 'user', content: '上下文信息验收', createdAt: now, status: 'done', requestId: 'context-run' }, + { id: 'context-assistant', role: 'assistant', content: '收到。', createdAt: now + 1, status: 'done', requestId: 'context-run', tools: [], parts: [] } + ] }] + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, + YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'), + YCODE_DISABLE_GPU: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('COMPOSER_CONTEXT_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 300000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output.slice(-8000)) + process.exitCode = code ? 1 : 0 + }) + })().catch(error => { console.error(error); process.exitCode = 1 }) +} else { + const { app, BrowserWindow, clipboard } = require('electron') + const { createServer } = require('node:http') + const { pathToFileURL } = require('node:url') + const directory = process.env.YCODE_QA_DIRECTORY + // GPU 关闭由主进程 bundle 依据 YCODE_DISABLE_GPU 处理;这里不能重复调用(Electron 会抛错)。 + process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP + app.setPath('home', path.join(directory, 'home')) + const checks = [], snapshots = {}, rendererErrors = [] + let main, server, phase = 'boot' + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source, win = main) { + let timer + try { + return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000) + })]) + } finally { clearTimeout(timer) } + } + async function wait(source, label, win = main) { + for (let index = 0; index < 400; index++) { const value = await read(source, win); if (value) return value; await pause(40) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + async function hover(selector, win = main) { + const point = await read(`(()=>{const node=document.querySelector(${JSON.stringify(selector)});if(!node)return null;const box=node.getBoundingClientRect();return [Math.round(box.x+box.width/2),Math.round(box.y+box.height/2)]})()`, win) + assert.ok(point, 'hover target: ' + selector) + if (!win.webContents.debugger.isAttached()) win.webContents.debugger.attach('1.3') + await win.webContents.debugger.sendCommand('Input.dispatchMouseEvent', { type: 'mouseMoved', x: point[0], y: point[1], button: 'none' }) + await pause(260) + } + + const branchSource = `(()=>{const left=document.querySelector('.cr-input-toolbar__left');const pill=left&&left.querySelector('._branchLabel_jdvlk_244'); + if(!left||!pill)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}; + const style=getComputedStyle(pill);const button=pill.querySelector('._branchLabelIconButton_jdvlk_279'); + const def=pill.querySelector('._branchLabelIconDefault_jdvlk_267');const copy=pill.querySelector('._branchLabelIconCopy_jdvlk_270'); + const text=pill.querySelector('._branchLabelText_jdvlk_316');const tstyle=getComputedStyle(text); + const addMenu=left.querySelector('.cr-add-menu');const permission=left.querySelector('.cr-permission-setting'); + const svgDefault=def.querySelector('svg');const svgCopy=copy.querySelector('svg'); + return {pill:{box:box(pill),radius:style.borderRadius,padding:style.padding,gap:style.gap,maxWidth:style.maxWidth,height:style.height, + font:style.fontSize+'/'+style.fontWeight+'/'+style.lineHeight,color:style.color,display:style.display, + background:style.backgroundColor,transition:style.transitionProperty+' '+style.transitionDuration}, + text:{value:text.textContent.trim(),overflow:tstyle.overflow,textOverflow:tstyle.textOverflow,whiteSpace:tstyle.whiteSpace,font:tstyle.fontSize+'/'+tstyle.fontWeight+'/'+tstyle.lineHeight}, + button:button?{box:box(button),tag:button.tagName}:null, + icons:{defaultOpacity:getComputedStyle(def).opacity,copyOpacity:getComputedStyle(copy).opacity, + defaultSvg:svgDefault?{box:box(svgDefault),viewBox:svgDefault.getAttribute('viewBox'),className:svgDefault.getAttribute('class'),paths:svgDefault.querySelectorAll('path').length}:null, + copySvg:svgCopy?{box:box(svgCopy),viewBox:svgCopy.getAttribute('viewBox'),className:svgCopy.getAttribute('class'),strokeWidth:svgCopy.getAttribute('stroke-width'),rects:svgCopy.querySelectorAll('rect').length,paths:svgCopy.querySelectorAll('path').length}:null}, + order:{addMenu:addMenu?box(addMenu):null,permission:permission?box(permission):null}}})()` + + const ringSource = `(()=>{const right=document.querySelector('.cr-input-toolbar__right');const ring=right&&right.querySelector('.cr-context-usage'); + if(!right||!ring)return null;const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]}; + const style=getComputedStyle(ring);const svg=ring.querySelector('svg.cr-context-usage__ring'); + const track=ring.querySelector('circle:not(.cr-context-usage__ring-progress)');const progress=ring.querySelector('.cr-context-usage__ring-progress'); + const wrapper=right.querySelector('.cr-context-usage-tooltip-trigger');const model=right.querySelector('.cr-model-selector'); + return {ring:{box:box(ring),radius:style.borderRadius,width:style.width,height:style.height,transition:style.transitionProperty+' '+style.transitionDuration, + display:style.display,role:ring.getAttribute('role'),background:style.backgroundColor}, + wrapper:wrapper?box(wrapper):null, + svg:svg?{box:box(svg),viewBox:svg.getAttribute('viewBox'),className:svg.getAttribute('class')}:null, + track:track?{r:track.getAttribute('r'),strokeWidth:track.getAttribute('stroke-width'),fill:track.getAttribute('fill'),stroke:getComputedStyle(track).stroke}:null, + progress:progress?{r:progress.getAttribute('r'),strokeWidth:progress.getAttribute('stroke-width'),strokeLinecap:progress.getAttribute('stroke-linecap'), + transform:progress.getAttribute('transform'),dashArray:progress.getAttribute('stroke-dasharray'),dashOffset:Number(progress.getAttribute('stroke-dashoffset')), + transition:getComputedStyle(progress).transitionProperty+' '+getComputedStyle(progress).transitionDuration,stroke:getComputedStyle(progress).stroke}:null, + firstChild:right.firstElementChild?right.firstElementChild.className:null, + modelLeft:model?box(model)[0]:null}})()` + + const popoverSource = `(()=>{const popover=document.querySelector('.cr-context-usage-popover');if(!popover)return null; + const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(popover); + const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value}; + const title=popover.querySelector('.cr-context-usage-popover__title');const close=popover.querySelector('.cr-context-usage-popover__close'); + const percent=popover.querySelector('.cr-context-usage-popover__percent');const usedValue=popover.querySelector('.cr-context-usage-popover__used-value'); + const usedLabel=popover.querySelector('.cr-context-usage-popover__used-label');const bar=popover.querySelector('.cr-context-usage-popover__bar'); + const rows=[...popover.querySelectorAll('.cr-context-usage-popover__row')].map(row=>({name:row.querySelector('.cr-context-usage-popover__row-name')?.textContent, + value:row.querySelector('.cr-context-usage-popover__row-value')?.textContent,swatch:box(row.querySelector('.cr-context-usage-popover__swatch')).slice(2)})); + return {box:box(popover),width:style.width,padding:style.padding,borderRadius:style.borderRadius,font:style.fontSize+'/'+style.lineHeight, + background:style.backgroundColor,zIndex:style.zIndex, + title:title?{text:title.textContent,font:getComputedStyle(title).fontSize+'/'+getComputedStyle(title).fontWeight+'/'+getComputedStyle(title).lineHeight}:null, + close:close?box(close):null, + percent:percent?{text:percent.textContent,font:getComputedStyle(percent).fontSize+'/'+getComputedStyle(percent).fontWeight+'/'+getComputedStyle(percent).lineHeight}:null, + usedLabel:usedLabel?{text:usedLabel.textContent,color:getComputedStyle(usedLabel).color}:null, + usedValue:usedValue?usedValue.textContent:null, + bar:bar?{box:box(bar),height:getComputedStyle(bar).height,radius:getComputedStyle(bar).borderRadius,fillWidth:popover.querySelector('.cr-context-usage-popover__bar-fill')?.style.width}:null, + rows,bodyText:popover.textContent,tokenTertiary:probe('color','--cr-text-tertiary')}})()` + + ;(async () => { + // 受控上游:最后一次 chunk 带真实 usage(provider 语义,含总 prompt tokens)。 + server = createServer(async (req, res) => { + let raw = '' + for await (const part of req) raw += part + const body = JSON.parse(raw || '{}') + if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'USAGE_REPLY' } }] })); return } + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }) + const chunk = payload => `data: ${JSON.stringify({ id: 'local-usage', object: 'chat.completion.chunk', created: 1, model: body.model, ...payload })}\n\n` + res.write(chunk({ choices: [{ index: 0, delta: { role: 'assistant', content: 'USAGE_REPLY' }, finish_reason: null }] })) + res.write(chunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + usage: { prompt_tokens: QA_PROMPT_TOKENS, completion_tokens: QA_COMPLETION_TOKENS, total_tokens: QA_PROMPT_TOKENS + QA_COMPLETION_TOKENS } })) + res.write('data: [DONE]\n\n') + res.end() + }) + // 注意:导入主进程 bundle 之前不能 await 任何东西,否则 Electron 会先进入 ready, + // bundle 顶层的 app.disableHardwareAcceleration() 就会抛错。这里全部用同步准备。 + const sync = require('node:fs') + server.listen(0, '127.0.0.1') + sync.mkdirSync(process.env.YCODE_USER_DATA_DIR, { recursive: true }) + app.setPath('userData', process.env.YCODE_USER_DATA_DIR) + app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR) + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href) + await app.whenReady() + await new Promise(resolve => server.listening ? resolve() : server.once('listening', resolve)) + const baseUrl = `http://127.0.0.1:${server.address().port}/v1` + for (let index = 0; index < 200; index++) { + main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') + if (main && !main.webContents.isLoading()) break + await pause(40) + } + assert.ok(main, 'main window') + main.setSize(1336, 902) + main.showInactive() + main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) }) + await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer') + await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-usage', name: 'Local Usage', providerId: 'custom', + model: 'model-usage', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, + maxInputTokens: QA_CONTEXT_WINDOW, maxOutputTokens: 4096 })})`) + await read('window.ycode.models.select("local-usage")') + + await step('conversation-with-git-workspace') + await wait(`!!document.querySelector('.cr-input-toolbar__left')`, 'composer toolbar') + await wait(`!!document.querySelector('._branchLabel_jdvlk_244')`, 'branch label') + const branchInfo = await read(branchSource) + snapshots.branch = branchInfo + check('branch label shows the real repository branch', branchInfo.text.value, QA_BRANCH) + // 原版证据里是 6 字符的 master(82 宽);宽度由分支名长度决定,这里断言固定契约 + 上限。 + check('branch pill keeps the recovered geometry', [Math.round(branchInfo.pill.box[3]), branchInfo.pill.padding, branchInfo.pill.radius, branchInfo.pill.gap, branchInfo.pill.maxWidth], + [32, '5px 8px', '999px', '8px', '188px']) + check('branch pill width stays within the recovered cap', branchInfo.pill.box[2] <= 188 && branchInfo.pill.box[2] > 32, true) + check('branch text keeps 14/400/22 with ellipsis', [branchInfo.text.font, branchInfo.text.overflow, branchInfo.text.textOverflow, branchInfo.text.whiteSpace], + ['14px/400/22px', 'hidden', 'ellipsis', 'nowrap']) + check('branch icon button is a 16x16 button', [branchInfo.button.tag, branchInfo.button.box[2], branchInfo.button.box[3]], ['BUTTON', 16, 16]) + check('default glyph is the recovered 16x16 branch icon', [branchInfo.icons.defaultSvg.viewBox, branchInfo.icons.defaultSvg.className, branchInfo.icons.defaultSvg.paths, branchInfo.icons.defaultSvg.box[2]], + ['0 0 16 16', 'wb-icon', 1, 16]) + check('hover glyph is the recovered lucide copy icon', [branchInfo.icons.copySvg.viewBox, branchInfo.icons.copySvg.className, branchInfo.icons.copySvg.strokeWidth, branchInfo.icons.copySvg.rects, branchInfo.icons.copySvg.paths], + ['0 0 24 24', 'lucide lucide-copy wb-icon', '1.5', 1, 1]) + check('default glyph visible and copy glyph hidden before hover', [branchInfo.icons.defaultOpacity, branchInfo.icons.copyOpacity], ['1', '0']) + check('branch label sits 8px after the add menu', Math.round(branchInfo.pill.box[0] - (branchInfo.order.addMenu[0] + branchInfo.order.addMenu[2])), 8) + check('permission setting sits 8px after the branch label', Math.round(branchInfo.order.permission[0] - (branchInfo.pill.box[0] + branchInfo.pill.box[2])), 8) + + await step('branch-hover-and-copy') + await hover('._branchLabel_jdvlk_244') + snapshots.branchHover = await read(`(()=>{const pill=document.querySelector('._branchLabel_jdvlk_244'); + const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';pill.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value}; + return {background:getComputedStyle(pill).backgroundColor,hover:pill.matches(':hover'), + defaultOpacity:getComputedStyle(pill.querySelector('._branchLabelIconDefault_jdvlk_267')).opacity, + copyOpacity:getComputedStyle(pill.querySelector('._branchLabelIconCopy_jdvlk_270')).opacity, + secondary:getComputedStyle(pill).getPropertyValue('--wb-bg-secondary').trim(), + secondaryResolved:probe('backgroundColor','--wb-bg-secondary')}})()`) + check('hovering swaps the glyph to copy', [snapshots.branchHover.defaultOpacity, snapshots.branchHover.copyOpacity], ['0', '1']) + check('hovering paints the pill with the resolved --wb-bg-secondary', snapshots.branchHover.background, snapshots.branchHover.secondaryResolved) + check('branch pill hover state is real', snapshots.branchHover.hover, true) + clipboard.writeText('') + await read(`(()=>{document.querySelector('._branchLabelIconButton_jdvlk_279').click();return true})()`) + await pause(160) + check('clicking the glyph copies the real branch name', clipboard.readText(), QA_BRANCH) + + await step('context-usage-ring') + const ring = await read(ringSource) + snapshots.ring = ring + check('usage ring keeps the recovered 32x32 / radius 16 box', [Math.round(ring.ring.box[2]), Math.round(ring.ring.box[3]), ring.ring.radius, ring.ring.role], [32, 32, '16px', 'button']) + check('usage ring is the first element of the right group', ring.firstChild.includes('cr-context-usage-tooltip-trigger'), true) + check('usage ring sits 4px before the model selector', Math.round(ring.modelLeft - (ring.ring.box[0] + ring.ring.box[2])), 4) + check('ring svg is the recovered 16x16 ring', [ring.svg.viewBox, ring.svg.className, ring.svg.box[2], ring.svg.box[3]], ['0 0 16 16', 'cr-context-usage__ring', 16, 16]) + check('ring track matches the recovered circle', [ring.track.r, ring.track.strokeWidth, ring.track.fill], ['7', '2', 'none']) + check('ring progress circle matches the recovered geometry', [ring.progress.r, ring.progress.strokeWidth, ring.progress.strokeLinecap, ring.progress.transform], ['7', '2', 'round', 'rotate(-90 8 8)']) + check('ring progress keeps the recovered dashoffset transition', ring.progress.transition, 'stroke-dashoffset 0.3s') + check('no usage yet: ring stays empty', Math.round(ring.progress.dashOffset), Math.round(RING_CIRCUMFERENCE)) + + await step('usage-popover-without-usage') + await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`) + await wait('!!document.querySelector(".cr-context-usage-popover")', 'usage popover') + const emptyPopover = await read(popoverSource) + snapshots.emptyPopover = emptyPopover + check('popover keeps the recovered 280-wide card', [Math.round(emptyPopover.box[2]), emptyPopover.padding, emptyPopover.borderRadius, emptyPopover.font], [280, '16px', '16px', '12px/18px']) + check('popover keeps the recovered header', [emptyPopover.title.text, emptyPopover.title.font, Math.round(emptyPopover.close[2]), Math.round(emptyPopover.close[3])], ['上下文用量', '12px/400/20px', 16, 16]) + check('no usage yet: popover says so instead of inventing numbers', emptyPopover.bodyText.includes('暂时无法获取用量明细,请稍后重试'), true) + check('no usage yet: popover shows no percentage', emptyPopover.percent, null) + await read(`(()=>{document.querySelector('.cr-context-usage-popover__close').click();return true})()`) + await wait('!document.querySelector(".cr-context-usage-popover")', 'popover closed') + check('close button dismisses the popover', await read('!document.querySelector(".cr-context-usage-popover")'), true) + + await step('real-turn-usage') + await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null); + if(!input)throw new Error('Missing composer input'); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'用量验收'); + input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`) + await read(`(()=>{document.querySelector('.cr-input-toolbar__send').click();return true})()`) + await wait(`document.body.textContent.includes('USAGE_REPLY')`, 'controlled upstream reply') + await wait(`(()=>{const progress=document.querySelector('.cr-context-usage__ring-progress');return progress&&Number(progress.getAttribute('stroke-dashoffset'))<${RING_CIRCUMFERENCE - 1}})()`, 'ring reflects real usage') + await pause(400) + const usedRing = await read(ringSource) + snapshots.usedRing = usedRing + const expectedOffset = RING_CIRCUMFERENCE * (1 - QA_EXPECTED_PERCENT / 100) + check('ring dashoffset follows the real provider usage', Math.abs(usedRing.progress.dashOffset - expectedOffset) < 0.6, true) + check('ring stroke uses the recovered progress colour', usedRing.progress.stroke, 'rgb(156, 163, 175)') + check('ring track uses the recovered track colour', usedRing.track.stroke, 'rgba(128, 128, 128, 0.2)') + + await step('usage-tooltip') + await hover('.cr-context-usage') + const tooltip = await read(`(()=>{const node=document.querySelector('.cr-context-usage-tooltip');if(!node)return null; + const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';node.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value}; + const style=getComputedStyle(node);return {text:node.querySelector('.wb-tooltip__content')?.textContent,role:node.getAttribute('role'), + background:style.backgroundColor,color:style.color,borderRadius:style.borderRadius,padding:style.padding,font:style.fontSize+'/'+style.lineHeight, + arrow:!!node.querySelector('.wb-tooltip__arrow'),tokenBg:probe('backgroundColor','--wb-tooltip-bg'),tokenFg:probe('color','--wb-tooltip-text-color')}})()`) + snapshots.tooltip = tooltip + check('hover tooltip uses the recovered usage sentence', tooltip?.text, `${QA_EXPECTED_PERCENT}% · ${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW} 上下文已使用`) + check('tooltip keeps the recovered base surface', [tooltip.background, tooltip.color, tooltip.borderRadius, tooltip.padding, tooltip.font, tooltip.arrow], + [tooltip.tokenBg || 'rgba(0, 0, 0, 0.75)', tooltip.tokenFg || 'rgb(255, 255, 255)', '8px', '4px 8px', '12px/20px', true]) + + await step('usage-popover-with-real-numbers') + await read(`(()=>{document.querySelector('.cr-context-usage').click();return true})()`) + await wait('!!document.querySelector(".cr-context-usage-popover")', 'popover with usage') + await pause(200) + const usedPopover = await read(popoverSource) + snapshots.usedPopover = usedPopover + check('popover shows the real percentage', usedPopover.percent.text, `${QA_EXPECTED_PERCENT}%`) + check('popover percent keeps 20/700/28', usedPopover.percent.font, '20px/700/28px') + check('popover shows the real used/total pair', usedPopover.usedValue, `${QA_PROMPT_TOKENS} / ${QA_CONTEXT_WINDOW}`) + check('popover used label keeps the recovered copy', [usedPopover.usedLabel.text, usedPopover.usedLabel.color], ['已使用', usedPopover.tokenTertiary]) + check('popover bar keeps the recovered 8px track', [Math.round(usedPopover.bar.box[3]), usedPopover.bar.height, usedPopover.bar.radius], [8, '8px', '3px']) + check('popover bar fill follows the same percentage', usedPopover.bar.fillWidth, `${QA_EXPECTED_PERCENT}%`) + check('popover legend keeps the five recovered categories', usedPopover.rows.map(row => row.name), + ['系统提示词', '工具及子智能体', '对话消息', '连接器及MCP', '技能']) + check('legend swatches keep the recovered 8px dots', usedPopover.rows.map(row => row.swatch), [[8, 8], [8, 8], [8, 8], [8, 8], [8, 8]]) + check('legend values stay unknown instead of fabricated', usedPopover.rows.map(row => row.value), ['—', '—', '—', '—', '—']) + + await step('dark-theme-via-native-settings') + // 真实路径:设置窗口切成深色,聊天区 `data-cr-theme` 随之变化,输入区 token 跟着切。 + const lightTokens = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244'); + return {popoverBackground:getComputedStyle(popover).backgroundColor,bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor, + pillColor:getComputedStyle(pill).color,scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`) + await read(`window.ycode.app.openSettings('appearance')`) + let settings + for (let index = 0; index < 200; index++) { + settings = BrowserWindow.getAllWindows().find(win => win !== main) + if (settings && !settings.webContents.isLoading()) break + await pause(25) + } + assert.ok(settings, 'settings window') + await pause(600) + snapshots.settingsWindow = await read(`({url:location.href,search:location.search,bodyClass:document.body.className,rootHtml:(document.querySelector('#root')?.innerHTML||'').slice(0,240)})`, settings) + await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) + await read(`(()=>{const tab=document.querySelector('[data-settings-tab="appearance"]');if(tab)tab.click();return true})()`, settings) + await wait(`!!document.querySelector('.segmented')`, 'appearance panel', settings) + await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='深色');if(!node)throw new Error('missing dark option');node.click();return true})()`, settings) + await wait(`document.documentElement.dataset.theme==='dark'`, 'main window dark theme') + await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='dark'`, 'composer dark scope') + await pause(250) + const darkPopover = await read(`(()=>{const popover=document.querySelector('.cr-context-usage-popover');const pill=document.querySelector('._branchLabel_jdvlk_244'); + const probe=(property,token)=>{const el=document.createElement('div');el.style[property]='var('+token+')';popover.appendChild(el);const value=getComputedStyle(el)[property];el.remove();return value}; + return {popoverBackground:getComputedStyle(popover).backgroundColor,pillColor:getComputedStyle(pill).color, + bar:getComputedStyle(popover.querySelector('.cr-context-usage-popover__bar')).backgroundColor, + tokenElevated:probe('backgroundColor','--cr-bg-elevated'),tokenActive:probe('backgroundColor','--cr-bg-active'), + tokenTextPrimary:probe('color','--wb-color-text-primary'),scope:document.querySelector('.chat-pane')?.dataset.crTheme ?? null}})()`) + snapshots.dark = { lightTokens, darkPopover } + check('native settings switches the composer to the dark scope', [lightTokens.scope, darkPopover.scope], ['light', 'dark']) + check('dark popover uses the recovered dark elevated surface', darkPopover.popoverBackground, 'rgb(36, 38, 41)') + check('dark popover bar uses the recovered dark active fill', darkPopover.bar, 'rgba(255, 255, 255, 0.1)') + check('dark pill switches to the dark primary text colour', [darkPopover.pillColor, lightTokens.pillColor, darkPopover.pillColor === darkPopover.tokenTextPrimary], ['rgb(255, 255, 255)', 'rgb(0, 0, 0)', true]) + await read(`(()=>{const node=[...document.querySelectorAll('.segmented__item')].find(item=>item.textContent.trim()==='浅色');if(!node)throw new Error('missing light option');node.click();return true})()`, settings) + await wait(`document.documentElement.dataset.theme==='light'`, 'main window light theme') + await wait(`document.querySelector('.chat-pane')?.dataset.crTheme==='light'`, 'composer light scope') + await read(`document.querySelector('.settings-window-close').click()`, settings) + for (let index = 0; index < 200 && !settings.isDestroyed(); index++) await pause(20) + check('light theme restored through the same settings window', await read(`[document.documentElement.dataset.theme,document.querySelector('.chat-pane').dataset.crTheme]`), ['light', 'light']) + check('no renderer error logs', rendererErrors, []) + + server.close() + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', + scope: 'Real isolated YCode App/preload/IPC/renderer + real OMP kernel + controlled local OpenAI-compatible upstream + real git repository; measured branch pill, usage ring, tooltip and popover in the live window after a real turn.', + sourceEvidence: ['ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a', 'contexts-BkjwYSZT.css', 'src-CyXHA0IC.css', 'chatInput.contextUsage.*'], + snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('COMPOSER_CONTEXT_' + report.result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('COMPOSER_CONTEXT_FAILURE ' + JSON.stringify(failure)) + app.exit(report.result === 'PASS' ? 0 : 1) + })().catch(async error => { + if (server) server.close() + await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2)) + console.error(error); app.exit(1) + }) +} diff --git a/scripts/verify-git-branch.cjs b/scripts/verify-git-branch.cjs new file mode 100644 index 0000000..c2b696c --- /dev/null +++ b/scripts/verify-git-branch.cjs @@ -0,0 +1,115 @@ +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { build } = require('esbuild') + +const failures = [] + +function runGit(cwd, ...args) { + return execFileSync('git', ['-C', cwd, ...args], { + windowsHide: true, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }).trim() +} + +async function check(name, verify) { + try { + await verify() + console.log(`PASS ${name}`) + } catch (error) { + failures.push({ name, error }) + console.error(`FAIL ${name}: ${error instanceof Error ? error.message : String(error)}`) + } +} + +async function main() { + const repositoryRoot = path.resolve(__dirname, '..') + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ycode-git-branch-')) + const bundlePath = path.join(repositoryRoot, '.tmp', 'git-branch.cjs') + + try { + await fs.mkdir(path.dirname(bundlePath), { recursive: true }) + await build({ + entryPoints: [path.join(repositoryRoot, 'src/main/git.ts')], + bundle: true, + platform: 'node', + format: 'cjs', + outfile: bundlePath, + logLevel: 'silent' + }) + + const { readGitBranch } = require(bundlePath) + const repoPath = path.join(tempRoot, 'repo') + const emptyRepoPath = path.join(tempRoot, 'empty-repo') + const emptyPath = path.join(tempRoot, 'empty') + const missingPath = path.join(tempRoot, 'missing') + await Promise.all([ + fs.mkdir(repoPath), + fs.mkdir(emptyRepoPath), + fs.mkdir(emptyPath) + ]) + + runGit(repoPath, 'init', '-b', 'master') + runGit(repoPath, 'config', 'user.name', 'YCode Test') + runGit(repoPath, 'config', 'user.email', 'ycode-test@example.invalid') + await fs.writeFile(path.join(repoPath, 'README.md'), 'git branch verification\n', 'utf8') + runGit(repoPath, 'add', 'README.md') + runGit(repoPath, 'commit', '-m', 'initial commit') + + await check('initialized master branch', async () => { + const result = await readGitBranch(repoPath) + assert.deepEqual(result, { repo: true, branch: 'master', detached: false, head: null }) + }) + + runGit(repoPath, 'checkout', '-b', 'feature/x') + await check('feature branch name', async () => { + const result = await readGitBranch(repoPath) + assert.equal(result.repo, true) + assert.equal(result.branch, 'feature/x') + assert.equal(result.detached, false) + assert.equal(result.head, null) + }) + + runGit(repoPath, 'checkout', '--detach') + const expectedHead = runGit(repoPath, 'rev-parse', '--short', 'HEAD') + await check('detached HEAD short SHA', async () => { + const result = await readGitBranch(repoPath) + assert.deepEqual(result, { repo: true, branch: null, detached: true, head: expectedHead }) + }) + + await check('empty directory is outside Git', async () => { + const result = await readGitBranch(emptyPath) + assert.deepEqual(result, { repo: false, branch: null, detached: false, head: null }) + }) + + await check('missing path is outside Git', async () => { + const result = await readGitBranch(missingPath) + assert.deepEqual(result, { repo: false, branch: null, detached: false, head: null }) + }) + + runGit(emptyRepoPath, 'init', '-b', 'master') + await check('initialized repository without commits', async () => { + const result = await readGitBranch(emptyRepoPath) + // An unborn HEAD still resolves through symbolic-ref to the initialized branch. + assert.deepEqual(result, { repo: true, branch: 'master', detached: false, head: null }) + }) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + + if (failures.length) { + console.error(`GIT_BRANCH_FAIL (${failures.length})`) + for (const { name } of failures) console.error(`- ${name}`) + process.exitCode = 1 + } else { + console.log('GIT_BRANCH_PASS') + } +} + +main().catch((error) => { + console.error(`GIT_BRANCH_FAIL: ${error instanceof Error ? error.stack ?? error.message : String(error)}`) + process.exitCode = 1 +}) diff --git a/src/main/agent/loop.ts b/src/main/agent/loop.ts index f7a4cc7..57bef24 100644 --- a/src/main/agent/loop.ts +++ b/src/main/agent/loop.ts @@ -97,6 +97,8 @@ export async function runTurn(options: RunTurnOptions): Promise { } else if (delta.type === 'reasoning') { reasoning += delta.text emit({ runId, type: 'part', part: { type: 'reasoning', id: `reasoning-${round}`, text: reasoning } }) + } else if (delta.type === 'usage') { + emit({ runId, type: 'usage', usage: delta.usage }) } else if (delta.type === 'tool_calls') { for (const call of delta.calls) { pendingCalls.push({ id: call.id, name: call.function.name, argsRaw: call.function.arguments }) diff --git a/src/main/agent/omp-turn.ts b/src/main/agent/omp-turn.ts index 702401e..fe65f3b 100644 --- a/src/main/agent/omp-turn.ts +++ b/src/main/agent/omp-turn.ts @@ -11,7 +11,7 @@ * - 取消(abort)→ ChatEvent `cancelled` */ -import type { ChatEvent, ToolCallRecord } from '@shared/types' +import type { ChatEvent, TokenUsage, ToolCallRecord } from '@shared/types' import { OmpKernel, type OmpFrame, type OmpToolInfo } from '../omp' import { toolSpecs } from './tools' import { executeTurnTool } from './conversation-tools' @@ -169,6 +169,11 @@ export async function runOmpTurn(options: RunOmpTurnOptions): Promise { return value !== null && typeof value === 'object' && !Array.isArray(value) } +/** + * OMP 助手消息上的真实 token 用量 → ChatEvent `usage`。 + * + * 字段含义来自 OMP 内核自身(`omp.exe` 内 `contextTokens ?? input + cacheRead + cacheWrite` + * 即本次请求真实消耗的上下文;`totalTokens = input + output + cacheRead + cacheWrite`): + * - `input` 不含缓存命中,缓存命中单列在 `cacheRead`/`cacheWrite`。 + * - 上游没给 usage、或本消息是中止/错误终态时返回 null,不产生事件、不估算。 + */ +function readOmpUsage(message: unknown): TokenUsage | null { + if (!isRecord(message) || message.role !== 'assistant') return null + if (message.stopReason === 'aborted' || message.stopReason === 'error') return null + const usage = message.usage + if (!isRecord(usage)) return null + const count = (value: unknown): number | undefined => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + const input = count(usage.input) ?? 0 + const output = count(usage.output) ?? 0 + const cacheRead = count(usage.cacheRead) ?? 0 + const cacheWrite = count(usage.cacheWrite) ?? 0 + const inputTokens = count(usage.contextTokens) ?? input + cacheRead + cacheWrite + if (inputTokens === 0 && output === 0) return null + const totalTokens = count(usage.totalTokens) ?? input + output + cacheRead + cacheWrite + return { inputTokens, outputTokens: output, totalTokens, ...(cacheRead > 0 ? { cachedTokens: cacheRead } : {}) } +} + function containsBinaryPayload(value: unknown, depth = 0): boolean { if (depth > 4 || value === null || typeof value !== 'object') return false if ( diff --git a/src/main/gateway/index.ts b/src/main/gateway/index.ts index 7e10b93..14d6ad6 100644 --- a/src/main/gateway/index.ts +++ b/src/main/gateway/index.ts @@ -10,7 +10,7 @@ * 模型路由、上下文裁剪、工具编排由上层(agent/loop.ts)负责。 */ -import type { GatewayConfig, ToolSpec } from '@shared/types' +import type { GatewayConfig, TokenUsage, ToolSpec } from '@shared/types' import { resolveOpenAIEndpoint } from '@shared/openai-endpoint' import { parseSse } from './sse' import { buildResponsesRequest, parseResponsesStream } from './responses' @@ -34,6 +34,7 @@ export type GatewayDelta = | { type: 'text'; text: string } | { type: 'reasoning'; text: string } | { type: 'tool_calls'; calls: WireToolCall[] } + | { type: 'usage'; usage: TokenUsage } | { type: 'finish'; reason: string | null } export interface GatewayStreamRequest { diff --git a/src/main/gateway/responses.ts b/src/main/gateway/responses.ts index 9bcd3da..28c4859 100644 --- a/src/main/gateway/responses.ts +++ b/src/main/gateway/responses.ts @@ -149,6 +149,22 @@ export async function* parseResponsesStream(stream: ReadableStream, const response = object(payload.response) if (response.error || response.status !== 'completed' || !Array.isArray(response.output)) throw error('Responses 上游未返回合法成功终态') for (let i = 0; i < response.output.length; i++) yield* output({ output_index: i }, object(response.output[i]), true) + const usage = object(response.usage) + const inputTokens = usage.input_tokens + const outputTokens = usage.output_tokens + const totalTokens = usage.total_tokens + if (typeof inputTokens === 'number' && typeof outputTokens === 'number' && typeof totalTokens === 'number') { + const cachedTokens = object(usage.input_tokens_details).cached_tokens + yield { + type: 'usage', + usage: { + inputTokens, + outputTokens, + totalTokens, + ...(typeof cachedTokens === 'number' ? { cachedTokens } : {}) + } + } + } completed = true break } diff --git a/src/main/git.ts b/src/main/git.ts new file mode 100644 index 0000000..6a6b4c6 --- /dev/null +++ b/src/main/git.ts @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process' +import { promises as fs } from 'node:fs' +import { promisify } from 'node:util' +import type { GitBranchInfo } from '@shared/types' + +const execFileAsync = promisify(execFile) +const GIT_TIMEOUT_MS = 5_000 + +const outsideRepository = (): GitBranchInfo => ({ repo: false, branch: null, detached: false, head: null }) + +function isUnavailableError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const candidate = error as NodeJS.ErrnoException & { killed?: boolean } + return candidate.code === 'ENOENT' || candidate.code === 'ETIMEDOUT' || candidate.killed === true +} + +async function runGit(cwd: string, args: string[]): Promise { + const result = await execFileAsync('git', ['-C', cwd, ...args], { + windowsHide: true, + timeout: GIT_TIMEOUT_MS, + encoding: 'utf8', + maxBuffer: 64 * 1024 + }) + return result.stdout.trim() +} + +/** Read the Git branch for a workspace without allowing Git failures to escape to IPC. */ +export async function readGitBranch(cwd: string): Promise { + try { + if (typeof cwd !== 'string' || cwd.length === 0) return outsideRepository() + const stat = await fs.stat(cwd) + if (!stat.isDirectory()) return outsideRepository() + } catch { + return outsideRepository() + } + + try { + if (await runGit(cwd, ['rev-parse', '--is-inside-work-tree']) !== 'true') return outsideRepository() + } catch (error) { + return isUnavailableError(error) ? { ...outsideRepository(), unavailable: true } : outsideRepository() + } + + try { + const branch = await runGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD']) + if (branch) return { repo: true, branch, detached: false, head: null } + } catch (error) { + if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true } + } + + try { + const head = await runGit(cwd, ['rev-parse', '--short', 'HEAD']) + return { repo: true, branch: null, detached: true, head: head || null } + } catch (error) { + if (isUnavailableError(error)) return { ...outsideRepository(), unavailable: true } + return { repo: true, branch: null, detached: true, head: null } + } +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5d3fbad..9dc81e2 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -11,7 +11,7 @@ import { BrowserWindow, app, dialog, ipcMain, shell, type OpenDialogOptions } fr import { randomUUID } from 'node:crypto' import { unlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import type { ChatEvent, ChatMessage, WindowState } from '@shared/types' +import type { ChatEvent, ChatMessage, GitBranchInfo, WindowState } from '@shared/types' import { freezeAssistantModelMetadata } from '@shared/assistant-model' import type { ConfigPatchLike, EditAction, SendPayload, WorkspaceAttachResult } from '@shared/ipc' import { applyAssistantEvent } from '@shared/message-parts' @@ -45,6 +45,7 @@ import * as experts from './capabilities/experts' import { createCapabilitySnapshot } from './capabilities/runtime' import type { McpServerInput } from '@shared/capabilities' import { agentMode, agentModeContextPatch, thinkingLevel } from './agent/mode-policy' +import { readGitBranch } from './git' /** runId → 取消控制器 */ const activeRuns = new Map() @@ -266,6 +267,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { if (sessionId) return (await sessions.getSessionContext(sessionId)).cwd ?? await config.getWorkspaceRoot() return workspaces.workspaceRoot() } + ipcMain.handle('git:branch', async (_event, sessionId: unknown): Promise => { + if (typeof sessionId !== 'string' || !sessionId.trim()) return { repo: false, branch: null, detached: false, head: null } + try { + return await readGitBranch(await sessionRoot(sessionId)) + } catch { + return { repo: false, branch: null, detached: false, head: null } + } + }) ipcMain.handle('resources:openExternal', async (_event, input: string) => { const url = new URL(input) if (!['http:', 'https:'].includes(url.protocol)) throw new Error('链接协议不受支持') diff --git a/src/preload/index.ts b/src/preload/index.ts index e90a79e..a9f8ead 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -99,6 +99,9 @@ const api: YCodeApi = { return () => ipcRenderer.removeListener('settings:changed', handler) } }, + git: { + branch: (sessionId) => ipcRenderer.invoke('git:branch', sessionId) + }, config: { get: () => ipcRenderer.invoke('config:get'), update: (patch: ConfigPatchLike) => ipcRenderer.invoke('config:update', patch) diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 4ccbe91..856da21 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -19,6 +19,7 @@ import { clearExpertInputPrefill, expertInputUpdate } from '../capabilities/expe import SourceSkillPicker from '../capabilities/SourceSkillPicker' import { SkillsIcon } from '../capabilities/SkillSourceIcons' import { ComposerControls, type ComposerSendPayload } from '../workbuddy/composer/ComposerControls' +import { ComposerBranchLabel, ContextUsageRing } from '../workbuddy/composer/context-info/ComposerContextInfo' import { QuestionFloating } from '../workbuddy/conversation' import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation' import type { ChatMessage } from '@shared/types' @@ -203,6 +204,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, // 模型标签:OMP 内核激活时显示 OMP 的实际模型,否则显示自建网关的模型 const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || appInfo?.omp?.model || '未配置模型' + /** 上下文用量环的分母:模型自己的真实上下文窗口配置(拿不到就不编造总数)。 */ + const modelContextWindow = models.find((model) => model.id === effectiveModelId)?.maxInputTokens useEffect(() => { const el = scrollRef.current @@ -290,6 +293,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const composer = ( <> { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []} + branchLabelSlot={} + contextUsageSlot={ 0} running={sending || !!running} />} onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })} onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }} addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`} diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index da806d9..093dae0 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import type { ChatEvent, ChatMessage, SessionMeta } from '@shared/types' +import type { ChatEvent, ChatMessage, SessionMeta, TokenUsage } from '@shared/types' import type { SendPayload } from '@shared/ipc' import type { InteractionResponse } from '@shared/conversation' import { applyAssistantEvent } from '@shared/message-parts' @@ -37,6 +37,8 @@ interface ChatState { currentSessionId: string | null messages: ChatMessage[] sessionMessages: Record + /** 每个会话最近一次上游返回的真实 token 用量(`usage` 事件),没有真实值时不写入。 */ + sessionUsage: Record runs: Record sendingSessions: string[] running: RunningState | null @@ -121,7 +123,7 @@ export const useChatStore = create((set, get) => { }) return ({ - sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, runs: {}, sendingSessions: [], + sessions: [], currentSessionId: null, messages: [], sessionMessages: {}, sessionUsage: {}, runs: {}, sendingSessions: [], running: null, sending: false, loading: false, error: null, draftContext: {}, draftId: 0, async init() { try { @@ -415,6 +417,11 @@ export const useChatStore = create((set, get) => { const terminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error' if (cancelRequestedRuns.has(event.runId) && !terminal) return if (terminal) cancelRequestedRuns.delete(event.runId) + // 真实 token 用量只记录,不参与消息投影(上游没给就不产生该事件)。 + if (event.type === 'usage') { + set((current) => ({ sessionUsage: { ...current.sessionUsage, [owner]: event.usage } })) + return + } const key: ChatStreamKey = { sessionId: owner, assistantId: run.assistantId } streamCommitter.enqueue(key, event) }, diff --git a/src/renderer/src/workbuddy/composer/ComposerControls.tsx b/src/renderer/src/workbuddy/composer/ComposerControls.tsx index 65951af..96d3e20 100644 --- a/src/renderer/src/workbuddy/composer/ComposerControls.tsx +++ b/src/renderer/src/workbuddy/composer/ComposerControls.tsx @@ -52,6 +52,10 @@ export interface ComposerControlsProps { onSkipFutureFullAccessConfirmationChange?(skip: boolean): void | Promise sessionId?: string | null modelSlot?: ReactNode + /** 左组紧邻「+」之后:Git 分支标签(TODO 21)。 */ + branchLabelSlot?: ReactNode + /** 右组最前:上下文用量环(TODO 21)。 */ + contextUsageSlot?: ReactNode expertSlot?: ReactNode floatingSlot?: ReactNode capabilityReferences?: ReactNode @@ -145,6 +149,8 @@ export function ComposerControls({ onSkipFutureFullAccessConfirmationChange, sessionId, modelSlot, + branchLabelSlot, + contextUsageSlot, expertSlot, floatingSlot, capabilityReferences, @@ -545,11 +551,13 @@ export function ComposerControls({ if (anchor) (id === 'experts' ? onOpenExperts : onOpenSkills)?.(anchor) } }} /> + {branchLabelSlot} {expertSlot} {permissionPlacement === 'toolbar' && permissionControl}
+ {contextUsageSlot} {modelSlot &&
{modelSlot}
} (null) + useEffect(() => { + if (!sessionId) { + setInfo(null) + return + } + let active = true + const read = (): void => { + const request = window.ycode.git?.branch?.(sessionId) + if (!request) return + void request.then((value) => { if (active) setInfo(value) }).catch(() => { if (active) setInfo(null) }) + } + setInfo(null) + read() + const timer = window.setInterval(read, BRANCH_REFRESH_INTERVAL_MS) + const onFocus = (): void => read() + window.addEventListener('focus', onFocus) + return () => { active = false; window.clearInterval(timer); window.removeEventListener('focus', onFocus) } + }, [sessionId]) + return info +} + +/** + * 分支标签:`._branchLabel_jdvlk_244`。 + * 悬停/聚焦时图标由分支字形换成 copy 字形(原版 CSS 的 opacity 互换),点击复制分支名。 + * 原版对 detached HEAD / 无首次提交 / 非仓库 / 检测失败的显示未采集:这里只在拿到真实分支名时渲染。 + */ +export function ComposerBranchLabel({ sessionId }: { sessionId?: string | null }): React.JSX.Element | null { + const info = useGitBranch(sessionId) + const branch = info?.repo && !info.unavailable ? info.branch : null + if (!branch) return null + return ( + + + {branch} + + ) +} + +export interface ContextUsageRingProps { + sessionId?: string | null + /** 当前模型声明的上下文窗口(自定义模型的真实配置值);未知时不显示用量明细。 */ + contextWindow?: number + /** 本会话是否已经有消息(决定「本会话暂无用量明细」还是「暂时无法获取」)。 */ + hasMessages: boolean + /** 是否正在跑回合(决定「正在会话中,无法展示用量信息」)。 */ + running: boolean +} + +/** + * 上下文用量环 + 浮层:`.cr-context-usage`(32×32,内含 16×16 双圆环)。 + * 进度与数字只用上游真实 `usage.input_tokens` 与模型真实上下文窗口。 + */ +export function ContextUsageRing({ sessionId, contextWindow, hasMessages, running }: ContextUsageRingProps): React.JSX.Element | null { + const usage: TokenUsage | undefined = useChatStore((state) => (sessionId ? state.sessionUsage[sessionId] : undefined)) + const [open, setOpen] = useState(false) + const [hover, setHover] = useState(false) + + useEffect(() => { if (!sessionId) setOpen(false) }, [sessionId]) + useEffect(() => { + if (!open) return + const outside = (event: PointerEvent): void => { + if (event.target instanceof Node && (event.target as Element).closest?.('.cr-context-usage')) return + setOpen(false) + } + const escape = (event: KeyboardEvent): void => { if (event.key === 'Escape') setOpen(false) } + document.addEventListener('pointerdown', outside) + document.addEventListener('keydown', escape, true) + return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', escape, true) } + }, [open]) + + const total = typeof contextWindow === 'number' && contextWindow > 0 ? contextWindow : undefined + const used = usage?.inputTokens + const percent = useMemo(() => used !== undefined && total !== undefined ? Math.min(100, Math.max(0, (used / total) * 100)) : null, [used, total]) + if (!sessionId) return null + + const dashOffset = percent === null ? RING_CIRCUMFERENCE : RING_CIRCUMFERENCE * (1 - percent / 100) + const tooltipText = percent !== null && used !== undefined && total !== undefined + ? `${Math.round(percent)}% · ${used} / ${total} 上下文已使用` + : undefined + const unavailable = percent === null + + const body = unavailable + ? + {running ? '正在会话中,无法展示用量信息' : !total ? '暂时无法获取用量明细,请稍后重试' : hasMessages ? '暂时无法获取用量明细,请稍后重试' : '本会话暂无用量明细,发送一条消息后可查看'} + + : null + + return ( + +
setHover(true)} onMouseLeave={() => setHover(false)} + onClick={() => setOpen((value) => !value)} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + setOpen((value) => !value) + }}> + + + {hover && tooltipText && !open ? ( +
+
{tooltipText}
+
+
+ ) : null} + + {open ? ( + // 浮层渲染在用量环内部(锚定到环本身);必须阻断冒泡,否则内部点击会回到环的开关上把它又打开。 +
event.stopPropagation()}> +
+ 上下文用量 + +
+ {body} + {!body ? ( + <> +
+ {Math.round(percent!)}% + + 已使用 + {used} / {total} + +
+
+
+
+
+
+
+ {[['sp', '系统提示词'], ['tool', '工具及子智能体'], ['conv', '对话消息'], ['mcp', '连接器及MCP'], ['skill', '技能']].map(([key, label]) => ( +
+ + {label} + — +
+ ))} +
+ + ) : null} +
+ ) : null} +
+ + ) +} diff --git a/src/renderer/src/workbuddy/composer/context-info/icons.tsx b/src/renderer/src/workbuddy/composer/context-info/icons.tsx new file mode 100644 index 0000000..3646d8d --- /dev/null +++ b/src/renderer/src/workbuddy/composer/context-info/icons.tsx @@ -0,0 +1,26 @@ +/** + * 会话输入区分支标签图标(TODO 21)。 + * + * SVG 几何逐字来自固定 REA 归档 + * ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a + * (WorkBuddy 5.8.3 会话输入区,视口 1336×902,.tmp/rea-debug-ready-20261017/layout.json + * 的 svg_nodes:默认分支字形 + 悬停 copy 字形)。原版悬停 copy 用的就是 lucide copy + * (class 同为 `lucide lucide-copy wb-icon`,viewBox 0 0 24 24、stroke-width 1.5)。 + */ + +export function BranchGlyphIcon(): React.JSX.Element { + return ( + + ) +} + +export function BranchCopyIcon(): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/workbuddy/composer/context-info/source.css b/src/renderer/src/workbuddy/composer/context-info/source.css new file mode 100644 index 0000000..daf2865 --- /dev/null +++ b/src/renderer/src/workbuddy/composer/context-info/source.css @@ -0,0 +1,405 @@ +/* ---- 会话输入区上下文信息(TODO 21)---- + 分支标签与上下文用量环的规则逐条取自回收样式表: + - `contexts-BkjwYSZT.css`:`._branchLabel_jdvlk_244` 及其子元素(Git 分支标签) + - `src-CyXHA0IC.css`:`.cr-context-usage*`、`.cr-context-usage-popover*`、`.cr-context-usage-requesting-tip*` + 运行时几何来自 REA Evidence ev_2569313a7a0aa901c5a3b8dc1d7fd190109b9006c915830109b62195f312376a + (WorkBuddy 5.8.3,视口 1336×902,`.tmp/rea-debug-ready-20261017/layout.json`): + 左组 `[add menu 431][分支标签 471,82×32][权限 561][状态芯片]`; + 右组 `[用量环 903,32×32][模型选择器 939][语音 1117][发送 1157]`。 + 规则收敛在 `.cr-input-toolbar` 内,避免与其它作用域互相污染。 */ + +/* 分支标签(原 `.contexts-BkjwYSZT.css`) */ +.cr-input-toolbar ._branchLabel_jdvlk_244 { + box-sizing: border-box; + max-width: 188px; + height: 32px; + color: var(--wb-color-text-primary); + user-select: none; + background: 0 0; + border: none; + border-radius: 999px; + flex-shrink: 0; + align-items: center; + gap: 8px; + padding: 5px 8px; + font-family: PingFang SC, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; + font-size: 14px; + font-weight: 400; + line-height: 22px; + transition: background .15s; + display: inline-flex; +} + +.cr-input-toolbar ._branchLabel_jdvlk_244:hover, +.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within { + background: var(--wb-bg-secondary); +} + +.cr-input-toolbar ._branchLabel_jdvlk_244:hover ._branchLabelIconDefault_jdvlk_267, +.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within ._branchLabelIconDefault_jdvlk_267 { + opacity: 0; +} + +.cr-input-toolbar ._branchLabel_jdvlk_244:hover ._branchLabelIconCopy_jdvlk_270, +.cr-input-toolbar ._branchLabel_jdvlk_244:focus-within ._branchLabelIconCopy_jdvlk_270 { + opacity: 1; +} + +/* 原版窄宽度下会用的紧凑形态(回收规则;当前只在证据里出现规则、未观察到运行时触发条件) */ +.cr-input-toolbar ._branchLabelCompact_jdvlk_274 { + justify-content: center; + width: 32px; +} + +.cr-input-toolbar ._branchLabelIconButton_jdvlk_279 { + width: 16px; + height: 16px; + color: inherit; + cursor: pointer; + background: 0 0; + border: none; + flex-shrink: 0; + justify-content: center; + align-items: center; + padding: 0; + display: inline-flex; + position: relative; +} + +.cr-input-toolbar ._branchLabelIconButton_jdvlk_279 svg path { + fill-opacity: 1; +} + +.cr-input-toolbar ._branchLabelIconButton_jdvlk_279:focus-visible { + outline: 2px solid var(--wb-border-focus); + outline-offset: 1px; + border-radius: 4px; +} + +.cr-input-toolbar ._branchLabelIconDefault_jdvlk_267, +.cr-input-toolbar ._branchLabelIconCopy_jdvlk_270 { + justify-content: center; + align-items: center; + transition: opacity .15s; + display: inline-flex; + position: absolute; + inset: 0; +} + +.cr-input-toolbar ._branchLabelIconCopy_jdvlk_270 { + opacity: 0; +} + +.cr-input-toolbar ._branchLabelText_jdvlk_316 { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +/* 上下文用量环(原 `.src-CyXHA0IC.css`) */ +.cr-input-toolbar .cr-context-usage-tooltip-trigger { + flex-shrink: 0; + display: inline-flex; +} + +.cr-input-toolbar .cr-context-usage { + user-select: none; + border-radius: 16px; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + transition: background-color .2s; + display: inline-flex; + position: relative; +} + +.cr-input-toolbar .cr-context-usage:hover { + background-color: var(--cr-context-usage-hover-bg, #0000000d); +} + +.cr-input-toolbar .cr-context-usage--clickable { + cursor: pointer; +} + +.cr-input-toolbar .cr-context-usage__ring { + display: block; +} + +.cr-input-toolbar .cr-context-usage__ring-progress { + transition: stroke-dashoffset .3s; +} + +.cr-input-toolbar .cr-context-usage__ring-track { + stroke: var(--cr-ring-track, rgba(128, 128, 128, 0.2)); +} + +.cr-input-toolbar .cr-context-usage__ring-progress { + stroke: var(--cr-ring-progress, #9ca3af); +} + +/* 用量浮层(原 `.src-CyXHA0IC.css`;原版由 JS 定位,这里按同一组件的 + `.cr-context-usage-requesting-tip` 的锚定方式内联定位:上方 8px、右对齐)。 */ +.cr-input-toolbar .cr-context-usage-popover { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 10000; + border: 1px solid var(--cr-context-popover-border, var(--cr-border-default, #ebebeb)); + background: var(--cr-context-popover-bg, var(--cr-bg-elevated, #fff)); + width: 280px; + box-shadow: var(--cr-shadow-popover); + color: var(--cr-text-primary, #000000e6); + border-radius: 16px; + padding: 16px; + font-size: 12px; + line-height: 1.5; +} + +.cr-input-toolbar .cr-context-usage-popover__header { + align-items: center; + gap: 8px; + margin-bottom: 8px; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__title { + color: var(--cr-text-primary, #000000e6); + flex: 1; + font-size: 12px; + font-weight: 400; + line-height: 20px; +} + +.cr-input-toolbar .cr-context-usage-popover__close { + width: 16px; + height: 16px; + color: var(--cr-text-secondary, #00000080); + cursor: pointer; + background: 0 0; + border: none; + border-radius: 4px; + flex: none; + justify-content: center; + align-items: center; + padding: 0; + transition: color .15s; + display: inline-flex; +} + +.cr-input-toolbar .cr-context-usage-popover__close:hover { + color: var(--cr-text-primary, #000000e6); +} + +.cr-input-toolbar .cr-context-usage-popover__stats { + align-items: center; + gap: 8px; + margin-bottom: 4px; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__percent { + color: var(--cr-text-primary, #000000e6); + font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 28px; +} + +.cr-input-toolbar .cr-context-usage-popover__used-info { + align-items: center; + gap: 4px; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__used-label { + color: var(--cr-text-tertiary, #0000004d); + font-size: 12px; + line-height: 20px; +} + +.cr-input-toolbar .cr-context-usage-popover__used-value { + color: var(--cr-text-tertiary, #0000004d); + font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 12px; + line-height: 20px; +} + +.cr-input-toolbar .cr-context-usage-popover__bar { + background: var(--cr-bg-active, #00000014); + border-radius: 3px; + height: 8px; + margin-bottom: 16px; + display: flex; + overflow: hidden; +} + +.cr-input-toolbar .cr-context-usage-popover__bar-fill { + gap: 1px; + height: 100%; + transition: width .3s; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__seg { + height: 100%; + transition: width .25s; +} + +.cr-input-toolbar .cr-context-usage-popover__seg--sp { + background: var(--cr-cat-system-prompt, #6366f1); +} + +.cr-input-toolbar .cr-context-usage-popover__seg--tool { + background: var(--cr-cat-tools, #10b981); +} + +.cr-input-toolbar .cr-context-usage-popover__seg--conv { + background: var(--cr-cat-conversation, #f59e0b); +} + +.cr-input-toolbar .cr-context-usage-popover__seg--mcp { + background: var(--cr-cat-mcp, #8b5cf6); +} + +.cr-input-toolbar .cr-context-usage-popover__seg--skill { + background: var(--cr-cat-skills, #ec4899); +} + +.cr-input-toolbar .cr-context-usage-popover__legend { + flex-direction: column; + gap: 4px; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__row { + align-items: center; + gap: 8px; + height: 22px; + display: flex; +} + +.cr-input-toolbar .cr-context-usage-popover__swatch { + border-radius: 50%; + flex: 0 0 8px; + width: 8px; + height: 8px; +} + +.cr-input-toolbar .cr-context-usage-popover__swatch--sp { + background: var(--cr-cat-system-prompt, #6366f1); +} + +.cr-input-toolbar .cr-context-usage-popover__swatch--tool { + background: var(--cr-cat-tools, #10b981); +} + +.cr-input-toolbar .cr-context-usage-popover__swatch--conv { + background: var(--cr-cat-conversation, #f59e0b); +} + +.cr-input-toolbar .cr-context-usage-popover__swatch--mcp { + background: var(--cr-cat-mcp, #8b5cf6); +} + +.cr-input-toolbar .cr-context-usage-popover__swatch--skill { + background: var(--cr-cat-skills, #ec4899); +} + +.cr-input-toolbar .cr-context-usage-popover__row-name { + color: var(--cr-text-primary, #000000e6); + flex: 1; + font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 13px; + line-height: 22px; +} + +.cr-input-toolbar .cr-context-usage-popover__row-value-tooltip { + align-items: center; + display: inline-flex; +} + +.cr-input-toolbar .cr-context-usage-popover__row-value { + color: var(--cr-text-tertiary, #0000004d); + font-family: SF Pro Text, -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 12px; + line-height: 20px; +} + +.cr-input-toolbar .cr-context-usage-popover__window-control { + border-top: 1px solid var(--cr-bg-primary-default, #ebebeb); + margin-top: 16px; + padding-top: 16px; +} + +/* 「无法展示用量」提示(原 `.cr-context-usage-requesting-tip`) */ +.cr-input-toolbar .cr-context-usage-requesting-tip { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + z-index: 1000; + border: 1px solid var(--cr-context-popover-border, var(--cr-border-default, #ebebeb)); + background: var(--cr-context-popover-bg, var(--cr-bg-elevated, #fff)); + width: max-content; + max-width: 240px; + box-shadow: var(--cr-shadow-popover); + color: var(--cr-text-secondary, #00000080); + white-space: nowrap; + border-radius: 8px; + padding: 8px 12px; + font-size: 12px; + line-height: 1.5; +} + +.cr-input-toolbar .cr-context-usage-requesting-tip__text { + display: block; +} + +/* 悬停提示:`.wb-tooltip` 基类(src-Bh3YZ35H.css)+ tokens-Df-i-zsO.css 的取值。 + 原版提示是 floating-ui 浮层,这里按组件自身的上方/右对齐方向内联渲染(与 requesting-tip 同向)。 */ +.cr-input-toolbar .cr-context-usage-tooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + border-radius: 8px; + padding: 4px 8px; + background: var(--wb-tooltip-bg, var(--wb-palette-black-75, #000000bf)); + max-width: 320px; + color: var(--wb-tooltip-text-color, var(--wb-palette-white-100, #fff)); + pointer-events: none; + box-sizing: border-box; + text-align: left; + backdrop-filter: blur(4px); + white-space: nowrap; + font-size: 12px; + line-height: 20px; +} + +[data-theme='dark'] .cr-input-toolbar .cr-context-usage-tooltip { + background: var(--wb-tooltip-bg, var(--wb-palette-white-75, #ffffffbf)); + color: var(--wb-tooltip-text-color, var(--wb-palette-black-100, #000)); +} + +.cr-input-toolbar .cr-context-usage-tooltip__content { + overflow-y: auto; +} + +.cr-input-toolbar .cr-context-usage-tooltip__arrow { + border-style: solid; + width: 0; + height: 0; + position: absolute; + left: 50%; + top: 100%; + transform: translateX(-50%); + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 3px solid var(--wb-tooltip-bg, var(--wb-palette-black-75, #000000bf)); + border-bottom: 0; +} + +[data-theme='dark'] .cr-input-toolbar .cr-context-usage-tooltip__arrow { + border-top-color: var(--wb-tooltip-bg, var(--wb-palette-white-75, #ffffffbf)); +} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ca7ea34..88bbb1c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -10,7 +10,8 @@ import type { GatewayConfigView, WorkspaceInfo, WindowState, - AppInfo + AppInfo, + GitBranchInfo } from './types' import type { DownloadPayload, DownloadResult, ResourceEntry, ResourceRange, WorkspaceResource } from './resources' import type { ChatAttachment, InteractionResponse, MessageQuote, PermissionMode } from './conversation' @@ -91,6 +92,10 @@ export interface YCodeApi { onSettingsChanged(listener: () => void): () => void onSettingsNavigate(listener: (section: string, action?: 'new') => void): () => void } + git: { + /** 读取某个会话工作目录的真实分支信息;目录不在仓库内或 git 不可用时返回 repo:false/unavailable。 */ + branch(sessionId: string): Ipc + } config: { get(): Ipc update(patch: ConfigPatchLike): Ipc diff --git a/src/shared/types.ts b/src/shared/types.ts index 1c6f01d..89bf343 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -125,11 +125,39 @@ export type ChatEvent = { sessionId?: string; turnDurationMs?: number } & ( | { runId: string; type: 'interaction'; interaction: ChatInteraction } | { runId: string; type: 'tool_call'; call: ToolCallRecord } | { runId: string; type: 'tool_result'; call: ToolCallRecord } + | { runId: string; type: 'usage'; usage: TokenUsage } | { runId: string; type: 'done'; content: string; tools: ToolCallRecord[] } | { runId: string; type: 'cancelled' } | { runId: string; type: 'error'; message: string } ) +/** + * 上游返回的真实 token 用量(Responses `response.completed.usage`)。 + * 只做透传,不做估算:拿不到就不产生该事件。 + */ +export interface TokenUsage { + inputTokens: number + outputTokens: number + totalTokens: number + cachedTokens?: number +} + +/** + * 会话工作目录的 Git 信息(用于对话框的分支标签)。 + * `repo: false` 表示该目录不在 Git 仓库内;`detached: true` 时 `branch` 为空、`head` 为短 SHA。 + */ +export interface GitBranchInfo { + /** 目录是否位于 Git 工作树内 */ + repo: boolean + /** 分支名;detached HEAD 或尚无提交时为 null */ + branch: string | null + detached: boolean + /** detached HEAD 的短 SHA;无提交时为 null */ + head: string | null + /** git 可执行文件缺失或命令超时时为 true,UI 不显示分支标签 */ + unavailable?: boolean +} + /** 一次会话的元信息 */ export interface SessionMeta { id: string