pi-note 0.21.0 → 0.22.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.
@@ -27,7 +27,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
27
27
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
28
28
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
29
29
  }) });
30
- const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
30
+ const server = new Server({ name: 'pinote', version: '0.22.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
31
31
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
32
32
  let queue = Promise.resolve();
33
33
  function serial(run) {
@@ -134,7 +134,10 @@ function actionRevision(data, control) {
134
134
  function staleAction() {
135
135
  throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
136
136
  }
137
- function view(data, scoped = false) {
137
+ function secondaryActions(meta) {
138
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
139
+ }
140
+ function view(data, scoped = false, secondary = false) {
138
141
  const { tasks, task, config, revision } = data;
139
142
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
140
143
  const controls = [];
@@ -147,7 +150,7 @@ function view(data, scoped = false) {
147
150
  action('done', 'Complete task', { icon: icons.check });
148
151
  }
149
152
  else if (state.proposal) {
150
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
153
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
151
154
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
152
155
  }
153
156
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -196,7 +199,7 @@ function view(data, scoped = false) {
196
199
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
197
200
  }
198
201
  else if (state.proposal) {
199
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
202
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
200
203
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
201
204
  }
202
205
  else
@@ -253,7 +256,7 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
253
256
  if (request.params.uri !== URI)
254
257
  throw new Error('Unknown resource');
255
258
  const data = await snapshot(context(request.params._meta).state, extra.signal);
256
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
259
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
257
260
  }));
258
261
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
259
262
  const state = context(request.params._meta).state;
@@ -351,13 +354,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
351
354
  extra.signal.throwIfAborted();
352
355
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
353
356
  }
354
- const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
357
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
355
358
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
356
359
  return result;
357
360
  }));
358
361
  const empty = { type: 'object', properties: {}, additionalProperties: false };
359
362
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
360
- { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
363
+ { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object', properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
361
364
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
362
365
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
363
366
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -376,7 +379,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
376
379
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
377
380
  if (state.proposal && !await consumed(state.proposal.id))
378
381
  await consume(state.proposal.id);
379
- result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
382
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
380
383
  }
381
384
  else if (request.params.name === 'get_current')
382
385
  result = await selected(state, extra.signal);
package/mcp/README.md CHANGED
@@ -55,10 +55,14 @@ normal personal data. No desktop notifications are sent.
55
55
  fails closed; repair it manually rather than replacing it with defaults.
56
56
  - **Footer:** selected task plus configured fields; safe HTTP(S) links are clickable.
57
57
  The title previews the task; **✔** completes it and clears the selection without
58
- resetting the conversation or editor. Suggestions show **✖ / ✚** to dismiss or
59
- create/start/select. These use the gateway's generic footer actions, not a Pinote
58
+ resetting the conversation or editor. Suggestions show only **✚**: left-click to
59
+ create/start/select, right-click to dismiss. Right-click requires gateway 0.22.0+
60
+ and its Kitty mouse mapping; **Dismiss suggestion** in the app menu remains the
61
+ keyboard/older-host fallback. These use the gateway's generic footer actions, not a Pinote
60
62
  extension or private handler. Install the gateway's Kitty protocol handler as
61
- described in its native UI documentation; app menus remain the keyboard fallback.
63
+ and right-click mouse mapping described in its native UI documentation. Existing
64
+ installations need to add the mapping and reload Kitty; package updates do not
65
+ edit terminal configuration. App menus remain the keyboard fallback.
62
66
  - **Agent:** `propose`, `get_current`, `update_current`, `fields`, and `tags` are
63
67
  available through gateway tool discovery. `propose` returns opaque UI state;
64
68
  it does not create a task. It replaces an unaccepted suggestion only when no task
package/mcp/server.ts CHANGED
@@ -32,7 +32,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
32
32
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
33
33
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
34
34
  }) });
35
- const server = new Server({ name: 'pinote', version: '0.21.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
35
+ const server = new Server({ name: 'pinote', version: '0.22.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
36
36
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
37
37
  let queue = Promise.resolve();
38
38
  function serial<T>(run: () => Promise<T>): Promise<T> {
@@ -115,7 +115,10 @@ function actionRevision(data: Snapshot, control: Control): string {
115
115
  function staleAction(): never {
116
116
  throw new McpError(-33003, 'View changed; refresh before acting', { 'native-ui/action': { status: 'not-applied', reason: 'stale-view' } });
117
117
  }
118
- function view(data: Snapshot, scoped = false) {
118
+ function secondaryActions(meta: Record<string, unknown> | undefined): boolean {
119
+ return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
120
+ }
121
+ function view(data: Snapshot, scoped = false, secondary = false) {
119
122
  const { tasks, task, config, revision } = data;
120
123
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
121
124
  const controls: Control[] = [];
@@ -127,7 +130,7 @@ function view(data: Snapshot, scoped = false) {
127
130
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
128
131
  action('done', 'Complete task', { icon: icons.check });
129
132
  } else if (state.proposal) {
130
- action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: state.proposal.text });
133
+ action('proposal:accept', 'Accept suggestion', { icon: icons.add, description: `Left-click to create/start/select; right-click to dismiss.\n${state.proposal.text}` });
131
134
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
132
135
  }
133
136
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -161,12 +164,12 @@ function view(data: Snapshot, scoped = false) {
161
164
  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?' });
162
165
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
163
166
  }
164
- const footer: { text: string; url?: string; action?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
167
+ const footer: { text: string; url?: string; action?: string; secondaryAction?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
165
168
  if (task) {
166
169
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
167
170
  footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
168
171
  } else if (state.proposal) {
169
- footer.push({ text: `${icons.cross} `, action: 'proposal:dismiss', width: 3 }, { text: `${icons.add} `, action: 'proposal:accept', width: 3 });
172
+ footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
170
173
  footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
171
174
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
172
175
  const titleParts = footer.length;
@@ -208,7 +211,7 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
208
211
  server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
209
212
  if (request.params.uri !== URI) throw new Error('Unknown resource');
210
213
  const data = await snapshot(context(request.params._meta).state, extra.signal);
211
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta))) }] };
214
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
212
215
  }));
213
216
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
214
217
  const state = context(request.params._meta).state;
@@ -272,13 +275,13 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
272
275
  extra.signal.throwIfAborted();
273
276
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
274
277
  }
275
- const result = { view: view(await snapshot(next, extra.signal), scoped), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
278
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
276
279
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
277
280
  return result;
278
281
  }));
279
282
  const empty = { type: 'object' as const, properties: {}, additionalProperties: false };
280
283
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [
281
- { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user clicks + to create/start/select or dismiss. Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
284
+ { name: 'propose', description: 'Before multi-step implementation or configuration migrations, suggest a task if get_current returns null; skip informational questions and trivial edits. Show a short action-oriented title, optional details after a blank line, and reuse a saved tag from tags. The user left-clicks + to create/start/select or right-clicks it to dismiss (also available in the app menu). Replaces an unaccepted suggestion; continue working without waiting or asking again. Never creates a task before a user action.', inputSchema: { type: 'object' as const, properties: { text: { type: 'string', minLength: 1, maxLength: 4000 }, tag: { type: 'string', minLength: 1, maxLength: 64 } }, required: ['text'], additionalProperties: false } },
282
285
  { name: 'get_current', description: 'Read the task selected through the native Pinote UI for this conversation before multi-step implementation or configuration migrations. Returns null if unselected; then use tags and propose to suggest a task without creating one. Skip informational questions and trivial edits; never replace an existing selection.', inputSchema: empty, annotations: { readOnlyHint: true } },
283
286
  { name: 'fields', description: 'Read global footer field definitions before setting task handoff values. Does not edit settings.', inputSchema: empty, annotations: { readOnlyHint: true } },
284
287
  { name: 'tags', description: 'List saved Pinote tags.', inputSchema: empty, annotations: { readOnlyHint: true } },
@@ -294,7 +297,7 @@ server.setRequestHandler(CallToolRequestSchema, (request, extra) => serial(async
294
297
  if (!args.text.trim()) throw new Error('Task text must not be blank');
295
298
  outgoing = boundedState({ ...state, selectedId: null, proposal: { ...args, id: randomUUID() } });
296
299
  if (state.proposal && !await consumed(state.proposal.id)) await consume(state.proposal.id);
297
- result = { status: 'Suggested; user can accept (+) or dismiss. Continue working without asking again.' };
300
+ result = { status: 'Suggested; user can left-click + to accept or right-click + to dismiss (or use the app menu). Continue working without asking again.' };
298
301
  } else if (request.params.name === 'get_current') result = await selected(state, extra.signal);
299
302
  else if (request.params.name === 'fields') result = settings().config;
300
303
  else if (request.params.name === 'tags') result = JSON.parse(await note(['agent', 'tags'], extra.signal));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",