pi-note 0.16.1 → 0.17.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
@@ -81,7 +81,12 @@ to model**: it is a local custom session entry, excluded from subsequent model
81
81
  requests, compaction and branch summaries. It survives session resume but does not
82
82
  modify the note, editor draft or task selection, and never starts a model call.
83
83
  The agent can still read the task separately with `pinote_get_current`.
84
- Previewing requires Pi to be idle with no other Pinote operation open.
84
+ Click previews work while the agent is running, provided no other Pinote operation
85
+ is open. Each click saves a fresh snapshot in the transcript, above any currently
86
+ streaming response. Later output scrolls it upward; it does not expire. Preview
87
+ reads never lock out the agent's task updates. Settings still requires Pi to be idle.
88
+
89
+ ![Preview above a running response](../docs/images/pi-note-active-preview.png)
85
90
 
86
91
  On Linux, the task text (pin, tag and title) is a clickable OSC 8 link to a
87
92
  private per-session Unix socket. Overflow at the configured title width stays linked.
@@ -184,8 +189,9 @@ configuration only; project-local files are not read.
184
189
  - `pinote_tags`: list saved tag names.
185
190
  - `pinote_propose`: show a suggested task beside the pin icon in the footer without
186
191
  creating a note. Optional `tag`; full text is retained, but the footer shows only
187
- its title. Requires a TUI with no selected task. The agent continues your work
188
- while the suggestion awaits your choice.
192
+ its title. Requires a TUI with no selected task. A new proposal replaces the
193
+ unaccepted suggestion without waiting for dismissal. The agent continues your
194
+ work while the suggestion awaits your choice.
189
195
  - `pinote_add`: create an active task. Optional `tag`. `select: true` starts and
190
196
  remembers it for this session only; use that only after the user agrees.
191
197
 
@@ -204,7 +210,7 @@ asking in chat. The pin footer shows a pending suggestion, distinct from a selec
204
210
  ![Suggested task controls](../docs/images/pi-task-suggestion.png)
205
211
  ![Selected task with Done control](../docs/images/pi-task-selected.png)
206
212
 
207
- - Muted **✕** dismisses without creating a note; further offers are suppressed until a new session.
213
+ - Muted **✕** dismisses only the current suggestion without creating a note; the agent can suggest another task while none is selected.
208
214
  - Accent **+** creates, starts, and selects the suggested task.
209
215
  - Green **✓** completes the selected task, starts a clean unselected session, and reloads Pi without a completion prompt. Menu **Done** and **Complete task** in Settings do the same.
210
216
 
@@ -216,8 +222,11 @@ Controls use the [Kitty preview handler](#display-only-preview), with no additio
216
222
 
217
223
  Completion requires an idle Pi session and no pending Pinote operation. It rejects stale selected-task links and externally changed revisions. Accepted mutations drain even if the helper disconnects. On an error, check `/pi-note` before retrying, because a write may already have committed. Failed or stale writes never restart Pi. If another extension cancels the new session, the task remains completed and Pi reports the cancellation. The reload runs through the new session's fresh context; retired contexts are not reused.
218
224
 
219
- An existing suggestion is retained rather than replaced. Pending suggestions survive `/reload` and session resume, including their full text and tag; new links replace stale capabilities. Accepted or dismissed suggestions never reappear on reload. New sessions and forks start without a suggestion; selecting a task or navigating the tree clears it. Suggestion state is local, display-only session metadata, not model context. Pi saves the session after the first submitted message; quitting before that can still lose unsaved session state. Repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
225
+ If another task fits better, the agent can replace an unaccepted suggestion by calling `pinote_propose` again, without waiting for dismissal. Replacement invalidates the old +/✕ links; only the latest suggestion can be accepted or dismissed. Pending suggestions survive `/reload` and session resume, including their full text and tag; new links replace stale capabilities. Accepted or dismissed suggestions never reappear on reload, but dismissal does not block new proposals—even in sessions saved by older versions. New sessions and forks start without a suggestion; selecting a task or navigating the tree clears it. Suggestion state is local, display-only session metadata, not model context. Pi saves the session after the first submitted message; quitting before that can still lose unsaved session state. Repeated acceptance cannot duplicate a note. A busy Pinote operation blocks acceptance. Noninteractive agents still ask in chat before using `pinote_add` with `select: true`. An existing selection is not replaced unless the user asks to switch. Reuse a saved
220
226
  tag name when it fits.
227
+
228
+ ![Pending suggestion replaced without creating a task](../docs/images/pi-task-replacement.png)
229
+
221
230
  Task text starts with a short, action-oriented title (aim for at most 60 characters).
222
231
  Put context, URLs, commands, and acceptance criteria after a blank line; the title
223
232
  should not contain implementation details. This is agent guidance, not a storage limit.
package/index.ts CHANGED
@@ -20,7 +20,7 @@ const offerGuidance = {
20
20
  "github-remote": `${implementationOfferGuidance} For eligible work, first verify with Git that the current repository has a remote whose URL host is github.com (HTTPS or SSH). Only then use pinote_propose to show one suggested \`[tag] task\` in the bottom bar instead of asking in chat. In noninteractive modes, ask before creating it. Local paths, other hosts, and GitHub-looking URL paths do not qualify. If no GitHub remote is verified, do not offer a task.`,
21
21
  never: "Do not offer to create a pinote task. Create one only when the user explicitly requests it.",
22
22
  };
23
- const consentGuidance = "The suggestion bar's plus creates, starts and selects the note; its cross dismisses it. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
23
+ const consentGuidance = "When a different task would fit better and no task is selected, call pinote_propose again to replace the unaccepted suggestion without waiting for dismissal. The suggestion bar's plus creates, starts and selects the note; its cross dismisses only that suggestion, not future suggestions. Continue the requested work while the suggestion is pending; do not ask again in chat or create a note yourself. On a chat no, continue without a note; on a chat yes, call pinote_add with select true. Never add or select without consent. If a task is already selected, do not replace it unless the user asks to switch.";
24
24
  const titleGuidance = "Start task text with a short, action-oriented summary (aim for at most 60 characters). Put context, URLs, commands, and acceptance criteria after a blank line. Never put implementation details in the title.";
25
25
  const tagGuidance = "Reuse a pinote_tags name when it fits. One tag; case-sensitive, trimmed, at most 64 characters. Tags are set when creating tasks with pinote_add; agent tools cannot retag existing tasks. Do not use pinote_update_current for tags or task text.";
26
26
  type Task = {
@@ -136,8 +136,7 @@ export default function (pi: ExtensionAPI) {
136
136
  let previewBridge: ReturnType<typeof createPreviewBridge> | undefined;
137
137
  const suggestionType = "pinote-suggestion";
138
138
  let suggestion: { text: string; tag?: string } | undefined;
139
- let offerDeclined = false;
140
- const saveSuggestion = () => pi.appendEntry(suggestionType, { suggestion: suggestion ?? null, declined: offerDeclined });
139
+ const saveSuggestion = () => pi.appendEntry(suggestionType, { suggestion: suggestion ?? null });
141
140
  const clearSuggestion = (ctx: ExtensionContext, persist = true) => {
142
141
  const hadSuggestion = suggestion !== undefined;
143
142
  previewBridge?.clearSuggestion();
@@ -147,23 +146,23 @@ export default function (pi: ExtensionAPI) {
147
146
  };
148
147
  const restoreSuggestion = (ctx: ExtensionContext) => {
149
148
  suggestion = undefined;
150
- offerDeclined = false;
151
149
  for (const entry of ctx.sessionManager.getBranch()) {
152
150
  if (entry.type !== "custom" || entry.customType !== suggestionType) continue;
153
151
  const data = entry.data;
154
- if (!record(data) || typeof data.declined !== "boolean") continue;
152
+ if (!record(data)) continue;
155
153
  const value = data.suggestion;
156
- suggestion = record(value) && text(value.text) && value.text.trim() &&
154
+ // Legacy dismissal entries clear their proposal, but never block a new one.
155
+ suggestion = data.declined !== true && record(value) && text(value.text) && value.text.trim() &&
157
156
  (value.tag === undefined || (text(value.tag) && value.tag.trim() && value.tag.length <= 64))
158
157
  ? { text: value.text, ...(value.tag === undefined ? {} : { tag: value.tag as string }) } : undefined;
159
- offerDeclined = data.declined;
160
158
  }
161
- if (selectedId !== null || offerDeclined) suggestion = undefined;
159
+ if (selectedId !== null) suggestion = undefined;
162
160
  };
163
161
  let alive = true;
164
162
  let epoch = 0;
165
163
  let refreshSerial = 0;
166
164
  let pending: symbol | undefined;
165
+ let previewPending = false;
167
166
  let setupAbort: AbortController | undefined;
168
167
  let activeContext: ExtensionContext | undefined;
169
168
  let cliState: CLIAction | undefined;
@@ -312,18 +311,18 @@ export default function (pi: ExtensionAPI) {
312
311
  }
313
312
  };
314
313
  const preview = async (ctx: ExtensionContext, requestSignal?: AbortSignal): Promise<boolean> => {
315
- if (!ctx.hasUI || ctx.mode !== "tui" || !ctx.isIdle() || pending || !validId(selectedId)) return false;
314
+ if (!ctx.hasUI || ctx.mode !== "tui" || pending || previewPending || !validId(selectedId)) return false;
316
315
  const generation = epoch;
317
316
  const branch = branchEpoch;
318
317
  const id = selectedId;
319
- const operation = Symbol();
320
- pending = operation;
318
+ // Serialize clicks without blocking the agent's task updates.
319
+ previewPending = true;
321
320
  // Complete before the bridge's 5s and helper's 6s deadlines; disconnection cancels reads too.
322
321
  const controller = new AbortController();
323
322
  const deadline = Date.now() + 4000;
324
323
  const timer = setTimeout(() => controller.abort(), 4000);
325
324
  const signal = requestSignal ? AbortSignal.any([requestSignal, controller.signal]) : controller.signal;
326
- const canUse = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && ctx.isIdle() &&
325
+ const canUse = () => alive && generation === epoch && branch === branchEpoch && selectedId === id && !pending &&
327
326
  !signal.aborted && Date.now() < deadline;
328
327
  try {
329
328
  const current = await currentTask(signal, canUse);
@@ -336,7 +335,7 @@ export default function (pi: ExtensionAPI) {
336
335
  return false;
337
336
  } finally {
338
337
  clearTimeout(timer);
339
- if (pending === operation) pending = undefined;
338
+ previewPending = false;
340
339
  }
341
340
  };
342
341
  const dispatchCompletion = (ctx: ExtensionContext, displayed: Task, selection: string): Promise<boolean> => {
@@ -425,7 +424,6 @@ export default function (pi: ExtensionAPI) {
425
424
  if (_event.reason === "new" || _event.reason === "fork") {
426
425
  // A fork may include an ancestor proposal whose consent was consumed elsewhere.
427
426
  clearSuggestion(ctx, false);
428
- offerDeclined = false;
429
427
  if (ctx.sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === suggestionType)) saveSuggestion();
430
428
  }
431
429
  footerConfig = loadFooterConfig((message) => {
@@ -719,7 +717,6 @@ export default function (pi: ExtensionAPI) {
719
717
  const proposed = suggestion;
720
718
  if (!alive || !proposed || selectedId !== null) return false;
721
719
  if (choice === "no") {
722
- offerDeclined = true;
723
720
  clearSuggestion(ctx);
724
721
  return true;
725
722
  }
@@ -758,7 +755,7 @@ export default function (pi: ExtensionAPI) {
758
755
  name: "pinote_propose",
759
756
  label: "Pinote suggest task",
760
757
  promptGuidelines: createGuidance,
761
- description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. An existing suggestion is retained; a dismissed offer is not repeated. In noninteractive modes ask in chat, then use pinote_add only after consent.",
758
+ description: "Show a suggested task beside the pin icon in the footer, without creating it or asking in chat. + creates, starts and selects it; ✕ dismisses it. Continue the requested work while awaiting consent. Requires an interactive TUI with no selected task. A new proposal replaces the unaccepted suggestion and invalidates its old links; no dismissal is needed. Dismissal clears only the current suggestion and allows later proposals. In noninteractive modes ask in chat, then use pinote_add only after consent.",
762
759
  parameters: Type.Object({
763
760
  text: Type.String({ minLength: 1, description: "Short action-oriented title, then optional details after a blank line." }),
764
761
  tag: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
@@ -774,13 +771,11 @@ export default function (pi: ExtensionAPI) {
774
771
  if (current || selectedId !== null) throw new Error("A task is already selected; keep it unless the user asks to switch.");
775
772
  if (pending || setupAbort) throw new Error("A pinote operation is already open. Retry after it finishes.");
776
773
  const result = (status: string) => ({ content: [{ type: "text" as const, text: JSON.stringify({ status }) }], details: { status } });
777
- if (offerDeclined) return result("dismissed; continue without a note and do not offer again");
778
- if (suggestion) return result("pending; existing suggestion retained, continue work without asking again");
779
774
  suggestion = { text: params.text, ...(params.tag === undefined ? {} : { tag: params.tag.trim() }) };
780
775
  saveSuggestion();
781
776
  previewBridge?.setSuggestion((choice) => respondToSuggestion(choice, ctx));
782
777
  ctx.ui.setStatus("pinote", suggestionStatus(ctx));
783
- return result("pending; user can click ✓ to add and select or ✕ to dismiss, continue work without asking again");
778
+ return result("pending; user can click + to add and select or ✕ to dismiss, continue work without asking again");
784
779
  },
785
780
  });
786
781
  pi.registerTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/preview-click.cjs CHANGED
@@ -59,7 +59,7 @@ if (require.main === module) {
59
59
  process.exitCode = 2;
60
60
  } else {
61
61
  preview(process.argv[2]).catch(() => {
62
- console.error('Pinote preview or suggestion unavailable; select a task and wait until Pi is idle.');
62
+ console.error('Pinote action unavailable; select a task and retry after any Pinote operation finishes. Done also requires Pi to be idle.');
63
63
  process.exitCode = 1;
64
64
  });
65
65
  }