@zhuxixi/pi-agent-board 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/superpowers/plans/2026-08-22-launch-cwd-favorites.md +916 -0
- package/docs/superpowers/specs/2026-08-22-cwd-favorites-design.md +131 -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/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,131 @@
|
|
|
1
|
+
# Spec: Launch 对话框 cwd 常用目录榜(频率排名 + 模糊补全)
|
|
2
|
+
|
|
3
|
+
- Date: 2026-08-22
|
|
4
|
+
- Status: draft(待 review)
|
|
5
|
+
|
|
6
|
+
## Background
|
|
7
|
+
|
|
8
|
+
Ctrl+N 打开 launch 对话框后,`cwd` 字段进入目录选择器,目前只能从 `~` 开始逐层
|
|
9
|
+
浏览 + 输入过滤(`src/core/launch-options.mjs` 的 `listDirectorySuggestions`,
|
|
10
|
+
`src/ui/dashboard.ts` 的 launch picker)。launch-prefs.json 只记忆上一次的 cwd,
|
|
11
|
+
没有「常用目录」概念。
|
|
12
|
+
|
|
13
|
+
本机现状(2026-08-22 实测):agent-board 共 177 个 view,cwd 分布高度集中——
|
|
14
|
+
`/home/elling` 107 次、`zima-blue-cli` 20、`jfox` 17、`.pi/agent/extensions` 10、
|
|
15
|
+
`pi-agent-board` 10,其余个位数。用户每次起 session 都要手动浏览目录,重复劳动。
|
|
16
|
+
|
|
17
|
+
用户需求:按**真实使用频率自动排名**的候选目录榜,用得越多排越前;纯键盘快速
|
|
18
|
+
选择;并且输入时能做**路径补全**(输入 `jfox` → 出现完整路径 → tab/回车补全),
|
|
19
|
+
类似 zsh 的 cd 补全体验。
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
1. 持久化的 cwd 使用统计:每次成功发起 session 时对 cwd 计数 +1;统计独立于
|
|
24
|
+
session 生命周期(删 view 不减计数)。
|
|
25
|
+
2. 首次使用时从现有 view 的 meta.json 一次性导入计数,立即有榜单数据。
|
|
26
|
+
3. cwd 选择器交互升级:
|
|
27
|
+
- 打开选择器(未输入)时显示频率榜 Top 8,↑↓ 选 + 回车直接确认;
|
|
28
|
+
- 输入时先对候选榜做大小写不敏感子串匹配(路径任意部分);
|
|
29
|
+
- 有匹配 → 候选模式显示匹配项;无匹配 → 回落现有文件系统浏览(旧能力完整保留);
|
|
30
|
+
- tab 把高亮项的完整路径补全进输入框(picker 保持打开,可继续微调),
|
|
31
|
+
回车最终确认;输入框内容本身就是有效路径时回车直接生效。
|
|
32
|
+
4. 候选行显示 `~` 简写路径 + 使用次数(如 `107×`)。
|
|
33
|
+
|
|
34
|
+
## Non-goals
|
|
35
|
+
|
|
36
|
+
- 不做手动收藏/置顶编辑(无配置文件 UI)。
|
|
37
|
+
- 不做模糊 subsequence 匹配(先做子串,不够再升级)。
|
|
38
|
+
- 不改 launch-prefs.json 结构。
|
|
39
|
+
- 不改 launch 对话框的其他字段(model/thinking/action)。
|
|
40
|
+
|
|
41
|
+
## Design
|
|
42
|
+
|
|
43
|
+
### 新模块:`src/core/cwd-stats.mjs`
|
|
44
|
+
|
|
45
|
+
- 新文件 `~/.pi/agent/agent-board/cwd-stats.json`(root 由现有
|
|
46
|
+
`paths.mjs` 的 `defaultRoot()` 派生,尊重 `$AGENT_BOARD_ROOT`):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"version": 1,
|
|
51
|
+
"entries": {
|
|
52
|
+
"/home/elling": { "count": 107, "lastUsed": 1756332000000 }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `paths.mjs` 增加 `cwdStatsPath(root)`,风格与 `launchPrefsPath` 一致。
|
|
58
|
+
- 函数:
|
|
59
|
+
- `readCwdStats(root)`:缺失/损坏 → 返回空 entries(沿用 store.mjs 的容错读风格)。
|
|
60
|
+
- `seedCwdStatsFromViews(root)`:仅当 cwd-stats.json 不存在时执行;遍历
|
|
61
|
+
roster 全部 view 的 meta.json,按 cwd 聚合计数,lastUsed 取该 cwd 下 view 的
|
|
62
|
+
updatedAt 最大值(缺失则用当前时间);原子写(`atomicWriteJson`)。
|
|
63
|
+
- `recordCwdLaunch(root, cwd)`:count +1、lastUsed 更新,原子写;cwd 非法
|
|
64
|
+
(空/不存在)直接忽略。
|
|
65
|
+
- `rankedCwdCandidates(root, limit)`:count 降序、lastUsed 降序;末尾始终补
|
|
66
|
+
home 目录兜底(若不在榜内则 count 0 排最后),保证「用户根目录」永远可一键选。
|
|
67
|
+
- 写入用 `src/core/atomic.mjs` 的 `atomicWriteJson`(temp + rename,防并发写坏),
|
|
68
|
+
与 board 现有 meta.json 写入同款。
|
|
69
|
+
|
|
70
|
+
### 埋点
|
|
71
|
+
|
|
72
|
+
- `src/ui/dashboard.ts` 的 `submitDispatch`:`res.ok` 分支内
|
|
73
|
+
`recordCwdLaunch(root, launchCwd)`,try/catch 尽力而为,失败不影响派发。
|
|
74
|
+
- lazy seed:launch 对话框首次打开(`openLaunchDialog`)时若 cwd-stats.json
|
|
75
|
+
不存在则调用 `seedCwdStatsFromViews`(同步、一次性、容错)。
|
|
76
|
+
|
|
77
|
+
### 选择器交互
|
|
78
|
+
|
|
79
|
+
`LaunchState` 增加字段:
|
|
80
|
+
- `cwdRanked: {path, count}[]`:打开 picker 时由 `rankedCwdCandidates(root, 8)` 生成;
|
|
81
|
+
- `cwdPickerMode: "favorites" | "browse"`:候选模式 / 文件系统浏览模式。
|
|
82
|
+
|
|
83
|
+
行为规则(`openLaunchPicker("cwd")` 与 `handleLaunchPickerKey`):
|
|
84
|
+
|
|
85
|
+
| 输入(cwdQuery) | 模式 | 建议列表 |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| 空 | favorites | 频率榜 Top 8,首项高亮 |
|
|
88
|
+
| 非空,候选榜有子串匹配(大小写不敏感、路径任意部分) | favorites | 匹配项(保持排名序) |
|
|
89
|
+
| 非空,无匹配 | browse | 现有 `listDirectorySuggestions` 文件系统浏览 |
|
|
90
|
+
|
|
91
|
+
- 打开 picker 时 cwdQuery 置空(不再 seed 成 `~`);在 launch 主对话框 cwd 字段上
|
|
92
|
+
直接打字进入 picker 时,query = 已输入字符(现有 type-to-jump 行为保留)。
|
|
93
|
+
输入 `~` 等无候选匹配时自然进入 browse 模式,旧浏览能力保留。
|
|
94
|
+
- tab(favorites 模式):`cwdQuery = 高亮项完整路径`,picker 保持打开;
|
|
95
|
+
此时输入框即完整路径,回车经现有 `resolveDirectoryValue` 直接生效。
|
|
96
|
+
- 回车(favorites 模式):选中高亮项。
|
|
97
|
+
- esc:关闭 picker(现有行为)。
|
|
98
|
+
- ↑↓:移动高亮(现有逻辑复用)。
|
|
99
|
+
|
|
100
|
+
### 渲染
|
|
101
|
+
|
|
102
|
+
- favorites 模式候选行:`displayPath(value)`(`~` 简写)+ 右侧 dim 次数
|
|
103
|
+
(`{count}×`);browse 模式渲染不变。
|
|
104
|
+
- 底部提示区分文案:
|
|
105
|
+
- favorites:`常用目录 · type to search · tab complete · enter choose · esc back`
|
|
106
|
+
- browse:现有 `type to filter folders · enter choose · esc back`
|
|
107
|
+
|
|
108
|
+
## Error handling
|
|
109
|
+
|
|
110
|
+
- 统计读写全部容错:损坏的 cwd-stats.json 视为空表,永不抛到 UI。
|
|
111
|
+
- seed 与 record 的失败静默忽略(console 调试输出可选)。
|
|
112
|
+
- 榜单为空(无 stats、无 home)时 picker 直接进入 browse 模式,行为与现状一致。
|
|
113
|
+
|
|
114
|
+
## Testing
|
|
115
|
+
|
|
116
|
+
- `test/cwd-stats.test.mjs`(新):seed 从 views 导入、record 累加与 lastUsed、
|
|
117
|
+
排序(count 优先、lastUsed tie-break)、损坏文件容错、home 兜底、空表行为。
|
|
118
|
+
- 候选匹配逻辑(新函数,放 launch-options.mjs 或 cwd-stats.mjs):大小写不敏感、
|
|
119
|
+
路径任意部分匹配、无匹配判定。
|
|
120
|
+
- `test/dashboard-render.test.mjs`:repo 现状无 DashboardComponent 单测 harness,picker
|
|
121
|
+
渲染断言由 Task 5 手工验收覆盖(路径 + 次数、browse 模式渲染不变)。
|
|
122
|
+
- 所有测试用 tmp dir 作 root(沿 paths.mjs「显式 root 可测」约定)。
|
|
123
|
+
|
|
124
|
+
## Verification
|
|
125
|
+
|
|
126
|
+
- 启动后打开 launch 对话框 cwd 选择器:Top 榜应为 `~`(107×)、zima-blue-cli 等,
|
|
127
|
+
与现有 view 统计一致。
|
|
128
|
+
- 输入 `jfox`:`~/git-repo/github/jfox` 出现在榜中;tab 补全完整路径;回车发起
|
|
129
|
+
session;再次打开选择器 jfox 计数 +1。
|
|
130
|
+
- 输入 `~`:回落文件系统浏览,旧行为不变。
|
|
131
|
+
- 删除某 view 后计数不变(stats 独立于 view 生命周期)。
|
package/package.json
CHANGED
|
@@ -88,6 +88,7 @@ export async function openDashboard(
|
|
|
88
88
|
};
|
|
89
89
|
const comp = new DashboardComponent(tui, theme as never, keybindings, wrappedDone, {
|
|
90
90
|
service,
|
|
91
|
+
root: service.getRoot(),
|
|
91
92
|
defaultCwd: ctx.cwd,
|
|
92
93
|
initialSelectedId: options.initialSelectedId,
|
|
93
94
|
availableModels,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent cwd usage stats for the launch dialog's directory favorites.
|
|
3
|
+
*
|
|
4
|
+
* Counts every successfully dispatched session per cwd, independent of the
|
|
5
|
+
* view lifecycle (deleting a view does not decrement). The dashboard reads
|
|
6
|
+
* the ranked list for the cwd picker's favorites mode. Pure node, no Pi imports.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
import { atomicWriteJson, readJson } from "./atomic.mjs";
|
|
11
|
+
import * as P from "./paths.mjs";
|
|
12
|
+
import { readMeta, readRoster } from "./store.mjs";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} CwdStatsEntry
|
|
16
|
+
* @property {number} count
|
|
17
|
+
* @property {number} lastUsed epoch ms, like meta.updatedAt
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
21
|
+
function emptyStats() {
|
|
22
|
+
return { version: 1, entries: {} };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {string} root @returns {{version: number, entries: Record<string, CwdStatsEntry>}} */
|
|
26
|
+
export function readCwdStats(root) {
|
|
27
|
+
const raw = readJson(P.cwdStatsPath(root), null);
|
|
28
|
+
if (!raw || typeof raw !== "object" || typeof raw.entries !== "object" || raw.entries === null) return emptyStats();
|
|
29
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
30
|
+
const entries = {};
|
|
31
|
+
for (const [dir, entry] of Object.entries(raw.entries)) {
|
|
32
|
+
if (!entry || typeof entry.count !== "number") continue;
|
|
33
|
+
entries[dir] = {
|
|
34
|
+
count: Math.max(0, Math.floor(entry.count)),
|
|
35
|
+
lastUsed: typeof entry.lastUsed === "number" ? entry.lastUsed : 0,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return { version: 1, entries };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One-time seed: aggregate cwd counts from every roster view's meta.json.
|
|
43
|
+
* No-op when cwd-stats.json already exists.
|
|
44
|
+
* @param {string} root
|
|
45
|
+
*/
|
|
46
|
+
export function seedCwdStatsFromViews(root) {
|
|
47
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
48
|
+
/** @type {Record<string, CwdStatsEntry>} */
|
|
49
|
+
const entries = {};
|
|
50
|
+
for (const viewId of readRoster(root).views ?? []) {
|
|
51
|
+
const meta = readMeta(root, viewId);
|
|
52
|
+
const cwd = meta?.cwd;
|
|
53
|
+
if (!cwd) continue;
|
|
54
|
+
const lastUsed = typeof meta.updatedAt === "number" ? meta.updatedAt : Date.now();
|
|
55
|
+
const existing = entries[cwd];
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.count += 1;
|
|
58
|
+
existing.lastUsed = Math.max(existing.lastUsed, lastUsed);
|
|
59
|
+
} else {
|
|
60
|
+
entries[cwd] = { count: 1, lastUsed };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
atomicWriteJson(P.cwdStatsPath(root), { version: 1, entries });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Seed when the stats file is missing; tolerate every failure (dashboard UX
|
|
68
|
+
* must never break because of stats bookkeeping).
|
|
69
|
+
* @param {string} root
|
|
70
|
+
*/
|
|
71
|
+
export function ensureCwdStatsSeeded(root) {
|
|
72
|
+
if (existsSync(P.cwdStatsPath(root))) return;
|
|
73
|
+
try {
|
|
74
|
+
seedCwdStatsFromViews(root);
|
|
75
|
+
} catch {
|
|
76
|
+
/* best effort */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Record one successful dispatch for `cwd`. Invalid dirs are ignored.
|
|
82
|
+
* @param {string} root @param {string} cwd
|
|
83
|
+
*/
|
|
84
|
+
export function recordCwdLaunch(root, cwd) {
|
|
85
|
+
if (!cwd || !existsSync(cwd)) return;
|
|
86
|
+
const stats = readCwdStats(root);
|
|
87
|
+
const existing = stats.entries[cwd] ?? { count: 0, lastUsed: 0 };
|
|
88
|
+
stats.entries[cwd] = { count: existing.count + 1, lastUsed: Date.now() };
|
|
89
|
+
atomicWriteJson(P.cwdStatsPath(root), stats);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Ranked candidates: count desc, then lastUsed desc; home appended at the
|
|
94
|
+
* end when absent so the user's home dir is always one keystroke away.
|
|
95
|
+
* @param {string} root @param {number=} limit
|
|
96
|
+
* @returns {Array<{path: string, count: number}>}
|
|
97
|
+
*/
|
|
98
|
+
export function rankedCwdCandidates(root, limit = 8) {
|
|
99
|
+
const stats = readCwdStats(root);
|
|
100
|
+
const rows = Object.entries(stats.entries).map(([dir, entry]) => ({
|
|
101
|
+
path: dir,
|
|
102
|
+
count: entry.count,
|
|
103
|
+
lastUsed: entry.lastUsed,
|
|
104
|
+
}));
|
|
105
|
+
rows.sort((a, b) => b.count - a.count || b.lastUsed - a.lastUsed);
|
|
106
|
+
const out = rows.slice(0, Math.max(1, limit)).map(({ path, count }) => ({ path, count }));
|
|
107
|
+
const home = os.homedir();
|
|
108
|
+
if (out.some((entry) => entry.path === home)) return out;
|
|
109
|
+
const homeRow = rows.find((entry) => entry.path === home);
|
|
110
|
+
out.push(homeRow ? { path: home, count: homeRow.count } : { path: home, count: 0 });
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
@@ -315,3 +315,50 @@ function existsDir(dir) {
|
|
|
315
315
|
return false;
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Keep only candidates whose path is an existing directory.
|
|
321
|
+
* @param {CwdCandidate[]} candidates
|
|
322
|
+
* @returns {CwdCandidate[]}
|
|
323
|
+
*/
|
|
324
|
+
export function existingCwdCandidates(candidates) {
|
|
325
|
+
return candidates.filter((entry) => existsDir(entry.path));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* @typedef {Object} CwdCandidate
|
|
330
|
+
* @property {string} path
|
|
331
|
+
* @property {number} count
|
|
332
|
+
*/
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Filter ranked cwd candidates by case-insensitive substring match anywhere
|
|
336
|
+
* in the path (empty query keeps the full ranked list).
|
|
337
|
+
* @param {CwdCandidate[]} candidates
|
|
338
|
+
* @param {string} query
|
|
339
|
+
* @returns {CwdCandidate[]}
|
|
340
|
+
*/
|
|
341
|
+
export function filterCwdCandidates(candidates, query) {
|
|
342
|
+
const q = String(query ?? "").trim().toLowerCase();
|
|
343
|
+
if (!q) return candidates;
|
|
344
|
+
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
349
|
+
* is empty or matches ranked candidates, filesystem browse otherwise.
|
|
350
|
+
* @param {string} query
|
|
351
|
+
* @param {CwdCandidate[]} ranked
|
|
352
|
+
* @param {string} baseCwd
|
|
353
|
+
* @returns {{mode: "favorites"|"browse", suggestions: string[]}}
|
|
354
|
+
*/
|
|
355
|
+
export function nextCwdPickerState(query, ranked, baseCwd) {
|
|
356
|
+
if (!ranked || ranked.length === 0) {
|
|
357
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
358
|
+
}
|
|
359
|
+
const matches = filterCwdCandidates(ranked, query);
|
|
360
|
+
if (String(query ?? "").trim() === "" || matches.length > 0) {
|
|
361
|
+
return { mode: "favorites", suggestions: matches.map((entry) => entry.path) };
|
|
362
|
+
}
|
|
363
|
+
return { mode: "browse", suggestions: listDirectorySuggestions(query, baseCwd) };
|
|
364
|
+
}
|
package/src/core/paths.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export const rosterPath = (root) => path.join(root, "roster.json");
|
|
|
21
21
|
export const launchPrefsPath = (root) => path.join(root, "launch-prefs.json");
|
|
22
22
|
/** @param {string} root */
|
|
23
23
|
export const gcHistoryPath = (root) => path.join(root, "gc-history.jsonl");
|
|
24
|
+
/** @param {string} root */
|
|
25
|
+
export const cwdStatsPath = (root) => path.join(root, "cwd-stats.json");
|
|
24
26
|
|
|
25
27
|
/** @param {string} root */
|
|
26
28
|
export const viewsDir = (root) => path.join(root, "views");
|
package/src/runtime/service.mjs
CHANGED
package/src/ui/dashboard.ts
CHANGED
|
@@ -17,11 +17,14 @@ import {
|
|
|
17
17
|
canonicalModelRef,
|
|
18
18
|
clampThinkingLevel,
|
|
19
19
|
listDirectorySuggestions,
|
|
20
|
+
existingCwdCandidates,
|
|
21
|
+
nextCwdPickerState,
|
|
20
22
|
resolveDirectoryValue,
|
|
21
23
|
resolveLaunchContext,
|
|
22
24
|
supportedThinkingLevels,
|
|
23
25
|
} from "../core/launch-options.mjs";
|
|
24
26
|
import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
|
|
27
|
+
import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
|
|
25
28
|
import { filterRows, groupRowsByFolder, rowState, stateGlyph } from "../core/rows.mjs";
|
|
26
29
|
import { loadSessionView } from "../core/session-view.mjs";
|
|
27
30
|
import { GROUP_LABELS } from "../core/types.mjs";
|
|
@@ -81,6 +84,11 @@ interface InputNotice {
|
|
|
81
84
|
expiresAt: number;
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
interface CwdCandidate {
|
|
88
|
+
path: string;
|
|
89
|
+
count: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
84
92
|
interface LaunchState {
|
|
85
93
|
fieldIndex: number;
|
|
86
94
|
picker: LaunchPicker;
|
|
@@ -89,6 +97,8 @@ interface LaunchState {
|
|
|
89
97
|
cwdQuery: string;
|
|
90
98
|
cwdSuggestions: string[];
|
|
91
99
|
cwdSuggestionIndex: number;
|
|
100
|
+
cwdRanked: CwdCandidate[];
|
|
101
|
+
cwdPickerMode: "favorites" | "browse";
|
|
92
102
|
choices: LaunchChoice[];
|
|
93
103
|
scopeSource: "scoped" | "all";
|
|
94
104
|
model: LaunchModel | null;
|
|
@@ -102,6 +112,7 @@ interface LaunchState {
|
|
|
102
112
|
|
|
103
113
|
export interface DashboardDeps {
|
|
104
114
|
service: Service;
|
|
115
|
+
root: string;
|
|
105
116
|
defaultCwd: string;
|
|
106
117
|
availableModels: LaunchModel[];
|
|
107
118
|
currentModel: LaunchModel | null;
|
|
@@ -562,11 +573,24 @@ export class DashboardComponent implements Component {
|
|
|
562
573
|
launch.picker = null;
|
|
563
574
|
return;
|
|
564
575
|
}
|
|
576
|
+
if (launch.picker === "cwd" && matchesKey(data, Key.tab)) {
|
|
577
|
+
if (launch.cwdPickerMode === "favorites" && launch.cwdSuggestions.length > 0) {
|
|
578
|
+
const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
|
|
579
|
+
launch.cwdQuery = completed;
|
|
580
|
+
const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
|
|
581
|
+
launch.cwdPickerMode = state.mode;
|
|
582
|
+
launch.cwdSuggestions = state.suggestions;
|
|
583
|
+
launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
565
587
|
if (launch.picker === "cwd") {
|
|
566
588
|
const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
|
|
567
589
|
if (next !== null) {
|
|
568
590
|
launch.cwdQuery = next;
|
|
569
|
-
|
|
591
|
+
const state = nextCwdPickerState(next, launch.cwdRanked, launch.cwd);
|
|
592
|
+
launch.cwdPickerMode = state.mode;
|
|
593
|
+
launch.cwdSuggestions = state.suggestions;
|
|
570
594
|
launch.cwdSuggestionIndex = 0;
|
|
571
595
|
}
|
|
572
596
|
return;
|
|
@@ -611,6 +635,11 @@ export class DashboardComponent implements Component {
|
|
|
611
635
|
const prompt = this.input.trim();
|
|
612
636
|
if (!prompt) return this.toListMode();
|
|
613
637
|
const defaults = this.launchDefaults();
|
|
638
|
+
try {
|
|
639
|
+
ensureCwdStatsSeeded(this.deps.root);
|
|
640
|
+
} catch {
|
|
641
|
+
/* best effort: favorites degrade to browse mode */
|
|
642
|
+
}
|
|
614
643
|
this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
|
|
615
644
|
this.mode = "launch";
|
|
616
645
|
this.inputNotice = null;
|
|
@@ -647,7 +676,6 @@ export class DashboardComponent implements Component {
|
|
|
647
676
|
thinkingOptions: ThinkingLevel[];
|
|
648
677
|
scopeSource: "scoped" | "all";
|
|
649
678
|
};
|
|
650
|
-
const initialBrowserCwd = homeLaunchRoot(cwd);
|
|
651
679
|
const modelFiltered = filterLaunchChoices(context.choices, "");
|
|
652
680
|
const modelIndex = Math.max(0, modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, context.selectedModel)));
|
|
653
681
|
const thinkingOptions = supportedThinkingLevels(context.selectedModel) as ThinkingLevel[];
|
|
@@ -658,9 +686,11 @@ export class DashboardComponent implements Component {
|
|
|
658
686
|
picker: null,
|
|
659
687
|
action: "background",
|
|
660
688
|
cwd,
|
|
661
|
-
cwdQuery:
|
|
662
|
-
cwdSuggestions:
|
|
689
|
+
cwdQuery: "",
|
|
690
|
+
cwdSuggestions: [],
|
|
663
691
|
cwdSuggestionIndex: 0,
|
|
692
|
+
cwdRanked: [],
|
|
693
|
+
cwdPickerMode: "browse",
|
|
664
694
|
choices: context.choices,
|
|
665
695
|
scopeSource: context.scopeSource,
|
|
666
696
|
model: context.selectedModel,
|
|
@@ -678,8 +708,16 @@ export class DashboardComponent implements Component {
|
|
|
678
708
|
if (!launch) return;
|
|
679
709
|
launch.picker = picker;
|
|
680
710
|
if (picker === "cwd") {
|
|
681
|
-
|
|
682
|
-
|
|
711
|
+
try {
|
|
712
|
+
ensureCwdStatsSeeded(this.deps.root);
|
|
713
|
+
launch.cwdRanked = existingCwdCandidates(rankedCwdCandidates(this.deps.root, 8));
|
|
714
|
+
} catch {
|
|
715
|
+
launch.cwdRanked = [];
|
|
716
|
+
}
|
|
717
|
+
launch.cwdQuery = seed ?? "";
|
|
718
|
+
const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
|
|
719
|
+
launch.cwdPickerMode = state.mode;
|
|
720
|
+
launch.cwdSuggestions = state.suggestions;
|
|
683
721
|
launch.cwdSuggestionIndex = 0;
|
|
684
722
|
return;
|
|
685
723
|
}
|
|
@@ -794,6 +832,11 @@ export class DashboardComponent implements Component {
|
|
|
794
832
|
if (!res.ok) this.notice(res.error ?? "Dispatch failed", "error");
|
|
795
833
|
else {
|
|
796
834
|
this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
|
|
835
|
+
try {
|
|
836
|
+
recordCwdLaunch(this.deps.root, launchCwd);
|
|
837
|
+
} catch {
|
|
838
|
+
/* best effort: stats must never block dispatch */
|
|
839
|
+
}
|
|
797
840
|
try {
|
|
798
841
|
this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
|
|
799
842
|
} catch {
|
|
@@ -1474,9 +1517,19 @@ export class DashboardComponent implements Component {
|
|
|
1474
1517
|
lines.push("");
|
|
1475
1518
|
if (launch.picker === "cwd") {
|
|
1476
1519
|
lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1520
|
+
if (launch.cwdPickerMode === "favorites") {
|
|
1521
|
+
const counts = new Map(launch.cwdRanked.map((entry) => [entry.path, entry.count]));
|
|
1522
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => {
|
|
1523
|
+
const count = counts.get(value) ?? 0;
|
|
1524
|
+
return `${displayPath(value)}${count > 0 ? ` ${count}×` : ""}`;
|
|
1525
|
+
}));
|
|
1526
|
+
lines.push("");
|
|
1527
|
+
lines.push(t.fg("dim", "常用目录 · type to search · tab complete · enter choose · esc back"));
|
|
1528
|
+
} else {
|
|
1529
|
+
lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
|
|
1530
|
+
lines.push("");
|
|
1531
|
+
lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
|
|
1532
|
+
}
|
|
1480
1533
|
} else if (launch.picker === "model") {
|
|
1481
1534
|
lines.push(t.fg("warning", `model› ${singleLineInput(launch.modelQuery)}${cursor()}`));
|
|
1482
1535
|
lines.push(...this.renderLaunchSuggestions(inner, launch.modelFiltered, launch.modelIndex, (choice) => `${formatLaunchModel(choice.model)}${choice.thinkingLevel ? ` · ${choice.thinkingLevel}` : ""}`));
|
|
@@ -1662,10 +1715,6 @@ function stripBracketedPaste(data: string): string {
|
|
|
1662
1715
|
return data.replace(/\x1b\[200~|\x1b\[201~/g, "");
|
|
1663
1716
|
}
|
|
1664
1717
|
|
|
1665
|
-
function homeLaunchRoot(fallback: string): string {
|
|
1666
|
-
return process.env.HOME || process.env.USERPROFILE ? "~" : fallback;
|
|
1667
|
-
}
|
|
1668
|
-
|
|
1669
1718
|
function renderCenteredBox(lines: string[], width: number, height: number, theme: ThemeLike): string[] {
|
|
1670
1719
|
const innerWidth = Math.max(20, Math.min(width - 6, Math.max(...lines.map((line) => visibleWidth(line)), 20)));
|
|
1671
1720
|
const boxWidth = Math.min(width, innerWidth + 4);
|