pi-note 0.28.0 → 0.29.1

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,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { pinoteToken } from '../token.ts';
3
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
4
+ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
+ import { McpError, ResourceListChangedNotificationSchema, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
6
+ import { ActionResultSchema, ChangedSchema, StaleActionDataSchema, UI_STALE_ACTION } from './protocol.ts';
7
+
8
+ export interface RemoteConfig { url: URL; token: string; scope: string }
9
+ /** PINOTE_ALLOW_HTTP=1 explicitly permits plaintext on a trusted private VPN. */
10
+ export function remoteConfig(env: NodeJS.ProcessEnv = process.env): RemoteConfig {
11
+ const token = pinoteToken(env);
12
+ let url: URL;
13
+ try { url = new URL(env.PINOTE_URL ?? 'http://127.0.0.1:8765/mcp'); }
14
+ catch { throw new Error('Invalid PINOTE_URL'); }
15
+ if (url.username || url.password || url.search || url.hash) throw new Error('PINOTE_URL must not contain credentials, query, or fragment');
16
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
17
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && (local || env.PINOTE_ALLOW_HTTP === '1'))) {
18
+ throw new Error('Remote Pinote requires HTTPS; PINOTE_ALLOW_HTTP=1 permits a trusted private VPN');
19
+ }
20
+ return { url, token, scope: createHash('sha256').update(url.href).digest('hex') };
21
+ }
22
+ export class RemoteFailure extends Error {
23
+ readonly uncertain: boolean;
24
+ readonly stale: boolean;
25
+ constructor(uncertain = false, stale = false) {
26
+ super(stale ? 'Action not applied: state changed. Refresh and review before retrying.' :
27
+ `Pinote request failed.${uncertain ? ' The write may have committed; refresh before retrying.' : ' Context unavailable; retry an explicit call to reconnect.'}`);
28
+ this.uncertain = uncertain && !stale; this.stale = stale;
29
+ }
30
+ }
31
+ export interface Peer {
32
+ read(uri: string, meta: Record<string, unknown>, signal: AbortSignal): Promise<{ contents: Array<{ text?: string; mimeType?: string }> }>;
33
+ tool(name: string, args: Record<string, unknown>, meta: Record<string, unknown>, signal: AbortSignal): Promise<CallToolResult>;
34
+ action(params: Record<string, unknown>, signal: AbortSignal): Promise<ReturnType<typeof ActionResultSchema.parse>>;
35
+ close(): Promise<void>;
36
+ }
37
+ export type PeerFactory = (config: RemoteConfig, signal: AbortSignal, changed: () => void, disconnected: () => void) => Promise<Peer>;
38
+ export const connectPeer: PeerFactory = async (config, signal, changed, disconnected) => {
39
+ const client = new Client({ name: 'pinote-pi', version: '1' }, { capabilities: {} });
40
+ const transport = new StreamableHTTPClientTransport(config.url, {
41
+ requestInit: { headers: { Authorization: `Bearer ${config.token}` }, redirect: 'error' },
42
+ // Never replay POSTs, resume streams, or reconnect behind the caller's epoch.
43
+ reconnectionOptions: { maxRetries: 0, initialReconnectionDelay: 1000, maxReconnectionDelay: 1000, reconnectionDelayGrowFactor: 1 },
44
+ fetch: (url, init) => fetch(url, { ...init, redirect: 'error', signal: init?.method === 'DELETE'
45
+ ? AbortSignal.timeout(1000) // Bounded session cleanup remains possible after operation cancellation.
46
+ : AbortSignal.any([signal, ...(init?.signal ? [init.signal] : [])]) }),
47
+ });
48
+ let closing = false;
49
+ // Losing the notification stream must clear the idle UI, not silently leave a stale view.
50
+ // SDK errors can contain response bodies/URLs; expose only our stable failure messages.
51
+ client.onerror = () => {
52
+ if (closing) return;
53
+ closing = true; disconnected();
54
+ void client.close().catch(() => {});
55
+ };
56
+ client.onclose = () => { closing = true; disconnected(); };
57
+ client.setNotificationHandler(ChangedSchema, changed);
58
+ client.setNotificationHandler(ResourceListChangedNotificationSchema, changed);
59
+ const abort = () => { void client.close().catch(() => {}); };
60
+ signal.addEventListener('abort', abort, { once: true });
61
+ try {
62
+ signal.throwIfAborted();
63
+ await client.connect(transport, { signal, timeout: 10_000 });
64
+ signal.throwIfAborted();
65
+ } catch {
66
+ signal.removeEventListener('abort', abort);
67
+ await client.close().catch(() => {});
68
+ throw new RemoteFailure();
69
+ }
70
+ const options = (operation: AbortSignal) => ({ signal: AbortSignal.any([signal, operation]), timeout: 20_000 });
71
+ return {
72
+ read: (uri, meta, operation) => client.readResource({ uri, _meta: meta }, options(operation)),
73
+ tool: (name, args, meta, operation) => client.callTool({ name, arguments: args, _meta: meta }, undefined, options(operation)) as Promise<CallToolResult>,
74
+ action: (params, operation) => client.request({ method: 'native-ui/action', params }, ActionResultSchema, options(operation)),
75
+ async close() {
76
+ closing = true;
77
+ signal.removeEventListener('abort', abort);
78
+ // Pinote owns no detached jobs: release the HTTP session rather than waiting for idle expiry.
79
+ try { await transport.terminateSession(); } catch { /* A dropped server may already have discarded it. */ }
80
+ await client.close();
81
+ },
82
+ };
83
+ };
84
+ /** Stable errors only: neither env credentials nor transport/server error bodies escape. */
85
+ export function remoteFailure(error: unknown, mutation: boolean): RemoteFailure {
86
+ return new RemoteFailure(mutation, error instanceof McpError && error.code === UI_STALE_ACTION && StaleActionDataSchema.safeParse(error.data).success);
87
+ }
package/remote/exit.ts ADDED
@@ -0,0 +1,84 @@
1
+ import { CustomEditor, type ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { truncateToWidth } from '@earendil-works/pi-tui';
3
+ import { clean } from './protocol.ts';
4
+ import type { RemoteSession } from './session.ts';
5
+
6
+ const leave = 'Exit without completing';
7
+ const complete = 'Complete task and exit';
8
+
9
+ /** Default remote companion. /quit and nonempty-editor input remain Pi-owned. */
10
+ export function registerRemoteExit(pi: ExtensionAPI, getSession: () => RemoteSession | undefined): void {
11
+ let invalidate: (() => void) | undefined;
12
+ let stop: (() => void) | undefined;
13
+ pi.on('session_start', (_event, ctx) => {
14
+ stop?.();
15
+ if (ctx.mode !== 'tui' || !ctx.hasUI) return;
16
+ const previous = ctx.ui.getEditorComponent();
17
+ let alive = true, pending = false, epoch = 0;
18
+ let controller: AbortController | undefined;
19
+ invalidate = () => { epoch++; controller?.abort(); };
20
+ const exit = async () => {
21
+ if (!alive || pending) return;
22
+ pending = true;
23
+ const generation = epoch, session = getSession();
24
+ const operation = controller = new AbortController();
25
+ const sessionId = ctx.sessionManager.getSessionId();
26
+ const validSession = session?.current();
27
+ const current = () => alive && generation === epoch && !operation.signal.aborted &&
28
+ session === getSession() && !!validSession?.() &&
29
+ ctx.sessionManager.getSessionId() === sessionId && ctx.ui.getEditorText().length === 0;
30
+ const shutdown = () => { if (!ctx.isIdle()) ctx.abort(); ctx.shutdown(); };
31
+ try {
32
+ if (!session) throw new Error('unavailable');
33
+ await session.refresh(true);
34
+ if (!current()) return;
35
+ const view = session.view;
36
+ if (!view) throw new Error('unavailable');
37
+ if (view.state.selectedId == null) { shutdown(); return; }
38
+ const branch = ctx.sessionManager.getLeafId();
39
+ const done = view.controls.find((c) => c.id === 'done' && c.kind === 'action');
40
+ const title = truncateToWidth(clean((view.body ?? view.title).split('\n', 1)[0]), 70);
41
+ const choice = await ctx.ui.select(`Pinote · ${title}`, ['Cancel', leave, complete], {
42
+ signal: AbortSignal.any([operation.signal, session.signal]),
43
+ });
44
+ if (!current()) return;
45
+ if (choice === leave) { shutdown(); return; }
46
+ if (choice !== complete) return;
47
+ const latest = session.view;
48
+ if (!ctx.isIdle() || ctx.sessionManager.getLeafId() !== branch || !done ||
49
+ !latest || latest.state.selectedId !== view.state.selectedId || latest.revision !== view.revision ||
50
+ !latest.controls.some((c) => c.id === done.id && c.kind === done.kind && c.revision === done.revision)) {
51
+ throw new Error('changed');
52
+ }
53
+ // Approval belongs to this exact displayed view, never to a refreshed revision.
54
+ await session.action(view, done, undefined, () => ctx.isIdle());
55
+ if (!current()) return;
56
+ if (session.view && session.view.state.selectedId == null) ctx.shutdown();
57
+ else ctx.ui.notify('Pinote: Pi stayed open because the selection was not cleared. Refresh /pinote before retrying.', 'warning');
58
+ } catch {
59
+ if (alive && generation === epoch) ctx.ui.notify('Pinote: Pi stayed open. Completion was not confirmed and may have committed; refresh /pinote before retrying. /quit exits without completing.', 'warning');
60
+ } finally { pending = false; }
61
+ };
62
+ const factory: NonNullable<ReturnType<typeof ctx.ui.getEditorComponent>> = (tui, theme, keybindings) => {
63
+ const editor = previous ? previous(tui, theme, keybindings) : new CustomEditor(tui, theme, keybindings);
64
+ const handleInput = editor.handleInput.bind(editor);
65
+ editor.handleInput = (data) => {
66
+ if (alive && keybindings.matches(data, 'app.exit') && editor.getText().length === 0) { void exit(); return; }
67
+ handleInput(data);
68
+ };
69
+ return editor;
70
+ };
71
+ ctx.ui.setEditorComponent(factory);
72
+ stop = () => {
73
+ alive = false;
74
+ invalidate?.();
75
+ if (ctx.ui.getEditorComponent() === factory) ctx.ui.setEditorComponent(previous);
76
+ };
77
+ });
78
+ pi.on('session_shutdown', () => { stop?.(); stop = invalidate = undefined; });
79
+ const interrupted = () => { invalidate?.(); };
80
+ pi.on('agent_start', interrupted);
81
+ pi.on('session_before_switch', interrupted);
82
+ pi.on('session_before_fork', interrupted);
83
+ pi.on('session_before_tree', interrupted);
84
+ }
@@ -0,0 +1,129 @@
1
+ // Vendored native footer primitive from mcp-session-gateway; no gateway runtime dependency.
2
+ import type { Theme } from '@earendil-works/pi-coding-agent';
3
+ import { truncateToWidth, visibleWidth, type Component, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
4
+ import { clean, footerText, type NativeView } from './protocol.ts';
5
+
6
+ type Item = NonNullable<NativeView['footer']>[number];
7
+ type Row = { uri: string; view: NativeView };
8
+ type Hit = Row & { item: Item; x: number; end: number; y: number };
9
+ const line = (text: string) => clean(text).replace(/\n/g, ' ');
10
+
11
+ /** Fullscreen-only click regions: no action hyperlinks, pointer changes, or keyboard focus. */
12
+ export class NativeFooter implements Component {
13
+ private hits: Hit[] = [];
14
+ private hover?: Hit;
15
+ private armed?: string;
16
+ private singleClick?: ReturnType<typeof setTimeout>;
17
+ private pending = 0;
18
+ private queue = Promise.resolve();
19
+ private disposed = false;
20
+
21
+ private rows: () => Row[];
22
+ private theme: Theme;
23
+ private invoke: (hit: Row, item: Item, action?: string) => Promise<boolean>;
24
+ private repaint: () => void;
25
+ constructor(rows: () => Row[], theme: Theme,
26
+ invoke: (hit: Row, item: Item, action?: string) => Promise<boolean>, repaint: () => void) {
27
+ this.rows = rows; this.theme = theme; this.invoke = invoke; this.repaint = repaint;
28
+ }
29
+
30
+ private doubleKey(hit: Hit): string | undefined {
31
+ const action = hit.item.doubleAction;
32
+ return action ? JSON.stringify([hit.uri, action,
33
+ hit.view.controls.find((control) => control.id === action)?.revision ?? hit.view.revision]) : undefined;
34
+ }
35
+
36
+ render(width: number): string[] {
37
+ this.hits = [];
38
+ return this.rows().filter(({ view }) => view.footer?.length).map(({ uri, view }, y) => {
39
+ let rendered = '', x = 0;
40
+ for (const item of view.footer ?? []) {
41
+ if (x >= width) break;
42
+ const text = footerText({ ...view, footer: [item] },
43
+ (value, budget) => truncateToWidth(value, Math.max(0, Math.min(budget, width - x))),
44
+ (value, style) => {
45
+ const colored = style.color ? this.theme.fg(style.color, value) : value;
46
+ return style.italic ? this.theme.italic(colored) : colored;
47
+ });
48
+ const end = x + visibleWidth(text);
49
+ if (end > x && (item.openApp || item.action || item.doubleAction || item.secondaryAction)) {
50
+ this.hits.push({ uri, view, item, x, end, y });
51
+ }
52
+ rendered += text;
53
+ x = end;
54
+ }
55
+ const hovered = this.hits.find((hit) => hit.uri === uri && hit.uri === this.hover?.uri && hit.item === this.hover.item);
56
+ if (hovered || this.pending) {
57
+ const label = (id: string) => line(view.controls.find((control) => control.id === id)?.label ?? id);
58
+ const hint = this.pending ? 'Working…' : hovered!.item.openApp ? `Open ${line(view.title)}`
59
+ : [hovered!.item.action ? `Click: ${label(hovered!.item.action)}` : '',
60
+ hovered!.item.doubleAction ? `Double-click: ${label(hovered!.item.doubleAction)}` : ''].filter(Boolean).join(' · ');
61
+ const remaining = width - x - 3;
62
+ if (remaining >= 12) rendered += this.theme.fg('muted', ` · ${truncateToWidth(hint, remaining)}`);
63
+ }
64
+ return rendered;
65
+ });
66
+ }
67
+
68
+ private cancelSingleClick(): void {
69
+ clearTimeout(this.singleClick);
70
+ this.singleClick = undefined;
71
+ this.armed = undefined;
72
+ }
73
+
74
+ private dispatch(hit: Hit, action?: string, open = false): void {
75
+ if (!action && !open) return;
76
+ this.pending++;
77
+ this.repaint();
78
+ this.queue = this.queue.then(async () => {
79
+ if (!this.disposed) await this.invoke(hit, hit.item, action);
80
+ }).catch(() => {}).finally(() => {
81
+ this.pending--;
82
+ if (!this.disposed) this.repaint();
83
+ });
84
+ }
85
+
86
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
87
+ const hit = this.hits.find((item) => item.y === event.y && event.x >= item.x && event.x < item.end);
88
+ if (event.type === 'move') {
89
+ const changed = hit?.item !== this.hover?.item;
90
+ this.hover = hit;
91
+ return changed ? { handled: true, render: true } : undefined;
92
+ }
93
+ if (!hit || event.ctrl || event.alt || event.shift || event.type === 'wheel' || event.type === 'drag') return;
94
+ if (event.type !== 'click') return { handled: true, render: false };
95
+ let action: string | undefined;
96
+ let open = false;
97
+ if (event.button === 'right') {
98
+ this.cancelSingleClick();
99
+ if (this.pending) return { handled: true, render: false };
100
+ action = hit.item.secondaryAction;
101
+ } else if (event.button === 'left') {
102
+ const count = event.clickCount ?? 1;
103
+ if (count === 1 && !this.pending) {
104
+ this.cancelSingleClick();
105
+ this.armed = this.doubleKey(hit);
106
+ action = hit.item.action;
107
+ open = Boolean(hit.item.openApp);
108
+ if (this.armed) {
109
+ // Match Pi TUI's 500ms component double-click window (measured on release).
110
+ this.singleClick = setTimeout(() => {
111
+ this.cancelSingleClick();
112
+ this.dispatch(hit, action, open);
113
+ }, 500);
114
+ return { handled: true, render: false };
115
+ }
116
+ } else if (count === 2 && this.armed && this.armed === this.doubleKey(hit)) {
117
+ this.cancelSingleClick();
118
+ action = hit.item.doubleAction;
119
+ } else {
120
+ this.cancelSingleClick();
121
+ }
122
+ }
123
+ this.dispatch(hit, action, open);
124
+ return { handled: true, render: false };
125
+ }
126
+
127
+ invalidate(): void { this.hits = []; this.hover = undefined; }
128
+ dispose(): void { this.disposed = true; this.hits = []; this.cancelSingleClick(); }
129
+ }
package/remote/menu.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { DynamicBorder, getSettingsListTheme, keyText, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Container, type TUI } from '@earendil-works/pi-tui';
3
+ import { clean, type NativeView } from './protocol.ts';
4
+
5
+ export interface MenuState { search: string; selected?: string }
6
+ export interface MenuChoice { id: string; value?: string }
7
+ const line = (text: string) => clean(text).replace(/\n/g, ' ');
8
+
9
+ /** Reserve the native dock (including footer/widgets), not just the terminal's last row. */
10
+ export function inlineHeight(tui: TUI, width: number, component: Component): number {
11
+ const children = tui.children ?? [];
12
+ const owner = children.find((child) => (child as Partial<Container>).children?.includes(component));
13
+ const reserved = owner ? children.slice(1).filter((child) => child !== owner)
14
+ .reduce((height, child) => height + child.render(width).length, 0) : 0;
15
+ return Math.max(1, Math.min(tui.terminal.rows - 6, tui.terminal.rows - 1 - reserved));
16
+ }
17
+
18
+ /** Public Input/SelectList APIs only; state belongs to this navigation session. */
19
+ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuState, signal: AbortSignal,
20
+ watch: (changed: () => void) => () => void): Promise<MenuChoice | undefined> {
21
+ return ctx.ui.custom<MenuChoice | undefined>((tui, theme, keys, done) => {
22
+ const input = new Input(); input.setValue(state.search);
23
+ const settings = getSettingsListTheme();
24
+ const items = view.controls.map((c) => {
25
+ const values = c.kind === 'toggle' ? ['false', 'true'] : c.kind === 'select' && (c.options?.length ?? 0) < 5
26
+ ? c.options?.map((o, i) => `${i + 1}. ${line(o.label)}`) : undefined;
27
+ const index = c.options?.findIndex((o) => o.value === c.value) ?? -1;
28
+ return { id: c.id, label: `${c.icon ? `${line(c.icon)} ` : ''}${line(c.label)}`, description: line(c.description ?? ''), values,
29
+ value: c.kind === 'action' ? 'Open' : c.kind === 'select' ? values?.[index] ?? line(c.options?.find((o) => o.value === c.value)?.label ?? String(c.value ?? '')) : line(String(c.value ?? '')) };
30
+ });
31
+ items.push({ id: '__refresh', label: 'Refresh', description: '', value: '', values: undefined });
32
+ let filtered = items, list: SelectList | undefined, layout = '', geometry = '', usable = false, listTop = 0, listRows = 0;
33
+ const cancel = () => done(undefined);
34
+ const changed = () => done({ id: '__changed' });
35
+ const unwatch = watch(changed);
36
+ signal.addEventListener('abort', cancel, { once: true });
37
+ const activate = () => {
38
+ if (signal.aborted || !usable || !layout || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}`) return;
39
+ const item = filtered.find((item) => item.id === list?.getSelectedItem()?.value);
40
+ if (item) done({ id: item.id, value: item.values?.[(item.values.indexOf(item.value) + 1) % item.values.length] });
41
+ };
42
+ const component: Component & { dispose(): void; focused: boolean } = {
43
+ get focused() { return input.focused; },
44
+ set focused(value) { input.focused = value; },
45
+ render(width) {
46
+ const height = inlineHeight(tui, width, component);
47
+ geometry = `${width}:${tui.terminal.rows}`;
48
+ usable = width >= 28 && height >= 10;
49
+ if (!usable) { layout = ''; return [truncateToWidth('Enlarge terminal · Esc back', width)]; }
50
+ const compact = height < 16;
51
+ const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
52
+ const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')}/Space change · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
53
+ const header = [...border, truncateToWidth(theme.fg('accent', line(view.title)), width),
54
+ ...(!compact && view.body ? wrapTextWithAnsi(line(view.body), width).slice(0, 2) : []), ...input.render(width), ''];
55
+ const detailRows = compact ? 1 : 2;
56
+ // SelectList may append a scroll indicator. Reserve that row even at the ends.
57
+ const capacity = Math.min(10, height - header.length - detailRows - hints.length - border.length - 2);
58
+ if (capacity < 1) { usable = false; layout = ''; return [truncateToWidth('Enlarge terminal · Esc back', width)]; }
59
+ filtered = items.filter((item) => state.search.toLowerCase().split(/\s+/).every((term) => item.label.toLowerCase().includes(term)));
60
+ const signature = `${width}:${capacity}:${state.search}`;
61
+ if (layout !== signature) {
62
+ layout = signature;
63
+ const labelWidth = Math.min(36, Math.floor((width - 6) * 0.6), Math.max(1, ...items.map((item) => visibleWidth(item.label))));
64
+ const row = (item: typeof items[number], selected: boolean) => {
65
+ const label = truncateToWidth(item.label, labelWidth, '');
66
+ return settings.label(label + ' '.repeat(Math.max(0, labelWidth - visibleWidth(label))), selected) + ' '
67
+ + settings.value(truncateToWidth(item.value, Math.max(1, width - labelWidth - 6), ''), selected);
68
+ };
69
+ list = new SelectList(filtered.map((item) => ({ value: item.id, label: row(item, false) })), capacity, {
70
+ selectedPrefix: () => '', selectedText: () => settings.cursor + row(filtered.find((item) => item.id === list?.getSelectedItem()?.value)!, true),
71
+ description: settings.description, scrollInfo: settings.hint, noMatch: () => settings.hint(' No matching settings'),
72
+ });
73
+ list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.id === state.selected)));
74
+ list.onSelectionChange = (item) => { state.selected = item.value; };
75
+ list.onSelect = activate; list.onCancel = cancel;
76
+ if (list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
77
+ }
78
+ const selected = filtered.find((item) => item.id === list?.getSelectedItem()?.value);
79
+ const wrapped = wrapTextWithAnsi(selected?.description ?? '', width - 4);
80
+ const description = wrapped.slice(0, detailRows);
81
+ if (wrapped.length > detailRows) description[detailRows - 1] = truncateToWidth(description[detailRows - 1], width - 5, '') + '…';
82
+ while (description.length < detailRows) description.push('');
83
+ const rows = list!.render(width); listTop = header.length; listRows = Math.min(capacity, filtered.length);
84
+ return [...header, ...rows, '', ...description.map((s) => settings.description(` ${s}`)), ...hints.map(settings.hint), ...border]
85
+ .map((s) => truncateToWidth(s, width));
86
+ },
87
+ handleInput(data) {
88
+ if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
89
+ else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}`) {
90
+ if (matchesKey(data, Key.ctrl('r'))) done({ id: '__refresh' });
91
+ else if (data === ' ' && !state.search) activate();
92
+ else if ((['tui.select.up', 'tui.select.down', 'tui.select.pageUp', 'tui.select.pageDown', 'tui.select.confirm'] as const).some((key) => keys.matches(data, key))) { if (layout) list?.handleInput(data); }
93
+ else { input.handleInput(data); if (state.search !== input.getValue()) { state.search = input.getValue(); layout = ''; } }
94
+ }
95
+ tui.requestRender();
96
+ },
97
+ handleMouse(event) {
98
+ if (!usable || !layout || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}` || event.y < listTop || event.y >= listTop + listRows) return;
99
+ return list?.handleMouse({ ...event, y: event.y - listTop });
100
+ },
101
+ invalidate() { layout = ''; input.invalidate(); list?.invalidate(); },
102
+ dispose() { unwatch(); signal.removeEventListener('abort', cancel); },
103
+ };
104
+ return component;
105
+ });
106
+ }
@@ -0,0 +1,77 @@
1
+ // Vendored native-ui v1 wire primitives from mcp-session-gateway; independently hosted here.
2
+ import { z } from 'zod';
3
+
4
+ // Private, versioned terminal UI protocol, not the HTML MCP Apps extension.
5
+ export const UI_MIME = 'application/vnd.mcp-native-ui+json';
6
+ export const UI_CONTEXT = 'native-ui/context';
7
+ export const UI_STATE = 'native-ui/state';
8
+ export const UI_FEATURES = 'native-ui/features';
9
+ export const UI_STALE_ACTION = -33003;
10
+ export const StaleActionDataSchema = z.object({ 'native-ui/action': z.object({ status: z.literal('not-applied'), reason: z.literal('stale-view') }) });
11
+ const text = z.string().max(20_000);
12
+ const scalar = z.union([text, z.number().finite(), z.boolean(), z.null()]);
13
+ export const StateSchema = z.record(z.string(), z.unknown()).refine((state) => JSON.stringify(state).length <= 8192, 'UI state exceeds 8 KiB');
14
+ const control = z.object({
15
+ id: z.string().min(1).max(256), label: text, icon: z.string().max(8).optional(), description: text.optional(),
16
+ kind: z.enum(['action', 'text', 'number', 'toggle', 'select']),
17
+ value: scalar.optional(), multiline: z.boolean().optional(),
18
+ options: z.array(z.object({ label: text, value: scalar })).max(256).optional(),
19
+ confirm: text.optional(), revision: z.string().min(1).max(256).optional(),
20
+ }).strict();
21
+ export const ViewSchema = z.object({
22
+ version: z.literal(1), title: text, revision: z.string().min(1).max(256),
23
+ state: StateSchema, body: text.optional(), retainOnNew: z.boolean().optional(),
24
+ controls: z.array(control).max(256),
25
+ footer: z.array(z.object({ text, openApp: z.literal(true).optional(), url: z.string().max(2048).optional(), action: z.string().min(1).max(256).optional(), secondaryAction: z.string().min(1).max(256).optional(), doubleAction: z.string().min(1).max(256).optional(), style: z.object({ italic: z.boolean().optional(), color: z.enum(['accent', 'muted', 'text']).optional() }).strict().optional(), width: z.number().int().min(3).max(1000).optional() }).strict()).max(128).optional(),
26
+ }).strict().superRefine((view, ctx) => {
27
+ if (new Set(view.controls.map((c) => c.id)).size !== view.controls.length) ctx.addIssue({ code: 'custom', message: 'Duplicate control IDs' });
28
+ for (const item of view.footer ?? []) {
29
+ if (item.openApp && (item.url !== undefined || item.action || item.secondaryAction || item.doubleAction)) {
30
+ ctx.addIssue({ code: 'custom', message: 'App launchers cannot also contain URLs or server actions' });
31
+ }
32
+ if (item.secondaryAction && (!item.action || !view.controls.some((c) => c.id === item.secondaryAction && c.kind === 'action'))) {
33
+ ctx.addIssue({ code: 'custom', message: 'Secondary footer actions require a primary action and an action control' });
34
+ }
35
+ if (item.doubleAction && (item.url !== undefined || !view.controls.some((c) => c.id === item.doubleAction && c.kind === 'action'))) {
36
+ ctx.addIssue({ code: 'custom', message: 'Double footer actions must reference an action control and cannot have a URL' });
37
+ }
38
+ if (item.action && (item.url !== undefined || !view.controls.some((c) => c.id === item.action && c.kind === 'action'))) {
39
+ ctx.addIssue({ code: 'custom', message: 'Footer actions must reference an action control and cannot have a URL' });
40
+ }
41
+ }
42
+ });
43
+ export const ActionResultSchema = z.object({ view: ViewSchema, draft: text.optional(), preview: text.optional() });
44
+ export const ChangedSchema = z.object({ method: z.literal('notifications/native-ui/changed'), params: z.object({ uri: z.string().max(4096) }) });
45
+ export type NativeView = z.infer<typeof ViewSchema>;
46
+ export type NativeControl = z.infer<typeof control>;
47
+
48
+ export function clean(text: string): string {
49
+ return cleanUnbounded(text).slice(0, 20_000);
50
+ }
51
+
52
+ /** Preserve long generated preview lines while still stripping terminal controls. */
53
+ export function cleanUnbounded(text: string): string {
54
+ return text.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)|\x1b\[[0-?]*[ -/]*[@-~]/g, '')
55
+ .replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, '');
56
+ }
57
+ /** Make significant whitespace and controls explicit in operator disclosures. */
58
+ export const literal = (value: unknown) => (JSON.stringify(value) ?? '(unset)')
59
+ .replace(/[^\x21-\x7e]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
60
+
61
+ // Editors render their contents literally; do not silently alter editable data.
62
+ export function editorText(text: string): string {
63
+ if (/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(text)) throw new Error('Unsupported terminal controls in editor text');
64
+ return text;
65
+ }
66
+ export function footerText(view: NativeView, clip: (text: string, width: number) => string = (text) => text, styleText: (text: string, style: NonNullable<NonNullable<NativeView['footer']>[number]['style']>) => string = (text) => text): string {
67
+ return (view.footer ?? []).map(({ text, url, style, width }) => {
68
+ const clipped = clip(clean(text).replace(/\n/g, ' '), width ?? 60);
69
+ const label = style ? styleText(clipped, style) : clipped;
70
+ if (!url || /[\s\u0000-\u001f\u007f-\u009f]/u.test(url)) return label;
71
+ try {
72
+ const target = new URL(url);
73
+ if (!['https:', 'http:'].includes(target.protocol) || target.username || target.password) return label;
74
+ return `\x1b]8;;${target.href}\x1b\\${label}\x1b]8;;\x1b\\`;
75
+ } catch { return label; }
76
+ }).join('');
77
+ }