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 +3 -0
- package/dist-mcp/icons/external-link.txt +1 -0
- package/dist-mcp/icons/open-app.txt +1 -0
- package/dist-mcp/mcp/server.js +41 -13
- package/exit-confirm.ts +127 -0
- package/icons/external-link.txt +1 -0
- package/icons/open-app.txt +1 -0
- package/mcp/README.md +52 -2
- package/mcp/server.ts +34 -14
- package/package.json +2 -1
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
|
+
▤
|
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -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
|
|
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.
|
|
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 =
|
|
203
|
-
|
|
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
|
-
|
|
228
|
+
let fieldCount = 0;
|
|
214
229
|
if (task) {
|
|
215
230
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
216
|
-
if (
|
|
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
|
-
|
|
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;
|
package/exit-confirm.ts
ADDED
|
@@ -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
|
-
- **
|
|
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
|
|
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.
|
|
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 }[] =
|
|
174
|
-
|
|
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
|
-
|
|
192
|
+
let fieldCount = 0;
|
|
182
193
|
if (task) {
|
|
183
194
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
184
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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",
|