@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 +6 -19
- package/extensions/rename.ts +79 -55
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# `@henryqw/pi-herdr-rename`
|
|
2
2
|
|
|
3
|
-
Give each conversation
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
package/extensions/rename.ts
CHANGED
|
@@ -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
|
|
22
|
-
const
|
|
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
|
|
25
|
+
const SEMANTIC_BRANCH = /^[a-z][a-z0-9-]{0,11}\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
27
26
|
|
|
28
|
-
type
|
|
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
|
|
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
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
-
|
|
271
|
+
const branch = await runGit(["branch", "--show-current"]);
|
|
265
272
|
if (!branch || branch.startsWith("worktree/")) {
|
|
266
|
-
|
|
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(
|
|
277
|
+
const semanticBranch = availableBranch(branchCandidate, branches);
|
|
272
278
|
await runGit(branch ? ["branch", "-m", semanticBranch] : ["switch", "-c", semanticBranch]);
|
|
273
|
-
branch = semanticBranch;
|
|
274
279
|
}
|
|
275
|
-
if (
|
|
276
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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": "
|
|
4
|
-
"description": "Generate short Pi
|
|
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",
|