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 +77 -31
- package/README_zh.md +84 -33
- package/bin/anchor.js +16 -16
- package/dist/{chunk-VXEQDTK4.js → chunk-XL3YH2AF.js} +78 -22
- package/dist/{extension-Cq1NH1jT.d.cts → extension-CvTgSv1L.d.cts} +5 -0
- package/dist/{extension-Cq1NH1jT.d.ts → extension-CvTgSv1L.d.ts} +5 -0
- package/dist/extension.cjs +78 -22
- package/dist/extension.d.cts +1 -1
- package/dist/extension.d.ts +1 -1
- package/dist/extension.js +1 -1
- package/dist/index.cjs +70 -20
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/extension.ts +23 -5
- package/src/store.ts +23 -12
- package/src/tui.ts +18 -8
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# ⌖ Anchor
|
|
2
2
|
|
|
3
|
-
> **A digital sticky note docked below your Pi editor —
|
|
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
|
[](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml)
|
|
7
6
|
[](package.json)
|
|
8
7
|
[](tsconfig.json)
|
|
9
8
|
[](tests/store.test.ts)
|
|
9
|
+
[](package.json)
|
|
10
10
|
[](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
|
|
19
|
+
<img src="assets/hero-en.svg" alt="Anchor Below-Editor Sticky Strip" width="820">
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
```text
|
|
23
|
-
|
|
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
|
-
##
|
|
32
|
+
## Core Value
|
|
32
33
|
|
|
33
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
40
|
-
- **
|
|
41
|
-
- **0ms Human
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
55
|
-
|
|
56
|
-
|
|
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 |
|
|
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,
|
|
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
|
|
70
|
-
| **
|
|
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
|
[](https://github.com/3ZEROS12/anchor/actions/workflows/ci.yml)
|
|
7
6
|
[](package.json)
|
|
8
7
|
[](tsconfig.json)
|
|
9
8
|
[](tests/store.test.ts)
|
|
9
|
+
[](package.json)
|
|
10
10
|
[](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
|
|
19
|
+
<img src="assets/hero-zh.svg" alt="Anchor 输入框下方常驻便签条预览" width="820">
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
```text
|
|
23
|
-
|
|
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
|
-
|
|
34
|
-
* **绝不跟上方插件抢地盘**:便签条挂载在输入框**正下方 (`belowEditor`)**,把输入框上方 (`aboveEditor`) 完全留给 `pi-lingual` 等高频伴学卡片,上下分工井然有序。
|
|
35
|
-
* **真·便签常驻视野**:有便签时在输入框下沿安静显示 1 行,不再像以前那样“刚发第一句话就神秘消失”;无便签时 0 行完全隐形。
|
|
36
|
-
* **自己随手写,0ms 上屏**:直接在输入框敲 `便签: 明天检查网关重试`(或 `pin: ...`、`/pin ...`),**0.00ms 当场拦截贴上便签条**,不用苦等大模型转圈调工具,不花 1 个 Token,还会自动关联你当前正在改的代码文件。
|
|
34
|
+
深夜写代码查问题时,你经常顺眼看到另一个文件里的隐患,或者手头的长线重构今天只做了一半。传统的记录方式总有三处具体的别扭:
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
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
|
|
34
|
-
const num =
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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})\)
|
|
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]
|
|
72
|
-
const total = parseInt(ratioMatch[2] ?? ratioMatch[4]
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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
|
|
753
|
-
const
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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*(
|
|
891
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
package/dist/extension.cjs
CHANGED
|
@@ -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})\)
|
|
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]
|
|
109
|
-
const total = parseInt(ratioMatch[2] ?? ratioMatch[4]
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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
|
|
789
|
-
const
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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*(
|
|
927
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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" });
|
package/dist/extension.d.cts
CHANGED
|
@@ -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-
|
|
2
|
+
export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-CvTgSv1L.cjs';
|
package/dist/extension.d.ts
CHANGED
|
@@ -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-
|
|
2
|
+
export { z as default, t as parseAnchorCommandInput, u as parseQuickNoteInput, x as resolveAnchorTarget } from './extension-CvTgSv1L.js';
|
package/dist/extension.js
CHANGED
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})\)
|
|
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]
|
|
136
|
-
const total = parseInt(ratioMatch[2] ?? ratioMatch[4]
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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
|
|
817
|
-
const
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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*(
|
|
956
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-anchor",
|
|
3
|
-
"version": "0.5.
|
|
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*(
|
|
77
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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]"
|
|
105
|
-
const ratioMatch = rawTitle.match(/(?:\[(\d{1,3})\/(\d{1,3})\]|\((\d{1,3})\/(\d{1,3})\)
|
|
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]
|
|
108
|
-
const total = parseInt(ratioMatch[2] ?? ratioMatch[4]
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
|
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 +
|
|
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:
|
|
242
|
-
const
|
|
243
|
-
const
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
326
|
+
(!currentSessionFile || !isSameOrSubPath(anchor.sessionFile, currentSessionFile))
|
|
317
327
|
);
|
|
318
328
|
|
|
319
329
|
const stepLabel = anchor.progress
|