pi-anchor 0.5.0 → 0.5.1

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # ⌖ Anchor
2
2
 
3
- > **A digital sticky note docked below your Pi editor — with four clever touches.**
4
- > Stick a note in 0ms (`pin: ...`), see it persistently below your editor (`belowEditor`, zero collision with `aboveEditor` extensions), track long tasks with micro progress bars `[■■□□ 2/4]`, jump straight back to the original conversation session (`↪`), and auto-tear off on `git commit`.
3
+ > **A digital sticky note docked below your Pi editor — 0ms instant stick, micro progress bars `[■■□□ 2/4]`, 1-click session teleport (`↪`), and automatic teardown on `git commit`.**
5
4
 
6
5
  [![CI Status](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml/badge.svg)](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml)
7
6
  [![Node v20+](https://img.shields.io/badge/Node-v20+-22c55e.svg)](package.json)
8
7
  [![TypeScript Strict](https://img.shields.io/badge/TypeScript-Strict-3b82f6.svg)](tsconfig.json)
9
8
  [![Tests: 10 Passed](https://img.shields.io/badge/Tests-10%20Passed-22c55e.svg)](tests/store.test.ts)
9
+ [![Startup: < 45ms](https://img.shields.io/badge/Startup-%3C%2045ms-success.svg)](package.json)
10
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-f97316.svg)](LICENSE)
11
11
 
12
12
  **English** | [简体中文](./README_zh.md)
@@ -16,58 +16,104 @@ pi install npm:pi-anchor
16
16
  ```
17
17
 
18
18
  <p align="center">
19
- <img src="assets/hero-en.svg" alt="Anchor Sticky Notes" width="820">
19
+ <img src="assets/hero-en.svg" alt="Anchor Below-Editor Sticky Strip" width="820">
20
20
  </p>
21
21
 
22
22
  ```text
23
- ╭─ Pi Editor ──────────────────────────────────────────────────────────────────────╮
23
+ ┌─ [aboveEditor stays 100% free for pi-lingual and other HUD widgets] ────────────┐
24
+ ╭─┴─ Pi Editor ────────────────────────────────────────────────────────────────────╮
24
25
  │ ❯ pin: Refactor auth flow: 1. Extract interface 2. HttpOnly cookie 3. Unit tests │
25
- ╰──────────────────────────────────────────────────────────────────────────────────╯
26
- ⌖ #1 [■■□□ 2/4] Refactor auth flow ▸ Unit tests (app · auth.ts ↪)
26
+ ╰─┬────────────────────────────────────────────────────────────────────────────────╯
27
+ └─► ⌖ #1 [■■□□ 2/4] Refactor auth flow ▸ Unit tests (app · auth.ts ↪)
27
28
  ```
28
29
 
29
30
  ---
30
31
 
31
- ## Why Anchor?
32
+ ## Core Value
32
33
 
33
- When you're deep in a coding session and think *"Wait — I need to check the rate-limit fallback in `src/gateway.ts` after this"*, or when you pause a multi-day task for the night:
34
- - Writing a `TODO.md` inside your repo pollutes `git status` and burns prompt tokens on every turn.
35
- - Normal startup banners vanish the second you send a message, so you forget what you wrote.
34
+ Late at night, mid-way through fixing a bug, you spot a second issue in `src/auth.ts` or need to pause a multi-step refactor until tomorrow. Standard ways of recording that note create immediate friction:
36
35
 
37
- **Anchor is a real sticky note stuck right under your Pi input box (`belowEditor`)** — stored outside your repo in `~/.anchor/state.json` (4 source files, 0 dependencies), designed for **human hands first**:
36
+ 1. **Terminal Exit Wipes Working Memory**: Closing the terminal or running `/new` clears the agent's context. Yesterday's unfinished steps and the conversation history behind them vanish.
37
+ 2. **`TODO.md` Pollutes Git & Burns Context Tokens**: Tracking personal reminders inside repository markdown files dirties `git status` and re-injects stale checklists into the system prompt on every turn.
38
+ 3. **Splash Banners Vanish When You Start Typing**: Traditional startup widgets disappear on the first message. For the rest of a two-hour session, your notes are hidden unless you manually run a slash command.
38
39
 
39
- ### 1. Docked Below Your Editor & 0ms Instant Stick (`pin: ...`)
40
- - **Zero Widget Collision**: Anchor docks its 1-line strip **below** your editor (`placement: "belowEditor"`), leaving the space above your editor (`aboveEditor`) 100% free for tools like `pi-lingual`.
41
- - **0ms Human Input**: Type `pin: Check token expiry` (or `note: ...`, `便签: ...`, `/pin ...`) directly in the editor. Anchor intercepts it in **0.00ms** without triggering an LLM call or burning tokens, and automatically links the file you were just editing.
40
+ **Anchor acts like a physical sticky note stuck to the bottom bezel of your Pi editor**:
41
+ - **Persistent Below-Editor Strip (`belowEditor`)**: Docks a single-line note strip directly beneath your input box. It stays in view while you code, leaves `aboveEditor` untouched for extensions like `pi-lingual`, and collapses to 0 lines when empty.
42
+ - **0ms Human-First Capture (`pin: ...`)**: Type `pin: Check token expiry` (or `/pin ...`) straight into the editor. An input hook intercepts the text in `0.00ms` without calling the language model or spending tokens.
43
+ - **Micro Progress Bar (`[■■□□ 2/4]`)**: Write `[2/4]` or numbered steps (`1. Extract interface 2. HttpOnly cookie 3. Unit tests`) in your note to get an inline 4-cell progress bar with next-step hints.
44
+ - **Session Teleport (`↪ Jump to session`)**: Every note bookmarks the exact `.jsonl` session file where it was created or last touched. Select `↪ Jump to session` in `/anchor` to switch Pi straight back to that conversation.
42
45
 
43
- ### 2. Micro Progress Bar for Long Tasks (`[■■□□ 2/4]`)
44
- - Write a ratio (`[2/4]` or `5/30`) or numbered steps (`1. Extract interface 2. HttpOnly cookie 3. Unit tests`) in your note:
45
- ```text
46
- /pin Refactor auth flow: 1. Extract interface 2. HttpOnly cookie 3. Unit tests
47
- ```
48
- - Anchor renders a crisp micro progress bar (`[■■□□ 2/3] ▸ Unit tests`) right on the sticky strip. Run `/step 1` (or select `▶ Advance step` in `/anchor`) to tick it forward. Simple one-off notes stay 100% bar-free.
46
+ ---
47
+
48
+ ## How It Works
49
+
50
+ | Mode / Action | What You Input | What the Engine Does | What You See | Best For |
51
+ | :--- | :--- | :--- | :--- | :--- |
52
+ | **Stick a Note (0ms)** | Type `pin: Fix auth in src/auth.ts` or `/pin ...` | Intercepts via input hook in `0ms`, extracts file paths, and links current `sessionFile` | 1-line strip appears below editor: `⌖ #1 Fix auth (app · auth.ts ↪)` | Capturing a tangent without derailing the current prompt |
53
+ | **Track Multi-Step Work** | Type `/pin Refactor: 1. API 2. Cookie 3. Tests` then `/step 1` | Parses step list and increments `done / total` counter in `~/.anchor/state.json` | Strip updates in place: `⌖ #1 [■■□□ 2/3] Refactor ▸ Tests` | Multi-session features and long-running study goals |
54
+ | **Resume Context (`↪`)** | Run `/anchor` ➔ select `↪ Jump to session` (or `/anchor jump 1`) | Invokes Pi's native `ctx.switchSession(note.sessionFile)` | Terminal switches directly back to the original conversation transcript | Picking up a complex task days later with full history |
55
+ | **Auto-Tear on Commit** | Commit code (`git commit -m "feat: fix auth"`) or run `/done 1` | Matches commit message against active notes (filtering generic stop-words) | Note archives to `archive.jsonl`; strip clears (`⌖ Auto-torn off #1`) | Keeping the sticky strip clean without manual bookkeeping |
56
+
57
+ ---
49
58
 
50
- ### 3. Conversation Session Bookmark (`↪ Jump to session`)
51
- - Every sticky note automatically bookmarks the Pi conversation session (`sessionFile`) where it was created or last worked on.
52
- - Days later, open `/anchor` and pick **`↪ Jump to session`** (or run `/anchor jump 1`) to teleport Pi straight back into that exact conversation history.
59
+ ## Features & Engineering Answers
53
60
 
54
- ### 4. File Whisper & Auto-Tear on `git commit`
55
- - **1-Line File Whisper (0 Idle Tokens)**: Briefs the AI once on Turn 1, then consumes **0 tokens** on Turn 2+. When the AI reads or edits a matching file, Anchor slips a 1-line comment (`// ⌖ Note #1: ...`) onto that file result (strictly skipping `.json` and `.env`).
56
- - **Auto-Tear on Commit**: When `git commit -m "..."` matches your note, Anchor tears the note off automatically (`⌖ Auto-torn off #1`). Run `/anchor undo` anytime to put it back.
61
+ ### 1. Will the sticky strip collide with `pi-lingual` or overflow narrow split panes?
62
+ Anchor mounts its widget with `{ placement: "belowEditor" }`. Extensions that render translation cards or diagnostics above the input box (`aboveEditor`) never overlap with Anchor.
63
+ On narrow terminal splits (such as 50-column tmux panes), the strip measures visual column widths using Unicode East Asian Width rules (CJK/emoji = 2 cells, ASCII = 1 cell) and dynamically folds multiple notes into `⌖ 3: #1 [■■□□ 2/4] Refactor… +2` while stripping unclosed brackets before ellipses.
64
+
65
+ ### 2. Does keeping sticky notes cost tokens on every turn?
66
+ **Turn 2+ consumes 0 system prompt tokens.**
67
+ Anchor injects a compact summary of active notes only on Turn 1 of a cold session. From Turn 2 onwards, the system prompt hook returns nothing. When the agent reads or edits a file that matches a note's path or glob (`src/auth/*.ts`), Anchor appends a single syntax-safe comment line (`// ⌖ Note #1: ...`) to that tool result, while returning `null` for `.json`, `.env`, lockfiles, and binaries.
68
+
69
+ ### 3. What happens to old notes or accidental `git commit` matches?
70
+ - **7-Day Quiet Mode (Zero Silent Deletion)**: Notes untouched for more than 7 days transition to `sleeping` (`quiet`). They stop appearing in Turn-1 prompts for unrelated workspaces, remain 100% visible in `/anchor`, and wake up automatically when you open their target project.
71
+ - **Stop-Word Filtered Commit Matching & 1-Tap Undo**: Commit matching strips generic words (`fix`, `feat`, `chore`, `bug`, `update`) so a commit like `fix: typo in bug template` never tears off a note titled `fix bug in login`. If you ever tear off a note by mistake, `/anchor undo` pops the last entry from `archive.jsonl` right back onto your strip.
72
+ - **Bracketed Ratio Parsing**: Progress ratios require explicit brackets (`[2/4]` or `(5/30)`), ensuring calendar dates in note titles (like `Before 10/12 release`) are never mistaken for progress bars.
73
+
74
+ ### 4. Storage Topology & Cross-Process Lock Safety
75
+ All state lives in `~/.anchor/` outside your git repository:
76
+ ```text
77
+ ~/.anchor/
78
+ ├── state.json # Active and quiet sticky notes (< 2KB, atomic temp-file + rename)
79
+ ├── state.lock # Cross-process exclusive lock (fs.openSync 'wx' + dead-PID reclamation)
80
+ └── archive.jsonl # Append-only history of torn-off notes (powers /anchor undo)
81
+ ```
57
82
 
58
83
  ---
59
84
 
60
85
  ## Commands
61
86
 
62
- | Where | Input / Command | What It Does |
87
+ | Where | Input / Command | Description |
63
88
  | :--- | :--- | :--- |
64
89
  | **Pi Editor** | `pin: <note>` or `便签: <note>` | Stick a note in **0ms** without calling the LLM |
65
- | **Pi Slash** | `/pin <note>` | Stick a note (supports `[1/4]` or `1. A 2. B` steps) |
66
- | **Pi Slash** | `/anchor` | Open interactive board (tear off, `+1` step, or `↪` jump to session) |
90
+ | **Pi Slash** | `/pin <note>` | Stick a note (supports `[1/4]` or `1. A 2. B 3. C` steps) |
91
+ | **Pi Slash** | `/anchor` | Open interactive sticky note board (tear off, `+1` step, `↪` jump to session, or read details) |
67
92
  | **Pi Slash** | `/step 1` | Advance progress bar on note `#1` by `+1` |
68
93
  | **Pi Slash** | `/anchor jump 1` | Switch Pi back to the conversation session linked to note `#1` |
69
- | **Pi Slash** | `/done 1` / `/anchor undo` | Tear off note `#1`, or put back the last torn-off note |
70
- | **Terminal CLI** | `anchor` / `anchor step 1` / `anchor done 1` | View, step, or tear off notes outside Pi |
94
+ | **Pi Slash** | `/done 1` / `/anchor 1` | Tear off note `#1` and archive it |
95
+ | **Pi Slash** | `/anchor undo` | Put back the most recently torn-off note |
96
+ | **Terminal CLI** | `anchor` / `anchor "<note>"` | View or stick notes from any shell outside Pi |
97
+ | **Terminal CLI** | `anchor step 1` / `anchor done 1` / `anchor undo` | Advance progress, tear off, or restore notes from the command line |
98
+
99
+ ---
100
+
101
+ ## Author's Note
102
+
103
+ Anchor started because I hated watching `TODO.md` files rot inside my git repositories, yet closing my terminal at night always meant losing track of what I still needed to fix the next morning.
104
+
105
+ In its early versions, I over-engineered the solution. By `v0.3`, Anchor had grown into a 10-file miniature project manager with four cognitive quadrants (`Today`, `Upcoming`, `Habits`, `Backlog`), `[P0]` priority tags, daily recurring habit trackers, pre-flight shell test runners on startup, and a graveyard eviction state machine. Ironically, the more features it gained, the less I wanted to use it. Worse, its startup banner disappeared the moment I typed my first message, hiding my notes for the rest of the coding session.
106
+
107
+ In `v0.4` and `v0.5`, I threw away 60% of the codebase and rebuilt Anchor around four files (`types.ts`, `store.ts`, `tui.ts`, `extension.ts`) as a simple digital sticky note:
108
+ 1. Dock the 1-line note strip **below** the editor (`belowEditor`) so it stays in sight while coding and never fights with `pi-lingual` above the editor.
109
+ 2. Let human hands stick a note in `0ms` by typing `pin: ...` without waiting on a model call.
110
+ 3. Add a 4-cell progress bar (`[■■□□ 2/4]`) for multi-step refactors and bookmark the session file (`↪`) so clicking a note teleports you back to the exact conversation where the work started.
111
+
112
+ ```bash
113
+ npm run typecheck # Strict tsc --noEmit check (0 errors)
114
+ npm test # Native Node test runner (10/10 passing)
115
+ npm run build # Dual ESM/CJS compilation via tsup + declaration emit
116
+ ```
71
117
 
72
118
  ---
73
119
 
package/README_zh.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # ⌖ Anchor
2
2
 
3
- > **贴在 Pi 输入框正下方的电子便签 —— 专为顺手盲打与跨会话续传设计。**
4
- > 敲 `便签: ...` 0ms 瞬贴上屏,在输入框**正下方 (`belowEditor`)** 常驻 1 行极简便签条(与上方 `pi-lingual` 零冲突),支持长线任务**迷你进度条 `[■■□□ 2/4]`**、**一键穿越回当时的对话上下文 (`↪`)**,以及 **`git commit` 自动撕便签**。
3
+ > **贴在 Pi 输入框正下方的电子便签 —— `便签: ...` 0ms 瞬贴上屏、长线任务迷你进度条 `[■■□□ 2/4]`、一键切回原对话上下文 (`↪`),`git commit` 提交代码自动撕便签。**
5
4
 
6
5
  [![CI 状态](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml/badge.svg)](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml)
7
6
  [![Node v20+](https://img.shields.io/badge/Node-v20+-22c55e.svg)](package.json)
8
7
  [![TypeScript Strict](https://img.shields.io/badge/TypeScript-Strict-3b82f6.svg)](tsconfig.json)
9
8
  [![Tests: 10 Passed](https://img.shields.io/badge/Tests-10%20通过-22c55e.svg)](tests/store.test.ts)
9
+ [![启动耗时: < 45ms](https://img.shields.io/badge/启动耗时-%3C%2045ms-success.svg)](package.json)
10
10
  [![开源协议: MIT](https://img.shields.io/badge/开源协议-MIT-f97316.svg)](LICENSE)
11
11
 
12
12
  [English](./README.md) | **简体中文**
@@ -16,56 +16,107 @@ pi install npm:pi-anchor
16
16
  ```
17
17
 
18
18
  <p align="center">
19
- <img src="assets/hero-zh.svg" alt="Anchor 电子便签预览" width="820">
19
+ <img src="assets/hero-zh.svg" alt="Anchor 输入框下方常驻便签条预览" width="820">
20
20
  </p>
21
21
 
22
22
  ```text
23
- ╭─ Pi 输入框 ──────────────────────────────────────────────────────────────────────╮
23
+ ┌─ [输入框上方 aboveEditor 完全留给 pi-lingual 等插件,上下零冲突] ─────────────┐
24
+ ╭─┴─ Pi 输入框 ────────────────────────────────────────────────────────────────────╮
24
25
  │ ❯ 便签: 重构登录鉴权流: 1. 抽出接口 2. 替换Cookie 3. 补单测 4. 压测 │
25
- ╰──────────────────────────────────────────────────────────────────────────────────╯
26
- ⌖ #1 [■■□□ 2/4] 重构登录鉴权流 ▸ 补单测 (app · auth.ts ↪)
26
+ ╰─┬────────────────────────────────────────────────────────────────────────────────╯
27
+ └─► ⌖ #1 [■■□□ 2/4] 重构登录鉴权流 ▸ 补单测 (app · auth.ts ↪)
27
28
  ```
28
29
 
29
30
  ---
30
31
 
31
- ## 为什么这次真正顺手了?(四大巧思)
32
+ ## Core Value
32
33
 
33
- ### 1. 输入框正下方常驻 1 行便签条 (`belowEditor`) + 0ms 瞬贴
34
- * **绝不跟上方插件抢地盘**:便签条挂载在输入框**正下方 (`belowEditor`)**,把输入框上方 (`aboveEditor`) 完全留给 `pi-lingual` 等高频伴学卡片,上下分工井然有序。
35
- * **真·便签常驻视野**:有便签时在输入框下沿安静显示 1 行,不再像以前那样“刚发第一句话就神秘消失”;无便签时 0 行完全隐形。
36
- * **自己随手写,0ms 上屏**:直接在输入框敲 `便签: 明天检查网关重试`(或 `pin: ...`、`/pin ...`),**0.00ms 当场拦截贴上便签条**,不用苦等大模型转圈调工具,不花 1 个 Token,还会自动关联你当前正在改的代码文件。
34
+ 深夜写代码查问题时,你经常顺眼看到另一个文件里的隐患,或者手头的长线重构今天只做了一半。传统的记录方式总有三处具体的别扭:
37
35
 
38
- ### 2. 长线任务迷你进度条 (`[■■□□ 2/4]`)
39
- * 遇到跨天或分步任务,随手在便签里写上进度比(如 `[5/30]`)或分步序号(`1. 抽出接口 2. 替换Cookie 3. 补单测`):
40
- ```text
41
- 便签: 重构登录鉴权流: 1. 抽出接口 2. 替换Cookie 3. 补单测
42
- ```
43
- * 便签条会自动渲染出 4 格迷你进度条与下一步提示:`[■■□□ 2/3] ▸ 补单测`。敲 `/step 1`(或在 `/anchor` 菜单里点 `▶ Advance step`)即可推进一格;普通单句备忘则保持纯粹文字,绝不画蛇添足。
36
+ 1. **关掉终端或开新会话即失忆**:按下 `Ctrl+C` 或敲 `/new` 开启新会话,昨晚改到哪一步、当时那场对话聊了什么上下文,瞬间断线。
37
+ 2. **`TODO.md` 弄脏 Git 且每轮白烧 Token**:在项目根目录维护待办文件,既制造杂乱的 Git 修改记录,又在每一轮对话里反复把旧清单塞进系统提示词。
38
+ 3. **开局闪现横幅一打字就消失**:普通插件只在打开终端时闪一下提示,你发完第一句话它就自我隐藏。接下来两个小时写代码,除非专门敲命令查询,否则根本看不见自己记了什么。
44
39
 
45
- ### 3. 绑定原对话 Session,一键穿越回去 (`↪ Jump to session`)
46
- * 贴便签或修改关联代码时,Anchor 会自动记录当前对话的 `sessionFile` 书签(显示 `↪` 标记)。
47
- * 哪怕过了好几天、开了十几个新窗口,当你看到便签想接着干时,打开 `/anchor` 选中它点击 **`↪ Jump to session`**(或敲 `/anchor jump 1`),Pi 就会**直接切换回当时那场对话的完整上下文**!
40
+ **Anchor 的定位很单纯:贴在 Pi 输入框下边框的一张电子便签**:
41
+ - **输入框正下方常驻 1 行 (`belowEditor`)**:便签条固定在输入框下沿,写代码时抬眼可见;输入框上方空间完全留给 `pi-lingual` 等插件;没有便签时自动收起为 0 行。
42
+ - **自己随手敲,0ms 瞬贴 (`便签: ...`)**:直接在输入框敲 `便签: 检查网关超时重试`(或 `pin: ...`、`/pin ...`),输入钩子在 `0.00ms` 内直接拦截上屏,不调用大模型、不耗费 1 个 Token,并自动关联当前刚修改过的代码文件。
43
+ - **长线任务迷你进度条 (`[■■□□ 2/4]`)**:在便签里写 `[2/4]` 或分步序号 `1. 抽出接口 2. 替换Cookie 3. 补单测`,便签条自动显示 4 格进度条与下一步提示。
44
+ - **原对话一键穿越 (`↪ Jump to session`)**:每张便签自动记录创建或施工时的会话文件路径(`sessionFile`)。在 `/anchor` 面板中点击 `↪ Jump to session`,直接切回当时那场对话。
48
45
 
49
- ### 4. 碰文件顺手递纸条 & `git commit` 自动撕便签
50
- * **碰文件递纸条(平时 0 Token)**:首轮对话简要提醒,第 2 轮起 **0 Token**;当 AI 读取或修改关联文件时,在文件结果末尾顺手附带 1 行语法安全注释(`// ⌖ Note #1: ...`,严格跳过 `.json`/`.env`)。
51
- * **提交代码自动撕下**:执行 `git commit -m "..."` 命中便签内容时,当场自动撕下归档;撕错了敲 `/anchor undo` 一秒贴回。
46
+ ---
47
+
48
+ ## How It Works
49
+
50
+ | 操作场景 | 你的输入 | 后台执行逻辑 | 界面即时反馈 | 适用时刻 |
51
+ | :--- | :--- | :--- | :--- | :--- |
52
+ | **0ms 随手贴便签** | 输入框敲 `便签: 把 src/auth.ts 改用 Cookie` | `input` 钩子 0ms 拦截,提取文件路径并记录当前 `sessionFile` | 输入框下方立即浮现:`⌖ #1 把 src/auth.ts 改用 Cookie (app · auth.ts ↪)` | 改代码中途冒出支线念头,不想打断当前对话 |
53
+ | **分步推进长任务** | 敲 `/pin 重构: 1. 接口 2. Cookie 3. 单测` 后敲 `/step 1` | 解析分步列表并在 `~/.anchor/state.json` 推进完成步数 | 下方便签条实时变为:`⌖ #1 [■■□□ 2/3] 重构 ▸ 单测` | 跨天重构、分步排障或刷课程章节 |
54
+ | **切回原对话 (`↪`)** | 敲 `/anchor` 选中便签点 `↪ Jump to session`(或 `/anchor jump 1`) | 调用 Pi 原生 `ctx.switchSession(note.sessionFile)` | 终端直接切回贴这张便签时的完整历史对话上下文 | 隔了几天回来继续施工,需要找回当时的讨论细节 |
55
+ | **提交代码自动撕** | 执行 `git commit -m "feat: 登录改用 Cookie"` 或敲 `/done 1` | 过滤通用停用词后比对提交信息与便签关键词 | 便签自动归档至 `archive.jsonl`,下方便签条同步消失 | 写完代码顺手提交,免去手动打勾销账 |
52
56
 
53
57
  ---
54
58
 
55
- ## 常用操作速查
59
+ ## Features & Engineering Answers
60
+
61
+ ### 1. 下方常驻便签条会和 `pi-lingual` 冲突,或者在窄分屏下撑爆换行吗?
62
+ Anchor 显式指定 `{ placement: "belowEditor" }` 挂载在输入框下方,与挂载在输入框上方(`aboveEditor`)的 `pi-lingual` 物理隔离,上下各司其职。
63
+ 面对 50 列宽度的窄分屏终端,排版函数按 Unicode 东亚字符宽度标准(中文/Emoji 计 2 列,ASCII 计 1 列)动态计算剩余预算:窄屏下自动折叠为 `⌖ 3: #1 [■■□□ 2/4] 重构登录… +2`,并在省略号截断前自动清理未闭合的左括号。
64
+
65
+ ### 2. 便签常驻显示,会不会每一轮对话都偷偷消耗上下文 Token?
66
+ **第 2 轮起系统提示词消耗为 0 Token。**
67
+ 界面上的便签条是纯本地终端 TUI 渲染组件。仅在会话第 1 轮冷启动时,Anchor 向系统提示词注入一次简短摘要;从第 2 轮起完全静默。只有当 AI 读取或编辑了便签关联的代码文件时,Anchor 才在该文件工具返回结果末尾追加 1 行对应语言的注释(`// ⌖ Note #1: ...`),遇到 `.json`、`.env`、锁文件与二进制文件则严格返回 `null` 跳过。
68
+
69
+ ### 3. 很久没碰的旧便签会丢吗?普通的 `git commit -m "fix bug"` 会不会误撕便签?
70
+ - **7 天转静默,永不自动删数据**:超过 7 天未触碰的便签自动标记为 `quiet`(不在无关工程的首轮提示词里刷存在感),但在 `/anchor` 列表中始终保留,一旦进入对应工程目录即自动唤醒。
71
+ - **停用词过滤与一键反悔**:Git 提交匹配会自动剔除 `fix`、`feat`、`bug`、`修复`、`优化` 等高频泛化词,杜绝无关提交误删便签。万一手动撕错了,敲 `/anchor undo` 即可从 `archive.jsonl` 原样贴回。
72
+ - **进度比防误判守卫**:比例进度条严格要求带括号(如 `[2/4]` 或 `(5/30)`),日常写 `10/12 前发布接口` 等日期数字绝不会被误判为进度条。
73
+
74
+ ### 4. 全局存储拓扑与多窗口并发锁
75
+ 所有便签数据独立存放在用户主目录 `~/.anchor/`,项目仓库保持 100% 干净:
76
+ ```text
77
+ ~/.anchor/
78
+ ├── state.json # 当前便签状态表 (< 2KB,临时文件写入 + 原子 rename)
79
+ ├── state.lock # 跨进程排他文件锁 (fs.openSync 'wx' + 死进程 PID 自动回收)
80
+ └── archive.jsonl # 已撕下便签的追加式历史记录 (支撑 /anchor undo 秒级恢复)
81
+ ```
82
+
83
+ ---
56
84
 
57
- | 位置 | 输入 / 命令 | 作用 |
85
+ ## Commands
86
+
87
+ | 位置 | 输入 / 命令 | 作用说明 |
58
88
  | :--- | :--- | :--- |
59
- | **输入框直接敲** | `便签: <内容>` 或 `pin: <内容>` | **0ms 直接贴便签**(不触发大模型请求,自动绑定当前改过的文件与 Session) |
60
- | **斜杠命令** | `/pin <内容>` | 贴一张新便签(支持写 `[2/5]` 或 `1. A 2. B` 自动生成进度条) |
61
- | **斜杠命令** | `/anchor` | 打开交互面板(支持撕便签、推进度 `+1`、`↪` 穿越回原对话 Session) |
62
- | **斜杠命令** | `/step 1` | 将 `#1` 便签的进度条往前推进 `+1` 步 |
63
- | **斜杠命令** | `/anchor jump 1` | 直接切换回 `#1` 便签绑定的那次历史对话 (`switchSession`) |
64
- | **斜杠命令** | `/done 1` / `/anchor undo` | 撕掉 `#1` 便签,或把刚撕掉的便签重新贴回来 |
65
- | **终端 CLI** | `anchor` / `anchor step 1` / `anchor done 1` | 不进 Pi 也能在外部终端查看、推进度或撕便签 |
89
+ | **Pi 输入框直敲** | `便签: <内容>` 或 `pin: <内容>` | **0ms 直接贴便签**(不触发大模型请求,自动绑定当前改过的文件与 Session) |
90
+ | **Pi 斜杠命令** | `/pin <内容>` | 贴一张新便签(支持写 `[1/4]` 或 `1. A 2. B 3. C` 自动生成进度条) |
91
+ | **Pi 斜杠命令** | `/anchor` | 打开交互面板(回车可撕便签、推进度 `+1`、`↪` 切回原对话 Session、看详情) |
92
+ | **Pi 斜杠命令** | `/step 1` | 将 `#1` 便签的进度条往前推进 `+1` 步 |
93
+ | **Pi 斜杠命令** | `/anchor jump 1` | 直接切换回 `#1` 便签绑定的那场历史对话 (`switchSession`) |
94
+ | **Pi 斜杠命令** | `/done 1` / `/anchor 1` | 按编号 `#1` 直接撕下便签并归档 |
95
+ | **Pi 斜杠命令** | `/anchor undo` | 把刚刚撕下的便签重新贴回原位 |
96
+ | **系统命令行** | `anchor` / `anchor "<内容>"` | 不进 Pi 也能在外部终端查看或贴便签 |
97
+ | **系统命令行** | `anchor step 1` / `anchor done 1` / `anchor undo` | 在外部终端推进进度、撕便签或撤销 |
98
+
99
+ ---
100
+
101
+ ## Author's Note
102
+
103
+ 做 Anchor 的初衷很简单:我讨厌项目仓库里的 `TODO.md` 弄脏 Git 提交历史,但每天深夜关掉终端时,又需要有个地方随手记一句“明天接着改哪儿”。
104
+
105
+ 早期版本里,我犯了典型的过度设计病。到 `v0.3` 时,Anchor 膨胀成了 10 个源码文件的小型任务管理系统:塞进了四象限分类(`Today`、`Upcoming`、`Habits`、`Backlog`)、`[P0]` 优先级标签、每日习惯打卡、启动时同步阻塞跑测试脚本,甚至还有一套自动把过期任务移入坟场的淘汰机制。结果功能越堆越多,我自己反而越用越别扭——尤其荒诞的是,开局弹出的便签提示在我发出第一句话后就自动隐藏了,真正写代码的几个小时里根本看不见它。
106
+
107
+ 在 `v0.4` 和 `v0.5` 中,我删掉了 60% 的代码,只留 4 个核心文件(`types.ts`、`store.ts`、`tui.ts`、`extension.ts`),让它老老实实回归一张贴在输入框下沿的电子便签:
108
+ 1. 用 `belowEditor` 把单行便签条固定在输入框正下方,写代码时一眼能瞥见,同时把输入框上方的位置让给 `pi-lingual`。
109
+ 2. 便签本来就是给人自己随手写的,敲一句 `便签: ...` 在 0ms 内直接上屏,不必等大模型转圈。
110
+ 3. 针对跨天的大活,顺手加上 4 格迷你进度条(`[■■□□ 2/4]`)和原对话书签(`↪`),点一下就能切回当时的对话上下文接着干。
111
+
112
+ ```bash
113
+ npm run typecheck # TypeScript 严格类型检查 (0 错误)
114
+ npm test # 原生 Node 测试运行器 (10/10 全绿通过)
115
+ npm run build # tsup 双格式打包与类型声明生成
116
+ ```
66
117
 
67
118
  ---
68
119
 
69
- ## 开源协议
120
+ ## License
70
121
 
71
122
  MIT © [Jason Song](https://github.com/3ZEROS12)
package/bin/anchor.js CHANGED
@@ -30,8 +30,8 @@ if (args.length === 0 || args[0] === 'list' || args[0] === 'ls') {
30
30
  const sorted = sortAnchorsForDisplay(allNotes, process.cwd());
31
31
  console.log(`\n⌖ Notes (${sorted.length}):\n`);
32
32
 
33
- sorted.forEach((a, idx) => {
34
- const num = String(idx + 1).padStart(2, '0');
33
+ sorted.forEach((a) => {
34
+ const num = a.id.replace(/^anc-/, '').padStart(2, '0');
35
35
  const bar = a.progress ? `${formatProgressBar(a.progress)} ` : '';
36
36
  const origin = formatOrigin(a) + (a.sessionFile ? ' ↪' : '');
37
37
  const age = a.status === 'sleeping'
@@ -66,13 +66,13 @@ if (args[0] === 'step' || args[0] === '+') {
66
66
  const idInput = args[1] || '1';
67
67
  const delta = args[2] ? parseInt(args[2], 10) : 1;
68
68
  try {
69
- const allNotes = store
70
- .list({ cwd: process.cwd(), all: true })
71
- .filter(a => a.status === 'active' || a.status === 'sleeping');
72
- const sorted = sortAnchorsForDisplay(allNotes, process.cwd());
73
-
74
- let targetId = idInput;
75
- if (/^\d+$/.test(idInput)) {
69
+ const direct = store.get(idInput);
70
+ let targetId = direct ? direct.id : idInput;
71
+ if (!direct && /^\d+$/.test(idInput)) {
72
+ const allNotes = store
73
+ .list({ cwd: process.cwd(), all: true })
74
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
75
+ const sorted = sortAnchorsForDisplay(allNotes, process.cwd());
76
76
  const seqIndex = parseInt(idInput, 10);
77
77
  if (seqIndex >= 1 && seqIndex <= sorted.length) {
78
78
  targetId = sorted[seqIndex - 1].id;
@@ -111,13 +111,13 @@ if (args[0] === 'done' || args[0] === 'rm' || args[0] === 'close') {
111
111
  process.exit(1);
112
112
  }
113
113
  try {
114
- const allNotes = store
115
- .list({ cwd: process.cwd(), all: true })
116
- .filter(a => a.status === 'active' || a.status === 'sleeping');
117
- const sorted = sortAnchorsForDisplay(allNotes, process.cwd());
118
-
119
- let targetId = idInput;
120
- if (/^\d+$/.test(idInput)) {
114
+ const direct = store.get(idInput);
115
+ let targetId = direct ? direct.id : idInput;
116
+ if (!direct && /^\d+$/.test(idInput)) {
117
+ const allNotes = store
118
+ .list({ cwd: process.cwd(), all: true })
119
+ .filter(a => a.status === 'active' || a.status === 'sleeping');
120
+ const sorted = sortAnchorsForDisplay(allNotes, process.cwd());
121
121
  const seqIndex = parseInt(idInput, 10);
122
122
  if (seqIndex >= 1 && seqIndex <= sorted.length) {
123
123
  targetId = sorted[seqIndex - 1].id;
@@ -66,10 +66,10 @@ function extractProgressFromText(rawTitle) {
66
66
  progress: { done: 0, total: steps.length, steps }
67
67
  };
68
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|$))/);
69
+ const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\))/);
70
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);
71
+ const done = parseInt(ratioMatch[1] ?? ratioMatch[3], 10);
72
+ const total = parseInt(ratioMatch[2] ?? ratioMatch[4], 10);
73
73
  if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
74
74
  const stripped = rawTitle.replace(ratioMatch[0], " ").replace(/\s+/g, " ").trim();
75
75
  return {
@@ -91,7 +91,8 @@ function isAnchorTargetingWorkspace(anchor, targetCwd) {
91
91
  }
92
92
  if (anchor.files && anchor.files.length > 0) {
93
93
  for (const f of anchor.files) {
94
- if (isSameOrSubPath(f, normTarget)) return true;
94
+ const resolvedFile = !path.isAbsolute(f) && !/^[a-zA-Z]:\//.test(f) && anchor.cwd ? `${anchor.cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${f.replace(/^\/+/, "")}` : f;
95
+ if (isSameOrSubPath(resolvedFile, normTarget)) return true;
95
96
  }
96
97
  }
97
98
  if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
@@ -181,6 +182,41 @@ function makeSafeTaskAnnotation(filePath, anchor) {
181
182
  const num = anchor.id.replace(/^anc-/, "");
182
183
  return "\n\n" + fmt(`\u2316 Note #${num}: ${anchor.title}`);
183
184
  }
185
+ var COMMIT_STOP_WORDS = /* @__PURE__ */ new Set([
186
+ "fix",
187
+ "feat",
188
+ "chore",
189
+ "docs",
190
+ "test",
191
+ "tests",
192
+ "refactor",
193
+ "style",
194
+ "perf",
195
+ "build",
196
+ "ci",
197
+ "bug",
198
+ "issue",
199
+ "update",
200
+ "add",
201
+ "remove",
202
+ "todo",
203
+ "note",
204
+ "task",
205
+ "the",
206
+ "and",
207
+ "for",
208
+ "with",
209
+ "\u4FEE\u590D",
210
+ "\u4F18\u5316",
211
+ "\u66F4\u65B0",
212
+ "\u6DFB\u52A0",
213
+ "\u4FEE\u6539",
214
+ "\u5B8C\u6210",
215
+ "\u95EE\u9898",
216
+ "\u4EFB\u52A1",
217
+ "\u6A21\u5757",
218
+ "\u529F\u80FD"
219
+ ]);
184
220
  function matchesCommitMessage(anchor, commitMessages) {
185
221
  const idLower = anchor.id.toLowerCase();
186
222
  const numId = `#${anchor.id.replace(/^anc-/, "")}`;
@@ -189,21 +225,22 @@ function matchesCommitMessage(anchor, commitMessages) {
189
225
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
190
226
  try {
191
227
  const seg = new Intl.Segmenter("und", { granularity: "word" });
192
- titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2);
228
+ titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
193
229
  } catch {
194
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
230
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
195
231
  }
196
232
  } else {
197
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
233
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
198
234
  }
199
235
  for (const msg of commitMessages) {
200
236
  const msgLower = msg.toLowerCase();
201
- if (msgLower.includes(idLower) || msgLower.includes(numId)) {
237
+ if (msgLower.includes(idLower) || new RegExp(`(?:^|\\s|\\()${numId}(?:\\b|\\)|$)`).test(msgLower)) {
202
238
  return { matched: true, message: msg };
203
239
  }
204
240
  if (titleTokens.length > 0) {
205
241
  const hits = titleTokens.filter((tok) => msgLower.includes(tok)).length;
206
- if (hits >= Math.min(2, titleTokens.length)) {
242
+ const requiredHits = titleTokens.length === 1 ? 1 : 2;
243
+ if (hits >= requiredHits) {
207
244
  return { matched: true, message: msg };
208
245
  }
209
246
  }
@@ -504,9 +541,10 @@ var AnchorStore = class {
504
541
  if (idx === -1) throw new Error(`Note not found: ${id}`);
505
542
  const current = state.anchors[idx];
506
543
  const now = Date.now();
507
- const total = explicitTotal && explicitTotal >= 2 ? explicitTotal : current.progress?.total || 4;
544
+ const safeDelta = Number.isFinite(delta) ? delta : 1;
545
+ const total = explicitTotal && Number.isFinite(explicitTotal) && explicitTotal >= 1 ? explicitTotal : current.progress?.total || 4;
508
546
  const prevDone = current.progress?.done || 0;
509
- const nextDone = Math.max(0, Math.min(total, prevDone + delta));
547
+ const nextDone = Math.max(0, Math.min(total, prevDone + safeDelta));
510
548
  current.progress = {
511
549
  ...current.progress || {},
512
550
  done: nextDone,
@@ -749,15 +787,24 @@ function updateStartupBanner(ctx, store) {
749
787
  const titleClipped = truncateToWidth(a.title, availTitle);
750
788
  return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
751
789
  }
752
- const countTag = fg("accent", `\u2316 ${sorted.length} notes: `);
753
- const segments = sorted.slice(0, 3).map((a) => {
790
+ const maxItems = width < 68 ? 1 : width < 100 ? 2 : 3;
791
+ const visibleSlice = sorted.slice(0, maxItems);
792
+ const countTag = fg("accent", `\u2316 ${sorted.length}: `);
793
+ const moreCount = sorted.length - visibleSlice.length;
794
+ const moreSuffix = moreCount > 0 ? fg("dim", ` +${moreCount}`) : "";
795
+ const perSlotWidth = Math.max(
796
+ 10,
797
+ Math.floor((width - getDisplayWidth(countTag) - getDisplayWidth(moreSuffix) - (visibleSlice.length - 1) * 5) / visibleSlice.length)
798
+ );
799
+ const segments = visibleSlice.map((a) => {
754
800
  const num = a.id.replace(/^anc-/, "");
755
801
  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));
802
+ const prefix = `#${num} ${bar}`;
803
+ const titleBudget = Math.max(6, perSlotWidth - getDisplayWidth(prefix));
804
+ const shortTitle = truncateToWidth(a.title, titleBudget);
757
805
  return `${fg("dim", `#${num}`)} ${bar}${shortTitle}`;
758
806
  });
759
- const more = sorted.length > 3 ? fg("dim", ` +${sorted.length - 3}`) : "";
760
- return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${more}`, width)];
807
+ return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${moreSuffix}`, width)];
761
808
  },
762
809
  invalidate: () => {
763
810
  }
@@ -798,7 +845,7 @@ async function openAnchorDashboard(ctx, store) {
798
845
  if (!anchor) return;
799
846
  const currentSessionFile = ctx.sessionManager?.getSessionFile?.();
800
847
  const canJumpSession = Boolean(
801
- anchor.sessionFile && fs2.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || path2.resolve(anchor.sessionFile) !== path2.resolve(currentSessionFile))
848
+ anchor.sessionFile && fs2.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || !isSameOrSubPath(anchor.sessionFile, currentSessionFile))
802
849
  );
803
850
  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)";
804
851
  const actions = [
@@ -887,11 +934,14 @@ function parseAnchorCommandInput(raw) {
887
934
  };
888
935
  }
889
936
  function parseQuickNoteInput(text) {
890
- const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
891
- return m && m[1].trim() ? m[1].trim() : null;
937
+ const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.*)$/i);
938
+ if (!m) return null;
939
+ return m[1].trim();
892
940
  }
893
941
  function resolveAnchorTarget(store, idOrIndex, cwd) {
894
942
  const clean = idOrIndex.trim().replace(/^#/, "");
943
+ const direct = store.get(clean);
944
+ if (direct) return direct.id;
895
945
  if (/^\d+$/.test(clean)) {
896
946
  const seqIndex = parseInt(clean, 10);
897
947
  const visible = store.list({ cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
@@ -900,7 +950,7 @@ function resolveAnchorTarget(store, idOrIndex, cwd) {
900
950
  return sorted[seqIndex - 1].id;
901
951
  }
902
952
  }
903
- return store.get(clean)?.id;
953
+ return void 0;
904
954
  }
905
955
  function getSessionFileSafe(ctx) {
906
956
  try {
@@ -949,7 +999,13 @@ function extension_default(pi) {
949
999
  pi.on("input", async (event, ctx) => {
950
1000
  if (event?.source === "extension") return { action: "continue" };
951
1001
  const quickNote = parseQuickNoteInput(String(event?.text || ""));
952
- if (!quickNote) return { action: "continue" };
1002
+ if (quickNote === null) return { action: "continue" };
1003
+ if (!quickNote) {
1004
+ if (hasActiveUI(ctx)) {
1005
+ await openAnchorDashboard(ctx, store);
1006
+ }
1007
+ return { action: "handled" };
1008
+ }
953
1009
  const anc = createStickyNoteFromUser(quickNote, ctx);
954
1010
  const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
955
1011
  if (hasActiveUI(ctx)) {
@@ -1201,7 +1257,7 @@ ${snippet}` };
1201
1257
  return;
1202
1258
  }
1203
1259
  const rawTrimmed = (args || "").trim();
1204
- if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
1260
+ if (/^(?:#?anc-\d+|#?\d+)$/i.test(rawTrimmed)) {
1205
1261
  const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
1206
1262
  if (resolvedId) {
1207
1263
  const settled = store.settle(resolvedId, { settledBy: "manual" });
@@ -197,8 +197,13 @@ declare function parseAnchorCommandInput(raw: string): {
197
197
  /**
198
198
  * Detect 0ms human-first quick note prefix in the input box:
199
199
  * e.g. `pin: 明天把Token改Cookie` or `便签: 检查网关重试 [1/4]`
200
+ * Returns `{ matched: true, note: '...' }` (or `note: ''` if user typed bare `pin:`)
200
201
  */
201
202
  declare function parseQuickNoteInput(text: string): string | null;
203
+ /**
204
+ * Resolve note target by exact ID first (`#2` -> `anc-2`), falling back to 1-based display row index
205
+ * only when no note with that ID exists. Prevents settling the wrong note when sort order differs from ID order!
206
+ */
202
207
  declare function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined;
203
208
  declare function export_default(pi: ExtensionAPI): void;
204
209
 
@@ -197,8 +197,13 @@ declare function parseAnchorCommandInput(raw: string): {
197
197
  /**
198
198
  * Detect 0ms human-first quick note prefix in the input box:
199
199
  * e.g. `pin: 明天把Token改Cookie` or `便签: 检查网关重试 [1/4]`
200
+ * Returns `{ matched: true, note: '...' }` (or `note: ''` if user typed bare `pin:`)
200
201
  */
201
202
  declare function parseQuickNoteInput(text: string): string | null;
203
+ /**
204
+ * Resolve note target by exact ID first (`#2` -> `anc-2`), falling back to 1-based display row index
205
+ * only when no note with that ID exists. Prevents settling the wrong note when sort order differs from ID order!
206
+ */
202
207
  declare function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined;
203
208
  declare function export_default(pi: ExtensionAPI): void;
204
209
 
@@ -103,10 +103,10 @@ function extractProgressFromText(rawTitle) {
103
103
  progress: { done: 0, total: steps.length, steps }
104
104
  };
105
105
  }
106
- const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)|(?:^|\s)(\d{1,3})\/(\d{1,3})(?=\s|$))/);
106
+ const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\))/);
107
107
  if (ratioMatch) {
108
- const done = parseInt(ratioMatch[1] ?? ratioMatch[3] ?? ratioMatch[5], 10);
109
- const total = parseInt(ratioMatch[2] ?? ratioMatch[4] ?? ratioMatch[6], 10);
108
+ const done = parseInt(ratioMatch[1] ?? ratioMatch[3], 10);
109
+ const total = parseInt(ratioMatch[2] ?? ratioMatch[4], 10);
110
110
  if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
111
111
  const stripped = rawTitle.replace(ratioMatch[0], " ").replace(/\s+/g, " ").trim();
112
112
  return {
@@ -128,7 +128,8 @@ function isAnchorTargetingWorkspace(anchor, targetCwd) {
128
128
  }
129
129
  if (anchor.files && anchor.files.length > 0) {
130
130
  for (const f of anchor.files) {
131
- if (isSameOrSubPath(f, normTarget)) return true;
131
+ const resolvedFile = !import_node_path.default.isAbsolute(f) && !/^[a-zA-Z]:\//.test(f) && anchor.cwd ? `${anchor.cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${f.replace(/^\/+/, "")}` : f;
132
+ if (isSameOrSubPath(resolvedFile, normTarget)) return true;
132
133
  }
133
134
  }
134
135
  if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
@@ -218,6 +219,41 @@ function makeSafeTaskAnnotation(filePath, anchor) {
218
219
  const num = anchor.id.replace(/^anc-/, "");
219
220
  return "\n\n" + fmt(`\u2316 Note #${num}: ${anchor.title}`);
220
221
  }
222
+ var COMMIT_STOP_WORDS = /* @__PURE__ */ new Set([
223
+ "fix",
224
+ "feat",
225
+ "chore",
226
+ "docs",
227
+ "test",
228
+ "tests",
229
+ "refactor",
230
+ "style",
231
+ "perf",
232
+ "build",
233
+ "ci",
234
+ "bug",
235
+ "issue",
236
+ "update",
237
+ "add",
238
+ "remove",
239
+ "todo",
240
+ "note",
241
+ "task",
242
+ "the",
243
+ "and",
244
+ "for",
245
+ "with",
246
+ "\u4FEE\u590D",
247
+ "\u4F18\u5316",
248
+ "\u66F4\u65B0",
249
+ "\u6DFB\u52A0",
250
+ "\u4FEE\u6539",
251
+ "\u5B8C\u6210",
252
+ "\u95EE\u9898",
253
+ "\u4EFB\u52A1",
254
+ "\u6A21\u5757",
255
+ "\u529F\u80FD"
256
+ ]);
221
257
  function matchesCommitMessage(anchor, commitMessages) {
222
258
  const idLower = anchor.id.toLowerCase();
223
259
  const numId = `#${anchor.id.replace(/^anc-/, "")}`;
@@ -226,21 +262,22 @@ function matchesCommitMessage(anchor, commitMessages) {
226
262
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
227
263
  try {
228
264
  const seg = new Intl.Segmenter("und", { granularity: "word" });
229
- titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2);
265
+ titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
230
266
  } catch {
231
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
267
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
232
268
  }
233
269
  } else {
234
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
270
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
235
271
  }
236
272
  for (const msg of commitMessages) {
237
273
  const msgLower = msg.toLowerCase();
238
- if (msgLower.includes(idLower) || msgLower.includes(numId)) {
274
+ if (msgLower.includes(idLower) || new RegExp(`(?:^|\\s|\\()${numId}(?:\\b|\\)|$)`).test(msgLower)) {
239
275
  return { matched: true, message: msg };
240
276
  }
241
277
  if (titleTokens.length > 0) {
242
278
  const hits = titleTokens.filter((tok) => msgLower.includes(tok)).length;
243
- if (hits >= Math.min(2, titleTokens.length)) {
279
+ const requiredHits = titleTokens.length === 1 ? 1 : 2;
280
+ if (hits >= requiredHits) {
244
281
  return { matched: true, message: msg };
245
282
  }
246
283
  }
@@ -540,9 +577,10 @@ var AnchorStore = class {
540
577
  if (idx === -1) throw new Error(`Note not found: ${id}`);
541
578
  const current = state.anchors[idx];
542
579
  const now = Date.now();
543
- const total = explicitTotal && explicitTotal >= 2 ? explicitTotal : current.progress?.total || 4;
580
+ const safeDelta = Number.isFinite(delta) ? delta : 1;
581
+ const total = explicitTotal && Number.isFinite(explicitTotal) && explicitTotal >= 1 ? explicitTotal : current.progress?.total || 4;
544
582
  const prevDone = current.progress?.done || 0;
545
- const nextDone = Math.max(0, Math.min(total, prevDone + delta));
583
+ const nextDone = Math.max(0, Math.min(total, prevDone + safeDelta));
546
584
  current.progress = {
547
585
  ...current.progress || {},
548
586
  done: nextDone,
@@ -785,15 +823,24 @@ function updateStartupBanner(ctx, store) {
785
823
  const titleClipped = truncateToWidth(a.title, availTitle);
786
824
  return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
787
825
  }
788
- const countTag = fg("accent", `\u2316 ${sorted.length} notes: `);
789
- const segments = sorted.slice(0, 3).map((a) => {
826
+ const maxItems = width < 68 ? 1 : width < 100 ? 2 : 3;
827
+ const visibleSlice = sorted.slice(0, maxItems);
828
+ const countTag = fg("accent", `\u2316 ${sorted.length}: `);
829
+ const moreCount = sorted.length - visibleSlice.length;
830
+ const moreSuffix = moreCount > 0 ? fg("dim", ` +${moreCount}`) : "";
831
+ const perSlotWidth = Math.max(
832
+ 10,
833
+ Math.floor((width - getDisplayWidth(countTag) - getDisplayWidth(moreSuffix) - (visibleSlice.length - 1) * 5) / visibleSlice.length)
834
+ );
835
+ const segments = visibleSlice.map((a) => {
790
836
  const num = a.id.replace(/^anc-/, "");
791
837
  const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
792
- const shortTitle = truncateToWidth(a.title, Math.max(14, Math.floor((width - 20) / Math.min(3, sorted.length)) - 12));
838
+ const prefix = `#${num} ${bar}`;
839
+ const titleBudget = Math.max(6, perSlotWidth - getDisplayWidth(prefix));
840
+ const shortTitle = truncateToWidth(a.title, titleBudget);
793
841
  return `${fg("dim", `#${num}`)} ${bar}${shortTitle}`;
794
842
  });
795
- const more = sorted.length > 3 ? fg("dim", ` +${sorted.length - 3}`) : "";
796
- return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${more}`, width)];
843
+ return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${moreSuffix}`, width)];
797
844
  },
798
845
  invalidate: () => {
799
846
  }
@@ -834,7 +881,7 @@ async function openAnchorDashboard(ctx, store) {
834
881
  if (!anchor) return;
835
882
  const currentSessionFile = ctx.sessionManager?.getSessionFile?.();
836
883
  const canJumpSession = Boolean(
837
- anchor.sessionFile && import_node_fs2.default.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || import_node_path2.default.resolve(anchor.sessionFile) !== import_node_path2.default.resolve(currentSessionFile))
884
+ anchor.sessionFile && import_node_fs2.default.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || !isSameOrSubPath(anchor.sessionFile, currentSessionFile))
838
885
  );
839
886
  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)";
840
887
  const actions = [
@@ -923,11 +970,14 @@ function parseAnchorCommandInput(raw) {
923
970
  };
924
971
  }
925
972
  function parseQuickNoteInput(text) {
926
- const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
927
- return m && m[1].trim() ? m[1].trim() : null;
973
+ const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.*)$/i);
974
+ if (!m) return null;
975
+ return m[1].trim();
928
976
  }
929
977
  function resolveAnchorTarget(store, idOrIndex, cwd) {
930
978
  const clean = idOrIndex.trim().replace(/^#/, "");
979
+ const direct = store.get(clean);
980
+ if (direct) return direct.id;
931
981
  if (/^\d+$/.test(clean)) {
932
982
  const seqIndex = parseInt(clean, 10);
933
983
  const visible = store.list({ cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
@@ -936,7 +986,7 @@ function resolveAnchorTarget(store, idOrIndex, cwd) {
936
986
  return sorted[seqIndex - 1].id;
937
987
  }
938
988
  }
939
- return store.get(clean)?.id;
989
+ return void 0;
940
990
  }
941
991
  function getSessionFileSafe(ctx) {
942
992
  try {
@@ -985,7 +1035,13 @@ function extension_default(pi) {
985
1035
  pi.on("input", async (event, ctx) => {
986
1036
  if (event?.source === "extension") return { action: "continue" };
987
1037
  const quickNote = parseQuickNoteInput(String(event?.text || ""));
988
- if (!quickNote) return { action: "continue" };
1038
+ if (quickNote === null) return { action: "continue" };
1039
+ if (!quickNote) {
1040
+ if (hasActiveUI(ctx)) {
1041
+ await openAnchorDashboard(ctx, store);
1042
+ }
1043
+ return { action: "handled" };
1044
+ }
989
1045
  const anc = createStickyNoteFromUser(quickNote, ctx);
990
1046
  const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : "";
991
1047
  if (hasActiveUI(ctx)) {
@@ -1237,7 +1293,7 @@ ${snippet}` };
1237
1293
  return;
1238
1294
  }
1239
1295
  const rawTrimmed = (args || "").trim();
1240
- if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
1296
+ if (/^(?:#?anc-\d+|#?\d+)$/i.test(rawTrimmed)) {
1241
1297
  const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
1242
1298
  if (resolvedId) {
1243
1299
  const settled = store.settle(resolvedId, { settledBy: "manual" });
@@ -1,2 +1,2 @@
1
1
  import '@earendil-works/pi-coding-agent';
2
- export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-Cq1NH1jT.cjs';
2
+ export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-CvTgSv1L.cjs';
@@ -1,2 +1,2 @@
1
1
  import '@earendil-works/pi-coding-agent';
2
- export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-Cq1NH1jT.js';
2
+ export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-CvTgSv1L.js';
package/dist/extension.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  parseAnchorCommandInput,
4
4
  parseQuickNoteInput,
5
5
  resolveAnchorTarget
6
- } from "./chunk-VXEQDTK4.js";
6
+ } from "./chunk-XL3YH2AF.js";
7
7
  export {
8
8
  extension_default as default,
9
9
  parseAnchorCommandInput,
package/dist/index.cjs CHANGED
@@ -130,10 +130,10 @@ function extractProgressFromText(rawTitle) {
130
130
  progress: { done: 0, total: steps.length, steps }
131
131
  };
132
132
  }
133
- const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)|(?:^|\s)(\d{1,3})\/(\d{1,3})(?=\s|$))/);
133
+ const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\))/);
134
134
  if (ratioMatch) {
135
- const done = parseInt(ratioMatch[1] ?? ratioMatch[3] ?? ratioMatch[5], 10);
136
- const total = parseInt(ratioMatch[2] ?? ratioMatch[4] ?? ratioMatch[6], 10);
135
+ const done = parseInt(ratioMatch[1] ?? ratioMatch[3], 10);
136
+ const total = parseInt(ratioMatch[2] ?? ratioMatch[4], 10);
137
137
  if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
138
138
  const stripped = rawTitle.replace(ratioMatch[0], " ").replace(/\s+/g, " ").trim();
139
139
  return {
@@ -155,7 +155,8 @@ function isAnchorTargetingWorkspace(anchor, targetCwd) {
155
155
  }
156
156
  if (anchor.files && anchor.files.length > 0) {
157
157
  for (const f of anchor.files) {
158
- if (isSameOrSubPath(f, normTarget)) return true;
158
+ const resolvedFile = !import_node_path.default.isAbsolute(f) && !/^[a-zA-Z]:\//.test(f) && anchor.cwd ? `${anchor.cwd.replace(/\\/g, "/").replace(/\/+$/, "")}/${f.replace(/^\/+/, "")}` : f;
159
+ if (isSameOrSubPath(resolvedFile, normTarget)) return true;
159
160
  }
160
161
  }
161
162
  if (anchor.cwd && isSameOrSubPath(anchor.cwd, normTarget)) {
@@ -245,6 +246,41 @@ function makeSafeTaskAnnotation(filePath, anchor) {
245
246
  const num = anchor.id.replace(/^anc-/, "");
246
247
  return "\n\n" + fmt(`\u2316 Note #${num}: ${anchor.title}`);
247
248
  }
249
+ var COMMIT_STOP_WORDS = /* @__PURE__ */ new Set([
250
+ "fix",
251
+ "feat",
252
+ "chore",
253
+ "docs",
254
+ "test",
255
+ "tests",
256
+ "refactor",
257
+ "style",
258
+ "perf",
259
+ "build",
260
+ "ci",
261
+ "bug",
262
+ "issue",
263
+ "update",
264
+ "add",
265
+ "remove",
266
+ "todo",
267
+ "note",
268
+ "task",
269
+ "the",
270
+ "and",
271
+ "for",
272
+ "with",
273
+ "\u4FEE\u590D",
274
+ "\u4F18\u5316",
275
+ "\u66F4\u65B0",
276
+ "\u6DFB\u52A0",
277
+ "\u4FEE\u6539",
278
+ "\u5B8C\u6210",
279
+ "\u95EE\u9898",
280
+ "\u4EFB\u52A1",
281
+ "\u6A21\u5757",
282
+ "\u529F\u80FD"
283
+ ]);
248
284
  function matchesCommitMessage(anchor, commitMessages) {
249
285
  const idLower = anchor.id.toLowerCase();
250
286
  const numId = `#${anchor.id.replace(/^anc-/, "")}`;
@@ -253,21 +289,22 @@ function matchesCommitMessage(anchor, commitMessages) {
253
289
  if (typeof Intl !== "undefined" && Intl.Segmenter) {
254
290
  try {
255
291
  const seg = new Intl.Segmenter("und", { granularity: "word" });
256
- titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2);
292
+ titleTokens = Array.from(seg.segment(title)).filter((s) => s.isWordLike).map((s) => s.segment.trim().toLowerCase()).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
257
293
  } catch {
258
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
294
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
259
295
  }
260
296
  } else {
261
- titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2);
297
+ titleTokens = title.split(/[\s,._\-\/]+/).filter((t) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
262
298
  }
263
299
  for (const msg of commitMessages) {
264
300
  const msgLower = msg.toLowerCase();
265
- if (msgLower.includes(idLower) || msgLower.includes(numId)) {
301
+ if (msgLower.includes(idLower) || new RegExp(`(?:^|\\s|\\()${numId}(?:\\b|\\)|$)`).test(msgLower)) {
266
302
  return { matched: true, message: msg };
267
303
  }
268
304
  if (titleTokens.length > 0) {
269
305
  const hits = titleTokens.filter((tok) => msgLower.includes(tok)).length;
270
- if (hits >= Math.min(2, titleTokens.length)) {
306
+ const requiredHits = titleTokens.length === 1 ? 1 : 2;
307
+ if (hits >= requiredHits) {
271
308
  return { matched: true, message: msg };
272
309
  }
273
310
  }
@@ -568,9 +605,10 @@ var AnchorStore = class {
568
605
  if (idx === -1) throw new Error(`Note not found: ${id}`);
569
606
  const current = state.anchors[idx];
570
607
  const now = Date.now();
571
- const total = explicitTotal && explicitTotal >= 2 ? explicitTotal : current.progress?.total || 4;
608
+ const safeDelta = Number.isFinite(delta) ? delta : 1;
609
+ const total = explicitTotal && Number.isFinite(explicitTotal) && explicitTotal >= 1 ? explicitTotal : current.progress?.total || 4;
572
610
  const prevDone = current.progress?.done || 0;
573
- const nextDone = Math.max(0, Math.min(total, prevDone + delta));
611
+ const nextDone = Math.max(0, Math.min(total, prevDone + safeDelta));
574
612
  current.progress = {
575
613
  ...current.progress || {},
576
614
  done: nextDone,
@@ -813,15 +851,24 @@ function updateStartupBanner(ctx, store) {
813
851
  const titleClipped = truncateToWidth(a.title, availTitle);
814
852
  return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
815
853
  }
816
- const countTag = fg("accent", `\u2316 ${sorted.length} notes: `);
817
- const segments = sorted.slice(0, 3).map((a) => {
854
+ const maxItems = width < 68 ? 1 : width < 100 ? 2 : 3;
855
+ const visibleSlice = sorted.slice(0, maxItems);
856
+ const countTag = fg("accent", `\u2316 ${sorted.length}: `);
857
+ const moreCount = sorted.length - visibleSlice.length;
858
+ const moreSuffix = moreCount > 0 ? fg("dim", ` +${moreCount}`) : "";
859
+ const perSlotWidth = Math.max(
860
+ 10,
861
+ Math.floor((width - getDisplayWidth(countTag) - getDisplayWidth(moreSuffix) - (visibleSlice.length - 1) * 5) / visibleSlice.length)
862
+ );
863
+ const segments = visibleSlice.map((a) => {
818
864
  const num = a.id.replace(/^anc-/, "");
819
865
  const bar = a.progress ? `${formatProgressBar(a.progress)} ` : "";
820
- const shortTitle = truncateToWidth(a.title, Math.max(14, Math.floor((width - 20) / Math.min(3, sorted.length)) - 12));
866
+ const prefix = `#${num} ${bar}`;
867
+ const titleBudget = Math.max(6, perSlotWidth - getDisplayWidth(prefix));
868
+ const shortTitle = truncateToWidth(a.title, titleBudget);
821
869
  return `${fg("dim", `#${num}`)} ${bar}${shortTitle}`;
822
870
  });
823
- const more = sorted.length > 3 ? fg("dim", ` +${sorted.length - 3}`) : "";
824
- return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${more}`, width)];
871
+ return [truncateToWidth(`${countTag}${segments.join(fg("dim", " \xB7 "))}${moreSuffix}`, width)];
825
872
  },
826
873
  invalidate: () => {
827
874
  }
@@ -862,7 +909,7 @@ async function openAnchorDashboard(ctx, store) {
862
909
  if (!anchor) return;
863
910
  const currentSessionFile = ctx.sessionManager?.getSessionFile?.();
864
911
  const canJumpSession = Boolean(
865
- anchor.sessionFile && import_node_fs2.default.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || import_node_path2.default.resolve(anchor.sessionFile) !== import_node_path2.default.resolve(currentSessionFile))
912
+ anchor.sessionFile && import_node_fs2.default.existsSync(anchor.sessionFile) && typeof ctx.switchSession === "function" && (!currentSessionFile || !isSameOrSubPath(anchor.sessionFile, currentSessionFile))
866
913
  );
867
914
  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)";
868
915
  const actions = [
@@ -952,11 +999,14 @@ function parseAnchorCommandInput(raw) {
952
999
  };
953
1000
  }
954
1001
  function parseQuickNoteInput(text) {
955
- const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
956
- return m && m[1].trim() ? m[1].trim() : null;
1002
+ const m = (text || "").trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.*)$/i);
1003
+ if (!m) return null;
1004
+ return m[1].trim();
957
1005
  }
958
1006
  function resolveAnchorTarget(store, idOrIndex, cwd) {
959
1007
  const clean = idOrIndex.trim().replace(/^#/, "");
1008
+ const direct = store.get(clean);
1009
+ if (direct) return direct.id;
960
1010
  if (/^\d+$/.test(clean)) {
961
1011
  const seqIndex = parseInt(clean, 10);
962
1012
  const visible = store.list({ cwd, all: true }).filter((a) => a.status === "active" || a.status === "sleeping");
@@ -965,7 +1015,7 @@ function resolveAnchorTarget(store, idOrIndex, cwd) {
965
1015
  return sorted[seqIndex - 1].id;
966
1016
  }
967
1017
  }
968
- return store.get(clean)?.id;
1018
+ return void 0;
969
1019
  }
970
1020
  // Annotate the CommonJS export names for ESM import in node:
971
1021
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as Anchor, a as AnchorProgress, b as AnchorStore } from './extension-Cq1NH1jT.cjs';
2
- export { c as AnchorEvidence, d as AnchorPriority, e as AnchorStatus, f as AnchorStoreState, C as COMMENT_FORMATS, M as MS_PER_DAY, Q as QUIET_AFTER_DAYS, T as TouchMatchResult, g as acquireSyncLock, h as atomicRenameWithRetry, i as extractPathsFromText, j as extractProgressFromText, k as findMatchedAnchors, l as getDefaultStorageDir, m as globToRegExp, n as isAnchorTargetingWorkspace, o as isSameOrSubPath, p as makeSafeTaskAnnotation, q as matchAnchorAgainstTouchedFiles, r as matchesCommitMessage, s as normalizePath, t as parseAnchorCommandInput, u as parseQuickNoteInput, v as renderActiveAnchorsContext, w as renderColdStartAnchorsContext, x as resolveAnchorTarget, y as sweepQuietNotes } from './extension-Cq1NH1jT.cjs';
1
+ import { A as Anchor, a as AnchorProgress, b as AnchorStore } from './extension-CvTgSv1L.cjs';
2
+ export { c as AnchorEvidence, d as AnchorPriority, e as AnchorStatus, f as AnchorStoreState, C as COMMENT_FORMATS, M as MS_PER_DAY, Q as QUIET_AFTER_DAYS, T as TouchMatchResult, g as acquireSyncLock, h as atomicRenameWithRetry, i as extractPathsFromText, j as extractProgressFromText, k as findMatchedAnchors, l as getDefaultStorageDir, m as globToRegExp, n as isAnchorTargetingWorkspace, o as isSameOrSubPath, p as makeSafeTaskAnnotation, q as matchAnchorAgainstTouchedFiles, r as matchesCommitMessage, s as normalizePath, t as parseAnchorCommandInput, u as parseQuickNoteInput, v as renderActiveAnchorsContext, w as renderColdStartAnchorsContext, x as resolveAnchorTarget, y as sweepQuietNotes } from './extension-CvTgSv1L.cjs';
3
3
  import { ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
4
4
 
5
5
  declare function stripAnsi(str: string): string;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as Anchor, a as AnchorProgress, b as AnchorStore } from './extension-Cq1NH1jT.js';
2
- export { c as AnchorEvidence, d as AnchorPriority, e as AnchorStatus, f as AnchorStoreState, C as COMMENT_FORMATS, M as MS_PER_DAY, Q as QUIET_AFTER_DAYS, T as TouchMatchResult, g as acquireSyncLock, h as atomicRenameWithRetry, i as extractPathsFromText, j as extractProgressFromText, k as findMatchedAnchors, l as getDefaultStorageDir, m as globToRegExp, n as isAnchorTargetingWorkspace, o as isSameOrSubPath, p as makeSafeTaskAnnotation, q as matchAnchorAgainstTouchedFiles, r as matchesCommitMessage, s as normalizePath, t as parseAnchorCommandInput, u as parseQuickNoteInput, v as renderActiveAnchorsContext, w as renderColdStartAnchorsContext, x as resolveAnchorTarget, y as sweepQuietNotes } from './extension-Cq1NH1jT.js';
1
+ import { A as Anchor, a as AnchorProgress, b as AnchorStore } from './extension-CvTgSv1L.js';
2
+ export { c as AnchorEvidence, d as AnchorPriority, e as AnchorStatus, f as AnchorStoreState, C as COMMENT_FORMATS, M as MS_PER_DAY, Q as QUIET_AFTER_DAYS, T as TouchMatchResult, g as acquireSyncLock, h as atomicRenameWithRetry, i as extractPathsFromText, j as extractProgressFromText, k as findMatchedAnchors, l as getDefaultStorageDir, m as globToRegExp, n as isAnchorTargetingWorkspace, o as isSameOrSubPath, p as makeSafeTaskAnnotation, q as matchAnchorAgainstTouchedFiles, r as matchesCommitMessage, s as normalizePath, t as parseAnchorCommandInput, u as parseQuickNoteInput, v as renderActiveAnchorsContext, w as renderColdStartAnchorsContext, x as resolveAnchorTarget, y as sweepQuietNotes } from './extension-CvTgSv1L.js';
3
3
  import { ExtensionContext, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
4
4
 
5
5
  declare function stripAnsi(str: string): string;
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  truncateToWidth,
35
35
  updateAnchorStatusBar,
36
36
  updateStartupBanner
37
- } from "./chunk-VXEQDTK4.js";
37
+ } from "./chunk-XL3YH2AF.js";
38
38
  export {
39
39
  AnchorStore,
40
40
  COMMENT_FORMATS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-anchor",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A digital sticky note docked below your Pi editor — stick notes in 0ms (pin: ...), track long tasks with micro progress bars [■■□□], jump back to the original session (↪), and auto-tear off on git commit.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/extension.ts CHANGED
@@ -71,14 +71,25 @@ export function parseAnchorCommandInput(raw: string): {
71
71
  /**
72
72
  * Detect 0ms human-first quick note prefix in the input box:
73
73
  * e.g. `pin: 明天把Token改Cookie` or `便签: 检查网关重试 [1/4]`
74
+ * Returns `{ matched: true, note: '...' }` (or `note: ''` if user typed bare `pin:`)
74
75
  */
75
76
  export function parseQuickNoteInput(text: string): string | null {
76
- const m = (text || '').trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.+)$/i);
77
- return m && m[1].trim() ? m[1].trim() : null;
77
+ const m = (text || '').trim().match(/^(?:pin|note|便签|备忘)\s*[::]\s*(.*)$/i);
78
+ if (!m) return null;
79
+ return m[1].trim();
78
80
  }
79
81
 
82
+ /**
83
+ * Resolve note target by exact ID first (`#2` -> `anc-2`), falling back to 1-based display row index
84
+ * only when no note with that ID exists. Prevents settling the wrong note when sort order differs from ID order!
85
+ */
80
86
  export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?: string): string | undefined {
81
87
  const clean = idOrIndex.trim().replace(/^#/, '');
88
+ // 1. Exact ID match first (matches #num shown in TUI and CLI!)
89
+ const direct = store.get(clean);
90
+ if (direct) return direct.id;
91
+
92
+ // 2. Fallback to 1-based display row index
82
93
  if (/^\d+$/.test(clean)) {
83
94
  const seqIndex = parseInt(clean, 10);
84
95
  const visible = store
@@ -89,7 +100,7 @@ export function resolveAnchorTarget(store: AnchorStore, idOrIndex: string, cwd?:
89
100
  return sorted[seqIndex - 1].id;
90
101
  }
91
102
  }
92
- return store.get(clean)?.id;
103
+ return undefined;
93
104
  }
94
105
 
95
106
  function getSessionFileSafe(ctx?: ExtensionContext): string | undefined {
@@ -152,7 +163,14 @@ export default function (pi: ExtensionAPI) {
152
163
  pi.on('input', async (event: any, ctx: ExtensionContext) => {
153
164
  if (event?.source === 'extension') return { action: 'continue' };
154
165
  const quickNote = parseQuickNoteInput(String(event?.text || ''));
155
- if (!quickNote) return { action: 'continue' };
166
+ if (quickNote === null) return { action: 'continue' };
167
+
168
+ if (!quickNote) {
169
+ if (hasActiveUI(ctx)) {
170
+ await openAnchorDashboard(ctx, store);
171
+ }
172
+ return { action: 'handled' };
173
+ }
156
174
 
157
175
  const anc = createStickyNoteFromUser(quickNote, ctx);
158
176
  const bar = anc.progress ? ` ${formatProgressBar(anc.progress)}` : '';
@@ -433,7 +451,7 @@ export default function (pi: ExtensionAPI) {
433
451
  }
434
452
 
435
453
  const rawTrimmed = (args || '').trim();
436
- if (/^(?:#?anc-\d+|#\d+)$/i.test(rawTrimmed)) {
454
+ if (/^(?:#?anc-\d+|#?\d+)$/i.test(rawTrimmed)) {
437
455
  const resolvedId = resolveAnchorTarget(store, rawTrimmed, ctx.cwd);
438
456
  if (resolvedId) {
439
457
  const settled = store.settle(resolvedId, { settledBy: 'manual' });
package/src/store.ts CHANGED
@@ -101,11 +101,11 @@ export function extractProgressFromText(rawTitle: string): { cleanTitle: string;
101
101
  };
102
102
  }
103
103
 
104
- // 2. Check explicit ratio tag: "[2/5]", "(0/4)", or trailing/standalone "5/30"
105
- const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)|(?:^|\s)(\d{1,3})\/(\d{1,3})(?=\s|$))/);
104
+ // 2. Check explicit bracketed ratio tag: "[2/5]" or "(0/4)" (requires brackets so dates like 10/12 are never hijacked)
105
+ const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\))/);
106
106
  if (ratioMatch) {
107
- const done = parseInt(ratioMatch[1] ?? ratioMatch[3] ?? ratioMatch[5], 10);
108
- const total = parseInt(ratioMatch[2] ?? ratioMatch[4] ?? ratioMatch[6], 10);
107
+ const done = parseInt(ratioMatch[1] ?? ratioMatch[3], 10);
108
+ const total = parseInt(ratioMatch[2] ?? ratioMatch[4], 10);
109
109
  if (!isNaN(done) && !isNaN(total) && total >= 2 && total <= 500 && done >= 0 && done <= total) {
110
110
  const stripped = rawTitle
111
111
  .replace(ratioMatch[0], ' ')
@@ -137,7 +137,10 @@ export function isAnchorTargetingWorkspace(anchor: Anchor, targetCwd?: string):
137
137
 
138
138
  if (anchor.files && anchor.files.length > 0) {
139
139
  for (const f of anchor.files) {
140
- if (isSameOrSubPath(f, normTarget)) return true;
140
+ const resolvedFile = (!path.isAbsolute(f) && !/^[a-zA-Z]:\//.test(f) && anchor.cwd)
141
+ ? `${anchor.cwd.replace(/\\/g, '/').replace(/\/+$/, '')}/${f.replace(/^\/+/, '')}`
142
+ : f;
143
+ if (isSameOrSubPath(resolvedFile, normTarget)) return true;
141
144
  }
142
145
  }
143
146
 
@@ -221,6 +224,12 @@ export function makeSafeTaskAnnotation(filePath: string, anchor: Anchor): string
221
224
  return '\n\n' + fmt(`⌖ Note #${num}: ${anchor.title}`);
222
225
  }
223
226
 
227
+ const COMMIT_STOP_WORDS = new Set([
228
+ 'fix', 'feat', 'chore', 'docs', 'test', 'tests', 'refactor', 'style', 'perf', 'build', 'ci',
229
+ 'bug', 'issue', 'update', 'add', 'remove', 'todo', 'note', 'task', 'the', 'and', 'for', 'with',
230
+ '修复', '优化', '更新', '添加', '修改', '完成', '问题', '任务', '模块', '功能'
231
+ ]);
232
+
224
233
  /**
225
234
  * Clever Touch 3: Match a `git commit -m "..."` message against a sticky note (supports CJK & English)
226
235
  */
@@ -236,22 +245,23 @@ export function matchesCommitMessage(anchor: Anchor, commitMessages: string[]):
236
245
  titleTokens = Array.from(seg.segment(title))
237
246
  .filter((s: any) => s.isWordLike)
238
247
  .map((s: any) => s.segment.trim().toLowerCase())
239
- .filter((t: string) => t.length >= 2);
248
+ .filter((t: string) => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
240
249
  } catch {
241
- titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
250
+ titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
242
251
  }
243
252
  } else {
244
- titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2);
253
+ titleTokens = title.split(/[\s,._\-\/]+/).filter(t => t.length >= 2 && !COMMIT_STOP_WORDS.has(t));
245
254
  }
246
255
 
247
256
  for (const msg of commitMessages) {
248
257
  const msgLower = msg.toLowerCase();
249
- if (msgLower.includes(idLower) || msgLower.includes(numId)) {
258
+ if (msgLower.includes(idLower) || new RegExp(`(?:^|\\s|\\()${numId}(?:\\b|\\)|$)`).test(msgLower)) {
250
259
  return { matched: true, message: msg };
251
260
  }
252
261
  if (titleTokens.length > 0) {
253
262
  const hits = titleTokens.filter(tok => msgLower.includes(tok)).length;
254
- if (hits >= Math.min(2, titleTokens.length)) {
263
+ const requiredHits = titleTokens.length === 1 ? 1 : 2;
264
+ if (hits >= requiredHits) {
255
265
  return { matched: true, message: msg };
256
266
  }
257
267
  }
@@ -615,11 +625,12 @@ export class AnchorStore {
615
625
 
616
626
  const current = state.anchors[idx];
617
627
  const now = Date.now();
618
- const total = explicitTotal && explicitTotal >= 2
628
+ const safeDelta = Number.isFinite(delta) ? delta : 1;
629
+ const total = explicitTotal && Number.isFinite(explicitTotal) && explicitTotal >= 1
619
630
  ? explicitTotal
620
631
  : (current.progress?.total || 4);
621
632
  const prevDone = current.progress?.done || 0;
622
- const nextDone = Math.max(0, Math.min(total, prevDone + delta));
633
+ const nextDone = Math.max(0, Math.min(total, prevDone + safeDelta));
623
634
 
624
635
  current.progress = {
625
636
  ...(current.progress || {}),
package/src/tui.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionCommandContext, ExtensionContext } from '@earendil-works/pi-coding-agent';
2
- import { AnchorStore, isAnchorTargetingWorkspace } from './store.ts';
2
+ import { AnchorStore, isAnchorTargetingWorkspace, isSameOrSubPath } from './store.ts';
3
3
  import type { Anchor, AnchorProgress } from './types.ts';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
@@ -238,16 +238,26 @@ export function updateStartupBanner(ctx: ExtensionContext, store: AnchorStore):
238
238
  return [truncateToWidth(`${prefix}${titleClipped}${nextStep}${suffix}`, width)];
239
239
  }
240
240
 
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 => {
241
+ // Multiple notes: adaptive horizontal sticky strip (fits narrow 50-col and wide terminals cleanly)
242
+ const maxItems = width < 68 ? 1 : (width < 100 ? 2 : 3);
243
+ const visibleSlice = sorted.slice(0, maxItems);
244
+ const countTag = fg('accent', `⌖ ${sorted.length}: `);
245
+ const moreCount = sorted.length - visibleSlice.length;
246
+ const moreSuffix = moreCount > 0 ? fg('dim', ` +${moreCount}`) : '';
247
+ const perSlotWidth = Math.max(
248
+ 10,
249
+ Math.floor((width - getDisplayWidth(countTag) - getDisplayWidth(moreSuffix) - (visibleSlice.length - 1) * 5) / visibleSlice.length)
250
+ );
251
+
252
+ const segments = visibleSlice.map(a => {
244
253
  const num = a.id.replace(/^anc-/, '');
245
254
  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));
255
+ const prefix = `#${num} ${bar}`;
256
+ const titleBudget = Math.max(6, perSlotWidth - getDisplayWidth(prefix));
257
+ const shortTitle = truncateToWidth(a.title, titleBudget);
247
258
  return `${fg('dim', `#${num}`)} ${bar}${shortTitle}`;
248
259
  });
249
- const more = sorted.length > 3 ? fg('dim', ` +${sorted.length - 3}`) : '';
250
- return [truncateToWidth(`${countTag}${segments.join(fg('dim', ' · '))}${more}`, width)];
260
+ return [truncateToWidth(`${countTag}${segments.join(fg('dim', ' · '))}${moreSuffix}`, width)];
251
261
  },
252
262
  invalidate: () => {}
253
263
  });
@@ -313,7 +323,7 @@ export async function openAnchorDashboard(
313
323
  anchor.sessionFile &&
314
324
  fs.existsSync(anchor.sessionFile) &&
315
325
  typeof (ctx as ExtensionCommandContext).switchSession === 'function' &&
316
- (!currentSessionFile || path.resolve(anchor.sessionFile) !== path.resolve(currentSessionFile))
326
+ (!currentSessionFile || !isSameOrSubPath(anchor.sessionFile, currentSessionFile))
317
327
  );
318
328
 
319
329
  const stepLabel = anchor.progress