@henryqw/pi-herdr-rename 1.0.2 → 2.0.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
@@ -1,6 +1,6 @@
1
1
  # `@henryqw/pi-herdr-rename`
2
2
 
3
- Give each conversation a short semantic title: Pi session name, current Herdr pane, and a Git branch when the checkout is still generated.
3
+ Give each conversation one short human title across Pi and Herdr while keeping semantic naming for Git branches.
4
4
 
5
5
  ## Install
6
6
 
@@ -21,28 +21,15 @@ Requires Pi Coding Agent 0.84.2+.
21
21
 
22
22
  | Surface | Type | Purpose |
23
23
  | --- | --- | --- |
24
- | `/rename` | command | Generate a title from up to three recent user/assistant rounds. |
24
+ | `/rename` | command | Generate a display title and semantic branch from up to three recent user/assistant rounds. |
25
25
 
26
- First real user prompt also generates a title in the background and does not delay the main reply.
26
+ First real user prompt generates a title in the background after Pi expands skill and prompt-template shorthand. It does not delay main reply. Extension-injected prompts, empty prompts, and image-only input are ignored.
27
27
 
28
- Successful titles use `type: subject`, lowercase words, and by default at most four words and 40 characters. Example: `fix: extension name` Git branch `fix/extension-name`.
28
+ Display titles are natural task phrases, preferably three or four words and always at most four words and 20 characters. Model classification stays internal: `refactor: update task logic` displays as `Update task logic` and maps to Git branch `refactor/update-task-logic`.
29
29
 
30
- In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes the semantic branch; custom workspace names stay. The enclosing Herdr tab updates only when this pane is the tab's only pane. Outside Herdr, only the Pi session name changes.
30
+ In a linked worktree, a detached checkout or Herdr `worktree/...` branch is renamed; an existing non-generated branch stays. A generated workspace label such as `worktree-brave-meadow-4aa8` becomes display title; custom workspace names stay. Enclosing Herdr tab updates only when this pane is tab's only pane. Outside Herdr, only Pi session name changes.
31
31
 
32
- Tries the assigned profile primary, then fallback. Never substitutes the current session model. No viable route leaves titles unchanged. Resuming a named session reapplies the saved title without another model request.
33
-
34
- ## Config
35
-
36
- `~/.pi/agent/config/pi-herdr-rename.json`
37
-
38
- ```json
39
- {
40
- "maxWords": 4,
41
- "maxChars": 40
42
- }
43
- ```
44
-
45
- `maxWords` must be at least 2 and `maxChars` at least 6; defaults are 4 and 40. Invalid limits use defaults. Model routes live in `~/.pi/agent/config/pi-task-models.json`. Malformed shared task-model config is reported, left unchanged, and never changes a title.
32
+ Tries assigned profile primary, then fallback, while honoring configured thinking level. Never substitutes current session model. No viable route leaves titles unchanged. Resuming a session created by this version reapplies saved display title and semantic branch without another model request. Older titles receive no migration.
46
33
 
47
34
  ## Remove
48
35
 
@@ -1,8 +1,5 @@
1
- import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
3
1
  import {
4
2
  BorderedLoader,
5
- getAgentDir,
6
3
  type ExtensionAPI,
7
4
  type ExtensionContext,
8
5
  } from "@earendil-works/pi-coding-agent";
@@ -18,20 +15,19 @@ const WIDGET_KEY = "pi-herdr-rename";
18
15
  const WIDGET_RESULT_MS = 2_000;
19
16
  const MAX_MESSAGE_CHARS = 1_000;
20
17
  const MAX_CONTEXT_CHARS = 2_000;
21
- const DEFAULT_MAX_WORDS = 4;
22
- const DEFAULT_MAX_CHARS = 40;
18
+ const DISPLAY_MAX_WORDS = 4;
19
+ const DISPLAY_MAX_CHARS = 20;
20
+ const SEMANTIC_TYPE_MAX_CHARS = 12;
23
21
  const RENAME_TASK = "pi-herdr-rename/rename";
24
22
  const DEFAULT_RENAME_PROFILE = "fast" as const;
23
+ const TITLE_STATE_TYPE = "pi-herdr-rename/title";
25
24
  const HERDR_DEFAULT_WORKTREE_NAME = /^(?:worktree[-/])?(?:brave|calm|clear|green|lucky|quiet|rapid|silver)-(?:river|cloud|field|forest|harbor|meadow|stone|valley)-[0-9a-f]{4}$/;
26
- const configPath = () => join(getAgentDir(), "config", "pi-herdr-rename.json");
25
+ const SEMANTIC_BRANCH = /^[a-z][a-z0-9-]{0,11}\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
27
26
 
28
- type RenameConfig = { maxWords: number; maxChars: number };
27
+ type GeneratedTitle = { display: string; branch: string };
29
28
 
30
29
  class RenameModelError extends Error {}
31
30
 
32
- const positiveInteger = (value: unknown, fallback: number, minimum = 1) =>
33
- typeof value === "number" && Number.isInteger(value) && value >= minimum ? value : fallback;
34
-
35
31
  function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
36
32
  let config;
37
33
  try {
@@ -55,9 +51,30 @@ function configuredRenameRoutes(ctx: ExtensionContext): ResolvedTaskRoute[] {
55
51
  return routes;
56
52
  }
57
53
 
58
- function branchFromTitle(title: string): string | undefined {
54
+ function parseGeneratedTitle(title: string): GeneratedTitle | undefined {
59
55
  const match = /^([a-z][a-z0-9-]*): ([a-z0-9]+(?: [a-z0-9]+)*)$/.exec(title);
60
- return match ? `${match[1]}/${match[2].replaceAll(" ", "-")}` : undefined;
56
+ if (!match) return undefined;
57
+ const subject = match[2];
58
+ if (
59
+ match[1].length > SEMANTIC_TYPE_MAX_CHARS ||
60
+ subject.length > DISPLAY_MAX_CHARS ||
61
+ subject.split(" ").length > DISPLAY_MAX_WORDS
62
+ ) return undefined;
63
+ return {
64
+ display: subject[0].toUpperCase() + subject.slice(1),
65
+ branch: `${match[1]}/${subject.replaceAll(" ", "-")}`,
66
+ };
67
+ }
68
+
69
+ function savedTitle(ctx: ExtensionContext): GeneratedTitle | undefined {
70
+ const entry = [...ctx.sessionManager.getBranch()]
71
+ .reverse()
72
+ .find((candidate) => candidate.type === "custom" && candidate.customType === TITLE_STATE_TYPE);
73
+ if (entry?.type !== "custom" || !entry.data || typeof entry.data !== "object" || Array.isArray(entry.data)) return undefined;
74
+ const { display, branch } = entry.data as { display?: unknown; branch?: unknown };
75
+ return typeof display === "string" && typeof branch === "string" && SEMANTIC_BRANCH.test(branch)
76
+ ? { display, branch }
77
+ : undefined;
61
78
  }
62
79
 
63
80
  function branchAvailable(candidate: string, branches: string[]): boolean {
@@ -77,20 +94,6 @@ function availableBranch(branch: string, branches: string[]): string {
77
94
  throw new Error("Could not choose an available semantic branch.");
78
95
  }
79
96
 
80
- async function configured(): Promise<RenameConfig> {
81
- try {
82
- const config: unknown = JSON.parse(await readFile(configPath(), "utf8"));
83
- if (config && typeof config === "object" && !Array.isArray(config)) {
84
- const values = config as { maxWords?: unknown; maxChars?: unknown };
85
- return {
86
- maxWords: positiveInteger(values.maxWords, DEFAULT_MAX_WORDS, 2),
87
- maxChars: positiveInteger(values.maxChars, DEFAULT_MAX_CHARS, 6),
88
- };
89
- }
90
- } catch {}
91
- return { maxWords: DEFAULT_MAX_WORDS, maxChars: DEFAULT_MAX_CHARS };
92
- }
93
-
94
97
  function messageText(content: unknown): string {
95
98
  if (typeof content === "string") return content;
96
99
  if (!Array.isArray(content)) return "";
@@ -144,10 +147,9 @@ function recentConversation(ctx: ExtensionContext, fallback?: string): string |
144
147
  return selected.reverse().join("\n\n");
145
148
  }
146
149
 
147
- async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<string> {
148
- const { maxWords, maxChars } = await configured();
150
+ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortSignal): Promise<GeneratedTitle> {
149
151
  const completionContext = {
150
- systemPrompt: `Return only a semantic title for latest user intent. Format: type: subject. Use lowercase type and lowercase alphanumeric subject words separated by spaces. No other punctuation; at most ${maxWords} words and at most ${maxChars} characters.`,
152
+ systemPrompt: `Return only type: subject for latest user intent. Type: lowercase semantic word, max ${SEMANTIC_TYPE_MAX_CHARS} characters. Subject: natural task phrase, preferably 3-4 lowercase alphanumeric words, max ${DISPLAY_MAX_WORDS} words and ${DISPLAY_MAX_CHARS} characters. No other punctuation.`,
151
153
  messages: [{ role: "user" as const, content: text.slice(0, MAX_CONTEXT_CHARS), timestamp: Date.now() }],
152
154
  };
153
155
  const complete = async (route: ResolvedTaskRoute) => {
@@ -173,7 +175,6 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
173
175
  env: auth.env,
174
176
  signal,
175
177
  maxRetries: 0,
176
- maxTokens: 64,
177
178
  ...(route.thinkingLevel === "off" ? {} : { reasoning: route.thinkingLevel }),
178
179
  }).result();
179
180
  } catch (error) {
@@ -196,10 +197,9 @@ async function generateTitle(text: string, ctx: ExtensionContext, signal: AbortS
196
197
  .trim()
197
198
  .toLowerCase()
198
199
  .replace(/\s+/g, " ");
199
- if (!title || title.length > maxChars || title.split(" ").length > maxWords || !branchFromTitle(title)) {
200
- throw new RenameModelError("Rename task model returned an invalid title.");
201
- }
202
- return title;
200
+ const generated = parseGeneratedTitle(title);
201
+ if (!generated) throw new RenameModelError("Rename task model returned an invalid title.");
202
+ return generated;
203
203
  } catch (error) {
204
204
  if (signal.aborted || !(error instanceof RenameModelError)) throw error;
205
205
  failure = error;
@@ -213,6 +213,7 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
213
213
  pi.exec(command, [...args], options));
214
214
  let latestUserText: string | undefined;
215
215
  let automaticStarted = false;
216
+ let automaticPending = false;
216
217
  let sequence = 0;
217
218
  let active: AbortController | undefined;
218
219
  let widgetSequence = 0;
@@ -221,12 +222,18 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
221
222
  const isCurrent = (request: number, controller: AbortController) =>
222
223
  request === sequence && active === controller && !controller.signal.aborted;
223
224
 
224
- const applyHerdr = async (title: string, request: number, controller: AbortController): Promise<void> => {
225
+ const applyHerdr = async (
226
+ displayTitle: string,
227
+ branchCandidate: string,
228
+ previousDisplayTitle: string | undefined,
229
+ request: number,
230
+ controller: AbortController,
231
+ ): Promise<void> => {
225
232
  const paneId = process.env.HERDR_PANE_ID;
226
233
  if (!paneId) return;
227
234
 
228
235
  if (!isCurrent(request, controller)) return;
229
- await herdr.run(["pane", "rename", paneId, title], { signal: controller.signal });
236
+ await herdr.run(["pane", "rename", paneId, displayTitle], { signal: controller.signal });
230
237
  if (!isCurrent(request, controller)) return;
231
238
 
232
239
  const paneResponse: unknown = await herdr.json(["pane", "get", paneId], { signal: controller.signal });
@@ -239,7 +246,7 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
239
246
  const paneCount = (tabResponse as { result?: { tab?: { pane_count?: unknown } } }).result?.tab?.pane_count;
240
247
  if (typeof paneCount !== "number") throw new Error("Herdr tab response omitted pane_count.");
241
248
  if (paneCount === 1 && isCurrent(request, controller)) {
242
- await herdr.run(["tab", "rename", tabId, title], { signal: controller.signal });
249
+ await herdr.run(["tab", "rename", tabId, displayTitle], { signal: controller.signal });
243
250
  }
244
251
  if (!isCurrent(request, controller)) return;
245
252
 
@@ -261,19 +268,21 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
261
268
  return result.stdout.trim();
262
269
  };
263
270
 
264
- let branch = await runGit(["branch", "--show-current"]);
271
+ const branch = await runGit(["branch", "--show-current"]);
265
272
  if (!branch || branch.startsWith("worktree/")) {
266
- const generatedBranch = branchFromTitle(title);
267
- if (!generatedBranch || !isCurrent(request, controller)) return;
273
+ if (!isCurrent(request, controller)) return;
268
274
  const branches = (await runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"]))
269
275
  .split("\n")
270
276
  .filter(Boolean);
271
- const semanticBranch = availableBranch(generatedBranch, branches);
277
+ const semanticBranch = availableBranch(branchCandidate, branches);
272
278
  await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
273
- branch = semanticBranch;
274
279
  }
275
- if (HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) && isCurrent(request, controller)) {
276
- await herdr.run(["workspace", "rename", workspaceId, branch], { signal: controller.signal });
280
+ if (
281
+ workspaceName !== displayTitle &&
282
+ (HERDR_DEFAULT_WORKTREE_NAME.test(workspaceName) || workspaceName === previousDisplayTitle) &&
283
+ isCurrent(request, controller)
284
+ ) {
285
+ await herdr.run(["workspace", "rename", workspaceId, displayTitle], { signal: controller.signal });
277
286
  }
278
287
  };
279
288
 
@@ -289,13 +298,20 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
289
298
  };
290
299
 
291
300
  const rename = async (text: string, ctx: ExtensionContext, manual: boolean): Promise<string | undefined> => {
301
+ if (manual) {
302
+ automaticPending = false;
303
+ automaticStarted = true;
304
+ }
292
305
  const { request, controller } = begin();
293
306
  try {
294
307
  const title = await generateTitle(text, ctx, controller.signal);
295
308
  if (!isCurrent(request, controller)) return;
296
- pi.setSessionName(title);
297
- await applyHerdr(title, request, controller);
298
- return title;
309
+ const saved = savedTitle(ctx);
310
+ const previousDisplayTitle = saved && pi.getSessionName() === saved.display ? saved.display : undefined;
311
+ pi.setSessionName(title.display);
312
+ pi.appendEntry(TITLE_STATE_TYPE, title);
313
+ await applyHerdr(title.display, title.branch, previousDisplayTitle, request, controller);
314
+ return title.display;
299
315
  } catch (error) {
300
316
  if (isCurrent(request, controller) && (manual || error instanceof RenameModelError)) {
301
317
  ctx.ui.notify(error instanceof Error ? error.message : "Rename failed.", "warning");
@@ -318,6 +334,7 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
318
334
  active?.abort();
319
335
  active = undefined;
320
336
  sequence++;
337
+ automaticPending = false;
321
338
  latestUserText = latestSessionUserText(ctx);
322
339
  try {
323
340
  const taskModels = readTaskModelsConfig();
@@ -330,33 +347,40 @@ export default function herdrRenameExtension(pi: ExtensionAPI): void {
330
347
  }
331
348
  const title = pi.getSessionName();
332
349
  automaticStarted = Boolean(title || latestUserText);
333
- if (!title) return;
350
+ const saved = savedTitle(ctx);
351
+ if (!title || title !== saved?.display) return;
334
352
 
335
353
  const { request, controller } = begin();
336
- void applyHerdr(title, request, controller)
354
+ void applyHerdr(title, saved.branch, saved.display, request, controller)
337
355
  .catch(() => undefined)
338
356
  .finally(() => finish(request, controller));
339
357
  });
340
358
 
341
- pi.on("input", (event, ctx) => {
359
+ pi.on("input", (event) => {
342
360
  if (event.source === "extension" || !event.text.trim()) return { action: "continue" };
343
361
  latestUserText = event.text.slice(0, MAX_MESSAGE_CHARS);
344
- if (!automaticStarted) {
345
- automaticStarted = true;
346
- void rename(latestUserText, ctx, false);
347
- }
362
+ if (!automaticStarted) automaticPending = true;
348
363
  return { action: "continue" };
349
364
  });
350
365
 
366
+ pi.on("before_agent_start", (event, ctx) => {
367
+ if (automaticStarted || !automaticPending || !event.prompt.trim()) return;
368
+ automaticPending = false;
369
+ automaticStarted = true;
370
+ latestUserText = event.prompt.slice(0, MAX_MESSAGE_CHARS);
371
+ void rename(latestUserText, ctx, false);
372
+ });
373
+
351
374
  pi.on("session_shutdown", (_event, ctx) => {
352
375
  clearWidget(ctx);
353
376
  active?.abort();
354
377
  active = undefined;
378
+ automaticPending = false;
355
379
  sequence++;
356
380
  });
357
381
 
358
382
  pi.registerCommand("rename", {
359
- description: "Generate a new chat title from recent conversation context",
383
+ description: "Generate a new display title from recent conversation context",
360
384
  handler: async (_args, ctx) => {
361
385
  const context = recentConversation(ctx, latestUserText);
362
386
  if (!context) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@henryqw/pi-herdr-rename",
3
- "version": "1.0.2",
4
- "description": "Generate short Pi chat titles and rename the current Herdr location.",
3
+ "version": "2.0.0",
4
+ "description": "Generate short Pi display titles and rename the current Herdr location.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi",