pi-anchor 0.2.0 → 0.2.2

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
@@ -57,11 +57,13 @@ export function groupAnchorsByQuadrant(anchors: Anchor[], now: number = Date.now
57
57
  return groups;
58
58
  }
59
59
 
60
+ const ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
61
+
60
62
  /**
61
63
  * Strip ANSI escape codes from string
62
64
  */
63
65
  export function stripAnsi(str: string): string {
64
- return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
66
+ return str.replace(ANSI_REGEX, '');
65
67
  }
66
68
 
67
69
  /**
@@ -120,30 +122,58 @@ export function getDisplayWidth(str: string): number {
120
122
  return width;
121
123
  }
122
124
 
125
+ export function hasActiveUI(ctx: ExtensionContext | null | undefined): ctx is ExtensionContext {
126
+ if (!ctx) return false;
127
+ try {
128
+ return Boolean(ctx.hasUI && ctx.ui);
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
123
134
  /**
124
135
  * Truncate string to target terminal visual width with ellipsis
125
136
  */
126
137
  export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…'): string {
127
- const current = getDisplayWidth(str);
128
- if (current <= maxWidth) return str;
138
+ if (maxWidth <= 0) return '';
139
+ const singleLine = str.replace(/[\r\n\t]+/g, ' ');
140
+ const totalWidth = getDisplayWidth(singleLine);
141
+ if (totalWidth <= maxWidth) return singleLine;
129
142
 
130
143
  const ellipsisWidth = getDisplayWidth(ellipsis);
131
- const target = maxWidth - ellipsisWidth;
132
- if (target <= 0) return ellipsis.slice(0, maxWidth);
144
+ const target = Math.max(0, maxWidth - ellipsisWidth);
145
+ if (target === 0) return ellipsis.slice(0, maxWidth);
133
146
 
134
147
  let accumulated = '';
135
148
  let accumWidth = 0;
149
+ let i = 0;
150
+
151
+ while (i < singleLine.length) {
152
+ ANSI_REGEX.lastIndex = i;
153
+ const match = ANSI_REGEX.exec(singleLine);
154
+ if (match && match.index === i) {
155
+ accumulated += match[0];
156
+ i += match[0].length;
157
+ continue;
158
+ }
159
+
160
+ const codePoint = singleLine.codePointAt(i) || 0;
161
+ const charLen = codePoint > 0xffff ? 2 : 1;
162
+ const fullChar = singleLine.slice(i, i + charLen);
163
+ const charWidth = getDisplayWidth(fullChar);
136
164
 
137
- for (const char of str) {
138
- const charWidth = getDisplayWidth(char);
139
165
  if (accumWidth + charWidth > target) {
140
166
  break;
141
167
  }
142
- accumulated += char;
168
+ accumulated += fullChar;
143
169
  accumWidth += charWidth;
170
+ i += charLen;
144
171
  }
145
172
 
146
- return accumulated + ellipsis;
173
+ // Invariant 11: Guard against trailing open brackets right before ellipsis (e.g. "Fix auth (…" -> "Fix auth…")
174
+ accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, '');
175
+
176
+ return accumulated + ellipsis + (singleLine.includes('\x1b') ? '\x1b[0m' : '');
147
177
  }
148
178
 
149
179
  /**
@@ -271,15 +301,19 @@ export function formatOrigin(anchor: Anchor | string): string {
271
301
  * Update footer status bar capsule: `⌖ 1`
272
302
  */
273
303
  export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore): void {
274
- if (!ctx.hasUI || !ctx.ui) return;
304
+ if (!hasActiveUI(ctx)) return;
275
305
 
276
- const active = store.list({ status: 'active', cwd: ctx.cwd });
277
- if (active.length === 0) {
278
- ctx.ui.setStatus('anchor', undefined);
279
- return;
280
- }
306
+ try {
307
+ const active = store.list({ status: 'active', cwd: ctx.cwd });
308
+ if (active.length === 0) {
309
+ ctx.ui.setStatus('anchor', undefined);
310
+ return;
311
+ }
281
312
 
282
- ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
313
+ ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
314
+ } catch {
315
+ // Ignore stale context errors during session replacement
316
+ }
283
317
  }
284
318
 
285
319
  /**
@@ -288,49 +322,53 @@ export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore)
288
322
  * Automatically cleared on agent_start (when user sends first message).
289
323
  */
290
324
  export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void {
291
- if (!ctx.hasUI || !ctx.ui) return;
325
+ if (!hasActiveUI(ctx)) return;
292
326
 
293
- const active = store.list({ status: 'active', cwd: ctx.cwd });
294
- if (active.length === 0) {
295
- ctx.ui.setWidget('anchor-startup', undefined);
296
- return;
297
- }
327
+ try {
328
+ const active = store.list({ status: 'active', cwd: ctx.cwd });
329
+ if (active.length === 0) {
330
+ ctx.ui.setWidget('anchor-startup', undefined);
331
+ return;
332
+ }
298
333
 
299
- const groups = groupAnchorsByQuadrant(active);
300
- const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
301
- const topItems = sorted.slice(0, 3);
302
-
303
- const factory = (_tui?: any, theme?: any) => ({
304
- render: (width: number) => {
305
- const maxTitleWidth = Math.max(16, width - 36);
306
- const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
307
-
308
- const lines: string[] = [
309
- fg('accent', `⌖ Anchor · ${active.length} active commitments:`)
310
- ];
311
-
312
- for (let i = 0; i < topItems.length; i++) {
313
- const a = topItems[i];
314
- const num = (i + 1).toString().padStart(2, '0');
315
- const q = classifyAnchor(a);
316
- const tgt = formatTargetDate(a);
317
- const titlePadded = truncateToWidth(a.title, maxTitleWidth);
318
- lines.push(` • ${fg('muted', `[${q}]`)} ${fg('dim', num)} ${titlePadded} ${fg('dim', `(${tgt})`)}`);
319
- }
320
-
321
- if (sorted.length > 3) {
322
- lines.push(fg('dim', ` (+${sorted.length - 3} more · run /anchor to inspect)`));
323
- }
324
-
325
- return lines;
326
- },
327
- invalidate: () => {}
328
- });
329
-
330
- // Reactive component factory in real TUI mode, static string[] in mock tests
331
- const content = (ctx as any).ui?.theme ? factory : factory().render(80);
332
-
333
- ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
334
+ const groups = groupAnchorsByQuadrant(active);
335
+ const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
336
+ const topItems = sorted.slice(0, 3);
337
+
338
+ const factory = (_tui?: any, theme?: any) => ({
339
+ render: (width: number) => {
340
+ const maxTitleWidth = Math.max(12, width - 36);
341
+ const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
342
+
343
+ const lines: string[] = [
344
+ truncateToWidth(fg('accent', `⌖ Anchor · ${active.length} active commitments:`), width)
345
+ ];
346
+
347
+ for (let i = 0; i < topItems.length; i++) {
348
+ const a = topItems[i];
349
+ const num = a.id.replace(/^anc-/, '').padStart(2, '0');
350
+ const q = classifyAnchor(a);
351
+ const tgt = formatTargetDate(a);
352
+ const titlePadded = truncateToWidth(a.title, maxTitleWidth);
353
+ lines.push(truncateToWidth(` • ${fg('muted', `[${q}]`)} ${fg('dim', num)} ${titlePadded} ${fg('dim', `(${tgt})`)}`, width));
354
+ }
355
+
356
+ if (sorted.length > 3) {
357
+ lines.push(truncateToWidth(fg('dim', ` (+${sorted.length - 3} more · run /anchor to inspect)`), width));
358
+ }
359
+
360
+ return lines;
361
+ },
362
+ invalidate: () => {}
363
+ });
364
+
365
+ // Reactive component factory in real TUI mode, static string[] in mock tests
366
+ const content = (ctx as any).ui?.theme ? factory : factory().render(80);
367
+
368
+ ctx.ui.setWidget('anchor-startup', content as any, { placement: 'aboveEditor' });
369
+ } catch {
370
+ // Ignore stale context errors during session replacement
371
+ }
334
372
  }
335
373
 
336
374
  /**
@@ -340,7 +378,7 @@ export async function openAnchorDashboard(
340
378
  ctx: ExtensionContext,
341
379
  store: AnchorStore
342
380
  ): Promise<void> {
343
- if (!ctx.hasUI || !ctx.ui) return;
381
+ if (!hasActiveUI(ctx)) return;
344
382
 
345
383
  const list = store.list({ status: 'active', cwd: ctx.cwd });
346
384
  if (list.length === 0) {
@@ -358,10 +396,13 @@ export async function openAnchorDashboard(
358
396
 
359
397
  const optionMap = new Map<string, Anchor>();
360
398
  const displayOptions: string[] = [];
399
+ const termCols = process.stdout?.columns || 96;
400
+ const isNarrow = termCols < 88;
401
+ const titleWidth = isNarrow ? Math.max(18, termCols - 36) : 34;
361
402
 
362
403
  for (let i = 0; i < sortedList.length; i++) {
363
404
  const a = sortedList[i];
364
- const num = (i + 1).toString().padStart(2, '0');
405
+ const num = a.id.replace(/^anc-/, '').padStart(2, '0');
365
406
  const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
366
407
  const origin = formatOrigin(a);
367
408
  const target = formatTargetDate(a);
@@ -373,27 +414,51 @@ export async function openAnchorDashboard(
373
414
 
374
415
  // Clean tabular column alignment: ID, Category, Title, Project, Target, Created
375
416
  const colNum = `${num} `;
376
- const colTitle = padToWidth(titleWithFiles, 34);
417
+ const colTitle = padToWidth(titleWithFiles, titleWidth);
377
418
  const colOrigin = padToWidth(origin, 10);
378
419
  const colTarget = padToWidth(target, 12);
379
420
  const colCreated = created;
380
421
 
381
- const label = `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
422
+ const label = isNarrow
423
+ ? `${colNum}${category}${colTitle} ${colTarget}`.trimEnd()
424
+ : `${colNum}${category}${colTitle} ${colOrigin} ${colTarget} ${colCreated}`.trimEnd();
382
425
 
383
426
  optionMap.set(label, a);
384
427
  displayOptions.push(label);
385
428
  }
386
429
 
387
- const selected = await ctx.ui.select('⌖ Anchors (enter to complete):', displayOptions);
430
+ const selected = await ctx.ui.select('⌖ Anchors (select to inspect or settle):', displayOptions);
388
431
  if (!selected) return;
389
432
 
390
433
  const anchor = optionMap.get(selected);
391
434
  if (!anchor) return;
392
435
 
393
- store.settle(anchor.id, { settledBy: 'manual-command' });
394
- const successMsg = anchor.recurrence === 'daily'
395
- ? `⌖ Completed for today: "${anchor.title}" (resets tomorrow)`
396
- : `⌖ Settled: "${anchor.title}"`;
397
- ctx.ui.notify(successMsg, 'info');
398
- updateAnchorStatusBar(ctx, store);
436
+ const action = await ctx.ui.select(
437
+ `⌖ Anchor #${anchor.id}: "${anchor.title}"`,
438
+ [
439
+ '✓ Settle (Mark completed and archive)',
440
+ 'ℹ View details & associated files',
441
+ '↩ Cancel'
442
+ ]
443
+ );
444
+ if (!action || action.startsWith('↩')) return;
445
+
446
+ if (action.startsWith('✓')) {
447
+ store.settle(anchor.id, { settledBy: 'manual-command' });
448
+ const successMsg = anchor.recurrence === 'daily'
449
+ ? `⌖ Completed for today: "${anchor.title}" (resets tomorrow)`
450
+ : `⌖ Settled #${anchor.id}: "${anchor.title}" (/anchor undo to revert)`;
451
+ ctx.ui.notify(successMsg, 'info');
452
+ updateAnchorStatusBar(ctx, store);
453
+ } else if (action.startsWith('ℹ')) {
454
+ const details = [
455
+ `#${anchor.id} [${anchor.priority.toUpperCase()}] ${anchor.title}`,
456
+ anchor.description ? `Description: ${anchor.description}` : null,
457
+ anchor.targetDate ? `Target Date: ${anchor.targetDate} (${formatTargetDate(anchor)})` : null,
458
+ anchor.files && anchor.files.length > 0 ? `Files: ${anchor.files.join(', ')}` : null,
459
+ anchor.verifyCommand ? `Verify Command: ${anchor.verifyCommand}` : null,
460
+ `Created: ${formatCreationTime(anchor.createdAt)} (in ${anchor.project || 'global'})`
461
+ ].filter(Boolean).join('\n');
462
+ ctx.ui.notify(details, 'info');
463
+ }
399
464
  }
package/src/types.ts CHANGED
@@ -69,6 +69,7 @@ export interface AnchorStoreState {
69
69
  version: number;
70
70
  anchors: Anchor[];
71
71
  lastSweepAt: number;
72
+ nextId?: number;
72
73
  }
73
74
 
74
75
  export interface TouchMatchResult {