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.
- package/dist-mcp/icons/external-link.txt +1 -0
- package/dist-mcp/icons/open-app.txt +1 -0
- package/dist-mcp/mcp/server.js +38 -10
- package/icons/external-link.txt +1 -0
- package/icons/open-app.txt +1 -0
- package/mcp/README.md +18 -2
- package/mcp/server.ts +31 -11
- 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) {
|
|
@@ -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 =
|
|
203
|
-
|
|
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
|
-
|
|
226
|
+
let fieldCount = 0;
|
|
214
227
|
if (task) {
|
|
215
228
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
216
|
-
if (
|
|
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
|
-
|
|
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
|
-
- **
|
|
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.
|
|
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 }[] =
|
|
174
|
-
|
|
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
|
-
|
|
190
|
+
let fieldCount = 0;
|
|
182
191
|
if (task) {
|
|
183
192
|
for (const field of fields(config.config, task.agent_notes)) {
|
|
184
|
-
if (
|
|
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
|
-
|
|
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;
|