pi-note 0.37.0 → 0.39.2
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/README.md +52 -15
- package/dist-mcp/mcp/core.js +85 -36
- package/dist-mcp/mcp/field-events.js +295 -0
- package/dist-mcp/mcp/http.js +20 -1
- package/examples/field-events.mjs +86 -0
- package/mcp/FIELD_EVENTS.md +151 -0
- package/mcp/README.md +7 -0
- package/mcp/core.ts +80 -38
- package/mcp/field-events.ts +238 -0
- package/mcp/http.ts +14 -1
- package/package.json +2 -1
- package/remote/dialogs.ts +150 -0
- package/remote/footer.ts +2 -1
- package/remote/menu.ts +107 -57
- package/remote/navigation.ts +90 -0
- package/remote/protocol.ts +2 -1
- package/remote/session.ts +14 -4
- package/remote/ui.ts +156 -133
- package/remote.ts +6 -1
package/remote/ui.ts
CHANGED
|
@@ -1,61 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { type ExtensionAPI, type ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Text, truncateToWidth } from '@earendil-works/pi-tui';
|
|
3
3
|
import { NativeFooter } from './footer.ts';
|
|
4
|
-
import { clean,
|
|
4
|
+
import { clean, editorText, footerText, type NativeControl, type NativeView } from './protocol.ts';
|
|
5
5
|
import { RemoteFailure } from './client.ts';
|
|
6
6
|
import { RemoteSession } from './session.ts';
|
|
7
|
-
import {
|
|
7
|
+
import { remoteMenu, type MenuState } from './menu.ts';
|
|
8
|
+
import { document, editValue, working, type Draft } from './dialogs.ts';
|
|
9
|
+
import { disclosure, menuView, parentScreen, serverScreen, type Screen } from './navigation.ts';
|
|
8
10
|
|
|
9
11
|
const line = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
10
12
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// Escaped values can exceed the wire text limit. Every disclosure line must remain reviewable.
|
|
16
|
-
const disclosure = cleanUnbounded(text);
|
|
17
|
-
const reviewed = await ctx.ui.custom<boolean>((tui, theme, keys, done) => {
|
|
18
|
-
let offset = 0, page = 1, max = 0, geometry = '', usable = false;
|
|
19
|
-
let seen = new Set<number>();
|
|
20
|
-
const cancel = () => done(false);
|
|
21
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
22
|
-
const component: Component & { dispose(): void } = {
|
|
23
|
-
render(width) {
|
|
24
|
-
const height = inlineHeight(tui, width, component);
|
|
25
|
-
const current = `${width}:${tui.terminal.rows}:${height}`;
|
|
26
|
-
if (geometry !== current) seen = new Set();
|
|
27
|
-
geometry = current;
|
|
28
|
-
page = Math.max(1, Math.min(18, height - 5));
|
|
29
|
-
usable = width >= 20 && height >= 6 && tui.terminal.rows >= 14;
|
|
30
|
-
if (!usable) return [truncateToWidth('Enlarge terminal · Esc cancel', width)];
|
|
31
|
-
const lines = disclosure.split('\n').flatMap((s) => wrapTextWithAnsi(s, width));
|
|
32
|
-
max = Math.max(0, lines.length - page); offset = Math.min(offset, max);
|
|
33
|
-
for (let i = offset; i < Math.min(lines.length, offset + page); i++) seen.add(i);
|
|
34
|
-
const complete = seen.size === lines.length;
|
|
35
|
-
const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
|
|
36
|
-
return [...border, truncateToWidth(theme.fg('accent', line(title)), width), ...lines.slice(offset, offset + page), '',
|
|
37
|
-
truncateToWidth(complete ? `Review complete · ${keyText('tui.select.confirm')} to choose · ${keyText('tui.select.cancel')} cancel` : `${keyText('tui.select.up')}/${keyText('tui.select.down')} review ALL · ${keyText('tui.select.cancel')} cancel`, width), ...border];
|
|
38
|
-
},
|
|
39
|
-
handleInput(data) {
|
|
40
|
-
if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
|
|
41
|
-
else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
|
|
42
|
-
if (keys.matches(data, 'tui.select.confirm') && !signal.aborted) {
|
|
43
|
-
// Every wrapped line must have been visible in this geometry.
|
|
44
|
-
const lines = disclosure.split('\n').flatMap((s) => wrapTextWithAnsi(s, tui.terminal.columns));
|
|
45
|
-
if (seen.size === lines.length) done(true);
|
|
46
|
-
} else if (keys.matches(data, 'tui.select.down')) offset = Math.min(max, offset + 1);
|
|
47
|
-
else if (keys.matches(data, 'tui.select.up')) offset = Math.max(0, offset - 1);
|
|
48
|
-
else if (matchesKey(data, Key.pageDown)) offset = Math.min(max, offset + page);
|
|
49
|
-
else if (matchesKey(data, Key.pageUp)) offset = Math.max(0, offset - page);
|
|
50
|
-
}
|
|
51
|
-
tui.requestRender();
|
|
52
|
-
},
|
|
53
|
-
invalidate() {},
|
|
54
|
-
dispose() { signal.removeEventListener('abort', cancel); },
|
|
55
|
-
};
|
|
56
|
-
return component;
|
|
57
|
-
});
|
|
58
|
-
return reviewed && !signal.aborted && await ctx.ui.select(line(title), ['Cancel', 'Confirm'], { signal }) === 'Confirm';
|
|
13
|
+
/** Review and approval are separate; Enter can never approve by default. */
|
|
14
|
+
export async function review(ctx: ExtensionContext, title: string, text: string, signal: AbortSignal, accept = 'Confirm'): Promise<boolean> {
|
|
15
|
+
return await document(ctx, title, text, signal, true) && !signal.aborted &&
|
|
16
|
+
await ctx.ui.select(line(title), ['Cancel', accept], { signal }) === accept && !signal.aborted;
|
|
59
17
|
}
|
|
60
18
|
|
|
61
19
|
export class RemoteUI {
|
|
@@ -63,7 +21,9 @@ export class RemoteUI {
|
|
|
63
21
|
private repaint = () => {};
|
|
64
22
|
private menuChanged?: () => void;
|
|
65
23
|
private opening = false;
|
|
24
|
+
private acting = false;
|
|
66
25
|
private closing = false;
|
|
26
|
+
private failure?: RemoteFailure;
|
|
67
27
|
private pi: ExtensionAPI;
|
|
68
28
|
private session: RemoteSession;
|
|
69
29
|
private exitOnComplete: (taskId: number) => boolean;
|
|
@@ -80,9 +40,11 @@ export class RemoteUI {
|
|
|
80
40
|
if (!this.session.fullscreen) return { render: () => [], invalidate() {} };
|
|
81
41
|
return new NativeFooter(() => this.session.view ? [{ uri: 'pinote://ui', view: this.session.view }] : [], theme,
|
|
82
42
|
async ({ view }, item, action) => {
|
|
43
|
+
if (this.opening || this.acting) return false;
|
|
83
44
|
if (item.openApp) { await this.open(ctx); return true; }
|
|
84
45
|
const control = view.controls.find((c) => c.id === action && c.kind === 'action');
|
|
85
|
-
|
|
46
|
+
// The footer's deliberate double-click is already completion approval.
|
|
47
|
+
return control ? this.dispatch(ctx, view, control, undefined, action === 'done' && item.doubleAction === action) : false;
|
|
86
48
|
}, this.repaint);
|
|
87
49
|
}, { placement: 'belowEditor' });
|
|
88
50
|
this.render();
|
|
@@ -96,21 +58,44 @@ export class RemoteUI {
|
|
|
96
58
|
}
|
|
97
59
|
private render(): void {
|
|
98
60
|
if (this.ctx?.mode !== 'tui') return;
|
|
99
|
-
|
|
61
|
+
const theme = this.ctx.ui.theme;
|
|
62
|
+
this.ctx.ui.setStatus('pinote-remote', this.session.fullscreen ? undefined : this.session.view ? footerText(this.session.view, truncateToWidth, (text, style) => {
|
|
63
|
+
let rendered = style.color ? theme.fg(style.color, text) : text;
|
|
64
|
+
if (style.italic) rendered = theme.italic(rendered);
|
|
65
|
+
return style.strikethrough ? theme.strikethrough(rendered) : rendered;
|
|
66
|
+
}) : 'Pinote · unavailable (/pinote to reconnect)');
|
|
100
67
|
this.repaint();
|
|
101
68
|
}
|
|
102
|
-
async dispatch(ctx: ExtensionContext, view: NativeView, control: NativeControl, value?: NativeControl['value']): Promise<boolean> {
|
|
69
|
+
async dispatch(ctx: ExtensionContext, view: NativeView, control: NativeControl, value?: NativeControl['value'], completionConfirmed = false): Promise<boolean> {
|
|
103
70
|
if (ctx.mode !== 'tui') throw new Error('Pinote UI actions require TUI mode');
|
|
71
|
+
if (this.acting) return false;
|
|
104
72
|
if (control.id === 'done' && !ctx.isIdle()) {
|
|
105
73
|
ctx.ui.notify('Wait for the current agent run before completing this task.', 'warning'); return false;
|
|
106
74
|
}
|
|
75
|
+
this.acting = true; this.failure = undefined;
|
|
107
76
|
const current = this.session.current(), signal = this.session.signal;
|
|
108
77
|
try {
|
|
109
78
|
if (typeof value === 'string') editorText(value);
|
|
110
|
-
if (control.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
79
|
+
if (control.id === 'done' && !completionConfirmed) {
|
|
80
|
+
const exits = typeof view.state.selectedId === 'number' && this.exitOnComplete(view.state.selectedId);
|
|
81
|
+
const accept = exits ? 'Complete task and exit' : 'Complete task';
|
|
82
|
+
const summary = truncateToWidth(line(view.body ?? ''), 100);
|
|
83
|
+
if (await ctx.ui.select(`Pinote / ${accept} #${view.state.selectedId}\n${summary}\n${exits ? 'Closes this dedicated Pi session.' : 'Clears selection; keeps this conversation and editor.'}`,
|
|
84
|
+
['Cancel', accept], { signal }) !== accept) return false;
|
|
85
|
+
}
|
|
86
|
+
if (control.confirm) {
|
|
87
|
+
const details = control.kind === 'action' ? control.confirm : `${control.confirm}\n\n${control.id === 'add' || control.id === 'add-field' ? '' : `Before:\n${disclosure(control.value)}\n\n`}After:\n${disclosure(value)}`;
|
|
88
|
+
const accept = control.id === 'add' ? 'Create, start and select' : control.id === 'proposal:accept' ? 'Accept suggestion' :
|
|
89
|
+
control.id === 'remove-field' ? 'Remove display field' : control.id === 'add-field' ? 'Add field' : 'Save change';
|
|
90
|
+
if (!await review(ctx, line(control.label), details, signal, accept)) return false;
|
|
91
|
+
}
|
|
92
|
+
if (!current() || signal.aborted) return false;
|
|
93
|
+
if (control.id === 'continue' && ctx.ui.getEditorText().length) {
|
|
94
|
+
ctx.ui.notify('Draft not inserted: your editor already contains text. Clear or save it first.', 'warning'); return false;
|
|
95
|
+
}
|
|
96
|
+
const result = await working(ctx, control.id === 'preview' || control.id === 'proposal:preview' ? 'Loading preview…' : 'Applying Pinote action…',
|
|
97
|
+
() => this.session.action(view, control, value, () => control.id !== 'done' || ctx.isIdle()), signal);
|
|
98
|
+
if (!current() || !result) return false;
|
|
114
99
|
const selected = this.session.view;
|
|
115
100
|
if (selected && typeof selected.state.selectedId === 'number' && selected.state.selectedId !== view.state.selectedId) {
|
|
116
101
|
await this.renameKittyTab(ctx, selected, signal);
|
|
@@ -118,122 +103,160 @@ export class RemoteUI {
|
|
|
118
103
|
}
|
|
119
104
|
if (control.id === 'done' && ctx.isIdle() && typeof view.state.selectedId === 'number' &&
|
|
120
105
|
this.session.view && this.session.view.state.selectedId == null && this.exitOnComplete(view.state.selectedId)) {
|
|
121
|
-
this.closing = true;
|
|
122
|
-
ctx.shutdown();
|
|
123
|
-
return true;
|
|
106
|
+
this.closing = true; ctx.shutdown(); return true;
|
|
124
107
|
}
|
|
125
|
-
if (result.preview)
|
|
126
|
-
if (result.draft && await review(ctx, 'Insert
|
|
108
|
+
if (result.preview) await document(ctx, `Pinote / ${control.id === 'proposal:preview' ? 'Suggestion' : 'Task preview'}`, result.preview, signal);
|
|
109
|
+
if (result.draft && await review(ctx, 'Pinote / Insert task prompt', `Insert into the editor only; never submit.\n\n${disclosure(result.draft)}`, signal, 'Insert draft') && current()) {
|
|
127
110
|
if (ctx.ui.getEditorText().length) ctx.ui.notify('Draft not inserted: clear the editor first.', 'warning');
|
|
128
111
|
else ctx.ui.setEditorText(editorText(result.draft));
|
|
129
112
|
}
|
|
130
113
|
return true;
|
|
131
114
|
} catch (error) {
|
|
132
115
|
if (current()) {
|
|
133
|
-
|
|
134
|
-
|
|
116
|
+
this.failure = error instanceof RemoteFailure ? error : new RemoteFailure(true);
|
|
117
|
+
ctx.ui.notify(this.failure.message, 'warning');
|
|
118
|
+
// Reconnection is an explicit read, never an automatic replay of a write.
|
|
135
119
|
}
|
|
136
120
|
return false;
|
|
137
|
-
}
|
|
121
|
+
} finally { this.acting = false; }
|
|
138
122
|
}
|
|
139
123
|
private async renameKittyTab(ctx: ExtensionContext, view: NativeView, signal: AbortSignal): Promise<void> {
|
|
140
124
|
const window = process.env.KITTY_WINDOW_ID, socket = process.env.KITTY_LISTEN_ON;
|
|
141
125
|
const title = line(view.footer?.find((item) => item.action === 'preview')?.text ?? '').trim();
|
|
142
|
-
// Never fall back to the focused tab or discover an unrelated Kitty instance.
|
|
143
126
|
if (!window || !/^[1-9]\d*$/.test(window) || !socket || !title || process.env.SSH_CONNECTION || process.env.SSH_TTY) return;
|
|
144
127
|
try {
|
|
145
|
-
const result = await this.pi.exec('kitty', ['@', '--to', socket, 'set-tab-title', `--match=window_id:${window}`, '--', title], {
|
|
146
|
-
signal, timeout: 1500,
|
|
147
|
-
});
|
|
128
|
+
const result = await this.pi.exec('kitty', ['@', '--to', socket, 'set-tab-title', `--match=window_id:${window}`, '--', title], { signal, timeout: 1500 });
|
|
148
129
|
if (result.code !== 0) throw new Error('Kitty title update failed');
|
|
149
130
|
} catch {
|
|
150
131
|
if (!signal.aborted) ctx.ui.notify('Pinote task selected, but the Kitty tab could not be renamed.', 'warning');
|
|
151
132
|
}
|
|
152
133
|
}
|
|
134
|
+
private async refresh(ctx: ExtensionContext): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
await working(ctx, 'Refreshing Pinote…', () => this.session.refresh(true), this.session.signal);
|
|
137
|
+
this.failure = undefined;
|
|
138
|
+
return !!this.session.view && !this.session.signal.aborted;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
this.failure = error instanceof RemoteFailure ? error : new RemoteFailure();
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
153
144
|
async open(ctx: ExtensionContext): Promise<void> {
|
|
154
145
|
if (ctx.mode !== 'tui') { ctx.ui.notify('Pinote UI actions require TUI mode', 'warning'); return; }
|
|
155
|
-
if (this.opening) return;
|
|
146
|
+
if (this.opening || this.acting) return;
|
|
156
147
|
this.opening = true;
|
|
157
148
|
const current = this.session.current(), signal = this.session.signal;
|
|
158
|
-
const menus = new Map<string, MenuState>();
|
|
149
|
+
const menus = new Map<string, MenuState>(), drafts = new Map<string, Draft>();
|
|
150
|
+
let screen = 'home' as Screen, notice = '';
|
|
151
|
+
const go = async (next: Screen): Promise<boolean> => {
|
|
152
|
+
const view = this.session.view;
|
|
153
|
+
if (!view) return false;
|
|
154
|
+
const target = serverScreen(next);
|
|
155
|
+
if ((view.state.screen ?? 'tasks') !== target) {
|
|
156
|
+
const control = view.controls.find((c) => c.id === `screen:${target}`);
|
|
157
|
+
if (!control) return false;
|
|
158
|
+
try { await working(ctx, 'Opening Pinote…', () => this.session.action(view, control), signal); }
|
|
159
|
+
catch (error) { this.failure = error instanceof RemoteFailure ? error : new RemoteFailure(); return false; }
|
|
160
|
+
}
|
|
161
|
+
screen = next; notice = ''; return true;
|
|
162
|
+
};
|
|
159
163
|
try {
|
|
160
|
-
await this.
|
|
164
|
+
await this.refresh(ctx);
|
|
165
|
+
await go('home');
|
|
161
166
|
while (current() && !signal.aborted && !this.closing) {
|
|
167
|
+
if (this.failure || !this.session.view) {
|
|
168
|
+
const failure = this.failure;
|
|
169
|
+
const title = failure?.uncertain ? 'Pinote / Verify result' : failure?.stale ? 'Pinote / State changed' : 'Pinote / Unavailable';
|
|
170
|
+
const choice = await ctx.ui.select(`${title}\n${failure?.message ?? 'Task state could not be verified.'}\nNo automatic retries. Invalid config needs repair on the service.`,
|
|
171
|
+
['Refresh and verify', 'Close'], { signal });
|
|
172
|
+
if (choice !== 'Refresh and verify' || !current()) return;
|
|
173
|
+
if (await this.refresh(ctx)) await go(screen === 'field' ? 'fields' : screen);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
162
176
|
const view = this.session.view;
|
|
163
|
-
if (
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
menus.
|
|
167
|
-
|
|
177
|
+
if (screen === 'field' && view.state.screen !== 'field') screen = 'fields';
|
|
178
|
+
const menu = menuView(view, screen);
|
|
179
|
+
const key = screen === 'field' ? `field:${String(view.state.field)}` : screen;
|
|
180
|
+
const state = menus.get(key) ?? { search: screen === 'tasks' ? String(view.state.search ?? '') : '' };
|
|
181
|
+
menus.set(key, state);
|
|
182
|
+
const choice = await remoteMenu(ctx, menu, state, signal, (changed) => {
|
|
168
183
|
this.menuChanged = changed;
|
|
169
184
|
return () => { if (this.menuChanged === changed) this.menuChanged = undefined; };
|
|
185
|
+
}, { search: screen === 'tasks' ? 'tasks' : screen === 'fields' ? 'local' : 'none', notice,
|
|
186
|
+
searchLabel: screen === 'fields' ? 'Filter fields' : undefined,
|
|
187
|
+
onSearch: async (query) => {
|
|
188
|
+
const latest = this.session.view, control = latest?.controls.find((c) => c.id === 'search');
|
|
189
|
+
if (!current() || !latest || !control) throw new RemoteFailure();
|
|
190
|
+
try { await this.session.action(latest, control, query); }
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (current()) { this.failure = error instanceof RemoteFailure ? error : new RemoteFailure(); this.menuChanged?.(); }
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
},
|
|
170
196
|
});
|
|
171
197
|
if (!current()) return;
|
|
172
198
|
if (!choice) {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return;
|
|
199
|
+
const parent = parentScreen(screen);
|
|
200
|
+
if (!parent) return;
|
|
201
|
+
await go(parent); continue;
|
|
177
202
|
}
|
|
178
203
|
if (choice.id === '__changed') continue;
|
|
179
|
-
if (choice.id === '__refresh') { await this.
|
|
204
|
+
if (choice.id === '__refresh') { await this.refresh(ctx); continue; }
|
|
205
|
+
if (choice.id.startsWith('__preview:')) {
|
|
206
|
+
const id = Number(choice.id.slice('__preview:'.length));
|
|
207
|
+
try {
|
|
208
|
+
const result = await working(ctx, 'Loading task text…', () => this.session.tool('get_task', { task_id: id }, signal), signal);
|
|
209
|
+
if (!current() || !result) return;
|
|
210
|
+
const content = result.content.find((item) => item.type === 'text');
|
|
211
|
+
const task = JSON.parse(content?.type === 'text' ? content.text : 'null');
|
|
212
|
+
if (task?.id !== id || typeof task.text !== 'string') throw new RemoteFailure();
|
|
213
|
+
await document(ctx, `Pinote / Task text #${id}`, task.text, signal);
|
|
214
|
+
} catch (error) { if (current()) this.failure = error instanceof RemoteFailure ? error : new RemoteFailure(); }
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (['__tasks', '__settings', '__prompts', '__footer', '__fields'].includes(choice.id)) { await go(choice.id.slice(2) as Screen); continue; }
|
|
180
218
|
const control = view.controls.find((c) => c.id === choice.id);
|
|
181
219
|
if (!control) continue;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
if (control.id.startsWith('field:')) {
|
|
221
|
+
if (await this.dispatch(ctx, view, control)) { screen = 'field'; notice = ''; }
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
let applied = false;
|
|
225
|
+
if (control.kind === 'text' || control.kind === 'number') {
|
|
226
|
+
const draftKey = `${key}:${control.id}`;
|
|
227
|
+
const draft = drafts.get(draftKey) ?? { text: String(control.value ?? '') };
|
|
228
|
+
drafts.set(draftKey, draft);
|
|
229
|
+
do {
|
|
230
|
+
let value: NativeControl['value'];
|
|
231
|
+
try { value = await editValue(ctx, `${menu.title} / ${line(control.label)}`, view, control, draft, signal); }
|
|
232
|
+
catch { ctx.ui.notify('This value cannot be edited safely in the terminal. Edit it on the service.', 'warning'); break; }
|
|
233
|
+
if (value === undefined || !current()) break;
|
|
234
|
+
applied = await this.dispatch(ctx, view, control, value);
|
|
235
|
+
if (applied) drafts.delete(draftKey);
|
|
236
|
+
// Approval cancellation returns to the draft; a stale/uncertain action returns to recovery.
|
|
237
|
+
} while (!applied && !this.failure && current() && !signal.aborted);
|
|
238
|
+
} else if (control.kind === 'toggle' || control.kind === 'select') {
|
|
239
|
+
const options = control.kind === 'toggle' ? [{ label: 'On', value: true }, { label: 'Off', value: false }] : control.options ?? [];
|
|
240
|
+
const choices = options.map((o, i) => ({ id: `option:${i}`, kind: 'action' as const,
|
|
241
|
+
label: `${line(o.label)}${o.value === control.value ? ' [current]' : ''}`, description: control.description }));
|
|
242
|
+
const picked = await remoteMenu(ctx, { ...view, title: `${menu.title} / ${line(control.label)}`, body: 'Choose a value, then review and confirm the shared change.', controls: choices },
|
|
243
|
+
{ search: '', selected: `option:${Math.max(0, options.findIndex((o) => o.value === control.value))}` }, signal, () => () => {}, { search: 'none' });
|
|
244
|
+
const option = picked && options[Number(picked.id.split(':')[1])];
|
|
245
|
+
if (option && current()) applied = await this.dispatch(ctx, view, control, option.value);
|
|
246
|
+
} else applied = await this.dispatch(ctx, view, control);
|
|
247
|
+
if (applied) {
|
|
248
|
+
if (control.id.startsWith('select:') || ['add', 'proposal:accept', 'continue'].includes(control.id)) return;
|
|
249
|
+
if (control.id === 'remove-field') screen = 'fields';
|
|
250
|
+
notice = control.id === 'clear' ? 'Selection cleared; task progress is unchanged.' : control.id === 'done' ? 'Task completed.' :
|
|
251
|
+
control.id === 'proposal:dismiss' ? 'Suggestion dismissed; tasks are unchanged.' : control.confirm ? `Saved: ${line(control.label)}.` : '';
|
|
201
252
|
}
|
|
202
|
-
if (current() && await this.dispatch(ctx, view, control, value) && control.id.startsWith('select:')) return;
|
|
203
253
|
}
|
|
204
|
-
}
|
|
205
|
-
finally { this.opening = false; }
|
|
254
|
+
} finally { this.opening = false; this.menuChanged = undefined; }
|
|
206
255
|
}
|
|
207
256
|
}
|
|
208
257
|
|
|
258
|
+
/** Historical TUI-only preview entries remain readable after upgrading. */
|
|
209
259
|
export function registerPreview(pi: ExtensionAPI): void {
|
|
210
260
|
pi.registerEntryRenderer<{ title: string; text: string }>('pinote-remote-preview', (entry, _options, theme) =>
|
|
211
261
|
new Text(`${theme.fg('accent', line(entry.data?.title ?? 'Pinote preview'))}\n\n${clean(entry.data?.text ?? '')}`, 0, 0));
|
|
212
262
|
}
|
|
213
|
-
|
|
214
|
-
/** JSON input preserves exact whitespace and does not confuse a placeholder with an initial value. */
|
|
215
|
-
async function jsonInput(ctx: ExtensionContext, title: string, value: string, signal: AbortSignal): Promise<string | undefined> {
|
|
216
|
-
if (signal.aborted) return undefined;
|
|
217
|
-
return ctx.ui.custom<string | undefined>((tui, theme, keys, done) => {
|
|
218
|
-
const input = new Input(); input.setValue(value.replace(/[^\x20-\x7e]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`));
|
|
219
|
-
const cancel = () => done(undefined);
|
|
220
|
-
input.onSubmit = (text) => { if (!signal.aborted) done(text); };
|
|
221
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
222
|
-
return {
|
|
223
|
-
get focused() { return input.focused; },
|
|
224
|
-
set focused(value: boolean) { input.focused = value; },
|
|
225
|
-
render(width) {
|
|
226
|
-
const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
|
|
227
|
-
return [...border, truncateToWidth(theme.fg('accent', title), width), ...input.render(width),
|
|
228
|
-
truncateToWidth('JSON value · Enter submit · Esc cancel', width), ...border];
|
|
229
|
-
},
|
|
230
|
-
handleInput(data) {
|
|
231
|
-
if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
|
|
232
|
-
else input.handleInput(data);
|
|
233
|
-
tui.requestRender();
|
|
234
|
-
},
|
|
235
|
-
invalidate() { input.invalidate(); },
|
|
236
|
-
dispose() { signal.removeEventListener('abort', cancel); },
|
|
237
|
-
};
|
|
238
|
-
});
|
|
239
|
-
}
|
package/remote.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { ENTRY_TYPE, RemoteSession, type SavedState } from './remote/session.ts'
|
|
|
5
5
|
import { registerTools } from './remote/tools.ts';
|
|
6
6
|
import { registerPreview, RemoteUI } from './remote/ui.ts';
|
|
7
7
|
import { registerRemoteExit } from './remote/exit.ts';
|
|
8
|
-
import { editorText } from './remote/protocol.ts';
|
|
8
|
+
import { clean, editorText } from './remote/protocol.ts';
|
|
9
9
|
import { taskCompleted } from './remote/completion.ts';
|
|
10
10
|
|
|
11
11
|
// Only an accepted /new (previousSessionFile) may carry this process-local selection.
|
|
@@ -47,6 +47,11 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
47
47
|
try {
|
|
48
48
|
const config = remoteConfig();
|
|
49
49
|
session = new RemoteSession(config, (saved) => pi.appendEntry(ENTRY_TYPE, saved));
|
|
50
|
+
session.onFieldNotification = (notice) => {
|
|
51
|
+
if (!ctx.hasUI) return false;
|
|
52
|
+
ctx.ui.notify(clean(`Pinote #${notice.taskId} · ${notice.field}: ${notice.message}`).replace(/\n/g, ' '), 'warning');
|
|
53
|
+
return true; // Observation only; fresh task-scoped context reaches the next agent run.
|
|
54
|
+
};
|
|
50
55
|
session.onCompleted = (taskId, signal) => taskCompleted(pi.events, taskId, signal, () => {
|
|
51
56
|
if (ctx.hasUI) ctx.ui.notify('Pinote: completion hook did not finish successfully. The task completion remains confirmed.', 'warning');
|
|
52
57
|
});
|