pi-naming 0.5.2 → 0.6.0

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
@@ -10,11 +10,11 @@ Automatic and manual naming for Pi sessions and terminals.
10
10
  pi install npm:pi-naming
11
11
  ```
12
12
 
13
- Available through npm after publication. Run `/reload` after installation or configuration changes.
13
+ Available through npm after publication. Run `/reload` after installation.
14
14
 
15
15
  - **Automatic naming**: the first real user input in a new, unnamed session generates a title in the background and applies it to the allowed session, workspace and tab targets.
16
16
  - **`/rename [name]`**: supply an explicit name, or omit it to generate one from user messages on the current branch. Uses the same targets and execution path as automatic naming.
17
- - **`/config:naming`**: open the normal TUI settings menu; use `reset` to restore defaults.
17
+ - **`/config:naming`**: open the TUI settings panel (Pi's `SettingsList`); toggles switch in place, enums and numbers open a submenu, free text opens a prefilled input. Every change is saved and applied immediately, no `/reload`. Use `reset` to restore defaults.
18
18
  - Manual generation considers all user input on the current branch and names the main task. Explicit later corrections or goal changes take precedence; procedural follow-ups such as “continue”, “verify”, or “commit” should not overshadow the topic. Other branches, assistant replies and tool output are excluded. Automatic naming still attempts only once on the first input, not after every turn.
19
19
  - Automatic results do not overwrite an existing name. Manual commands supersede pending requests; session switches and reloads discard old results and errors.
20
20
  - Each target is independent. Unsupported, disabled, unidentified or failed terminal targets are reported without preventing session naming or other target updates. Non-UI modes receive Pi messages instead of silent failures.
package/README.zh-CN.md CHANGED
@@ -10,11 +10,11 @@
10
10
  pi install npm:pi-naming
11
11
  ```
12
12
 
13
- 本包发布后可通过 npm 安装。安装或修改配置后执行 `/reload`。
13
+ 本包发布后可通过 npm 安装。安装后执行 `/reload`。
14
14
 
15
15
  - **首条消息自动命名**:新建且未命名的 session 收到首条真实用户输入后,后台生成标题,同步允许修改的 session、workspace 和 tab。
16
16
  - **`/rename [名称]`**:显式指定名称,或省略名称、根据当前分支用户消息生成标题。与自动命名使用相同的目标配置和执行逻辑。
17
- - **`/config:naming`**:打开常规 TUI 配置菜单;输入 `reset` 恢复默认值。
17
+ - **`/config:naming`**:打开 TUI 配置面板(Pi 自带 `SettingsList`);开关原地切换,枚举与数值回车开二级列表,自由文本回车开预填输入框。改一项立即保存并生效,不需要 `/reload`。输入 `reset` 恢复默认值。
18
18
  - 手动生成时综合当前分支的全部用户输入,围绕主要任务命名;后续明确纠正或目标变更优先,“继续”“验证一下”“提交”等流程性跟进不应盖过主题。不会读取其他分支、assistant 回复或工具输出。自动命名仍仅在首条输入时尝试一次,不随每轮对话更新。
19
19
  - 不覆盖已有名称的自动命名结果。手动命令优先于尚未完成的旧请求;会话切换或 reload 后丢弃旧结果和错误。
20
20
  - 各目标独立执行。终端不支持、被禁用、缺少归属信息或改名失败均明确报告,不阻止 session 和其他可用目标改名。无交互 UI 时通过 Pi 消息报告。
package/SKILL.md CHANGED
@@ -5,9 +5,9 @@ description: 配置 Pi 统一 /rename、首条消息命名和终端目标归属
5
5
 
6
6
  # 配置 pi-naming / Configure pi-naming
7
7
 
8
- 读取 `<pi-agent-dir>/extensions/pi-naming/config.json`,遵守 `PI_CODING_AGENT_DIR`。使用 `/config:naming` 打开 TUI 配置菜单,输入 `reset` 恢复默认值,保存后 reload。字段和默认值见关联文档和配置示例。
8
+ 读取 `<pi-agent-dir>/extensions/pi-naming/config.json`,遵守 `PI_CODING_AGENT_DIR`。使用 `/config:naming` 打开 TUI 配置面板(Pi 自带 SettingsList),输入 `reset` 恢复默认值;面板里改一项立即生效,不需要 reload。字段和默认值见关联文档和配置示例。
9
9
 
10
- Use `/config:naming` to open the normal TUI settings menu; use `reset` for defaults and reload after saving.
10
+ Use `/config:naming` to open the TUI settings panel (Pi's SettingsList); use `reset` for defaults. Changes apply immediately, no reload needed after saving.
11
11
 
12
12
  - `automaticNaming` / `manualNaming` 控制自动输入和 `/rename [名称]`,`targets` 直接分别控制 session/workspace/tab。Control automatic input and `/rename [name]`; `targets` directly selects session/workspace/tab.
13
13
  - `title` 控制长度、语言、补充提示、超时、输出预算和思考档位(`maxTokens` 默认 2048、`effort` 默认 low,二者与 `maxLength` 相互独立);模型鉴权复用 Pi。Controls length, language, instructions, timeout, output budget and thinking effort (`maxTokens` default 2048, `effort` default low, both independent from `maxLength`); model authentication comes from Pi.
@@ -75,6 +75,14 @@
75
75
  "zh-CN": "统一命名 session 与允许修改的终端目标;省略名称时综合当前分支全部用户消息生成",
76
76
  "en-US": "Name the session and allowed terminal targets; omit the name to summarize all user messages on the current branch"
77
77
  },
78
+ "namingManualDisabled": {
79
+ "zh-CN": "手动命名已关闭:用 /config:naming 打开「手动命名」后再试",
80
+ "en-US": "Manual naming is off: enable it in /config:naming and retry"
81
+ },
82
+ "namingNoTargets": {
83
+ "zh-CN": "没有启用任何命名目标:用 /config:naming 至少打开 session、workspace 或 tab 之一",
84
+ "en-US": "No naming target is enabled: turn on session, workspace, or tab in /config:naming"
85
+ },
78
86
  "namingFailed": {
79
87
  "zh-CN": "命名失败:{error}",
80
88
  "en-US": "Naming failed: {error}"
@@ -123,10 +131,6 @@
123
131
  "zh-CN": "用法:/config:naming(打开配置菜单)或 /config:naming reset(恢复默认值)",
124
132
  "en-US": "Usage: /config:naming (open the settings menu) or /config:naming reset (restore defaults)"
125
133
  },
126
- "configCommandInput": {
127
- "zh-CN": "输入配置值",
128
- "en-US": "Enter a configuration value"
129
- },
130
134
  "configCommandInteractiveOnly": {
131
135
  "zh-CN": "命名配置需要在 TUI 中修改;请在交互式 Pi 会话中执行命令。",
132
136
  "en-US": "Naming configuration requires the TUI; run this command in an interactive Pi session."
@@ -135,120 +139,124 @@
135
139
  "zh-CN": "命名配置",
136
140
  "en-US": "Naming settings"
137
141
  },
138
- "configAutomaticNaming": {
139
- "zh-CN": "自动命名:{value}",
140
- "en-US": "Automatic naming: {value}"
142
+ "configOn": {
143
+ "zh-CN": "开",
144
+ "en-US": "on"
141
145
  },
142
- "configManualNaming": {
143
- "zh-CN": "手动 /rename:{value}",
144
- "en-US": "Manual /rename: {value}"
146
+ "configOff": {
147
+ "zh-CN": "关",
148
+ "en-US": "off"
145
149
  },
146
- "configSessionTarget": {
147
- "zh-CN": "命名 session:{value}",
148
- "en-US": "Name session: {value}"
150
+ "configCommandSaved": {
151
+ "zh-CN": "命名配置已保存并立即生效:{path}",
152
+ "en-US": "Naming configuration saved and applied immediately: {path}"
149
153
  },
150
- "configWorkspaceTarget": {
151
- "zh-CN": "命名 workspace:{value}",
152
- "en-US": "Name workspace: {value}"
154
+ "configCommandInvalid": {
155
+ "zh-CN": "命名配置无效:{error}",
156
+ "en-US": "Invalid naming configuration: {error}"
153
157
  },
154
- "configTabTarget": {
155
- "zh-CN": "命名 tab:{value}",
156
- "en-US": "Name tab: {value}"
158
+ "panelLabelAutomaticNaming": {
159
+ "zh-CN": "自动命名",
160
+ "en-US": "Automatic naming"
157
161
  },
158
- "configMaxLength": {
159
- "zh-CN": "最大标题长度:{value}",
160
- "en-US": "Maximum title length: {value}"
162
+ "panelDescAutomaticNaming": {
163
+ "zh-CN": "首条用户消息后自动生成并应用会话标题",
164
+ "en-US": "Generate and apply a session title after the first user message"
161
165
  },
162
- "configMaxTokens": {
163
- "zh-CN": "输出预算:{value} tokens",
164
- "en-US": "Output budget: {value} tokens"
166
+ "panelLabelManualNaming": {
167
+ "zh-CN": "手动 /rename",
168
+ "en-US": "Manual /rename"
165
169
  },
166
- "configEffort": {
167
- "zh-CN": "思考档位:{value}",
168
- "en-US": "Thinking effort: {value}"
170
+ "panelDescManualNaming": {
171
+ "zh-CN": "允许用 /rename 手动命名;省略名称时综合当前分支全部用户消息生成",
172
+ "en-US": "Allow manual naming through /rename; omitting the name summarizes all user messages on the current branch"
169
173
  },
170
- "configPreferredLength": {
171
- "zh-CN": "首选标题长度:{value}",
172
- "en-US": "Preferred title length: {value}"
174
+ "panelLabelSessionTarget": {
175
+ "zh-CN": "命名 session",
176
+ "en-US": "Name session"
173
177
  },
174
- "configLanguage": {
175
- "zh-CN": "标题语言:{value}",
176
- "en-US": "Title language: {value}"
178
+ "panelDescSessionTarget": {
179
+ "zh-CN": "将生成的标题写入 Pi 会话名",
180
+ "en-US": "Write the generated title to the Pi session name"
177
181
  },
178
- "configInstructions": {
179
- "zh-CN": "标题提示:{value}",
180
- "en-US": "Title instructions: {value}"
182
+ "panelLabelWorkspaceTarget": {
183
+ "zh-CN": "命名 workspace",
184
+ "en-US": "Name workspace"
181
185
  },
182
- "configTimeout": {
183
- "zh-CN": "请求超时:{value} ms",
184
- "en-US": "Request timeout: {value} ms"
186
+ "panelDescWorkspaceTarget": {
187
+ "zh-CN": "同时修改终端工作区名称(需后端支持且目标独占)",
188
+ "en-US": "Also rename the terminal workspace (needs backend support and exclusive ownership)"
185
189
  },
186
- "configOn": {
187
- "zh-CN": "开",
188
- "en-US": "on"
190
+ "panelLabelTabTarget": {
191
+ "zh-CN": "命名 tab",
192
+ "en-US": "Name tab"
189
193
  },
190
- "configOff": {
191
- "zh-CN": "关",
192
- "en-US": "off"
194
+ "panelDescTabTarget": {
195
+ "zh-CN": "同时修改终端标签页名称(需后端支持且目标独占)",
196
+ "en-US": "Also rename the terminal tab (needs backend support and exclusive ownership)"
193
197
  },
194
- "configEmpty": {
195
- "zh-CN": "未设置",
196
- "en-US": "not set"
198
+ "panelLabelMaxLength": {
199
+ "zh-CN": "最大标题长度",
200
+ "en-US": "Maximum title length"
197
201
  },
198
- "configPresetValue": {
199
- "zh-CN": "常用值:{value}",
200
- "en-US": "Common value: {value}"
202
+ "panelDescMaxLength": {
203
+ "zh-CN": "标题允许的最多 Unicode 码点数",
204
+ "en-US": "Maximum number of Unicode code points allowed in a title"
201
205
  },
202
- "configCustom": {
203
- "zh-CN": "自定义…",
204
- "en-US": "Custom…"
206
+ "panelLabelPreferredLength": {
207
+ "zh-CN": "首选标题长度",
208
+ "en-US": "Preferred title length"
205
209
  },
206
- "configCancel": {
207
- "zh-CN": "取消",
208
- "en-US": "Cancel"
210
+ "panelDescPreferredLength": {
211
+ "zh-CN": "提示模型尽量控制在该长度以内,不得大于最大长度",
212
+ "en-US": "Ask the model to stay within this length; must not exceed the maximum length"
209
213
  },
210
- "configDone": {
211
- "zh-CN": "完成",
212
- "en-US": "Done"
214
+ "panelLabelLanguage": {
215
+ "zh-CN": "标题语言",
216
+ "en-US": "Title language"
213
217
  },
214
- "configMaxLengthInput": {
215
- "zh-CN": "最大标题长度(正整数)",
216
- "en-US": "Maximum title length (positive integer)"
218
+ "panelDescLanguage": {
219
+ "zh-CN": "auto 表示跟随消息的主要语言",
220
+ "en-US": "auto follows the dominant language of the messages"
217
221
  },
218
- "configMaxTokensInput": {
219
- "zh-CN": "输出预算(正整数,思考与标题共享)",
220
- "en-US": "Output budget (positive integer, shared by thinking and title)"
222
+ "panelLabelInstructions": {
223
+ "zh-CN": "标题提示",
224
+ "en-US": "Title instructions"
221
225
  },
222
- "configEffortInput": {
223
- "zh-CN": "思考档位",
224
- "en-US": "Thinking effort"
226
+ "panelDescInstructions": {
227
+ "zh-CN": "附加给命名模型的自定义要求,可留空",
228
+ "en-US": "Extra instructions for the naming model; can be empty"
225
229
  },
226
- "configEffortInvalid": {
227
- "zh-CN": "思考档位无效:{value};可选值:{options}",
228
- "en-US": "Invalid thinking effort: {value}; allowed: {options}"
230
+ "panelLabelTimeout": {
231
+ "zh-CN": "请求超时(毫秒)",
232
+ "en-US": "Request timeout (ms)"
229
233
  },
230
- "configPreferredLengthInput": {
231
- "zh-CN": "首选标题长度(正整数)",
232
- "en-US": "Preferred title length (positive integer)"
234
+ "panelDescTimeout": {
235
+ "zh-CN": "单次命名模型请求的超时时间",
236
+ "en-US": "Timeout for one naming model request"
233
237
  },
234
- "configLanguageInput": {
235
- "zh-CN": "标题语言",
236
- "en-US": "Title language"
238
+ "panelLabelMaxTokens": {
239
+ "zh-CN": "输出预算",
240
+ "en-US": "Output budget"
237
241
  },
238
- "configInstructionsInput": {
239
- "zh-CN": "标题提示(可留空)",
240
- "en-US": "Title instructions (can be empty)"
242
+ "panelDescMaxTokens": {
243
+ "zh-CN": "思考与标题共享的输出 token 上限",
244
+ "en-US": "Output token budget shared by thinking and the title"
241
245
  },
242
- "configTimeoutInput": {
243
- "zh-CN": "请求超时(毫秒,正整数)",
244
- "en-US": "Request timeout (positive milliseconds)"
246
+ "panelLabelEffort": {
247
+ "zh-CN": "思考档位",
248
+ "en-US": "Thinking effort"
245
249
  },
246
- "configCommandSaved": {
247
- "zh-CN": "命名配置已保存:{path}。请执行 /reload 使配置生效。",
248
- "en-US": "Naming configuration saved to {path}. Run /reload to apply it."
250
+ "panelDescEffort": {
251
+ "zh-CN": "命名请求使用的推理强度",
252
+ "en-US": "Reasoning effort used for naming requests"
249
253
  },
250
- "configCommandInvalid": {
251
- "zh-CN": "命名配置无效:{error}",
252
- "en-US": "Invalid naming configuration: {error}"
254
+ "panelInputPrompt": {
255
+ "zh-CN": "输入",
256
+ "en-US": "Input"
257
+ },
258
+ "panelValueUnset": {
259
+ "zh-CN": "未设置",
260
+ "en-US": "not set"
253
261
  }
254
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-naming",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Configurable Pi session naming and manual terminal workspace/tab renaming",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -57,19 +57,23 @@
57
57
  "workspace"
58
58
  ],
59
59
  "dependencies": {
60
- "pi-extensions-i18n": "^0.8.0",
61
- "pi-terminal-mux": "^0.6.5"
60
+ "pi-extensions-i18n": "^0.9.0",
61
+ "pi-model-request": "^0.1.1",
62
+ "pi-terminal-mux": "^0.6.7"
62
63
  },
63
64
  "peerDependencies": {
65
+ "@earendil-works/pi-ai": ">=0.80.0",
64
66
  "@earendil-works/pi-coding-agent": ">=0.80.0",
65
- "@earendil-works/pi-ai": ">=0.80.0"
67
+ "@earendil-works/pi-tui": ">=0.80.0"
66
68
  },
67
69
  "devDependencies": {
70
+ "@earendil-works/pi-ai": "0.85.1",
68
71
  "@earendil-works/pi-coding-agent": "0.85.1",
72
+ "@earendil-works/pi-tui": "0.85.1",
69
73
  "@types/node": "24.12.4",
70
- "pi-terminal-mux": "^0.6.5",
74
+ "pi-model-request": "^0.1.1",
75
+ "pi-terminal-mux": "^0.6.7",
71
76
  "tsx": "4.23.1",
72
- "typescript": "5.9.3",
73
- "@earendil-works/pi-ai": "0.85.1"
77
+ "typescript": "5.9.3"
74
78
  }
75
79
  }
@@ -0,0 +1,378 @@
1
+ /**
2
+ * pi-naming 的 TUI 配置面板。
3
+ *
4
+ * 原来用 `ctx.ui.select` 开链式菜单,一行文字里既放当前值又放「按下去会变成什么」,
5
+ * 屏幕上看不出哪项是状态、哪项是按钮。这里改用 Pi 自带 `SettingsList`:
6
+ * 左边字段名、右边当前值、选中项下方给一行说明。开关项原地切换,枚举与数值项回车
7
+ * 打开二级列表,自由文本项回车打开预填当前值的输入框。改一项立即写盘并同步运行期配置,
8
+ * Esc 关闭,全程不需要 /reload。
9
+ */
10
+
11
+ import {
12
+ getSelectListTheme,
13
+ getSettingsListTheme,
14
+ type ExtensionCommandContext,
15
+ } from "@earendil-works/pi-coding-agent";
16
+ import {
17
+ Container,
18
+ Input,
19
+ SelectList,
20
+ SettingsList,
21
+ Text,
22
+ type Component,
23
+ type SelectItem,
24
+ type SettingItem,
25
+ } from "@earendil-works/pi-tui";
26
+ import { LANGUAGE_PRESETS, MAX_LENGTH_PRESETS, MAX_TOKENS_PRESETS, PREFERRED_LENGTH_PRESETS, TIMEOUT_PRESETS, TITLE_EFFORT_LEVELS, parseConfig, type NamingConfig } from "./config.ts";
27
+ import { i18n } from "./i18n.ts";
28
+
29
+ /** 面板字段名;与配置里的字段一一对应,测试用它核对没有字段漏在面板外。 */
30
+ export const PANEL_FIELD = {
31
+ automaticNaming: "automaticNaming",
32
+ manualNaming: "manualNaming",
33
+ sessionTarget: "targets.session",
34
+ workspaceTarget: "targets.workspace",
35
+ tabTarget: "targets.tab",
36
+ maxLength: "title.maxLength",
37
+ preferredLength: "title.preferredLength",
38
+ language: "title.language",
39
+ instructions: "title.instructions",
40
+ timeoutMs: "title.timeoutMs",
41
+ maxTokens: "title.maxTokens",
42
+ effort: "title.effort",
43
+ } as const;
44
+
45
+ /** 面板字段名的联合类型。 */
46
+ export type PanelFieldId = (typeof PANEL_FIELD)[keyof typeof PANEL_FIELD];
47
+
48
+ /** 二级列表一项:label 是展示文本,value 是写回配置的值。 */
49
+ export interface PanelOption {
50
+ /** 展示文本。 */
51
+ label: string;
52
+ /** 写回配置的值。 */
53
+ value: string;
54
+ }
55
+
56
+ /** 面板一行:字段名、取值方式和文案 key。 */
57
+ interface PanelField {
58
+ /** 字段名。 */
59
+ id: PanelFieldId;
60
+ /** `toggle` 原地切换,`choice` 回车开候选列表,`text` 回车开输入框。 */
61
+ kind: "toggle" | "choice" | "text";
62
+ /** 行标题的文案 key。 */
63
+ labelKey: string;
64
+ /** 说明行的文案 key。 */
65
+ descriptionKey: string;
66
+ /** 候选列表是否需要上方过滤输入框。 */
67
+ search?: boolean;
68
+ }
69
+
70
+ /** 语言候选列表最多同时显示几行。 */
71
+ const CHOICE_MENU_MAX_VISIBLE = 8;
72
+
73
+ /** 开关两个状态在面板上承载的值,与本地化文案无关。 */
74
+ const TOGGLE_ON = "on";
75
+ const TOGGLE_OFF = "off";
76
+
77
+ /** 面板字段顺序:总开关在最前,其余按「命名目标 → 标题偏好」排列。 */
78
+ const PANEL_FIELDS: readonly PanelField[] = [
79
+ { id: PANEL_FIELD.automaticNaming, kind: "toggle", labelKey: "panelLabelAutomaticNaming", descriptionKey: "panelDescAutomaticNaming" },
80
+ { id: PANEL_FIELD.manualNaming, kind: "toggle", labelKey: "panelLabelManualNaming", descriptionKey: "panelDescManualNaming" },
81
+ { id: PANEL_FIELD.sessionTarget, kind: "toggle", labelKey: "panelLabelSessionTarget", descriptionKey: "panelDescSessionTarget" },
82
+ { id: PANEL_FIELD.workspaceTarget, kind: "toggle", labelKey: "panelLabelWorkspaceTarget", descriptionKey: "panelDescWorkspaceTarget" },
83
+ { id: PANEL_FIELD.tabTarget, kind: "toggle", labelKey: "panelLabelTabTarget", descriptionKey: "panelDescTabTarget" },
84
+ { id: PANEL_FIELD.maxLength, kind: "choice", labelKey: "panelLabelMaxLength", descriptionKey: "panelDescMaxLength" },
85
+ { id: PANEL_FIELD.preferredLength, kind: "choice", labelKey: "panelLabelPreferredLength", descriptionKey: "panelDescPreferredLength" },
86
+ { id: PANEL_FIELD.language, kind: "choice", labelKey: "panelLabelLanguage", descriptionKey: "panelDescLanguage" },
87
+ { id: PANEL_FIELD.instructions, kind: "text", labelKey: "panelLabelInstructions", descriptionKey: "panelDescInstructions" },
88
+ { id: PANEL_FIELD.timeoutMs, kind: "choice", labelKey: "panelLabelTimeout", descriptionKey: "panelDescTimeout" },
89
+ { id: PANEL_FIELD.maxTokens, kind: "choice", labelKey: "panelLabelMaxTokens", descriptionKey: "panelDescMaxTokens" },
90
+ { id: PANEL_FIELD.effort, kind: "choice", labelKey: "panelLabelEffort", descriptionKey: "panelDescEffort" },
91
+ ];
92
+
93
+ /** 面板覆盖的字段名,测试用它证明配置里没有字段被漏下。 */
94
+ export const PANEL_FIELD_IDS: readonly string[] = PANEL_FIELDS.map((field) => field.id);
95
+
96
+ /** 取本地化的开关文案。 */
97
+ export function toggleLabels(): { on: string; off: string } {
98
+ return { on: i18n.t("configOn"), off: i18n.t("configOff") };
99
+ }
100
+
101
+ /** 布尔字段的候选列表。 */
102
+ function toggleOptions(): PanelOption[] {
103
+ const labels = toggleLabels();
104
+ return [
105
+ { label: labels.on, value: TOGGLE_ON },
106
+ { label: labels.off, value: TOGGLE_OFF },
107
+ ];
108
+ }
109
+
110
+ /** 数值字段的候选列表,labels 与值同文本。 */
111
+ function numberOptions(values: readonly string[]): PanelOption[] {
112
+ return values.map((value) => ({ label: value, value }));
113
+ }
114
+
115
+ /** 思考档位的候选列表。 */
116
+ function effortOptions(): PanelOption[] {
117
+ return numberOptions(TITLE_EFFORT_LEVELS);
118
+ }
119
+
120
+ /**
121
+ * 语言字段的候选列表。
122
+ *
123
+ * 常见语言放在前面,当前值不在列表里时补进去:语言代码是自由字符串,用户可能填
124
+ * 任意 BCP-47 代码,面板不能因为候选表里没有就把已存值显示成空白。
125
+ */
126
+ function languageOptions(current: string): PanelOption[] {
127
+ const values: string[] = [...LANGUAGE_PRESETS];
128
+ if (!values.includes(current)) values.push(current);
129
+ return numberOptions(values);
130
+ }
131
+
132
+ /** 按字段名取候选列表;文本字段没有候选。 */
133
+ function fieldOptions(id: PanelFieldId, config: NamingConfig): PanelOption[] {
134
+ switch (id) {
135
+ case PANEL_FIELD.automaticNaming:
136
+ case PANEL_FIELD.manualNaming:
137
+ case PANEL_FIELD.sessionTarget:
138
+ case PANEL_FIELD.workspaceTarget:
139
+ case PANEL_FIELD.tabTarget:
140
+ return toggleOptions();
141
+ case PANEL_FIELD.maxLength:
142
+ return numberOptions(MAX_LENGTH_PRESETS);
143
+ case PANEL_FIELD.preferredLength:
144
+ return numberOptions(PREFERRED_LENGTH_PRESETS);
145
+ case PANEL_FIELD.language:
146
+ return languageOptions(config.title.language);
147
+ case PANEL_FIELD.timeoutMs:
148
+ return numberOptions(TIMEOUT_PRESETS);
149
+ case PANEL_FIELD.maxTokens:
150
+ return numberOptions(MAX_TOKENS_PRESETS);
151
+ case PANEL_FIELD.effort:
152
+ return effortOptions();
153
+ default:
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /** 按配置值取展示文本;不在候选表里就直接显示原值。 */
159
+ export function optionLabelForValue(options: readonly PanelOption[], value: string): string {
160
+ return options.find((option) => option.value === value)?.label ?? value;
161
+ }
162
+
163
+ /** 按展示文本反查写回值;找不到说明候选表变了,返回 undefined 由调用方保持不变。 */
164
+ export function optionValueFromLabel(
165
+ options: readonly PanelOption[],
166
+ label: string,
167
+ ): string | undefined {
168
+ return options.find((option) => option.label === label)?.value;
169
+ }
170
+
171
+ /** 读取一个字段当前存的值,统一转成面板上承载的字符串。 */
172
+ function readField(config: NamingConfig, id: PanelFieldId): string {
173
+ switch (id) {
174
+ case PANEL_FIELD.automaticNaming:
175
+ return config.automaticNaming ? TOGGLE_ON : TOGGLE_OFF;
176
+ case PANEL_FIELD.manualNaming:
177
+ return config.manualNaming ? TOGGLE_ON : TOGGLE_OFF;
178
+ case PANEL_FIELD.sessionTarget:
179
+ return config.targets.session ? TOGGLE_ON : TOGGLE_OFF;
180
+ case PANEL_FIELD.workspaceTarget:
181
+ return config.targets.workspace ? TOGGLE_ON : TOGGLE_OFF;
182
+ case PANEL_FIELD.tabTarget:
183
+ return config.targets.tab ? TOGGLE_ON : TOGGLE_OFF;
184
+ case PANEL_FIELD.maxLength:
185
+ return String(config.title.maxLength);
186
+ case PANEL_FIELD.preferredLength:
187
+ return String(config.title.preferredLength);
188
+ case PANEL_FIELD.language:
189
+ return config.title.language;
190
+ case PANEL_FIELD.instructions:
191
+ return config.title.instructions;
192
+ case PANEL_FIELD.timeoutMs:
193
+ return String(config.title.timeoutMs);
194
+ case PANEL_FIELD.maxTokens:
195
+ return String(config.title.maxTokens);
196
+ case PANEL_FIELD.effort:
197
+ return config.title.effort;
198
+ }
199
+ }
200
+
201
+ /** 用新值重建配置;文本字段原样写入,数值字段转成数字。 */
202
+ function writeField(config: NamingConfig, id: PanelFieldId, value: string): NamingConfig {
203
+ const title = { ...config.title };
204
+ switch (id) {
205
+ case PANEL_FIELD.automaticNaming:
206
+ return { ...config, automaticNaming: value === TOGGLE_ON };
207
+ case PANEL_FIELD.manualNaming:
208
+ return { ...config, manualNaming: value === TOGGLE_ON };
209
+ case PANEL_FIELD.sessionTarget:
210
+ return { ...config, targets: { ...config.targets, session: value === TOGGLE_ON } };
211
+ case PANEL_FIELD.workspaceTarget:
212
+ return { ...config, targets: { ...config.targets, workspace: value === TOGGLE_ON } };
213
+ case PANEL_FIELD.tabTarget:
214
+ return { ...config, targets: { ...config.targets, tab: value === TOGGLE_ON } };
215
+ case PANEL_FIELD.maxLength:
216
+ return { ...config, title: { ...title, maxLength: Number(value) } };
217
+ case PANEL_FIELD.preferredLength:
218
+ return { ...config, title: { ...title, preferredLength: Number(value) } };
219
+ case PANEL_FIELD.language:
220
+ return { ...config, title: { ...title, language: value } };
221
+ case PANEL_FIELD.instructions:
222
+ return { ...config, title: { ...title, instructions: value } };
223
+ case PANEL_FIELD.timeoutMs:
224
+ return { ...config, title: { ...title, timeoutMs: Number(value) } };
225
+ case PANEL_FIELD.maxTokens:
226
+ return { ...config, title: { ...title, maxTokens: Number(value) } };
227
+ case PANEL_FIELD.effort:
228
+ return { ...config, title: { ...title, effort: value as NamingConfig["title"]["effort"] } };
229
+ }
230
+ }
231
+
232
+ /** 把候选列表转成 `SelectList` 的条目;展示文本同时是回传值。 */
233
+ function toSelectItems(options: readonly PanelOption[]): SelectItem[] {
234
+ return options.map((option) => ({ value: option.label, label: option.label }));
235
+ }
236
+
237
+ /** 按展示文本建二级选择列表:Enter 选定并回传,Esc 不改动直接返回。 */
238
+ function createChoiceSubmenu(
239
+ options: readonly PanelOption[],
240
+ currentLabel: string,
241
+ done: (selectedLabel?: string) => void,
242
+ ): Component {
243
+ const items = toSelectItems(options);
244
+ const selectList = new SelectList(
245
+ items,
246
+ Math.max(1, Math.min(items.length, CHOICE_MENU_MAX_VISIBLE)),
247
+ getSelectListTheme(),
248
+ );
249
+ const index = options.findIndex((option) => option.label === currentLabel);
250
+ if (index >= 0) selectList.setSelectedIndex(index);
251
+ selectList.onSelect = (item) => done(item.value);
252
+ selectList.onCancel = () => done(undefined);
253
+ return selectList;
254
+ }
255
+
256
+ /** 自由文本二级菜单:预填当前值的 `Input`。 */
257
+ function createTextSubmenu(
258
+ value: string,
259
+ placeholder: string,
260
+ done: (value?: string) => void,
261
+ ): Component {
262
+ const input = new Input({ prompt: `${i18n.t("panelInputPrompt")} `, placeholder });
263
+ input.setValue(value);
264
+ input.focused = true;
265
+ input.onSubmit = (submitted) => done(submitted);
266
+ input.onEscape = () => done(undefined);
267
+ return {
268
+ /** 一行可编辑文本。 */
269
+ render: (width) => input.render(width),
270
+ /** 无本地缓存。 */
271
+ invalidate: () => input.invalidate(),
272
+ /** 所有按键都归输入框。 */
273
+ handleInput: (data) => input.handleInput(data),
274
+ };
275
+ }
276
+
277
+ /** 把当前配置转成 Pi 设置列表的条目。 */
278
+ export function buildSettingItems(config: NamingConfig): SettingItem[] {
279
+ return PANEL_FIELDS.map((field) => {
280
+ const label = i18n.t(field.labelKey);
281
+ const description = i18n.t(field.descriptionKey);
282
+ const options = fieldOptions(field.id, config);
283
+ const stored = readField(config, field.id);
284
+ if (field.kind === "toggle") {
285
+ return { id: field.id, label, description, currentValue: optionLabelForValue(options, stored), values: options.map((option) => option.label) };
286
+ }
287
+ if (field.kind === "choice") {
288
+ return {
289
+ id: field.id,
290
+ label,
291
+ description,
292
+ currentValue: optionLabelForValue(options, stored),
293
+ // 二级列表接管 Enter,避免在候选之间反复原地循环。
294
+ submenu: (currentValue, done) => createChoiceSubmenu(options, currentValue, done),
295
+ };
296
+ }
297
+ return {
298
+ id: field.id,
299
+ label,
300
+ description,
301
+ currentValue: stored || i18n.t("panelValueUnset"),
302
+ submenu: (_currentValue, done) => createTextSubmenu(stored, description, done),
303
+ };
304
+ });
305
+ }
306
+
307
+ /**
308
+ * 把面板返回的展示文本写回配置;无法识别时返回 undefined,由调用方保持不变。
309
+ *
310
+ * 结果统一过一遍 `parseConfig`,保证写盘的值仍在合法区间内(候选表与校验规则不会各走各的)。
311
+ */
312
+ export function applyPanelChange(
313
+ config: NamingConfig,
314
+ id: string,
315
+ value: string,
316
+ ): NamingConfig | undefined {
317
+ const field = PANEL_FIELDS.find((candidate) => candidate.id === id);
318
+ if (!field) return undefined;
319
+ if (field.kind === "text") {
320
+ try {
321
+ return parseConfig(writeField(config, field.id, value));
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ const stored = optionValueFromLabel(fieldOptions(field.id, config), value);
327
+ if (stored === undefined) return undefined;
328
+ try {
329
+ return parseConfig(writeField(config, field.id, stored));
330
+ } catch {
331
+ return undefined;
332
+ }
333
+ }
334
+
335
+ /** 面板与宿主之间的接口;配置读写与生效动作都由入口注入。 */
336
+ export interface ConfigPanelHandlers {
337
+ /** 读取当前配置。 */
338
+ getConfig(): NamingConfig;
339
+ /** 某一项被改动后调用,由入口负责保存并同步运行期配置。 */
340
+ onChange(config: NamingConfig): void;
341
+ }
342
+
343
+ /** 打开配置面板;用户改动即时生效,Esc 关闭。 */
344
+ export async function openConfigPanel(
345
+ ctx: ExtensionCommandContext,
346
+ handlers: ConfigPanelHandlers,
347
+ ): Promise<void> {
348
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
349
+ const container = new Container();
350
+ container.addChild(new Text(theme.fg("accent", theme.bold(i18n.t("configMenuTitle"))), 1, 1));
351
+
352
+ const settingsList = new SettingsList(
353
+ buildSettingItems(handlers.getConfig()),
354
+ PANEL_FIELDS.length,
355
+ getSettingsListTheme(),
356
+ (id, newValue) => {
357
+ const next = applyPanelChange(handlers.getConfig(), id, newValue);
358
+ if (next) handlers.onChange(next);
359
+ },
360
+ () => done(undefined),
361
+ );
362
+ container.addChild(settingsList);
363
+
364
+ // ctx.ui.custom 要求返回 Component:整体布局交给 Container,键盘转给设置列表,
365
+ // 列表改了数据后必须 requestRender,否则画面停在上一帧。
366
+ return {
367
+ /** 面板整体按 Container 布局渲染。 */
368
+ render: (width) => container.render(width),
369
+ /** 无本地缓存,交给 Container 清理。 */
370
+ invalidate: () => container.invalidate(),
371
+ /** 键盘输入交给设置列表,并触发一次重绘。 */
372
+ handleInput: (data) => {
373
+ settingsList.handleInput(data);
374
+ tui.requestRender();
375
+ },
376
+ };
377
+ });
378
+ }
package/src/config.ts CHANGED
@@ -12,6 +12,13 @@ const MAX_TIMER_MS = 2_147_483_647;
12
12
  /** 思考档位:与 pi-ai 的 ThinkingLevel 一致,优先级由低到高。 */
13
13
  export const TITLE_EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
14
14
 
15
+ /** 标题长度与超时等数值字段的候选预设;配置面板与命令行共用同一份。 */
16
+ export const MAX_LENGTH_PRESETS = ["15", "30", "60"] as const;
17
+ export const PREFERRED_LENGTH_PRESETS = ["10", "20", "40"] as const;
18
+ export const LANGUAGE_PRESETS = ["auto", "中文", "English", "日本語"] as const;
19
+ export const TIMEOUT_PRESETS = ["5000", "10000", "30000"] as const;
20
+ export const MAX_TOKENS_PRESETS = ["1024", "2048", "4096"] as const;
21
+
15
22
  export type TitleEffort = typeof TITLE_EFFORT_LEVELS[number];
16
23
 
17
24
  /** 标题请求的默认输出预算;推理模型的思考与标题共享该额度。 */
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { TerminalRenameOutcome, TerminalRenameTarget, ResolveRenameOptions } from "pi-terminal-mux";
3
- import { configPath, isTitleEffort, loadConfig, parseConfig, saveConfig, TITLE_EFFORT_LEVELS, type NamingConfig } from "./config.ts";
3
+ import { configPath, loadConfig, parseConfig, saveConfig, type NamingConfig } from "./config.ts";
4
+ import { openConfigPanel } from "./config-panel.ts";
4
5
  import { i18n } from "./i18n.ts";
5
6
  import { NOTICE_TAG_COLOR, installNoticeRenderer, notifyWithSource, type NoticeColor, type NoticeSource } from "pi-extensions-i18n";
6
7
 
@@ -15,25 +16,6 @@ import { getCurrentSessionUserMessages, requestSessionNameWithTimeout, type Sess
15
16
  const RENAME_COMMAND = "rename";
16
17
  const CONFIG_COMMAND_ALIASES = ["config:naming", "naming-config", "pi-naming-config"] as const;
17
18
  const CONFIG_RESET_COMMAND = "reset";
18
- const CONFIG_OPTION = {
19
- automaticNaming: 0,
20
- manualNaming: 1,
21
- sessionTarget: 2,
22
- workspaceTarget: 3,
23
- tabTarget: 4,
24
- maxLength: 5,
25
- preferredLength: 6,
26
- language: 7,
27
- instructions: 8,
28
- timeout: 9,
29
- maxTokens: 10,
30
- effort: 11,
31
- } as const;
32
- const MAX_LENGTH_PRESETS = ["15", "30", "60"] as const;
33
- const PREFERRED_LENGTH_PRESETS = ["10", "20", "40"] as const;
34
- const LANGUAGE_PRESETS = ["auto", "中文", "English", "日本語"] as const;
35
- const TIMEOUT_PRESETS = ["5000", "10000", "30000"] as const;
36
- const MAX_TOKENS_PRESETS = ["1024", "2048", "4096"] as const;
37
19
  const MESSAGE_TYPE = "pi-naming";
38
20
 
39
21
  export interface TerminalNamingAdapter {
@@ -65,10 +47,21 @@ export interface NamingConfigStore {
65
47
  path(): string;
66
48
  }
67
49
 
68
- /** 注册配置命令,通过 TUI 菜单和输入框修改命名配置。 */
50
+ /** 运行期持有的命名配置;配置面板改动后直接改这里的值,不用重启会话。 */
51
+ export interface NamingRuntime {
52
+ config: NamingConfig;
53
+ }
54
+
55
+ /** 把新配置写进运行期持有者;没有运行期持有者(如只读命令场景)时不动任何状态。 */
56
+ function applyConfig(runtime: NamingRuntime | undefined, next: NamingConfig): void {
57
+ if (runtime) runtime.config = next;
58
+ }
59
+
60
+ /** 注册配置命令,通过 TUI 面板修改命名配置。 */
69
61
  export function registerNamingConfigCommand(
70
62
  pi: ExtensionAPI,
71
63
  store: NamingConfigStore = { load: loadConfig, save: saveConfig, path: configPath },
64
+ runtime?: NamingRuntime,
72
65
  ): void {
73
66
  const command = {
74
67
  description: i18n.t("configCommandDescription"),
@@ -81,7 +74,9 @@ export function registerNamingConfigCommand(
81
74
  }
82
75
  if (argument === CONFIG_RESET_COMMAND) {
83
76
  try {
84
- store.save(parseConfig({}));
77
+ const defaults = parseConfig({});
78
+ store.save(defaults);
79
+ applyConfig(runtime, defaults);
85
80
  report(pi, ctx, {
86
81
  message: i18n.t("configCommandSaved", { path: store.path() }),
87
82
  level: "info",
@@ -104,132 +99,18 @@ export function registerNamingConfigCommand(
104
99
  return;
105
100
  }
106
101
 
107
- /** Saves one validated menu change and reports its result. */
108
- const save = (next: NamingConfig): boolean => {
102
+ /** 面板里改一项:先存盘、再同步运行期配置;失败只报错,不静默丢失界面上的改动。 */
103
+ const applyPanelChange = (next: NamingConfig): void => {
109
104
  try {
110
105
  store.save(next);
111
106
  config = next;
112
- report(pi, ctx, { message: i18n.t("configCommandSaved", { path: store.path() }), level: "info" });
113
- return true;
107
+ applyConfig(runtime, next);
114
108
  } catch (error) {
115
109
  report(pi, ctx, { message: i18n.t("configCommandInvalid", { error: errorMessage(error) }), level: "error" });
116
- return false;
117
110
  }
118
111
  };
119
- /** Formats a boolean setting for the localized menu label. */
120
- const toggle = (value: boolean): string => value ? i18n.t("configOn") : i18n.t("configOff");
121
- /** Opens one text input for a scalar title setting. */
122
- const editText = async (title: string, current: string): Promise<string | undefined> =>
123
- ctx.ui.input(title, current);
124
-
125
- /** Opens a choice list for common values and falls back to text only for custom values. */
126
- const chooseSettingValue = async (
127
- title: string,
128
- current: string,
129
- options: readonly string[],
130
- ): Promise<string | undefined> => {
131
- const customChoice = i18n.t("configCustom");
132
- const cancelChoice = i18n.t("configCancel");
133
- const choices = [
134
- ...options.map((value) => i18n.t("configPresetValue", { value })),
135
- customChoice,
136
- cancelChoice,
137
- ];
138
- const selected = await ctx.ui.select(title, choices);
139
- if (selected === undefined || selected === cancelChoice) return undefined;
140
- if (selected === customChoice) return ctx.ui.input(title, current);
141
- const index = choices.indexOf(selected);
142
- return index >= 0 && index < options.length ? options[index] : undefined;
143
- };
144
112
 
145
- while (true) {
146
- const doneChoice = i18n.t("configDone");
147
- const choices = [
148
- i18n.t("configAutomaticNaming", { value: toggle(config.automaticNaming) }),
149
- i18n.t("configManualNaming", { value: toggle(config.manualNaming) }),
150
- i18n.t("configSessionTarget", { value: toggle(config.targets.session) }),
151
- i18n.t("configWorkspaceTarget", { value: toggle(config.targets.workspace) }),
152
- i18n.t("configTabTarget", { value: toggle(config.targets.tab) }),
153
- i18n.t("configMaxLength", { value: config.title.maxLength }),
154
- i18n.t("configPreferredLength", { value: config.title.preferredLength }),
155
- i18n.t("configLanguage", { value: config.title.language }),
156
- i18n.t("configInstructions", { value: config.title.instructions || i18n.t("configEmpty") }),
157
- i18n.t("configTimeout", { value: config.title.timeoutMs }),
158
- i18n.t("configMaxTokens", { value: config.title.maxTokens }),
159
- i18n.t("configEffort", { value: config.title.effort }),
160
- doneChoice,
161
- ];
162
- const selected = await ctx.ui.select(i18n.t("configMenuTitle"), choices);
163
- if (selected === undefined || selected === doneChoice) return;
164
- const selectedIndex = choices.indexOf(selected);
165
- let next: NamingConfig | undefined;
166
- if (selectedIndex === CONFIG_OPTION.automaticNaming) {
167
- next = parseConfig({ ...config, automaticNaming: !config.automaticNaming });
168
- } else if (selectedIndex === CONFIG_OPTION.manualNaming) {
169
- next = parseConfig({ ...config, manualNaming: !config.manualNaming });
170
- } else if (selectedIndex === CONFIG_OPTION.sessionTarget) {
171
- next = parseConfig({ ...config, targets: { ...config.targets, session: !config.targets.session } });
172
- } else if (selectedIndex === CONFIG_OPTION.workspaceTarget) {
173
- next = parseConfig({ ...config, targets: { ...config.targets, workspace: !config.targets.workspace } });
174
- } else if (selectedIndex === CONFIG_OPTION.tabTarget) {
175
- next = parseConfig({ ...config, targets: { ...config.targets, tab: !config.targets.tab } });
176
- } else {
177
- let inputTitle = i18n.t("configTimeoutInput");
178
- let inputValue = String(config.title.timeoutMs);
179
- let options: readonly string[] = TIMEOUT_PRESETS;
180
- if (selectedIndex === CONFIG_OPTION.maxLength) {
181
- inputTitle = i18n.t("configMaxLengthInput");
182
- inputValue = String(config.title.maxLength);
183
- options = MAX_LENGTH_PRESETS;
184
- } else if (selectedIndex === CONFIG_OPTION.preferredLength) {
185
- inputTitle = i18n.t("configPreferredLengthInput");
186
- inputValue = String(config.title.preferredLength);
187
- options = PREFERRED_LENGTH_PRESETS;
188
- } else if (selectedIndex === CONFIG_OPTION.language) {
189
- inputTitle = i18n.t("configLanguageInput");
190
- inputValue = config.title.language;
191
- options = LANGUAGE_PRESETS;
192
- } else if (selectedIndex === CONFIG_OPTION.instructions) {
193
- inputTitle = i18n.t("configInstructionsInput");
194
- inputValue = config.title.instructions;
195
- options = [];
196
- } else if (selectedIndex === CONFIG_OPTION.maxTokens) {
197
- inputTitle = i18n.t("configMaxTokensInput");
198
- inputValue = String(config.title.maxTokens);
199
- options = MAX_TOKENS_PRESETS;
200
- } else if (selectedIndex === CONFIG_OPTION.effort) {
201
- inputTitle = i18n.t("configEffortInput");
202
- inputValue = config.title.effort;
203
- options = TITLE_EFFORT_LEVELS;
204
- }
205
- const input = selectedIndex === CONFIG_OPTION.instructions
206
- ? await ctx.ui.input(inputTitle, inputValue)
207
- : await chooseSettingValue(inputTitle, inputValue, options);
208
- if (input === undefined) continue;
209
- const title = { ...config.title };
210
- if (selectedIndex === CONFIG_OPTION.maxLength) title.maxLength = Number(input.trim());
211
- else if (selectedIndex === CONFIG_OPTION.preferredLength) title.preferredLength = Number(input.trim());
212
- else if (selectedIndex === CONFIG_OPTION.language) title.language = input;
213
- else if (selectedIndex === CONFIG_OPTION.instructions) title.instructions = input;
214
- else if (selectedIndex === CONFIG_OPTION.maxTokens) title.maxTokens = Number(input.trim());
215
- else if (selectedIndex === CONFIG_OPTION.effort) {
216
- // 自定义输入可能不是受支持的档位;明确报错并重开菜单,不静默丢弃也不落到其它字段。
217
- if (!isTitleEffort(input)) {
218
- report(pi, ctx, {
219
- message: i18n.t("configEffortInvalid", { value: input, options: TITLE_EFFORT_LEVELS.join(", ") }),
220
- level: "error",
221
- });
222
- continue;
223
- }
224
- title.effort = input;
225
- } else title.timeoutMs = Number(input.trim());
226
- try { next = parseConfig({ ...config, title }); }
227
- catch (error) {
228
- report(pi, ctx, { message: i18n.t("configCommandInvalid", { error: errorMessage(error) }), level: "error" });
229
- }
230
- }
231
- if (next) save(next);
232
- }
113
+ await openConfigPanel(ctx, { getConfig: () => config, onChange: applyPanelChange });
233
114
  },
234
115
  };
235
116
  for (const name of CONFIG_COMMAND_ALIASES) pi.registerCommand(name, command);
@@ -239,15 +120,17 @@ export function registerNamingConfigCommand(
239
120
  export async function registerNaming(
240
121
  pi: ExtensionAPI,
241
122
  config: NamingConfig,
242
- dependencies: { requestName?: SessionNameRequester; loadTerminal?: () => Promise<TerminalNamingAdapter> } = {},
123
+ dependencies: { requestName?: SessionNameRequester; loadTerminal?: () => Promise<TerminalNamingAdapter>; runtime?: NamingRuntime } = {},
243
124
  ): Promise<void> {
244
- const { requestName, loadTerminal = loadTerminalAdapter } = dependencies;
245
- if ((!config.automaticNaming && !config.manualNaming) || !Object.values(config.targets).some(Boolean)) return;
246
- let terminal: TerminalNamingAdapter | undefined;
247
- let terminalLoadError: unknown;
248
- if (config.targets.workspace || config.targets.tab) {
249
- try { terminal = await loadTerminal(); } catch (error) { terminalLoadError = error; }
250
- }
125
+ const { requestName, loadTerminal = loadTerminalAdapter, runtime } = dependencies;
126
+ // 运行期配置随时可能被配置面板改写,所以每次使用都读一次,不缓存快照。
127
+ const current = (): NamingConfig => runtime?.config ?? config;
128
+ // 入口常驻注册,开关一律在调用时现取:面板里把 automaticNaming / manualNaming / targets
129
+ // 打开后下一次输入或 /rename 就生效。注册与否在加载时定死的话,改完必须 /reload。
130
+ // 终端能力按需加载:面板里把 workspace/tab 打开后,下一次命名就会去加载,不用 reload。
131
+ let terminalPromise: Promise<TerminalNamingAdapter> | undefined;
132
+ /** 取终端适配器(只加载一次);加载失败按调用方处理。 */
133
+ const getTerminal = (): Promise<TerminalNamingAdapter> => (terminalPromise ??= loadTerminal());
251
134
  let generation = 0;
252
135
  let request = 0;
253
136
  let eligible = false;
@@ -257,31 +140,37 @@ export async function registerNaming(
257
140
  generation++;
258
141
  eligible = !pi.getSessionName() && getCurrentSessionUserMessages(ctx).length === 0;
259
142
  attempted = false;
260
- if (terminalLoadError !== undefined) {
261
- report(pi, ctx, { message: i18n.t("terminalNamingFailed", { error: errorMessage(terminalLoadError) }), level: "warning" });
262
- }
263
143
  });
264
144
  pi.on("session_shutdown", () => { generation++; eligible = false; });
265
145
  pi.on("session_tree", () => { generation++; eligible = false; });
266
146
 
267
147
  /** 先捕获终端身份,再生成标题;任一新请求或会话切换都会使旧结果失效。 */
268
148
  async function rename(args: string, ctx: ExtensionContext, automatic: boolean): Promise<void> {
149
+ const liveTargets = current().targets;
150
+ // 所有目标都关着时不做任何事:既不改名,也不白花一次模型调用。
151
+ if (!Object.values(liveTargets).some(Boolean)) {
152
+ report(pi, ctx, { message: i18n.t("namingNoTargets"), level: "warning" });
153
+ return;
154
+ }
269
155
  const currentGeneration = generation;
270
156
  const currentRequest = ++request;
271
157
  // 新请求和 session 生命周期变化都会使当前请求失效。
272
158
  const isCurrent = () => generation === currentGeneration && request === currentRequest;
273
159
  let targets: TerminalRenameOutcome[] = [];
274
160
  let resolutionError: unknown;
275
- if (terminal) {
276
- try { targets = terminal.resolve({ tab: config.targets.tab, workspace: config.targets.workspace }); }
277
- catch (error) { resolutionError = error; }
161
+ // 只有开了 workspace/tab 目标才需要终端;适配器不提前加载,面板打开开关后一样能用。
162
+ if (liveTargets.workspace || liveTargets.tab) {
163
+ try {
164
+ const terminal = await getTerminal();
165
+ targets = terminal.resolve({ tab: liveTargets.tab, workspace: liveTargets.workspace });
166
+ } catch (error) { resolutionError = error; }
278
167
  }
279
168
  let label = automatic ? "" : args.trim();
280
169
  if (!label) {
281
170
  try {
282
171
  label = await requestSessionNameWithTimeout({
283
172
  userMessages: automatic ? [args] : getCurrentSessionUserMessages(ctx),
284
- ctx, requestName, title: config.title,
173
+ ctx, requestName, title: current().title,
285
174
  });
286
175
  } catch (error) {
287
176
  if (isCurrent()) report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" });
@@ -291,7 +180,7 @@ export async function registerNaming(
291
180
  if (!isCurrent() || (automatic && pi.getSessionName())) return;
292
181
 
293
182
  const renamed: string[] = [];
294
- if (config.targets.session) {
183
+ if (current().targets.session) {
295
184
  try { pi.setSessionName(label); renamed.push(i18n.t("piSessionTarget")); }
296
185
  catch (error) { report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" }); }
297
186
  }
@@ -300,8 +189,8 @@ export async function registerNaming(
300
189
  }
301
190
  for (const target of targets) {
302
191
  let result = target;
303
- if (target.status === "ready" && terminal) {
304
- try { result = terminal.rename(target.reference, label); }
192
+ if (target.status === "ready") {
193
+ try { result = (await getTerminal()).rename(target.reference, label); }
305
194
  catch (error) { result = { status: "failed", operation: target.reference.operation, error: errorMessage(error) }; }
306
195
  }
307
196
  if (result.status === "renamed") {
@@ -320,39 +209,46 @@ export async function registerNaming(
320
209
  }
321
210
  }
322
211
 
323
- if (config.manualNaming) {
324
- pi.registerCommand(RENAME_COMMAND, {
325
- description: i18n.t("renameDescription"),
326
- getArgumentCompletions: () => null,
327
- handler: async (args, ctx) => { await rename(args, ctx, false); },
328
- });
329
- }
330
- if (config.automaticNaming) {
331
- pi.on("input", (event, ctx) => {
332
- if (!eligible || attempted || event.source === "extension" || pi.getSessionName()) return;
333
- const text = event.text.trim();
334
- if (!text) return;
335
- attempted = true;
336
- const inputGeneration = generation;
337
- void rename(text, ctx, true).catch((error: unknown) => {
338
- if (generation !== inputGeneration) return;
339
- report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" });
340
- });
212
+ // /rename 常驻注册;manualNaming 关掉时报告原因,而不是让命令凭空消失(Pi 没有内置 /rename 可回退)。
213
+ pi.registerCommand(RENAME_COMMAND, {
214
+ description: i18n.t("renameDescription"),
215
+ getArgumentCompletions: () => null,
216
+ handler: async (args, ctx) => {
217
+ if (!current().manualNaming) {
218
+ report(pi, ctx, { message: i18n.t("namingManualDisabled"), level: "warning" });
219
+ return;
220
+ }
221
+ await rename(args, ctx, false);
222
+ },
223
+ });
224
+ pi.on("input", (event, ctx) => {
225
+ if (!current().automaticNaming) return;
226
+ if (!eligible || attempted || event.source === "extension" || pi.getSessionName()) return;
227
+ const text = event.text.trim();
228
+ if (!text) return;
229
+ attempted = true;
230
+ const inputGeneration = generation;
231
+ void rename(text, ctx, true).catch((error: unknown) => {
232
+ if (generation !== inputGeneration) return;
233
+ report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" });
341
234
  });
342
- }
235
+ });
343
236
  }
344
237
 
345
238
  /** 配置错误在 session_start 报告,不注册不完整的命名功能。 */
346
239
  export default async function namingExtension(pi: ExtensionAPI): Promise<void> {
347
240
  // 提示画成会话区里的带底色消息块;渲染器在本包这个模块实例里注册一次。
348
241
  installNoticeRenderer(pi);
349
- registerNamingConfigCommand(pi);
350
242
  let config: NamingConfig;
351
243
  try { config = loadConfig(); }
352
244
  catch (error) {
245
+ registerNamingConfigCommand(pi);
353
246
  pi.on("session_start", (_event, ctx) => report(pi, ctx, { message:
354
247
  i18n.t("namingConfigFailed", { error: errorMessage(error) }), level: "warning" }));
355
248
  return;
356
249
  }
357
- await registerNaming(pi, config);
250
+ // 面板改一项就改这里,后续命名请求读到的就是新值,不用 /reload。
251
+ const runtime: NamingRuntime = { config };
252
+ registerNamingConfigCommand(pi, undefined, runtime);
253
+ await registerNaming(pi, config, { runtime });
358
254
  }
@@ -3,11 +3,12 @@ import type {
3
3
  ExtensionContext,
4
4
  SessionEntry,
5
5
  } from "@earendil-works/pi-coding-agent";
6
+ import { createModelRequester } from "pi-model-request";
6
7
  import { i18n } from "./i18n.ts";
7
8
  import { DEFAULT_TITLE_CONFIG, type TitleConfig } from "./config.ts";
8
9
 
9
10
  /** 生成标题所需的最小 session 上下文;终端消费者可复用该契约。 */
10
- export type SessionNameContext = Pick<ExtensionContext, "model" | "modelRegistry">;
11
+ export type SessionNameContext = Pick<ExtensionContext, "model" | "modelRegistry" | "sessionManager">;
11
12
 
12
13
  /** 可注入的标题 completion,便于终端消费者和测试复用同一套请求逻辑。 */
13
14
  export type SessionNameCompletion = (...args: Parameters<typeof completeSimple>) => ReturnType<typeof completeSimple>;
@@ -147,12 +148,13 @@ export async function requestSessionName({
147
148
  throw new Error(i18n.t("sessionNameNoModel"));
148
149
  }
149
150
 
150
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
151
- if (auth.ok === false) {
152
- throw new Error(i18n.t("sessionNameAuthFailed", { error: auth.error }));
153
- }
151
+ // 命名请求由扩展自己发出:鉴权与 provider 会话头交给共享请求器,与 Pi 核心行为一致。
152
+ const request = createModelRequester(ctx, {
153
+ base: completion,
154
+ authError: (error) => new Error(i18n.t("sessionNameAuthFailed", { error })),
155
+ });
154
156
 
155
- const response = await completion(
157
+ const response = await request(
156
158
  model,
157
159
  {
158
160
  systemPrompt: [
@@ -171,9 +173,6 @@ export async function requestSessionName({
171
173
  ],
172
174
  },
173
175
  {
174
- apiKey: auth.apiKey,
175
- headers: auth.headers,
176
- env: auth.env,
177
176
  maxTokens: resolveTitleMaxTokens(model, title.maxTokens),
178
177
  // 命名不需要高强度推理;档位可配置,降低思考占用预算的波动。
179
178
  reasoning: title.effort,