pi-anchor 0.2.1 → 0.2.3

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
@@ -122,13 +122,23 @@ export function getDisplayWidth(str: string): number {
122
122
  return width;
123
123
  }
124
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
+
125
134
  /**
126
135
  * Truncate string to target terminal visual width with ellipsis
127
136
  */
128
137
  export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…'): string {
129
138
  if (maxWidth <= 0) return '';
130
- const totalWidth = getDisplayWidth(str);
131
- if (totalWidth <= maxWidth) return str;
139
+ const singleLine = str.replace(/[\r\n\t]+/g, ' ');
140
+ const totalWidth = getDisplayWidth(singleLine);
141
+ if (totalWidth <= maxWidth) return singleLine;
132
142
 
133
143
  const ellipsisWidth = getDisplayWidth(ellipsis);
134
144
  const target = Math.max(0, maxWidth - ellipsisWidth);
@@ -138,18 +148,18 @@ export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…')
138
148
  let accumWidth = 0;
139
149
  let i = 0;
140
150
 
141
- while (i < str.length) {
151
+ while (i < singleLine.length) {
142
152
  ANSI_REGEX.lastIndex = i;
143
- const match = ANSI_REGEX.exec(str);
153
+ const match = ANSI_REGEX.exec(singleLine);
144
154
  if (match && match.index === i) {
145
155
  accumulated += match[0];
146
156
  i += match[0].length;
147
157
  continue;
148
158
  }
149
159
 
150
- const codePoint = str.codePointAt(i) || 0;
160
+ const codePoint = singleLine.codePointAt(i) || 0;
151
161
  const charLen = codePoint > 0xffff ? 2 : 1;
152
- const fullChar = str.slice(i, i + charLen);
162
+ const fullChar = singleLine.slice(i, i + charLen);
153
163
  const charWidth = getDisplayWidth(fullChar);
154
164
 
155
165
  if (accumWidth + charWidth > target) {
@@ -163,7 +173,7 @@ export function truncateToWidth(str: string, maxWidth: number, ellipsis = '…')
163
173
  // Invariant 11: Guard against trailing open brackets right before ellipsis (e.g. "Fix auth (…" -> "Fix auth…")
164
174
  accumulated = accumulated.replace(/\s*[\(\[\{(【]$/, '');
165
175
 
166
- return accumulated + ellipsis + (str.includes('\x1b') ? '\x1b[0m' : '');
176
+ return accumulated + ellipsis + (singleLine.includes('\x1b') ? '\x1b[0m' : '');
167
177
  }
168
178
 
169
179
  /**
@@ -291,15 +301,19 @@ export function formatOrigin(anchor: Anchor | string): string {
291
301
  * Update footer status bar capsule: `⌖ 1`
292
302
  */
293
303
  export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore): void {
294
- if (!ctx.hasUI || !ctx.ui) return;
304
+ if (!hasActiveUI(ctx)) return;
295
305
 
296
- const active = store.list({ status: 'active', cwd: ctx.cwd });
297
- if (active.length === 0) {
298
- ctx.ui.setStatus('anchor', undefined);
299
- return;
300
- }
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
+ }
301
312
 
302
- ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
313
+ ctx.ui.setStatus('anchor', `⌖ ${active.length}`);
314
+ } catch {
315
+ // Ignore stale context errors during session replacement
316
+ }
303
317
  }
304
318
 
305
319
  /**
@@ -308,49 +322,53 @@ export function updateAnchorStatusBar(ctx: ExtensionContext, store: AnchorStore)
308
322
  * Automatically cleared on agent_start (when user sends first message).
309
323
  */
310
324
  export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore): void {
311
- if (!ctx.hasUI || !ctx.ui) return;
325
+ if (!hasActiveUI(ctx)) return;
312
326
 
313
- const active = store.list({ status: 'active', cwd: ctx.cwd });
314
- if (active.length === 0) {
315
- ctx.ui.setWidget('anchor-startup', undefined);
316
- return;
317
- }
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
+ }
318
333
 
319
- const groups = groupAnchorsByQuadrant(active);
320
- const sorted = [...groups.today, ...groups.upcoming, ...groups.habits, ...groups.backlog];
321
- const topItems = sorted.slice(0, 3);
322
-
323
- const factory = (_tui?: any, theme?: any) => ({
324
- render: (width: number) => {
325
- const maxTitleWidth = Math.max(16, width - 36);
326
- const fg = theme?.fg ? theme.fg.bind(theme) : (_c: string, text: string) => text;
327
-
328
- const lines: string[] = [
329
- fg('accent', `⌖ Anchor · ${active.length} active commitments:`)
330
- ];
331
-
332
- for (let i = 0; i < topItems.length; i++) {
333
- const a = topItems[i];
334
- const num = (i + 1).toString().padStart(2, '0');
335
- const q = classifyAnchor(a);
336
- const tgt = formatTargetDate(a);
337
- const titlePadded = truncateToWidth(a.title, maxTitleWidth);
338
- lines.push(` • ${fg('muted', `[${q}]`)} ${fg('dim', num)} ${titlePadded} ${fg('dim', `(${tgt})`)}`);
339
- }
340
-
341
- if (sorted.length > 3) {
342
- lines.push(fg('dim', ` (+${sorted.length - 3} more · run /anchor to inspect)`));
343
- }
344
-
345
- return lines;
346
- },
347
- invalidate: () => {}
348
- });
349
-
350
- // Reactive component factory in real TUI mode, static string[] in mock tests
351
- const content = (ctx as any).ui?.theme ? factory : factory().render(80);
352
-
353
- 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
+ }
354
372
  }
355
373
 
356
374
  /**
@@ -360,7 +378,7 @@ export async function openAnchorDashboard(
360
378
  ctx: ExtensionContext,
361
379
  store: AnchorStore
362
380
  ): Promise<void> {
363
- if (!ctx.hasUI || !ctx.ui) return;
381
+ if (!hasActiveUI(ctx)) return;
364
382
 
365
383
  const list = store.list({ status: 'active', cwd: ctx.cwd });
366
384
  if (list.length === 0) {
@@ -378,10 +396,13 @@ export async function openAnchorDashboard(
378
396
 
379
397
  const optionMap = new Map<string, Anchor>();
380
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;
381
402
 
382
403
  for (let i = 0; i < sortedList.length; i++) {
383
404
  const a = sortedList[i];
384
- const num = (i + 1).toString().padStart(2, '0');
405
+ const num = a.id.replace(/^anc-/, '').padStart(2, '0');
385
406
  const category = padToWidth(`[${classifyAnchor(a)}]`, 12);
386
407
  const origin = formatOrigin(a);
387
408
  const target = formatTargetDate(a);
@@ -393,12 +414,14 @@ export async function openAnchorDashboard(
393
414
 
394
415
  // Clean tabular column alignment: ID, Category, Title, Project, Target, Created
395
416
  const colNum = `${num} `;
396
- const colTitle = padToWidth(titleWithFiles, 34);
417
+ const colTitle = padToWidth(titleWithFiles, titleWidth);
397
418
  const colOrigin = padToWidth(origin, 10);
398
419
  const colTarget = padToWidth(target, 12);
399
420
  const colCreated = created;
400
421
 
401
- 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();
402
425
 
403
426
  optionMap.set(label, a);
404
427
  displayOptions.push(label);
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 {