Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

本项目所有值得注意的变更都记录在此文件中。

## 3.8.1-32 - 2026-09-06

### 新增(回归防护网补缺:6 个测试文件 46 条用例 + 1 个缺陷锁定)

- **补齐「源码有、测试无」的六个真实覆盖缺口**(test/tool-group-view.test.ts、test/command.test.ts、test/protocol-part-view.test.ts、test/renderable.test.ts、test/plugin-protocol.test.ts、test/clipboard-text.test.ts,由 Hopper(TestEngineerAgent)按回归防护职责补写;版本号 bump 留待发版流程统一处理)。
- 为什么改:用户要求为 zcode-cli 织回归防护网、防止新功能改坏已有功能。全量源码 ↔ 测试映射摸底发现:80 个既有测试已覆盖绝大多数模块(含经其它测试文件间接覆盖的 bigmodel-users / color-scheme / update-available-view / login-flow 导出函数),但六个模块零直接测试——tool-group-view(工具分组折叠摘要,TUI 高频视觉路径)、command(captureCommand 子进程捕获,zai-oauth / darwin-oauth-callback / update 三处依赖的基础工具)、protocol-part-view(五类协议 part 渲染,仅被 registry 测试顺带 import)、renderable(三个组件类型守卫)、plugin-protocol(插件方法名协议契约表)、clipboard-text(Ctrl+V 智能粘贴,3.8.1-23 新增功能)。
- 改了什么:六个新测试文件共 46 条用例,全部锁定当前行为契约——tool-group-view 10 条(成员增删、展开传播、隐藏内容、搜索文本拼接、折叠摘要的单复数 / read-search 分组 / 进行中失败中断的图标优先级、展开时空行分隔);command 5 条(stdout/stderr 分离捕获、非零退出码、多行输出、启动失败契约);protocol-part-view 14 条(可见性过滤、file 的 url 藏于展开态、retry/compaction/subagent/agent 渲染、搜索文本、update() 换装);renderable 6 条(三守卫正反分支);plugin-protocol 4 条(11 个方法名逐字锁定 + workspace 路径归一);clipboard-text 2 条(跨平台返回契约 + darwin pbpaste 专项,linux CI 无剪贴板工具走 undefined 路径)。
- 缺陷发现(先红后绿纪律,未修实现——修复归开发侧,已记 TODO T5):command.test.ts 一条用例以 `test.failing` 锁定——captureCommand 在目标二进制不存在时设计意图是返回 `{ code: 1, stderr: 启动错误 }`,实测抛 `ERR_STREAM_PREMATURE_CLOSE`(error 事件后子进程流提前关闭,readText 的异步迭代先于 Promise.all reject);影响 update(用户未装 gh)与 zai-oauth 打开浏览器的降级路径。修复后去掉 .failing 标记即可,若实现先达标 bun 会反向报错提醒。
- 验证:全量 `bun test` 746 pass / 0 fail(84 files,含新增 6 文件 46 条);`tsc --noEmit` 通过。
- 配套治理(2026-09-06 同日,Hopper 配置):main 分支保护已启用——required status check `validate`(strict)、Require pull request before merging(0 approvals,单人可自合并)、enforce_admins、禁 force push、禁删分支。此后所有改动必须走 feature 分支 + PR、CI 绿灯才能进 main,直接 push main 会被拒绝(按 GitHub 官方文档语义:required checks 拦未验证 commit、Require PR 完全禁直接 push)。注意:本仓库是 fork(上游 kingsword09/zcode-cli),GitHub 对 fork 的 workflow 默认不随 push 自动运行——首次走 PR 前先在 Actions 页签确认 workflows 已启用,否则 PR 会等不到 `validate` 检查。

### 修复

- **未登录时 `/model` 列表选官方条目(如 `bigmodel/glm-5.3`)完全不能用:模型切换现在把无凭证的官方槽位引用解析到同 provider 的 env 槽(custom-provider)**(src/identity.ts、packages/zcode-tui/src/selectors.ts、packages/zcode-tui/src/index.ts、test/identity.test.ts、test/selectors.test.ts,版本号 bump 至 3.8.1-32:VERSION、package.json、test/update.test.ts、三版 README 徽章与安装 URL)。
- 为什么改:用户报告 3.8.1-31 的列表去重有 bug——`env-` 前缀条目被剔除后,未登录(无 vault 登录、官方槽无 key)时选中官方条目 `bigmodel/glm-5.3` 完全不能用,而它已是该模型在列表里的唯一路径;用户要求列表里剩下的所有模型选项不管登录与否都能用、未登录走 custom-provider 逻辑。根因:去重保留官方条目、删掉 env 槽条目,但官方 `bigmodel` 槽位归 `/login` 管、未登录时没有凭证,`setModel("bigmodel/glm-5.3")` 发给 runtime 后无 key 可用;真正带 key 的 `env-bigmodel` 槽位条目恰好被去重删掉了。
- 改了什么:① src/identity.ts 新增 `resolveModelSlotRef()`:官方槽引用(`<provider>/<model>`)在该 provider 有 vault 登录或官方槽 key 时原样返回;两者皆无而 `env-<provider>` 槽位带 key 且声明了该模型时回退为 env 槽引用(按 provider 独立判定——登录 zai 不影响选 bigmodel 模型时的回退);已是 env 槽引用、env 槽未声明该模型或无带 key 的 env 槽则原样返回。② TUI 三个模型切换入口——`/model` 选择列表与快捷循环切换共用的 `switchTransientModel`(手输 `/model <provider/model>` 也汇入此处)、`/settings → Model providers` 保存后的会话应用——发桥前一律过 `resolveModelSlotRef()`;`/settings` 持久化写进 config.json 的 main/lite 同样写解析后的槽位引用,未登录时保存的块直接指向可用的 env 槽(登录后的 `switchModelBlockToOfficialProvider` 迁移语义不变)。③ selectors.ts 的 current 标注改为双形式匹配(内部槽位形式 `env-<provider>/<model>` 与显示形式 `<provider>/<model>`),修复去重后 config 保存值(env 槽形式)与列表条目(官方形式)对不上导致「current」标记与 `/settings` 预选丢失的显示回归(3.8.1-31 引入);`/settings` 的 main/lite 预选查找同步双形式匹配。
- 溯源与防回归评估:3.8.1-31 的去重方向(每个模型只显示一次、官方条目胜出)保持不变——修复不动 `withoutEnvSlotTwins()` 的取舍,只把「未登录时 env 槽是唯一可用路径」的事实从显示层(保留 env 独有条目)补到切换层(官方条目在运行时解析回 env 槽);env 独有条目(无官方孪生)原样保留、原样可用;登录态(vault token 或官方槽 key)行为与 3.8.1-31 完全一致(原样走官方槽);3.8.1-26 的「未登录时 env 文件是 model block 权威」语义不受影响——`/settings` 未登录保存写 env 槽引用,与 launcher 启动同步写的方向一致。
- 验证:`tsc --noEmit` 通过;identity 单测新增 7 用例(未登录回退、env 槽未声明该模型原样、无带 key env 槽原样、官方槽 key 原样、vault token 原样、跨 provider 独立判定、env 引用与无斜杠别名原样),selectors 单测新增 2 用例(current 标注以 env 槽形式匹配官方孪生:flat picker 与 provider 级联);全量 `bun test` 746 pass / 0 fail(84 files);TUI 冒烟 5 项全过(`build:tui` 重建后 vendor 内 `@zcode/tui` 副本按 `installLocalTui` 同步骤手动同步)。

## 3.8.1-31 - 2026-09-06

### 变更
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
![ZCode CLI](./assets/logo.svg)

[![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--31-blue.svg)](./CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--32-blue.svg)](./CHANGELOG.md)
[![Type](https://img.shields.io/badge/Type-CLI_Tool-blue.svg)]()

English | [简体中文](README_zh_hans.md) | [繁體中文](README_zh_hant.md)
Expand All @@ -29,7 +29,7 @@ redistribute the extracted runtime before publishing a release.
## Quick start

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
zcode
```

Expand Down Expand Up @@ -59,9 +59,9 @@ Reopen it anytime with `/setup`; press Esc to skip.
## Install and update

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
# or
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
```

GitHub Releases are the only distribution channel; the project does not
Expand Down
8 changes: 4 additions & 4 deletions README_zh_hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
![ZCode CLI](./assets/logo.svg)

[![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--31-blue.svg)](./CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--32-blue.svg)](./CHANGELOG.md)
[![Type](https://img.shields.io/badge/Type-CLI_Tool-blue.svg)]()

[English](README.md) | 简体中文 | [繁體中文](README_zh_hant.md)
Expand All @@ -26,7 +26,7 @@
## 快速开始

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
zcode
```

Expand Down Expand Up @@ -54,9 +54,9 @@ zcode
## 安装与更新

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
# 或
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
```

GitHub Release 是唯一的分发渠道,本项目不发布到 npm。包装名为
Expand Down
8 changes: 4 additions & 4 deletions README_zh_hant.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
![ZCode CLI](./assets/logo.svg)

[![License](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--31-blue.svg)](./CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-3.8.1--32-blue.svg)](./CHANGELOG.md)
[![Type](https://img.shields.io/badge/Type-CLI_Tool-blue.svg)]()

[English](README.md) | [简体中文](README_zh_hans.md) | 繁體中文
Expand All @@ -26,7 +26,7 @@
## 快速開始

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
zcode
```

Expand Down Expand Up @@ -54,9 +54,9 @@ zcode
## 安裝與更新

```bash
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
npm install -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
# 或
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-31.tgz
bun add -g https://github.com/xhqing/zcode-cli/releases/latest/download/zcode-cli-3.8.1-32.tgz
```

GitHub Release 是唯一的分發渠道,本項目不發布到 npm。包名為
Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
## 🟠 橙色紧急度(边界情况出错 / 防护缺口 / 口径不一致,排在红色紧急度之后计划处理)

- [ ] **T2** **客户端设置保存改为「读盘合并」写入,防止用进程内存旧配置快照整体重写 `~/.zcode/cli/config.json`、冲掉外部对配置的修改(含 hooks 挂载)**。**为什么**:2026-09-04 实测事故(T1 转登材料)——一次客户端设置保存把进程内存中的旧配置快照整体写回用户级 config.json,把外部对 hooks 段的修改(DayTradingAgent 上移到用户级的两条安全 hook)静默冲掉;不修的话,任何经客户端保存设置的时点都可能无声丢配置。T1 落地后项目级 hooks 走 trust store 单源、DayTradingAgent 也将撤回用户级挂载,敞口收窄,但用户级 hooks 段及其它外部工具对 config.json 的修改仍会被冲。**做什么**:定位设置保存路径(TUI 侧 `/config` 类命令或 runtime 侧 config 写入点),把「内存快照整体写回」改为「写前读盘 → 只合并本次编辑目标字段 → 写回」,或至少保留非编辑目标的段(hooks 等)。**验证口径**:外部修改 config.json 的 hooks 段 → 经 TUI 保存任一设置 → hooks 段原样保留。(记录:2026-09-04 13:10,由 T1 同源风险提示裁定立项)
- [ ] **T5** **修复 `captureCommand`(src/command.ts)启动失败路径抛 `ERR_STREAM_PREMATURE_CLOSE`,实现设计意图的 `{ code: 1, stderr: 启动错误 }` 降级返回**。**为什么**:2026-09-06 Hopper 补回归用例时发现(test/command.test.ts 已用 `test.failing` 锁定契约)——spawn 的 error 事件触发后子进程 stdout/stderr 流提前关闭,`readText()` 的异步迭代先于 `Promise.all` reject,整个调用抛异常而非返回结果;`launchError` 分支(`stderr || launchError`)实际永远不可达。受影响调用方:update.ts 调 `gh`(用户未装 gh 时本应优雅报 code 1 + 提示,实际未捕获异常)、zai-oauth.ts / darwin-oauth-callback.ts 打开浏览器。**做什么**(实现归开发侧):error 路径下不迭代已关闭的流(如 readText 捕获 premature close 返回已收内容、或 error 事件后直接短路读取)。**验证口径**:`captureCommand("/nonexistent-binary", [])` 返回 `{ code: 1, stderr: 非空 }` 不抛异常;修复后去掉 test/command.test.ts 里该用例的 `.failing` 标记(bun 会先反向报错提醒)。(记录:2026-09-06 13:26,Hopper 回归防护网补缺时发现立项)

## 🟢 绿色紧急度(计划类新功能实现 / 改造方案落地,按计划排期推进)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.8.1-31
3.8.1-32
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-cli",
"version": "3.8.1-31",
"version": "3.8.1-32",
"description": "Unofficial terminal client for the ZCode agent runtime",
"keywords": [
"agent",
Expand Down
35 changes: 25 additions & 10 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ import {
clearIdentitiesWithChangedKeys,
clearOAuthLoginCredentials,
readBigModelKeyNameHint,
readProviderApiKeySnapshot
readProviderApiKeySnapshot,
resolveModelSlotRef
} from "../../../src/identity.ts";
import { displayModelRef } from "../../../src/env-config.ts";
import { resolveBigmodelUserName, writeBigmodelUserName } from "../../../src/bigmodel-users.ts";
Expand Down Expand Up @@ -3345,7 +3346,11 @@ class ZCodeTui {
this.settingSwitchInFlight = true;
try {
const previousModel = this.model;
const result = await this.options.setTransientModel(modelId);
// The picked id may be the official-slot twin of a custom-provider slot
// (the deduplicated picker keeps only the official entry). While that
// provider has no stored login, the official slot has no credential, so
// the switch targets the credentialed env slot instead.
const result = await this.options.setTransientModel(await resolveModelSlotRef(modelId));
await this.handleResult(result, false, "model");
const status = this.model === previousModel ? "already active" : "now";
this.addNotice(
Expand Down Expand Up @@ -3389,12 +3394,17 @@ class ZCodeTui {

// Preselect from the saved config value only: it keeps the internal
// `<slot>/<model>` form this.model no longer carries (the display form
// has the env- prefix stripped).
// has the env- prefix stripped). Match either form — after the
// env-slot-twin dedup the listed value is the official id while the saved
// block may still point at the env slot, and env-only entries keep the
// prefixed id.
const cascade = providerModelPicker(this.modelOptions, savedModel);
if (!cascade || cascade.providers.items.length === 0) {
this.addNotice("No model providers available to configure.", "muted");
return;
}
const savedModelIds = new Set(savedModel ? [savedModel, displayModelRef(savedModel)] : []);
const savedLiteIds = new Set(savedLite ? [savedLite, displayModelRef(savedLite)] : []);

let providerIndex = cascade.providers.selectedIndex;
while (!this.stopped) {
Expand All @@ -3417,7 +3427,7 @@ class ZCodeTui {
let confirmed = false;
// Track the in-progress main selection so Esc at lite returns to the
// model the user just chose, not the saved default.
let mainIndex = group.models.items.findIndex((item) => item.value === savedModel);
let mainIndex = group.models.items.findIndex((item) => savedModelIds.has(item.value));
if (mainIndex < 0) mainIndex = group.models.selectedIndex;
while (!this.stopped && !confirmed) {
// Level 2 — main model
Expand All @@ -3444,7 +3454,7 @@ class ZCodeTui {
};
const liteItems = [...liteCandidates, sameAsMainItem];
const savedLiteIndex = liteItems.findIndex(
(item) => item.value === savedLite && item.value !== mainChoice.value
(item) => savedLiteIds.has(item.value) && item.value !== mainChoice.value
);
const liteChoice = await this.showChoice({
title: `Select lite model · ${group.label}`,
Expand All @@ -3455,13 +3465,18 @@ class ZCodeTui {
});
if (!liteChoice) continue; // Esc → back to main selection (this while)

// Persist to config.json. A write failure surfaces as a notice and
// skips the session switch — the user can retry.
// Persist to config.json as the slot reference the switch actually
// targets: while signed out the official-slot twin resolves to the
// credentialed env slot (resolveModelSlotRef), keeping the saved block
// and the session application below on a usable slot. A write failure
// surfaces as a notice and skips the session switch — the user can retry.
const mainSlotRef = await resolveModelSlotRef(mainChoice.value);
const liteSlotRef = await resolveModelSlotRef(liteChoice.value);
try {
await updateUserConfig((config) => {
const model = isRecord(config.model) ? config.model : {};
model.main = mainChoice.value;
model.lite = liteChoice.value;
model.main = mainSlotRef;
model.lite = liteSlotRef;
config.model = model;
});
} catch (error) {
Expand All @@ -3477,7 +3492,7 @@ class ZCodeTui {
);

// Apply main model to the current session
await this.applySettingCommand(`/model ${mainChoice.value}`, "model");
await this.applySettingCommand(`/model ${mainSlotRef}`, "model");
confirmed = true;
}
// Return to the settings menu after a successful cascade instead of
Expand Down
Loading