pi-note 0.17.1 → 0.18.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 +14 -3
- package/footer-config.ts +14 -4
- package/footer-settings.ts +21 -6
- package/index.ts +38 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -112,8 +112,11 @@ are rejected. The **Preview task** Settings action is also available when the
|
|
|
112
112
|
socket cannot start; the task remains visible without a link.
|
|
113
113
|
|
|
114
114
|
Each Pi session remembers its own task, including several sessions in one folder.
|
|
115
|
-
Resume restores that session's task
|
|
116
|
-
|
|
115
|
+
Resume restores that session's task. `/new` keeps the current task selected and
|
|
116
|
+
prepopulates the editor with `newSessionPrompt`; nothing is submitted automatically.
|
|
117
|
+
A fresh Pi launch starts unselected and does not import an older per-folder selection.
|
|
118
|
+
Completion still starts a clean, unselected session. Retention requires normal
|
|
119
|
+
file-backed Pi sessions; `--no-session` does not retain the selection across `/new`. Pi saves the session after the first submitted message;
|
|
117
120
|
quitting before that leaves the next launch unselected, while the task stays in progress.
|
|
118
121
|
Use **Continue**
|
|
119
122
|
to insert the same configured prompt. Completing, removing, or scheduling a task clears it
|
|
@@ -131,6 +134,7 @@ Configure `~/.pi/agent/pi-note.json` (or the agent directory set by
|
|
|
131
134
|
```json
|
|
132
135
|
{
|
|
133
136
|
"handoffPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet.",
|
|
137
|
+
"newSessionPrompt": "Read the current Pinote task. Summarize your understanding and proposed approach, but don’t start work yet.",
|
|
134
138
|
"taskOfferPolicy": "github-remote"
|
|
135
139
|
}
|
|
136
140
|
```
|
|
@@ -158,6 +162,13 @@ warns until you fix the file and reload. No project-local configuration is read.
|
|
|
158
162
|
In `/pi-note → Settings`, select **Task prompt** to edit the text inserted after
|
|
159
163
|
selection or **Continue**. **Shift+Enter** or **Ctrl+J** adds a newline; **Enter** saves it
|
|
160
164
|
globally, **Ctrl+C** clears the edit, and **Esc** cancels that unfinished edit.
|
|
165
|
+
|
|
166
|
+
**New session prompt** edits the separate draft inserted by `/new` when a task is
|
|
167
|
+
retained. Its default matches the built-in task prompt; an empty/whitespace-only
|
|
168
|
+
value disables insertion without clearing the task. `/new` rereads it each time,
|
|
169
|
+
so changes need no `/reload`. Invalid configuration warns and leaves the retained
|
|
170
|
+
task selected without inserting a prompt. Both prompts are literal text, not templates.
|
|
171
|
+
|
|
161
172
|
Settings and fields save automatically after each confirmed change; no separate
|
|
162
173
|
Save action is needed. Leaving Settings with **Esc** or **Tab** keeps saved changes
|
|
163
174
|
and discards only unfinished input. Saving never changes existing editor input or submits anything.
|
|
@@ -309,7 +320,7 @@ String definitions and the older `{ "label": "Next", "width": 24 }` form still
|
|
|
309
320
|
work. Absent/`null` fields retain the legacy automatic PR link plus `Bar` fields;
|
|
310
321
|
the first Settings save turns this legacy list into explicit global definitions.
|
|
311
322
|
|
|
312
|
-
Settings saves apply immediately in this Pi session. Run `/reload` after manual
|
|
323
|
+
Settings saves include both prompts and apply immediately in this Pi session. Run `/reload` after manual
|
|
313
324
|
footer edits or in other running sessions; settings are also reread on session start.
|
|
314
325
|
Invalid footer configuration warns and uses footer defaults; missing files silently
|
|
315
326
|
use defaults. The task prompt is also read separately on selection/Continue as
|
package/footer-config.ts
CHANGED
|
@@ -4,13 +4,20 @@ import { join } from "node:path";
|
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
|
|
6
6
|
export const defaultHandoffPrompt = "Read the current Pinote task. Summarize your understanding, but don’t start work yet.";
|
|
7
|
+
export const defaultNewSessionPrompt = defaultHandoffPrompt;
|
|
8
|
+
export function parseNewSessionPrompt(value: unknown): string {
|
|
9
|
+
if (typeof value !== "string" || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
|
|
10
|
+
throw new Error("newSessionPrompt in pi-note.json must be a string without control characters (except tabs/newlines).");
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
7
14
|
export function parseHandoffPrompt(value: unknown): string {
|
|
8
15
|
if (typeof value !== "string" || !value.trim() || /[\x00-\x08\x0b-\x1f\x7f-\x9f]/u.test(value)) {
|
|
9
16
|
throw new Error("handoffPrompt in pi-note.json must be a nonblank string without control characters (except tabs/newlines).");
|
|
10
17
|
}
|
|
11
18
|
return value;
|
|
12
19
|
}
|
|
13
|
-
export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string };
|
|
20
|
+
export type PinoteSettings = { footer: FooterConfig; handoffPrompt: string; newSessionPrompt?: string };
|
|
14
21
|
|
|
15
22
|
export type FooterField = { name: string; label: string; link: boolean; format: string; width?: number };
|
|
16
23
|
export type FooterConfig = { titleWidth: number; fieldWidth: number; maxFields: number; fields: FooterField[] | null };
|
|
@@ -90,9 +97,12 @@ export function loadFooterConfig(warn: (message: string) => void): FooterConfig
|
|
|
90
97
|
|
|
91
98
|
// Synchronous compare-and-replace: reject edits made while the settings dialog was open,
|
|
92
99
|
// preserve unrelated keys, and never leave partial JSON behind.
|
|
93
|
-
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string) {
|
|
100
|
+
export function saveFooterConfig(config: FooterConfig, expectedRaw: string | null, handoffPrompt?: string, newSessionPrompt?: string) {
|
|
94
101
|
const checked = parseFooterConfig({ footer: config });
|
|
95
|
-
const
|
|
102
|
+
const prompts = {
|
|
103
|
+
...(handoffPrompt === undefined ? {} : { handoffPrompt: parseHandoffPrompt(handoffPrompt) }),
|
|
104
|
+
...(newSessionPrompt === undefined ? {} : { newSessionPrompt: parseNewSessionPrompt(newSessionPrompt) }),
|
|
105
|
+
};
|
|
96
106
|
const directory = getAgentDir();
|
|
97
107
|
mkdirSync(directory, { recursive: true });
|
|
98
108
|
const path = join(directory, "pi-note.json");
|
|
@@ -107,7 +117,7 @@ export function saveFooterConfig(config: FooterConfig, expectedRaw: string | nul
|
|
|
107
117
|
if (lstatSync(path, { throwIfNoEntry: false })?.isSymbolicLink()) throw new Error("pi-note.json is a symlink. Edit its target manually rather than replacing the link.");
|
|
108
118
|
const root = expectedRaw === null ? {} : JSON.parse(expectedRaw);
|
|
109
119
|
if (!object(root)) throw new Error("Invalid pi-note configuration; expected an object.");
|
|
110
|
-
raw = `${JSON.stringify({ ...root, ...
|
|
120
|
+
raw = `${JSON.stringify({ ...root, ...prompts, footer: checked }, null, 2)}\n`;
|
|
111
121
|
writeFileSync(temp, raw, { flag: "wx", mode: 0o600 });
|
|
112
122
|
renameSync(temp, path);
|
|
113
123
|
} finally { rmSync(temp, { force: true }); closeSync(descriptor); rmSync(lock, { force: true }); }
|
package/footer-settings.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Editor, Input, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
|
-
import { defaultFooterField, parseFooterConfig, parseHandoffPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
|
|
3
|
+
import { defaultFooterField, defaultNewSessionPrompt, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, type PinoteSettings, type FooterConfig, type FooterField } from "./footer-config.ts";
|
|
4
4
|
|
|
5
5
|
type Matches = (data: string, action: string) => boolean;
|
|
6
6
|
type Row = { label: string; action: () => void };
|
|
@@ -42,7 +42,9 @@ export class TaskMenu {
|
|
|
42
42
|
export class FooterSettings {
|
|
43
43
|
private draft: FooterConfig;
|
|
44
44
|
private prompt: string;
|
|
45
|
+
private newSessionPrompt: string;
|
|
45
46
|
private editor?: Editor;
|
|
47
|
+
private editorMode?: "handoff" | "newSession";
|
|
46
48
|
private createEditor: () => Editor;
|
|
47
49
|
private knownFields: string[];
|
|
48
50
|
private theme: Theme;
|
|
@@ -65,6 +67,7 @@ export class FooterSettings {
|
|
|
65
67
|
this.configurationError = configurationError;
|
|
66
68
|
this.draft = structuredClone(config.footer);
|
|
67
69
|
this.prompt = config.handoffPrompt;
|
|
70
|
+
this.newSessionPrompt = config.newSessionPrompt === undefined ? defaultNewSessionPrompt : parseNewSessionPrompt(config.newSessionPrompt);
|
|
68
71
|
this.createEditor = createEditor;
|
|
69
72
|
this.save = save;
|
|
70
73
|
this.draft.fields ??= [];
|
|
@@ -86,11 +89,13 @@ export class FooterSettings {
|
|
|
86
89
|
private validate(field: FooterField): FooterField {
|
|
87
90
|
return parseFooterConfig({ footer: { fields: [field] } }).fields![0];
|
|
88
91
|
}
|
|
89
|
-
private persist(footer = this.draft, handoffPrompt = this.prompt) {
|
|
90
|
-
const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt)
|
|
92
|
+
private persist(footer = this.draft, handoffPrompt = this.prompt, newSessionPrompt = this.newSessionPrompt) {
|
|
93
|
+
const settings = { footer: parseFooterConfig({ footer }), handoffPrompt: parseHandoffPrompt(handoffPrompt),
|
|
94
|
+
newSessionPrompt: parseNewSessionPrompt(newSessionPrompt) };
|
|
91
95
|
this.save(structuredClone(settings));
|
|
92
96
|
this.draft = settings.footer;
|
|
93
97
|
this.prompt = settings.handoffPrompt;
|
|
98
|
+
this.newSessionPrompt = settings.newSessionPrompt;
|
|
94
99
|
}
|
|
95
100
|
private persistField(field: FooterField) {
|
|
96
101
|
const candidate = this.validate(field);
|
|
@@ -119,11 +124,19 @@ export class FooterSettings {
|
|
|
119
124
|
}),
|
|
120
125
|
}));
|
|
121
126
|
rows.push({ label: `Task prompt: ${display(this.prompt)}`, action: () => {
|
|
127
|
+
this.editorMode = "handoff";
|
|
122
128
|
this.editor = this.createEditor();
|
|
123
129
|
this.editor.disableSubmit = true;
|
|
124
130
|
this.editor.setText(this.prompt);
|
|
125
131
|
this.editor.focused = this.hasFocus;
|
|
126
132
|
} });
|
|
133
|
+
rows.push({ label: `New session prompt: ${display(this.newSessionPrompt).trim() || "(disabled)"}`, action: () => {
|
|
134
|
+
this.editorMode = "newSession";
|
|
135
|
+
this.editor = this.createEditor();
|
|
136
|
+
this.editor.disableSubmit = true;
|
|
137
|
+
this.editor.setText(this.newSessionPrompt);
|
|
138
|
+
this.editor.focused = this.hasFocus;
|
|
139
|
+
} });
|
|
127
140
|
const configured = this.draft.fields!.map((field) => field.name);
|
|
128
141
|
for (const name of [...configured, ...this.knownFields.filter((name) => !configured.includes(name))]) {
|
|
129
142
|
rows.push({ label: `Field: ${display(name)}${configured.includes(name) ? "" : " (not configured)"}`, action: () => this.edit(name) });
|
|
@@ -168,11 +181,13 @@ export class FooterSettings {
|
|
|
168
181
|
if (isTab(data)) this.done("tasks");
|
|
169
182
|
else if (this.editor) {
|
|
170
183
|
if (matchesKey(data, "ctrl+c")) this.editor.setText("");
|
|
171
|
-
else if (this.matches(data, "tui.select.cancel")) this.editor = undefined;
|
|
184
|
+
else if (this.matches(data, "tui.select.cancel")) { this.editor = undefined; this.editorMode = undefined; }
|
|
172
185
|
else if (this.matches(data, "tui.input.newLine")) this.editor.handleInput(data);
|
|
173
186
|
else if (this.matches(data, "tui.select.confirm")) {
|
|
174
|
-
this.persist(this.draft, this.editor.getExpandedText());
|
|
187
|
+
if (this.editorMode === "newSession") this.persist(this.draft, this.prompt, this.editor.getExpandedText());
|
|
188
|
+
else this.persist(this.draft, this.editor.getExpandedText(), this.newSessionPrompt);
|
|
175
189
|
this.editor = undefined;
|
|
190
|
+
this.editorMode = undefined;
|
|
176
191
|
} else this.editor.handleInput(data);
|
|
177
192
|
} else if (this.input) {
|
|
178
193
|
if (this.matches(data, "tui.select.cancel")) this.input = undefined;
|
|
@@ -197,7 +212,7 @@ export class FooterSettings {
|
|
|
197
212
|
const lines = [this.theme.fg("accent", "Pinote — Global Settings"), this.theme.fg("dim", "Tasks (Tab) · Settings · Fields")];
|
|
198
213
|
if (this.configurationError) lines.push(this.theme.fg("warning", this.configurationError));
|
|
199
214
|
if (this.editor) {
|
|
200
|
-
lines.push(this.theme.fg("accent", "Task prompt"), ...this.editor.render(width),
|
|
215
|
+
lines.push(this.theme.fg("accent", this.editorMode === "newSession" ? "New session prompt" : "Task prompt"), ...this.editor.render(width),
|
|
201
216
|
this.theme.fg("dim", "Enter save · Shift+Enter/Ctrl+J newline · Ctrl+C clear · Esc cancel edit"));
|
|
202
217
|
} else if (this.input) {
|
|
203
218
|
lines.push(this.theme.fg("accent", this.input.heading), ...this.input.widget.render(width),
|
package/index.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
import { updateTaskFooter, clearTaskFooter } from "./footer-status.ts";
|
|
9
|
-
import { defaultFooterConfig, defaultHandoffPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
|
|
9
|
+
import { defaultFooterConfig, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, loadFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig, type FooterConfig, type PinoteSettings } from "./footer-config.ts";
|
|
10
10
|
import { FooterSettings, TaskMenu, withSettingsTab, type SettingsOption, type SettingsResult } from "./footer-settings.ts";
|
|
11
11
|
import { TaskPicker, taskState, taskTag } from "./task-picker.ts";
|
|
12
12
|
import { bundledCLIVersion, detectCLI, setupCLI, setupHint, versionAtLeast, type CLIAction } from "./setup.ts";
|
|
@@ -36,6 +36,13 @@ type Summary = Pick<Task, "id" | "text" | "state" | "tag">;
|
|
|
36
36
|
// The standard Pi footer accepts text, so ship a portable terminal glyph, not a theme icon.
|
|
37
37
|
const noteIcon = readFileSync(new URL("./icons/note.txt", import.meta.url), "utf8").trim();
|
|
38
38
|
const previewType = "pinote-preview";
|
|
39
|
+
// /new replaces the extension runtime, even before the outgoing session is saved.
|
|
40
|
+
// Transfer only the ID, keyed by the exact destination session file, within this process.
|
|
41
|
+
const continuationKey = Symbol.for("pi-note.new-session-selections");
|
|
42
|
+
const processState = globalThis as typeof globalThis & {
|
|
43
|
+
[continuationKey]?: Map<string, number>;
|
|
44
|
+
};
|
|
45
|
+
const continuations = processState[continuationKey] ??= new Map<string, number>();
|
|
39
46
|
const compatible = "Incompatible note CLI response. Install pinote 0.3.0+ and check note on PATH.";
|
|
40
47
|
const validId = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) > 0;
|
|
41
48
|
const record = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -420,6 +427,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
420
427
|
alive = true;
|
|
421
428
|
activeContext = ctx;
|
|
422
429
|
selectedId = readSelection(ctx);
|
|
430
|
+
const sessionFile = ctx.sessionManager.getSessionFile?.();
|
|
431
|
+
const continuation = sessionFile ? continuations.get(sessionFile) : undefined;
|
|
432
|
+
if (sessionFile) continuations.delete(sessionFile);
|
|
433
|
+
const carrying = _event.reason === "new" && validId(continuation);
|
|
434
|
+
if (carrying) remember(continuation);
|
|
423
435
|
restoreSuggestion(ctx);
|
|
424
436
|
if (_event.reason === "new" || _event.reason === "fork") {
|
|
425
437
|
// A fork may include an ancestor proposal whose consent was consumed elsewhere.
|
|
@@ -451,6 +463,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
451
463
|
if (!alive || generation !== epoch) return;
|
|
452
464
|
if (suggestion) previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
|
|
453
465
|
await refresh(ctx);
|
|
466
|
+
if (carrying && alive && generation === epoch) {
|
|
467
|
+
try {
|
|
468
|
+
const branch = branchEpoch;
|
|
469
|
+
const current = await selected(ctx);
|
|
470
|
+
if (!current || !alive || generation !== epoch || branch !== branchEpoch || selectedId !== current.id ||
|
|
471
|
+
!ctx.hasUI || ctx.mode !== "tui") return;
|
|
472
|
+
const config = readConfig();
|
|
473
|
+
const prompt = "newSessionPrompt" in config ? parseNewSessionPrompt(config.newSessionPrompt) : defaultNewSessionPrompt;
|
|
474
|
+
if (prompt.trim()) {
|
|
475
|
+
const draft = ctx.ui.getEditorText();
|
|
476
|
+
ctx.ui.setEditorText(draft ? `${draft}\n\n${prompt}` : prompt);
|
|
477
|
+
}
|
|
478
|
+
} catch (error) {
|
|
479
|
+
if (alive && generation === epoch && ctx.hasUI) ctx.ui.notify(`Pinote new session: ${clean(String(error))}`, "warning");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
454
482
|
});
|
|
455
483
|
pi.on("session_tree", async (_event, ctx) => {
|
|
456
484
|
if (!alive) return;
|
|
@@ -470,6 +498,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
470
498
|
pi.on("before_agent_start", async (_event, ctx) => { await refresh(ctx); });
|
|
471
499
|
pi.on("agent_end", async (_event, ctx) => { await refresh(ctx); });
|
|
472
500
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
501
|
+
// Shutdown means replacement was accepted; cancelled /new never leaves a transfer.
|
|
502
|
+
if (_event.reason === "new" && _event.targetSessionFile && validId(selectedId)) {
|
|
503
|
+
// Bound orphaned transfers if creating the replacement runtime fails.
|
|
504
|
+
if (continuations.size >= 32) continuations.delete(continuations.keys().next().value!);
|
|
505
|
+
continuations.set(_event.targetSessionFile, selectedId);
|
|
506
|
+
}
|
|
473
507
|
setupAbort?.abort();
|
|
474
508
|
cancelCompletionDispatch();
|
|
475
509
|
clearSuggestion(ctx, false);
|
|
@@ -551,7 +585,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
551
585
|
return;
|
|
552
586
|
}
|
|
553
587
|
if (settings) {
|
|
554
|
-
let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt };
|
|
588
|
+
let config: PinoteSettings = { footer: { ...defaultFooterConfig, fields: [] }, handoffPrompt: defaultHandoffPrompt, newSessionPrompt: defaultNewSessionPrompt };
|
|
555
589
|
let expectedRaw: string | null = null;
|
|
556
590
|
let configurationError: string | undefined;
|
|
557
591
|
try {
|
|
@@ -560,6 +594,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
560
594
|
config = {
|
|
561
595
|
footer: { ...document.config, fields: effectiveFooterFields(document.config, current?.agent_notes) },
|
|
562
596
|
handoffPrompt: "handoffPrompt" in root ? parseHandoffPrompt(root.handoffPrompt) : defaultHandoffPrompt,
|
|
597
|
+
newSessionPrompt: "newSessionPrompt" in root ? parseNewSessionPrompt(root.newSessionPrompt) : defaultNewSessionPrompt,
|
|
563
598
|
};
|
|
564
599
|
expectedRaw = document.raw;
|
|
565
600
|
} catch (error) {
|
|
@@ -590,7 +625,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
590
625
|
}), (updated) => {
|
|
591
626
|
if (!canAct()) throw new Error("This session changed. Reopen settings before editing.");
|
|
592
627
|
if (configurationError) throw new Error(configurationError);
|
|
593
|
-
const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt);
|
|
628
|
+
const saved = saveFooterConfig(updated.footer, expectedRaw, updated.handoffPrompt, updated.newSessionPrompt);
|
|
594
629
|
expectedRaw = saved.raw;
|
|
595
630
|
footerConfig = saved.config;
|
|
596
631
|
void refresh(ctx);
|