pi-anchor 0.4.0 → 0.5.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/src/tui.ts CHANGED
@@ -1,6 +1,7 @@
1
- import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
1
+ import type { ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
2
  import { AnchorStore, isAnchorTargetingWorkspace } from './store.ts';
3
- import type { Anchor } from './types.ts';
3
+ import type { Anchor, AnchorProgress } from './types.ts';
4
+ import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
 
6
7
  const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
@@ -110,6 +111,18 @@ export function padToWidth(str: string, targetWidth: number): string {
110
111
  return truncated + ' '.repeat(targetWidth - current);
111
112
  }
112
113
 
114
+ /**
115
+ * Render a 4-cell micro progress bar: e.g. `[■■□□ 2/4]` or `[■□□□ 5/30]`
116
+ */
117
+ export function formatProgressBar(progress?: AnchorProgress, cells = 4): string {
118
+ if (!progress || !progress.total || progress.total < 1) return '';
119
+ const done = Math.max(0, Math.min(progress.total, progress.done || 0));
120
+ const filled = Math.min(cells, Math.max(done > 0 ? 1 : 0, Math.round((done / progress.total) * cells)));
121
+ const finalFilled = done >= progress.total ? cells : (done === 0 ? 0 : filled);
122
+ const bar = '■'.repeat(finalFilled) + '□'.repeat(Math.max(0, cells - finalFilled));
123
+ return `[${bar} ${done}/${progress.total}]`;
124
+ }
125
+
113
126
  /**
114
127
  * Format relative sticky-note age cleanly: 'just now', '15m ago', '3h ago', '2d ago'
115
128
  */
@@ -125,7 +138,7 @@ export function formatRelativeAge(timestamp: number, now: number = Date.now()):
125
138
  }
126
139
 
127
140
  /**
128
- * Format workspace & first file context concisely (e.g. 'anchor · src/auth.ts' or 'College/优化')
141
+ * Format workspace & first file context concisely (e.g. 'anchor · auth.ts' or 'College/优化')
129
142
  */
130
143
  export function formatOrigin(anchor: Anchor | string): string {
131
144
  if (typeof anchor === 'string') {
@@ -176,19 +189,21 @@ export function sortAnchorsForDisplay(anchors: Anchor[], cwd?: string): Anchor[]
176
189
  }
177
190
 
178
191
  /**
179
- * Update status bar badge: `⌖ 2` (invisible when 0 active notes)
192
+ * Update status bar badge (`⌖ 2`) AND the persistent below-editor sticky strip!
180
193
  */
181
194
  export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore): void {
182
195
  if (!hasActiveUI(ctx)) return;
183
196
  try {
184
197
  const active = store.list({ status: 'active', cwd: ctx.cwd });
185
198
  ctx.ui.setStatus('anchor', active.length > 0 ? `⌖ ${active.length}` : undefined);
199
+ updateStartupBanner(ctx, store);
186
200
  } catch {}
187
201
  }
188
202
 
189
203
  /**
190
- * Render clean sticky-note card above editor on session startup.
191
- * Automatically disappears as soon as the user sends their first message.
204
+ * Render persistent 1-line sticky-note strip BELOW the editor (`placement: 'belowEditor'`).
205
+ * Never collides with `pi-lingual` (`aboveEditor`), stays visible while you work,
206
+ * and takes 0 lines when there are no active notes.
192
207
  */
193
208
  export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void {
194
209
  if (!hasActiveUI(ctx)) return;
@@ -196,54 +211,59 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
196
211
  try {
197
212
  const active = store.list({ status: 'active', cwd: ctx.cwd });
198
213
  if (active.length === 0) {
199
- ctx.ui.setWidget('anchor-startup', undefined);
214
+ ctx.ui.setWidget('anchor-startup', undefined, { placement: 'belowEditor' });
200
215
  return;
201
216
  }
202
217
 
203
218
  const sorted = sortAnchorsForDisplay(active, ctx.cwd);
204
- const topItems = sorted.slice(0, 3);
205
219
 
206
220
  const factory = (_tui?: any, theme?: any) => ({
207
221
  render: (width: number) => {
208
- const maxTitleWidth = Math.max(14, width - 34);
209
222
  const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
210
- const label = active.length === 1 ? '1 note' : `${active.length} notes`;
211
223
 
212
- const lines: string[] = [
213
- truncateToWidth(fg('accent', `⌖ ${label}`), width)
214
- ];
215
-
216
- for (const a of topItems) {
217
- const num = a.id.replace(/^anc-/, '').padStart(2, '0');
224
+ // Single note: render rich 1-line sticky strip with optional progress bar & next step
225
+ if (sorted.length === 1) {
226
+ const a = sorted[0];
227
+ const num = a.id.replace(/^anc-/, '');
228
+ const bar = a.progress ? `${fg('accent', formatProgressBar(a.progress))} ` : '';
229
+ const nextStep = a.progress?.steps && a.progress.steps[a.progress.done]
230
+ ? fg('muted', ` ▸ ${a.progress.steps[a.progress.done]}`)
231
+ : '';
218
232
  const origin = formatOrigin(a);
219
- const age = formatRelativeAge(a.createdAt);
220
- const titleClipped = truncateToWidth(a.title, maxTitleWidth);
221
- lines.push(
222
- truncateToWidth(
223
- ` ${fg('dim', num)} ${titleClipped} ${fg('muted', `· ${origin}`)} ${fg('dim', `(${age})`)}`,
224
- width
225
- )
226
- );
227
- }
228
-
229
- if (sorted.length > 3) {
230
- lines.push(truncateToWidth(fg('dim', ` +${sorted.length - 3} more · /anchor`), width));
233
+ const sessionIcon = a.sessionFile ? ' ↪' : '';
234
+ const prefix = `${fg('accent', '⌖')} ${fg('dim', `#${num}`)} ${bar}`;
235
+ const suffix = ` ${fg('muted', `(${origin}${sessionIcon})`)}`;
236
+ const availTitle = Math.max(12, width - getDisplayWidth(prefix) - getDisplayWidth( nextStep) - getDisplayWidth(suffix) - 2);
237
+ const titleClipped = truncateToWidth(a.title, availTitle);
238
+ return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
231
239
  }
232
240
 
233
- return lines;
241
+ // Multiple notes: compact horizontal sticky strip (`⌖ #1 ... · #2 ...`)
242
+ const countTag = fg('accent', `⌖ ${sorted.length} notes: `);
243
+ const segments = sorted.slice(0, 3).map(a => {
244
+ const num = a.id.replace(/^anc-/, '');
245
+ const bar = a.progress ? `${formatProgressBar(a.progress)} ` : '';
246
+ const shortTitle = truncateToWidth(a.title, Math.max(14, Math.floor((width - 20) / Math.min(3, sorted.length)) - 12));
247
+ return `${fg('dim', `#${num}`)} ${bar}${shortTitle}`;
248
+ });
249
+ const more = sorted.length > 3 ? fg('dim', ` +${sorted.length - 3}`) : '';
250
+ return [truncateToWidth(`${countTag}${segments.join(fg('dim', ' · '))}${more}`, width)];
234
251
  },
235
252
  invalidate: () => {}
236
253
  });
237
254
 
238
255
  const content = (ctx as any).ui?.theme ? factory : factory().render(80);
239
- ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
256
+ ctx.ui.setWidget('anchor-startup', content as any, { placement: 'belowEditor' });
240
257
  } catch {}
241
258
  }
242
259
 
243
260
  /**
244
- * Interactive `/anchor` sticky-note board
261
+ * Interactive `/anchor` sticky-note board with 1-click progress stepping and session teleport (`switchSession`)
245
262
  */
246
- export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorStore): Promise<void> {
263
+ export async function openAnchorDashboard(
264
+ ctx: ExtensionContext | ExtensionCommandContext,
265
+ store: AnchorStore
266
+ ): Promise<void> {
247
267
  if (!hasActiveUI(ctx)) return;
248
268
 
249
269
  const allNotes = store
@@ -251,7 +271,7 @@ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorSt
251
271
  .filter(a => a.status === 'active' || a.status === 'sleeping');
252
272
 
253
273
  if (allNotes.length === 0) {
254
- ctx.ui.notify('⌖ No sticky notes. Use /pin <note> to stick one.', 'info');
274
+ ctx.ui.notify('⌖ No sticky notes. Type `pin: <note>` or `/pin <note>` to stick one.', 'info');
255
275
  return;
256
276
  }
257
277
 
@@ -264,13 +284,14 @@ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorSt
264
284
 
265
285
  for (const a of sorted) {
266
286
  const num = a.id.replace(/^anc-/, '').padStart(2, '0');
267
- const origin = formatOrigin(a);
287
+ const bar = a.progress ? `${formatProgressBar(a.progress)} ` : '';
288
+ const origin = formatOrigin(a) + (a.sessionFile ? ' ↪' : '');
268
289
  const age = a.status === 'sleeping'
269
290
  ? `${formatRelativeAge(a.createdAt)} · quiet`
270
291
  : formatRelativeAge(a.createdAt);
271
292
 
272
293
  const colNum = `${num} `;
273
- const colTitle = padToWidth(a.title, titleWidth);
294
+ const colTitle = padToWidth(`${bar}${a.title}`, titleWidth);
274
295
  const colOrigin = padToWidth(origin, 22);
275
296
 
276
297
  const label = isNarrow
@@ -287,8 +308,22 @@ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorSt
287
308
  const anchor = optionMap.get(selected);
288
309
  if (!anchor) return;
289
310
 
311
+ const currentSessionFile = (ctx as any).sessionManager?.getSessionFile?.();
312
+ const canJumpSession = Boolean(
313
+ anchor.sessionFile &&
314
+ fs.existsSync(anchor.sessionFile) &&
315
+ typeof (ctx as ExtensionCommandContext).switchSession === 'function' &&
316
+ (!currentSessionFile || path.resolve(anchor.sessionFile) !== path.resolve(currentSessionFile))
317
+ );
318
+
319
+ const stepLabel = anchor.progress
320
+ ? `▶ Advance step (${anchor.progress.done}/${anchor.progress.total} ➔ ${Math.min(anchor.progress.total, anchor.progress.done + 1)}/${anchor.progress.total})`
321
+ : '▶ Start progress bar (1/4)';
322
+
290
323
  const actions = [
291
324
  '✓ Done (tear off note)',
325
+ stepLabel,
326
+ ...(canJumpSession ? ['↪ Jump to session (resume linked conversation)'] : []),
292
327
  ...(anchor.status === 'sleeping' ? ['⚡ Wake up (mark active)'] : []),
293
328
  'ℹ Read full note',
294
329
  '↩ Back'
@@ -301,6 +336,23 @@ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorSt
301
336
  store.settle(anchor.id, { settledBy: 'manual' });
302
337
  ctx.ui.notify(`⌖ Torn off #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}" (/anchor undo to put back)`, 'info');
303
338
  updateAnchorStatusBar(ctx, store);
339
+ } else if (action.startsWith('▶')) {
340
+ const { anchor: updated, completed } = store.step(anchor.id, 1);
341
+ updateAnchorStatusBar(ctx, store);
342
+ if (completed) {
343
+ ctx.ui.notify(
344
+ `⌖ #${updated.id.replace(/^anc-/, '')} reached ${formatProgressBar(updated.progress)}! Run /done ${updated.id.replace(/^anc-/, '')} to tear off.`,
345
+ 'info'
346
+ );
347
+ } else {
348
+ ctx.ui.notify(`⌖ Advanced #${updated.id.replace(/^anc-/, '')} to ${formatProgressBar(updated.progress)}`, 'info');
349
+ }
350
+ } else if (action.startsWith('↪') && anchor.sessionFile) {
351
+ try {
352
+ await (ctx as ExtensionCommandContext).switchSession(anchor.sessionFile);
353
+ } catch (err: any) {
354
+ ctx.ui.notify(`Could not switch session: ${err.message}`, 'error');
355
+ }
304
356
  } else if (action.startsWith('⚡')) {
305
357
  store.touch(anchor.id);
306
358
  ctx.ui.notify(`⌖ Woke up #${anchor.id.replace(/^anc-/, '')}: "${anchor.title}"`, 'info');
@@ -308,8 +360,10 @@ export async function openAnchorDashboard(ctx: ExtensionContext, store: AnchorSt
308
360
  } else if (action.startsWith('ℹ')) {
309
361
  const details = [
310
362
  `⌖ Note #${anchor.id.replace(/^anc-/, '')}: ${anchor.title}`,
363
+ anchor.progress ? `Progress: ${formatProgressBar(anchor.progress)}${anchor.progress.steps ? ` (${anchor.progress.steps.join(' → ')})` : ''}` : null,
311
364
  anchor.description ? `Details: ${anchor.description}` : null,
312
365
  anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
366
+ anchor.sessionFile ? `Session: ${ path.basename(anchor.sessionFile)}` : null,
313
367
  `Workspace: ${anchor.project || 'global'} · Pinned ${formatRelativeAge(anchor.createdAt)}`
314
368
  ].filter(Boolean).join('\n');
315
369
  ctx.ui.notify(details, 'info');
package/src/types.ts CHANGED
@@ -6,6 +6,15 @@
6
6
  export type AnchorStatus = 'active' | 'sleeping' | 'settled';
7
7
  export type AnchorPriority = 'p0' | 'p1' | 'p2';
8
8
 
9
+ export interface AnchorProgress {
10
+ /** Completed steps or units */
11
+ done: number;
12
+ /** Total steps or units */
13
+ total: number;
14
+ /** Optional parsed step labels (e.g. ['抽出接口', '替换Cookie', '补单测']) */
15
+ steps?: string[];
16
+ }
17
+
9
18
  export interface AnchorEvidence {
10
19
  settledAt?: number;
11
20
  settledBy?: 'commit' | 'manual' | 'one-tap-settlement' | 'manual-command' | 'verification-test';
@@ -33,10 +42,14 @@ export interface Anchor {
33
42
  updatedAt: number;
34
43
  /** Last activity timestamp (ms) */
35
44
  lastTouchedAt: number;
36
- /** Associated file paths or globs (explicit or auto-extracted from note text) */
45
+ /** Associated file paths or globs (explicit, auto-extracted from note text, or inherited from active session) */
37
46
  files: string[];
38
47
  /** Optional tags */
39
48
  tags: string[];
49
+ /** Associated Pi conversation session file (.jsonl) for 1-click session jump/resume */
50
+ sessionFile?: string;
51
+ /** Optional lightweight progress tracker for long-term or multi-step notes */
52
+ progress?: AnchorProgress;
40
53
  /** Optional due date ('YYYY-MM-DD') */
41
54
  targetDate?: string;
42
55
  /** Optional priority kept for backward compatibility */