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.
@@ -0,0 +1,150 @@
1
+ import { BorderedLoader, DynamicBorder, getSelectListTheme, keyText, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { Editor, Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type Component, type TUI } from '@earendil-works/pi-tui';
3
+ import { clean, cleanUnbounded, editorText, type NativeControl, type NativeView } from './protocol.ts';
4
+ import { heading, inlineHeight } from './menu.ts';
5
+ import { inputValue } from './navigation.ts';
6
+
7
+ /** One scrollable, geometry-aware document for previews and mandatory review. */
8
+ export async function document(ctx: ExtensionContext, title: string, text: string, signal: AbortSignal, mustReview = false): Promise<boolean> {
9
+ if (signal.aborted || ctx.mode !== 'tui') return false;
10
+ const content = cleanUnbounded(text);
11
+ return !!await ctx.ui.custom<boolean>((tui, theme, keys, done) => {
12
+ let offset = 0, page = 1, max = 0, geometry = '', usable = false, lines: string[] = [], complete = false;
13
+ const seen = new Set<number>();
14
+ const cancel = () => done(false);
15
+ signal.addEventListener('abort', cancel, { once: true });
16
+ const component: Component & { dispose(): void; capturesViewportInput: boolean } = {
17
+ capturesViewportInput: true,
18
+ render(width) {
19
+ const height = inlineHeight(tui, width, component);
20
+ const current = `${width}:${tui.terminal.rows}:${height}`;
21
+ if (geometry !== current) seen.clear();
22
+ geometry = current;
23
+ usable = width >= 28 && height >= 8;
24
+ if (!usable) return [truncateToWidth('Enlarge terminal · Esc back', width)];
25
+ const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
26
+ lines = content.split('\n').flatMap((s) => wrapTextWithAnsi(s, width));
27
+ // Reserve wrapped hints even before the final line becomes reviewable.
28
+ const hint = `${keyText('tui.select.up')}/${keyText('tui.select.down')} scroll · ${keyText('tui.select.pageUp')}/${keyText('tui.select.pageDown')} page · ${keyText('tui.select.cancel')} back`;
29
+ const hints = wrapTextWithAnsi(hint, width);
30
+ page = Math.max(1, Math.min(18, height - border.length * 2 - hints.length - 4));
31
+ max = Math.max(0, lines.length - page); offset = Math.min(offset, max);
32
+ for (let i = offset; i < Math.min(lines.length, offset + page); i++) seen.add(i);
33
+ complete = seen.size === lines.length;
34
+ const status = mustReview ? complete ? `Review complete · ${keyText('tui.select.confirm')} to choose` : `Review ALL lines · ${offset + 1}–${Math.min(lines.length, offset + page)} / ${lines.length}`
35
+ : `Read-only · ${offset + 1}–${Math.min(lines.length, offset + page)} / ${lines.length}`;
36
+ return [...border, theme.fg('accent', heading(title, width)), '',
37
+ ...lines.slice(offset, offset + page), '', truncateToWidth(theme.fg('muted', status), width),
38
+ ...hints.map((s) => theme.fg('dim', s)), ...border].map((s) => truncateToWidth(s, width));
39
+ },
40
+ handleInput(data) {
41
+ if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
42
+ else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
43
+ if (mustReview && complete && keys.matches(data, 'tui.select.confirm') && !signal.aborted) done(true);
44
+ else if (keys.matches(data, 'tui.select.down')) offset = Math.min(max, offset + 1);
45
+ else if (keys.matches(data, 'tui.select.up')) offset = Math.max(0, offset - 1);
46
+ else if (keys.matches(data, 'tui.select.pageDown')) offset = Math.min(max, offset + page);
47
+ else if (keys.matches(data, 'tui.select.pageUp')) offset = Math.max(0, offset - page);
48
+ }
49
+ tui.requestRender();
50
+ },
51
+ handleMouse(event) {
52
+ if (!usable || event.type !== 'wheel' || !event.wheelDelta) return;
53
+ offset = Math.max(0, Math.min(max, offset + (event.wheelDelta > 0 ? 3 : -3)));
54
+ return { handled: true, render: true };
55
+ },
56
+ invalidate() { geometry = ''; },
57
+ dispose() { signal.removeEventListener('abort', cancel); },
58
+ };
59
+ return component;
60
+ });
61
+ }
62
+
63
+ export interface Draft { text: string }
64
+ /** Native editors, bounded to the available dock, with exact submitted whitespace. */
65
+ export async function editValue(ctx: ExtensionContext, title: string, view: NativeView, control: NativeControl, draft: Draft, signal: AbortSignal): Promise<NativeControl['value'] | undefined> {
66
+ if (signal.aborted) return undefined;
67
+ editorText(draft.text);
68
+ return ctx.ui.custom<NativeControl['value'] | undefined>((tui, theme, keys, done) => {
69
+ let editorRows = 20, usable = false, geometry = '', error = '', inputTop = 0, inputHeight = 0;
70
+ // Editor's public renderer sizes itself to 30% of terminal.rows (minimum five).
71
+ // Give it this component's budget rather than the whole terminal/dock.
72
+ const editorTui = { terminal: { get rows() { return editorRows; }, get columns() { return tui.terminal.columns; } }, requestRender: () => tui.requestRender() } as TUI;
73
+ const widget = control.multiline ? new Editor(editorTui, { borderColor: (s) => theme.fg('border', s), selectList: getSelectListTheme() }) : new Input();
74
+ if (widget instanceof Editor) { widget.disableSubmit = true; widget.setText(draft.text); }
75
+ else widget.setValue(draft.text);
76
+ const initial = widget instanceof Editor ? widget.getExpandedText() : widget.getValue(), original = draft.text;
77
+ const value = () => {
78
+ const text = widget instanceof Editor ? widget.getExpandedText() : widget.getValue();
79
+ return text === initial ? original : text;
80
+ };
81
+ const cancel = () => { draft.text = value(); done(undefined); };
82
+ signal.addEventListener('abort', cancel, { once: true });
83
+ const component: Component & { focused: boolean; dispose(): void; capturesViewportInput: boolean } = {
84
+ capturesViewportInput: true,
85
+ get focused() { return widget.focused; },
86
+ set focused(focused) { widget.focused = focused; },
87
+ render(width) {
88
+ const height = inlineHeight(tui, width, component);
89
+ geometry = `${width}:${tui.terminal.rows}:${height}`;
90
+ const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
91
+ const hints = wrapTextWithAnsi(`${keyText('tui.input.submit')} review${control.multiline ? ` · ${keyText('tui.input.newLine')} newline` : ''} · ${keyText('tui.select.cancel')} cancel`, width);
92
+ const help = wrapTextWithAnsi(clean(control.description ?? 'Nothing is saved until you review and confirm.'), width).slice(0, 2);
93
+ const header = [...border, theme.fg('accent', heading(title, width)), ...help.map((s) => theme.fg('muted', s)), ''];
94
+ const errors = error ? wrapTextWithAnsi(error, width).slice(0, 2) : [];
95
+ const normalization = original.includes('\t') ? [truncateToWidth('Editing expands tabs to spaces; review the exact change.', width)] : [];
96
+ const budget = height - header.length - errors.length - normalization.length - hints.length - border.length - 1;
97
+ usable = width >= 28 && budget >= (control.multiline ? 7 : 1);
98
+ if (!usable) return [truncateToWidth('Enlarge terminal · Esc back', width)];
99
+ editorRows = Math.floor(Math.max(5, budget - 2) / 0.3);
100
+ const rows = widget.render(width);
101
+ inputTop = header.length; inputHeight = rows.length;
102
+ return [...header, ...rows, ...errors.map((s) => theme.fg('error', s)), ...normalization.map((s) => theme.fg('muted', s)), '', ...hints.map((s) => theme.fg('dim', s)), ...border]
103
+ .map((s) => truncateToWidth(s, width));
104
+ },
105
+ handleInput(data) {
106
+ if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
107
+ else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
108
+ if (keys.matches(data, 'tui.input.submit') && !keys.matches(data, 'tui.input.newLine')) {
109
+ draft.text = value();
110
+ try { done(inputValue(control, draft.text, view)); }
111
+ catch (cause) { error = cause instanceof Error ? cause.message : 'Invalid value.'; }
112
+ } else {
113
+ const previous = value();
114
+ widget.handleInput(data);
115
+ try { editorText(value()); draft.text = value(); error = ''; }
116
+ catch {
117
+ if (widget instanceof Editor) widget.setText(previous); else widget.setValue(previous);
118
+ error = 'Unsupported terminal controls in pasted text.';
119
+ }
120
+ }
121
+ }
122
+ tui.requestRender();
123
+ },
124
+ handleMouse(event) {
125
+ if (!usable || event.y < inputTop || event.y >= inputTop + inputHeight) return;
126
+ return widget.handleMouse?.({ ...event, y: event.y - inputTop });
127
+ },
128
+ invalidate() { geometry = ''; widget.invalidate(); },
129
+ dispose() { signal.removeEventListener('abort', cancel); },
130
+ };
131
+ return component;
132
+ });
133
+ }
134
+
135
+ /** Keep operation keys out of the prompt; never offer a mutation retry while it is pending. */
136
+ export async function working<T>(ctx: ExtensionContext, title: string, run: () => Promise<T>, signal: AbortSignal): Promise<T | undefined> {
137
+ if (signal.aborted) return undefined;
138
+ const result = await ctx.ui.custom<{ value?: T; error?: unknown }>((tui, theme, _keys, done) => {
139
+ const loader = new BorderedLoader(tui, theme, title, { cancellable: false });
140
+ let settled = false;
141
+ const finish = (result: { value?: T; error?: unknown }) => { if (!settled) { settled = true; done(result); } };
142
+ const abort = () => finish({});
143
+ signal.addEventListener('abort', abort, { once: true });
144
+ void run().then((value) => finish({ value }), (error) => finish({ error }));
145
+ return { render: (width) => loader.render(width), invalidate: () => loader.invalidate(),
146
+ dispose() { signal.removeEventListener('abort', abort); loader.dispose(); } };
147
+ });
148
+ if (result?.error) throw result.error;
149
+ return result?.value;
150
+ }
package/remote/footer.ts CHANGED
@@ -43,7 +43,8 @@ export class NativeFooter implements Component {
43
43
  (value, budget) => truncateToWidth(value, Math.max(0, Math.min(budget, width - x))),
44
44
  (value, style) => {
45
45
  const colored = style.color ? this.theme.fg(style.color, value) : value;
46
- return style.italic ? this.theme.italic(colored) : colored;
46
+ const italic = style.italic ? this.theme.italic(colored) : colored;
47
+ return style.strikethrough ? this.theme.strikethrough(italic) : italic;
47
48
  });
48
49
  const end = x + visibleWidth(text);
49
50
  if (end > x && (item.openApp || item.action || item.doubleAction || item.secondaryAction)) {
package/remote/menu.ts CHANGED
@@ -1,10 +1,21 @@
1
- import { DynamicBorder, getSettingsListTheme, keyText, type ExtensionContext } from '@earendil-works/pi-coding-agent';
1
+ import { DynamicBorder, getSelectListTheme, keyText, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
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';
3
+ import { clean, editorText, type NativeView } from './protocol.ts';
4
4
 
5
- export interface MenuState { search: string; selected?: string }
6
- export interface MenuChoice { id: string; value?: string }
5
+ export interface MenuState { search: string; selected?: string; input?: Input }
6
+ export interface MenuChoice { id: string }
7
+ export interface MenuOptions {
8
+ search?: 'none' | 'local' | 'tasks';
9
+ searchLabel?: string;
10
+ onSearch?: (query: string) => Promise<void>;
11
+ notice?: string;
12
+ }
7
13
  const line = (text: string) => clean(text).replace(/\n/g, ' ');
14
+ /** Keep the current submenu/editor identifiable when a breadcrumb exceeds the viewport. */
15
+ export function heading(title: string, width: number): string {
16
+ const text = line(title);
17
+ return truncateToWidth(visibleWidth(text) > width && text.includes(' / ') ? `… / ${text.split(' / ').slice(-2).join(' / ')}` : text, width);
18
+ }
8
19
 
9
20
  /** Reserve the native dock (including footer/widgets), not just the terminal's last row. */
10
21
  export function inlineHeight(tui: TUI, width: number, component: Component): number {
@@ -15,91 +26,130 @@ export function inlineHeight(tui: TUI, width: number, component: Component): num
15
26
  return Math.max(1, Math.min(tui.terminal.rows - 6, tui.terminal.rows - 1 - reserved));
16
27
  }
17
28
 
18
- /** Public Input/SelectList APIs only; state belongs to this navigation session. */
29
+ /** Native selection and input; only task search goes to the service. */
19
30
  export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuState, signal: AbortSignal,
20
- watch: (changed: () => void) => () => void): Promise<MenuChoice | undefined> {
31
+ watch: (changed: () => void) => () => void, options: MenuOptions = {}): Promise<MenuChoice | undefined> {
32
+ if (signal.aborted) return Promise.resolve(undefined);
21
33
  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;
34
+ const search = options.search ?? 'local';
35
+ // Keep the native input/caret when a server result replaces the menu component.
36
+ const input = state.input ?? new Input();
37
+ if (!state.input) {
38
+ state.search = clean(state.search.replace(/[\r\n\t]/g, ' '));
39
+ input.handleInput(state.search);
40
+ state.input = input;
41
+ }
42
+ const tasks = search === 'tasks';
43
+ const items = view.controls.map((c) => ({ value: c.id,
44
+ label: `${c.icon ? `${line(c.icon)} ` : ''}${line(c.label)}`,
45
+ description: c.kind === 'action' ? undefined : c.kind === 'text' ? c.multiline ? 'Edit…' : line(String(c.value ?? '')) || '(none)'
46
+ : c.kind === 'toggle' ? c.value ? 'On' : 'Off'
47
+ : c.kind === 'select' ? line(c.options?.find((o) => o.value === c.value)?.label ?? '') : String(c.value ?? ''),
48
+ help: line(c.description ?? ''),
49
+ }));
50
+ let filtered = items, list: SelectList | undefined, layout = '', geometry = '', usable = false, listTop = 0, listRows = 0, capacity = 1, rowHeight = 1;
51
+ let timer: ReturnType<typeof setTimeout> | undefined, disposed = false, searching = false, error = '';
33
52
  const cancel = () => done(undefined);
34
- const changed = () => done({ id: '__changed' });
35
- const unwatch = watch(changed);
53
+ const unwatch = watch(() => done({ id: '__changed' }));
36
54
  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] });
55
+ const fresh = () => !tasks || state.search === String(view.state.search ?? '');
56
+ const schedule = () => {
57
+ clearTimeout(timer);
58
+ if (fresh() || !options.onSearch || searching || disposed) return;
59
+ timer = setTimeout(() => {
60
+ if (disposed || signal.aborted) return;
61
+ searching = true;
62
+ void options.onSearch!(state.search).catch(() => { error = 'Search unavailable · Ctrl+R refresh'; }).finally(() => {
63
+ searching = false;
64
+ if (!disposed) tui.requestRender();
65
+ });
66
+ }, 250);
67
+ };
68
+ schedule();
69
+ const activate = (preview = false) => {
70
+ if (signal.aborted || !usable || !layout || !fresh() || searching || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) return;
71
+ const item = list?.getSelectedItem();
72
+ if (item) done({ id: preview && tasks ? `__preview:${item.value.slice('select:'.length)}` : item.value });
41
73
  };
42
- const component: Component & { dispose(): void; focused: boolean } = {
74
+ const component: Component & { dispose(): void; focused: boolean; capturesViewportInput: boolean } = {
75
+ capturesViewportInput: true,
43
76
  get focused() { return input.focused; },
44
77
  set focused(value) { input.focused = value; },
45
78
  render(width) {
46
79
  const height = inlineHeight(tui, width, component);
47
- geometry = `${width}:${tui.terminal.rows}`;
80
+ geometry = `${width}:${tui.terminal.rows}:${height}`;
48
81
  usable = width >= 28 && height >= 10;
49
82
  if (!usable) { layout = ''; return [truncateToWidth('Enlarge terminal · Esc back', width)]; }
50
- const compact = height < 16;
83
+ const compact = height < 18;
51
84
  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), ''];
85
+ const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')} ${tasks ? 'select · F1 preview' : 'open'} · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
86
+ const summary = (view.body ?? '').split('\n').flatMap((s) => wrapTextWithAnsi(clean(s), width - 2));
87
+ const summaryLimit = compact ? 1 : 3;
88
+ const header = [...border, theme.fg('accent', heading(view.title, width)),
89
+ ...summary.slice(0, summaryLimit).map((s, i) => theme.fg('muted', i === summaryLimit - 1 && summary.length > summaryLimit ? `${s}…` : s)),
90
+ ...(options.notice ? [truncateToWidth(theme.fg('success', line(options.notice)), width)] : []),
91
+ ...(search !== 'none' ? [theme.fg('muted', options.searchLabel ?? (tasks ? 'Search tasks · title, description, tag or #ID' : 'Type to filter')), ...input.render(width)] : []), ''];
55
92
  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);
93
+ rowHeight = tasks && !compact ? 2 : 1;
94
+ capacity = Math.min(10, Math.floor((height - header.length - detailRows - hints.length - border.length - 2) / rowHeight));
58
95
  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)));
96
+ filtered = search === 'local' ? items.filter((item) => state.search.toLowerCase().split(/\s+/).every((term) => `${item.label} ${item.description ?? ''} ${item.help}`.toLowerCase().includes(term))) : items;
60
97
  const signature = `${width}:${capacity}:${state.search}`;
61
98
  if (layout !== signature) {
62
99
  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)));
100
+ list = new SelectList(filtered, capacity, {
101
+ ...getSelectListTheme(), noMatch: () => theme.fg('muted', tasks ? state.search ? ' No tasks match this search.' : ' No active tasks. Esc back to create one.' : ' No matching items.'),
102
+ }, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: Math.min(40, Math.floor(width * 0.6)) });
103
+ list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.value === state.selected)));
74
104
  list.onSelectionChange = (item) => { state.selected = item.value; };
75
- list.onSelect = activate; list.onCancel = cancel;
105
+ list.onSelect = () => activate(); list.onCancel = cancel;
76
106
  if (list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
77
107
  }
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, '') + '…';
108
+ const selectedIndex = filtered.findIndex((item) => item.value === list?.getSelectedItem()?.value);
109
+ const selected = filtered[selectedIndex];
110
+ const help = selected && width <= 40 && selected.description && selected.description !== 'Edit…'
111
+ ? `Current: ${selected.description}. ${selected.help}` : selected?.help ?? '';
112
+ const description = wrapTextWithAnsi(error || (!fresh() || searching ? 'Searching… Selection is disabled until results arrive.' : help), width - 4).slice(0, detailRows);
82
113
  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]
114
+ const nativeRows = list!.render(width);
115
+ const start = Math.max(0, Math.min(selectedIndex - Math.floor(capacity / 2), filtered.length - capacity));
116
+ listRows = Math.min(capacity, filtered.length); listTop = header.length;
117
+ const rows = nativeRows.flatMap((row, i) => rowHeight === 2 && i < listRows
118
+ ? [row, theme.fg('muted', truncateToWidth(` ${filtered[start + i].help}`, width))] : [row]);
119
+ return [...header, ...rows, '', ...description.map((s) => theme.fg('muted', ` ${s}`)), ...hints.map((s) => theme.fg('dim', s)), ...border]
85
120
  .map((s) => truncateToWidth(s, width));
86
121
  },
87
122
  handleInput(data) {
88
123
  if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
89
- else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}`) {
124
+ else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
90
125
  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 = ''; } }
126
+ else if (tasks && matchesKey(data, Key.f1)) activate(true);
127
+ else if (keys.matches(data, 'tui.select.confirm')) activate();
128
+ else if (keys.matches(data, 'tui.select.pageUp') || keys.matches(data, 'tui.select.pageDown')) {
129
+ const index = filtered.findIndex((item) => item.value === list?.getSelectedItem()?.value);
130
+ list?.setSelectedIndex(index + (keys.matches(data, 'tui.select.pageUp') ? -capacity : capacity));
131
+ state.selected = list?.getSelectedItem()?.value;
132
+ } else if (keys.matches(data, 'tui.select.up') || keys.matches(data, 'tui.select.down')) { if (layout) list?.handleInput(data); }
133
+ else if (search !== 'none') {
134
+ input.handleInput(data);
135
+ const value = input.getValue().replace(/[\r\n\t]/g, ' ');
136
+ try { editorText(value); }
137
+ catch { input.setValue(state.search); error = 'Unsupported terminal controls in search.'; tui.requestRender(); return; }
138
+ if (value !== input.getValue()) input.setValue(value);
139
+ if (state.search !== value) {
140
+ if (tasks && value.length > 256) input.setValue(state.search);
141
+ else { state.search = value; state.selected = undefined; layout = ''; error = ''; schedule(); }
142
+ }
143
+ }
94
144
  }
95
145
  tui.requestRender();
96
146
  },
97
147
  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 });
148
+ if (!usable || !layout || !fresh() || searching || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}` || event.y < listTop || event.y >= listTop + listRows * rowHeight) return;
149
+ return list?.handleMouse({ ...event, y: Math.floor((event.y - listTop) / rowHeight) });
100
150
  },
101
151
  invalidate() { layout = ''; input.invalidate(); list?.invalidate(); },
102
- dispose() { unwatch(); signal.removeEventListener('abort', cancel); },
152
+ dispose() { disposed = true; clearTimeout(timer); unwatch(); signal.removeEventListener('abort', cancel); },
103
153
  };
104
154
  return component;
105
155
  });
@@ -0,0 +1,90 @@
1
+ import { defaultFooterField, parseAgentGuidance, parseFooterConfig, parseHandoffPrompt, parseNewSessionPrompt } from '../footer-config.ts';
2
+ import { clean, editorText, type NativeControl, type NativeView } from './protocol.ts';
3
+
4
+ export type Screen = 'home' | 'tasks' | 'settings' | 'prompts' | 'footer' | 'fields' | 'field';
5
+ const group = (id: Screen, label: string, description: string): NativeControl => ({ id: `__${id}`, label, description, kind: 'action' });
6
+ export const parentScreen = (screen: Screen): Screen | undefined => ({ tasks: 'home', settings: 'home', prompts: 'settings', footer: 'settings', fields: 'settings', field: 'fields' } as const)[screen as Exclude<Screen, 'home'>];
7
+ export const serverScreen = (screen: Screen) => screen === 'home' || screen === 'tasks' ? 'tasks' : screen === 'field' ? 'field' : 'settings';
8
+
9
+ /** Local navigation only. Server controls/revisions remain the authority for every action. */
10
+ export function menuView(view: NativeView, screen: Screen): NativeView {
11
+ const take = (...ids: string[]) => ids.flatMap((id) => view.controls.filter((c) => c.id === id));
12
+ let controls: NativeControl[], title: string, body: string;
13
+ switch (screen) {
14
+ case 'home':
15
+ title = 'Pinote'; body = view.body ?? '';
16
+ controls = [...take('preview', 'proposal:preview', 'proposal:accept', 'proposal:dismiss', 'continue'),
17
+ group('tasks', view.state.selectedId ? 'Switch task…' : 'Choose a task…', 'Search tasks by title, description, tag or #ID. Enter starts and selects the chosen task.'),
18
+ ...take('add'), group('settings', 'Settings…', 'Shared prompts, task suggestions and footer display settings.'), ...take('done', 'clear')];
19
+ break;
20
+ case 'tasks':
21
+ title = 'Pinote / Tasks'; body = view.body ?? '';
22
+ controls = view.controls.filter((c) => c.id.startsWith('select:'));
23
+ break;
24
+ case 'settings':
25
+ title = 'Pinote / Settings'; body = 'Shared service settings · affects all connected clients. Task values are unchanged.';
26
+ controls = [group('prompts', 'Prompts and agent guidance…', 'Edit shared task prompts and injected instructions.'),
27
+ ...take('taskOfferPolicy'), group('footer', 'Footer layout…', 'Set title/field widths and the maximum visible handoff fields.'),
28
+ group('fields', 'Footer fields…', 'Edit display definitions, not saved task values.')];
29
+ break;
30
+ case 'prompts':
31
+ title = 'Pinote / Settings / Prompts'; body = 'Shared prompts and instructions · each change requires review and confirmation.';
32
+ controls = take('handoffPrompt', 'newSessionPrompt', 'agentGuidance', 'reset-agent-guidance');
33
+ // The service deliberately omits oversized editors. Keep its explanation visible.
34
+ if (view.body?.includes('editor limit')) body = view.body;
35
+ break;
36
+ case 'footer':
37
+ title = 'Pinote / Settings / Footer layout'; body = 'Shared display settings · widths are terminal columns. Task values are retained.';
38
+ controls = take('titleWidth', 'fieldWidth', 'maxFields');
39
+ break;
40
+ case 'fields':
41
+ title = 'Pinote / Settings / Footer fields'; body = 'Display definitions only. Populated task values appear in the footer.';
42
+ controls = [...view.controls.filter((c) => c.id.startsWith('field:')), ...take('add-field')];
43
+ break;
44
+ case 'field':
45
+ title = `Pinote / Settings / Fields / ${clean(String(view.state.field ?? ''))}`;
46
+ body = view.body ?? 'Shared display definition. Task values are unchanged.';
47
+ controls = take('label', 'format', 'width', 'link', 'remove-field');
48
+ break;
49
+ }
50
+ return { ...view, title, body, controls };
51
+ }
52
+
53
+ /** Reuse the same domain parsers as the service, before the remote revision-checked save. */
54
+ export function inputValue(control: NativeControl, text: string, view: NativeView): NativeControl['value'] {
55
+ editorText(text);
56
+ if (text.length > 20_000) throw new Error('This editor accepts at most 20,000 characters. Edit larger values on the service.');
57
+ if (control.kind === 'number') {
58
+ if (!/^[+-]?\d+$/.test(text.trim())) throw new Error('Enter a whole number.');
59
+ const value = Number(text);
60
+ parseFooterConfig({ footer: { [control.id === 'width' ? 'fieldWidth' : control.id]: value } });
61
+ return value;
62
+ }
63
+ if (!control.multiline && /[\r\n\t]/.test(text)) throw new Error('Enter one line, without tabs.');
64
+ switch (control.id) {
65
+ case 'add':
66
+ if (!text.trim() || [...text].length > 4000) throw new Error('Enter task text, up to 4,000 characters.');
67
+ break;
68
+ case 'handoffPrompt': parseHandoffPrompt(text); break;
69
+ case 'newSessionPrompt': parseNewSessionPrompt(text); break;
70
+ case 'agentGuidance': parseAgentGuidance(text); break;
71
+ case 'add-field': {
72
+ const field = parseFooterConfig({ footer: { fields: [defaultFooterField(text)] } }).fields![0];
73
+ const fields = view.controls.filter((c) => c.id.startsWith('field:'));
74
+ if (fields.length >= 64) throw new Error('At most 64 field definitions are allowed.');
75
+ if (fields.some((c) => c.id === `field:${field.name}`)) throw new Error('A field with this name already exists.');
76
+ return field.name;
77
+ }
78
+ case 'label': case 'format':
79
+ parseFooterConfig({ footer: { fields: [{ ...defaultFooterField('Example'), [control.id]: text }] } });
80
+ break;
81
+ }
82
+ return text;
83
+ }
84
+
85
+ /** Quoted lines preserve significant whitespace without turning Unicode into unreadable escapes. */
86
+ export function disclosure(value: unknown): string {
87
+ const quote = (s: string) => JSON.stringify(s).replace(/[\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g,
88
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`);
89
+ return typeof value === 'string' ? value.split('\n').map(quote).join('\n') : JSON.stringify(value) ?? '(unset)';
90
+ }
@@ -22,7 +22,8 @@ export const ViewSchema = z.object({
22
22
  version: z.literal(1), title: text, revision: z.string().min(1).max(256),
23
23
  state: StateSchema, body: text.optional(), retainOnNew: z.boolean().optional(),
24
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(),
25
+ fieldNotifications: z.array(z.object({ id: z.string().uuid(), taskId: z.number().int().positive().safe(), field: z.string().min(1).max(128), message: z.string().min(1).max(1000) }).strict()).max(64).optional(),
26
+ 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(), strikethrough: z.boolean().optional(), color: z.enum(['accent', 'muted', 'text', 'warning', 'error', 'success']).optional() }).strict().optional(), width: z.number().int().min(3).max(1000).optional() }).strict()).max(128).optional(),
26
27
  }).strict().superRefine((view, ctx) => {
27
28
  if (new Set(view.controls.map((c) => c.id)).size !== view.controls.length) ctx.addIssue({ code: 'custom', message: 'Duplicate control IDs' });
28
29
  for (const item of view.footer ?? []) {
package/remote/session.ts CHANGED
@@ -4,14 +4,17 @@ import { connectPeer, remoteFailure, RemoteFailure, type Peer, type PeerFactory,
4
4
  import { StateSchema, ViewSchema, UI_CONTEXT, UI_FEATURES, UI_STATE, type NativeView, type NativeControl } from './protocol.ts';
5
5
 
6
6
  export const ENTRY_TYPE = 'pinote-remote-v1';
7
- export interface SavedState { scope: string; contextId: string; state: Record<string, unknown>; retainOnNew?: boolean }
7
+ export interface SavedState { scope: string; contextId: string; state: Record<string, unknown>; retainOnNew?: boolean; fieldReceipts?: string[] }
8
8
  export interface BranchEntry { type: string; customType?: string; data?: unknown }
9
9
  export function restoreState(branch: readonly BranchEntry[], scope: string): SavedState {
10
10
  let saved: SavedState = { scope, contextId: randomUUID(), state: {} };
11
11
  for (const entry of branch) {
12
12
  if (entry.type !== 'custom' || entry.customType !== ENTRY_TYPE) continue;
13
13
  const data = entry.data as SavedState | undefined;
14
- if (data?.scope === scope && typeof data.contextId === 'string' && data.contextId.length > 0 && data.contextId.length <= 256 && StateSchema.safeParse(data.state).success) saved = structuredClone(data);
14
+ if (data?.scope === scope && typeof data.contextId === 'string' && data.contextId.length > 0 && data.contextId.length <= 256 && StateSchema.safeParse(data.state).success) {
15
+ saved = structuredClone(data);
16
+ saved.fieldReceipts = Array.isArray(data.fieldReceipts) ? data.fieldReceipts.filter((id) => typeof id === 'string' && /^[a-f0-9-]{36}$/.test(id)).slice(-4096) : [];
17
+ }
15
18
  }
16
19
  return saved;
17
20
  }
@@ -33,6 +36,7 @@ export class RemoteSession {
33
36
  fullscreen = false;
34
37
  onChange = () => {};
35
38
  onCompleted?: (taskId: number, signal: AbortSignal) => Promise<void>;
39
+ onFieldNotification?: (notice: NonNullable<NativeView['fieldNotifications']>[number]) => boolean;
36
40
  private config: RemoteConfig;
37
41
  private persist: (saved: SavedState) => void;
38
42
  private factory: PeerFactory;
@@ -61,7 +65,7 @@ export class RemoteSession {
61
65
  if (!this.saved) throw new RemoteFailure();
62
66
  return {
63
67
  [UI_CONTEXT]: { conversationId: this.conversationId, contextId: this.saved.contextId, state: this.saved.state },
64
- [UI_FEATURES]: { actionRevisions: true, footerStyles: true, footerDoubleActions: true, footerSecondaryActions: true, footerNativeActions: this.fullscreen },
68
+ [UI_FEATURES]: { actionRevisions: true, fieldEvents: true, footerStyles: true, footerDoubleActions: true, footerSecondaryActions: true, footerNativeActions: this.fullscreen },
65
69
  };
66
70
  }
67
71
  private serial<T>(run: () => Promise<T>, signal?: AbortSignal): Promise<T> {
@@ -95,9 +99,15 @@ export class RemoteSession {
95
99
  throw remoteFailure(error, mutation);
96
100
  }
97
101
  private apply(view: NativeView): void {
98
- const changed = JSON.stringify(this.saved?.state) !== JSON.stringify(view.state) || this.saved?.retainOnNew !== view.retainOnNew;
102
+ let changed = JSON.stringify(this.saved?.state) !== JSON.stringify(view.state) || this.saved?.retainOnNew !== view.retainOnNew;
99
103
  this.view = view;
100
104
  this.saved = { ...this.saved!, state: view.state, retainOnNew: view.retainOnNew };
105
+ const receipts = new Set(this.saved.fieldReceipts ?? []);
106
+ for (const notice of view.fieldNotifications ?? []) {
107
+ if (notice.taskId !== view.state.selectedId || receipts.has(notice.id)) continue;
108
+ if (this.onFieldNotification?.(notice)) { receipts.add(notice.id); changed = true; }
109
+ }
110
+ if (receipts.size) this.saved.fieldReceipts = [...receipts].slice(-4096);
101
111
  if (changed) this.persist(structuredClone(this.saved));
102
112
  this.onChange();
103
113
  }