pi-note 0.40.0 → 0.40.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.
- package/README.md +14 -8
- package/dist-mcp/mcp/core.js +2 -0
- package/mcp/core.ts +2 -0
- package/package.json +1 -1
- package/remote/menu.ts +21 -10
- package/remote/navigation.ts +12 -16
- package/remote/ui.ts +6 -2
package/README.md
CHANGED
|
@@ -52,10 +52,11 @@ state routing, tools and UI.
|
|
|
52
52
|
|
|
53
53
|
## Use
|
|
54
54
|
|
|
55
|
-
Open **`/pinote`** for the
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
Open **`/pinote`** for the searchable task list, newest-created first, with
|
|
56
|
+
**+ New task** as its first option. All search terms must match the title,
|
|
57
|
+
description, tag or numeric ID. Results show status and the current selection;
|
|
58
|
+
selection waits for fresh search results. Selected-task and pending-suggestion
|
|
59
|
+
actions follow the task results.
|
|
59
60
|
Press **F1** on a result to read its full title and text without selecting it.
|
|
60
61
|
Selecting a task starts it and returns to the unchanged prompt without another
|
|
61
62
|
confirmation. **Clear selection** leaves its progress unchanged.
|
|
@@ -68,9 +69,10 @@ Footer double-click directly approves suggestion acceptance or completion;
|
|
|
68
69
|
completion requires idle Pi. Failed or uncertain
|
|
69
70
|
writes offer refresh/verification, never automatic replay.
|
|
70
71
|
|
|
71
|
-
**
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
Press **Tab** for **Settings**: **Agent guidance**, **Fields**, **Task offers**,
|
|
73
|
+
then **Footer layout**. **Tab** or **Escape** returns to tasks with the search
|
|
74
|
+
retained. Editors accept plain text or numbers, not JSON; validation retains the
|
|
75
|
+
draft. Cancelling a change's confirmation returns to its text draft. Values
|
|
74
76
|
are saved only after review and confirmation.
|
|
75
77
|
|
|
76
78
|
Menus use Pi's native inline components, themes and configured selection keys.
|
|
@@ -212,7 +214,7 @@ CLI/service instead of silently truncating.
|
|
|
212
214
|
|
|
213
215
|
### Field watchers (pi-note 0.40.0+)
|
|
214
216
|
|
|
215
|
-
Open **Settings →
|
|
217
|
+
Open **Settings → Fields → Watchers** to enable bundled watchers or add a
|
|
216
218
|
trusted executable on the service machine. Files in the packaged `watchers/`
|
|
217
219
|
folder are discovered automatically, but remain disabled until enabled explicitly.
|
|
218
220
|
Each watcher handles task-field changes and scheduled checks independently.
|
|
@@ -226,6 +228,10 @@ See [setup, script contract and safeguards](mcp/FIELD_EVENTS.md).
|
|
|
226
228
|
|
|
227
229
|
## Migration
|
|
228
230
|
|
|
231
|
+
Since pi-note 0.40.1, `/pinote` opens tasks directly and **Tab** opens settings.
|
|
232
|
+
Update both client and service for newest-created-first search results, then
|
|
233
|
+
`/reload` Pi.
|
|
234
|
+
|
|
229
235
|
Since pi-note 0.40.0, `fieldWatchers` replaces `fieldHandler`. Existing handlers
|
|
230
236
|
appear disabled and need explicit re-enabling: enabling now authorizes automatic
|
|
231
237
|
worker turns, not just notifications. Update/restart the service and `/reload` Pi.
|
package/dist-mcp/mcp/core.js
CHANGED
|
@@ -266,6 +266,8 @@ function view(data, scoped = false, secondary = false, features = {}) {
|
|
|
266
266
|
const matches = tasks.filter((t) => state.launch
|
|
267
267
|
? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
|
|
268
268
|
: terms.every((term) => `${t.text} ${t.tag ?? ''} #${t.id}`.toLowerCase().includes(term)));
|
|
269
|
+
// IDs are assigned by SQLite AUTOINCREMENT at creation and retained on edits/restores.
|
|
270
|
+
matches.sort((a, b) => b.id - a.id);
|
|
269
271
|
for (const candidate of matches.slice(0, 200))
|
|
270
272
|
action(`select:${candidate.id}`, taskTitle(candidate.text), { description: `#${candidate.id} · ${candidate.tag || 'No tag'} · ${humanStatus(candidate.state)}${candidate.id === task?.id ? ' · CURRENT' : ''} · Select and start this task for this conversation` });
|
|
271
273
|
if (matches.length > 200)
|
package/mcp/core.ts
CHANGED
|
@@ -234,6 +234,8 @@ function view(data: Snapshot, scoped = false, secondary = false, features: { foo
|
|
|
234
234
|
const matches = tasks.filter((t) => state.launch
|
|
235
235
|
? t.id === state.launch.task_id && t.updated_at === state.launch.updated_at
|
|
236
236
|
: terms.every((term) => `${t.text} ${t.tag ?? ''} #${t.id}`.toLowerCase().includes(term)));
|
|
237
|
+
// IDs are assigned by SQLite AUTOINCREMENT at creation and retained on edits/restores.
|
|
238
|
+
matches.sort((a, b) => b.id - a.id);
|
|
237
239
|
for (const candidate of matches.slice(0, 200)) action(`select:${candidate.id}`, taskTitle(candidate.text), { description: `#${candidate.id} · ${candidate.tag || 'No tag'} · ${humanStatus(candidate.state)}${candidate.id === task?.id ? ' · CURRENT' : ''} · Select and start this task for this conversation` });
|
|
238
240
|
if (matches.length > 200) body += '\nShowing the first 200 matches; use Filter tasks to narrow the list.';
|
|
239
241
|
}
|
package/package.json
CHANGED
package/remote/menu.ts
CHANGED
|
@@ -2,11 +2,12 @@ import { DynamicBorder, getSelectListTheme, keyText, type ExtensionContext } fro
|
|
|
2
2
|
import { Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Container, type TUI } from '@earendil-works/pi-tui';
|
|
3
3
|
import { clean, editorText, type NativeView } from './protocol.ts';
|
|
4
4
|
|
|
5
|
-
export interface MenuState { search: string; selected?: string; input?: Input }
|
|
5
|
+
export interface MenuState { search: string; selected?: string; input?: Input; pendingSearch?: Promise<void> }
|
|
6
6
|
export interface MenuChoice { id: string }
|
|
7
7
|
export interface MenuOptions {
|
|
8
8
|
search?: 'none' | 'local' | 'tasks';
|
|
9
9
|
searchLabel?: string;
|
|
10
|
+
tab?: 'settings' | 'tasks';
|
|
10
11
|
onSearch?: (query: string) => Promise<void>;
|
|
11
12
|
notice?: string;
|
|
12
13
|
}
|
|
@@ -55,21 +56,28 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
|
|
|
55
56
|
const fresh = () => !tasks || state.search === String(view.state.search ?? '');
|
|
56
57
|
const schedule = () => {
|
|
57
58
|
clearTimeout(timer);
|
|
58
|
-
if (fresh() || !options.onSearch ||
|
|
59
|
+
if (fresh() || !options.onSearch || state.pendingSearch || disposed) return;
|
|
59
60
|
timer = setTimeout(() => {
|
|
60
61
|
if (disposed || signal.aborted) return;
|
|
61
62
|
searching = true;
|
|
62
|
-
|
|
63
|
+
const query = state.search;
|
|
64
|
+
const pending = options.onSearch!(query).catch(() => { error = 'Search unavailable · Ctrl+R refresh'; });
|
|
65
|
+
state.pendingSearch = pending;
|
|
66
|
+
void pending.then(() => {
|
|
67
|
+
if (state.pendingSearch === pending) state.pendingSearch = undefined;
|
|
63
68
|
searching = false;
|
|
64
|
-
if (!disposed) tui.requestRender();
|
|
69
|
+
if (!disposed) { if (!error && state.search !== query) schedule(); tui.requestRender(); }
|
|
65
70
|
});
|
|
66
71
|
}, 250);
|
|
67
72
|
};
|
|
73
|
+
// A notification can replace this component before its search action settles.
|
|
74
|
+
// Share the in-flight read so replacements never queue stale duplicate searches.
|
|
75
|
+
if (state.pendingSearch) void state.pendingSearch.then(() => { if (!disposed) { schedule(); tui.requestRender(); } });
|
|
68
76
|
schedule();
|
|
69
77
|
const activate = (preview = false) => {
|
|
70
78
|
if (signal.aborted || !usable || !layout || !fresh() || searching || geometry !== `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) return;
|
|
71
79
|
const item = list?.getSelectedItem();
|
|
72
|
-
if (item) done({ id: preview
|
|
80
|
+
if (item && (!preview || item.value.startsWith('select:'))) done({ id: preview ? `__preview:${item.value.slice('select:'.length)}` : item.value });
|
|
73
81
|
};
|
|
74
82
|
const component: Component & { dispose(): void; focused: boolean; capturesViewportInput: boolean } = {
|
|
75
83
|
capturesViewportInput: true,
|
|
@@ -82,7 +90,7 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
|
|
|
82
90
|
if (!usable) { layout = ''; return [truncateToWidth('Enlarge terminal · Esc back', width)]; }
|
|
83
91
|
const compact = height < 18;
|
|
84
92
|
const border = new DynamicBorder((s) => theme.fg('border', s)).render(width);
|
|
85
|
-
const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')} ${tasks ? '
|
|
93
|
+
const hints = wrapTextWithAnsi(`${keyText('tui.select.up')}/${keyText('tui.select.down')} move · ${keyText('tui.select.confirm')} ${tasks ? 'open · F1 preview task' : 'open'}${options.tab ? ` · ${keyText('tui.input.tab')} ${options.tab}` : ''} · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
|
|
86
94
|
const summary = (view.body ?? '').split('\n').flatMap((s) => wrapTextWithAnsi(clean(s), width - 2));
|
|
87
95
|
const summaryLimit = compact ? 1 : 3;
|
|
88
96
|
const header = [...border, theme.fg('accent', heading(view.title, width)),
|
|
@@ -98,12 +106,14 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
|
|
|
98
106
|
if (layout !== signature) {
|
|
99
107
|
layout = signature;
|
|
100
108
|
list = new SelectList(filtered, capacity, {
|
|
101
|
-
...getSelectListTheme(), noMatch: () => theme.fg('muted', tasks ? state.search ? ' No tasks match this search.' : ' No active tasks.
|
|
109
|
+
...getSelectListTheme(), noMatch: () => theme.fg('muted', tasks ? state.search ? ' No tasks match this search.' : ' No active tasks.' : ' No matching items.'),
|
|
102
110
|
}, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: Math.min(40, Math.floor(width * 0.6)) });
|
|
103
|
-
|
|
111
|
+
const selected = filtered.some((item) => item.value === state.selected) ? state.selected
|
|
112
|
+
: tasks && state.search.trim() ? filtered.find((item) => item.value.startsWith('select:'))?.value : undefined;
|
|
113
|
+
list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.value === selected)));
|
|
104
114
|
list.onSelectionChange = (item) => { state.selected = item.value; };
|
|
105
115
|
list.onSelect = () => activate(); list.onCancel = cancel;
|
|
106
|
-
if (list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
|
|
116
|
+
if (fresh() && list.getSelectedItem()) state.selected = list.getSelectedItem()!.value;
|
|
107
117
|
}
|
|
108
118
|
const selectedIndex = filtered.findIndex((item) => item.value === list?.getSelectedItem()?.value);
|
|
109
119
|
const selected = filtered[selectedIndex];
|
|
@@ -122,7 +132,8 @@ export function remoteMenu(ctx: ExtensionContext, view: NativeView, state: MenuS
|
|
|
122
132
|
handleInput(data) {
|
|
123
133
|
if (keys.matches(data, 'tui.select.cancel') || matchesKey(data, Key.ctrl('c'))) cancel();
|
|
124
134
|
else if (usable && geometry === `${tui.terminal.columns}:${tui.terminal.rows}:${inlineHeight(tui, tui.terminal.columns, component)}`) {
|
|
125
|
-
if (
|
|
135
|
+
if (options.tab && keys.matches(data, 'tui.input.tab')) done({ id: `__${options.tab}` });
|
|
136
|
+
else if (matchesKey(data, Key.ctrl('r'))) done({ id: '__refresh' });
|
|
126
137
|
else if (tasks && matchesKey(data, Key.f1)) activate(true);
|
|
127
138
|
else if (keys.matches(data, 'tui.select.confirm')) activate();
|
|
128
139
|
else if (keys.matches(data, 'tui.select.pageUp') || keys.matches(data, 'tui.select.pageDown')) {
|
package/remote/navigation.ts
CHANGED
|
@@ -2,34 +2,30 @@ import { defaultFooterField, parseAgentGuidance, parseFooterConfig, parseHandoff
|
|
|
2
2
|
import { parseFieldWatchers } from '../field-watchers.ts';
|
|
3
3
|
import { clean, editorText, type NativeControl, type NativeView } from './protocol.ts';
|
|
4
4
|
|
|
5
|
-
export type Screen = '
|
|
5
|
+
export type Screen = 'tasks' | 'settings' | 'prompts' | 'footer' | 'fields' | 'field' | 'watchers';
|
|
6
6
|
const group = (id: Screen, label: string, description: string): NativeControl => ({ id: `__${id}`, label, description, kind: 'action' });
|
|
7
|
-
export const parentScreen = (screen: Screen): Screen | undefined => ({
|
|
8
|
-
export const serverScreen = (screen: Screen) => screen === '
|
|
7
|
+
export const parentScreen = (screen: Screen): Screen | undefined => ({ settings: 'tasks', prompts: 'settings', footer: 'settings', fields: 'settings', field: 'fields', watchers: 'fields' } as const)[screen as Exclude<Screen, 'tasks'>];
|
|
8
|
+
export const serverScreen = (screen: Screen) => screen === 'tasks' ? 'tasks' : screen === 'field' ? 'field' : 'settings';
|
|
9
9
|
|
|
10
10
|
/** Local navigation only. Server controls/revisions remain the authority for every action. */
|
|
11
11
|
export function menuView(view: NativeView, screen: Screen): NativeView {
|
|
12
12
|
const take = (...ids: string[]) => ids.flatMap((id) => view.controls.filter((c) => c.id === id));
|
|
13
13
|
let controls: NativeControl[], title: string, body: string;
|
|
14
14
|
switch (screen) {
|
|
15
|
-
case 'home':
|
|
16
|
-
title = 'Pinote'; body = view.body ?? '';
|
|
17
|
-
controls = [...take('preview', 'proposal:preview', 'proposal:accept', 'proposal:dismiss', 'continue'),
|
|
18
|
-
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.'),
|
|
19
|
-
...take('add'), group('settings', 'Settings…', 'Shared prompts, task suggestions and footer display settings.'), ...take('done', 'clear')];
|
|
20
|
-
break;
|
|
21
15
|
case 'tasks':
|
|
22
|
-
title = 'Pinote
|
|
23
|
-
controls = view.controls.filter((c) => c.id.startsWith('select:'))
|
|
16
|
+
title = 'Pinote'; body = view.body ?? '';
|
|
17
|
+
controls = [...take('add'), ...view.controls.filter((c) => c.id.startsWith('select:')),
|
|
18
|
+
...take('preview', 'proposal:preview', 'proposal:accept', 'proposal:dismiss', 'continue', 'done', 'clear')];
|
|
19
|
+
if (!controls.some((c) => c.id.startsWith('select:'))) body = view.state.search ? 'No tasks match this search.' : 'No active tasks. Create one with + New task.';
|
|
24
20
|
break;
|
|
25
21
|
case 'settings':
|
|
26
22
|
title = 'Pinote / Settings'; body = 'Shared service settings · affects all connected clients. Task values are unchanged.';
|
|
27
|
-
controls = [group('prompts', '
|
|
28
|
-
|
|
29
|
-
group('
|
|
23
|
+
controls = [group('prompts', 'Agent guidance', 'Edit shared task prompts and injected instructions.'),
|
|
24
|
+
group('fields', 'Fields', 'Edit display definitions, not saved task values.'),
|
|
25
|
+
...take('taskOfferPolicy'), group('footer', 'Footer layout', 'Set title/field widths and the maximum visible handoff fields.')];
|
|
30
26
|
break;
|
|
31
27
|
case 'prompts':
|
|
32
|
-
title = 'Pinote / Settings /
|
|
28
|
+
title = 'Pinote / Settings / Agent guidance'; body = 'Shared prompts and instructions · each change requires review and confirmation.';
|
|
33
29
|
controls = take('handoffPrompt', 'newSessionPrompt', 'agentGuidance', 'reset-agent-guidance');
|
|
34
30
|
// The service deliberately omits oversized editors. Keep its explanation visible.
|
|
35
31
|
if (view.body?.includes('editor limit')) body = view.body;
|
|
@@ -39,7 +35,7 @@ export function menuView(view: NativeView, screen: Screen): NativeView {
|
|
|
39
35
|
controls = take('titleWidth', 'fieldWidth', 'maxFields');
|
|
40
36
|
break;
|
|
41
37
|
case 'fields':
|
|
42
|
-
title = 'Pinote / Settings /
|
|
38
|
+
title = 'Pinote / Settings / Fields'; body = 'Display definitions only. Populated task values appear in the footer.';
|
|
43
39
|
controls = [group('watchers', 'Watchers…', 'Bundled and custom field watchers; explicitly enable trusted code and automatic worker actions.'),
|
|
44
40
|
...view.controls.filter((c) => c.id.startsWith('field:')), ...take('add-field')];
|
|
45
41
|
break;
|
package/remote/ui.ts
CHANGED
|
@@ -148,8 +148,11 @@ export class RemoteUI {
|
|
|
148
148
|
this.opening = true;
|
|
149
149
|
const current = this.session.current(), signal = this.session.signal;
|
|
150
150
|
const menus = new Map<string, MenuState>(), drafts = new Map<string, Draft>();
|
|
151
|
-
let screen = '
|
|
151
|
+
let screen = 'tasks' as Screen, notice = '';
|
|
152
152
|
const go = async (next: Screen): Promise<boolean> => {
|
|
153
|
+
// Search changes view revisions. Navigate only after it settles, using fresh controls.
|
|
154
|
+
await menus.get('tasks')?.pendingSearch;
|
|
155
|
+
if (!current()) return false;
|
|
153
156
|
const view = this.session.view;
|
|
154
157
|
if (!view) return false;
|
|
155
158
|
const target = serverScreen(next);
|
|
@@ -163,7 +166,7 @@ export class RemoteUI {
|
|
|
163
166
|
};
|
|
164
167
|
try {
|
|
165
168
|
await this.refresh(ctx);
|
|
166
|
-
await go('
|
|
169
|
+
await go('tasks');
|
|
167
170
|
while (current() && !signal.aborted && !this.closing) {
|
|
168
171
|
if (this.failure || !this.session.view) {
|
|
169
172
|
const failure = this.failure;
|
|
@@ -184,6 +187,7 @@ export class RemoteUI {
|
|
|
184
187
|
this.menuChanged = changed;
|
|
185
188
|
return () => { if (this.menuChanged === changed) this.menuChanged = undefined; };
|
|
186
189
|
}, { search: screen === 'tasks' ? 'tasks' : screen === 'fields' ? 'local' : 'none', notice,
|
|
190
|
+
tab: screen === 'tasks' ? 'settings' : screen === 'settings' ? 'tasks' : undefined,
|
|
187
191
|
searchLabel: screen === 'fields' ? 'Filter fields' : undefined,
|
|
188
192
|
onSearch: async (query) => {
|
|
189
193
|
const latest = this.session.view, control = latest?.controls.find((c) => c.id === 'search');
|