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.
- package/dist-mcp/icons/external-link.txt +1 -0
- package/dist-mcp/icons/open-app.txt +1 -0
- package/dist-mcp/mcp/server.js +49 -15
- package/icons/external-link.txt +1 -0
- package/icons/open-app.txt +1 -0
- package/mcp/README.md +25 -4
- package/mcp/server.ts +41 -15
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
↗
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
▤
|
package/dist-mcp/mcp/server.js
CHANGED
|
@@ -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.
|
|
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
|
|
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 =
|
|
197
|
-
|
|
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
|
-
|
|
226
|
+
let fieldCount = 0;
|
|
208
227
|
if (task) {
|
|
209
228
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
210
|
-
if (
|
|
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
|
-
|
|
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
|
-
- **
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
|
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;
|
|
168
|
-
|
|
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
|
-
|
|
190
|
+
let fieldCount = 0;
|
|
176
191
|
if (task) {
|
|
177
192
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
178
|
-
if (
|
|
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
|
-
|
|
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
|
}));
|