@zhuxixi/pi-agent-board 0.3.1 → 0.4.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/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +916 -0
- package/docs/superpowers/plans/2026-08-22-question-tool-grouping.md +211 -0
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +131 -0
- package/docs/superpowers/specs/2026-08-22-question-tool-grouping-design.md +114 -0
- package/package.json +1 -1
- package/src/commands/agent-board.ts +1 -0
- package/src/core/cwd-stats.mjs +112 -0
- package/src/core/events.mjs +7 -3
- package/src/core/launch-options.mjs +47 -0
- package/src/core/paths.mjs +2 -0
- package/src/runtime/service.mjs +4 -0
- package/src/ui/dashboard.ts +62 -13
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
# Launch cwd 常用目录榜 Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** 在 launch 对话框的 cwd 选择器里加入按使用频率排名的候选目录榜(空输入显示 Top 8,输入做大小写不敏感子串匹配,tab 补全完整路径,无匹配回落文件系统浏览)。
|
|
6
|
+
|
|
7
|
+
**Architecture:** 新增纯数据模块 `src/core/cwd-stats.mjs`(持久统计 + 排名,seed 自现有 view),匹配与选择器状态决策放 `src/core/launch-options.mjs`(纯函数,可单测),`src/ui/dashboard.ts` 只做接线与渲染,`src/commands/agent-board.ts` 把 store root 传入 DashboardDeps。
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node.js ESM(纯 node 无 Pi 依赖)、node:test、pi-tui 的 `Key`/`matchesKey`、现有 `atomic.mjs` 原子写与 `store.mjs` 容错读。
|
|
10
|
+
|
|
11
|
+
## Global Constraints
|
|
12
|
+
|
|
13
|
+
- 实现全部在 worktree `/home/elling/git-repo/github/pi-agent-board/.pi/worktrees/issue-22-launch-cwd-favorites` 内完成并提交;**禁止**写 main 工作区(`/home/elling/git-repo/github/pi-agent-board`)。
|
|
14
|
+
- Worktree 的 `node_modules` 是指向主仓库的 symlink,已就绪;测试与 typecheck 命令都在 worktree 目录下执行。
|
|
15
|
+
- 所有 cwd 统计写入走 `src/core/atomic.mjs` 的 `atomicWriteJson`(temp + rename);读取走 `readJson` 容错,缺失/损坏一律按空表,**永不 throw 到 UI**。
|
|
16
|
+
- 删除 view 不减计数;seed 仅在 `cwd-stats.json` 不存在时执行(幂等)。
|
|
17
|
+
- 候选匹配 = 大小写不敏感子串、路径任意部分;不做 fuzzy subsequence(spec Non-goal)。
|
|
18
|
+
- tab = 把高亮项完整路径补全进输入框(picker 保持打开),回车最终确认;esc 关闭 picker。
|
|
19
|
+
- home 目录永远在候选榜兜底(不在榜内时 count 0 排最后,不重复)。
|
|
20
|
+
- 保留现有 type-to-jump:launch 主对话框 cwd 字段上直接打字进入 picker 时,query = 已输入字符。
|
|
21
|
+
- `git add <file>` 按文件 stage,**禁止 `git add -A`**;commit message 用 conventional commits(`feat:`/`test:`/`refactor:`)。
|
|
22
|
+
- 验证命令(均在 worktree 内):`npm test`(= `node --test test/*.test.mjs`)、`npm run typecheck`(= `tsc --noEmit`)。
|
|
23
|
+
- 文件顶部注释与 commit 用英文;UI 文案保持设计里的中文提示行。
|
|
24
|
+
|
|
25
|
+
## File Structure
|
|
26
|
+
|
|
27
|
+
| 文件 | 责任 |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| Create `src/core/cwd-stats.mjs` | cwd 使用统计持久层:read/seed/record/rank,纯 node |
|
|
30
|
+
| Modify `src/core/paths.mjs` | 加 `cwdStatsPath(root)` 路径函数 |
|
|
31
|
+
| Create `test/cwd-stats.test.mjs` | 统计模块单测(tmp dir 作 root) |
|
|
32
|
+
| Modify `src/core/launch-options.mjs` | 加纯函数 `filterCwdCandidates` / `nextCwdPickerState` |
|
|
33
|
+
| Create `test/launch-options.test.mjs` | 匹配与选择器状态决策单测 |
|
|
34
|
+
| Modify `src/ui/dashboard.ts` | LaunchState 字段、picker 状态机、tab 补全、seed/record 埋点、渲染 |
|
|
35
|
+
| Modify `src/commands/agent-board.ts` | DashboardDeps 传入 `root` |
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### Task 1: cwd-stats 数据层(TDD)
|
|
40
|
+
|
|
41
|
+
**Files:**
|
|
42
|
+
- Create: `src/core/cwd-stats.mjs`
|
|
43
|
+
- Modify: `src/core/paths.mjs`(在 `gcHistoryPath` 之后加路径函数)
|
|
44
|
+
- Test: `test/cwd-stats.test.mjs`
|
|
45
|
+
|
|
46
|
+
**Interfaces:**
|
|
47
|
+
- Consumes: `atomicWriteJson` / `readJson`(`../core/atomic.mjs`)、`cwdStatsPath`(`../core/paths.mjs`)、`readRoster` / `readMeta`(`../core/store.mjs`);`readMeta(root, viewId)` 返回 `{ id, cwd, updatedAt, ... } | null`,`updatedAt` 为 epoch ms。
|
|
48
|
+
- Produces:
|
|
49
|
+
- `readCwdStats(root)` → `{ version: 1, entries: Record<string, { count: number, lastUsed: number }> }`
|
|
50
|
+
- `seedCwdStatsFromViews(root)` → `void`(仅当 stats 文件不存在时写入)
|
|
51
|
+
- `ensureCwdStatsSeeded(root)` → `void`(文件存在直接返回;失败吞掉)
|
|
52
|
+
- `recordCwdLaunch(root, cwd)` → `void`(cwd 非目录时忽略)
|
|
53
|
+
- `rankedCwdCandidates(root, limit = 8)` → `Array<{ path: string, count: number }>`(count desc、lastUsed desc;home 兜底末尾)
|
|
54
|
+
|
|
55
|
+
- [ ] **Step 1: 写失败测试**
|
|
56
|
+
|
|
57
|
+
创建 `test/cwd-stats.test.mjs`:
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import assert from "node:assert/strict";
|
|
61
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
62
|
+
import * as os from "node:os";
|
|
63
|
+
import { tmpdir } from "node:os";
|
|
64
|
+
import { join } from "node:path";
|
|
65
|
+
import test from "node:test";
|
|
66
|
+
import * as P from "../src/core/paths.mjs";
|
|
67
|
+
import {
|
|
68
|
+
ensureCwdStatsSeeded,
|
|
69
|
+
rankedCwdCandidates,
|
|
70
|
+
readCwdStats,
|
|
71
|
+
recordCwdLaunch,
|
|
72
|
+
seedCwdStatsFromViews,
|
|
73
|
+
} from "../src/core/cwd-stats.mjs";
|
|
74
|
+
|
|
75
|
+
function freshRoot() {
|
|
76
|
+
return mkdtempSync(join(tmpdir(), "cwd-stats-"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @param {string} root @param {Array<[string, string, number]>} views */
|
|
80
|
+
function writeViews(root, views) {
|
|
81
|
+
for (const [viewId, cwd, updatedAt] of views) {
|
|
82
|
+
mkdirSync(join(root, "views", viewId), { recursive: true });
|
|
83
|
+
writeFileSync(P.metaPath(root, viewId), JSON.stringify({ id: viewId, cwd, updatedAt }));
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(P.rosterPath(root), JSON.stringify({ version: 1, views: views.map(([viewId]) => viewId) }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
test("readCwdStats tolerates a missing stats file", () => {
|
|
89
|
+
const root = freshRoot();
|
|
90
|
+
try {
|
|
91
|
+
assert.deepEqual(readCwdStats(root), { version: 1, entries: {} });
|
|
92
|
+
} finally {
|
|
93
|
+
rmSync(root, { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("seedCwdStatsFromViews aggregates counts and max updatedAt per cwd", () => {
|
|
98
|
+
const root = freshRoot();
|
|
99
|
+
try {
|
|
100
|
+
writeViews(root, [
|
|
101
|
+
["v1", "/a", 100],
|
|
102
|
+
["v2", "/a", 300],
|
|
103
|
+
["v3", "/b", 200],
|
|
104
|
+
]);
|
|
105
|
+
seedCwdStatsFromViews(root);
|
|
106
|
+
assert.deepEqual(readCwdStats(root).entries, {
|
|
107
|
+
"/a": { count: 2, lastUsed: 300 },
|
|
108
|
+
"/b": { count: 1, lastUsed: 200 },
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
rmSync(root, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("seedCwdStatsFromViews is a one-time no-op when the file exists", () => {
|
|
116
|
+
const root = freshRoot();
|
|
117
|
+
try {
|
|
118
|
+
writeViews(root, [["v1", "/a", 100]]);
|
|
119
|
+
seedCwdStatsFromViews(root);
|
|
120
|
+
writeViews(root, [
|
|
121
|
+
["v1", "/a", 100],
|
|
122
|
+
["v2", "/b", 200],
|
|
123
|
+
]);
|
|
124
|
+
seedCwdStatsFromViews(root);
|
|
125
|
+
assert.deepEqual(readCwdStats(root).entries, { "/a": { count: 1, lastUsed: 100 } });
|
|
126
|
+
} finally {
|
|
127
|
+
rmSync(root, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("recordCwdLaunch increments count, sets lastUsed, ignores invalid dirs", () => {
|
|
132
|
+
const root = freshRoot();
|
|
133
|
+
try {
|
|
134
|
+
recordCwdLaunch(root, join(root, "nope", "does", "not", "exist"));
|
|
135
|
+
assert.deepEqual(readCwdStats(root).entries, {});
|
|
136
|
+
recordCwdLaunch(root, root);
|
|
137
|
+
let entry = readCwdStats(root).entries[root];
|
|
138
|
+
assert.equal(entry.count, 1);
|
|
139
|
+
const first = entry.lastUsed;
|
|
140
|
+
recordCwdLaunch(root, root);
|
|
141
|
+
entry = readCwdStats(root).entries[root];
|
|
142
|
+
assert.equal(entry.count, 2);
|
|
143
|
+
assert.ok(entry.lastUsed >= first);
|
|
144
|
+
} finally {
|
|
145
|
+
rmSync(root, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("rankedCwdCandidates orders by count desc, lastUsed desc, appends home fallback", () => {
|
|
150
|
+
const root = freshRoot();
|
|
151
|
+
try {
|
|
152
|
+
writeViews(root, [
|
|
153
|
+
["v1", "/rare", 100],
|
|
154
|
+
["v2", "/rare", 200],
|
|
155
|
+
["v3", "/common", 50],
|
|
156
|
+
["v4", "/common", 60],
|
|
157
|
+
["v5", "/common", 70],
|
|
158
|
+
["v6", "/mid", 500],
|
|
159
|
+
["v7", "/mid", 600],
|
|
160
|
+
]);
|
|
161
|
+
seedCwdStatsFromViews(root);
|
|
162
|
+
const ranked = rankedCwdCandidates(root, 8);
|
|
163
|
+
assert.deepEqual(ranked.slice(0, 3), [
|
|
164
|
+
{ path: "/common", count: 3 },
|
|
165
|
+
{ path: "/mid", count: 2 },
|
|
166
|
+
{ path: "/rare", count: 2 },
|
|
167
|
+
]);
|
|
168
|
+
const last = ranked[ranked.length - 1];
|
|
169
|
+
assert.equal(last.path, os.homedir());
|
|
170
|
+
assert.equal(last.count, 0);
|
|
171
|
+
} finally {
|
|
172
|
+
rmSync(root, { recursive: true, force: true });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("home fallback is not duplicated when home is already in stats", () => {
|
|
177
|
+
const root = freshRoot();
|
|
178
|
+
try {
|
|
179
|
+
writeViews(root, [["v1", os.homedir(), 100]]);
|
|
180
|
+
seedCwdStatsFromViews(root);
|
|
181
|
+
const ranked = rankedCwdCandidates(root, 8);
|
|
182
|
+
assert.equal(ranked.filter((entry) => entry.path === os.homedir()).length, 1);
|
|
183
|
+
assert.equal(ranked[0].count, 1);
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(root, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("corrupt stats file degrades to empty and stays writable", () => {
|
|
190
|
+
const root = freshRoot();
|
|
191
|
+
try {
|
|
192
|
+
writeFileSync(P.cwdStatsPath(root), "{not json");
|
|
193
|
+
assert.deepEqual(readCwdStats(root), { version: 1, entries: {} });
|
|
194
|
+
recordCwdLaunch(root, root);
|
|
195
|
+
assert.equal(readCwdStats(root).entries[root].count, 1);
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(root, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("ensureCwdStatsSeeded seeds once and is idempotent", () => {
|
|
202
|
+
const root = freshRoot();
|
|
203
|
+
try {
|
|
204
|
+
writeViews(root, [["v1", "/a", 100]]);
|
|
205
|
+
ensureCwdStatsSeeded(root);
|
|
206
|
+
assert.ok(existsSync(P.cwdStatsPath(root)));
|
|
207
|
+
ensureCwdStatsSeeded(root);
|
|
208
|
+
assert.deepEqual(readCwdStats(root).entries, { "/a": { count: 1, lastUsed: 100 } });
|
|
209
|
+
} finally {
|
|
210
|
+
rmSync(root, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- [ ] **Step 2: 跑测试确认失败**
|
|
216
|
+
|
|
217
|
+
Run: `npm test`
|
|
218
|
+
Expected: FAIL —— `Cannot find module '../src/core/cwd-stats.mjs'`(paths 也还没有 `cwdStatsPath`)。
|
|
219
|
+
|
|
220
|
+
- [ ] **Step 3: 实现 paths.mjs 路径函数**
|
|
221
|
+
|
|
222
|
+
`src/core/paths.mjs`,在 `gcHistoryPath` 之后插入(文件已有同款注释风格):
|
|
223
|
+
|
|
224
|
+
```js
|
|
225
|
+
/** @param {string} root */
|
|
226
|
+
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
改为:
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
/** @param {string} root */
|
|
233
|
+
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
234
|
+
/** @param {string} root */
|
|
235
|
+
export const cwdStatsPath = (root) => path.join(root, "cwd-stats.json");
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- [ ] **Step 4: 实现 cwd-stats.mjs**
|
|
239
|
+
|
|
240
|
+
创建 `src/core/cwd-stats.mjs`:
|
|
241
|
+
|
|
242
|
+
```js
|
|
243
|
+
/**
|
|
244
|
+
* Persistent cwd usage stats for the launch dialog's directory favorites.
|
|
245
|
+
*
|
|
246
|
+
* Counts every successfully dispatched session per cwd, independent of the
|
|
247
|
+
* view lifecycle (deleting a view does not decrement). The dashboard reads
|
|
248
|
+
* the ranked list for the cwd picker's favorites mode. Pure node, no Pi imports.
|
|
249
|
+
*/
|
|
250
|
+
import { existsSync } from "node:fs";
|
|
251
|
+
import * as os from "node:os";
|
|
252
|
+
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
253
|
+
import * as P from "./paths.mjs";
|
|
254
|
+
import { readMeta, readRoster } from "./store.mjs";
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @typedef {Object} CwdStatsEntry
|
|
258
|
+
* @property {number} count
|
|
259
|
+
* @property {number} lastUsed epoch ms, like meta.updatedAt
|
|
260
|
+
*/
|
|
261
|
+
|
|
262
|
+
/** @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
263
|
+
function emptyStats() {
|
|
264
|
+
return { version: 1, entries: {} };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @param {string} root @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
268
|
+
export function readCwdStats(root) {
|
|
269
|
+
const raw = readJson(P.cwdStatsPath(root), null);
|
|
270
|
+
if (!raw || typeof raw !== "object" || typeof raw.entries !== "object" || raw.entries === null) return emptyStats();
|
|
271
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
272
|
+
const entries = {};
|
|
273
|
+
for (const [dir, entry] of Object.entries(raw.entries)) {
|
|
274
|
+
if (!entry || typeof entry.count !== "number") continue;
|
|
275
|
+
entries[dir] = {
|
|
276
|
+
count: Math.max(0, Math.floor(entry.count)),
|
|
277
|
+
lastUsed: typeof entry.lastUsed === "number" ? entry.lastUsed : 0,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return { version: 1, entries };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* One-time seed: aggregate cwd counts from every roster view's meta.json.
|
|
285
|
+
* No-op when cwd-stats.json already exists.
|
|
286
|
+
* @param {string} root
|
|
287
|
+
*/
|
|
288
|
+
export function seedCwdStatsFromViews(root) {
|
|
289
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
290
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
291
|
+
const entries = {};
|
|
292
|
+
for (const viewId of readRoster(root).views ?? []) {
|
|
293
|
+
const meta = readMeta(root, viewId);
|
|
294
|
+
const cwd = meta?.cwd;
|
|
295
|
+
if (!cwd) continue;
|
|
296
|
+
const lastUsed = typeof meta.updatedAt === "number" ? meta.updatedAt : Date.now();
|
|
297
|
+
const existing = entries[cwd];
|
|
298
|
+
if (existing) {
|
|
299
|
+
existing.count += 1;
|
|
300
|
+
existing.lastUsed = Math.max(existing.lastUsed, lastUsed);
|
|
301
|
+
} else {
|
|
302
|
+
entries[cwd] = { count: 1, lastUsed };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
atomicWriteJson(P.cwdStatsPath(root), { version: 1, entries });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Seed when the stats file is missing; tolerate every failure (dashboard UX
|
|
310
|
+
* must never break because of stats bookkeeping).
|
|
311
|
+
* @param {string} root
|
|
312
|
+
*/
|
|
313
|
+
export function ensureCwdStatsSeeded(root) {
|
|
314
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
315
|
+
try {
|
|
316
|
+
seedCwdStatsFromViews(root);
|
|
317
|
+
} catch {
|
|
318
|
+
/* best effort */
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Record one successful dispatch for `cwd`. Invalid dirs are ignored.
|
|
324
|
+
* @param {string} root @param {string} cwd
|
|
325
|
+
*/
|
|
326
|
+
export function recordCwdLaunch(root, cwd) {
|
|
327
|
+
if (!cwd || !existsSync(cwd)) return;
|
|
328
|
+
const stats = readCwdStats(root);
|
|
329
|
+
const existing = stats.entries[cwd] ?? { count: 0, lastUsed: 0 };
|
|
330
|
+
stats.entries[cwd] = { count: existing.count + 1, lastUsed: Date.now() };
|
|
331
|
+
atomicWriteJson(P.cwdStatsPath(root), stats);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Ranked candidates: count desc, then lastUsed desc; home appended at the
|
|
336
|
+
* end when absent so the user's home dir is always one keystroke away.
|
|
337
|
+
* @param {string} root @param {number=} limit
|
|
338
|
+
* @returns {Array<{path: string, count: number}>}
|
|
339
|
+
*/
|
|
340
|
+
export function rankedCwdCandidates(root, limit = 8) {
|
|
341
|
+
const stats = readCwdStats(root);
|
|
342
|
+
const rows = Object.entries(stats.entries).map(([dir, entry]) => ({
|
|
343
|
+
path: dir,
|
|
344
|
+
count: entry.count,
|
|
345
|
+
lastUsed: entry.lastUsed,
|
|
346
|
+
}));
|
|
347
|
+
rows.sort((a, b) => b.count - a.count || b.lastUsed - a.lastUsed);
|
|
348
|
+
const out = rows.map(({ path, count }) => ({ path, count }));
|
|
349
|
+
const home = os.homedir();
|
|
350
|
+
if (!out.some((entry) => entry.path === home)) out.push({ path: home, count: 0 });
|
|
351
|
+
return out.slice(0, Math.max(1, limit));
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
- [ ] **Step 5: 跑测试确认通过**
|
|
356
|
+
|
|
357
|
+
Run: `npm test`
|
|
358
|
+
Expected: PASS(8 个新用例 + 既有用例全绿)。
|
|
359
|
+
|
|
360
|
+
- [ ] **Step 6: Commit**
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
git add src/core/paths.mjs src/core/cwd-stats.mjs test/cwd-stats.test.mjs
|
|
364
|
+
git commit -m "feat: add persistent cwd usage stats with view seeding (issue #22)"
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
---
|
|
368
|
+
|
|
369
|
+
### Task 2: 候选匹配与选择器状态纯函数(TDD)
|
|
370
|
+
|
|
371
|
+
**Files:**
|
|
372
|
+
- Modify: `src/core/launch-options.mjs`(文件末尾追加两个导出)
|
|
373
|
+
- Test: `test/launch-options.test.mjs`
|
|
374
|
+
|
|
375
|
+
**Interfaces:**
|
|
376
|
+
- Consumes: 同文件内已导出的 `listDirectorySuggestions(query, baseCwd)`。
|
|
377
|
+
- Produces:
|
|
378
|
+
- `filterCwdCandidates(candidates: Array<{path, count}>, query: string)` → 过滤后的同形数组(空 query 返回原数组;大小写不敏感子串,路径任意部分;保持传入顺序)
|
|
379
|
+
- `nextCwdPickerState(query, ranked, baseCwd)` → `{ mode: "favorites" | "browse", suggestions: string[] }`(query 空或候选有匹配 → favorites 返回匹配项 path;否则 browse 返回 `listDirectorySuggestions(query, baseCwd)`)
|
|
380
|
+
|
|
381
|
+
- [ ] **Step 1: 写失败测试**
|
|
382
|
+
|
|
383
|
+
创建 `test/launch-options.test.mjs`:
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
import assert from "node:assert/strict";
|
|
387
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
388
|
+
import { tmpdir } from "node:os";
|
|
389
|
+
import { join } from "node:path";
|
|
390
|
+
import test from "node:test";
|
|
391
|
+
import { filterCwdCandidates, listDirectorySuggestions, nextCwdPickerState } from "../src/core/launch-options.mjs";
|
|
392
|
+
|
|
393
|
+
const ranked = [
|
|
394
|
+
{ path: "/home/elling", count: 107 },
|
|
395
|
+
{ path: "/home/elling/git-repo/github/zima-blue-cli", count: 20 },
|
|
396
|
+
{ path: "/home/elling/git-repo/github/jfox", count: 17 },
|
|
397
|
+
];
|
|
398
|
+
|
|
399
|
+
test("filterCwdCandidates matches case-insensitive substring anywhere in path", () => {
|
|
400
|
+
assert.deepEqual(filterCwdCandidates(ranked, "jfox"), [
|
|
401
|
+
{ path: "/home/elling/git-repo/github/jfox", count: 17 },
|
|
402
|
+
]);
|
|
403
|
+
assert.deepEqual(filterCwdCandidates(ranked, "GITHUB"), [ranked[1], ranked[2]]);
|
|
404
|
+
assert.deepEqual(filterCwdCandidates(ranked, ""), ranked);
|
|
405
|
+
assert.deepEqual(filterCwdCandidates(ranked, "no-such-dir"), []);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("nextCwdPickerState: empty query shows full ranked favorites", () => {
|
|
409
|
+
const state = nextCwdPickerState("", ranked, "/tmp");
|
|
410
|
+
assert.equal(state.mode, "favorites");
|
|
411
|
+
assert.deepEqual(state.suggestions, ranked.map((entry) => entry.path));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("nextCwdPickerState: matching query stays favorites in ranked order", () => {
|
|
415
|
+
const state = nextCwdPickerState("git-repo", ranked, "/tmp");
|
|
416
|
+
assert.equal(state.mode, "favorites");
|
|
417
|
+
assert.deepEqual(state.suggestions, [
|
|
418
|
+
"/home/elling/git-repo/github/zima-blue-cli",
|
|
419
|
+
"/home/elling/git-repo/github/jfox",
|
|
420
|
+
]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("nextCwdPickerState: unmatched query falls back to filesystem browse", () => {
|
|
424
|
+
const root = mkdtempSync(join(tmpdir(), "cwd-picker-"));
|
|
425
|
+
try {
|
|
426
|
+
const state = nextCwdPickerState("no-such-dir-xyz", ranked, root);
|
|
427
|
+
assert.equal(state.mode, "browse");
|
|
428
|
+
assert.deepEqual(state.suggestions, listDirectorySuggestions("no-such-dir-xyz", root));
|
|
429
|
+
} finally {
|
|
430
|
+
rmSync(root, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
- [ ] **Step 2: 跑测试确认失败**
|
|
436
|
+
|
|
437
|
+
Run: `npm test`
|
|
438
|
+
Expected: FAIL —— `filterCwdCandidates` / `nextCwdPickerState` not exported。
|
|
439
|
+
|
|
440
|
+
- [ ] **Step 3: 实现两个纯函数**
|
|
441
|
+
|
|
442
|
+
`src/core/launch-options.mjs` 文件末尾追加(`existsDir` 函数之后):
|
|
443
|
+
|
|
444
|
+
```js
|
|
445
|
+
/**
|
|
446
|
+
* @typedef {Object} CwdCandidate
|
|
447
|
+
* @property {string} path
|
|
448
|
+
* @property {number} count
|
|
449
|
+
*/
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Filter ranked cwd candidates by case-insensitive substring match anywhere
|
|
453
|
+
* in the path (empty query keeps the full ranked list).
|
|
454
|
+
* @param {CwdCandidate[]} candidates
|
|
455
|
+
* @param {string} query
|
|
456
|
+
* @returns {CwdCandidate[]}
|
|
457
|
+
*/
|
|
458
|
+
export function filterCwdCandidates(candidates, query) {
|
|
459
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
460
|
+
if (!q) return candidates;
|
|
461
|
+
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
466
|
+
* is empty or matches ranked candidates, filesystem browse otherwise.
|
|
467
|
+
* @param {string} query
|
|
468
|
+
* @param {CwdCandidate[]} ranked
|
|
469
|
+
* @param {string} baseCwd
|
|
470
|
+
* @returns {{mode: "favorites"|"browse", suggestions: string[]}}
|
|
471
|
+
*/
|
|
472
|
+
export function nextCwdPickerState(query, ranked, baseCwd) {
|
|
473
|
+
const matches = filterCwdCandidates(ranked, query);
|
|
474
|
+
if (String(query ?? "").trim() === "" || matches.length > 0) {
|
|
475
|
+
return { mode: "favorites", suggestions: matches.map((entry) => entry.path) };
|
|
476
|
+
}
|
|
477
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
- [ ] **Step 4: 跑测试确认通过**
|
|
482
|
+
|
|
483
|
+
Run: `npm test`
|
|
484
|
+
Expected: PASS。
|
|
485
|
+
|
|
486
|
+
- [ ] **Step 5: Commit**
|
|
487
|
+
|
|
488
|
+
```bash
|
|
489
|
+
git add src/core/launch-options.mjs test/launch-options.test.mjs
|
|
490
|
+
git commit -m "feat: add cwd candidate filter and picker state resolver (issue #22)"
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
### Task 3: dashboard 接线与埋点(deps.root / picker 状态机 / tab 补全 / seed+record)
|
|
496
|
+
|
|
497
|
+
**Files:**
|
|
498
|
+
- Modify: `src/commands/agent-board.ts`(传 `root`)
|
|
499
|
+
- Modify: `src/ui/dashboard.ts`(imports、类型、openLaunchDialog/openLaunchPicker/handleLaunchPickerKey/submitDispatch)
|
|
500
|
+
|
|
501
|
+
**Interfaces:**
|
|
502
|
+
- Consumes: `ensureCwdStatsSeeded` / `rankedCwdCandidates` / `recordCwdLaunch`(`../core/cwd-stats.mjs`)、`filterCwdCandidates` / `nextCwdPickerState`(`../core/launch-options.mjs`)、pi-tui `Key.tab`(已存在,`matchesKey(data, Key.tab)`)。
|
|
503
|
+
- Produces: `DashboardDeps.root: string`;`LaunchState.cwdRanked: CwdCandidate[]`、`LaunchState.cwdPickerMode: "favorites" | "browse"`(Task 4 渲染消费这两个字段)。
|
|
504
|
+
|
|
505
|
+
此任务无新增单测(repo 现有 UI 层无 DashboardComponent 单测,行为由 Task 1/2 纯函数测试 + Task 5 手工验收覆盖),但**必须** `npm test` + `npm run typecheck` 全绿。
|
|
506
|
+
|
|
507
|
+
- [ ] **Step 1: agent-board.ts 传入 root**
|
|
508
|
+
|
|
509
|
+
`src/commands/agent-board.ts`,DashboardComponent 构造处:
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
|
|
513
|
+
service,
|
|
514
|
+
defaultCwd: ctx.cwd,
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
改为:
|
|
518
|
+
|
|
519
|
+
```ts
|
|
520
|
+
const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
|
|
521
|
+
service,
|
|
522
|
+
root: opts.root,
|
|
523
|
+
defaultCwd: ctx.cwd,
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
- [ ] **Step 2: dashboard.ts 加 imports**
|
|
527
|
+
|
|
528
|
+
`src/ui/dashboard.ts`,`prewarm-schedule` import 之后插入:
|
|
529
|
+
|
|
530
|
+
```ts
|
|
531
|
+
import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
改为:
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
|
|
538
|
+
import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
`launch-options.mjs` import 块:
|
|
542
|
+
|
|
543
|
+
```ts
|
|
544
|
+
import {
|
|
545
|
+
canonicalModelRef,
|
|
546
|
+
clampThinkingLevel,
|
|
547
|
+
listDirectorySuggestions,
|
|
548
|
+
resolveDirectoryValue,
|
|
549
|
+
resolveLaunchContext,
|
|
550
|
+
supportedThinkingLevels,
|
|
551
|
+
} from "../core/launch-options.mjs";
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
改为:
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
import {
|
|
558
|
+
canonicalModelRef,
|
|
559
|
+
clampThinkingLevel,
|
|
560
|
+
listDirectorySuggestions,
|
|
561
|
+
nextCwdPickerState,
|
|
562
|
+
resolveDirectoryValue,
|
|
563
|
+
resolveLaunchContext,
|
|
564
|
+
supportedThinkingLevels,
|
|
565
|
+
} from "../core/launch-options.mjs";
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
- [ ] **Step 3: 类型定义**
|
|
569
|
+
|
|
570
|
+
`LaunchState` 接口前加候选类型,接口内加两个字段:
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
interface LaunchState {
|
|
574
|
+
fieldIndex: number;
|
|
575
|
+
picker: LaunchPicker;
|
|
576
|
+
action: "background" | "attach";
|
|
577
|
+
cwd: string;
|
|
578
|
+
cwdQuery: string;
|
|
579
|
+
cwdSuggestions: string[];
|
|
580
|
+
cwdSuggestionIndex: number;
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
改为:
|
|
584
|
+
|
|
585
|
+
```ts
|
|
586
|
+
interface CwdCandidate {
|
|
587
|
+
path: string;
|
|
588
|
+
count: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface LaunchState {
|
|
592
|
+
fieldIndex: number;
|
|
593
|
+
picker: LaunchPicker;
|
|
594
|
+
action: "background" | "attach";
|
|
595
|
+
cwd: string;
|
|
596
|
+
cwdQuery: string;
|
|
597
|
+
cwdSuggestions: string[];
|
|
598
|
+
cwdSuggestionIndex: number;
|
|
599
|
+
cwdRanked: CwdCandidate[];
|
|
600
|
+
cwdPickerMode: "favorites" | "browse";
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
`DashboardDeps`:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
export interface DashboardDeps {
|
|
607
|
+
service: Service;
|
|
608
|
+
defaultCwd: string;
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
改为:
|
|
612
|
+
|
|
613
|
+
```ts
|
|
614
|
+
export interface DashboardDeps {
|
|
615
|
+
service: Service;
|
|
616
|
+
root: string;
|
|
617
|
+
defaultCwd: string;
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
- [ ] **Step 4: buildLaunchState 初始化新字段,移除 homeLaunchRoot 依赖**
|
|
621
|
+
|
|
622
|
+
`buildLaunchState` 中:
|
|
623
|
+
|
|
624
|
+
```ts
|
|
625
|
+
const initialBrowserCwd = homeLaunchRoot(cwd);
|
|
626
|
+
const modelFiltered = filterLaunchChoices(context.choices, "");
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
改为:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
const modelFiltered = filterLaunchChoices(context.choices, "");
|
|
633
|
+
```
|
|
634
|
+
|
|
635
|
+
返回对象:
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
return {
|
|
639
|
+
fieldIndex: 0,
|
|
640
|
+
picker: null,
|
|
641
|
+
action: "background",
|
|
642
|
+
cwd,
|
|
643
|
+
cwdQuery: initialBrowserCwd,
|
|
644
|
+
cwdSuggestions: listDirectorySuggestions(initialBrowserCwd, cwd),
|
|
645
|
+
cwdSuggestionIndex: 0,
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
改为:
|
|
649
|
+
|
|
650
|
+
```ts
|
|
651
|
+
return {
|
|
652
|
+
fieldIndex: 0,
|
|
653
|
+
picker: null,
|
|
654
|
+
action: "background",
|
|
655
|
+
cwd,
|
|
656
|
+
cwdQuery: "",
|
|
657
|
+
cwdSuggestions: [],
|
|
658
|
+
cwdSuggestionIndex: 0,
|
|
659
|
+
cwdRanked: [],
|
|
660
|
+
cwdPickerMode: "browse",
|
|
661
|
+
```
|
|
662
|
+
|
|
663
|
+
删除已无引用的 `homeLaunchRoot`:
|
|
664
|
+
|
|
665
|
+
```ts
|
|
666
|
+
function homeLaunchRoot(fallback: string): string {
|
|
667
|
+
return process.env.HOME || process.env.USERPROFILE ? "~" : fallback;
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
整体删除该函数。验证无残留引用:`rg -n "homeLaunchRoot|initialBrowserCwd" src/` → 无输出。
|
|
672
|
+
|
|
673
|
+
- [ ] **Step 5: openLaunchDialog 加 lazy seed**
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
private openLaunchDialog(): void {
|
|
677
|
+
const prompt = this.input.trim();
|
|
678
|
+
if (!prompt) return this.toListMode();
|
|
679
|
+
const defaults = this.launchDefaults();
|
|
680
|
+
this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
改为:
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
private openLaunchDialog(): void {
|
|
687
|
+
const prompt = this.input.trim();
|
|
688
|
+
if (!prompt) return this.toListMode();
|
|
689
|
+
const defaults = this.launchDefaults();
|
|
690
|
+
try {
|
|
691
|
+
ensureCwdStatsSeeded(this.deps.root);
|
|
692
|
+
} catch {
|
|
693
|
+
/* best effort: favorites degrade to browse mode */
|
|
694
|
+
}
|
|
695
|
+
this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
- [ ] **Step 6: openLaunchPicker("cwd") 初始化 favorites 状态(保留 seed 语义)**
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
if (picker === "cwd") {
|
|
702
|
+
launch.cwdQuery = seed ?? launch.cwdQuery ?? homeLaunchRoot(launch.cwd);
|
|
703
|
+
launch.cwdSuggestions = listDirectorySuggestions(launch.cwdQuery, launch.cwd);
|
|
704
|
+
launch.cwdSuggestionIndex = 0;
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
改为:
|
|
710
|
+
|
|
711
|
+
```ts
|
|
712
|
+
if (picker === "cwd") {
|
|
713
|
+
try {
|
|
714
|
+
ensureCwdStatsSeeded(this.deps.root);
|
|
715
|
+
launch.cwdRanked = rankedCwdCandidates(this.deps.root, 8);
|
|
716
|
+
} catch {
|
|
717
|
+
launch.cwdRanked = [];
|
|
718
|
+
}
|
|
719
|
+
launch.cwdQuery = seed ?? "";
|
|
720
|
+
const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
|
|
721
|
+
launch.cwdPickerMode = state.mode;
|
|
722
|
+
launch.cwdSuggestions = state.suggestions;
|
|
723
|
+
launch.cwdSuggestionIndex = 0;
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
```
|
|
727
|
+
|
|
728
|
+
- [ ] **Step 7: handleLaunchPickerKey 加 tab 补全分支**
|
|
729
|
+
|
|
730
|
+
在 enter 处理分支结束、cwd 输入分支开始之间插入(锚点文本唯一):
|
|
731
|
+
|
|
732
|
+
```ts
|
|
733
|
+
}
|
|
734
|
+
if (launch.picker === "cwd") {
|
|
735
|
+
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
改为:
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
}
|
|
742
|
+
if (launch.picker === "cwd" && matchesKey(data, Key.tab)) {
|
|
743
|
+
if (launch.cwdPickerMode === "favorites" && launch.cwdSuggestions.length > 0) {
|
|
744
|
+
const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
|
|
745
|
+
launch.cwdQuery = completed;
|
|
746
|
+
const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
|
|
747
|
+
launch.cwdPickerMode = state.mode;
|
|
748
|
+
launch.cwdSuggestions = state.suggestions;
|
|
749
|
+
launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
if (launch.picker === "cwd") {
|
|
754
|
+
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
- [ ] **Step 8: cwd 输入分支切换到状态机**
|
|
758
|
+
|
|
759
|
+
```ts
|
|
760
|
+
if (launch.picker === "cwd") {
|
|
761
|
+
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
762
|
+
if (next !== null) {
|
|
763
|
+
launch.cwdQuery = next;
|
|
764
|
+
launch.cwdSuggestions = listDirectorySuggestions(next, launch.cwd);
|
|
765
|
+
launch.cwdSuggestionIndex = 0;
|
|
766
|
+
}
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
改为:
|
|
772
|
+
|
|
773
|
+
```ts
|
|
774
|
+
if (launch.picker === "cwd") {
|
|
775
|
+
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
776
|
+
if (next !== null) {
|
|
777
|
+
launch.cwdQuery = next;
|
|
778
|
+
const state = nextCwdPickerState(next, launch.cwdRanked, launch.cwd);
|
|
779
|
+
launch.cwdPickerMode = state.mode;
|
|
780
|
+
launch.cwdSuggestions = state.suggestions;
|
|
781
|
+
launch.cwdSuggestionIndex = 0;
|
|
782
|
+
}
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
(up/down/enter 处理不需要改:两个模式都走 `cwdSuggestions` + `cwdSuggestionIndex`,enter 的 `resolveDirectoryValue` 兜底逻辑不变。)
|
|
788
|
+
|
|
789
|
+
- [ ] **Step 9: submitDispatch 成功分支埋点**
|
|
790
|
+
|
|
791
|
+
```ts
|
|
792
|
+
if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
|
|
793
|
+
else {
|
|
794
|
+
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
改为:
|
|
798
|
+
|
|
799
|
+
```ts
|
|
800
|
+
if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
|
|
801
|
+
else {
|
|
802
|
+
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
803
|
+
try {
|
|
804
|
+
recordCwdLaunch(this.deps.root, launchCwd);
|
|
805
|
+
} catch {
|
|
806
|
+
/* best effort: stats must never block dispatch */
|
|
807
|
+
}
|
|
808
|
+
```
|
|
809
|
+
|
|
810
|
+
- [ ] **Step 10: 验证**
|
|
811
|
+
|
|
812
|
+
Run: `npm test && npm run typecheck`
|
|
813
|
+
Expected: 全 PASS、typecheck 无错误。
|
|
814
|
+
|
|
815
|
+
- [ ] **Step 11: Commit**
|
|
816
|
+
|
|
817
|
+
```bash
|
|
818
|
+
git add src/commands/agent-board.ts src/ui/dashboard.ts
|
|
819
|
+
git commit -m "feat: wire cwd favorites state machine into launch picker (issue #22)"
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
---
|
|
823
|
+
|
|
824
|
+
### Task 4: 渲染(favorites 行带计数 + 模式提示文案)
|
|
825
|
+
|
|
826
|
+
**Files:**
|
|
827
|
+
- Modify: `src/ui/dashboard.ts`(`renderLaunch` 的 cwd picker 分支)
|
|
828
|
+
|
|
829
|
+
**Interfaces:**
|
|
830
|
+
- Consumes: `launch.cwdPickerMode` / `launch.cwdRanked`(Task 3 产出)、同文件私有 `renderLaunchSuggestions`、`displayPath`。
|
|
831
|
+
|
|
832
|
+
- [ ] **Step 1: renderLaunch cwd 分支按模式渲染**
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
if (launch.picker === "cwd") {
|
|
836
|
+
lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
|
|
837
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
|
|
838
|
+
lines.push("");
|
|
839
|
+
lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
|
|
840
|
+
} else if (launch.picker === "model") {
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
改为:
|
|
844
|
+
|
|
845
|
+
```ts
|
|
846
|
+
if (launch.picker === "cwd") {
|
|
847
|
+
lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
|
|
848
|
+
if (launch.cwdPickerMode === "favorites") {
|
|
849
|
+
const counts = new Map(launch.cwdRanked.map((entry) => [entry.path, entry.count]));
|
|
850
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => {
|
|
851
|
+
const count = counts.get(value) ?? 0;
|
|
852
|
+
return `${displayPath(value)}${count > 0 ? ` ${count}×` : ""}`;
|
|
853
|
+
}));
|
|
854
|
+
lines.push("");
|
|
855
|
+
lines.push(t.fg("dim", "常用目录 · type to search · tab complete · enter choose · esc back"));
|
|
856
|
+
} else {
|
|
857
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
|
|
858
|
+
lines.push("");
|
|
859
|
+
lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
|
|
860
|
+
}
|
|
861
|
+
} else if (launch.picker === "model") {
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
- [ ] **Step 2: 验证**
|
|
865
|
+
|
|
866
|
+
Run: `npm test && npm run typecheck`
|
|
867
|
+
Expected: 全 PASS、typecheck 无错误。
|
|
868
|
+
|
|
869
|
+
- [ ] **Step 3: Commit**
|
|
870
|
+
|
|
871
|
+
```bash
|
|
872
|
+
git add src/ui/dashboard.ts
|
|
873
|
+
git commit -m "feat: render favorites rows with counts and mode hints (issue #22)"
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
### Task 5: 全量验证与手工验收
|
|
879
|
+
|
|
880
|
+
**Files:** 无代码改动;按需修 bug 并单独 commit。
|
|
881
|
+
|
|
882
|
+
- [ ] **Step 1: 全量检查**
|
|
883
|
+
|
|
884
|
+
Run: `npm run verify`(= typecheck + test + pack:dry)
|
|
885
|
+
Expected: 全部通过。
|
|
886
|
+
|
|
887
|
+
- [ ] **Step 2: 手工验收(本地 dev 加载)**
|
|
888
|
+
|
|
889
|
+
Dev 加载方式(README 记载):`ln -sf "$(pwd)" ~/.pi/agent/extensions/agent-board`(指向 worktree 目录),重启 Pi 后 `/agent-board`。验收清单:
|
|
890
|
+
|
|
891
|
+
1. 打开 dashboard → 输入任务 → enter → launch 对话框 → cwd 字段 enter 进入选择器:
|
|
892
|
+
- 未输入时列表 = 频率榜 Top 8(本机应见 `~ 107×`、zima-blue-cli、jfox 等),home 兜底在列
|
|
893
|
+
- 首项高亮,↑↓ 移动,enter 直接选中
|
|
894
|
+
2. 输入 `jfox` → 列表只剩 `~/git-repo/github/jfox 17×`;**tab** → 输入框变为完整路径、picker 仍打开;回车 → 选中,字段显示 `~/git-repo/github/jfox`
|
|
895
|
+
3. 输入 `~` 或 `/tmp/xxx` → 回落文件系统浏览(旧行为),提示行变为 `type to filter folders …`
|
|
896
|
+
4. 主对话框 cwd 字段上直接打字(如 `j`)→ 直接带 seed 进入 picker 且匹配候选(type-to-jump 保留)
|
|
897
|
+
5. 发起一个 session 后:`jq . ~/.pi/agent/agent-board/cwd-stats.json` 中对应 cwd 计数 +1、lastUsed 更新;再次打开 picker 排名反映新计数
|
|
898
|
+
6. 删除某个 view 后计数不变(stats 独立)
|
|
899
|
+
7. `rm ~/.pi/agent/agent-board/cwd-stats.json` 后再打开 launch 对话框 → 自动从现有 views 重新 seed
|
|
900
|
+
|
|
901
|
+
- [ ] **Step 3: 验收通过后收尾**
|
|
902
|
+
|
|
903
|
+
```bash
|
|
904
|
+
git status --short # 确认无意外文件;node_modules 为 symlink 不在版本控制
|
|
905
|
+
git log --oneline # 确认本分支提交序列
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
提交 PR(工作流第 9 步 zima-pr-monitor 负责,不在此 plan 内):推分支 `issue-22-launch-cwd-favorites` → 开 PR → 打 `zima:needs-review`。
|
|
909
|
+
|
|
910
|
+
---
|
|
911
|
+
|
|
912
|
+
## Notes
|
|
913
|
+
|
|
914
|
+
- 任务间依赖:Task 3 依赖 Task 1/2 的导出;Task 4 依赖 Task 3 的字段。必须顺序执行。
|
|
915
|
+
- 每步 `npm test` 都在 worktree 根目录运行;`node_modules` symlink 已就绪,无需 `npm install`。
|
|
916
|
+
- 若手工验收发现交互瑕疵(如 tab 补全后高亮索引错位),回到对应任务修,禁止直接改主仓库。
|