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/README.md +35 -34
- package/README_zh.md +33 -37
- package/assets/hero-en.svg +47 -30
- package/assets/hero-zh.svg +45 -28
- package/bin/anchor.js +39 -8
- package/dist/{chunk-LKY6WIKH.js → chunk-VXEQDTK4.js} +270 -60
- package/dist/{extension-B1-fzCox.d.cts → extension-Cq1NH1jT.d.cts} +42 -4
- package/dist/{extension-B1-fzCox.d.ts → extension-Cq1NH1jT.d.ts} +42 -4
- package/dist/extension.cjs +269 -60
- package/dist/extension.d.cts +1 -1
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +3 -1
- package/dist/index.cjs +156 -30
- package/dist/index.d.cts +15 -10
- package/dist/index.d.ts +15 -10
- package/dist/index.js +7 -1
- package/package.json +2 -2
- package/src/extension.ts +169 -42
- package/src/pi-types.d.ts +5 -1
- package/src/store.ts +93 -5
- package/src/tui.ts +89 -35
- package/src/types.ts +14 -1
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 ·
|
|
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
|
|
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
|
|
191
|
-
*
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
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
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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: '
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 */
|