@fanchaozz/provider-manager 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,26 +14,16 @@
14
14
  pi install npm:@fanchaozz/provider-manager
15
15
  ```
16
16
 
17
- 包托管在 **npmjs.com**。`pi install` 默认查 npmjs,所以什么都不用配。
18
-
19
- ### 不可访问 npmjs / 想要开发版
20
-
21
- ```bash
22
- git clone https://github.com/fanchaozz/provider-manager.git
23
- ln -s "$(pwd)/provider-manager" ~/.pi/agent/extensions/provider-manager
24
- # Windows: mklink /D "%USERPROFILE%\.pi\agent\extensions\provider-manager" "%CD%\provider-manager"
25
- # 或:复制
26
- cp -r provider-manager ~/.pi/agent/extensions/
27
- ```
28
-
29
- 或者在 pi 项目里 `pi install /path/to/provider-manager`。
30
-
31
17
  依赖:`@earendil-works/pi-coding-agent`(pi 自带)。jiti 向上找 `node_modules`,**不需要**在扩展目录内 `npm install`。
32
18
 
33
19
  安装后,首次加载时会自动创建 `~/.pi/agent/provider-manager.json`(见 [用户配置](#用户配置--provider-managerjson))。删除该文件即可回退到代码默认。
34
20
 
35
21
  ---
36
22
 
23
+ ## 最近变更
24
+
25
+ 版本变更记录看 [CHANGELOG.md](./CHANGELOG.md)。
26
+
37
27
  ## 快速开始
38
28
 
39
29
  | 想做什么 | 操作 |
@@ -41,8 +31,8 @@ cp -r provider-manager ~/.pi/agent/extensions/
41
31
  | 打开仪表盘 | `/providers` |
42
32
  | 列出 provider + 它们的 model | `/providers ls`(过滤:`/providers ls kdapi`) |
43
33
  | 新增 provider | 仪表盘 Providers 面板按 `n`,或 `/providers add [<id>]` |
44
- | 新增 model | 仪表盘 `Tab` 切到 Models 面板按 `n`,或 `/providers model <pid> add` |
45
- | 编辑 provider / model | 仪表盘按 `e` |
34
+ | 新增 / 删除 model | **仅通过 sync**(仪表盘 Models 面板按 `y`)。model 不可手动新增 |
35
+ | 编辑 provider / model | 仪表盘选中后按 `Enter`(表单里 Enter 进入字段 edit、确认) |
46
36
  | 删除 | 仪表盘按 `d`(确认对话框) |
47
37
  | 从 provider 的 API 拉取新 model 列表 | 仪表盘按 `y`,或 `/providers sync [<pid>]` |
48
38
  | 探测 auth + 可达性 + 1-token 测试调用 | 仪表盘 `t`(当前 model)或 `T`(provider 内全部) |
@@ -50,7 +40,7 @@ cp -r provider-manager ~/.pi/agent/extensions/
50
40
  | 打印命令帮助 | `/providers help` |
51
41
  | 关闭仪表盘 | `q` 或 `Esc` |
52
42
 
53
- `sync` 命令是给全新 provider 填充 model 列表最快的方式:拉取远端 model 列表,显示 checklist,把选中的写回。
43
+ `sync` 命令是给全新 provider 填充 model 列表最快的方式:拉取远端 model 列表,显示 checklist,把选中的写回。**model 只能通过 sync 增删**(仪表盘 `n` 在 model 面板已停用)。
54
44
 
55
45
  ---
56
46
 
@@ -58,10 +48,10 @@ cp -r provider-manager ~/.pi/agent/extensions/
58
48
 
59
49
  `/providers` 打开两栏 TUI:
60
50
 
61
- - **左栏** —— provider(id + model 数)
62
- - **右栏** —— 选中 provider 的 model(id + `[R]` reasoning / `[I]` image 标记 + ctx / max
63
- - **详情条** —— 选中行的原始 JSON
64
- - **底栏** —— 当前按键说明
51
+ - **左栏** —— provider(id + auth 状态 + model 数;0 model 时 ⚠ 提示)
52
+ - **右栏** —— 选中 provider 的 model(id + `[R]` reasoning / `[I]` image 标记 + ctx / max;R / I 实际值,未启用是 `-`)
53
+ - **详情面板** —— 选中行分组:Identity / Endpoint / Auth / Capabilities / Limits / Thinking levels / Cost
54
+ - **底栏** —— 按面板调整的按键提示(按 `?` 看完整 help)
65
55
 
66
56
  ### 按键绑定
67
57
 
@@ -69,29 +59,37 @@ cp -r provider-manager ~/.pi/agent/extensions/
69
59
  |---|---|
70
60
  | `↑↓` / `j k` | 在当前面板上下移动 |
71
61
  | `g` / `G` | 跳到顶 / 底 |
72
- | `Tab` | 切换 Providers ↔ Models 面板 |
73
- | `n` | 新增:Providers 面板下加 provider,Models 面板下加 model |
74
- | `e` | 编辑选中的 provider / model |
62
+ | `←` / `→` | 切换 Providers ↔ Models 面板 |
63
+ | `n` | **仅 Providers 面板**:新增 provider。Model 面板已停用(model 只能 sync) |
64
+ | `Enter` | 选中行进入 edit 表单(Provider Model) |
75
65
  | `d` | 删除(带确认对话框) |
76
66
  | `y` | 同步(拉取选中 provider 的远端 model 列表) |
77
- | `t` / `T` | 探测当前 model / provider 内全部 model |
78
- | `?` | 切换帮助覆盖层 |
67
+ | `t` / `T` | **仅 Models 面板**:探测当前 model / provider 内全部 model |
68
+ | `?` | 切换帮助覆盖层(按面板显示特有键) |
79
69
  | `q` / `Esc` | 关闭仪表盘 |
80
70
 
81
- provider 列表为空时,在 Models 面板按 `n` 会切到 Providers 面板并提示先创建一个。
71
+ 底部提示按面板动态调整(provider 面板显示 `n`、model 面板显示 `t` / `T`),不会出现在不该出现的面板里。
82
72
 
83
73
  ---
84
74
 
85
75
  ## 同步流程
86
76
 
87
- `sync` 是批量加 model 最快的方式。它会拉取选中 provider 的远端 model 列表并显示 checklist。
77
+ `sync` 是批量加 model 的唯一方式。它会拉取选中 provider 的远端 model 列表并显示 checklist。
88
78
 
89
- checklist **展示该 provider 的所有 model —— existing + remote new 都有**:
79
+ **checklist 展示该 provider 的所有 model**(existing + remote new 都有):
90
80
 
91
81
  - 已有 model 标 `<id> (existing)`,默认勾选。取消勾选 = 删除。
92
82
  - 远端新 model 只标 `<id>`,默认不勾选。勾选 = 添加。
93
83
 
94
- 按 `Enter` 写入结果,按 `Esc` 取消。保存时,最终 `models.json` 是(勾选的 existing)+(勾选的 new)的并集;如果某 model 同时被远端和 local 都有并都被勾选,优先用远端定义(这样能拉到最新的 `reasoning` / `input` / `ctx` / `maxTokens` / `thinkingLevelMap` 来自默认 model 配置)。
84
+ 按 `Enter` 写入结果,按 `Esc` 取消。保存时最终 `models.json` (勾选的 existing) + (勾选的 new) 的并集;远端 new 优先于 local(这样能拉到最新的 `reasoning` / `input` / `ctx` / `maxTokens` / `thinkingLevelMap`)。
85
+
86
+ **新加的 model 字段来自 `~/.pi/agent/provider-manager.json` 的 `defaultModel` 段**(不是代码内置默认)—— 这是 `loadDefaultModelConfig()` 的行为。在 sync 前编辑这个文件可以定制 sync 出来的 model 模板。
87
+
88
+ **proxy 字段**:provider 编辑表单里可填 `proxy`(形如 `http://127.0.0.1:7890`)。sync 时设到 `HTTPS_PROXY` / `HTTP_PROXY` 环境变量,请求结束后还原。其他并发 fetch 会临时看到同一 proxy(env 是进程全局的,sync 一次只 1 个 fetch)。
89
+
90
+ **apiKey 字符约束**:sync 拒绝 `code > 255` 的 apiKey(含 `•`、中文、emoji 等,常见于复制粘贴残留)。错误是 actionable:`apiKey contains non-Latin-1 character at position 7 (U+2022). Re-enter the key in the provider form.`
91
+
92
+ **检测 noise**:默认过滤 `embed*` / `tts` / `whisper` / `dall-e` / `clip` / `moderation` / `image-*` 等 embedding/tts/image-gen 类 model。
95
93
 
96
94
  ---
97
95
 
@@ -143,40 +141,43 @@ checklist **展示该 provider 的所有 model —— existing + remote new 都
143
141
 
144
142
  ## 表单编辑器(新增/编辑 model/provider)
145
143
 
146
- `addProviderFlow` / `editProviderFlow` / `addModelFlow` / `editModelFlow` / `deleteProviderFlow` / `deleteModelFlow` 都共用一个 TUI 表单(`components.ts:FormEditor`)。
144
+ `addProviderFlow` / `editProviderFlow` / `editModelFlow` / `deleteProviderFlow` / `deleteModelFlow` 都共用一个 TUI 表单(`components.ts:FormEditor`)。**`addModelFlow` 已停用**——model 只能通过 sync 增删。
147
145
 
148
146
  ### 字段类型
149
147
 
150
148
  | 类型 | 行为 |
151
149
  |---|---|
152
150
  | `text` | 自由文本输入 |
153
- | `secret` | 自由文本,TUI 渲染时遮罩 |
151
+ | `secret` | `text`,但用 `Enter` 进入 edit 后显示真实值(不显示 masked `••••Xn`),切 field 不写回 masked 覆盖原 key |
154
152
  | `number` | 自由数字输入,提交时校验 |
155
- | `select` | 选项列表;按 `e` 进 edit 模式,`Space` 选中,`↑↓`/`jk` 导航,`Enter` 确认 |
153
+ | `select` | 选项列表;按 `Enter` 进 edit,`Space` 选中,`↑↓` / `jk` 导航,再 `Enter` 退出 |
156
154
  | `multiselect` | 类似 `select` 但可多选;`Space` 切换每项 |
157
155
  | `levelmap` | 7 行(`off` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`);`Space` 切换每项;提交时归一化缺失的 key 为 `null` |
158
156
  | `readonly` | 仅展示,不可编辑 |
159
157
 
160
- ### 按键绑定
158
+ ### 按键绑定(统一 view / edit 两态模型)
161
159
 
162
- | 键 | 行为 |
163
- |---|---|
164
- | `e` / `E` | 进入 edit 模式(仅 `select` / `levelmap` / `multiselect`) |
165
- | `Esc` / `q` | edit 中:退出 edit(commit 当前值)。其他:取消整个表单 |
166
- | `s` | 保存整个表单。仅在 non-typeable 字段生效 — `s` `text` / `secret` / `number` / `json` 里是字符 |
167
- | `Enter` | edit 中(非输入字段):commit 并退出 edit。typeable 字段:commit + 移到下一字段。readonly 字段:保存表单 |
168
- | `Space` | edit 中(非输入字段):切换当前选项 |
169
- | `↑↓` / `j k` | 字段间导航;edit 中(非输入字段):选项间导航 |
170
- | `Backspace` | 删最后一个字符(typeable 字段) |
160
+ | 键 | view 模式 | edit 模式 |
161
+ |---|---|---|
162
+ | `↑↓` / `j k` | 切字段 | non-typeable:选项内 nav;typeable:no-op(j/k 是字符) |
163
+ | `Enter` | edit | commit + 退出 edit(留在原字段) |
164
+ | `Esc` / `q` | 取消整个 form | 退出 edit(commit) |
165
+ | `s` | 保存整个 form | no-op(typeable s 是字符;non-typeable 忽略) |
166
+ | `Space` | non-typeable 快捷进 edit | non-typeable 切换 / 选中 |
167
+ | `Backspace` | no-op | typeable 删最后一个字符 |
168
+ | 字符 | no-op | typeable 追加到 draft |
171
169
 
172
- ### 新增 model 流程:"Use default config?"
170
+ **view 模式不接受任何字符输入**(含 `s` / `q` / 数字 / 字母)。需要先按 `Enter` 进 edit 才能改。
173
171
 
174
- `addModelFlow` 在 name 之后问一次:
172
+ **typeable 字段的 `j` / `k` view 模式是 nav**(不当作字符),在 edit 模式是字符。
175
173
 
176
- - **Yes** 应用 `DEFAULT_MODEL_CONFIG`(见 [用户配置](#用户配置--provider-managerjson)),跳过剩余问题
177
- - **No** 逐个问 reasoning / input / ctx / max / thinking-level-map
174
+ **secret 字段的两种保护**(避免保存的 key masked 显示覆盖):
175
+ 1. 进入 edit `draft` 恢复为真实值(不是 masked `••••Xn`),用户能看见 / 修改真 key
176
+ 2. commitDraft 在 `draftIsOriginal=true`(用户没改)时不写回,保证原 key 完整
178
177
 
179
- `Esc` 在任何问题中都能取消整个流程(仪表盘自动恢复)。
178
+ ### `addProviderFlow` 与 `editProviderFlow` 结构同形
179
+
180
+ 两个流程都用同一个 FormEditor 模板(含 `id` / `name` / `baseUrl` / `apiKey` / `api` / `authHeader` / `proxy` 字段)。`addProviderFlow` 的 `id` 字段有 `validate`:字符集 `[a-z0-9_-]+` + 不能与已有 provider 重复(`json.providers[s]` 已存在则报 "provider already exists")。错误留在 FormEditor 自带的 `⚠ ...` 行显示,不弹 notify 打断流程。
180
181
 
181
182
  ---
182
183
 
@@ -218,17 +219,21 @@ cp ~/.pi/agent/models.json.bak ~/.pi/agent/models.json
218
219
 
219
220
  **仪表盘打开是空的。** 你的 `models.json` 里没有自定义 provider。本扩展只管 `models.json` — pi 内置 provider(anthropic / openai / google 等)不显示,用 pi 内置的 `/model`。
220
221
 
221
- **Sync 报 `ECONNREFUSED` / `ENOTFOUND`。** 选中 provider 的 `baseUrl` 不通。用 `/providers edit <pid>`(或仪表盘 `e`)改。
222
+ **Sync 报 `ECONNREFUSED` / `ENOTFOUND`。** 选中 provider 的 `baseUrl` 不通。用仪表盘选中该 provider 后按 `Enter` 进 edit 表单改。
222
223
 
223
224
  **Sync 报 `HTTP 500` / `HTTP 401`。** `baseUrl` 错或 `apiKey` 缺失/错。在 provider 编辑表单里核对。
224
225
 
225
- **设的 thinking level 一保存就消失。** pi 可能不支持该 level 换别的,或者填 `null` 禁用。
226
+ **Sync `Cannot convert argument to a ByteString`。** apiKey 含非 Latin-1 字符(`•`、中文、emoji 等,常见于复制粘贴残留)。**重新打开 provider 的 edit 表单,把 apiKey 字段清空再贴一次**真 key。
227
+
228
+ **Sync 总是 `ctx=128000` / `max=16384`(默认值),看起来没读到 user 配置。** 检查 `~/.pi/agent/provider-manager.json` 是否存在 + 路径正确(必须正好是这个文件名,不是 `providers.json` 等)。校验:`node -e "JSON.parse(require('fs').readFileSync(process.env.HOME+'/.pi/agent/provider-manager.json','utf8'))"`。
226
229
 
227
- **`provider-manager.json` 里的修改全部失效。** 文件损坏或校验失败(见 [Schema](#schema))。扩展会静默 fallback 默认。校验:`node -e "JSON.parse(require('fs').readFileSync(process.env.HOME+'/.pi/agent/provider-manager.json','utf8'))"`。
230
+ **Dashboard 显示的 `apiKey status` 与真状态不一致。** dashboard 不通过 pi runtime 检测 key 是否真有效(避免 OAuth / runtime / env / extension 多路径歧义),只自检 `models.json` 里 `apiKey` 字段是否非空。**真认证测试用 `t` / `T`**。
228
231
 
229
- **Sync 加的 model 字段错(不管什么都 `ctx=128000`)。** model 用的是默认,不是文件。文件没被读。检查路径:必须正好是 `~/.pi/agent/provider-manager.json`(不是 `~/.pi/agent/providers.json` 之类)。
232
+ **保存 secret 字段后 apiKey 变成了 `••••Xn`。** 这是 0.1.x 的老 bug(切 field 时 commitDraft 把 masked 字符串写回原 key)。0.2.0 已修:进入 edit 时 draft 恢复真实值,未改时 commitDraft 跳写。**0.2.0 起,re-enter edit 表单不会改变 key**。如果是老版本升上来:edit 表单里把 apiKey 清空再贴一次真 key。
233
+
234
+ **设的 thinking level 一保存就消失。** pi 可能不支持该 level — 换别的,或者填 `null` 禁用。
230
235
 
231
- **按 `n` / `e` / `d` / `y` 后 Esc 仪表盘消失。** 当前版本不应发生 — 仪表盘会自动恢复。如果发生了,请带 `~/.pi/agent/provider-manager.log` 反馈。
236
+ **按 `n` / `Enter` / `d` / `y` 后 Esc 仪表盘消失。** 当前版本不应发生 — 仪表盘会自动恢复。如果发生了,请带 `~/.pi/agent/provider-manager.log` 反馈。
232
237
 
233
238
  ---
234
239
 
package/README_EN.md CHANGED
@@ -14,25 +14,16 @@ A pi extension that manages custom providers and models in `~/.pi/agent/models.j
14
14
  pi install npm:@fanchaozz/provider-manager
15
15
  ```
16
16
 
17
- The package is hosted on **npmjs.com**. `pi install` queries npmjs by default, so no extra config is required.
18
-
19
- ### If you can't reach npmjs / want a development build
20
-
21
- ```bash
22
- git clone https://github.com/fanchaozz/provider-manager.git
23
- ln -s "$(pwd)/provider-manager" ~/.pi/agent/extensions/provider-manager
24
- # Windows: mklink /D "%USERPROFILE%\.pi\agent\extensions\provider-manager" "%CD%\provider-manager"
25
- # or: cp -r provider-manager ~/.pi/agent/extensions/
26
- ```
27
-
28
- Or from your pi project root: `pi install /path/to/provider-manager`.
29
-
30
17
  The extension depends on `@earendil-works/pi-coding-agent` (shipped with pi). jiti walks up `node_modules`, so **no `npm install` is needed** inside the extension directory.
31
18
 
32
19
  After install, `~/.pi/agent/provider-manager.json` is auto-created on first load (see [User config](#user-config--provider-managerjson)). Delete it to fall back to the code default.
33
20
 
34
21
  ---
35
22
 
23
+ ## Recent changes
24
+
25
+ Version history in [CHANGELOG.md](./CHANGELOG.md).
26
+
36
27
  ## Quick start
37
28
 
38
29
  | Want to… | Do this |
@@ -83,14 +74,22 @@ When the provider list is empty, pressing `n` on the Models pane switches to the
83
74
 
84
75
  ## Sync flow
85
76
 
86
- `sync` is the fastest way to add a batch of models. It fetches the remote model list for the selected provider and shows a checklist.
77
+ `sync` is the **only** way to add a batch of models. It fetches the remote model list for the selected provider and shows a checklist.
87
78
 
88
79
  The checklist **shows every model for that provider — both existing and remote new**:
89
80
 
90
81
  - Existing models are labelled `<id> (existing)`, default checked. Uncheck to delete.
91
82
  - Remote new models are labelled `<id>` only, default unchecked. Check to add.
92
83
 
93
- Press `Enter` to write the result, `Esc` to cancel. On save, the final `models.json` is the union of (checked existing) + (checked new); remote new is preferred over local if both are checked (so you pick up the fresh `reasoning` / `input` / `ctx` / `maxTokens` / `thinkingLevelMap` from the default model config).
84
+ Press `Enter` to write the result, `Esc` to cancel. On save, the final `models.json` is the union of (checked existing) + (checked new); remote new is preferred over local (so you pick up the fresh `reasoning` / `input` / `ctx` / `maxTokens` / `thinkingLevelMap`).
85
+
86
+ **Newly added models' fields come from `~/.pi/agent/provider-manager.json`'s `defaultModel` block** (not the code default) — this is the `loadDefaultModelConfig()` behavior. Edit that file before sync to customise the model template.
87
+
88
+ **proxy field**: a provider's edit form accepts a `proxy` URL (e.g. `http://127.0.0.1:7890`). On sync it's set as `HTTPS_PROXY` / `HTTP_PROXY` env vars and restored after the request. Other concurrent fetches in the same process will see the same proxy while it is set (env is process-global; sync runs at most one fetch at a time).
89
+
90
+ **apiKey character constraint**: sync rejects apiKeys with any character whose code > 255 (`•`, CJK, emoji, etc., typical from copy-paste). The error is actionable: `apiKey contains non-Latin-1 character at position 7 (U+2022). Re-enter the key in the provider form.`
91
+
92
+ **Noise filter**: embedding / tts / image-gen models are filtered by default (`embed*`, `tts`, `whisper`, `dall-e`, `clip`, `moderation`, `image-*`).
94
93
 
95
94
  ---
96
95
 
package/commands.ts CHANGED
@@ -11,7 +11,6 @@ import { openDashboard } from "./ui.ts";
11
11
  import {
12
12
  addProviderFlow,
13
13
  deleteProviderFlow,
14
- addModelFlow,
15
14
  restoreFromBackupFlow,
16
15
  syncFlow,
17
16
  } from "./forms.ts";
@@ -58,17 +57,6 @@ export function registerCommands(pi: ExtensionAPI): void {
58
57
  await deleteProviderFlow(ctx, id, () => undefined);
59
58
  return;
60
59
  }
61
- case "model": {
62
- const tokens = rest.split(/\s+/);
63
- const pid = tokens[0];
64
- if (tokens[1] === "add") {
65
- if (!pid) { ctx.ui.notify("用法: /providers model <pid> add", "warning"); return; }
66
- await addModelFlow(ctx, pid, () => undefined);
67
- } else {
68
- ctx.ui.notify("用法: /providers model <pid> add", "warning");
69
- }
70
- return;
71
- }
72
60
  case "reset": {
73
61
  await restoreFromBackupFlow(ctx, () => undefined);
74
62
  return;
@@ -219,13 +207,13 @@ function cmdHelp(ctx: ExtensionCommandContext): void {
219
207
  " /providers ls [filter] List providers and their models",
220
208
  " /providers add <id> Add a new provider (TUI: open dashboard, press n)",
221
209
  " /providers remove <id> Remove a provider (with confirm)",
222
- " /providers model <pid> add Add a model to a provider (TUI form)",
223
210
  " /providers sync [provider-id] Pick a provider (or pass id), fetch remote models, multi-select, write back. TUI: y on selected provider.",
224
211
  " /providers test <provider>/<model> Test model (auth + reachable + 1-shot generation). TUI: t on selected model.",
225
212
  " /providers test-all [provider] Batch test all models of a provider (concurrency 3). TUI: T on selected provider.",
226
213
  " /providers reset Restore models.json.bak (with confirm)",
227
214
  " /providers help This help",
228
215
  "",
216
+ "Models can only be added/removed via sync (y on a provider).",
229
217
  "Switching models is NOT done by this extension — use Ctrl+L or /model.",
230
218
  ];
231
219
  const out = lines.join("\n");
package/components.ts CHANGED
@@ -317,7 +317,13 @@ export class FormEditor<T extends Record<string, unknown>> {
317
317
  if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
318
318
  } else { // text, secret
319
319
  if (f.validate) { const err = f.validate(raw); if (err) { this.error = `${f.label}: ${err}`; return { ok: false }; } }
320
- (this.values as any)[f.key] = raw;
320
+ // secret: 避免覆写原值。draft 是 masked display ("••••Xn"),如果用户没改(draftIsOriginal=true),
321
+ // 切 field / Enter 退出 edit 都会调 commitDraft,不跳这会写回 masked 字符串覆盖真 key。
322
+ if (f.type === "secret" && this.draftIsOriginal) {
323
+ // no-op,保持 values[f.key] 原值
324
+ } else {
325
+ (this.values as any)[f.key] = raw;
326
+ }
321
327
  }
322
328
  this.error = null;
323
329
  return { ok: true };
@@ -364,11 +370,14 @@ export class FormEditor<T extends Record<string, unknown>> {
364
370
  const f0 = this.fields[this.cursor];
365
371
  const isNonInput = f0?.type === "select" || f0?.type === "levelmap" || f0?.type === "multiselect";
366
372
  const isTypeable = f0 && (f0.type === "text" || f0.type === "secret" || f0.type === "number" || f0.type === "json");
373
+ const isReadonly = f0?.type === "readonly";
374
+
375
+ // 统一模型:所有字段都遵 view / edit 两态。
376
+ // view(editing=false,默认):只响应 Enter(进 edit)、↑↓/j/k(切字段)、s(保存)、Esc/q(取消)
377
+ // edit(editing=true):可修改(typeable 输字符 / non-typeable 按 Space/↑↓)、Enter(提交+退出)、Esc(退出)、↑↓(提交+切字段)
367
378
 
368
- // Esc / q → 取消整个 form(仅当不在 typeable 字段里输入字母 q/q)
369
- // text/secret/number/json 字段里输入 q 应该被当作字符(apiKey q 不会被取消)
370
- if (matchesKey(data, "escape") || (data === "q" && !isTypeable)) {
371
- // edit 模式下 Esc = 退出 edit 模式(commit 当前值)
379
+ // 1. Esc / q → edit 中退出(commit);view 模式取消整个 form
380
+ if (matchesKey(data, "escape") || data === "q") {
372
381
  if (this.editing) {
373
382
  this.commitDraft();
374
383
  this.editing = false;
@@ -379,45 +388,66 @@ export class FormEditor<T extends Record<string, unknown>> {
379
388
  return;
380
389
  }
381
390
 
382
- // s → 保存整个 form(仅当不在 typeable 字段里输入字母 s)
383
- // text/secret/number/json 字段里输入 s 应该被当作字符(apiKey s 不会被保存)
384
- if (data === "s" && !isTypeable) {
391
+ // 2. s → 保存整个 form(仅在 view 模式;edit 模式下 s 是字符 / no-op
392
+ if (data === "s" && !this.editing) {
385
393
  const result = this.commitDraft();
386
394
  if (!result.ok) { this.invalidate(); return; }
387
395
  this.onSave(this.values);
388
396
  return;
389
397
  }
390
398
 
391
- // e非输入字段进入 edit 模式(保持整体风格一致:单键操作)
392
- if ((data === "e" || data === "E") && isNonInput && !this.editing) {
393
- this.editing = true;
394
- this.invalidate();
395
- return;
396
- }
397
- // Enter 行为
398
- // edit 模式下 → 退出 edit 模式(commit 当前值)
399
- // 输入字段 → 提交 + 移动到下一字段
400
- // 其他(readonly)→ 提交整个 form 并保存
399
+ // 3. Enter toggle edit(readonly 不响应)
400
+ // view: edit(所有 typeable / non-typeable)
401
+ // edit: commit + 退出 edit(不走下一字段,留在原字段;用户用 ↑↓ 切)
401
402
  if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
402
- if (this.editing && isNonInput) {
403
+ if (isReadonly) return;
404
+ if (this.editing) {
403
405
  this.commitDraft();
404
406
  this.editing = false;
405
407
  this.invalidate();
406
408
  return;
407
409
  }
408
- if (isTypeable) {
409
- // 输入字段:commit draft + 移动到下一字段
410
- const result = this.commitDraft();
411
- if (!result.ok) { this.invalidate(); return; }
412
- this.move(1);
410
+ // edit。secret field:draft = 原值(不是 masked 显示),用户可看/可改真 key;
411
+ // commitDraft 会配合 draftIsOriginal 避免覆写未改的原值。
412
+ if (f0?.type === "secret") {
413
+ const orig = (this.values as any)[f0.key];
414
+ this.draft = (orig === undefined || orig === null) ? "" : String(orig);
415
+ this.draftIsOriginal = true;
416
+ }
417
+ this.editing = true;
418
+ this.invalidate();
419
+ return;
420
+ }
421
+
422
+ // 4. ↑↓:
423
+ // view / edit + typeable:切字段(edit 模式下 commit + 退 edit)
424
+ // edit + non-typeable:在 step 7 处理(options 内 nav)
425
+ if (matchesKey(data, "down") || matchesKey(data, "up")) {
426
+ if (!(this.editing && isNonInput)) {
427
+ this.commitDraft();
428
+ this.move(matchesKey(data, "up") ? -1 : 1);
413
429
  this.editing = false;
414
430
  return;
415
431
  }
416
- // readonly 字段:Enter 什么都不做(用 s 保存)
432
+ }
433
+
434
+ // 5. j/k:
435
+ // view:切字段
436
+ // edit + typeable:字符(下面 printable 处理)
437
+ // edit + non-typeable:options 内 nav(在 step 7 处理)
438
+ if ((data === "j" || data === "k") && !this.editing) {
439
+ this.move(data === "j" ? 1 : -1);
417
440
  return;
418
441
  }
419
442
 
420
- // edit 模式下:↑↓/j/k options 内移动,Space pick
443
+ // 6. Space:view + non-typeable 快捷进 edit(与 Enter 等价)
444
+ if (data === " " && isNonInput && !this.editing) {
445
+ this.editing = true;
446
+ this.invalidate();
447
+ return;
448
+ }
449
+
450
+ // 7. edit + non-typeable:↑↓/j/k 在 options 内移动,Space pick
421
451
  if (this.editing && isNonInput) {
422
452
  if (f0!.type === "levelmap") {
423
453
  if (matchesKey(data, "down") || data === "j") {
@@ -454,7 +484,7 @@ export class FormEditor<T extends Record<string, unknown>> {
454
484
  }
455
485
  return; // edit 模式下其他键不响应
456
486
  }
457
- // multiselect 字段:↑↓ 选 option,Space toggle(在值里加/去)
487
+ // multiselect 字段:↑↓/j/k 选 option,Space toggle(在值里加/去)
458
488
  if (f0!.type === "multiselect" && f0!.options) {
459
489
  const opts = f0!.options;
460
490
  if (matchesKey(data, "down") || data === "j") {
@@ -481,38 +511,24 @@ export class FormEditor<T extends Record<string, unknown>> {
481
511
  }
482
512
  }
483
513
 
484
- // nav 模式下:↑↓ 切字段(所有字段),j/k 切字段(仅 non-input;text 字段 j/k 是普通字符)
485
- if (matchesKey(data, "down") || (isNonInput && data === "j")) {
486
- this.commitDraft();
487
- this.move(1);
488
- this.editing = false; // 切到新字段退出 edit
489
- return;
490
- }
491
- if (matchesKey(data, "up") || (isNonInput && data === "k")) {
492
- this.commitDraft();
493
- this.move(-1);
494
- this.editing = false;
495
- return;
496
- }
497
-
498
- // readonly 字段:忽略
499
- if (f0?.type === "readonly") return;
514
+ // 8. readonly:后续输入不响应
515
+ if (isReadonly) return;
500
516
 
501
- // nav 模式下 Space 在非输入字段:进入 edit 模式(不动值)
502
- if (data === " " && isNonInput && !this.editing) {
503
- this.editing = true;
504
- this.invalidate();
505
- return;
506
- }
517
+ // 9. view 模式:不接受任何字符输入(需先 Enter edit
518
+ if (!this.editing) return;
507
519
 
508
- // 文本类输入处理
520
+ // 10. Backspace:仅在 edit + typeable 删除 draft
509
521
  if (matchesKey(data, "backspace")) {
522
+ if (!isTypeable) return;
510
523
  if (this.draft.length > 0) this.draft = this.draft.slice(0, -1);
511
524
  this.draftIsOriginal = false;
512
525
  this.invalidate();
513
526
  return;
514
527
  }
528
+
529
+ // 11. 可打印字符:仅在 edit + typeable 追加 draft
515
530
  if (data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127) {
531
+ if (!isTypeable) return;
516
532
  // number 字段:第一个数字替换(避免 100 + "2" = 1002),后续 append
517
533
  if (f0?.type === "number" && /^\d$/.test(data) && this.draftIsOriginal) {
518
534
  this.draft = data;
@@ -671,8 +687,9 @@ export class FormEditor<T extends Record<string, unknown>> {
671
687
  valueStr = isActive ? raw : th.fg("text", raw);
672
688
  }
673
689
  const prefix = isActive ? th.fg("accent", "▸ ") : " ";
690
+ const editMarker = isActive && this.editing ? th.fg("accent", " [● edit]") : "";
674
691
  const hint = f.hint ? " " + th.fg("muted", f.hint) : "";
675
- const line = prefix + labelStr + valueStr + hint;
692
+ const line = prefix + labelStr + valueStr + editMarker + hint;
676
693
  lines.push(truncateForRender(line, width));
677
694
  }
678
695
 
@@ -680,12 +697,13 @@ export class FormEditor<T extends Record<string, unknown>> {
680
697
  lines.push(th.fg("borderMuted", "─".repeat(width)));
681
698
  const f = this.fields[this.cursor];
682
699
  const hints: string[] = ["↑↓ field"];
683
- if (f?.type === "multiselect") hints.push(this.editing ? "↑↓ option · Space toggle · Enter commit" : "e edit · Space toggle");
684
- else if (f?.type === "select") hints.push(this.editing ? "↑↓ option · Space pick · Enter commit" : "e edit · Space pick");
685
- else if (f?.type === "levelmap") hints.push(this.editing ? "↑↓ level · Space toggle · Enter commit" : "e edit · Space toggle");
686
- else hints.push("type to edit");
687
- hints.push("Backspace del");
688
- hints.push("s save");
700
+ if (f?.type === "multiselect") hints.push(this.editing ? "↑↓ option · Space toggle · Enter commit" : "Enter edit · Space toggle");
701
+ else if (f?.type === "select") hints.push(this.editing ? "↑↓ option · Space pick · Enter commit" : "Enter edit · Space pick");
702
+ else if (f?.type === "levelmap") hints.push(this.editing ? "↑↓ level · Space toggle · Enter commit" : "Enter edit · Space toggle");
703
+ else if (f?.type === "readonly") hints.push("readonly");
704
+ else hints.push(this.editing ? "type to edit" : "Enter edit · type");
705
+ hints.push(this.editing && f && (f.type === "text" || f.type === "secret" || f.type === "number" || f.type === "json") ? "Backspace del" : "Backspace");
706
+ hints.push(this.editing ? "Enter commit" : "s save");
689
707
  hints.push("Esc cancel");
690
708
  lines.push(th.fg("dim", " " + hints.join(" · ")));
691
709