diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a7a3e94d..4c6431f886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Notable changes to Bake. `/changelog` in the terminal prints the section for the ## [Unreleased] +## [0.3.1] - 2026-09-30 + +- `/settings` opens straight on its most general section, Terminal, instead of a list of sections. The sections run from most general to most specific on the tab row under the title: Terminal, Session, Agent, Shell, Web search, and Advanced. Tab and Shift-Tab move between them, and Escape from a section closes the panel. Typing on any section searches every setting, and a setting changed from another section's results opens that section with the changed row selected. +- Native Windows consoles, including the classic console host and editor terminals, draw the rounded frame instead of the ASCII one. Node writes to them as UTF-16, so the missing locale and `TERM` variables no longer mean the frame cannot be drawn. A Chinese, Japanese, or Korean system locale still selects the ASCII frame outside Windows Terminal, because the console host draws its line characters two cells wide there. + ## [0.3.0] - 2026-10-01 - `edit` no longer needs a prior `read`. A replacement whose `old_string` matches the current file exactly once is applied, even if the file changed since it was read, and changes elsewhere in the file are kept. When the model had not seen that content, the result shows the edited lines. `replace_all` and `write` still require a current read. `edits: [{ old_string, new_string }]` applies several changes to one file in one atomic call. A failed match names a whitespace-insensitive near match or lists the matching lines. `fs-observation-policy.editGuard: version` restores the old refusal. diff --git a/apps/cli/package.json b/apps/cli/package.json index 70ccffb92c..49da20b0e4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.3.0", + "version": "0.3.1", "publishConfig": { "access": "public" }, diff --git a/apps/tui/DESIGN-LAYOUT.md b/apps/tui/DESIGN-LAYOUT.md index e0f27efc8d..3a562f2d8b 100644 --- a/apps/tui/DESIGN-LAYOUT.md +++ b/apps/tui/DESIGN-LAYOUT.md @@ -188,7 +188,7 @@ The composer's rule and the divider that opens each turn are full-width runs of - One that is **not encoding UTF-8** writes the bytes through as mojibake, so the frame becomes punctuation on every row. - One configured to draw **East Asian Ambiguous** characters two cells wide draws a full-width horizontal run at twice the width Ink measured. The frame wraps, and Ink's own row arithmetic is wrong from then on — this is the damaging case. Every other Ambiguous character on this surface (the turn marker, the selection pointer, the caret) sits alone in a fixed-width rail, where a terminal that draws it wide shifts one row by one column; a border run accumulates that error across the whole line. -`resolveFrame` in `packages/app/src/frame.ts` decides once, before the first frame, and `@dsh-tui/ui` takes the answer as a prop — the presentation layer reads no environment. Encoding and `TERM` come from `LC_ALL`/`LC_CTYPE`/`LANG` in POSIX order; Windows Terminal sets neither but draws UTF-8, so its `WT_SESSION` stands in for both. Ambiguous width is a terminal *preference* and cannot be detected, so a CJK character locale (or `--locale zh`) stands in for it. `composerFrame` in the profile overrules the lot, which is the only answer for a terminal the environment describes wrongly. +`resolveFrame` in `packages/app/src/frame.ts` decides once, before the first frame, and `@dsh-tui/ui` takes the answer as a prop — the presentation layer reads no environment. Encoding and `TERM` come from `LC_ALL`/`LC_CTYPE`/`LANG` in POSIX order; Windows Terminal sets neither but draws UTF-8, so its `WT_SESSION` stands in for both. Native Windows needs neither: Node writes to the console as UTF-16 whatever the code page, so every Windows console draws the frame. Ambiguous width is a terminal *preference* and cannot be detected, so a CJK character locale (or `--locale zh`) stands in for it; outside Windows Terminal, Windows names that locale in the system rather than the environment, and its console host draws Ambiguous characters wide under a CJK code page. `composerFrame` in the profile overrules the lot, which is the only answer for a terminal the environment describes wrongly. The ASCII fallback is laid out against the same widths and spends the same rows. diff --git a/apps/tui/packages/app/README.i18n.yaml b/apps/tui/packages/app/README.i18n.yaml index 9b16097670..d14bb5cc13 100644 --- a/apps/tui/packages/app/README.i18n.yaml +++ b/apps/tui/packages/app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/tui/packages/app/README.md -README.md: 3fc43a29f47db2ab7e297bcc0175c4cd7a812a37 -README.zh.md: 4c5e6f9dcdb5c77b6968c58b21e0bfa6bda4b5a6 +README.md: 48af4f91845c3f6e8c43938b728c47d986f501ce +README.zh.md: a6829d328ae90d1e3cbb35ac50185362079615cc diff --git a/apps/tui/packages/app/README.md b/apps/tui/packages/app/README.md index 3fc43a29f4..48af4f9184 100644 --- a/apps/tui/packages/app/README.md +++ b/apps/tui/packages/app/README.md @@ -37,7 +37,7 @@ bun run start Use `bun run start --screen fullscreen` (or `bake --screen fullscreen` in an install) for a scrollable transcript with the input pinned at the bottom. The mouse wheel moves three rows, PgUp/PgDn move half a viewport, Ctrl+Home goes to the beginning, and Ctrl+End returns to the newest output. Scrolling up pauses following while new output arrives; scrolling back to the bottom resumes it. While paused, the row above the input leads with `↓ Latest · Ctrl+End`, which turns to `↓ New output · Ctrl+End` once output arrives below the view; clicking it also returns to the newest output. Fullscreen reports the mouse to Bake, so select text with Shift-drag, or Option-drag on macOS. Open sheets and selection panels keep their own navigation keys. Resizing rewraps visible history, and exit restores the shell screen and cursor. `--screen inline` uses native terminal scrollback and is the default; `INK_SCREEN_READER=true` also selects inline mode. Fullscreen has no transcript search yet. -`/settings` opens a panel of settings, saved in the settings file (`~/.bake/settings.yaml`) section by section; the terminal's own are in its `tui` section, where the `tui-runner` profile values are the defaults under the user's. The panel's first page lists sections, each with its values or what it holds: Session (the default model and, when the permission plugin is mounted, the default access preset, both marked `New sessions`), Terminal (the screen mode, inline by default or fullscreen, language, borders, whether the goal's objective is named on the header, tool output lines, menu rows, and the Ctrl+C quit window), then, when the composition registers them, Agent (parallel tool calls, subagents running at once, subagent nesting depth, whether subagents may pick a model, the models they may pick, and, once they may, Auto routing (beta)), Shell (the command timeout, the longest timeout a command may ask for, and the command output kept in memory), and Web search (searches per request, the search model, and its answer token limit). Advanced lists every registered settings namespace but `tui` and edits each field from the schema its plugin registered: on/off fields flip, constants are picked from a list, numbers and text are typed, secrets are typed masked and shown only as `Set` or `Not set`, and lists and maps open as JSON in `$VISUAL`, then `$EDITOR`, then `vi` (Notepad on Windows). The editor has the terminal until it exits, and the panel is drawn again after it; JSON with comments is accepted, and text that does not parse or an editor that exits non-zero leaves the value unchanged with the reason shown. The models subagents may pick come from the same catalog `/model` lists: Enter adds or removes one, each change is saved at once, and `Done` or Escape returns. The subagent plugin refuses the choice switched on with no model allowed, so switching it on from an empty list asks for the models first and saves both together, and the last model is kept while the choice is on. Auto routing (beta) lets the task router pick one of those models, and its reasoning effort, for a delegation that names none. Switching it on first says what it sends: each such delegation's task text and the names of the allowed models go to the router's URL, the hosted ing router by default, while the user's own providers still run every model. It also says when Bake is not signed in to the router, and then offers `Sign in and turn on` first; Enter on `Turn on` saves it without signing in. While it is on, the Agent section also offers Router account, the router's URL, the routing priority (`From the task` by default, or quality, cost, speed, or balanced), and Model calibration. Calibration asks the router how it sees each allowed model: the benchmarked model it matched or `unknown to the router`, its quality score, its price per million tokens, and `Ranked` or `Needs a hint`. Enter on a model sets its quality tier, its cost tier, or the benchmarked model it serves, each saved at once, and the list is asked again after every change. A quality tier set on a model the router has benchmarks for is marked as unused, since benchmarks win. When no model is ranked, the list warns that routed delegations keep the default model; a router that cannot be reached shows why. Router account signs in by email: it asks for an address, the router emails a six-digit code, and the code typed next is traded for a token the credential store keeps, so routed delegations send it from then on. A first sign-in creates the account. A refused address or code is shown on the field and asked again, and Escape leaves the stored token as it was. Signed in, the row names the account, and Enter offers signing in with another email or signing out. When the launch environment supplies the token, the row names its variable instead and signing in is not offered. Every write names only its field, so a secret the panel never showed is never lost. Typing on the first page searches every setting in every section, named by where it lives (`Shell › Command timeout`), and Enter edits the one found. Up/Down choose, Enter opens a section, a value list, or flips an on/off setting, Tab and Shift-Tab move to the next or previous section, named on a row of tabs under the title, and Escape returns to the page above and closes the panel from the first. A value list marks the value in force as `Current` and the default as `Default`, and ends with `Enter a value…` where the setting takes a typed one: durations accept `500ms`, `30s`, `2m`, or `1h`, sizes `64KB` or `1MB`, and a value outside the setting's bounds is refused with the bound it breaks. A setting the user has set also offers a pinned `Reset to default`, and a section with any offers `Reset this section to defaults` after a confirmation that defaults to keeping them. Screen mode and language apply at the next launch, and the panel marks them `Next launch` until then, as it does every field of a namespace whose plugin reads it only at start; `--screen`, or a `screen` set in the profile, overrides the saved screen, which the panel marks `Set by --screen`. The others apply at once, including edits made to the file by hand. A failed save, including a value the owning plugin refuses, shows its reason at the top of the panel and leaves the panel open. Closing the panel after a change names the file it was saved to. Without a settings file, only Session and Terminal are offered, and the panel says the changes last until exit. +`/settings` opens a panel of settings, saved in the settings file (`~/.bake/settings.yaml`) section by section; the terminal's own are in its `tui` section, where the `tui-runner` profile values are the defaults under the user's. The panel opens on its first and most general section, with every section named on a row of tabs under the title, most general first: Terminal (the screen mode, inline by default or fullscreen, language, borders, whether the goal's objective is named on the header, tool output lines, menu rows, and the Ctrl+C quit window), Session (the default model and, when the permission plugin is mounted, the default access preset, both marked `New sessions`), then, when the composition registers them, Agent (parallel tool calls, subagents running at once, subagent nesting depth, whether subagents may pick a model, the models they may pick, and, once they may, Auto routing (beta)), Shell (the command timeout, the longest timeout a command may ask for, and the command output kept in memory), and Web search (searches per request, the search model, and its answer token limit). Advanced lists every registered settings namespace but `tui` and edits each field from the schema its plugin registered: on/off fields flip, constants are picked from a list, numbers and text are typed, secrets are typed masked and shown only as `Set` or `Not set`, and lists and maps open as JSON in `$VISUAL`, then `$EDITOR`, then `vi` (Notepad on Windows). The editor has the terminal until it exits, and the panel is drawn again after it; JSON with comments is accepted, and text that does not parse or an editor that exits non-zero leaves the value unchanged with the reason shown. The models subagents may pick come from the same catalog `/model` lists: Enter adds or removes one, each change is saved at once, and `Done` or Escape returns. The subagent plugin refuses the choice switched on with no model allowed, so switching it on from an empty list asks for the models first and saves both together, and the last model is kept while the choice is on. Auto routing (beta) lets the task router pick one of those models, and its reasoning effort, for a delegation that names none. Switching it on first says what it sends: each such delegation's task text and the names of the allowed models go to the router's URL, the hosted ing router by default, while the user's own providers still run every model. It also says when Bake is not signed in to the router, and then offers `Sign in and turn on` first; Enter on `Turn on` saves it without signing in. While it is on, the Agent section also offers Router account, the router's URL, the routing priority (`From the task` by default, or quality, cost, speed, or balanced), and Model calibration. Calibration asks the router how it sees each allowed model: the benchmarked model it matched or `unknown to the router`, its quality score, its price per million tokens, and `Ranked` or `Needs a hint`. Enter on a model sets its quality tier, its cost tier, or the benchmarked model it serves, each saved at once, and the list is asked again after every change. A quality tier set on a model the router has benchmarks for is marked as unused, since benchmarks win. When no model is ranked, the list warns that routed delegations keep the default model; a router that cannot be reached shows why. Router account signs in by email: it asks for an address, the router emails a six-digit code, and the code typed next is traded for a token the credential store keeps, so routed delegations send it from then on. A first sign-in creates the account. A refused address or code is shown on the field and asked again, and Escape leaves the stored token as it was. Signed in, the row names the account, and Enter offers signing in with another email or signing out. When the launch environment supplies the token, the row names its variable instead and signing in is not offered. Every write names only its field, so a secret the panel never showed is never lost. Typing on a section's page searches every setting in every section, named by where it lives (`Shell › Command timeout`), and Enter edits the one found, then opens the section it lives in. Up/Down choose, Enter opens a subsection, a value list, or flips an on/off setting, Tab and Shift-Tab move to the next or previous section, and Escape returns to the page above and closes the panel from a section on the tab row. A value list marks the value in force as `Current` and the default as `Default`, and ends with `Enter a value…` where the setting takes a typed one: durations accept `500ms`, `30s`, `2m`, or `1h`, sizes `64KB` or `1MB`, and a value outside the setting's bounds is refused with the bound it breaks. A setting the user has set also offers a pinned `Reset to default`, and a section with any offers `Reset this section to defaults` after a confirmation that defaults to keeping them. Screen mode and language apply at the next launch, and the panel marks them `Next launch` until then, as it does every field of a namespace whose plugin reads it only at start; `--screen`, or a `screen` set in the profile, overrides the saved screen, which the panel marks `Set by --screen`. The others apply at once, including edits made to the file by hand. A failed save, including a value the owning plugin refuses, shows its reason at the top of the panel and leaves the panel open. Closing the panel after a change names the file it was saved to. Without a settings file, only Terminal and Session are offered, and the panel says the changes last until exit. The model is remembered too. A `/model` choice or a Shift-Tab step changes the current session and is also saved as the default model in the `agent-default-model` section of the same file, so the next launch and `/new` start on it; a resumed session keeps the model it recorded. The panel's default model row opens the same sheet. `/model` lists Recent first (the current model and up to four chosen before, kept as `recentModels` in the `tui` section), then each provider's models under its name, newest first. Each row shows the model's context window, `think` when it offers reasoning efforts, and `image` when it accepts images. ←→ step the selected model's reasoning effort on the row under the list, and Enter chooses the model and effort together. diff --git a/apps/tui/packages/app/README.zh.md b/apps/tui/packages/app/README.zh.md index 4c5e6f9dcd..a6829d328a 100644 --- a/apps/tui/packages/app/README.zh.md +++ b/apps/tui/packages/app/README.zh.md @@ -37,7 +37,7 @@ bun run start 使用 `bun run start --screen fullscreen`(安装版使用 `bake --screen fullscreen`)可启用可滚动的对话记录,并将输入框固定在底部。鼠标滚轮每次移动三行,PgUp/PgDn 每次移动半个视口,Ctrl+Home 跳到开头,Ctrl+End 返回最新输出。向上滚动后,新输出不会改变阅读位置;滚动回底部后恢复跟随。暂停跟随时,输入框上方一行以 `↓ 最新 · Ctrl+End` 开头,视图下方有新输出后变为 `↓ 新输出 · Ctrl+End`;点击该提示同样返回最新输出。全屏模式将鼠标事件交给 Bake,因此请用 Shift 拖动(macOS 上用 Option 拖动)选择文字。打开的详情页和选择面板保留各自的导航键。调整终端大小会重新换行可见历史,退出时恢复原来的 shell 屏幕和光标。`--screen inline` 使用终端原生滚动历史,也是默认模式;`INK_SCREEN_READER=true` 同样选择 inline 模式。全屏模式暂不支持对话记录搜索。 -`/settings` 打开设置面板,设置按分区保存在设置文件(`~/.bake/settings.yaml`)中;终端自身的设置位于 `tui` 部分,`tui-runner` 配置中的值作为默认值,位于用户设置之下。面板首页列出各分区,并显示其取值或所含内容:会话(默认模型,以及挂载权限插件时的默认权限预设,两者都标为 `新会话生效`)、终端(屏幕模式,默认 inline 或 fullscreen、语言、边框、标题行是否显示目标内容、工具输出行数、菜单行数和 Ctrl+C 退出确认时长),以及组合中注册了相应设置时的代理(并行工具调用数、同时运行的子代理数、子代理嵌套深度、子代理是否可选择模型、可选择的模型,以及允许选择模型后的自动路由(测试版))、Shell(命令超时、命令可请求的最长超时和内存中保留的命令输出)和网页搜索(每次请求的搜索次数、搜索模型和搜索回答令牌上限)。高级列出除 `tui` 外所有已注册的设置命名空间,并按插件注册的 schema 编辑每个字段:开关字段直接切换,常量从列表中选择,数字和文本通过输入填写,密钥以掩码输入且只显示为 `已设置` 或 `未设置`,列表和映射则以 JSON 在 `$VISUAL` 中打开,其次是 `$EDITOR`,最后是 `vi`(Windows 上为记事本)。编辑器退出前终端归其使用,退出后面板重新绘制;可以使用带注释的 JSON,无法解析的文本或以非零状态退出的编辑器不会改动取值,并显示原因。子代理可选择的模型来自 `/model` 所列的同一模型目录:Enter 添加或移除一个模型,每次更改立即保存,选择 `完成` 或按 Escape 返回。子代理插件不接受在未允许任何模型时开启该选项,因此从空列表开启时会先询问模型,并将两者一起保存;选项开启期间最后一个模型会被保留。自动路由(测试版)让任务路由器为未指定模型的委派从这些模型中选择一个,并选择其推理强度。开启前会先说明它发送的内容:每次此类委派的任务文本和允许模型的名称会发送到路由器地址(默认为托管的 ing 路由器),而所有模型仍由用户自己的提供方运行。若 Bake 尚未登录路由器也会一并提示,并优先提供 `登录并开启`;在 `开启` 上按 Enter 则不登录直接保存。开启后,代理分区还会提供路由器账户、路由器地址、路由优先(默认 `按任务判断`,也可选质量、成本、速度或均衡)以及模型校准。校准会询问路由器如何识别每个允许的模型:匹配到的已评测模型或 `路由器未识别`、质量分、每百万令牌价格,以及 `已评估` 或 `需要提示`。在某个模型上按 Enter 可设置其质量等级、成本等级或其所服务的已评测模型,每项立即保存,每次更改后都会重新询问路由器。若为路由器已有基准数据的模型设置了质量等级,该等级会被标为未使用,因为以基准数据为准。没有已评估的模型时,列表会提示路由的委派将使用默认模型;无法连接路由器时会显示原因。路由器账户通过邮箱登录:先询问邮箱地址,路由器随即发送六位验证码,接着输入的验证码会换取一个由凭据存储保管的令牌,此后路由的委派都会携带它。首次登录即创建账户。被拒绝的地址或验证码会显示在输入框上并再次询问,按 Escape 则保留已存储的令牌不变。已登录时,该行显示账户,按 Enter 可使用其他邮箱登录或退出登录。若启动环境提供了令牌,该行改为显示其变量名,且不提供登录。每次写入只涉及对应字段,因此面板从未显示过的密钥不会丢失。在首页输入文字会搜索所有分区中的所有设置,并标明其所在位置(`Shell › 命令超时`),按 Enter 即可编辑找到的设置。上下方向键选择,Enter 打开分区、可选值列表或切换开关类设置,Tab 和 Shift-Tab 切换到下一个或上一个分区(分区以标签形式列在标题下方),Escape 返回上一页,在首页则关闭面板。可选值列表将生效中的值标为 `当前`,将默认值标为 `默认`;可输入值的设置在列表末尾提供 `输入值…`:时长接受 `500ms`、`30s`、`2m` 或 `1h`,大小接受 `64KB` 或 `1MB`,超出设置范围的值会被拒绝,并说明违反的界限。用户设置过的项还会固定显示 `恢复默认值`;分区中有任何用户设置时会提供 `将本节恢复为默认值`,确认框默认选择保留。屏幕模式和语言在下次启动时生效,此前面板将其标为 `下次启动生效`;插件只在启动时读取的命名空间,其字段同样如此标记。`--screen` 或配置中设置的 `screen` 会覆盖已保存的屏幕模式,面板将其标为 `由 --screen 指定`。其余设置立即生效,手动编辑文件也是如此。保存失败时(包括所属插件拒绝的值),面板顶部会显示原因,面板保持打开。有更改时关闭面板,会提示保存到的文件。没有设置文件时,只提供会话和终端两个分区,并提示更改仅在本次运行有效。 +`/settings` 打开设置面板,设置按分区保存在设置文件(`~/.bake/settings.yaml`)中;终端自身的设置位于 `tui` 部分,`tui-runner` 配置中的值作为默认值,位于用户设置之下。面板打开时直接进入第一个、也是最通用的分区,所有分区以标签形式列在标题下方,按从通用到具体排列:终端(屏幕模式,默认 inline 或 fullscreen、语言、边框、标题行是否显示目标内容、工具输出行数、菜单行数和 Ctrl+C 退出确认时长)、会话(默认模型,以及挂载权限插件时的默认权限预设,两者都标为 `新会话生效`),以及组合中注册了相应设置时的代理(并行工具调用数、同时运行的子代理数、子代理嵌套深度、子代理是否可选择模型、可选择的模型,以及允许选择模型后的自动路由(测试版))、Shell(命令超时、命令可请求的最长超时和内存中保留的命令输出)和网页搜索(每次请求的搜索次数、搜索模型和搜索回答令牌上限)。高级列出除 `tui` 外所有已注册的设置命名空间,并按插件注册的 schema 编辑每个字段:开关字段直接切换,常量从列表中选择,数字和文本通过输入填写,密钥以掩码输入且只显示为 `已设置` 或 `未设置`,列表和映射则以 JSON 在 `$VISUAL` 中打开,其次是 `$EDITOR`,最后是 `vi`(Windows 上为记事本)。编辑器退出前终端归其使用,退出后面板重新绘制;可以使用带注释的 JSON,无法解析的文本或以非零状态退出的编辑器不会改动取值,并显示原因。子代理可选择的模型来自 `/model` 所列的同一模型目录:Enter 添加或移除一个模型,每次更改立即保存,选择 `完成` 或按 Escape 返回。子代理插件不接受在未允许任何模型时开启该选项,因此从空列表开启时会先询问模型,并将两者一起保存;选项开启期间最后一个模型会被保留。自动路由(测试版)让任务路由器为未指定模型的委派从这些模型中选择一个,并选择其推理强度。开启前会先说明它发送的内容:每次此类委派的任务文本和允许模型的名称会发送到路由器地址(默认为托管的 ing 路由器),而所有模型仍由用户自己的提供方运行。若 Bake 尚未登录路由器也会一并提示,并优先提供 `登录并开启`;在 `开启` 上按 Enter 则不登录直接保存。开启后,代理分区还会提供路由器账户、路由器地址、路由优先(默认 `按任务判断`,也可选质量、成本、速度或均衡)以及模型校准。校准会询问路由器如何识别每个允许的模型:匹配到的已评测模型或 `路由器未识别`、质量分、每百万令牌价格,以及 `已评估` 或 `需要提示`。在某个模型上按 Enter 可设置其质量等级、成本等级或其所服务的已评测模型,每项立即保存,每次更改后都会重新询问路由器。若为路由器已有基准数据的模型设置了质量等级,该等级会被标为未使用,因为以基准数据为准。没有已评估的模型时,列表会提示路由的委派将使用默认模型;无法连接路由器时会显示原因。路由器账户通过邮箱登录:先询问邮箱地址,路由器随即发送六位验证码,接着输入的验证码会换取一个由凭据存储保管的令牌,此后路由的委派都会携带它。首次登录即创建账户。被拒绝的地址或验证码会显示在输入框上并再次询问,按 Escape 则保留已存储的令牌不变。已登录时,该行显示账户,按 Enter 可使用其他邮箱登录或退出登录。若启动环境提供了令牌,该行改为显示其变量名,且不提供登录。每次写入只涉及对应字段,因此面板从未显示过的密钥不会丢失。在分区页面输入文字会搜索所有分区中的所有设置,并标明其所在位置(`Shell › 命令超时`),按 Enter 即可编辑找到的设置,随后打开其所在分区。上下方向键选择,Enter 打开子分区、可选值列表或切换开关类设置,Tab 和 Shift-Tab 切换到下一个或上一个分区,Escape 返回上一页,在标签行上的分区则关闭面板。可选值列表将生效中的值标为 `当前`,将默认值标为 `默认`;可输入值的设置在列表末尾提供 `输入值…`:时长接受 `500ms`、`30s`、`2m` 或 `1h`,大小接受 `64KB` 或 `1MB`,超出设置范围的值会被拒绝,并说明违反的界限。用户设置过的项还会固定显示 `恢复默认值`;分区中有任何用户设置时会提供 `将本节恢复为默认值`,确认框默认选择保留。屏幕模式和语言在下次启动时生效,此前面板将其标为 `下次启动生效`;插件只在启动时读取的命名空间,其字段同样如此标记。`--screen` 或配置中设置的 `screen` 会覆盖已保存的屏幕模式,面板将其标为 `由 --screen 指定`。其余设置立即生效,手动编辑文件也是如此。保存失败时(包括所属插件拒绝的值),面板顶部会显示原因,面板保持打开。有更改时关闭面板,会提示保存到的文件。没有设置文件时,只提供终端和会话两个分区,并提示更改仅在本次运行有效。 模型选择也会被记住。`/model` 的选择或 Shift-Tab 的切换会更改当前会话,同时作为默认模型保存到同一文件的 `agent-default-model` 部分,因此下次启动和 `/new` 都从该模型开始;恢复的会话保留其记录的模型。面板中的默认模型一行会打开同一张模型表。`/model` 先列出“最近使用”(当前模型,以及之前选过的至多四个,保存在 `tui` 部分的 `recentModels` 中),再按提供方分组列出其余模型,每组内较新的在前。每行显示模型的上下文窗口、提供推理强度时的 `思考`,以及接受图像时的 `图像`。←→ 在列表下方的一行中切换所选模型的推理强度,Enter 同时选定模型和推理强度。 diff --git a/apps/tui/packages/app/src/frame.ts b/apps/tui/packages/app/src/frame.ts index bc6acfee9c..fcd40415d8 100644 --- a/apps/tui/packages/app/src/frame.ts +++ b/apps/tui/packages/app/src/frame.ts @@ -20,6 +20,14 @@ export interface FrameRequest { readonly locale: Locale /** Process environment; only the locale and terminal-type variables are read. */ readonly env: Readonly>> + /** The operating system, as `process.platform` names it. */ + readonly platform: string + /** + * The operating system's locale, which Windows exposes instead of locale + * variables. Read only on native Windows outside Windows Terminal, so other + * platforms never consult it. + */ + readonly systemLocale: () => string } /** Locale variables in the order POSIX resolves them for character handling. */ @@ -28,6 +36,15 @@ const CTYPE_VARIABLES = ['LC_ALL', 'LC_CTYPE', 'LANG'] as const /** Languages whose terminals are commonly configured to draw Ambiguous characters two cells wide. */ const AMBIGUOUS_WIDE_LANGUAGES = ['zh', 'ja', 'ko'] as const +/** + * Whether a locale names a language whose terminals may draw Ambiguous characters wide. + * @param locale - a lowercased POSIX or BCP 47 locale name. + * @returns true for Chinese, Japanese, and Korean. + */ +function ambiguousWide(locale: string): boolean { + return AMBIGUOUS_WIDE_LANGUAGES.some(language => locale.startsWith(language)) +} + /** * The character-handling locale in force, as POSIX resolves it. * @param env - process environment. @@ -59,9 +76,14 @@ function ctypeOf(env: FrameRequest['env']): string | undefined { * * The first two are read from the environment. Windows Terminal sets neither * a locale nor `TERM`, yet draws UTF-8, so its `WT_SESSION` stands in for - * both. The third cannot be detected. It is a terminal preference, not a - * capability, so a CJK character locale stands in for it. The profile's own - * setting overrides all three. + * both. On native Windows neither applies: Node writes to the console as + * UTF-16, whatever the code page, and `TERM` is not a Windows convention, so + * any console there can draw the frame. The third cannot be detected. It is a + * terminal preference, not a capability, so a CJK character locale stands in + * for it. Windows names that locale in the system instead of the environment, + * and its console host draws Ambiguous characters wide under a CJK code page, + * so outside Windows Terminal the system locale is the signal there. The + * profile's own setting overrides all three. * * @param request - the profile's choice and the environment to read. * @returns the frame style to draw. @@ -69,13 +91,17 @@ function ctypeOf(env: FrameRequest['env']): string | undefined { export function resolveFrame(request: FrameRequest): FrameStyle { if (request.configured !== 'auto') return request.configured const ctype = ctypeOf(request.env) - if (ctype !== undefined && AMBIGUOUS_WIDE_LANGUAGES.some(language => ctype.startsWith(language))) return 'classic' + if (ctype !== undefined && ambiguousWide(ctype)) return 'classic' const terminal = request.env['WT_SESSION'] if (terminal === undefined || terminal === '') { - // No variable set means the C locale, which is not UTF-8. - if (ctype === undefined || !/utf-?8/.test(ctype)) return 'classic' const term = request.env['TERM'] - if (term === undefined || term === '' || term === 'dumb') return 'classic' + if (request.platform === 'win32') { + if (ambiguousWide(request.systemLocale().toLowerCase()) || term === 'dumb') return 'classic' + } else { + // No variable set means the C locale, which is not UTF-8. + if (ctype === undefined || !/utf-?8/.test(ctype)) return 'classic' + if (term === undefined || term === '' || term === 'dumb') return 'classic' + } } return request.locale === 'zh' ? 'classic' : 'round' } diff --git a/apps/tui/packages/app/src/preferences.ts b/apps/tui/packages/app/src/preferences.ts index 4700dd2b7d..2de50ad1db 100644 --- a/apps/tui/packages/app/src/preferences.ts +++ b/apps/tui/packages/app/src/preferences.ts @@ -150,6 +150,12 @@ interface ModelRoute { readonly model: string } +/** The top-level section a page hands over to, and the row to point at there. */ +interface Move { + readonly key: string + readonly initial?: string +} + /** What one run of the panel has done so far. */ interface Progress { changed: boolean @@ -318,8 +324,9 @@ export class Preferences implements RecentModels { } /** - * Run the `/settings` panel: pick a section, then a setting, then its value, - * until Escape leaves the top. Each pick is saved before the list returns + * Run the `/settings` panel. It opens on the first, most general section; + * Tab moves between sections, Enter picks a setting and then its value, and + * Escape from a section closes the panel. Each pick is saved before the list returns * with the pointer where it was; a failed save stays in the panel as its * warning instead of closing it. * @param copy - locale-owned labels. @@ -335,39 +342,43 @@ export class Preferences implements RecentModels { const account = session.routerAccount this.routerToken = account === undefined ? undefined : await Promise.resolve().then(account.status).then(status => ({ status }), () => undefined) - await this.page([], copy, interactions, signal, session, progress) + const first = this.sections(copy, session, interactions)[0] + let move: Move | undefined = first === undefined ? undefined : { key: first.key } + while (move !== undefined) move = await this.page([move.key], copy, interactions, signal, session, progress, move.initial) return this.closing(copy, progress.changed) } /** - * One page, until Escape returns to the page above. The top page also - * offers every setting below it to a search. The top page and each section - * directly under it carry the sections as tabs. - * @param keys - the sections leading to the page; empty for the top. - * @returns the section a tab moved to, which replaces this one; undefined after Escape. + * One section's page, until Escape returns to the page above. A top-level + * section carries every section as a tab, and offers every setting in the + * panel to a search. + * @param keys - the sections leading to the page, starting at a top-level one. + * @param initial - the row to point at first. + * @returns the top-level section to open instead of this one; undefined after Escape. */ private async page(keys: readonly string[], copy: TuiCopy, interactions: Interactions, signal: AbortSignal, - session: PanelSession, progress: Progress): Promise { - let initial: string | undefined + session: PanelSession, progress: Progress, initial?: string): Promise { + const topLevel = keys.length === 1 for (;;) { // Read again each time, so a row shows what the last pick saved. const top = this.sections(copy, session, interactions) const trail = locate(top, keys) - if (trail === undefined) return undefined - const page = trail.at(-1) - const sections = page === undefined ? top : page.sections ?? [] - const settings = page?.settings ?? [] + const page = trail?.at(-1) + if (page === undefined) return undefined + const sections = page.sections ?? [] + const settings = page.settings const resettable = settings.filter(setting => setting.reset !== undefined) const path = this.service?.documentPath - const title = page === undefined - ? path === undefined ? copy.settingsTitle : `${copy.settingsTitle} · ${compactPath(path, process.env['HOME'])}` - : [copy.settingsTitle, ...trail.map(section => section.label)].join(' › ') + const trailTitle = [copy.settingsTitle, ...trail!.map(section => section.label)].join(' › ') + // The first page a user sees also says where the settings are saved. + const title = !topLevel || path === undefined ? trailTitle : `${trailTitle} · ${compactPath(path, process.env['HOME'])}` + const own = new Set(settings.map(setting => `${SETTING}${keys[0]}/${setting.key}`)) const warnings = [this.scope === undefined ? copy.settingsUnsaved : undefined, progress.problem].filter(text => text !== undefined) const choices: Choice[] = [ ...settings.map(setting => settingChoice(setting, copy, `${SETTING}${setting.key}`)), // Before the sections, so a search lists the settings it found ahead - // of a section whose summary merely names them. - ...page === undefined ? searchable(top, copy) : [], + // of a section that merely holds them. + ...topLevel ? searchable(top, copy).filter(choice => !own.has(choice.value)) : [], ...sections.map(section => ({ value: `${SECTION}${section.key}`, label: section.label, description: summaryOf(section), ...section.status === undefined ? {} : { status: statusOf(section.status, copy) }, @@ -376,9 +387,9 @@ export class Preferences implements RecentModels { ] const listed = choices.filter(choice => choice.searchOnly !== true) if (listed.length === 0) return undefined - const tabs = keys.length > 1 ? undefined : { + const tabs = !topLevel ? undefined : { items: top.map(section => ({ value: `${TAB}${section.key}`, label: section.label })), - ...keys[0] === undefined ? {} : { active: `${TAB}${keys[0]}` }, + active: `${TAB}${keys[0]}`, } const picked = await interactions.choose({ title, choices, initial: initial !== undefined && listed.some(choice => choice.value === initial) ? initial : listed[0]!.value, @@ -388,8 +399,8 @@ export class Preferences implements RecentModels { signal.throwIfAborted() if (picked === undefined) return undefined progress.problem = undefined - // A section's tab replaces it; the top page opens the one it names. - if (picked.startsWith(TAB) && keys.length > 0) return picked.slice(TAB.length) + // A tab replaces this section with the one it names. + if (picked.startsWith(TAB)) return { key: picked.slice(TAB.length) } if (picked === RESET) { initial = settings[0] === undefined ? undefined : `${SETTING}${settings[0].key}` const confirmed = await interactions.choose({ @@ -401,28 +412,29 @@ export class Preferences implements RecentModels { await save(copy, progress, async () => { for (const setting of resettable) await setting.reset!() }) continue } - if (picked.startsWith(SECTION) || picked.startsWith(TAB)) { - let next: string | undefined = picked.slice(picked.startsWith(TAB) ? TAB.length : SECTION.length) - while (next !== undefined) { - // Escape returns to the top on the row of the section it left. - initial = `${SECTION}${next}` - next = await this.page([...keys, next], copy, interactions, signal, session, progress) - } + if (picked.startsWith(SECTION)) { + const next = picked.slice(SECTION.length) + // Escape returns here on the row of the section it left. + initial = picked + await this.page([...keys, next], copy, interactions, signal, session, progress) continue } // A search result names its sections before the setting. const found = picked.slice(SETTING.length).split('/') const at = found.length > 1 ? locate(top, found.slice(0, -1))?.at(-1) : page const setting = at?.settings.find(entry => entry.key === found.at(-1)) - initial = found.length > 1 ? `${SECTION}${found[0]}` : picked + // A search result's row on its top-level section: the setting itself, or the section holding it. + initial = found.length === 1 ? picked : found.length === 2 ? `${SETTING}${found[1]}` : `${SECTION}${found[1]}` if (setting === undefined) continue await this.edit(setting, copy, interactions, signal, progress) // A setting with its own picker saves through it; the row says whether it did. if (setting.open !== undefined && progress.problem === undefined) { const again = locate(this.sections(copy, session, interactions), found.length > 1 ? found.slice(0, -1) : keys) - const now = (found.length > 1 || keys.length > 0 ? again?.at(-1)?.settings : [])?.find(entry => entry.key === setting.key) + const now = again?.at(-1)?.settings.find(entry => entry.key === setting.key) if (now !== undefined && now.shown !== setting.shown) progress.changed = true } + // A setting found in another section opens that section, where the change shows. + if (found.length > 1 && found[0] !== keys[0]) return { key: found[0]!, initial } } } @@ -506,29 +518,28 @@ export class Preferences implements RecentModels { private sections(copy: TuiCopy, session: PanelSession, interactions: Interactions): readonly Section[] { const descriptors = this.service?.describe() ?? [] const curated = (list: readonly Curated[]): readonly Setting[] => list.flatMap(entry => this.curated(entry, descriptors, copy)) - const section = (key: string, label: string, settings: readonly Setting[], named = false): readonly Section[] => - settings.length === 0 ? [] : [{ key, label, settings, - // A bare number says nothing on the page above, so these sections name what they hold. - ...named ? { summary: settings.map(setting => setting.label).join(' · ') } : {} }] + const section = (key: string, label: string, settings: readonly Setting[]): readonly Section[] => + settings.length === 0 ? [] : [{ key, label, settings }] return [ - ...section('session', copy.settingsSession, [...this.modelRow(copy, session), ...this.permissionRow(copy, descriptors)]), + // Most general first: the panel opens on the first section. { key: 'terminal', label: copy.settingsTerminal, settings: this.terminalRows(copy) }, + ...section('session', copy.settingsSession, [...this.modelRow(copy, session), ...this.permissionRow(copy, descriptors)]), ...section('agent', copy.settingsAgent, curated([ { ns: 'agent-loop', path: ['maxParallelToolCalls'], label: copy.settingsParallelTools, steps: [1, 2, 4, 8, 16] }, { ns: 'subagent', path: ['maxActiveSubagents'], label: copy.settingsSubagentsActive, steps: [1, 2, 4, 8, 16] }, { ns: 'subagent', path: ['maxDepth'], label: copy.settingsSubagentDepth, steps: [0, 1, 2, 3], format: depth => depth === 0 ? copy.settingsSubagentDepthOff : String(depth) }, - ]).concat(this.subagentModelRows(copy, descriptors, session, interactions)), true), + ]).concat(this.subagentModelRows(copy, descriptors, session, interactions))), ...section('shell', copy.settingsShell, curated([ { ns: 'shell', path: ['timeoutMs'], label: copy.settingsShellTimeout, unit: 'ms', steps: [30_000, 60_000, 120_000, 300_000, 600_000] }, { ns: 'shell', path: ['maxTimeoutMs'], label: copy.settingsShellMaxTimeout, unit: 'ms', steps: [300_000, 600_000, 1_800_000, 3_600_000] }, { ns: 'shell', path: ['maxOutputBytes'], label: copy.settingsShellOutput, unit: 'bytes', steps: [16_000, 64_000, 256_000, 1_000_000] }, - ]), true), + ])), ...section('web', copy.settingsWeb, curated([ { ns: 'web-search-deepseek', path: ['maxUses'], label: copy.settingsWebUses, steps: [1, 3, 5, 10] }, { ns: 'web-search-deepseek', path: ['model'], label: copy.settingsWebModel }, { ns: 'web-search-deepseek', path: ['maxTokens'], label: copy.settingsWebTokens, steps: [1024, 2048, 4096, 8192] }, - ]), true), + ])), ...this.advanced(copy, descriptors, session, interactions), ] } @@ -1052,7 +1063,7 @@ export class Preferences implements RecentModels { }) .filter(section => section.settings.length > 0) .sort((left, right) => left.key.localeCompare(right.key)) - return sections.length === 0 ? [] : [{ key: 'advanced', label: copy.settingsAdvanced, summary: copy.settingsAdvancedAbout, settings: [], sections }] + return sections.length === 0 ? [] : [{ key: 'advanced', label: copy.settingsAdvanced, settings: [], sections }] } /** diff --git a/apps/tui/packages/app/src/runner.ts b/apps/tui/packages/app/src/runner.ts index 449b53610f..885f1be863 100644 --- a/apps/tui/packages/app/src/runner.ts +++ b/apps/tui/packages/app/src/runner.ts @@ -178,7 +178,10 @@ export async function run(ctx: Context, config: RunnerOptions, io: TuiIo): Promi const frame = (): FrameStyle => { const configured = preferences.value.composerFrame if (resolved?.configured !== configured) { - resolved = { configured, frame: resolveFrame({ configured, locale: preferences.launch.locale, env: process.env }) } + resolved = { configured, frame: resolveFrame({ + configured, locale: preferences.launch.locale, env: process.env, platform: process.platform, + systemLocale: () => Intl.DateTimeFormat().resolvedOptions().locale, + }) } } return resolved.frame } diff --git a/apps/tui/packages/app/tests/frame.test.ts b/apps/tui/packages/app/tests/frame.test.ts index 0fa9085b45..efdb2a619c 100644 --- a/apps/tui/packages/app/tests/frame.test.ts +++ b/apps/tui/packages/app/tests/frame.test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test' import { resolveFrame, type FrameRequest } from '../src/frame.ts' const ask = (env: FrameRequest['env'], overrides: Partial = {}) => - resolveFrame({ configured: 'auto', locale: 'en', env, ...overrides }) + resolveFrame({ configured: 'auto', locale: 'en', env, platform: 'linux', systemLocale: () => 'en-US', ...overrides }) const utf8 = { LANG: 'en_US.UTF-8', TERM: 'xterm-256color' } @@ -51,6 +51,31 @@ describe('resolveFrame', () => { expect(ask({ WT_SESSION: '' })).toBe('classic') }) + test('draws the rounded frame in any native Windows console', () => { + // Node writes to the console as UTF-16 whatever the code page, and no + // Windows console sets a locale or TERM, so their absence says nothing. + const windows = { platform: 'win32' } + expect(ask({}, windows)).toBe('round') + expect(ask({ TERM_PROGRAM: 'vscode' }, windows)).toBe('round') + expect(ask({ TERMINAL_EMULATOR: 'JetBrains-JediTerm' }, windows)).toBe('round') + expect(ask({ TERM: 'dumb' }, windows)).toBe('classic') + // The console host draws Ambiguous characters wide under a CJK code page, + // and Windows names that locale in the system, not the environment. + expect(ask({}, { ...windows, systemLocale: () => 'zh-CN' })).toBe('classic') + expect(ask({}, { ...windows, systemLocale: () => 'ja-JP' })).toBe('classic') + expect(ask({ LANG: 'ko_KR.UTF-8' }, windows)).toBe('classic') + expect(ask({}, { ...windows, locale: 'zh' })).toBe('classic') + // Windows Terminal draws them narrow, so the system locale does not apply there. + expect(ask({ WT_SESSION: 'guid' }, { ...windows, systemLocale: () => 'zh-CN' })).toBe('round') + }) + + test('never reads the system locale outside native Windows', () => { + const unread = { systemLocale: (): string => { throw new Error('read the system locale') } } + expect(ask(utf8, unread)).toBe('round') + expect(ask({}, { ...unread, platform: 'darwin' })).toBe('classic') + expect(ask({ WT_SESSION: 'guid' }, { ...unread, platform: 'win32' })).toBe('round') + }) + test('reads the variables in the order POSIX resolves them', () => { expect(ask({ LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'zh_CN.UTF-8', LANG: 'C', TERM: 'xterm' })).toBe('round') expect(ask({ LC_CTYPE: 'zh_CN.UTF-8', LANG: 'en_US.UTF-8', TERM: 'xterm' })).toBe('classic') diff --git a/apps/tui/packages/app/tests/preferences.spec.ts b/apps/tui/packages/app/tests/preferences.spec.ts index 15a6565965..ea0c790814 100644 --- a/apps/tui/packages/app/tests/preferences.spec.ts +++ b/apps/tui/packages/app/tests/preferences.spec.ts @@ -3,7 +3,7 @@ * the settings document, screen and language wait for the next launch, a flag * outranks the user's screen, and without a document the change stays local. * Plugin settings are offered in named sections and, from their schemas, under - * Advanced, and the top page searches all of them. + * Advanced, and each top-level section's page searches all of them. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -108,10 +108,9 @@ describe('Preferences', () => { const { preferences, changed } = await mount() expect(preferences.launch.screen).toBe('inline') const { interactions, prompts } = scripted( - pick('section:terminal'), pick('setting:goalObjective'), pick('setting:screen'), pick('fullscreen'), - back, back, + back, ) await expect(run(preferences, interactions)).resolves.toEqual({ kind: 'success', text: copy.settingsStored }) expect(MemoryProvider.doc[SETTINGS_NAMESPACE]).toEqual({ goalObjective: true, screen: 'fullscreen' }) @@ -119,27 +118,27 @@ describe('Preferences', () => { await vi.waitFor(() => expect(changed).toHaveBeenCalled()) // The process keeps the screen it started with; the panel says when the change lands. expect(preferences.screen).toBe('inline') - const page = prompts.at(-2)! - expect(page.title).toBe(`${copy.settingsTitle} › ${copy.settingsTerminal}`) + // The panel opens on its most general section. + expect(prompts[0]!.title).toBe(`${copy.settingsTitle} › ${copy.settingsTerminal}`) + expect(prompts[0]!.tabs?.active).toBe('tab:terminal') + expect(prompts[0]!.initial).toBe('setting:screen') + const page = prompts.at(-1)! expect(page.initial).toBe('setting:screen') expect(page.choices.find(choice => choice.value === 'setting:screen')).toMatchObject({ description: copy.settingsScreenFullscreen, status: { text: copy.settingsNextLaunch }, }) expect(page.choices.find(choice => choice.value === 'setting:goalObjective')?.description).toBe(copy.settingsOn) // The value picker marks what is in force, and the profile's value. - expect(prompts[3]!.choices.find(choice => choice.current === true)?.value).toBe('inline') - expect(prompts[3]!.choices.find(choice => choice.value === 'inline')?.description).toBe(copy.settingsDefault) - // Back at the top, the section's row reads its values. - expect(prompts.at(-1)!.choices.find(choice => choice.value === 'section:terminal')?.description) - .toBe(`${copy.settingsScreenFullscreen} · English · ${copy.settingsFrameAuto}`) + expect(prompts[2]!.choices.find(choice => choice.current === true)?.value).toBe('inline') + expect(prompts[2]!.choices.find(choice => choice.value === 'inline')?.description).toBe(copy.settingsDefault) }) it('closes without a message when nothing changed, and offers no reset over defaults', async () => { const { preferences } = await mount() - const { interactions, prompts } = scripted(pick('section:terminal'), pick('setting:locale'), back, back, back) + const { interactions, prompts } = scripted(pick('setting:locale'), back, back) await expect(run(preferences, interactions)).resolves.toEqual({ kind: 'success' }) + expect(prompts[0]!.choices.some(choice => choice.pinned === true)).toBe(false) expect(prompts[1]!.choices.some(choice => choice.pinned === true)).toBe(false) - expect(prompts[2]!.choices.some(choice => choice.pinned === true)).toBe(false) }) it('resets a section after a confirmation, keeps it on a refusal, and resets one setting on its own', async () => { @@ -147,54 +146,53 @@ describe('Preferences', () => { const { preferences } = await mount() const reset = (prompt: ChoicePrompt) => prompt.choices.find(choice => choice.pinned === true)?.value const { interactions, prompts } = scripted( - pick('section:terminal'), // One setting: its value list ends with its own reset. pick('setting:completionLimit'), reset, reset, pick('keep'), reset, pick('reset'), - back, back, + back, ) await run(preferences, interactions) - expect(prompts[2]!.choices.at(-1)).toMatchObject({ label: copy.settingsResetField, pinned: true }) - expect(prompts[4]!.title).toBe(copy.settingsResetConfirm) - expect(prompts[4]!.initial).toBe('keep') + expect(prompts[1]!.choices.at(-1)).toMatchObject({ label: copy.settingsResetField, pinned: true }) + expect(prompts[3]!.title).toBe(copy.settingsResetConfirm) + expect(prompts[3]!.initial).toBe('keep') expect(MemoryProvider.doc[SETTINGS_NAMESPACE]).toEqual({}) expect(MemoryProvider.doc['other']).toEqual({ kept: true }) expect(preferences.value).toEqual(base) // Nothing left to reset. - expect(prompts.at(-2)!.choices.some(choice => choice.pinned === true)).toBe(false) + expect(prompts.at(-1)!.choices.some(choice => choice.pinned === true)).toBe(false) }) it('keeps the panel open with the reason when a save fails', async () => { const { preferences } = await mount() const persist = vi.spyOn(MemoryProvider.prototype as unknown as { persist: () => Promise }, 'persist') .mockRejectedValueOnce(new Error('disk full')) - const { interactions, prompts } = scripted(pick('section:terminal'), - pick('setting:resultLines'), pick('16'), pick('setting:resultLines'), pick('8'), back, back) + const { interactions, prompts } = scripted( + pick('setting:resultLines'), pick('16'), pick('setting:resultLines'), pick('8'), back) await expect(run(preferences, interactions)).resolves.toMatchObject({ kind: 'success' }) - expect(prompts[3]!.warning).toBe(`${copy.settingsFailed}: disk full`) - expect(prompts[5]!.warning).toBeUndefined() + expect(prompts[2]!.warning).toBe(`${copy.settingsFailed}: disk full`) + expect(prompts[4]!.warning).toBeUndefined() expect(MemoryProvider.doc[SETTINGS_NAMESPACE]).toEqual({ resultLines: 8 }) persist.mockRestore() }) it('takes a typed number within its bounds, and names the bound a typed number breaks', async () => { const { preferences } = await mount() - const { interactions, prompts, typed } = scripted(pick('section:terminal'), + const { interactions, prompts, typed } = scripted( pick('setting:resultLines'), labelled(copy.settingsCustom), { typed: '-1' }, pick('setting:resultLines'), labelled(copy.settingsCustom), { typed: 'lots' }, pick('setting:resultLines'), labelled(copy.settingsCustom), { cancel: true }, pick('setting:doubleInterruptMs'), labelled(copy.settingsCustom), { typed: '1.5s' }, pick('setting:resultLines'), labelled(copy.settingsCustom), { typed: '12' }, - back, back) + back) await run(preferences, interactions) - expect(prompts[3]!.warning).toBe(`${copy.settingsResultLines}: ${copy.settingsBelowMin} 0`) - expect(prompts[5]!.warning).toBe(`${copy.settingsResultLines}: ${copy.settingsNotNumber}`) + expect(prompts[2]!.warning).toBe(`${copy.settingsResultLines}: ${copy.settingsBelowMin} 0`) + expect(prompts[4]!.warning).toBe(`${copy.settingsResultLines}: ${copy.settingsNotNumber}`) // Escape from the text leaves the value and the page as they were. - expect(prompts[7]!.warning).toBeUndefined() + expect(prompts[6]!.warning).toBeUndefined() expect(typed[3]).toEqual({ kind: 'text', message: `${copy.settingsDoubleInterrupt} · ${copy.settingsDurationHint}` }) expect(MemoryProvider.doc[SETTINGS_NAMESPACE]).toEqual({ doubleInterruptMs: 1500, resultLines: 12 }) - expect(prompts.at(-2)!.choices.find(choice => choice.value === 'setting:doubleInterruptMs')?.description).toBe('1500ms') + expect(prompts.at(-1)!.choices.find(choice => choice.value === 'setting:doubleInterruptMs')?.description).toBe('1500ms') }) it('offers the default model through the session\'s model picker', async () => { @@ -205,10 +203,13 @@ describe('Preferences', () => { await ctx.agentDefaultModel.saveSelection({ provider: 'mock', model: 'other', reasoningEffort: ReasoningEffortId('high') }) return { kind: 'success' as const } }) - const { interactions, prompts } = scripted(pick('section:session'), pick('setting:defaultModel'), back, back) + const { interactions, prompts } = scripted(pick('tab:session'), pick('setting:defaultModel'), back) const result = await run(preferences, interactions, { chooseModel }) expect(chooseModel).toHaveBeenCalledOnce() - expect(prompts[0]!.choices.find(choice => choice.searchOnly !== true)).toMatchObject({ value: 'section:session', label: copy.settingsSession, description: 'mock/model' }) + // The most general section comes first; Session follows it. + expect(prompts[0]!.tabs?.items.slice(0, 2)).toEqual([ + { value: 'tab:terminal', label: copy.settingsTerminal }, { value: 'tab:session', label: copy.settingsSession }, + ]) expect(prompts[1]!.choices[0]).toMatchObject({ value: 'setting:defaultModel', description: 'mock/model', status: { text: copy.settingsNewSessions } }) expect(prompts[2]!.choices[0]).toMatchObject({ value: 'setting:defaultModel', description: 'mock/other (high)' }) expect(result).toEqual({ kind: 'success', text: copy.settingsStored }) @@ -220,9 +221,9 @@ describe('Preferences', () => { const { preferences } = await mount({ screen: 'inline' }) expect(preferences.value.screen).toBe('fullscreen') expect(preferences.screen).toBe('inline') - const { interactions, prompts } = scripted(pick('section:terminal'), back, back) + const { interactions, prompts } = scripted(back) await run(preferences, interactions) - expect(prompts[1]!.choices.find(choice => choice.value === 'setting:screen')?.status?.text).toBe(copy.settingsByFlag) + expect(prompts[0]!.choices.find(choice => choice.value === 'setting:screen')?.status?.text).toBe(copy.settingsByFlag) }) it('reads a stored choice at launch', async () => { @@ -242,7 +243,7 @@ describe('Preferences', () => { const writes = changed.mock.calls.length await preferences.rememberModel('a/1') expect(changed.mock.calls).toHaveLength(writes) - const { interactions, prompts } = scripted(pick('section:terminal'), back, back) + const { interactions, prompts } = scripted(back) await run(preferences, interactions) expect(JSON.stringify(prompts.map(prompt => prompt.choices))).not.toContain('recentModels') }) @@ -260,15 +261,15 @@ describe('Preferences', () => { it('changes only this process without a settings document, and says so', async () => { const { preferences, changed } = await mount({ settings: false }) - const { interactions, prompts } = scripted(pick('section:terminal'), pick('setting:resultLines'), pick('16'), back, back) + const { interactions, prompts } = scripted(pick('setting:resultLines'), pick('16'), back) await expect(run(preferences, interactions)).resolves.toEqual({ kind: 'success', text: copy.settingsUnsaved }) expect(preferences.value.resultLines).toBe(16) expect(changed).toHaveBeenCalledOnce() expect(prompts[0]!.warning).toBe(copy.settingsUnsaved) // Only the terminal's own settings exist without a document. - expect(prompts[0]!.choices.filter(choice => choice.searchOnly !== true).map(choice => choice.value)).toEqual(['section:terminal']) + expect(prompts[0]!.tabs?.items.map(tab => tab.value)).toEqual(['tab:terminal']) // A reset returns to the profile's values without a document too. - expect(prompts[3]!.choices.some(choice => choice.pinned === true)).toBe(true) + expect(prompts[2]!.choices.some(choice => choice.pinned === true)).toBe(true) }) it('offers the default access preset from its registered namespace', async () => { @@ -276,7 +277,7 @@ describe('Preferences', () => { ctx.settings.register('permission', z.object({ defaultPreset: z.union([z.const('read-only').description('Read only'), z.const('workspace-write')]).required(), }), { base: { defaultPreset: 'read-only' } }) - const { interactions, prompts } = scripted(pick('section:session'), pick('setting:defaultPreset'), pick('workspace-write'), back, back) + const { interactions, prompts } = scripted(pick('tab:session'), pick('setting:defaultPreset'), pick('workspace-write'), back) await run(preferences, interactions) expect(prompts[2]!.choices.map(choice => choice.value)).toEqual(['read-only', 'workspace-write']) expect(prompts[2]!.choices[0]!.description).toBe('Read only') @@ -287,29 +288,23 @@ describe('Preferences', () => { const { ctx, preferences } = await mount() plugins(ctx) const { interactions, prompts } = scripted( - pick('section:shell'), + pick('tab:shell'), pick('setting:shell.timeoutMs'), labelled('5m'), pick('setting:shell.maxOutputBytes'), labelled(copy.settingsCustom), { typed: '256KB' }, // The owner refuses a timeout past the longest one allowed; the panel says so and stays. pick('setting:shell.timeoutMs'), labelled(copy.settingsCustom), { typed: '1h' }, - back, - pick('section:agent'), pick('setting:agent-loop.maxParallelToolCalls'), labelled('8'), back, - back, + pick('tab:agent'), pick('setting:agent-loop.maxParallelToolCalls'), labelled('8'), back, ) await run(preferences, interactions) - const top = prompts[0]!.choices.filter(choice => choice.searchOnly !== true).map(choice => choice.label) + const top = prompts[0]!.tabs?.items.map(tab => tab.label) expect(top).toEqual([copy.settingsTerminal, copy.settingsAgent, copy.settingsShell, copy.settingsAdvanced]) - // A bare number says nothing a page up, so a plugin section names what it holds. - expect(prompts[0]!.choices.find(choice => choice.value === 'section:shell')?.description) - .toBe([copy.settingsShellTimeout, copy.settingsShellMaxTimeout, copy.settingsShellOutput].join(' · ')) - expect(prompts[0]!.choices.find(choice => choice.value === 'section:advanced')?.description).toBe(copy.settingsAdvancedAbout) - expect(prompts[1]!.choices.map(choice => [choice.label, choice.description])).toEqual([ + expect(prompts[1]!.choices.filter(choice => choice.searchOnly !== true).map(choice => [choice.label, choice.description])).toEqual([ [copy.settingsShellTimeout, '2m'], [copy.settingsShellMaxTimeout, '10m'], [copy.settingsShellOutput, '64 KB'], ]) // Steps in the setting's unit, the schema's default marked, and a typed value last. expect(prompts[2]!.choices.map(choice => choice.label)).toEqual(['30s', '1m', '2m', '5m', '10m', copy.settingsCustom]) expect(prompts[2]!.choices.find(choice => choice.label === '2m')).toMatchObject({ current: true, description: copy.settingsDefault }) - expect(prompts[8]!.warning).toBe(`${copy.settingsFailed}: timeoutMs exceeds maxTimeoutMs`) + expect(prompts[7]!.warning).toBe(`${copy.settingsFailed}: timeoutMs exceeds maxTimeoutMs`) expect(MemoryProvider.doc['shell']).toEqual({ timeoutMs: 300_000, maxOutputBytes: 256_000 }) expect(MemoryProvider.doc['agent-loop']).toEqual({ maxParallelToolCalls: 8 }) }) @@ -326,14 +321,14 @@ describe('Preferences', () => { label: z.string(), }), { applies: 'restart' }) const { interactions, prompts, typed } = scripted( - pick('section:advanced'), pick('section:example-plugin'), + pick('tab:advanced'), pick('section:example-plugin'), pick('setting:verbose'), pick('setting:mode'), pick('careful'), pick('setting:limits.retries'), labelled(copy.settingsCustom), { typed: '12' }, pick('setting:limits.retries'), labelled(copy.settingsCustom), { typed: '3' }, pick('setting:label'), { typed: 'mine' }, pick('setting:hosts'), - back, back, back, + back, back, ) await run(preferences, interactions) expect(prompts[1]!.choices.find(choice => choice.value === 'section:example-plugin')).toMatchObject({ @@ -350,7 +345,7 @@ describe('Preferences', () => { expect(prompts[7]!.warning).toBe(`limits.retries: ${copy.settingsAboveMax} 9`) // A string with nothing to pick from asks for its text at once. expect(typed.at(-1)).toEqual({ kind: 'text', message: 'label' }) - expect(prompts.at(-3)!.warning).toBe(copy.settingsInFile) + expect(prompts.at(-2)!.warning).toBe(copy.settingsInFile) expect(MemoryProvider.doc['example-plugin']).toEqual({ apiKey: 'kept-secret', verbose: true, mode: 'careful', limits: { retries: 3 }, label: 'mine', }) @@ -370,9 +365,9 @@ describe('Preferences', () => { const preferences = new Preferences(ctx, base, undefined, vi.fn(), editText) ctx.settings.register('example-plugin', z.object({ hosts: z.array(z.string()).default([]) })) const { interactions, prompts } = scripted( - pick('section:advanced'), pick('section:example-plugin'), + pick('tab:advanced'), pick('section:example-plugin'), pick('setting:hosts'), pick('setting:hosts'), pick('setting:hosts'), pick('setting:hosts'), - back, back, back, + back, back, ) await run(preferences, interactions) expect(prompts[2]!.choices.find(choice => choice.value === 'setting:hosts')?.description).toBe(`[0] · ${copy.settingsInEditor}`) @@ -385,7 +380,7 @@ describe('Preferences', () => { expect(prompts[5]!.choices.find(choice => choice.value === 'setting:hosts')?.description).toBe(`[0] · ${copy.settingsInEditor}`) }) - it('searches every setting from the top page, and edits the one found in place', async () => { + it('searches every setting from a section\'s page, and opens the section of the one it edited', async () => { const { ctx, preferences } = await mount() plugins(ctx) const { interactions, prompts } = scripted( @@ -397,33 +392,35 @@ describe('Preferences', () => { const found = prompts[0]!.choices.find(choice => choice.label === `${copy.settingsShell} › ${copy.settingsShellTimeout}`) expect(found).toMatchObject({ searchOnly: true, description: '2m', value: 'setting:shell/shell.timeoutMs' }) expect(prompts[0]!.choices.some(choice => choice.label === `${copy.settingsAdvanced} › shell › timeoutMs` && choice.searchOnly === true)).toBe(true) + // The page's own settings are listed already, so the search does not repeat them. + expect(prompts[0]!.choices.some(choice => choice.label === `${copy.settingsTerminal} › ${copy.settingsScreen}`)).toBe(false) expect(MemoryProvider.doc['shell']).toEqual({ timeoutMs: 60_000 }) - // Back at the top with the section of the setting it changed under the pointer. - expect(prompts[2]!.initial).toBe('section:shell') + // The panel moves to the setting's section with the changed row under the pointer. + expect(prompts[2]!.tabs?.active).toBe('tab:shell') + expect(prompts[2]!.initial).toBe('setting:shell.timeoutMs') }) - it('moves between sections with their tabs, and returns to the top on the last one', async () => { + it('opens on the first section, moves between sections with their tabs, and closes on Escape', async () => { const { ctx, preferences } = await mount() plugins(ctx) const { interactions, prompts } = scripted( - pick('tab:terminal'), (prompt) => stepTab(prompt.tabs, false), (prompt) => stepTab(prompt.tabs, true), (prompt) => stepTab(prompt.tabs, true), - back, back, + back, ) await run(preferences, interactions) const labels = [copy.settingsTerminal, copy.settingsAgent, copy.settingsShell, copy.settingsAdvanced] - expect(prompts[0]!.tabs).toEqual({ items: ['terminal', 'agent', 'shell', 'advanced'].map((key, index) => ({ value: `tab:${key}`, label: labels[index] })) }) - expect(prompts.slice(1, 5).map(prompt => [prompt.title, prompt.tabs?.active])).toEqual([ - [`${copy.settingsTitle} › ${copy.settingsTerminal}`, 'tab:terminal'], - [`${copy.settingsTitle} › ${copy.settingsAgent}`, 'tab:agent'], - [`${copy.settingsTitle} › ${copy.settingsTerminal}`, 'tab:terminal'], + const items = ['terminal', 'agent', 'shell', 'advanced'].map((key, index) => ({ value: `tab:${key}`, label: labels[index] })) + expect(prompts.map(prompt => prompt.tabs)).toEqual([ + { items, active: 'tab:terminal' }, + { items, active: 'tab:agent' }, + { items, active: 'tab:terminal' }, // Shift-Tab wraps from the first section to the last. - [`${copy.settingsTitle} › ${copy.settingsAdvanced}`, 'tab:advanced'], + { items, active: 'tab:advanced' }, ]) - expect(prompts[5]!.initial).toBe('section:advanced') - expect(prompts[5]!.tabs?.active).toBeUndefined() + expect(prompts.map(prompt => prompt.title)).toEqual([copy.settingsTerminal, copy.settingsAgent, copy.settingsTerminal, copy.settingsAdvanced] + .map(label => `${copy.settingsTitle} › ${label}`)) }) it('asks which models subagents may use before switching the choice on, and saves both together', async () => { @@ -436,14 +433,14 @@ describe('Preferences', () => { ], unavailable: ['offline'] } const listModels = vi.fn(() => Promise.resolve(catalog)) const { interactions, prompts } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.enabled'), + pick('tab:agent'), pick('setting:subagent-model-selection.enabled'), pick('openrouter/anthropic/claude'), // The last model of a switched-on choice stays. pick('openrouter/anthropic/claude'), back, pick('setting:subagent-model-selection.allowedModels'), pick('deepseek/deepseek-v4-flash'), pick('openrouter/anthropic/claude'), labelled(copy.settingsSubagentAllowedDone), - back, back, + back, ) await expect(run(preferences, interactions, { listModels })).resolves.toMatchObject({ kind: 'success', text: copy.settingsStored }) expect(MemoryProvider.doc['subagent-model-selection']).toEqual({ @@ -482,16 +479,19 @@ describe('Preferences', () => { const { interactions, prompts } = scripted( // A search result and a field opened directly must use the same picker. pick('setting:advanced/subagent-model-selection/enabled'), + // The search opens the section it found the field in. pick('mock/first'), back, - pick('section:advanced'), pick('section:subagent-model-selection'), + pick('section:subagent-model-selection'), pick('setting:allowedModels'), pick('mock/second'), pick('mock/first'), - labelled(copy.settingsSubagentAllowedDone), back, back, back, + labelled(copy.settingsSubagentAllowedDone), back, back, ) await expect(run(preferences, interactions, { listModels })).resolves.toMatchObject({ kind: 'success', text: copy.settingsStored }) expect(editText).not.toHaveBeenCalled() expect(listModels).toHaveBeenCalledTimes(2) expect(prompts[1]!.warning).toBe(copy.settingsSubagentAllowedFirst) - expect(prompts[6]!.choices.find(choice => choice.value === 'mock/first')?.status?.text).toBe(copy.settingsSubagentAllowedOn) + expect(prompts[3]!.tabs?.active).toBe('tab:advanced') + expect(prompts[3]!.initial).toBe('section:subagent-model-selection') + expect(prompts[5]!.choices.find(choice => choice.value === 'mock/first')?.status?.text).toBe(copy.settingsSubagentAllowedOn) expect(MemoryProvider.doc['subagent-model-selection']).toEqual({ enabled: true, allowedModels: [{ provider: 'mock', model: 'second' }], }) @@ -502,10 +502,10 @@ describe('Preferences', () => { await ctx.plugin(SubagentModelSelection) await vi.waitFor(() => expect(ctx.settings.describe().some(entry => entry.ns === 'subagent-model-selection')).toBe(true)) const listModels = () => Promise.resolve({ entries: [{ route: 'mock/model', name: 'Mock', current: true }], unavailable: [] }) - const opened = scripted(pick('section:agent'), pick('setting:subagent-model-selection.enabled'), back, back, back) + const opened = scripted(pick('tab:agent'), pick('setting:subagent-model-selection.enabled'), back, back) await expect(run(preferences, opened.interactions, { listModels })).resolves.toEqual({ kind: 'success' }) expect(MemoryProvider.doc['subagent-model-selection']).toBeUndefined() - const plain = scripted(pick('section:agent'), back, back) + const plain = scripted(pick('tab:agent'), back) await run(preferences, plain.interactions) expect(plain.prompts[1]!.choices.filter(choice => choice.value.startsWith('setting:subagent')).map(choice => choice.label)) .toEqual([copy.settingsSubagentModels]) @@ -527,8 +527,8 @@ describe('task router settings', () => { vi.stubEnv('ING_API_TOKEN', '') const { preferences } = await mounted() const { interactions, prompts } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.router.enabled'), pick('off'), - pick('setting:subagent-model-selection.router.enabled'), pick('on'), back, back, + pick('tab:agent'), pick('setting:subagent-model-selection.router.enabled'), pick('off'), + pick('setting:subagent-model-selection.router.enabled'), pick('on'), back, ) await run(preferences, interactions, { describeRoutes: () => Promise.resolve([]) }) expect(MemoryProvider.doc['subagent-model-selection']).toEqual({ router: { enabled: true } }) @@ -552,10 +552,10 @@ describe('task router settings', () => { await ctx.settings.mutate('subagent-model-selection', [{ op: 'set', path: ['router', 'hints'], value: [{ provider: 'deepseek', model: 'deepseek-v4-flash', quality: 'low' }] }]) const { interactions, prompts } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.router.hints'), + pick('tab:agent'), pick('setting:subagent-model-selection.router.hints'), pick('corp/opus-alias'), pick('quality'), pick('high'), pick('corp/opus-alias'), pick('sameAs'), { typed: 'claude-opus-4.5' }, - labelled(copy.settingsSubagentAllowedDone), back, back, + labelled(copy.settingsSubagentAllowedDone), back, ) await run(preferences, interactions, { describeRoutes }) expect(MemoryProvider.doc['subagent-model-selection']).toEqual({ router: { enabled: true, hints: [ @@ -579,12 +579,12 @@ describe('task router settings', () => { it('warns when no allowed model is ranked, and reports a router that cannot be reached', async () => { const { ctx, preferences } = await mounted() await ctx.settings.mutate('subagent-model-selection', [{ op: 'set', path: ['router', 'enabled'], value: true }]) - const unranked = scripted(pick('section:agent'), pick('setting:subagent-model-selection.router.hints'), - labelled(copy.settingsSubagentAllowedDone), back, back) + const unranked = scripted(pick('tab:agent'), pick('setting:subagent-model-selection.router.hints'), + labelled(copy.settingsSubagentAllowedDone), back) await run(preferences, unranked.interactions, { describeRoutes: () => Promise.resolve([{ provider: 'corp', model: 'opus-alias', ranked: false }]) }) expect(unranked.prompts[2]!.warning).toBe(copy.settingsRouterNoneRanked) - const down = scripted(pick('section:agent'), pick('setting:subagent-model-selection.router.hints'), back, back) + const down = scripted(pick('tab:agent'), pick('setting:subagent-model-selection.router.hints'), back) await run(preferences, down.interactions, { describeRoutes: () => Promise.reject(new Error('router requires a token; set ING_API_TOKEN')) }) expect(down.prompts[2]!.warning).toContain(`${copy.settingsRouterUnavailable}: router requires a token; set ING_API_TOKEN`) }) @@ -617,8 +617,8 @@ describe('task router settings', () => { await ctx.settings.mutate('subagent-model-selection', [{ op: 'set', path: ['router', 'enabled'], value: true }]) const { controls, calls } = account({ wrongCodes: 1 }) const { interactions, prompts, typed } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.router.account'), - { typed: ' ada@example.com ' }, { typed: '000000' }, { typed: '123456' }, back, back, + pick('tab:agent'), pick('setting:subagent-model-selection.router.account'), + { typed: ' ada@example.com ' }, { typed: '000000' }, { typed: '123456' }, back, ) await run(preferences, interactions, { describeRoutes: () => Promise.resolve([]), routerAccount: controls }) expect(calls).toEqual(['code ada@example.com', 'sign-in ada@example.com 000000', 'sign-in ada@example.com 123456']) @@ -633,9 +633,9 @@ describe('task router settings', () => { const { preferences } = await mounted() const { controls, calls } = account() const { interactions, prompts } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.router.enabled'), pick('sign-in'), + pick('tab:agent'), pick('setting:subagent-model-selection.router.enabled'), pick('sign-in'), { typed: 'ada@example.com' }, { typed: '123456' }, - pick('setting:subagent-model-selection.router.account'), pick('out'), back, back, + pick('setting:subagent-model-selection.router.account'), pick('out'), back, ) await run(preferences, interactions, { describeRoutes: () => Promise.resolve([]), routerAccount: controls }) expect(prompts[2]!.choices.map(choice => choice.value)).toEqual(['sign-in', 'on', 'off']) @@ -651,7 +651,7 @@ describe('task router settings', () => { await ctx.settings.mutate('subagent-model-selection', [{ op: 'set', path: ['router', 'enabled'], value: true }]) const { controls, calls } = account({ fromEnv: true }) const { interactions, prompts } = scripted( - pick('section:agent'), pick('setting:subagent-model-selection.router.account'), back, back, + pick('tab:agent'), pick('setting:subagent-model-selection.router.account'), back, ) await run(preferences, interactions, { describeRoutes: () => Promise.resolve([]), routerAccount: controls }) expect(accountRow(prompts[1])?.description).toBe(`${copy.settingsRouterTokenFrom} ING_API_TOKEN`) diff --git a/apps/tui/packages/ui/src/copy.ts b/apps/tui/packages/ui/src/copy.ts index c679c2f224..c9c39fac8d 100644 --- a/apps/tui/packages/ui/src/copy.ts +++ b/apps/tui/packages/ui/src/copy.ts @@ -218,7 +218,7 @@ export const dictionaries = { settingsResetSection: 'Reset this section to defaults', settingsResetConfirm: 'Reset every setting in this section to its default?', settingsResetKeep: 'Keep my settings', settingsResetDo: 'Reset to defaults', settingsResetField: 'Reset to default', settingsSession: 'Session', settingsTerminal: 'Terminal', settingsAgent: 'Agent', settingsShell: 'Shell', - settingsWeb: 'Web search', settingsAdvanced: 'Advanced', settingsAdvancedAbout: 'Every plugin setting, by section', + settingsWeb: 'Web search', settingsAdvanced: 'Advanced', settingsField: 'setting', settingsFields: 'settings', settingsCustom: 'Enter a value…', settingsInFile: 'Edit in the settings file', settingsInEditor: 'Enter opens your editor', settingsEditorFailed: 'Editor did not save', settingsNotJson: 'Not valid JSON', settingsSecretSet: 'Set', settingsSecretUnset: 'Not set', settingsEmpty: 'Empty', @@ -464,7 +464,7 @@ export const dictionaries = { settingsResetSection: '将本节恢复为默认值', settingsResetConfirm: '将本节所有设置恢复为默认值?', settingsResetKeep: '保留当前设置', settingsResetDo: '恢复默认值', settingsResetField: '恢复默认值', settingsSession: '会话', settingsTerminal: '终端', settingsAgent: '代理', settingsShell: 'Shell', - settingsWeb: '网页搜索', settingsAdvanced: '高级', settingsAdvancedAbout: '按分区列出所有插件设置', + settingsWeb: '网页搜索', settingsAdvanced: '高级', settingsField: '项设置', settingsFields: '项设置', settingsCustom: '输入值…', settingsInFile: '请在设置文件中编辑', settingsInEditor: 'Enter 用编辑器打开', settingsEditorFailed: '编辑器未保存', settingsNotJson: '不是有效的 JSON', settingsSecretSet: '已设置', settingsSecretUnset: '未设置', settingsEmpty: '空', diff --git a/apps/tui/scripts/pty-smoke.ts b/apps/tui/scripts/pty-smoke.ts index d4b09b71b4..f3f0b6f41f 100755 --- a/apps/tui/scripts/pty-smoke.ts +++ b/apps/tui/scripts/pty-smoke.ts @@ -2125,7 +2125,7 @@ scenario('presets', 'minimal and cordis start, answer the recorded turn, and rea } }) -scenario('settings', 'a /settings choice is saved to the settings file, plugin settings are found by search, and a saved fullscreen screen opens at the next launch', +scenario('settings', '/settings opens on its most general section, a choice is saved to the settings file, plugin settings are found by search, and a saved fullscreen screen opens at the next launch', { replayOnly: true }, async run => { const copy = dictionaries.en @@ -2137,23 +2137,18 @@ scenario('settings', 'a /settings choice is saved to the settings file, plugin s await run.terminal('settings', [], async tty => { const start = tty.mark() tty.send('/settings\r', 'open the settings panel') - // Sections, each read by its values; the running composition fills the plugin ones. - await tty.expect(copy.settingsSession, 'deepseek-official/deepseek-v4-flash', copy.settingsTerminal, copy.settingsScreenInline, - copy.settingsAgent, copy.settingsAdvanced, start) - await tty.wait('the session section to be selected', text => picked(copy.settingsSession).test(text.slice(start))) - // The top page searches the settings inside every section. + // It opens on the most general section, with every section on its tab row; the running composition fills the plugin ones. + await tty.expect(`${copy.settingsTitle} › ${copy.settingsTerminal}`, copy.settingsScreenInline, + copy.settingsSession, copy.settingsAgent, copy.settingsAdvanced, start) + await tty.wait('the screen row to be selected', text => picked(copy.settingsScreen).test(text.slice(start))) + // A section's page searches the settings inside every section. const search = tty.mark() tty.send('parallel', 'search for a plugin setting') - await tty.wait('the agent loop setting found from the top', text => + await tty.wait('the agent loop setting found from the terminal section', text => picked(`${copy.settingsAgent} › ${copy.settingsParallelTools}`).test(text.slice(search))) const cleared = tty.mark() tty.send('\x7f'.repeat('parallel'.length), 'clear the search') - await tty.wait('the session section to be selected again', text => picked(copy.settingsSession).test(text.slice(cleared))) - const terminal = tty.mark() - tty.send('\x1b[B', 'point at the terminal section') - await tty.wait('the terminal section to be selected', text => picked(copy.settingsTerminal).test(text.slice(terminal))) - tty.send('\r', 'open the terminal section') - await tty.wait('the screen row to be selected', text => picked(copy.settingsScreen).test(text.slice(terminal))) + await tty.wait('the screen row to be selected again', text => picked(copy.settingsScreen).test(text.slice(cleared))) const values = tty.mark() tty.send('\r', 'open the screen values') await tty.wait('the inline value to be selected', text => picked(copy.settingsScreenInline).test(text.slice(values))) @@ -2164,9 +2159,7 @@ scenario('settings', 'a /settings choice is saved to the settings file, plugin s const chosen = tty.mark() tty.send('\r', 'choose fullscreen') await tty.expect(copy.settingsNextLaunch, chosen) - const up = tty.mark() - tty.send('\x1b', 'return to the sections') - await tty.wait('the terminal section under the pointer again', text => picked(copy.settingsTerminal).test(text.slice(up))) + await tty.wait('the screen row under the pointer again', text => picked(copy.settingsScreen).test(text.slice(chosen))) const closed = tty.mark() tty.send('\x1b', 'close the panel') await tty.expect(`${SCREEN.prompt}${SCREEN.caret}`, closed) @@ -2211,15 +2204,12 @@ scenario('settings-agent', 'Tab moves between /settings sections, subagent model await run.terminal('settings-agent', [], async tty => { const start = tty.mark() tty.send('/settings\r', 'open the settings panel') - await tty.expect(copy.settingsAgent, copy.pickerTabsHelp, start) - // Tab opens the first section, and each Tab the next one. + // The panel opens on its first section, and each Tab moves to the next one. + await tty.expect(`${copy.settingsTitle} › ${copy.settingsTerminal}`, copy.settingsAgent, copy.pickerTabsHelp, start) let at = tty.mark() - tty.send('\t', 'open the first section') + tty.send('\t', 'move to the session section') await tty.expect(`${copy.settingsTitle} › ${copy.settingsSession}`, at) at = tty.mark() - tty.send('\t', 'move to the terminal section') - await tty.expect(`${copy.settingsTitle} › ${copy.settingsTerminal}`, at) - at = tty.mark() tty.send('\t', 'move to the agent section') await tty.expect(`${copy.settingsTitle} › ${copy.settingsAgent}`, copy.settingsSubagentAllowed, copy.settingsSubagentAllowedNone, at) at = tty.mark() @@ -2240,13 +2230,10 @@ scenario('settings-agent', 'Tab moves between /settings sections, subagent model await tty.expect(`${copy.settingsTitle} › ${copy.settingsAgent}`, at) await tty.wait('the agent section to show the switch on and the model', text => text.slice(at).includes(copy.settingsOn) && text.slice(at).includes('deepseek-official/deepseek-v4-flash')) - at = tty.mark() - tty.send('\x1b', 'return to the top') - await tty.expect(copy.settingsAdvanced, at) // The model list found under Advanced opens the same catalog picker, never the editor. at = tty.mark() tty.send('advanced allowedModels', 'search for the model list under Advanced') - await tty.wait('the model list found from the top', text => + await tty.wait('the model list found from the agent section', text => picked(`${copy.settingsAdvanced} › subagent-model-selection › allowedModels`).test(text.slice(at))) at = tty.mark() tty.send('\r', 'open the list') @@ -2264,12 +2251,13 @@ scenario('settings-agent', 'Tab moves between /settings sections, subagent model tty.send('\r', 'remove it') await tty.expect(copy.settingsSubagentAllowed, at) at = tty.mark() - tty.send('\x1b', 'return to the top') - await tty.expect(copy.settingsAdvanced, at) + tty.send('\x1b', 'leave the list') + // An edit found by search opens the section it lives in. + await tty.expect(`${copy.settingsTitle} › ${copy.settingsAdvanced}`, at) // A list with no picker of its own opens in the editor, which reads the line typed into it. at = tty.mark() tty.send('advanced router.hints', 'search for the router hints under Advanced') - await tty.wait('the hint list found from the top', text => + await tty.wait('the hint list found from the advanced section', text => picked(`${copy.settingsAdvanced} › subagent-model-selection › router.hints`).test(text.slice(at))) at = tty.mark() tty.send('\r', 'open the list in the editor') diff --git a/bun.lock b/bun.lock index 49cae74d3c..f0c425bcc7 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "apps/cli": { "name": "@deepseek-ai/dsh", - "version": "0.3.0", + "version": "0.3.1", "bin": { "dsh": "lib/bin.js", }, diff --git a/package.json b/package.json index d3a1f4663c..266ef6aa87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bake", - "version": "0.3.0", + "version": "0.3.1", "description": "A terminal coding agent built on Cordis and the DeepSeek Harness runtime", "license": "MIT", "private": true,