pi-note 0.40.0 → 0.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -8
- package/dist-mcp/mcp/core.js +10 -7
- package/mcp/README.md +2 -1
- package/mcp/core.ts +10 -7
- package/package.json +1 -1
- package/remote/menu.ts +21 -10
- package/remote/navigation.ts +12 -16
- package/remote/session.ts +4 -2
- package/remote/ui.ts +6 -2
- package/remote.ts +2 -1
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.
|
|
@@ -105,6 +107,15 @@ Unrelated fresh launches start unselected.
|
|
|
105
107
|
Changing endpoints starts a separate selection namespace. Unsaved Pi sessions
|
|
106
108
|
cannot guarantee selection persistence.
|
|
107
109
|
|
|
110
|
+
With pi-note 0.41.0+ on both client and service, and note 0.25.0+, selecting a task
|
|
111
|
+
(including accepting a suggestion, creating one, or **Start in Kitty…**) appends
|
|
112
|
+
the actual Pi session ID to its persistent `pi-sessions` array, without duplicates.
|
|
113
|
+
Task reads and injected agent context include the array so later agents can locate
|
|
114
|
+
previous sessions on the machine that saved them. Reads, previews, reload/resume,
|
|
115
|
+
and carried selections do not append IDs; select the task in a new session to record it.
|
|
116
|
+
IDs are metadata only: transcripts are not uploaded, and unsaved/deleted sessions
|
|
117
|
+
may no longer be available. The array is separate from editable handoff fields.
|
|
118
|
+
|
|
108
119
|
The GTK app's **Start in Kitty…** action (note 0.13.0+) chooses a working directory
|
|
109
120
|
and opens a named tab with this task selected, without submitting a prompt.
|
|
110
121
|
In note 0.16.0+, it uses the tag's default folder when available, otherwise keeps
|
|
@@ -212,7 +223,7 @@ CLI/service instead of silently truncating.
|
|
|
212
223
|
|
|
213
224
|
### Field watchers (pi-note 0.40.0+)
|
|
214
225
|
|
|
215
|
-
Open **Settings →
|
|
226
|
+
Open **Settings → Fields → Watchers** to enable bundled watchers or add a
|
|
216
227
|
trusted executable on the service machine. Files in the packaged `watchers/`
|
|
217
228
|
folder are discovered automatically, but remain disabled until enabled explicitly.
|
|
218
229
|
Each watcher handles task-field changes and scheduled checks independently.
|
|
@@ -226,6 +237,10 @@ See [setup, script contract and safeguards](mcp/FIELD_EVENTS.md).
|
|
|
226
237
|
|
|
227
238
|
## Migration
|
|
228
239
|
|
|
240
|
+
Since pi-note 0.40.1, `/pinote` opens tasks directly and **Tab** opens settings.
|
|
241
|
+
Update both client and service for newest-created-first search results, then
|
|
242
|
+
`/reload` Pi.
|
|
243
|
+
|
|
229
244
|
Since pi-note 0.40.0, `fieldWatchers` replaces `fieldHandler`. Existing handlers
|
|
230
245
|
appear disabled and need explicit re-enabling: enabling now authorizes automatic
|
|
231
246
|
worker turns, not just notifications. Update/restart the service and `/reload` Pi.
|
package/dist-mcp/mcp/core.js
CHANGED
|
@@ -34,7 +34,7 @@ const ProposalInput = z.union([
|
|
|
34
34
|
ExistingProposal,
|
|
35
35
|
]);
|
|
36
36
|
const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
|
|
37
|
-
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
|
|
37
|
+
const Context = z.object({ conversationId: z.string().min(1).max(256), piSessionId: z.string().max(256).regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/).optional(), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
|
|
38
38
|
proposal: Proposal.nullable().optional(),
|
|
39
39
|
launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
|
|
40
40
|
selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
|
|
@@ -90,8 +90,8 @@ async function note(args, signal) {
|
|
|
90
90
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
91
91
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
92
92
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
93
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
94
|
-
throw new Error('Pinote
|
|
93
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 25)
|
|
94
|
+
throw new Error('Pinote service requires note 0.25.0+');
|
|
95
95
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
96
96
|
}
|
|
97
97
|
async function selected(state, signal) {
|
|
@@ -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)
|
|
@@ -361,7 +363,7 @@ async function agentContext(state, signal, automation) {
|
|
|
361
363
|
const guidance = agentGuidance();
|
|
362
364
|
const config = task ? settings().config : null;
|
|
363
365
|
const data = task
|
|
364
|
-
? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config, task.agent_notes) })}`
|
|
366
|
+
? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes, 'pi-sessions': task['pi-sessions'] })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config, task.agent_notes) })}`
|
|
365
367
|
: `No task is selected (including unavailable or completed selected targets).\nSaved tags:\n${await note(['agent', 'tags'], signal)}\nPending suggestion (not selected):\n${JSON.stringify(proposal ? { ...proposal, ...('task_id' in proposal ? { text: proposedTask.text, tag: proposedTask.tag } : {}) } : null)}`;
|
|
366
368
|
const statuses = task ? automation?.statuses(task) ?? [] : [];
|
|
367
369
|
let text = guidance ? `${guidance}\n${data}` : data;
|
|
@@ -419,6 +421,7 @@ export function createPinoteServer(options = {}) {
|
|
|
419
421
|
const caller = context(request.params._meta);
|
|
420
422
|
const state = caller.state;
|
|
421
423
|
const owner = caller.contextId ?? caller.conversationId;
|
|
424
|
+
const sessionArgs = caller.piSessionId ? ['--pi-session-id', caller.piSessionId] : [];
|
|
422
425
|
const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
|
|
423
426
|
const scoped = scopedActions(request.params._meta);
|
|
424
427
|
const { action, value } = request.params;
|
|
@@ -438,7 +441,7 @@ export function createPinoteServer(options = {}) {
|
|
|
438
441
|
next.proposal = null;
|
|
439
442
|
if (action === 'proposal:accept') {
|
|
440
443
|
const task = 'task_id' in proposal ? before.proposedTask : JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
441
|
-
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
444
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal);
|
|
442
445
|
next.selectedId = task.id;
|
|
443
446
|
}
|
|
444
447
|
}
|
|
@@ -468,7 +471,7 @@ export function createPinoteServer(options = {}) {
|
|
|
468
471
|
staleAction();
|
|
469
472
|
if (next.proposal)
|
|
470
473
|
await consume(next.proposal.id);
|
|
471
|
-
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal);
|
|
474
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at, ...sessionArgs], extra.signal);
|
|
472
475
|
next.selectedId = id;
|
|
473
476
|
next.proposal = null;
|
|
474
477
|
}
|
|
@@ -477,7 +480,7 @@ export function createPinoteServer(options = {}) {
|
|
|
477
480
|
await consume(next.proposal.id);
|
|
478
481
|
next.proposal = null;
|
|
479
482
|
const task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
480
|
-
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal);
|
|
483
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal);
|
|
481
484
|
next.selectedId = task.id;
|
|
482
485
|
}
|
|
483
486
|
else if (action === 'done') {
|
package/mcp/README.md
CHANGED
|
@@ -7,7 +7,8 @@ resources, notifications and its native terminal UI. No browser UI is served.
|
|
|
7
7
|
## Build and run
|
|
8
8
|
|
|
9
9
|
On the machine holding your notes, requires **Node.js 22.19+**, **Python 3.11+**
|
|
10
|
-
and the checkout's `note` CLI
|
|
10
|
+
and the checkout's `note` CLI **0.25.0+** (schema 8). Upgrade all installations
|
|
11
|
+
sharing the database before starting the service:
|
|
11
12
|
|
|
12
13
|
```sh
|
|
13
14
|
uv sync --locked
|
package/mcp/core.ts
CHANGED
|
@@ -36,14 +36,14 @@ const ProposalInput = z.union([
|
|
|
36
36
|
ExistingProposal,
|
|
37
37
|
]);
|
|
38
38
|
const Proposal = z.union([NewProposal.extend({ id: z.string().uuid() }), ExistingProposal.extend({ id: z.string().uuid() })]);
|
|
39
|
-
const Context = z.object({ conversationId: z.string().min(1).max(256), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
|
|
39
|
+
const Context = z.object({ conversationId: z.string().min(1).max(256), piSessionId: z.string().max(256).regex(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/).optional(), contextId: z.string().min(1).max(256).optional(), fieldReceipts: z.array(z.string().uuid()).max(4096).optional(), state: z.object({
|
|
40
40
|
proposal: Proposal.nullable().optional(),
|
|
41
41
|
launch: z.object({ task_id: TaskId, updated_at: z.string().min(1).max(128) }).strict().optional(),
|
|
42
42
|
selectedId: z.number().int().positive().safe().nullable().optional(), screen: z.enum(['tasks', 'settings', 'field']).optional(),
|
|
43
43
|
field: z.string().refine((value) => [...value].length <= 64, 'Field name exceeds 64 code points').optional(), search: z.string().max(256).optional(),
|
|
44
44
|
}).default({}) });
|
|
45
45
|
type State = z.infer<typeof Context>['state'];
|
|
46
|
-
type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string
|
|
46
|
+
type Task = { id: number; text: string; state: string; tag: string | null; updated_at: string; agent_notes: Record<string, string>; 'pi-sessions': string[] };
|
|
47
47
|
type Scalar = string | number | boolean | null;
|
|
48
48
|
type Control = { id: string; label: string; revision?: string; kind: 'action' | 'text' | 'number' | 'toggle' | 'select'; value?: Scalar; icon?: string; description?: string; multiline?: boolean; confirm?: string; options?: { label: string; value: Scalar }[] };
|
|
49
49
|
const Action = z.object({ method: z.literal('native-ui/action'), params: z.object({
|
|
@@ -81,7 +81,7 @@ async function note(args: string[], signal: AbortSignal): Promise<string> {
|
|
|
81
81
|
const options = { signal, timeout: 10_000, maxBuffer: 4 * 1024 * 1024 };
|
|
82
82
|
const version = (await exec(noteCommand, ['--version'], options)).stdout.trim();
|
|
83
83
|
const match = /^pinote (\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
84
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) <
|
|
84
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 25) throw new Error('Pinote service requires note 0.25.0+');
|
|
85
85
|
return (await exec(noteCommand, ['--no-notify', ...args], options)).stdout;
|
|
86
86
|
}
|
|
87
87
|
async function selected(state: State, signal: AbortSignal): Promise<Task | null> {
|
|
@@ -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
|
}
|
|
@@ -304,7 +306,7 @@ async function agentContext(state: State, signal: AbortSignal, automation?: Fiel
|
|
|
304
306
|
const guidance = agentGuidance();
|
|
305
307
|
const config = task ? settings().config : null;
|
|
306
308
|
const data = task
|
|
307
|
-
? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config!, task.agent_notes) })}`
|
|
309
|
+
? `Selected task (complete text, revision and handoff notes):\n${JSON.stringify({ id: task.id, text: task.text, tag: task.tag, state: task.state, updated_at: task.updated_at, agent_notes: task.agent_notes, 'pi-sessions': task['pi-sessions'] })}\nEffective handoff field definitions:\n${JSON.stringify({ ...config, fields: fields(config!, task.agent_notes) })}`
|
|
308
310
|
: `No task is selected (including unavailable or completed selected targets).\nSaved tags:\n${await note(['agent', 'tags'], signal)}\nPending suggestion (not selected):\n${JSON.stringify(proposal ? { ...proposal, ...('task_id' in proposal ? { text: proposedTask!.text, tag: proposedTask!.tag } : {}) } : null)}`;
|
|
309
311
|
const statuses = task ? automation?.statuses(task) ?? [] : [];
|
|
310
312
|
let text = guidance ? `${guidance}\n${data}` : data;
|
|
@@ -356,6 +358,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
356
358
|
const caller = context(request.params._meta);
|
|
357
359
|
const state = caller.state;
|
|
358
360
|
const owner = caller.contextId ?? caller.conversationId;
|
|
361
|
+
const sessionArgs = caller.piSessionId ? ['--pi-session-id', caller.piSessionId] : [];
|
|
359
362
|
const before = await snapshot(state, extra.signal, options.fieldAutomation, owner, caller.fieldReceipts);
|
|
360
363
|
const scoped = scopedActions(request.params._meta);
|
|
361
364
|
const { action, value } = request.params;
|
|
@@ -372,7 +375,7 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
372
375
|
next.proposal = null;
|
|
373
376
|
if (action === 'proposal:accept') {
|
|
374
377
|
const task: Task = 'task_id' in proposal ? before.proposedTask! : JSON.parse(await note(['agent', 'add', `--text=${proposal.text}`, ...(proposal.tag ? [`--tag=${proposal.tag}`] : [])], extra.signal));
|
|
375
|
-
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
378
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal); next.selectedId = task.id;
|
|
376
379
|
}
|
|
377
380
|
} else if (action.startsWith('screen:')) next.screen = action.slice(7) as State['screen'];
|
|
378
381
|
else if (action.startsWith('field:')) { next.screen = 'field'; next.field = action.slice(6); }
|
|
@@ -388,12 +391,12 @@ server.setRequestHandler(Action, (request, extra) => serial(async () => {
|
|
|
388
391
|
const target: Task = JSON.parse(await note(['agent', 'get', String(id)], extra.signal));
|
|
389
392
|
if (target.text !== candidate.text || target.state !== candidate.state || target.tag !== candidate.tag || state.launch && target.updated_at !== state.launch.updated_at) staleAction();
|
|
390
393
|
if (next.proposal) await consume(next.proposal.id);
|
|
391
|
-
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at], extra.signal); next.selectedId = id; next.proposal = null;
|
|
394
|
+
await note(['agent', 'start', String(id), '--expected-updated-at', target.updated_at, ...sessionArgs], extra.signal); next.selectedId = id; next.proposal = null;
|
|
392
395
|
} else if (action === 'add') {
|
|
393
396
|
if (next.proposal) await consume(next.proposal.id);
|
|
394
397
|
next.proposal = null;
|
|
395
398
|
const task: Task = JSON.parse(await note(['agent', 'add', `--text=${z.string().min(1).parse(value)}`], extra.signal));
|
|
396
|
-
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at], extra.signal); next.selectedId = task.id;
|
|
399
|
+
await note(['agent', 'start', String(task.id), '--expected-updated-at', task.updated_at, ...sessionArgs], extra.signal); next.selectedId = task.id;
|
|
397
400
|
} else if (action === 'done') {
|
|
398
401
|
await note(['agent', 'done', String(before.task!.id), '--expected-updated-at', before.task!.updated_at], extra.signal);
|
|
399
402
|
next.selectedId = null;
|
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/session.ts
CHANGED
|
@@ -31,6 +31,7 @@ export class RemoteSession {
|
|
|
31
31
|
private refreshing = false;
|
|
32
32
|
private active = false;
|
|
33
33
|
private conversationId = '';
|
|
34
|
+
private piSessionId?: string;
|
|
34
35
|
private saved?: SavedState;
|
|
35
36
|
view?: NativeView;
|
|
36
37
|
fullscreen = false;
|
|
@@ -46,10 +47,11 @@ export class RemoteSession {
|
|
|
46
47
|
get signal(): AbortSignal { return this.lifetime.signal; }
|
|
47
48
|
snapshot(): SavedState | undefined { return this.saved && structuredClone(this.saved); }
|
|
48
49
|
current(): () => boolean { const epoch = this.epoch; return () => this.active && epoch === this.epoch; }
|
|
49
|
-
async start(branch: readonly BranchEntry[], conversationId: string, carried?: SavedState): Promise<void> {
|
|
50
|
+
async start(branch: readonly BranchEntry[], conversationId: string, carried?: SavedState, piSessionId?: string): Promise<void> {
|
|
50
51
|
await this.stop();
|
|
51
52
|
this.lifetime = new AbortController(); this.active = true;
|
|
52
53
|
this.conversationId = conversationId;
|
|
54
|
+
this.piSessionId = piSessionId;
|
|
53
55
|
this.saved = carried?.scope === this.config.scope ? structuredClone(carried) : restoreState(branch, this.config.scope);
|
|
54
56
|
this.persist(this.saved);
|
|
55
57
|
await this.refresh(true);
|
|
@@ -64,7 +66,7 @@ export class RemoteSession {
|
|
|
64
66
|
private meta(): Record<string, unknown> {
|
|
65
67
|
if (!this.saved) throw new RemoteFailure();
|
|
66
68
|
return {
|
|
67
|
-
[UI_CONTEXT]: { conversationId: this.conversationId, contextId: this.saved.contextId, state: this.saved.state, fieldReceipts: this.saved.fieldReceipts ?? [] },
|
|
69
|
+
[UI_CONTEXT]: { conversationId: this.conversationId, piSessionId: this.piSessionId, contextId: this.saved.contextId, state: this.saved.state, fieldReceipts: this.saved.fieldReceipts ?? [] },
|
|
68
70
|
[UI_FEATURES]: { actionRevisions: true, fieldEvents: true, fieldWatchers: true, footerStyles: true, footerDoubleActions: true, footerSecondaryActions: true, footerNativeActions: this.fullscreen },
|
|
69
71
|
};
|
|
70
72
|
}
|
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');
|
package/remote.ts
CHANGED
|
@@ -89,7 +89,8 @@ export default function pinoteRemote(pi: ExtensionAPI): void {
|
|
|
89
89
|
ctx.ui.notify('Pinote launch ignored: invalid request or nonempty/resumed session. Existing selection is unchanged.', 'warning');
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
const piSessionId = ctx.sessionManager.getSessionId();
|
|
93
|
+
await session.start(branch, piSessionId || randomUUID(), carried, piSessionId || undefined);
|
|
93
94
|
if (launch) {
|
|
94
95
|
const view = session.view!;
|
|
95
96
|
// The service exposes this control only for the exact GUI-displayed revision.
|