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 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.
@@ -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 → Footer fields → Watchers** to enable bundled watchers or add a
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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-note",
3
- "version": "0.40.0",
3
+ "version": "0.40.1",
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/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');