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 CHANGED
@@ -52,10 +52,11 @@ state routing, tools and UI.
52
52
 
53
53
  ## Use
54
54
 
55
- Open **`/pinote`** for the selected task's actions, pending suggestion, task picker
56
- and shared settings. **Choose a task… / Switch task…** opens task-only search:
57
- all search terms must match the title, description, tag or numeric ID. Results show
58
- status and the current selection; selection waits for fresh search results.
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
- **Settings** groups prompts/guidance, task-offer policy, footer layout and field
72
- definitions. Editors accept plain text or numbers, not JSON; validation retains
73
- the draft. Cancelling a change's confirmation returns to its text draft. Values
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 → Footer fields → Watchers** to enable bundled watchers or add a
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.
@@ -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]) < 12)
94
- throw new Error('Pinote MCP requires note 0.12.0+');
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]) < 12) throw new Error('Pinote MCP requires note 0.12.0+');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Persistent pinote tasks, Markdown handoffs, and task status for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 || searching || disposed) return;
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
- void options.onSearch!(state.search).catch(() => { error = 'Search unavailable · Ctrl+R refresh'; }).finally(() => {
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 && tasks ? `__preview:${item.value.slice('select:'.length)}` : item.value });
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 ? 'select · F1 preview' : 'open'} · ${keyText('tui.select.cancel')} back · Ctrl+R refresh`, width);
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. Esc back to create one.' : ' No matching items.'),
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
- list.setSelectedIndex(Math.max(0, filtered.findIndex((item) => item.value === state.selected)));
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 (matchesKey(data, Key.ctrl('r'))) done({ id: '__refresh' });
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')) {
@@ -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 = 'home' | 'tasks' | 'settings' | 'prompts' | 'footer' | 'fields' | 'field' | 'watchers';
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 => ({ tasks: 'home', settings: 'home', prompts: 'settings', footer: 'settings', fields: 'settings', field: 'fields', watchers: 'fields' } as const)[screen as Exclude<Screen, 'home'>];
8
- export const serverScreen = (screen: Screen) => screen === 'home' || screen === 'tasks' ? 'tasks' : screen === 'field' ? 'field' : 'settings';
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 / Tasks'; body = view.body ?? '';
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', 'Prompts and agent guidance…', 'Edit shared task prompts and injected instructions.'),
28
- ...take('taskOfferPolicy'), group('footer', 'Footer layout…', 'Set title/field widths and the maximum visible handoff fields.'),
29
- group('fields', 'Footer fields…', 'Edit display definitions, not saved task values.')];
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 / Prompts'; body = 'Shared prompts and instructions · each change requires review and confirmation.';
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 / Footer fields'; body = 'Display definitions only. Populated task values appear in the footer.';
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 = 'home' as Screen, notice = '';
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('home');
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
- await session.start(branch, ctx.sessionManager.getSessionId() || randomUUID(), carried);
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.