@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhuxixi/pi-agent-board",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Agent-board dashboard for Pi: dispatch, monitor, peek/reply, and attach to background Pi sessions.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -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
+ }
@@ -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");
@@ -763,6 +763,10 @@ export function createService(opts) {
763
763
  return { ok: true, viewId: meta.id, reused: false };
764
764
  },
765
765
 
766
+ getRoot() {
767
+ return root;
768
+ },
769
+
766
770
  getLaunchPrefs() {
767
771
  return readLaunchPrefs(root);
768
772
  },
@@ -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
- launch.cwdSuggestions = listDirectorySuggestions(next, launch.cwd);
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: initialBrowserCwd,
662
- cwdSuggestions: listDirectorySuggestions(initialBrowserCwd, cwd),
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
- launch.cwdQuery = seed ?? launch.cwdQuery ?? homeLaunchRoot(launch.cwd);
682
- launch.cwdSuggestions = listDirectorySuggestions(launch.cwdQuery, launch.cwd);
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
- lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
1478
- lines.push("");
1479
- lines.push(t.fg("dim", "type to filter folders · enter choose · esc back"));
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);