@henryqw/pi-herdr-rename 0.1.0 → 0.3.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
@@ -17,17 +17,17 @@ pi remove npm:@henryqw/pi-herdr-rename
17
17
  ## Behavior
18
18
 
19
19
  - On the first real, non-empty text prompt in a new session, title generation starts in the background and does not delay the main Pi response. Extension-injected prompts, empty prompts, and image-only input are ignored.
20
- - Successful titles are lowercase, at most five words, and at most 60 characters. The first 1,000 characters of user text are sent to the rename model; prompt content is never logged.
20
+ - Successful titles are lowercase and, by default, at most four words and 40 characters. The first 1,000 characters of user text are sent to the rename model; prompt content is never logged.
21
21
  - A successful title updates the Pi session name and current Herdr pane. The enclosing Herdr tab is updated only when the current tab has one pane. Outside Herdr, only the Pi session name changes.
22
- - Resuming a named session reapplies its saved title without another rename-model request. Automatic failures stay quiet and do not change labels; there is no local fallback or retry.
22
+ - Resuming a named session reapplies its saved title without another rename-model request. Automatic rename-model errors show a warning; other automatic failures stay quiet. Failures do not change labels, fall back locally, or retry.
23
23
 
24
24
  ## Manual rename
25
25
 
26
- Run `/rename` to generate a title from up to the three most recent user/assistant rounds. It uses text only, caps each message at 1,000 characters and the complete context at 4,000 characters, and applies the same Pi and Herdr rules. The command warns without changing anything when no user text exists or generation fails. If requests overlap, the latest rename request wins.
26
+ Run `/rename` to generate a title from up to the three most recent user/assistant rounds. It uses text only, caps each message at 1,000 characters and the complete context at 4,000 characters, and applies the same Pi and Herdr rules. An animated widget shows `renaming...`, briefly changes to `renamed to <title>`, then disappears. The command warns without changing anything when no user text exists or generation fails. If requests overlap, the latest rename request wins.
27
27
 
28
28
  ## Rename model
29
29
 
30
- Run `/rename-model` to choose an available authenticated text model with Pi's native selector. The default is `openai-codex/gpt-5.6-luna`.
30
+ Run `/rename-model` to choose the model from Pi's available authenticated text models. Until a valid selection is saved, every session start prompts you to run the command and title generation remains disabled.
31
31
 
32
32
  The selection is saved in:
33
33
 
@@ -35,11 +35,13 @@ The selection is saved in:
35
35
 
36
36
  ```json
37
37
  {
38
- "model": "provider/model"
38
+ "model": "provider/model",
39
+ "maxWords": 4,
40
+ "maxChars": 40
39
41
  }
40
42
  ```
41
43
 
42
- Missing or malformed configuration uses the default. An unavailable configured model is not silently replaced; run `/rename-model` to choose another model.
44
+ `maxWords` and `maxChars` accept positive integers and default to 4 and 40. Invalid limits use their defaults. Missing, malformed, or unavailable model selection never falls back to another model.
43
45
 
44
46
  ## Development
45
47
 
@@ -1,35 +1,47 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import {
4
+ BorderedLoader,
4
5
  getAgentDir,
5
6
  type ExtensionAPI,
6
7
  type ExtensionContext,
7
8
  } from "@earendil-works/pi-coding-agent";
8
9
 
9
- const DEFAULT_MODEL = "openai-codex/gpt-5.6-luna";
10
+ const WIDGET_KEY = "pi-herdr-rename";
11
+ const WIDGET_RESULT_MS = 2_000;
10
12
  const MAX_MESSAGE_CHARS = 1_000;
11
13
  const MAX_CONTEXT_CHARS = 4_000;
12
- const TITLE_PROMPT =
13
- "Return only a short chat title for the current conversation topic, prioritizing the most recent user intent: lowercase, at most five words, and at most 60 characters.";
14
+ const DEFAULT_MAX_WORDS = 4;
15
+ const DEFAULT_MAX_CHARS = 40;
14
16
  const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
15
17
 
16
- async function configuredModel(): Promise<string> {
18
+ type RenameConfig = { model?: string; maxWords: number; maxChars: number };
19
+
20
+ class RenameModelError extends Error {}
21
+
22
+ const positiveInteger = (value: unknown, fallback: number) =>
23
+ typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback;
24
+
25
+ async function configured(): Promise<RenameConfig> {
17
26
  try {
18
27
  const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
19
28
  if (config && typeof config === "object" && !Array.isArray(config)) {
20
- const model = (config as { model?: unknown }).model;
21
- if (typeof model === "string" && /^[^\s/]+\/\S+$/.test(model)) return model;
29
+ const values = config as { model?: unknown; maxWords?: unknown; maxChars?: unknown };
30
+ return {
31
+ model: typeof values.model === "string" && /^[^\s/]+\/\S+$/.test(values.model) ? values.model : undefined,
32
+ maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS),
33
+ maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS),
34
+ };
22
35
  }
23
- } catch {
24
- // Missing or malformed config uses the default model.
25
- }
26
- return DEFAULT_MODEL;
36
+ } catch {}
37
+ return { maxWords: DEFAULT_MAX_WORDS, maxChars: DEFAULT_MAX_CHARS };
27
38
  }
28
39
 
29
40
  async function saveModel(model: string): Promise<void> {
30
41
  const path = configPath();
42
+ const { maxWords, maxChars } = await configured();
31
43
  await mkdir(dirname(path), { recursive: true });
32
- await writeFile(path, `${JSON.stringify({ model }, null, 2)}\n`, "utf8");
44
+ await writeFile(path, `${JSON.stringify({ model, maxWords, maxChars }, null, 2)}\n`, "utf8");
33
45
  }
34
46
 
35
47
  function messageText(content: unknown): string {
@@ -86,7 +98,8 @@ function recentConversation(ctx: ExtensionContext, fallback?: string): string |
86
98
  }
87
99
 
88
100
  async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
89
- const key = await configuredModel();
101
+ const { model: key, maxWords, maxChars } = await configured();
102
+ if (!key) throw new Error("Rename model is not configured. Run /rename-model.");
90
103
  const separator = key.indexOf("/");
91
104
  const provider = key.slice(0, separator);
92
105
  const id = key.slice(separator + 1);
@@ -98,11 +111,14 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
98
111
  const response = await ctx.modelRegistry.complete(
99
112
  model,
100
113
  {
101
- systemPrompt: TITLE_PROMPT,
114
+ systemPrompt: `Return only a short chat title for the current conversation topic, prioritizing the most recent user intent: lowercase, at most ${maxWords} words, and at most ${maxChars} characters.`,
102
115
  messages: [{ role: "user", content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
103
116
  },
104
117
  { signal, maxRetries: 0, maxTokens: 64 },
105
118
  );
119
+ if (response.stopReason === "error") {
120
+ throw new RenameModelError(response.errorMessage || "Rename model failed.");
121
+ }
106
122
  if (response.stopReason !== "stop") throw new Error("Rename model did not return a complete title.");
107
123
 
108
124
  const title = response.content
@@ -112,7 +128,7 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
112
128
  .trim()
113
129
  .toLowerCase()
114
130
  .replace(/\s+/g, " ");
115
- if (!title || title.length > 60 || title.split(" ").length > 5) {
131
+ if (!title || title.length > maxChars || title.split(" ").length > maxWords) {
116
132
  throw new Error("Rename model returned an invalid title.");
117
133
  }
118
134
  return title;
@@ -131,6 +147,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
131
147
  let automaticStarted = false;
132
148
  let sequence = 0;
133
149
  let active: AbortController | undefined;
150
+ let widgetSequence = 0;
151
+ let widgetTimer: ReturnType<typeof setTimeout> | undefined;
134
152
 
135
153
  const isCurrent = (request: number, controller: AbortController) =>
136
154
  request === sequence && active === controller && !controller.signal.aborted;
@@ -169,27 +187,40 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
169
187
  if (isCurrent(request, controller)) active = undefined;
170
188
  };
171
189
 
172
- const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<void> => {
190
+ const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<string | undefined> => {
173
191
  const { request, controller } = begin();
174
192
  try {
175
193
  const title = await generateTitle(text, ctx, controller.signal);
176
194
  if (!isCurrent(request, controller)) return;
177
195
  pi.setSessionName(title);
178
196
  await applyHerdr(title, request, controller);
197
+ return title;
179
198
  } catch (error) {
180
- if (manual && isCurrent(request, controller)) {
199
+ if (isCurrent(request, controller) && (manual || error instanceof RenameModelError)) {
181
200
  ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
182
201
  }
202
+ return undefined;
183
203
  } finally {
184
204
  finish(request, controller);
185
205
  }
186
206
  };
187
207
 
188
- pi.on("session_start", (_event, ctx) => {
208
+ const clearWidget = (ctx: ExtensionContext) => {
209
+ widgetSequence++;
210
+ if (widgetTimer) clearTimeout(widgetTimer);
211
+ widgetTimer = undefined;
212
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
213
+ };
214
+
215
+ pi.on("session_start", async (_event, ctx) => {
216
+ clearWidget(ctx);
189
217
  active?.abort();
190
218
  active = undefined;
191
219
  sequence++;
192
220
  latestUserText = latestSessionUserText(ctx);
221
+ if (!(await configured()).model) {
222
+ ctx.ui.notify("Run /rename-model to configure chat title generation.", "warning");
223
+ }
193
224
  const title = pi.getSessionName();
194
225
  automaticStarted = Boolean(title || latestUserText);
195
226
  if (!title) return;
@@ -210,7 +241,8 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
210
241
  return { action: "continue" };
211
242
  });
212
243
 
213
- pi.on("session_shutdown", () => {
244
+ pi.on("session_shutdown", (_event, ctx) => {
245
+ clearWidget(ctx);
214
246
  active?.abort();
215
247
  active = undefined;
216
248
  sequence++;
@@ -224,7 +256,26 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
224
256
  ctx.ui.notify("No user text is available to rename this chat.", "warning");
225
257
  return;
226
258
  }
227
- await rename(context, ctx, true);
259
+ if (widgetTimer) clearTimeout(widgetTimer);
260
+ widgetTimer = undefined;
261
+ const widgetRequest = ++widgetSequence;
262
+ ctx.ui.setWidget(
263
+ WIDGET_KEY,
264
+ (tui, theme) => new BorderedLoader(tui, theme, "renaming...", { cancellable: false }),
265
+ );
266
+ const title = await rename(context, ctx, true);
267
+ if (widgetRequest !== widgetSequence) return;
268
+ if (!title) {
269
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
270
+ return;
271
+ }
272
+ ctx.ui.setWidget(WIDGET_KEY, [`renamed to ${title}`]);
273
+ widgetTimer = setTimeout(() => {
274
+ if (widgetRequest !== widgetSequence) return;
275
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
276
+ widgetTimer = undefined;
277
+ }, WIDGET_RESULT_MS);
278
+ widgetTimer.unref?.();
228
279
  },
229
280
  });
230
281
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Generate short Pi chat titles and rename the current Herdr location.",
5
5
  "keywords": [
6
6
  "pi-package",