pi-note 0.23.0 → 0.24.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.
@@ -0,0 +1 @@
1
+ ↗
@@ -0,0 +1 @@
1
+ ▤
@@ -10,13 +10,15 @@ 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 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
22
  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
23
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
22
24
  proposal: Proposal.nullable().optional(),
@@ -27,7 +29,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
27
29
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
28
30
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
29
31
  }) });
30
- const server = new Server({ name: 'pinote', version: '0.23.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
32
+ const server = new Server({ name: 'pinote', version: '0.24.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
31
33
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
32
34
  let queue = Promise.resolve();
33
35
  function serial(run) {
@@ -139,6 +141,7 @@ function secondaryActions(meta) {
139
141
  }
140
142
  function footerFeatures(meta) {
141
143
  return {
144
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
142
145
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
143
146
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
144
147
  };
@@ -153,10 +156,10 @@ function view(data, scoped = false, secondary = false, features = {}) {
153
156
  // Footer actions remain valid on every screen, including Settings.
154
157
  if (task) {
155
158
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
156
- action('done', 'Complete task', { icon: icons.check });
159
+ action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete task; keep this conversation and draft' } : {}) });
157
160
  }
158
161
  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}` });
162
+ 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
163
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
161
164
  }
162
165
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -199,8 +202,18 @@ function view(data, scoped = false, secondary = false, features = {}) {
199
202
  if (matches.length > 200)
200
203
  body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
201
204
  }
202
- const footer = [{ text: `${icons.note} `, width: 3 }];
203
- if (task) {
205
+ const footer = features.footerNativeActions
206
+ ? [{ text: `${icons['open-app']} `, openApp: true, width: 3 }]
207
+ : [{ text: `${icons.note} `, width: 3 }];
208
+ if (features.footerNativeActions) {
209
+ if (task)
210
+ 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 });
211
+ else if (state.proposal)
212
+ 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 });
213
+ else
214
+ footer.push({ text: 'Pinote · No task', width: config.config.titleWidth });
215
+ }
216
+ else if (task) {
204
217
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
205
218
  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
219
  }
@@ -210,10 +223,10 @@ function view(data, scoped = false, secondary = false, features = {}) {
210
223
  }
211
224
  else
212
225
  footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
213
- const titleParts = footer.length;
226
+ let fieldCount = 0;
214
227
  if (task) {
215
228
  for (const field of fields(config.config, task.agent_notes)) {
216
- if (footer.length - titleParts >= config.config.maxFields)
229
+ if (fieldCount >= config.config.maxFields)
217
230
  break;
218
231
  const raw = task.agent_notes[field.name];
219
232
  if (!raw)
@@ -226,7 +239,22 @@ function view(data, scoped = false, secondary = false, features = {}) {
226
239
  continue;
227
240
  const substitutions = { value, url: url ?? '', number: number ?? '' };
228
241
  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 });
242
+ const target = field.link && url && url.length <= 2048 ? url : undefined;
243
+ const width = field.width ?? config.config.fieldWidth;
244
+ if (features.footerNativeActions) {
245
+ if (footer.length + (target ? 2 : 1) > 128)
246
+ break;
247
+ // Clip before splitting: each field still consumes one field slot, and
248
+ // the icon (including its space) shares the field's cell budget.
249
+ const icon = target ? ` ${icons['external-link']}` : '';
250
+ const plain = stripVTControlCharacters(rendered).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
251
+ footer.push({ text: truncateToWidth(plain.slice(0, 20_000), Math.max(0, width - visibleWidth(icon)), ''), width });
252
+ if (target)
253
+ footer.push({ text: icon, url: target });
254
+ }
255
+ else
256
+ footer.push({ text: rendered.slice(0, 20_000), ...(target ? { url: target } : {}), width });
257
+ fieldCount++;
230
258
  }
231
259
  }
232
260
  const editable = controls.filter((control) => {
@@ -269,7 +297,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
269
297
  const before = await snapshot(state, extra.signal);
270
298
  const scoped = scopedActions(request.params._meta);
271
299
  const { action, value } = request.params;
272
- const control = view(before, scoped).controls.find((item) => item.id === action);
300
+ const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
273
301
  if (!control || (control.revision ?? before.revision) !== request.params.revision)
274
302
  staleAction();
275
303
  const next = { ...before.state };
@@ -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
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,7 @@ 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 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
23
  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
24
  const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), state: z.object({
23
25
  proposal: Proposal.nullable().optional(),
@@ -32,7 +34,7 @@ const Action = z.object({ method: z.literal('native-ui/action'), params: z.objec
32
34
  uri: z.literal(URI), revision: z.string().min(1).max(256), action: z.string().min(1).max(256),
33
35
  value: z.union([z.string().max(20_000), z.number().finite(), z.boolean(), z.null()]).optional(), _meta: z.record(z.unknown()).optional(),
34
36
  }) });
35
- const server = new Server({ name: 'pinote', version: '0.23.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
37
+ const server = new Server({ name: 'pinote', version: '0.24.0' }, { capabilities: { tools: {}, resources: {}, experimental: { 'native-ui-v1': {} } } });
36
38
  // Serialize complete read/check/write cycles even when a host sends parallel calls.
37
39
  let queue = Promise.resolve();
38
40
  function serial<T>(run: () => Promise<T>): Promise<T> {
@@ -120,11 +122,12 @@ function secondaryActions(meta: Record<string, unknown> | undefined): boolean {
120
122
  }
121
123
  function footerFeatures(meta: Record<string, unknown> | undefined) {
122
124
  return {
125
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
123
126
  footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
124
127
  footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
125
128
  };
126
129
  }
127
- function view(data: Snapshot, scoped = false, secondary = false, features: { footerStyles?: boolean; footerDoubleActions?: boolean } = {}) {
130
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean } = {}) {
128
131
  const { tasks, task, config, revision } = data;
129
132
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
130
133
  const controls: Control[] = [];
@@ -134,9 +137,9 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
134
137
  // Footer actions remain valid on every screen, including Settings.
135
138
  if (task) {
136
139
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
137
- action('done', 'Complete task', { icon: icons.check });
140
+ action('done', 'Complete task', { icon: icons.check, ...(features.footerNativeActions ? { description: 'Complete task; keep this conversation and draft' } : {}) });
138
141
  } 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}` });
142
+ 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
143
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
141
144
  }
142
145
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -170,18 +173,24 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
170
173
  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
174
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
172
175
  }
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) {
176
+ const footer: { text: string; openApp?: true; url?: string; action?: string; secondaryAction?: string; doubleAction?: string; style?: { italic: boolean; color: 'accent' }; width?: number }[] = features.footerNativeActions
177
+ ? [{ text: `${icons['open-app']} `, openApp: true, width: 3 }]
178
+ : [{ text: `${icons.note} `, width: 3 }];
179
+ if (features.footerNativeActions) {
180
+ 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 });
181
+ 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 });
182
+ else footer.push({ text: 'Pinote · No task', width: config.config.titleWidth });
183
+ } else if (task) {
175
184
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
176
185
  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
186
  } else if (state.proposal) {
178
187
  footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
179
188
  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
189
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
181
- const titleParts = footer.length;
190
+ let fieldCount = 0;
182
191
  if (task) {
183
192
  for (const field of fields(config.config, task.agent_notes)) {
184
- if (footer.length - titleParts >= config.config.maxFields) break;
193
+ if (fieldCount >= config.config.maxFields) break;
185
194
  const raw = task.agent_notes[field.name];
186
195
  if (!raw) continue;
187
196
  const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
@@ -191,7 +200,18 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
191
200
  if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number) continue;
192
201
  const substitutions: Record<string, string> = { value, url: url ?? '', number: number ?? '' };
193
202
  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 });
203
+ const target = field.link && url && url.length <= 2048 ? url : undefined;
204
+ const width = field.width ?? config.config.fieldWidth;
205
+ if (features.footerNativeActions) {
206
+ if (footer.length + (target ? 2 : 1) > 128) break;
207
+ // Clip before splitting: each field still consumes one field slot, and
208
+ // the icon (including its space) shares the field's cell budget.
209
+ const icon = target ? ` ${icons['external-link']}` : '';
210
+ const plain = stripVTControlCharacters(rendered).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
211
+ footer.push({ text: truncateToWidth(plain.slice(0, 20_000), Math.max(0, width - visibleWidth(icon)), ''), width });
212
+ if (target) footer.push({ text: icon, url: target });
213
+ } else footer.push({ text: rendered.slice(0, 20_000), ...(target ? { url: target } : {}), width });
214
+ fieldCount++;
195
215
  }
196
216
  }
197
217
  const editable = controls.filter((control) => {
@@ -224,7 +244,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
224
244
  const before = await snapshot(state, extra.signal);
225
245
  const scoped = scopedActions(request.params._meta);
226
246
  const { action, value } = request.params;
227
- const control = view(before, scoped).controls.find((item) => item.id === action);
247
+ const control = view(before, scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)).controls.find((item) => item.id === action);
228
248
  if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
229
249
  const next: State = { ...before.state };
230
250
  let draft: string | undefined, preview: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",