pi-note 0.23.0 → 0.25.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
@@ -10,6 +10,9 @@ The extension and Python app share a repository but install separately.
10
10
  For server-provided UI instead of this Pinote-specific extension, see the opt-in
11
11
  [native MCP UI alternative](mcp/README.md). It uses a generic gateway host and has
12
12
  explicit initial-version limits; it is not activated by installing this extension.
13
+ MCP users can separately enable the [optional Ctrl+D exit companion](mcp/README.md#optional-ctrld-exit-confirmation).
14
+ It ships as `exit-confirm.ts` but is not loaded by default; do not load `index.ts`
15
+ alongside MCP.
13
16
 
14
17
  ## Install
15
18
 
@@ -0,0 +1 @@
1
+ ↗
@@ -0,0 +1 @@
1
+ ▤
@@ -10,13 +10,17 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
12
12
  import { z } from 'zod';
13
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
14
+ import { stripVTControlCharacters } from 'node:util';
13
15
  import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from "../footer-config.js";
14
16
  const URI = 'pinote://ui';
15
17
  const MIME = 'application/vnd.mcp-native-ui+json';
16
18
  const CONTEXT = 'native-ui/context';
17
19
  const exec = promisify(execFile);
18
20
  const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
19
- const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
21
+ const dataHome = process.env.XDG_DATA_HOME;
22
+ const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
23
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note', 'open-app', 'external-link'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
20
24
  const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
21
25
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
22
26
  proposal: Proposal.nullable().optional(),
@@ -27,7 +31,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
27
31
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
28
32
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
29
33
  }) });
30
- const server = new Server({ name: 'pinote', version: '0.23.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
34
+ const server = new Server({ name: 'pinote', version: '0.25.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
31
35
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
32
36
  let queue = Promise.resolve();
33
37
  function serial(run) {
@@ -139,6 +143,7 @@ function secondaryActions(meta) {
139
143
  }
140
144
  function footerFeatures(meta) {
141
145
  return {
146
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
142
147
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
143
148
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
144
149
  };
@@ -153,10 +158,10 @@ function view(data, scoped = false, secondary = false, features = {}) {
153
158
  // Footer actions remain valid on every screen, including Settings.
154
159
  if (task) {
155
160
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
156
- action('done', 'Complete task', { icon: icons.check });
161
+ action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete task; keep this conversation and draft' } : {}) });
157
162
  }
158
163
  else if (state.proposal) {
159
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
164
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: features.footerNativeActions ? `Create, start, and select this suggestion. Double-click the suggested footer title to accept; use Dismiss suggestion to dismiss.\n${state.proposal.text}` : `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
160
165
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
161
166
  }
162
167
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -199,8 +204,18 @@ function view(data, scoped = false, secondary = false, features = {}) {
199
204
  if (matches.length > 200)
200
205
  body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
201
206
  }
202
- const footer = [{ text: `${icons.note} `, width: 3 }];
203
- if (task) {
207
+ const footer = features.footerNativeActions
208
+ ? [{ text: `${icons['open-app']} `, openApp: true, width: 3 }]
209
+ : [{ text: `${icons.note} `, width: 3 }];
210
+ if (features.footerNativeActions) {
211
+ if (task)
212
+ footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', ...(task.state === 'in_progress' ? { doubleAction: 'done' } : {}), width: config.config.titleWidth });
213
+ else if (state.proposal)
214
+ footer.push({ text: `? ${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, doubleAction: 'proposal:accept', ...(features.footerStyles ? { style: { italic: true, color: 'accent' } } : {}), width: config.config.titleWidth });
215
+ else
216
+ footer.push({ text: 'Pinote · No task', width: config.config.titleWidth });
217
+ }
218
+ else if (task) {
204
219
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
205
220
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', ...(features.footerDoubleActions ? { doubleAction: 'done' } : {}), width: config.config.titleWidth });
206
221
  }
@@ -210,10 +225,10 @@ function view(data, scoped = false, secondary = false, features = {}) {
210
225
  }
211
226
  else
212
227
  footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
213
- const titleParts = footer.length;
228
+ let fieldCount = 0;
214
229
  if (task) {
215
230
  for (const field of fields(config.config, task.agent_notes)) {
216
- if (footer.length - titleParts >= config.config.maxFields)
231
+ if (fieldCount >= config.config.maxFields)
217
232
  break;
218
233
  const raw = task.agent_notes[field.name];
219
234
  if (!raw)
@@ -226,7 +241,22 @@ function view(data, scoped = false, secondary = false, features = {}) {
226
241
  continue;
227
242
  const substitutions = { value, url: url ?? '', number: number ?? '' };
228
243
  const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key) => substitutions[key])}`;
229
- footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
244
+ const target = field.link && url && url.length <= 2048 ? url : undefined;
245
+ const width = field.width ?? config.config.fieldWidth;
246
+ if (features.footerNativeActions) {
247
+ if (footer.length + (target ? 2 : 1) > 128)
248
+ break;
249
+ // Clip before splitting: each field still consumes one field slot, and
250
+ // the icon (including its space) shares the field's cell budget.
251
+ const icon = target ? ` ${icons['external-link']}` : '';
252
+ const plain = stripVTControlCharacters(rendered).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
253
+ footer.push({ text: truncateToWidth(plain.slice(0, 20_000), Math.max(0, width - visibleWidth(icon)), ''), width });
254
+ if (target)
255
+ footer.push({ text: icon, url: target });
256
+ }
257
+ else
258
+ footer.push({ text: rendered.slice(0, 20_000), ...(target ? { url: target } : {}), width });
259
+ fieldCount++;
230
260
  }
231
261
  }
232
262
  const editable = controls.filter((control) => {
@@ -269,7 +299,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
269
299
  const before = await snapshot(state, extra.signal);
270
300
  const scoped = scopedActions(request.params._meta);
271
301
  const { action, value } = request.params;
272
- const control = view(before, scoped).controls.find((item) => item.id === action);
302
+ const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
273
303
  if (!control || (control.revision ?? before.revision) !== request.params.revision)
274
304
  staleAction();
275
305
  const next = { ...before.state };
@@ -403,7 +433,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
403
433
  }
404
434
  else
405
435
  throw new Error('Unknown tool');
406
- return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
436
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : request.params.name === 'get_current' ? { _meta: { 'pinote/database': database } } : {}) };
407
437
  }
408
438
  catch (error) {
409
439
  return { isError: true, content: [{ type: 'text', text: String(error) }] };
@@ -413,8 +443,6 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
413
443
  // sessions. Stat polling survives missing files and atomic config replacement.
414
444
  server.oninitialized = () => {
415
445
  server.oninitialized = undefined;
416
- const dataHome = process.env.XDG_DATA_HOME;
417
- const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
418
446
  const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
419
447
  let timer;
420
448
  let closed = false;
@@ -0,0 +1,127 @@
1
+ import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { realpath } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { stripVTControlCharacters } from "node:util";
7
+
8
+ type Task = { id: number; text: string; updated_at: string; state: string };
9
+ type Current = { task: Task | null; database?: string };
10
+ const complete = "Complete task and exit";
11
+ const leave = "Exit without completing";
12
+ const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f-\x9f]/gu, " ");
13
+ function task(value: unknown): Task | null {
14
+ if (value === null) return null;
15
+ const item = value as Task | undefined;
16
+ if (!item || !Number.isSafeInteger(item.id) || item.id < 1 || typeof item.text !== "string" ||
17
+ typeof item.updated_at !== "string" || !item.updated_at ||
18
+ !["active", "in_progress", "done"].includes(item.state)) throw new Error("Invalid Pinote task response");
19
+ return item;
20
+ }
21
+ function same(left: Task | null, right: Task): boolean {
22
+ return left?.id === right.id && left.updated_at === right.updated_at && left.text === right.text && left.state === right.state;
23
+ }
24
+
25
+ // Optional MCP companion: deliberately absent from package.json's default extensions.
26
+ export default function (pi: ExtensionAPI) {
27
+ let invalidate: (() => void) | undefined;
28
+ let stop: (() => void) | undefined;
29
+ const readCurrent = async (): Promise<Current> => {
30
+ const replies: { handled?: boolean; error?: string; result?: Promise<unknown> }[] = [];
31
+ pi.events.emit("gateway:query-service-tool", {
32
+ service: "pinote", tool: "get_current",
33
+ respond: (reply: typeof replies[number]) => {
34
+ if (reply.handled) { replies.push(reply); void reply.result?.catch(() => {}); }
35
+ },
36
+ });
37
+ if (replies.length !== 1 || replies[0].error || !replies[0].result) {
38
+ throw new Error(replies[0]?.error || "Pinote gateway service unavailable; use /quit to exit without completing");
39
+ }
40
+ const result = await replies[0].result as {
41
+ isError?: boolean; content?: { type: string; text?: string }[]; _meta?: Record<string, unknown>;
42
+ };
43
+ if (result.isError) throw new Error(result.content?.find((item) => item.type === "text")?.text || "Pinote query failed");
44
+ const raw = result.content?.find((item) => item.type === "text")?.text;
45
+ if (raw === undefined) throw new Error("Missing Pinote task response");
46
+ const value = task(JSON.parse(raw));
47
+ if (value && !["active", "in_progress"].includes(value.state)) throw new Error("Pinote task is no longer active");
48
+ const database = result._meta?.["pinote/database"];
49
+ return { task: value, database: typeof database === "string" ? database : undefined };
50
+ };
51
+ pi.on("session_start", (_event, ctx) => {
52
+ if (ctx.mode !== "tui" || !ctx.hasUI) return;
53
+ const previous = ctx.ui.getEditorComponent();
54
+ let alive = true, pending = false, epoch = 0;
55
+ let controller: AbortController | undefined;
56
+ invalidate = () => { epoch++; controller?.abort(); };
57
+ const exit = async () => {
58
+ if (pending || !alive) return;
59
+ pending = true;
60
+ const generation = epoch;
61
+ const operation = controller = new AbortController();
62
+ const current = () => alive && generation === epoch && !operation.signal.aborted &&
63
+ ctx.ui.getEditorText().length === 0;
64
+ const shutdown = () => { if (!ctx.isIdle()) ctx.abort(); ctx.shutdown(); };
65
+ const run = async (args: string[]) => {
66
+ if (!current() || !ctx.isIdle()) throw new Error("Pinote exit cancelled: session changed or became busy");
67
+ const result = await pi.exec(process.env.PINOTE_NOTE_COMMAND || "note", args, { timeout: 10_000, signal: operation.signal });
68
+ if (result.killed || result.code !== 0) throw new Error(clean(result.stderr || result.stdout || "Pinote CLI failed or timed out"));
69
+ return result.stdout;
70
+ };
71
+ try {
72
+ const selected = await readCurrent();
73
+ if (!current()) return;
74
+ if (!selected.task) { shutdown(); return; }
75
+ const approved = selected.task;
76
+ const title = truncateToWidth(clean(approved.text.split("\n", 1)[0]), 70);
77
+ const choice = await ctx.ui.select(`Pinote · ${title}`, [complete, leave, "Cancel"], { signal: operation.signal });
78
+ if (!current()) return;
79
+ if (choice === leave) { shutdown(); return; }
80
+ if (choice !== complete) return;
81
+ if (!ctx.isIdle()) throw new Error("Wait for Pi to become idle before completing a task");
82
+ const dataHome = process.env.XDG_DATA_HOME;
83
+ const localDatabase = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), ".local/share"), "pinote/notes.db");
84
+ if (!selected.database || !isAbsolute(selected.database) ||
85
+ await realpath(selected.database) !== await realpath(localDatabase)) {
86
+ throw new Error("Pinote database mismatch: update the MCP server and use the same XDG_DATA_HOME in Pi and the service");
87
+ }
88
+ const version = (await run(["--version"])).trim();
89
+ const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
90
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 12) throw new Error("Pinote exit confirmation requires note 0.12.0+");
91
+ const local = task(JSON.parse(await run(["--no-notify", "agent", "get", String(approved.id)])));
92
+ const fresh = await readCurrent();
93
+ if (!same(local, approved) || !same(fresh.task, approved) || fresh.database !== selected.database) {
94
+ throw new Error("Pinote task changed; inspect it before retrying");
95
+ }
96
+ const done = task(JSON.parse(await run(["--no-notify", "agent", "done", String(approved.id), "--expected-updated-at", approved.updated_at])));
97
+ if (done?.id !== approved.id || done.state !== "done") throw new Error("Unexpected completion response; inspect the task before retrying");
98
+ if (current() && !(await readCurrent()).task && current()) shutdown();
99
+ else if (current()) ctx.ui.notify("Task completed; Pi stayed open because the selection changed", "info");
100
+ } catch (error) {
101
+ if (alive && generation === epoch) ctx.ui.notify(`Pinote: ${clean(String(error))}. Pi stayed open; inspect the task before retrying. /quit exits without completing.`, "error");
102
+ } finally { pending = false; }
103
+ };
104
+ const factory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (tui, theme, keybindings) => {
105
+ const editor = previous ? previous(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings);
106
+ const handleInput = editor.handleInput.bind(editor);
107
+ editor.handleInput = (data) => {
108
+ if (keybindings.matches(data, "app.exit") && editor.getText().length === 0) { void exit(); return; }
109
+ handleInput(data);
110
+ };
111
+ return editor;
112
+ };
113
+ ctx.ui.setEditorComponent(factory);
114
+ stop = () => {
115
+ alive = false;
116
+ invalidate?.();
117
+ if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(previous);
118
+ };
119
+ });
120
+ pi.on("session_shutdown", () => { stop?.(); stop = invalidate = undefined; });
121
+ // Retire in-flight dialogs/reads before navigation or a new agent run can act.
122
+ const interrupted = () => { invalidate?.(); };
123
+ pi.on("session_before_switch", interrupted);
124
+ pi.on("session_before_fork", interrupted);
125
+ pi.on("session_before_tree", interrupted);
126
+ pi.on("agent_start", interrupted);
127
+ }
@@ -0,0 +1 @@
1
+ ↗
@@ -0,0 +1 @@
1
+ ▤
package/mcp/README.md CHANGED
@@ -6,7 +6,8 @@ the generic [gateway UI host](https://github.com/kvidzibo/mcp-session-gateway)
6
6
  only renders and routes them. No model turn is needed for UI actions.
7
7
 
8
8
  This uses the gateway's **private native UI v1 protocol**, not browser MCP Apps or
9
- the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter.
9
+ the built-in Pi MCP resource renderer. Use gateway 0.12.0+ with its Pi adapter;
10
+ see below for features requiring newer gateways.
10
11
 
11
12
  ## Setup
12
13
 
@@ -54,7 +55,22 @@ normal personal data. No desktop notifications are sent.
54
55
  so whitespace round-trips exactly. Saves are confirmed, validated, and atomic; concurrent
55
56
  edits are rejected and unrelated settings are preserved. Invalid configuration
56
57
  fails closed; repair it manually rather than replacing it with defaults.
57
- - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
58
+ - **Fullscreen native footer (gateway 0.30.0+):** enabled only when the host
59
+ negotiates `native-ui/features.footerNativeActions=true` (the gateway advertises
60
+ `experimental.native-ui-v1.footerNativeActions=true`). **▤** opens the Pinote app;
61
+ there are no separate complete/accept buttons. Idle status reads “Pinote · No task”.
62
+ Task titles are plain text, not terminal links: single-click previews without changing state;
63
+ double-click completes an in-progress task immediately, with no extra confirmation.
64
+ Suggestions have a `? ` prefix and italic/accent styling when supported;
65
+ single-click is inert, double-click creates/starts/selects. Use the app menu to
66
+ dismiss a suggestion. Handoff labels are plain text with a separate **↗** link;
67
+ label and icon share the configured field width and count as one field. Very large
68
+ footers omit fields beyond the 128-segment protocol limit, keeping label/icon pairs intact.
69
+ Clicks/double-clicks are handled locally by the fullscreen host. Completion never
70
+ resets the conversation or editor; app menus remain the accessible keyboard fallback.
71
+ - **Regular/older-host footer:** unchanged when native footer actions are not
72
+ negotiated (the regular host never sends that feature). Selected task plus
73
+ configured fields; safe HTTP(S) links are clickable.
58
74
  Single-click the active title to preview; double-click it to complete. **✔** also
59
75
  completes and clears the selection without resetting the conversation or editor.
60
76
  Suggested titles use italic accent text; double-click to accept/start, then the
@@ -87,6 +103,40 @@ have saved the session for resume persistence. Existing extension selections are
87
103
  not imported. Changing the gateway config path or service name starts a separate
88
104
  selection namespace.
89
105
 
106
+ ## Optional Ctrl+D exit confirmation
107
+
108
+ `pi-note` 0.25.0 ships `pi/exit-confirm.ts`, a disabled-by-default **companion** to this
109
+ MCP setup, not the old `pi/index.ts` extension. It requires Pi 0.99.1+, a gateway
110
+ supporting `gateway:query-service-tool`, and this version of the Pinote MCP server
111
+ registered as `pinote`. Enable it for one launch:
112
+
113
+ ```bash
114
+ PINOTE_NOTE_COMMAND=/absolute/path/to/pinote/.venv/bin/note \
115
+ pi -e /absolute/path/to/pinote/pi/exit-confirm.ts
116
+ ```
117
+
118
+ For persistent use, add that absolute extension path to `extensions` in Pi's
119
+ user `settings.json`, preserving other entries, then `/reload`. Remove the path
120
+ and `/reload` to disable it. Installing `pi-note` does not enable the companion.
121
+ Load it after other editor extensions so it can wrap their editor without replacing
122
+ its normal input behavior.
123
+
124
+ With an empty editor, Ctrl+D (or your configured `app.exit` key) offers **Complete
125
+ task and exit**, **Exit without completing**, or **Cancel**. Escape cancels. With
126
+ no selected task Pi exits immediately; nonempty text keeps normal editing behavior.
127
+ Completion requires idle Pi, rechecks the selection and displayed revision, and
128
+ uses the local CLI without notifications. Query/completion failures leave Pi open;
129
+ inspect the task before retrying because a failed write may have committed. `/quit`
130
+ and other exit paths bypass the companion and never complete a task.
131
+
132
+ Pi and the MCP service must use the **same local database**: match `XDG_DATA_HOME`
133
+ (and HOME when using defaults). Set `PINOTE_NOTE_COMMAND` in Pi's environment if
134
+ `note` is not on PATH; its executable must use that same database. The companion
135
+ compares the server's database path and task revision before writing; it does not
136
+ fall back to a folder selection or create another selection store. Remote Pinote
137
+ services are unsupported. RPC/print mode is unaffected; no Pi/gateway patch or
138
+ personal configuration change is made by installation.
139
+
90
140
  ## Task-offer guidance
91
141
 
92
142
  Before multi-step implementation or configuration migrations, agents should call
package/mcp/server.ts CHANGED
@@ -10,6 +10,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
12
12
  import { z } from 'zod';
13
+ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
14
+ import { stripVTControlCharacters } from 'node:util';
13
15
  import { defaultFooterField, defaultHandoffPrompt, defaultNewSessionPrompt, effectiveFooterFields, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt, readFooterDocument, saveFooterConfig } from '../footer-config.ts';
14
16
 
15
17
  const URI = 'pinote://ui';
@@ -17,7 +19,9 @@ const MIME = 'application/vnd.mcp-native-ui+json';
17
19
  const CONTEXT = 'native-ui/context';
18
20
  const exec = promisify(execFile);
19
21
  const noteCommand = process.env.PINOTE_NOTE_COMMAND || 'note';
20
- const icons = Object.fromEntries(['add', 'check', 'cross', 'note'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
22
+ const dataHome = process.env.XDG_DATA_HOME;
23
+ const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
24
+ const icons = Object.fromEntries(['add', 'check', 'cross', 'note', 'open-app', 'external-link'].map((name) => [name, readFileSync(new URL(`../icons/${name}.txt`, import.meta.url), 'utf8').trim()]));
21
25
  const Proposal = z.object({ id: z.string().uuid(), text: z.string().min(1).max(4000), tag: z.string().trim().min(1).max(64).optional() }).strict();
22
26
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
23
27
  proposal: Proposal.nullable().optional(),
@@ -32,7 +36,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
32
36
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
33
37
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
34
38
  }) });
35
- const server = new Server({ name: 'pinote', version: '0.23.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
39
+ const server = new Server({ name: 'pinote', version: '0.25.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
36
40
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
37
41
  let queue = Promise.resolve();
38
42
  function serial<T>(run: () => Promise<T>): Promise<T> {
@@ -120,11 +124,12 @@ function secondaryActions(meta: Record<string, unknown> | undefined): boolean {
120
124
  }
121
125
  function footerFeatures(meta: Record<string, unknown> | undefined) {
122
126
  return {
127
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
123
128
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
124
129
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
125
130
  };
126
131
  }
127
- function view(data: Snapshot, scoped = false, secondary = false, features: { footerStyles?: boolean; footerDoubleActions?: boolean } = {}) {
132
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean } = {}) {
128
133
  const { tasks, task, config, revision } = data;
129
134
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
130
135
  const controls: Control[] = [];
@@ -134,9 +139,9 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
134
139
  // Footer actions remain valid on every screen, including Settings.
135
140
  if (task) {
136
141
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
137
- action('done', 'Complete task', { icon: icons.check });
142
+ action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete task; keep this conversation and draft' } : {}) });
138
143
  } else if (state.proposal) {
139
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
144
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: features.footerNativeActions ? `Create, start, and select this suggestion. Double-click the suggested footer title to accept; use Dismiss suggestion to dismiss.\n${state.proposal.text}` : `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
140
145
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
141
146
  }
142
147
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -170,18 +175,24 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
170
175
  for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, `${candidate.tag ? `[${candidate.tag}] ` : ''}${candidate.text.split('\n')[0]}`, { description: `#${candidate.id} · ${candidate.state}`, confirm: 'Select and start this task for this conversation?' });
171
176
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
172
177
  }
173
- const footer: { text: string; url?: string; action?: string; secondaryAction?: string; doubleAction?: string; style?: { italic: boolean; color: 'accent' }; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
174
- if (task) {
178
+ const footer: { text: string; openApp?: true; url?: string; action?: string; secondaryAction?: string; doubleAction?: string; style?: { italic: boolean; color: 'accent' }; width?: number }[] = features.footerNativeActions
179
+ ? [{ text: `${icons['open-app']} `, openApp: true, width: 3 }]
180
+ : [{ text: `${icons.note} `, width: 3 }];
181
+ if (features.footerNativeActions) {
182
+ if (task) footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', ...(task.state === 'in_progress' ? { doubleAction: 'done' } : {}), width: config.config.titleWidth });
183
+ else if (state.proposal) footer.push({ text: `? ${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, doubleAction: 'proposal:accept', ...(features.footerStyles ? { style: { italic: true, color: 'accent' } as const } : {}), width: config.config.titleWidth });
184
+ else footer.push({ text: 'Pinote · No task', width: config.config.titleWidth });
185
+ } else if (task) {
175
186
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
176
187
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', ...(features.footerDoubleActions ? { doubleAction: 'done' } : {}), width: config.config.titleWidth });
177
188
  } else if (state.proposal) {
178
189
  footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
179
190
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, ...(features.footerDoubleActions ? { doubleAction: 'proposal:accept' } : {}), ...(features.footerStyles ? { style: { italic: true, color: 'accent' } as const } : {}), width: config.config.titleWidth });
180
191
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
181
- const titleParts = footer.length;
192
+ let fieldCount = 0;
182
193
  if (task) {
183
194
  for (const field of fields(config.config, task.agent_notes)) {
184
- if (footer.length - titleParts >= config.config.maxFields) break;
195
+ if (fieldCount >= config.config.maxFields) break;
185
196
  const raw = task.agent_notes[field.name];
186
197
  if (!raw) continue;
187
198
  const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
@@ -191,7 +202,18 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
191
202
  if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number) continue;
192
203
  const substitutions: Record<string, string> = { value, url: url ?? '', number: number ?? '' };
193
204
  const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key: string) => substitutions[key])}`;
194
- footer.push({ text: rendered.slice(0, 20_000), ...(field.link && url && url.length <= 2048 ? { url } : {}), width: field.width ?? config.config.fieldWidth });
205
+ const target = field.link && url && url.length <= 2048 ? url : undefined;
206
+ const width = field.width ?? config.config.fieldWidth;
207
+ if (features.footerNativeActions) {
208
+ if (footer.length + (target ? 2 : 1) > 128) break;
209
+ // Clip before splitting: each field still consumes one field slot, and
210
+ // the icon (including its space) shares the field's cell budget.
211
+ const icon = target ? ` ${icons['external-link']}` : '';
212
+ const plain = stripVTControlCharacters(rendered).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
213
+ footer.push({ text: truncateToWidth(plain.slice(0, 20_000), Math.max(0, width - visibleWidth(icon)), ''), width });
214
+ if (target) footer.push({ text: icon, url: target });
215
+ } else footer.push({ text: rendered.slice(0, 20_000), ...(target ? { url: target } : {}), width });
216
+ fieldCount++;
195
217
  }
196
218
  }
197
219
  const editable = controls.filter((control) => {
@@ -224,7 +246,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
224
246
  const before = await snapshot(state, extra.signal);
225
247
  const scoped = scopedActions(request.params._meta);
226
248
  const { action, value } = request.params;
227
- const control = view(before, scoped).controls.find((item) => item.id === action);
249
+ const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
228
250
  if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
229
251
  const next: State = { ...before.state };
230
252
  let draft: string | undefined, preview: string | undefined;
@@ -314,15 +336,13 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
314
336
  result = JSON.parse(await note(['agent', 'update', String(task.id), '--expected-updated-at', args.expected_updated_at, '--set-json', JSON.stringify(args.set), ...args.remove.map((key) => `--remove=${key}`)], extra.signal));
315
337
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
316
338
  } else throw new Error('Unknown tool');
317
- return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : {}) };
339
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(outgoing ? { _meta: { 'native-ui/state': outgoing } } : request.params.name === 'get_current' ? { _meta: { 'pinote/database': database } } : {}) };
318
340
  } catch (error) { return { isError: true, content: [{ type: 'text', text: String(error) }] }; }
319
341
  }));
320
342
  // Every MCP process observes shared storage, including writes by CLI/GTK/other
321
343
  // sessions. Stat polling survives missing files and atomic config replacement.
322
344
  server.oninitialized = () => {
323
345
  server.oninitialized = undefined;
324
- const dataHome = process.env.XDG_DATA_HOME;
325
- const database = join(dataHome && isAbsolute(dataHome) ? dataHome : join(homedir(), '.local/share'), 'pinote/notes.db');
326
346
  const paths = [database, `${database}-wal`, join(getAgentDir(), 'pi-note.json'), receiptDir];
327
347
  let timer: ReturnType<typeof setTimeout> | undefined;
328
348
  let closed = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "files": [
29
29
  "index.ts",
30
+ "exit-confirm.ts",
30
31
  "footer-status.ts",
31
32
  "footer-links.ts",
32
33
  "footer-config.ts",