pi-naming 0.5.3 → 0.7.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,12 +10,12 @@ 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.
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.
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
+ - By default, model-based 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.
21
21
 
@@ -33,6 +33,7 @@ File: `<pi-agent-dir>/extensions/pi-naming/config.json`; respects `PI_CODING_AGE
33
33
  "tab": true
34
34
  },
35
35
  "title": {
36
+ "usePromptAsTitle": false,
36
37
  "maxLength": 15,
37
38
  "preferredLength": 10,
38
39
  "language": "auto",
@@ -48,6 +49,7 @@ File: `<pi-agent-dir>/extensions/pi-naming/config.json`; respects `PI_CODING_AGE
48
49
 
49
50
  | Title field | Default | Meaning |
50
51
  | --- | --- | --- |
52
+ | `usePromptAsTitle` | `false` | Use a shortened literal first prompt and current directory name, without calling a model |
51
53
  | `maxLength` | `15` | Maximum generated Unicode code points; longer output is truncated |
52
54
  | `preferredLength` | `10` | Preferred length requested from the model; must not exceed the maximum |
53
55
  | `language` | `"auto"` | Dominant message language, or a language such as `English` or `日本語` |
@@ -62,6 +64,10 @@ Lengths, timeout and output budget must be positive safe integers; timeout must
62
64
 
63
65
  For longer English titles use `maxLength: 60`, `preferredLength: 40`, `language: "English"`. Model and authentication come from Pi's current selection; title language is independent of UI language.
64
66
 
67
+ Set `title.usePromptAsTitle: true` (or toggle **Use prompt as title** in `/config:naming`) for deterministic naming. Automatic naming uses the triggering prompt; `/rename` without a name uses the first nonempty user message on the current branch. The format is `directory: prompt`, for example `project: Fix the login timeout`. Whitespace is collapsed without stripping literal quotes, Markdown, or `title:` prefixes. The full title respects `maxLength` in Unicode code points: the directory uses at most half the available space, leaving room for the prompt; limits below 5 show only the prompt. Increase `maxLength` to preserve longer directory names and prompts.
68
+
69
+ This mode needs no model or credentials. Model-specific preferences (`preferredLength`, `language`, `instructions`, `timeoutMs`, `maxTokens`, `effort`) apply only to model-based naming. Explicit `/rename My title` still uses the supplied name verbatim. Changes in the panel take effect on the next naming operation.
70
+
65
71
  ## Standalone and composed use
66
72
 
67
73
  - `pi-terminal-mux` is an automatically installed library dependency, not a separate extension to enable. It resolves terminal targets and executes renames, without generating titles.
package/README.zh-CN.md CHANGED
@@ -10,12 +10,12 @@
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` 恢复默认值。
18
- - 手动生成时综合当前分支的全部用户输入,围绕主要任务命名;后续明确纠正或目标变更优先,“继续”“验证一下”“提交”等流程性跟进不应盖过主题。不会读取其他分支、assistant 回复或工具输出。自动命名仍仅在首条输入时尝试一次,不随每轮对话更新。
17
+ - **`/config:naming`**:打开 TUI 配置面板(Pi 自带 `SettingsList`);开关原地切换,枚举与数值回车开二级列表,自由文本回车开预填输入框。改一项立即保存并生效,不需要 `/reload`。输入 `reset` 恢复默认值。
18
+ - 默认的模型命名模式在手动生成时综合当前分支的全部用户输入,围绕主要任务命名;后续明确纠正或目标变更优先,“继续”“验证一下”“提交”等流程性跟进不应盖过主题。不会读取其他分支、assistant 回复或工具输出。自动命名仍仅在首条输入时尝试一次,不随每轮对话更新。
19
19
  - 不覆盖已有名称的自动命名结果。手动命令优先于尚未完成的旧请求;会话切换或 reload 后丢弃旧结果和错误。
20
20
  - 各目标独立执行。终端不支持、被禁用、缺少归属信息或改名失败均明确报告,不阻止 session 和其他可用目标改名。无交互 UI 时通过 Pi 消息报告。
21
21
 
@@ -33,6 +33,7 @@ pi install npm:pi-naming
33
33
  "tab": true
34
34
  },
35
35
  "title": {
36
+ "usePromptAsTitle": false,
36
37
  "maxLength": 15,
37
38
  "preferredLength": 10,
38
39
  "language": "auto",
@@ -48,6 +49,7 @@ pi install npm:pi-naming
48
49
 
49
50
  | 标题字段 | 默认值 | 含义 |
50
51
  | --- | --- | --- |
52
+ | `usePromptAsTitle` | `false` | 用首条提示词的字面文本和当前目录名生成标题,不调用模型 |
51
53
  | `maxLength` | `15` | 生成标题最大 Unicode 码点数,超出时截断 |
52
54
  | `preferredLength` | `10` | 提示模型优先采用的长度,不得超过最大长度 |
53
55
  | `language` | `"auto"` | 跟随消息主要语言,或指定 `English`、`日本語` 等 |
@@ -62,6 +64,10 @@ pi install npm:pi-naming
62
64
 
63
65
  例如较长英文标题:`maxLength: 60`、`preferredLength: 40`、`language: "English"`。模型和鉴权直接复用 Pi 当前选择;UI 语言与标题语言独立。
64
66
 
67
+ 设置 `title.usePromptAsTitle: true`,或在 `/config:naming` 中打开「使用提示词命名」,即可使用确定性命名。自动命名使用触发它的提示词;不带名称的 `/rename` 使用当前分支首条非空用户消息。格式为 `目录名: 提示词`,例如 `project: 修复登录超时`。空白会合并为单个空格,保留字面引号、Markdown 和 `title:` 前缀。整个标题仍受 `maxLength` 的 Unicode 码点数限制:目录名最多占可用空间的一半,为提示词保留空间;上限小于 5 时只显示提示词。需要保留更长的目录名和提示词时可调大 `maxLength`。
68
+
69
+ 此模式不需要模型或鉴权。模型相关偏好(`preferredLength`、`language`、`instructions`、`timeoutMs`、`maxTokens`、`effort`)仅作用于模型命名。显式 `/rename 自定义标题` 仍直接使用输入名称。面板改动在下一次命名时立即生效。
70
+
65
71
  ## 独立使用与组合
66
72
 
67
73
  - `pi-terminal-mux` 是自动安装的库依赖,无需额外启用扩展;负责目标探测与终端执行,不负责生成标题。
package/SKILL.md CHANGED
@@ -5,12 +5,13 @@ 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.
14
+ - `title.usePromptAsTitle` 默认 false;打开后用首条非空提示词与当前目录名生成 `目录名: 提示词`,自动和不带名称的 `/rename` 都不调用模型,也无需鉴权。整个标题受 `maxLength` 限制,目录最多占可用空间一半,上限小于 5 时仅保留提示词;其他模型偏好不适用。Defaults to false; enable it to use `directory: first prompt` without a model or credentials. The full title respects `maxLength`, reserving at least half the space for the prompt; limits below 5 omit the directory. Model-only preferences do not apply.
14
15
  - 无终端也可命名 session;终端失败与跳过必须报告。普通会话明确目标路径不受旧 `PI_SUBAGENT_RENAME_*` 变量影响;关闭 target 即使变量为 `1` 也不执行。Session naming works without a terminal; report terminal failures and skips. The ordinary-session explicit-target path ignores legacy `PI_SUBAGENT_RENAME_*` variables; a disabled target never runs even when a variable is `1`.
15
16
  - 子进程归属由启动方通过 mux 协议提供,不把 pane 扩大为共享 tab,不修改父 workspace。Launchers grant child targets through the mux protocol; never expand a pane to a shared tab or rename the parent workspace.
16
17
  - 子代理需要显式配置 `subagentExtensions` 加载 naming。Child extension isolation requires explicitly loading naming in `subagentExtensions`.
@@ -7,6 +7,7 @@
7
7
  "tab": true
8
8
  },
9
9
  "title": {
10
+ "usePromptAsTitle": false,
10
11
  "maxLength": 15,
11
12
  "preferredLength": 10,
12
13
  "language": "auto",
@@ -72,8 +72,16 @@
72
72
  "en-US": "Invalid configuration field: {field}. Check the field name, type and allowed range."
73
73
  },
74
74
  "renameDescription": {
75
- "zh-CN": "统一命名 session 与允许修改的终端目标;省略名称时综合当前分支全部用户消息生成",
76
- "en-US": "Name the session and allowed terminal targets; omit the name to summarize all user messages on the current branch"
75
+ "zh-CN": "统一命名 session 与允许修改的终端目标;省略名称时按标题配置生成",
76
+ "en-US": "Name the session and allowed terminal targets; omit the name to generate a title using naming settings"
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"
77
85
  },
78
86
  "namingFailed": {
79
87
  "zh-CN": "命名失败:{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,132 @@
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 generates a title using naming settings"
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"
261
+ },
262
+ "panelLabelUsePromptAsTitle": {
263
+ "zh-CN": "使用提示词命名",
264
+ "en-US": "Use prompt as title"
265
+ },
266
+ "panelDescUsePromptAsTitle": {
267
+ "zh-CN": "用首条提示词和当前目录名生成标题,不调用模型",
268
+ "en-US": "Use the first prompt and current directory name without a model request"
253
269
  }
254
270
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-naming",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Configurable Pi session naming and manual terminal workspace/tab renaming",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -57,21 +57,23 @@
57
57
  "workspace"
58
58
  ],
59
59
  "dependencies": {
60
- "pi-extensions-i18n": "^0.8.0",
60
+ "pi-extensions-i18n": "^0.9.0",
61
61
  "pi-model-request": "^0.1.1",
62
- "pi-terminal-mux": "^0.6.6"
62
+ "pi-terminal-mux": "^0.6.7"
63
63
  },
64
64
  "peerDependencies": {
65
+ "@earendil-works/pi-ai": ">=0.80.0",
65
66
  "@earendil-works/pi-coding-agent": ">=0.80.0",
66
- "@earendil-works/pi-ai": ">=0.80.0"
67
+ "@earendil-works/pi-tui": ">=0.80.0"
67
68
  },
68
69
  "devDependencies": {
70
+ "@earendil-works/pi-ai": "0.85.1",
69
71
  "@earendil-works/pi-coding-agent": "0.85.1",
72
+ "@earendil-works/pi-tui": "0.85.1",
70
73
  "@types/node": "24.12.4",
71
74
  "pi-model-request": "^0.1.1",
72
- "pi-terminal-mux": "^0.6.6",
75
+ "pi-terminal-mux": "^0.6.7",
73
76
  "tsx": "4.23.1",
74
- "typescript": "5.9.3",
75
- "@earendil-works/pi-ai": "0.85.1"
77
+ "typescript": "5.9.3"
76
78
  }
77
79
  }
@@ -0,0 +1,385 @@
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
+ usePromptAsTitle: "title.usePromptAsTitle",
37
+ maxLength: "title.maxLength",
38
+ preferredLength: "title.preferredLength",
39
+ language: "title.language",
40
+ instructions: "title.instructions",
41
+ timeoutMs: "title.timeoutMs",
42
+ maxTokens: "title.maxTokens",
43
+ effort: "title.effort",
44
+ } as const;
45
+
46
+ /** 面板字段名的联合类型。 */
47
+ export type PanelFieldId = (typeof PANEL_FIELD)[keyof typeof PANEL_FIELD];
48
+
49
+ /** 二级列表一项:label 是展示文本,value 是写回配置的值。 */
50
+ export interface PanelOption {
51
+ /** 展示文本。 */
52
+ label: string;
53
+ /** 写回配置的值。 */
54
+ value: string;
55
+ }
56
+
57
+ /** 面板一行:字段名、取值方式和文案 key。 */
58
+ interface PanelField {
59
+ /** 字段名。 */
60
+ id: PanelFieldId;
61
+ /** `toggle` 原地切换,`choice` 回车开候选列表,`text` 回车开输入框。 */
62
+ kind: "toggle" | "choice" | "text";
63
+ /** 行标题的文案 key。 */
64
+ labelKey: string;
65
+ /** 说明行的文案 key。 */
66
+ descriptionKey: string;
67
+ /** 候选列表是否需要上方过滤输入框。 */
68
+ search?: boolean;
69
+ }
70
+
71
+ /** 语言候选列表最多同时显示几行。 */
72
+ const CHOICE_MENU_MAX_VISIBLE = 8;
73
+
74
+ /** 开关两个状态在面板上承载的值,与本地化文案无关。 */
75
+ const TOGGLE_ON = "on";
76
+ const TOGGLE_OFF = "off";
77
+
78
+ /** 面板字段顺序:总开关在最前,其余按「命名目标 → 标题偏好」排列。 */
79
+ const PANEL_FIELDS: readonly PanelField[] = [
80
+ { id: PANEL_FIELD.automaticNaming, kind: "toggle", labelKey: "panelLabelAutomaticNaming", descriptionKey: "panelDescAutomaticNaming" },
81
+ { id: PANEL_FIELD.manualNaming, kind: "toggle", labelKey: "panelLabelManualNaming", descriptionKey: "panelDescManualNaming" },
82
+ { id: PANEL_FIELD.sessionTarget, kind: "toggle", labelKey: "panelLabelSessionTarget", descriptionKey: "panelDescSessionTarget" },
83
+ { id: PANEL_FIELD.workspaceTarget, kind: "toggle", labelKey: "panelLabelWorkspaceTarget", descriptionKey: "panelDescWorkspaceTarget" },
84
+ { id: PANEL_FIELD.tabTarget, kind: "toggle", labelKey: "panelLabelTabTarget", descriptionKey: "panelDescTabTarget" },
85
+ { id: PANEL_FIELD.usePromptAsTitle, kind: "toggle", labelKey: "panelLabelUsePromptAsTitle", descriptionKey: "panelDescUsePromptAsTitle" },
86
+ { id: PANEL_FIELD.maxLength, kind: "choice", labelKey: "panelLabelMaxLength", descriptionKey: "panelDescMaxLength" },
87
+ { id: PANEL_FIELD.preferredLength, kind: "choice", labelKey: "panelLabelPreferredLength", descriptionKey: "panelDescPreferredLength" },
88
+ { id: PANEL_FIELD.language, kind: "choice", labelKey: "panelLabelLanguage", descriptionKey: "panelDescLanguage" },
89
+ { id: PANEL_FIELD.instructions, kind: "text", labelKey: "panelLabelInstructions", descriptionKey: "panelDescInstructions" },
90
+ { id: PANEL_FIELD.timeoutMs, kind: "choice", labelKey: "panelLabelTimeout", descriptionKey: "panelDescTimeout" },
91
+ { id: PANEL_FIELD.maxTokens, kind: "choice", labelKey: "panelLabelMaxTokens", descriptionKey: "panelDescMaxTokens" },
92
+ { id: PANEL_FIELD.effort, kind: "choice", labelKey: "panelLabelEffort", descriptionKey: "panelDescEffort" },
93
+ ];
94
+
95
+ /** 面板覆盖的字段名,测试用它证明配置里没有字段被漏下。 */
96
+ export const PANEL_FIELD_IDS: readonly string[] = PANEL_FIELDS.map((field) => field.id);
97
+
98
+ /** 取本地化的开关文案。 */
99
+ export function toggleLabels(): { on: string; off: string } {
100
+ return { on: i18n.t("configOn"), off: i18n.t("configOff") };
101
+ }
102
+
103
+ /** 布尔字段的候选列表。 */
104
+ function toggleOptions(): PanelOption[] {
105
+ const labels = toggleLabels();
106
+ return [
107
+ { label: labels.on, value: TOGGLE_ON },
108
+ { label: labels.off, value: TOGGLE_OFF },
109
+ ];
110
+ }
111
+
112
+ /** 数值字段的候选列表,labels 与值同文本。 */
113
+ function numberOptions(values: readonly string[]): PanelOption[] {
114
+ return values.map((value) => ({ label: value, value }));
115
+ }
116
+
117
+ /** 思考档位的候选列表。 */
118
+ function effortOptions(): PanelOption[] {
119
+ return numberOptions(TITLE_EFFORT_LEVELS);
120
+ }
121
+
122
+ /**
123
+ * 语言字段的候选列表。
124
+ *
125
+ * 常见语言放在前面,当前值不在列表里时补进去:语言代码是自由字符串,用户可能填
126
+ * 任意 BCP-47 代码,面板不能因为候选表里没有就把已存值显示成空白。
127
+ */
128
+ function languageOptions(current: string): PanelOption[] {
129
+ const values: string[] = [...LANGUAGE_PRESETS];
130
+ if (!values.includes(current)) values.push(current);
131
+ return numberOptions(values);
132
+ }
133
+
134
+ /** 按字段名取候选列表;文本字段没有候选。 */
135
+ function fieldOptions(id: PanelFieldId, config: NamingConfig): PanelOption[] {
136
+ switch (id) {
137
+ case PANEL_FIELD.automaticNaming:
138
+ case PANEL_FIELD.manualNaming:
139
+ case PANEL_FIELD.sessionTarget:
140
+ case PANEL_FIELD.workspaceTarget:
141
+ case PANEL_FIELD.tabTarget:
142
+ case PANEL_FIELD.usePromptAsTitle:
143
+ return toggleOptions();
144
+ case PANEL_FIELD.maxLength:
145
+ return numberOptions(MAX_LENGTH_PRESETS);
146
+ case PANEL_FIELD.preferredLength:
147
+ return numberOptions(PREFERRED_LENGTH_PRESETS);
148
+ case PANEL_FIELD.language:
149
+ return languageOptions(config.title.language);
150
+ case PANEL_FIELD.timeoutMs:
151
+ return numberOptions(TIMEOUT_PRESETS);
152
+ case PANEL_FIELD.maxTokens:
153
+ return numberOptions(MAX_TOKENS_PRESETS);
154
+ case PANEL_FIELD.effort:
155
+ return effortOptions();
156
+ default:
157
+ return [];
158
+ }
159
+ }
160
+
161
+ /** 按配置值取展示文本;不在候选表里就直接显示原值。 */
162
+ export function optionLabelForValue(options: readonly PanelOption[], value: string): string {
163
+ return options.find((option) => option.value === value)?.label ?? value;
164
+ }
165
+
166
+ /** 按展示文本反查写回值;找不到说明候选表变了,返回 undefined 由调用方保持不变。 */
167
+ export function optionValueFromLabel(
168
+ options: readonly PanelOption[],
169
+ label: string,
170
+ ): string | undefined {
171
+ return options.find((option) => option.label === label)?.value;
172
+ }
173
+
174
+ /** 读取一个字段当前存的值,统一转成面板上承载的字符串。 */
175
+ function readField(config: NamingConfig, id: PanelFieldId): string {
176
+ switch (id) {
177
+ case PANEL_FIELD.automaticNaming:
178
+ return config.automaticNaming ? TOGGLE_ON : TOGGLE_OFF;
179
+ case PANEL_FIELD.manualNaming:
180
+ return config.manualNaming ? TOGGLE_ON : TOGGLE_OFF;
181
+ case PANEL_FIELD.sessionTarget:
182
+ return config.targets.session ? TOGGLE_ON : TOGGLE_OFF;
183
+ case PANEL_FIELD.workspaceTarget:
184
+ return config.targets.workspace ? TOGGLE_ON : TOGGLE_OFF;
185
+ case PANEL_FIELD.tabTarget:
186
+ return config.targets.tab ? TOGGLE_ON : TOGGLE_OFF;
187
+ case PANEL_FIELD.usePromptAsTitle:
188
+ return config.title.usePromptAsTitle ? TOGGLE_ON : TOGGLE_OFF;
189
+ case PANEL_FIELD.maxLength:
190
+ return String(config.title.maxLength);
191
+ case PANEL_FIELD.preferredLength:
192
+ return String(config.title.preferredLength);
193
+ case PANEL_FIELD.language:
194
+ return config.title.language;
195
+ case PANEL_FIELD.instructions:
196
+ return config.title.instructions;
197
+ case PANEL_FIELD.timeoutMs:
198
+ return String(config.title.timeoutMs);
199
+ case PANEL_FIELD.maxTokens:
200
+ return String(config.title.maxTokens);
201
+ case PANEL_FIELD.effort:
202
+ return config.title.effort;
203
+ }
204
+ }
205
+
206
+ /** 用新值重建配置;文本字段原样写入,数值字段转成数字。 */
207
+ function writeField(config: NamingConfig, id: PanelFieldId, value: string): NamingConfig {
208
+ const title = { ...config.title };
209
+ switch (id) {
210
+ case PANEL_FIELD.automaticNaming:
211
+ return { ...config, automaticNaming: value === TOGGLE_ON };
212
+ case PANEL_FIELD.manualNaming:
213
+ return { ...config, manualNaming: value === TOGGLE_ON };
214
+ case PANEL_FIELD.sessionTarget:
215
+ return { ...config, targets: { ...config.targets, session: value === TOGGLE_ON } };
216
+ case PANEL_FIELD.workspaceTarget:
217
+ return { ...config, targets: { ...config.targets, workspace: value === TOGGLE_ON } };
218
+ case PANEL_FIELD.tabTarget:
219
+ return { ...config, targets: { ...config.targets, tab: value === TOGGLE_ON } };
220
+ case PANEL_FIELD.usePromptAsTitle:
221
+ return { ...config, title: { ...title, usePromptAsTitle: value === TOGGLE_ON } };
222
+ case PANEL_FIELD.maxLength:
223
+ return { ...config, title: { ...title, maxLength: Number(value) } };
224
+ case PANEL_FIELD.preferredLength:
225
+ return { ...config, title: { ...title, preferredLength: Number(value) } };
226
+ case PANEL_FIELD.language:
227
+ return { ...config, title: { ...title, language: value } };
228
+ case PANEL_FIELD.instructions:
229
+ return { ...config, title: { ...title, instructions: value } };
230
+ case PANEL_FIELD.timeoutMs:
231
+ return { ...config, title: { ...title, timeoutMs: Number(value) } };
232
+ case PANEL_FIELD.maxTokens:
233
+ return { ...config, title: { ...title, maxTokens: Number(value) } };
234
+ case PANEL_FIELD.effort:
235
+ return { ...config, title: { ...title, effort: value as NamingConfig["title"]["effort"] } };
236
+ }
237
+ }
238
+
239
+ /** 把候选列表转成 `SelectList` 的条目;展示文本同时是回传值。 */
240
+ function toSelectItems(options: readonly PanelOption[]): SelectItem[] {
241
+ return options.map((option) => ({ value: option.label, label: option.label }));
242
+ }
243
+
244
+ /** 按展示文本建二级选择列表:Enter 选定并回传,Esc 不改动直接返回。 */
245
+ function createChoiceSubmenu(
246
+ options: readonly PanelOption[],
247
+ currentLabel: string,
248
+ done: (selectedLabel?: string) => void,
249
+ ): Component {
250
+ const items = toSelectItems(options);
251
+ const selectList = new SelectList(
252
+ items,
253
+ Math.max(1, Math.min(items.length, CHOICE_MENU_MAX_VISIBLE)),
254
+ getSelectListTheme(),
255
+ );
256
+ const index = options.findIndex((option) => option.label === currentLabel);
257
+ if (index >= 0) selectList.setSelectedIndex(index);
258
+ selectList.onSelect = (item) => done(item.value);
259
+ selectList.onCancel = () => done(undefined);
260
+ return selectList;
261
+ }
262
+
263
+ /** 自由文本二级菜单:预填当前值的 `Input`。 */
264
+ function createTextSubmenu(
265
+ value: string,
266
+ placeholder: string,
267
+ done: (value?: string) => void,
268
+ ): Component {
269
+ const input = new Input({ prompt: `${i18n.t("panelInputPrompt")} `, placeholder });
270
+ input.setValue(value);
271
+ input.focused = true;
272
+ input.onSubmit = (submitted) => done(submitted);
273
+ input.onEscape = () => done(undefined);
274
+ return {
275
+ /** 一行可编辑文本。 */
276
+ render: (width) => input.render(width),
277
+ /** 无本地缓存。 */
278
+ invalidate: () => input.invalidate(),
279
+ /** 所有按键都归输入框。 */
280
+ handleInput: (data) => input.handleInput(data),
281
+ };
282
+ }
283
+
284
+ /** 把当前配置转成 Pi 设置列表的条目。 */
285
+ export function buildSettingItems(config: NamingConfig): SettingItem[] {
286
+ return PANEL_FIELDS.map((field) => {
287
+ const label = i18n.t(field.labelKey);
288
+ const description = i18n.t(field.descriptionKey);
289
+ const options = fieldOptions(field.id, config);
290
+ const stored = readField(config, field.id);
291
+ if (field.kind === "toggle") {
292
+ return { id: field.id, label, description, currentValue: optionLabelForValue(options, stored), values: options.map((option) => option.label) };
293
+ }
294
+ if (field.kind === "choice") {
295
+ return {
296
+ id: field.id,
297
+ label,
298
+ description,
299
+ currentValue: optionLabelForValue(options, stored),
300
+ // 二级列表接管 Enter,避免在候选之间反复原地循环。
301
+ submenu: (currentValue, done) => createChoiceSubmenu(options, currentValue, done),
302
+ };
303
+ }
304
+ return {
305
+ id: field.id,
306
+ label,
307
+ description,
308
+ currentValue: stored || i18n.t("panelValueUnset"),
309
+ submenu: (_currentValue, done) => createTextSubmenu(stored, description, done),
310
+ };
311
+ });
312
+ }
313
+
314
+ /**
315
+ * 把面板返回的展示文本写回配置;无法识别时返回 undefined,由调用方保持不变。
316
+ *
317
+ * 结果统一过一遍 `parseConfig`,保证写盘的值仍在合法区间内(候选表与校验规则不会各走各的)。
318
+ */
319
+ export function applyPanelChange(
320
+ config: NamingConfig,
321
+ id: string,
322
+ value: string,
323
+ ): NamingConfig | undefined {
324
+ const field = PANEL_FIELDS.find((candidate) => candidate.id === id);
325
+ if (!field) return undefined;
326
+ if (field.kind === "text") {
327
+ try {
328
+ return parseConfig(writeField(config, field.id, value));
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+ const stored = optionValueFromLabel(fieldOptions(field.id, config), value);
334
+ if (stored === undefined) return undefined;
335
+ try {
336
+ return parseConfig(writeField(config, field.id, stored));
337
+ } catch {
338
+ return undefined;
339
+ }
340
+ }
341
+
342
+ /** 面板与宿主之间的接口;配置读写与生效动作都由入口注入。 */
343
+ export interface ConfigPanelHandlers {
344
+ /** 读取当前配置。 */
345
+ getConfig(): NamingConfig;
346
+ /** 某一项被改动后调用,由入口负责保存并同步运行期配置。 */
347
+ onChange(config: NamingConfig): void;
348
+ }
349
+
350
+ /** 打开配置面板;用户改动即时生效,Esc 关闭。 */
351
+ export async function openConfigPanel(
352
+ ctx: ExtensionCommandContext,
353
+ handlers: ConfigPanelHandlers,
354
+ ): Promise<void> {
355
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
356
+ const container = new Container();
357
+ container.addChild(new Text(theme.fg("accent", theme.bold(i18n.t("configMenuTitle"))), 1, 1));
358
+
359
+ const settingsList = new SettingsList(
360
+ buildSettingItems(handlers.getConfig()),
361
+ PANEL_FIELDS.length,
362
+ getSettingsListTheme(),
363
+ (id, newValue) => {
364
+ const next = applyPanelChange(handlers.getConfig(), id, newValue);
365
+ if (next) handlers.onChange(next);
366
+ },
367
+ () => done(undefined),
368
+ );
369
+ container.addChild(settingsList);
370
+
371
+ // ctx.ui.custom 要求返回 Component:整体布局交给 Container,键盘转给设置列表,
372
+ // 列表改了数据后必须 requestRender,否则画面停在上一帧。
373
+ return {
374
+ /** 面板整体按 Container 布局渲染。 */
375
+ render: (width) => container.render(width),
376
+ /** 无本地缓存,交给 Container 清理。 */
377
+ invalidate: () => container.invalidate(),
378
+ /** 键盘输入交给设置列表,并触发一次重绘。 */
379
+ handleInput: (data) => {
380
+ settingsList.handleInput(data);
381
+ tui.requestRender();
382
+ },
383
+ };
384
+ });
385
+ }
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
  /** 标题请求的默认输出预算;推理模型的思考与标题共享该额度。 */
@@ -22,6 +29,7 @@ const NAMING_SWITCHES = ["automaticNaming", "manualNaming"] as const;
22
29
  const TARGET_KEYS = ["session", "workspace", "tab"] as const;
23
30
 
24
31
  export interface TitleConfig {
32
+ usePromptAsTitle: boolean;
25
33
  maxLength: number;
26
34
  preferredLength: number;
27
35
  language: string;
@@ -32,6 +40,7 @@ export interface TitleConfig {
32
40
  }
33
41
 
34
42
  export const DEFAULT_TITLE_CONFIG: Readonly<TitleConfig> = Object.freeze({
43
+ usePromptAsTitle: false,
35
44
  maxLength: 15,
36
45
  preferredLength: 10,
37
46
  language: "auto",
@@ -87,6 +96,10 @@ export function parseConfig(value: unknown): NamingConfig {
87
96
  if (!Object.hasOwn(DEFAULT_TITLE_CONFIG, key)) invalid(`title.${key}`);
88
97
  }
89
98
  const title: TitleConfig = { ...DEFAULT_TITLE_CONFIG };
99
+ if (titleRaw.usePromptAsTitle !== undefined) {
100
+ if (typeof titleRaw.usePromptAsTitle !== "boolean") invalid("title.usePromptAsTitle");
101
+ title.usePromptAsTitle = titleRaw.usePromptAsTitle;
102
+ }
90
103
  for (const key of ["maxLength", "preferredLength", "timeoutMs", "maxTokens"] as const) {
91
104
  if (titleRaw[key] !== undefined) title[key] = positiveInteger(titleRaw[key], `title.${key}`);
92
105
  }
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
 
@@ -10,30 +11,11 @@ const NOTICE_TAG = "naming";
10
11
  const NOTICE_COLOR: NoticeColor = NOTICE_TAG_COLOR;
11
12
  /** 本扩展的提示来源。 */
12
13
  const NOTICE_SOURCE: NoticeSource = { tag: NOTICE_TAG, color: NOTICE_COLOR };
13
- import { getCurrentSessionUserMessages, requestSessionNameWithTimeout, type SessionNameRequester } from "./session-name.ts";
14
+ import { buildPromptSessionName, getCurrentSessionUserMessages, requestSessionNameWithTimeout, type SessionNameRequester } from "./session-name.ts";
14
15
 
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,32 +140,42 @@ 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
- label = await requestSessionNameWithTimeout({
283
- userMessages: automatic ? [args] : getCurrentSessionUserMessages(ctx),
284
- ctx, requestName, title: config.title,
285
- });
171
+ const userMessages = automatic ? [args] : getCurrentSessionUserMessages(ctx);
172
+ const title = current().title;
173
+ label = title.usePromptAsTitle
174
+ ? buildPromptSessionName(userMessages, ctx.cwd, title.maxLength)
175
+ : await requestSessionNameWithTimeout({
176
+ userMessages,
177
+ ctx, requestName, title,
178
+ });
286
179
  } catch (error) {
287
180
  if (isCurrent()) report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" });
288
181
  return;
@@ -291,7 +184,7 @@ export async function registerNaming(
291
184
  if (!isCurrent() || (automatic && pi.getSessionName())) return;
292
185
 
293
186
  const renamed: string[] = [];
294
- if (config.targets.session) {
187
+ if (current().targets.session) {
295
188
  try { pi.setSessionName(label); renamed.push(i18n.t("piSessionTarget")); }
296
189
  catch (error) { report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" }); }
297
190
  }
@@ -300,8 +193,8 @@ export async function registerNaming(
300
193
  }
301
194
  for (const target of targets) {
302
195
  let result = target;
303
- if (target.status === "ready" && terminal) {
304
- try { result = terminal.rename(target.reference, label); }
196
+ if (target.status === "ready") {
197
+ try { result = (await getTerminal()).rename(target.reference, label); }
305
198
  catch (error) { result = { status: "failed", operation: target.reference.operation, error: errorMessage(error) }; }
306
199
  }
307
200
  if (result.status === "renamed") {
@@ -320,39 +213,46 @@ export async function registerNaming(
320
213
  }
321
214
  }
322
215
 
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
- });
216
+ // /rename 常驻注册;manualNaming 关掉时报告原因,而不是让命令凭空消失(Pi 没有内置 /rename 可回退)。
217
+ pi.registerCommand(RENAME_COMMAND, {
218
+ description: i18n.t("renameDescription"),
219
+ getArgumentCompletions: () => null,
220
+ handler: async (args, ctx) => {
221
+ if (!current().manualNaming) {
222
+ report(pi, ctx, { message: i18n.t("namingManualDisabled"), level: "warning" });
223
+ return;
224
+ }
225
+ await rename(args, ctx, false);
226
+ },
227
+ });
228
+ pi.on("input", (event, ctx) => {
229
+ if (!current().automaticNaming) return;
230
+ if (!eligible || attempted || event.source === "extension" || pi.getSessionName()) return;
231
+ const text = event.text.trim();
232
+ if (!text) return;
233
+ attempted = true;
234
+ const inputGeneration = generation;
235
+ void rename(text, ctx, true).catch((error: unknown) => {
236
+ if (generation !== inputGeneration) return;
237
+ report(pi, ctx, { message: i18n.t("namingFailed", { error: errorMessage(error) }), level: "error" });
341
238
  });
342
- }
239
+ });
343
240
  }
344
241
 
345
242
  /** 配置错误在 session_start 报告,不注册不完整的命名功能。 */
346
243
  export default async function namingExtension(pi: ExtensionAPI): Promise<void> {
347
244
  // 提示画成会话区里的带底色消息块;渲染器在本包这个模块实例里注册一次。
348
245
  installNoticeRenderer(pi);
349
- registerNamingConfigCommand(pi);
350
246
  let config: NamingConfig;
351
247
  try { config = loadConfig(); }
352
248
  catch (error) {
249
+ registerNamingConfigCommand(pi);
353
250
  pi.on("session_start", (_event, ctx) => report(pi, ctx, { message:
354
251
  i18n.t("namingConfigFailed", { error: errorMessage(error) }), level: "warning" }));
355
252
  return;
356
253
  }
357
- await registerNaming(pi, config);
254
+ // 面板改一项就改这里,后续命名请求读到的就是新值,不用 /reload。
255
+ const runtime: NamingRuntime = { config };
256
+ registerNamingConfigCommand(pi, undefined, runtime);
257
+ await registerNaming(pi, config, { runtime });
358
258
  }
@@ -4,6 +4,7 @@ import type {
4
4
  SessionEntry,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import { createModelRequester } from "pi-model-request";
7
+ import { basename } from "node:path";
7
8
  import { i18n } from "./i18n.ts";
8
9
  import { DEFAULT_TITLE_CONFIG, type TitleConfig } from "./config.ts";
9
10
 
@@ -131,6 +132,23 @@ export function normalizeSessionName(raw: string, maxLength = DEFAULT_TITLE_CONF
131
132
  return Array.from(name).slice(0, maxLength).join("").trim();
132
133
  }
133
134
 
135
+ /** 用首条非空提示词和目录名生成标题,保留字面文本,不做模型响应格式清理。 */
136
+ export function buildPromptSessionName(
137
+ userMessages: readonly string[],
138
+ cwd: string,
139
+ maxLength = DEFAULT_TITLE_CONFIG.maxLength,
140
+ ): string {
141
+ const singleLine = (value: string): string => value.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
142
+ const prompt = userMessages.map(singleLine).find(Boolean);
143
+ if (!prompt) throw new Error(i18n.t("sessionNameNoMessages"));
144
+ const promptPoints = Array.from(prompt);
145
+ const directory = Array.from(singleLine(basename(cwd) || cwd));
146
+ // 至少给提示词留一半空间;极短上限只显示提示词,避免目录前缀挤掉整个主题。
147
+ if (directory.length === 0 || maxLength < 5) return promptPoints.slice(0, maxLength).join("").trim();
148
+ const directoryLength = Math.min(directory.length, Math.floor((maxLength - 2) / 2));
149
+ return `${directory.slice(0, directoryLength).join("")}: ${promptPoints.slice(0, maxLength - directoryLength - 2).join("")}`.trim();
150
+ }
151
+
134
152
  /** 调用当前 session 模型生成名称,并把鉴权、空响应和模型错误显式抛出。 */
135
153
  export async function requestSessionName({
136
154
  userMessages,