pi-note 0.22.1 → 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.22.1' }, { 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) {
@@ -137,7 +139,14 @@ function staleAction() {
137
139
  function secondaryActions(meta) {
138
140
  return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
139
141
  }
140
- function view(data, scoped = false, secondary = false) {
142
+ function footerFeatures(meta) {
143
+ return {
144
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
145
+ footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
146
+ footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
147
+ };
148
+ }
149
+ function view(data, scoped = false, secondary = false, features = {}) {
141
150
  const { tasks, task, config, revision } = data;
142
151
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
143
152
  const controls = [];
@@ -147,10 +156,10 @@ function view(data, scoped = false, secondary = false) {
147
156
  // Footer actions remain valid on every screen, including Settings.
148
157
  if (task) {
149
158
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
150
- 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' } : {}) });
151
160
  }
152
161
  else if (state.proposal) {
153
- 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}` });
154
163
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
155
164
  }
156
165
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -193,21 +202,31 @@ function view(data, scoped = false, secondary = false) {
193
202
  if (matches.length > 200)
194
203
  body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
195
204
  }
196
- const footer = [{ text: `${icons.note} `, width: 3 }];
197
- 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) {
198
217
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
199
- footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
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 });
200
219
  }
201
220
  else if (state.proposal) {
202
221
  footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
203
- footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
222
+ 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' } } : {}), width: config.config.titleWidth });
204
223
  }
205
224
  else
206
225
  footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
207
- const titleParts = footer.length;
226
+ let fieldCount = 0;
208
227
  if (task) {
209
228
  for (const field of fields(config.config, task.agent_notes)) {
210
- if (footer.length - titleParts >= config.config.maxFields)
229
+ if (fieldCount >= config.config.maxFields)
211
230
  break;
212
231
  const raw = task.agent_notes[field.name];
213
232
  if (!raw)
@@ -220,7 +239,22 @@ function view(data, scoped = false, secondary = false) {
220
239
  continue;
221
240
  const substitutions = { value, url: url ?? '', number: number ?? '' };
222
241
  const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key) => substitutions[key])}`;
223
- 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++;
224
258
  }
225
259
  }
226
260
  const editable = controls.filter((control) => {
@@ -256,14 +290,14 @@ server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(a
256
290
  if (request.params.uri !== URI)
257
291
  throw new Error('Unknown resource');
258
292
  const data = await snapshot(context(request.params._meta).state, extra.signal);
259
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
293
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
260
294
  }));
261
295
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
262
296
  const state = context(request.params._meta).state;
263
297
  const before = await snapshot(state, extra.signal);
264
298
  const scoped = scopedActions(request.params._meta);
265
299
  const { action, value } = request.params;
266
- 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);
267
301
  if (!control || (control.revision ?? before.revision) !== request.params.revision)
268
302
  staleAction();
269
303
  const next = { ...before.state };
@@ -354,7 +388,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
354
388
  extra.signal.throwIfAborted();
355
389
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
356
390
  }
357
- const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
391
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
358
392
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
359
393
  return result;
360
394
  }));
@@ -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,9 +55,29 @@ 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
- The title previews the task; **✔** completes it and clears the selection without
59
- resetting the conversation or editor. Suggestions show only **✚**: left-click to
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.
74
+ Single-click the active title to preview; double-click it to complete. **✔** also
75
+ completes and clears the selection without resetting the conversation or editor.
76
+ Suggested titles use italic accent text; double-click to accept/start, then the
77
+ title returns to normal text. Acceptance cannot reuse the same click capability
78
+ to complete. Styling/double-click require gateway 0.28.0+ and double-click needs
79
+ its updated Kitty mouse mapping (Ctrl+Shift+double-click in fullscreen).
80
+ Older hosts retain plain titles and icon actions. Suggestions show **✚**: left-click to
60
81
  create/start/select, right-click to dismiss. Right-click requires gateway 0.22.0+
61
82
  and its Kitty mouse mapping; **Dismiss suggestion** in the app menu remains the
62
83
  keyboard/older-host fallback. These use the gateway's generic footer actions, not a Pinote
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.22.1' }, { 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> {
@@ -118,7 +120,14 @@ function staleAction(): never {
118
120
  function secondaryActions(meta: Record<string, unknown> | undefined): boolean {
119
121
  return z.object({ footerSecondaryActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success;
120
122
  }
121
- function view(data: Snapshot, scoped = false, secondary = false) {
123
+ function footerFeatures(meta: Record<string, unknown> | undefined) {
124
+ return {
125
+ footerNativeActions: z.object({ footerNativeActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
126
+ footerStyles: z.object({ footerStyles: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
127
+ footerDoubleActions: z.object({ footerDoubleActions: z.literal(true) }).safeParse(meta?.['native-ui/features']).success,
128
+ };
129
+ }
130
+ function view(data: Snapshot, scoped = false, secondary = false, features: { footerNativeActions?: boolean; footerStyles?: boolean; footerDoubleActions?: boolean } = {}) {
122
131
  const { tasks, task, config, revision } = data;
123
132
  const state = Context.parse({ conversationId: 'outgoing-state', state: { ...data.state, selectedId: task?.id ?? null } }).state;
124
133
  const controls: Control[] = [];
@@ -128,9 +137,9 @@ function view(data: Snapshot, scoped = false, secondary = false) {
128
137
  // Footer actions remain valid on every screen, including Settings.
129
138
  if (task) {
130
139
  action('preview', 'Preview full task', { description: 'Display only; never sent to the model' });
131
- 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' } : {}) });
132
141
  } else if (state.proposal) {
133
- 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}` });
134
143
  action('proposal:dismiss', 'Dismiss suggestion', { icon: icons.cross });
135
144
  }
136
145
  action('screen:tasks', 'Tasks', { icon: icons.note });
@@ -164,18 +173,24 @@ function view(data: Snapshot, scoped = false, secondary = false) {
164
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?' });
165
174
  if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
166
175
  }
167
- const footer: { text: string; url?: string; action?: string; secondaryAction?: string; width?: number }[] = [{ text: `${icons.note} `, width: 3 }];
168
- 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) {
169
184
  footer.push({ text: `${icons.check} `, action: 'done', width: 3 });
170
- footer.push({ text: `${task.tag ? `[${task.tag}] ` : ''}${task.text.split('\n')[0].slice(0, 2000)}`, action: 'preview', width: config.config.titleWidth });
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 });
171
186
  } else if (state.proposal) {
172
187
  footer.push({ text: `${icons.add} `, action: 'proposal:accept', ...(secondary ? { secondaryAction: 'proposal:dismiss' } : {}), width: 3 });
173
- footer.push({ text: `${state.proposal.tag ? `[${state.proposal.tag}] ` : ''}${state.proposal.text.split('\n')[0].slice(0, 2000)}`, width: config.config.titleWidth });
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 });
174
189
  } else footer.push({ text: 'Pinote · no task', width: config.config.titleWidth });
175
- const titleParts = footer.length;
190
+ let fieldCount = 0;
176
191
  if (task) {
177
192
  for (const field of fields(config.config, task.agent_notes)) {
178
- if (footer.length - titleParts >= config.config.maxFields) break;
193
+ if (fieldCount >= config.config.maxFields) break;
179
194
  const raw = task.agent_notes[field.name];
180
195
  if (!raw) continue;
181
196
  const link = /^\[([^\n]*)\]\((https?:\/\/[^\s]+)\)$/.exec(raw);
@@ -185,7 +200,18 @@ function view(data: Snapshot, scoped = false, secondary = false) {
185
200
  if (field.format.includes('<url>') && !url || field.format.includes('<number>') && !number) continue;
186
201
  const substitutions: Record<string, string> = { value, url: url ?? '', number: number ?? '' };
187
202
  const rendered = ` · ${field.label ? `${field.label}: ` : ''}${field.format.replace(/<(value|url|number)>/g, (_match, key: string) => substitutions[key])}`;
188
- 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++;
189
215
  }
190
216
  }
191
217
  const editable = controls.filter((control) => {
@@ -211,14 +237,14 @@ server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [
211
237
  server.setRequestHandler(ReadResourceRequestSchema, (request, extra) => serial(async () => {
212
238
  if (request.params.uri !== URI) throw new Error('Unknown resource');
213
239
  const data = await snapshot(context(request.params._meta).state, extra.signal);
214
- return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta))) }] };
240
+ return { contents: [{ uri: URI, mimeType: MIME, text: JSON.stringify(view(data, scopedActions(request.params._meta), secondaryActions(request.params._meta), footerFeatures(request.params._meta))) }] };
215
241
  }));
216
242
  server.setRequestHandler(Action, (request, extra) => serial(async () => {
217
243
  const state = context(request.params._meta).state;
218
244
  const before = await snapshot(state, extra.signal);
219
245
  const scoped = scopedActions(request.params._meta);
220
246
  const { action, value } = request.params;
221
- 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);
222
248
  if (!control || (control.revision ?? before.revision) !== request.params.revision) staleAction();
223
249
  const next: State = { ...before.state };
224
250
  let draft: string | undefined, preview: string | undefined;
@@ -275,7 +301,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
275
301
  extra.signal.throwIfAborted();
276
302
  saveFooterConfig(cfg, before.config.raw, handoff, newSession, policy);
277
303
  }
278
- const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
304
+ const result = { view: view(await snapshot(next, extra.signal), scoped, secondaryActions(request.params._meta), footerFeatures(request.params._meta)), ...(draft ? { draft } : {}), ...(preview ? { preview } : {}) };
279
305
  await server.notification({ method: 'notifications/native-ui/changed', params: { uri: URI } });
280
306
  return result;
281
307
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.22.1",
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",