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.
@@ -1,5 +1,6 @@
1
1
  // src/extension.ts
2
2
  import { Type } from "@sinclair/typebox";
3
+ import fs3 from "fs";
3
4
 
4
5
  // src/store.ts
5
6
  import fs from "fs";
@@ -53,6 +54,32 @@ function extractPathsFromText(text) {
53
54
  }
54
55
  return Array.from(results);
55
56
  }
57
+ function extractProgressFromText(rawTitle) {
58
+ if (!rawTitle) return { cleanTitle: "" };
59
+ const stepParts = Array.from(rawTitle.matchAll(/(?:^|\s)(\d+)\.\s*([^\d]+?)(?=\s+\d+\.\s|$)/g));
60
+ if (stepParts.length >= 2) {
61
+ const steps = stepParts.map((m) => m[2].trim().replace(/[;;,,]+$/, "")).filter(Boolean);
62
+ const firstMatchIdx = rawTitle.indexOf(stepParts[0][0]);
63
+ const prefix = rawTitle.slice(0, firstMatchIdx).replace(/[::\-—\s]+$/, "").trim();
64
+ return {
65
+ cleanTitle: prefix || steps.join(" \u2192 "),
66
+ progress: { done: 0, total: steps.length, steps }
67
+ };
68
+ }
69
+ const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)|(?:^|\s)(\d{1,3})\/(\d{1,3})(?=\s|$))/);
70
+ if (ratioMatch) {
71
+ const done = parseInt(ratioMatch[1] ?? ratioMatch[3] ?? ratioMatch[5], 10);
72
+ const total = parseInt(ratioMatch[2] ?? ratioMatch[4] ?? ratioMatch[6], 10);
73
+ if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
74
+ const stripped = rawTitle.replace(ratioMatch[0], " ").replace(/\s+/g, " ").trim();
75
+ return {
76
+ cleanTitle: stripped || rawTitle.trim(),
77
+ progress: { done, total }
78
+ };
79
+ }
80
+ }
81
+ return { cleanTitle: rawTitle.trim() };
82
+ }
56
83
  function isAnchorTargetingWorkspace(anchor, targetCwd) {
57
84
  if (!targetCwd) return true;
58
85
  const normTarget = targetCwd.trim().replace(/\\/g, "/").replace(/\/+$/, "");
@@ -195,8 +222,9 @@ function renderColdStartAnchorsContext(store, cwdOrNow, nowArg) {
195
222
  const lines = activeNotes.map((a) => {
196
223
  const num = a.id.replace(/^anc-/, "");
197
224
  const files = a.files.length > 0 ? ` (${a.files.slice(0, 2).join(", ")})` : "";
225
+ const prog = a.progress ? ` [${a.progress.done}/${a.progress.total}${a.progress.steps && a.progress.steps[a.progress.done] ? ` \xB7 next: ${a.progress.steps[a.progress.done]}` : ""}]` : "";
198
226
  const desc = a.description ? ` \u2014 ${a.description}` : "";
199
- return `\u2022 #${num} [${a.project}] ${a.title}${files}${desc}`;
227
+ return `\u2022 #${num} [${a.project}] ${a.title}${prog}${files}${desc}`;
200
228
  });
201
229
  return [
202
230
  `<sticky-notes count="${activeNotes.length}">`,
@@ -371,8 +399,9 @@ var AnchorStore = class {
371
399
  const state = this.loadState();
372
400
  const now = Date.now();
373
401
  const id = this.generateId(state);
402
+ const { cleanTitle, progress: parsedProgress } = extractProgressFromText(input.title);
374
403
  const explicitFiles = (input.files || []).map((f) => normalizePath(f)).filter(Boolean);
375
- const inferredFiles = explicitFiles.length > 0 ? explicitFiles : extractPathsFromText(`${input.title} ${input.description || ""}`);
404
+ const inferredFiles = explicitFiles.length > 0 ? explicitFiles : extractPathsFromText(`${cleanTitle} ${input.description || ""}`);
376
405
  let cwd = input.cwd ? normalizePath(input.cwd) : "";
377
406
  if (input.project && (/^[a-zA-Z]:[\\/]/.test(input.project.trim()) || input.project.trim().startsWith("/"))) {
378
407
  cwd = normalizePath(input.project.trim());
@@ -383,7 +412,7 @@ var AnchorStore = class {
383
412
  const projectName = input.project ? input.project.trim() : cwd ? path.basename(cwd) : "global";
384
413
  const anchor = {
385
414
  id,
386
- title: input.title.trim(),
415
+ title: cleanTitle,
387
416
  description: input.description?.trim(),
388
417
  priority: input.priority || "p1",
389
418
  status: "active",
@@ -393,7 +422,9 @@ var AnchorStore = class {
393
422
  updatedAt: now,
394
423
  lastTouchedAt: now,
395
424
  files: inferredFiles,
396
- tags: (input.tags || []).map((t) => t.trim()).filter(Boolean)
425
+ tags: (input.tags || []).map((t) => t.trim()).filter(Boolean),
426
+ ...input.sessionFile ? { sessionFile: normalizePath(input.sessionFile) } : {},
427
+ ...input.progress || parsedProgress ? { progress: input.progress || parsedProgress } : {}
397
428
  };
398
429
  state.anchors.push(anchor);
399
430
  this.saveState(state);
@@ -442,7 +473,7 @@ var AnchorStore = class {
442
473
  return updated;
443
474
  });
444
475
  }
445
- touch(id, timestamp = Date.now()) {
476
+ touch(id, timestamp = Date.now(), sessionFile) {
446
477
  return this.withLock(() => {
447
478
  const state = this.loadState();
448
479
  const idx = this.findAnchorIndex(state.anchors, id);
@@ -453,11 +484,44 @@ var AnchorStore = class {
453
484
  if (current.status === "sleeping") {
454
485
  current.status = "active";
455
486
  }
487
+ if (sessionFile) {
488
+ current.sessionFile = normalizePath(sessionFile);
489
+ }
456
490
  state.anchors[idx] = current;
457
491
  this.saveState(state);
458
492
  return current;
459
493
  });
460
494
  }
495
+ /**
496
+ * Advance (or set) progress on a sticky note:
497
+ * - If delta is provided (default +1), increments `done` up to `total`.
498
+ * - If note had no progress yet, initializes a 4-step bar (`1/4`).
499
+ */
500
+ step(id, delta = 1, explicitTotal) {
501
+ return this.withLock(() => {
502
+ const state = this.loadState();
503
+ const idx = this.findAnchorIndex(state.anchors, id);
504
+ if (idx === -1) throw new Error(`Note not found: ${id}`);
505
+ const current = state.anchors[idx];
506
+ const now = Date.now();
507
+ const total = explicitTotal && explicitTotal >= 2 ? explicitTotal : current.progress?.total || 4;
508
+ const prevDone = current.progress?.done || 0;
509
+ const nextDone = Math.max(0, Math.min(total, prevDone + delta));
510
+ current.progress = {
511
+ ...current.progress || {},
512
+ done: nextDone,
513
+ total
514
+ };
515
+ current.lastTouchedAt = now;
516
+ current.updatedAt = now;
517
+ if (current.status === "sleeping") {
518
+ current.status = "active";
519
+ }
520
+ state.anchors[idx] = current;
521
+ this.saveState(state);
522
+ return { anchor: current, completed: nextDone >= total };
523
+ });
524
+ }
461
525
  /**
462
526
  * Tear off (settle) a completed sticky note and archive it to archive.jsonl
463
527
  */
@@ -528,6 +592,7 @@ var AnchorStore = class {
528
592
  };
529
593
 
530
594
  // src/tui.ts
595
+ import fs2 from "fs";
531
596
  import path2 from "path";
532
597
  var ANSI_REGEX = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
533
598
  function stripAnsi(str) {
@@ -594,6 +659,14 @@ function padToWidth(str, targetWidth) {
594
659
  if (current >= targetWidth) return truncated;
595
660
  return truncated + " ".repeat(targetWidth - current);
596
661
  }
662
+ function formatProgressBar(progress, cells = 4) {
663
+ if (!progress || !progress.total || progress.total < 1) return "";
664
+ const done = Math.max(0, Math.min(progress.total, progress.done || 0));
665
+ const filled = Math.min(cells, Math.max(done > 0 ? 1 : 0, Math.round(done / progress.total * cells)));
666
+ const finalFilled = done >= progress.total ? cells : done === 0 ? 0 : filled;
667
+ const bar = "\u25A0".repeat(finalFilled) + "\u25A1".repeat(Math.max(0, cells - finalFilled));
668
+ return `[${bar} ${done}/${progress.total}]`;
669
+ }
597
670
  function formatRelativeAge(timestamp, now = Date.now()) {
598
671
  const diffSec = Math.max(0, Math.floor((now - timestamp) / 1e3));
599
672
  if (diffSec < 60) return "just now";
@@ -647,6 +720,7 @@ function updateAnchorStatusBar(ctx, store) {
647
720
  try {
648
721
  const active = store.list({ status: "active", cwd: ctx.cwd });
649
722
  ctx.ui.setStatus("anchor", active.length > 0 ? `\u2316 ${active.length}` : void 0);
723
+ updateStartupBanner(ctx, store);
650
724
  } catch {
651
725
  }
652
726
  }
@@ -655,41 +729,41 @@ function updateStartupBanner(ctx, store) {
655
729
  try {
656
730
  const active = store.list({ status: "active", cwd: ctx.cwd });
657
731
  if (active.length === 0) {
658
- ctx.ui.setWidget("anchor-startup", void 0);
732
+ ctx.ui.setWidget("anchor-startup", void 0, { placement: "belowEditor" });
659
733
  return;
660
734
  }
661
735
  const sorted = sortAnchorsForDisplay(active, ctx.cwd);
662
- const topItems = sorted.slice(0, 3);
663
736
  const factory = (_tui, theme) => ({
664
737
  render: (width) => {
665
- const maxTitleWidth = Math.max(14, width - 34);
666
738
  const fg = theme?.fg ? theme.fg.bind(theme) : (_c, text) => text;
667
- const label = active.length === 1 ? "1 note" : `${active.length} notes`;
668
- const lines = [
669
- truncateToWidth(fg("accent", `\u2316 ${label}`), width)
670
- ];
671
- for (const a of topItems) {
672
- const num = a.id.replace(/^anc-/, "").padStart(2, "0");
739
+ if (sorted.length === 1) {
740
+ const a = sorted[0];
741
+ const num = a.id.replace(/^anc-/, "");
742
+ const bar = a.progress ? `${fg("accent", formatProgressBar(a.progress))} ` : "";
743
+ const nextStep = a.progress?.steps && a.progress.steps[a.progress.done] ? fg("muted", ` \u25B8 ${a.progress.steps[a.progress.done]}`) : "";
673
744
  const origin = formatOrigin(a);
674
- const age = formatRelativeAge(a.createdAt);
675
- const titleClipped = truncateToWidth(a.title, maxTitleWidth);
676
- lines.push(
677
- truncateToWidth(
678
- ` ${fg("dim", num)} ${titleClipped} ${fg("muted", `\xB7 ${origin}`)} ${fg("dim", `(${age})`)}`,
679
- width
680
- )
681
- );
745
+ const sessionIcon = a.sessionFile ? " \u21AA" : "";
746
+ const prefix = `${fg("accent", "\u2316")} ${fg("dim", `#${num}`)} ${bar}`;
747
+ const suffix = ` ${fg("muted", `(${origin}${sessionIcon})`)}`;
748
+ const availTitle = Math.max(12, width - getDisplayWidth(prefix) - getDisplayWidth(nextStep) - getDisplayWidth(suffix) - 2);
749
+ const titleClipped = truncateToWidth(a.title, availTitle);
750
+ return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
682
751
  }
683
- if (sorted.length > 3) {
684
- lines.push(truncateToWidth(fg("dim", ` +${sorted.length - 3} more \xB7 /anchor`), width));
685
- }
686
- return lines;
752
+ const countTag = fg("accent", `\u2316 ${sorted.length} notes: `);
753
+ const segments = sorted.slice(0, 3).map((a) => {
754
+ const num = a.id.replace(/^anc-/, "");
755
+ const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
756
+ const shortTitle = truncateToWidth(a.title, Math.max(14, Math.floor((width - 20) / Math.min(3, sorted.length)) - 12));
757
+ return `${fg("dim", `#${num}`)} ${bar}${shortTitle}`;
758
+ });
759
+ const more = sorted.length > 3 ? fg("dim", ` +${sorted.length - 3}`) : "";
760
+ return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${more}`, width)];
687
761
  },
688
762
  invalidate: () => {
689
763
  }
690
764
  });
691
765
  const content = ctx.ui?.theme ? factory : factory().render(80);
692
- ctx.ui.setWidget("anchor-startup", content, { placement: "aboveEditor" });
766
+ ctx.ui.setWidget("anchor-startup", content, { placement: "belowEditor" });
693
767
  } catch {
694
768
  }
695
769
  }
@@ -697,7 +771,7 @@ async function openAnchorDashboard(ctx, store) {
697
771
  if (!hasActiveUI(ctx)) return;
698
772
  const allNotes = store.list({ cwd: ctx.cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
699
773
  if (allNotes.length === 0) {
700
- ctx.ui.notify("\u2316 No sticky notes. Use /pin <note> to stick one.", "info");
774
+ ctx.ui.notify("\u2316 No sticky notes. Type `pin: <note>` or `/pin <note>` to stick one.", "info");
701
775
  return;
702
776
  }
703
777
  const sorted = sortAnchorsForDisplay(allNotes, ctx.cwd);
@@ -708,10 +782,11 @@ async function openAnchorDashboard(ctx, store) {
708
782
  const titleWidth = isNarrow ? Math.max(20, termCols - 30) : 42;
709
783
  for (const a of sorted) {
710
784
  const num = a.id.replace(/^anc-/, "").padStart(2, "0");
711
- const origin = formatOrigin(a);
785
+ const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
786
+ const origin = formatOrigin(a) + (a.sessionFile ? " \u21AA" : "");
712
787
  const age = a.status === "sleeping" ? `${formatRelativeAge(a.createdAt)} \xB7 quiet` : formatRelativeAge(a.createdAt);
713
788
  const colNum = `${num} `;
714
- const colTitle = padToWidth(a.title, titleWidth);
789
+ const colTitle = padToWidth(`${bar}${a.title}`, titleWidth);
715
790
  const colOrigin = padToWidth(origin, 22);
716
791
  const label = isNarrow ? `${colNum}${colTitle} ${age}`.trimEnd() : `${colNum}${colTitle} ${colOrigin} ${age}`.trimEnd();
717
792
  optionMap.set(label, a);
@@ -721,8 +796,15 @@ async function openAnchorDashboard(ctx, store) {
721
796
  if (!selected) return;
722
797
  const anchor = optionMap.get(selected);
723
798
  if (!anchor) return;
799
+ const currentSessionFile = ctx.sessionManager?.getSessionFile?.();
800
+ const canJumpSession = Boolean(
801
+ anchor.sessionFile && fs2.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || path2.resolve(anchor.sessionFile) !== path2.resolve(currentSessionFile))
802
+ );
803
+ const stepLabel = anchor.progress ? `\u25B6 Advance step (${anchor.progress.done}/${anchor.progress.total} \u2794 ${Math.min(anchor.progress.total, anchor.progress.done + 1)}/${anchor.progress.total})` : "\u25B6 Start progress bar (1/4)";
724
804
  const actions = [
725
805
  "\u2713 Done (tear off note)",
806
+ stepLabel,
807
+ ...canJumpSession ? ["\u21AA Jump to session (resume linked conversation)"] : [],
726
808
  ...anchor.status === "sleeping" ? ["\u26A1 Wake up (mark active)"] : [],
727
809
  "\u2139 Read full note",
728
810
  "\u21A9 Back"
@@ -733,6 +815,23 @@ async function openAnchorDashboard(ctx, store) {
733
815
  store.settle(anchor.id, { settledBy: "manual" });
734
816
  ctx.ui.notify(`\u2316 Torn off #${anchor.id.replace(/^anc-/, "")}: "${anchor.title}" (/anchor undo to put back)`, "info");
735
817
  updateAnchorStatusBar(ctx, store);
818
+ } else if (action.startsWith("\u25B6")) {
819
+ const { anchor: updated, completed } = store.step(anchor.id, 1);
820
+ updateAnchorStatusBar(ctx, store);
821
+ if (completed) {
822
+ ctx.ui.notify(
823
+ `\u2316 #${updated.id.replace(/^anc-/, "")} reached ${formatProgressBar(updated.progress)}! Run /done ${updated.id.replace(/^anc-/, "")} to tear off.`,
824
+ "info"
825
+ );
826
+ } else {
827
+ ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
828
+ }
829
+ } else if (action.startsWith("\u21AA") && anchor.sessionFile) {
830
+ try {
831
+ await ctx.switchSession(anchor.sessionFile);
832
+ } catch (err) {
833
+ ctx.ui.notify(`Could not switch session: ${err.message}`, "error");
834
+ }
736
835
  } else if (action.startsWith("\u26A1")) {
737
836
  store.touch(anchor.id);
738
837
  ctx.ui.notify(`\u2316 Woke up #${anchor.id.replace(/^anc-/, "")}: "${anchor.title}"`, "info");
@@ -740,8 +839,10 @@ async function openAnchorDashboard(ctx, store) {
740
839
  } else if (action.startsWith("\u2139")) {
741
840
  const details = [
742
841
  `\u2316 Note #${anchor.id.replace(/^anc-/, "")}: ${anchor.title}`,
842
+ anchor.progress ? `Progress: ${formatProgressBar(anchor.progress)}${anchor.progress.steps ? ` (${anchor.progress.steps.join(" \u2192 ")})` : ""}` : null,
743
843
  anchor.description ? `Details: ${anchor.description}` : null,
744
844
  anchor.files.length > 0 ? `Files: ${anchor.files.join(", ")}` : null,
845
+ anchor.sessionFile ? `Session: ${path2.basename(anchor.sessionFile)}` : null,
745
846
  `Workspace: ${anchor.project || "global"} \xB7 Pinned ${formatRelativeAge(anchor.createdAt)}`
746
847
  ].filter(Boolean).join("\n");
747
848
  ctx.ui.notify(details, "info");
@@ -765,11 +866,30 @@ function parseAnchorCommandInput(raw) {
765
866
  targetId: settleMatch[1].trim().replace(/^#/, "")
766
867
  };
767
868
  }
869
+ const stepMatch = trimmed.match(/^(?:step|\+)\s*(\S+)(?:\s+([+-]?\d+))?$/i);
870
+ if (stepMatch) {
871
+ return {
872
+ mode: "step",
873
+ targetId: stepMatch[1].trim().replace(/^#/, ""),
874
+ delta: stepMatch[2] ? parseInt(stepMatch[2], 10) : 1
875
+ };
876
+ }
877
+ const jumpMatch = trimmed.match(/^(?:jump|resume|session)\s+(.+)$/i);
878
+ if (jumpMatch) {
879
+ return {
880
+ mode: "jump",
881
+ targetId: jumpMatch[1].trim().replace(/^#/, "")
882
+ };
883
+ }
768
884
  return {
769
885
  mode: "pin",
770
886
  title: trimmed
771
887
  };
772
888
  }
889
+ function parseQuickNoteInput(text) {
890
+ const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
891
+ return m && m[1].trim() ? m[1].trim() : null;
892
+ }
773
893
  function resolveAnchorTarget(store, idOrIndex, cwd) {
774
894
  const clean = idOrIndex.trim().replace(/^#/, "");
775
895
  if (/^\d+$/.test(clean)) {
@@ -782,33 +902,60 @@ function resolveAnchorTarget(store, idOrIndex, cwd) {
782
902
  }
783
903
  return store.get(clean)?.id;
784
904
  }
905
+ function getSessionFileSafe(ctx) {
906
+ try {
907
+ const file = ctx?.sessionManager?.getSessionFile?.();
908
+ return typeof file === "string" && file.trim() ? file.trim() : void 0;
909
+ } catch {
910
+ return void 0;
911
+ }
912
+ }
785
913
  function extension_default(pi) {
786
914
  const store = new AnchorStore();
787
915
  const annotatedThisSession = /* @__PURE__ */ new Set();
788
- pi.on("session_start", async (event, ctx) => {
916
+ const recentSessionFiles = [];
917
+ function rememberTouchedFile(filePath, cwd) {
918
+ const norm = normalizePath(filePath, cwd);
919
+ if (!norm) return;
920
+ const idx = recentSessionFiles.indexOf(norm);
921
+ if (idx !== -1) recentSessionFiles.splice(idx, 1);
922
+ recentSessionFiles.unshift(norm);
923
+ if (recentSessionFiles.length > 5) recentSessionFiles.pop();
924
+ }
925
+ function createStickyNoteFromUser(title, ctx) {
926
+ const sessionFile = getSessionFileSafe(ctx);
927
+ const anc = store.create({
928
+ title,
929
+ cwd: ctx.cwd,
930
+ sessionFile
931
+ });
932
+ if (anc.files.length === 0 && recentSessionFiles.length > 0) {
933
+ store.update(anc.id, { files: [recentSessionFiles[0]] });
934
+ }
935
+ updateAnchorStatusBar(ctx, store);
936
+ return store.get(anc.id) || anc;
937
+ }
938
+ pi.on("session_start", async (_event, ctx) => {
789
939
  annotatedThisSession.clear();
940
+ recentSessionFiles.length = 0;
790
941
  sweepQuietNotes(store);
791
942
  const quietForHere = store.list({ status: "sleeping", all: true }).filter((a) => (a.files.length > 0 || a.project && a.project !== "global" || a.cwd) && isAnchorTargetingWorkspace(a, ctx.cwd));
792
943
  for (const q of quietForHere) {
793
944
  store.touch(q.id);
794
945
  }
795
946
  updateAnchorStatusBar(ctx, store);
796
- if (event.reason !== "resume") {
797
- updateStartupBanner(ctx, store);
798
- } else if (hasActiveUI(ctx)) {
799
- try {
800
- ctx.ui.setWidget("anchor-startup", void 0);
801
- } catch {
802
- }
803
- }
947
+ updateStartupBanner(ctx, store);
804
948
  });
805
- pi.on("agent_start", async (_event, ctx) => {
949
+ pi.on("input", async (event, ctx) => {
950
+ if (event?.source === "extension") return { action: "continue" };
951
+ const quickNote = parseQuickNoteInput(String(event?.text || ""));
952
+ if (!quickNote) return { action: "continue" };
953
+ const anc = createStickyNoteFromUser(quickNote, ctx);
954
+ const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
806
955
  if (hasActiveUI(ctx)) {
807
- try {
808
- ctx.ui.setWidget("anchor-startup", void 0);
809
- } catch {
810
- }
956
+ ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
811
957
  }
958
+ return { action: "handled" };
812
959
  });
813
960
  pi.on("before_agent_start", async (event, ctx) => {
814
961
  const entries = ctx.sessionManager?.getEntries() || [];
@@ -825,6 +972,7 @@ ${snippet}` };
825
972
  pi.on("tool_result", async (event, ctx) => {
826
973
  if (event.isError) return;
827
974
  const lowerTool = String(event.toolName || "").toLowerCase();
975
+ const currentSessionFile = getSessionFileSafe(ctx);
828
976
  if (lowerTool === "bash" || lowerTool === "powershell") {
829
977
  const cmd = String(event.input?.command || "");
830
978
  if (cmd.includes("git commit")) {
@@ -849,12 +997,18 @@ ${snippet}` };
849
997
  }
850
998
  const pathInput = event.input?.path;
851
999
  if (typeof pathInput !== "string") return;
1000
+ const isMutation = MUTATION_TOOLS.has(lowerTool);
1001
+ if (isMutation) {
1002
+ rememberTouchedFile(pathInput, ctx.cwd);
1003
+ }
852
1004
  const touchedPath = normalizePath(pathInput, ctx.cwd);
853
1005
  const notes = store.list({ cwd: ctx.cwd }).filter((a) => a.status === "active" || a.status === "sleeping");
854
1006
  const matches = findMatchedAnchors(notes, [touchedPath]);
855
1007
  if (matches.length === 0) return;
856
- if (MUTATION_TOOLS.has(lowerTool)) {
857
- for (const m of matches) store.touch(m.anchor.id);
1008
+ if (isMutation) {
1009
+ for (const m of matches) {
1010
+ store.touch(m.anchor.id, Date.now(), currentSessionFile);
1011
+ }
858
1012
  updateAnchorStatusBar(ctx, store);
859
1013
  }
860
1014
  const targetNote = matches[0].anchor;
@@ -876,10 +1030,10 @@ ${snippet}` };
876
1030
  pi.registerTool({
877
1031
  name: "anchor",
878
1032
  label: "Anchor (Sticky Note)",
879
- description: "Pin, list, or tear off cross-session sticky notes stored in ~/.anchor/ (zero git repo clutter). Actions: pin (stick a new note), list (view notes), settle (tear off completed note).",
1033
+ description: "Pin, list, step, or tear off cross-session sticky notes stored in ~/.anchor/ (zero git repo clutter). Actions: pin (stick a new note), list (view notes), step (advance progress bar), settle (tear off completed note).",
880
1034
  promptSnippet: "Pin or tear off cross-session sticky notes that survive terminal restarts",
881
1035
  promptGuidelines: [
882
- 'Use `anchor` when the user asks to remember something for later/tomorrow or across sessions (e.g. "\u8BB0\u4E00\u4E0B\u660E\u5929\u91CD\u6784\u9274\u6743", "\u665A\u4E0A\u6E05\u7406\u7F13\u5B58").',
1036
+ "Use `anchor` when the user explicitly asks to remember something for later/tomorrow or across sessions.",
883
1037
  "BOUNDARY WITH TODO: `todo` is for multi-step work right now in this session; `anchor` is a sticky note for future sessions.",
884
1038
  'When a pinned note is completed, call `anchor` with action "settle" to tear it off.'
885
1039
  ],
@@ -887,22 +1041,23 @@ ${snippet}` };
887
1041
  action: Type.Union([
888
1042
  Type.Literal("pin"),
889
1043
  Type.Literal("list"),
1044
+ Type.Literal("step"),
890
1045
  Type.Literal("settle"),
891
1046
  Type.Literal("touch"),
892
1047
  Type.Literal("sweep")
893
1048
  ]),
894
- title: Type.Optional(Type.String({ description: "Sticky note text (for pin)" })),
1049
+ title: Type.Optional(Type.String({ description: "Sticky note text (supports [1/4] or 1. A 2. B progress steps)" })),
895
1050
  description: Type.Optional(Type.String({ description: "Optional extra details" })),
896
1051
  project: Type.Optional(Type.String({ description: "Optional project name or folder" })),
897
1052
  files: Type.Optional(Type.Array(Type.String(), { description: "Optional associated file paths" })),
898
- id: Type.Optional(Type.String({ description: "Note ID, e.g. anc-1 or 1 (for settle)" }))
1053
+ id: Type.Optional(Type.String({ description: "Note ID, e.g. anc-1 or 1 (for step/settle)" }))
899
1054
  }),
900
1055
  renderCall(args, theme) {
901
1056
  const action = args.action || "list";
902
1057
  let title = theme.fg("toolTitle", theme.bold("\u2316 anchor ")) + theme.fg("accent", action);
903
1058
  if (action === "pin" && args.title) {
904
1059
  title += ` "${args.title}"`;
905
- } else if ((action === "settle" || action === "touch") && args.id) {
1060
+ } else if ((action === "settle" || action === "touch" || action === "step") && args.id) {
906
1061
  title += ` ${theme.fg("dim", `#${String(args.id).replace(/^anc-/, "")}`)}`;
907
1062
  }
908
1063
  return {
@@ -932,7 +1087,8 @@ ${snippet}` };
932
1087
  description: params.description,
933
1088
  project: params.project,
934
1089
  cwd: ctx.cwd,
935
- files: params.files || []
1090
+ files: params.files?.length ? params.files : recentSessionFiles[0] ? [recentSessionFiles[0]] : [],
1091
+ sessionFile: getSessionFileSafe(ctx)
936
1092
  });
937
1093
  updateAnchorStatusBar(ctx, store);
938
1094
  return {
@@ -947,6 +1103,20 @@ ${snippet}` };
947
1103
  isError: false
948
1104
  };
949
1105
  }
1106
+ if (params.action === "step") {
1107
+ const rawId = params.id || "1";
1108
+ try {
1109
+ const targetId = resolveAnchorTarget(store, String(rawId), ctx.cwd) || String(rawId);
1110
+ const { anchor: updated } = store.step(targetId, 1);
1111
+ updateAnchorStatusBar(ctx, store);
1112
+ return {
1113
+ content: [{ type: "text", text: `Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}` }],
1114
+ isError: false
1115
+ };
1116
+ } catch (err) {
1117
+ return { content: [{ type: "text", text: err.message }], isError: true };
1118
+ }
1119
+ }
950
1120
  if (params.action === "settle") {
951
1121
  if (!params.id) {
952
1122
  return { content: [{ type: "text", text: "Error: id is required for settle" }], isError: true };
@@ -965,7 +1135,7 @@ ${snippet}` };
965
1135
  }
966
1136
  if (params.action === "touch" && params.id) {
967
1137
  try {
968
- const touched = store.touch(String(params.id));
1138
+ const touched = store.touch(String(params.id), Date.now(), getSessionFileSafe(ctx));
969
1139
  updateAnchorStatusBar(ctx, store);
970
1140
  return { content: [{ type: "text", text: `Refreshed note #${touched.id}` }], isError: false };
971
1141
  } catch (err) {
@@ -981,7 +1151,7 @@ ${snippet}` };
981
1151
  }
982
1152
  });
983
1153
  pi.registerCommand("anchor", {
984
- description: "View, pin, or tear off sticky notes (/anchor, /anchor done 1, /anchor undo)",
1154
+ description: "View, pin, step, jump to session, or tear off sticky notes (/anchor, /anchor step 1, /anchor jump 1, /anchor done 1)",
985
1155
  handler: async (args, ctx) => {
986
1156
  const parsed = parseAnchorCommandInput(args);
987
1157
  if (parsed.mode === "dashboard") {
@@ -998,6 +1168,27 @@ ${snippet}` };
998
1168
  }
999
1169
  return;
1000
1170
  }
1171
+ if (parsed.mode === "step" && parsed.targetId) {
1172
+ const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
1173
+ if (!resolvedId) {
1174
+ ctx.ui.notify(`\u2316 Note not found: ${parsed.targetId}`, "error");
1175
+ return;
1176
+ }
1177
+ const { anchor: updated } = store.step(resolvedId, parsed.delta || 1);
1178
+ ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
1179
+ updateAnchorStatusBar(ctx, store);
1180
+ return;
1181
+ }
1182
+ if (parsed.mode === "jump" && parsed.targetId) {
1183
+ const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
1184
+ const note = resolvedId ? store.get(resolvedId) : void 0;
1185
+ if (!note || !note.sessionFile || !fs3.existsSync(note.sessionFile)) {
1186
+ ctx.ui.notify(`\u2316 No saved conversation session found for #${parsed.targetId}`, "error");
1187
+ return;
1188
+ }
1189
+ await ctx.switchSession(note.sessionFile);
1190
+ return;
1191
+ }
1001
1192
  if (parsed.mode === "settle" && parsed.targetId) {
1002
1193
  const resolvedId = resolveAnchorTarget(store, parsed.targetId, ctx.cwd);
1003
1194
  if (!resolvedId) {
@@ -1019,21 +1210,37 @@ ${snippet}` };
1019
1210
  return;
1020
1211
  }
1021
1212
  }
1022
- const anc = store.create({ title: parsed.title || rawTrimmed, cwd: ctx.cwd });
1023
- ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}: "${anc.title}"`, "info");
1024
- updateAnchorStatusBar(ctx, store);
1213
+ const anc = createStickyNoteFromUser(parsed.title || rawTrimmed, ctx);
1214
+ const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
1215
+ ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
1025
1216
  }
1026
1217
  });
1027
1218
  pi.registerCommand("pin", {
1028
- description: "Stick a quick note across sessions (e.g. /pin Check auth token expiry)",
1219
+ description: "Stick a quick note across sessions (e.g. /pin Migrate auth [1/3])",
1029
1220
  handler: async (args, ctx) => {
1030
1221
  const text = (args || "").trim();
1031
1222
  if (!text) {
1032
1223
  await openAnchorDashboard(ctx, store);
1033
1224
  return;
1034
1225
  }
1035
- const anc = store.create({ title: text, cwd: ctx.cwd });
1036
- ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}: "${anc.title}"`, "info");
1226
+ const anc = createStickyNoteFromUser(text, ctx);
1227
+ const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
1228
+ ctx.ui.notify(`\u2316 Pinned #${anc.id.replace(/^anc-/, "")}${bar}: "${anc.title}"`, "info");
1229
+ }
1230
+ });
1231
+ pi.registerCommand("step", {
1232
+ description: "Advance progress bar on a sticky note (e.g. /step 1)",
1233
+ handler: async (args, ctx) => {
1234
+ const tokens = (args || "").trim().split(/\s+/).filter(Boolean);
1235
+ const target = tokens[0] || "1";
1236
+ const delta = tokens[1] ? parseInt(tokens[1], 10) : 1;
1237
+ const resolvedId = resolveAnchorTarget(store, target, ctx.cwd);
1238
+ if (!resolvedId) {
1239
+ ctx.ui.notify(`\u2316 Note not found: ${target}`, "error");
1240
+ return;
1241
+ }
1242
+ const { anchor: updated } = store.step(resolvedId, isNaN(delta) ? 1 : delta);
1243
+ ctx.ui.notify(`\u2316 Advanced #${updated.id.replace(/^anc-/, "")} to ${formatProgressBar(updated.progress)}`, "info");
1037
1244
  updateAnchorStatusBar(ctx, store);
1038
1245
  }
1039
1246
  });
@@ -1070,6 +1277,7 @@ export {
1070
1277
  isSameOrSubPath,
1071
1278
  globToRegExp,
1072
1279
  extractPathsFromText,
1280
+ extractProgressFromText,
1073
1281
  isAnchorTargetingWorkspace,
1074
1282
  matchAnchorAgainstTouchedFiles,
1075
1283
  findMatchedAnchors,
@@ -1088,6 +1296,7 @@ export {
1088
1296
  hasActiveUI,
1089
1297
  truncateToWidth,
1090
1298
  padToWidth,
1299
+ formatProgressBar,
1091
1300
  formatRelativeAge,
1092
1301
  formatOrigin,
1093
1302
  sortAnchorsForDisplay,
@@ -1095,6 +1304,7 @@ export {
1095
1304
  updateStartupBanner,
1096
1305
  openAnchorDashboard,
1097
1306
  parseAnchorCommandInput,
1307
+ parseQuickNoteInput,
1098
1308
  resolveAnchorTarget,
1099
1309
  extension_default
1100
1310
  };