pi-lazy-panel 0.1.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/LICENSE +21 -0
- package/README.md +104 -0
- package/README.zh.md +104 -0
- package/docs/design.md +235 -0
- package/docs/keybindings.md +172 -0
- package/docs/keybindings.zh.md +172 -0
- package/package.json +67 -0
- package/src/actions/session-actions.ts +585 -0
- package/src/actions/tree-actions.ts +161 -0
- package/src/config/config.ts +155 -0
- package/src/config/keymap.ts +219 -0
- package/src/config/keys.ts +279 -0
- package/src/config/pi-settings.ts +54 -0
- package/src/constants.ts +48 -0
- package/src/data/changelog.ts +63 -0
- package/src/data/content.ts +178 -0
- package/src/data/search.ts +183 -0
- package/src/data/sessions.ts +148 -0
- package/src/data/tree-fold.ts +163 -0
- package/src/data/tree.ts +258 -0
- package/src/i18n/index.ts +103 -0
- package/src/i18n/locales/en.json +341 -0
- package/src/i18n/locales/zh.json +341 -0
- package/src/index.ts +155 -0
- package/src/types.ts +346 -0
- package/src/ui/app.ts +2892 -0
- package/src/ui/frame.ts +116 -0
- package/src/ui/mouse-input.ts +197 -0
- package/src/ui/mouse.ts +99 -0
- package/src/ui/panes/content-pane.ts +132 -0
- package/src/ui/panes/sessions-pane.ts +161 -0
- package/src/ui/panes/tree-pane.ts +152 -0
- package/src/ui/search-highlight.ts +108 -0
- package/src/ui/tree-lines.ts +111 -0
- package/src/ui/tree-outline.ts +80 -0
- package/src/ui/widgets/changelog-dialog.ts +196 -0
- package/src/ui/widgets/compact-dialog.ts +29 -0
- package/src/ui/widgets/confirm-dialog.ts +98 -0
- package/src/ui/widgets/export-dialog.ts +62 -0
- package/src/ui/widgets/footer.ts +117 -0
- package/src/ui/widgets/fork-dialog.ts +28 -0
- package/src/ui/widgets/help-overlay.ts +220 -0
- package/src/ui/widgets/import-dialog.ts +32 -0
- package/src/ui/widgets/input-dialog.ts +136 -0
- package/src/ui/widgets/label-dialog.ts +28 -0
- package/src/ui/widgets/new-session-dialog.ts +29 -0
- package/src/ui/widgets/prompt-bar.ts +92 -0
- package/src/ui/widgets/rename-dialog.ts +29 -0
- package/src/ui/widgets/restore-dialog.ts +60 -0
- package/src/ui/widgets/search-bar.ts +65 -0
- package/src/ui/widgets/select-dialog.ts +193 -0
- package/src/ui/widgets/session-info-dialog.ts +189 -0
- package/src/ui/widgets/tree-dialog.ts +293 -0
- package/src/utils/format.ts +83 -0
- package/src/utils/paths.ts +33 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree actions — side effects triggered from the tree pane.
|
|
3
|
+
*
|
|
4
|
+
* restore -> ctx.navigateTree(entryId, { summarize, customInstructions }) (/tree Enter)
|
|
5
|
+
* other session: ctx.switchSession first, then navigate inside withSession
|
|
6
|
+
* copy -> copyToClipboard(full entry text) (/tree ctrl+x)
|
|
7
|
+
* label -> pi.setLabel / SessionManager.appendLabelChange (/tree shift+T)
|
|
8
|
+
*
|
|
9
|
+
* No dialogs here: the caller collects the summary choice / label first.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
copyToClipboard,
|
|
14
|
+
type ExtensionAPI,
|
|
15
|
+
type ExtensionCommandContext,
|
|
16
|
+
SessionManager,
|
|
17
|
+
} from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { EXTENSION_ID } from "../constants.ts";
|
|
19
|
+
import { isEffectiveLeaf, loadNodeText } from "../data/tree.ts";
|
|
20
|
+
import { t } from "../i18n/index.ts";
|
|
21
|
+
import type { EnterOutcome, RestoreOptions } from "../types.ts";
|
|
22
|
+
import { isCurrentSession, openSessionFile, resumeSession } from "./session-actions.ts";
|
|
23
|
+
|
|
24
|
+
/** The slice of the command context `restoreNode` needs; the `withSession` context has the same shape. */
|
|
25
|
+
export type RestoreContext = Pick<
|
|
26
|
+
ExtensionCommandContext,
|
|
27
|
+
"sessionManager" | "switchSession" | "navigateTree" | "isIdle" | "abort" | "waitForIdle" | "ui"
|
|
28
|
+
>;
|
|
29
|
+
|
|
30
|
+
type NavigateContext = Pick<RestoreContext, "navigateTree" | "isIdle" | "abort" | "waitForIdle" | "ui">;
|
|
31
|
+
|
|
32
|
+
/** How long to wait for pi to settle after aborting the current response before giving up. */
|
|
33
|
+
const IDLE_TIMEOUT_MS = 15_000;
|
|
34
|
+
|
|
35
|
+
/** Enter without a choice (the node is the leaf, or pi's `branchSummary.skipPrompt`): no summary. */
|
|
36
|
+
const NO_SUMMARY: RestoreOptions = { summarize: false };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Continue the conversation from `entryId` of `sessionFile` (what `/tree` does on Enter).
|
|
40
|
+
*
|
|
41
|
+
* 分流和打标签一致:目标是当前会话就直接走 pi 内存里的 `navigateTree`;是其他历史会话就先
|
|
42
|
+
* `switchSession`,再在 pi 给 `withSession` 的新 ctx 里 navigate(切换后旧 ctx 已失效,不能复用)。
|
|
43
|
+
* 光标节点就是活动叶子时不重复 restore,等价于直接进入该会话。
|
|
44
|
+
* `options` 就是 /tree 的三个选择:不做摘要 / 让模型总结被放弃的分支 / 带自定义指令总结。
|
|
45
|
+
*
|
|
46
|
+
* Throws when the file / entry does not exist, pi is busy for too long, the
|
|
47
|
+
* summary needs a model pi does not have, or the switch / navigation was
|
|
48
|
+
* cancelled. A failed navigation *after* a successful switch cannot be thrown
|
|
49
|
+
* back to the panel (pi has already torn it down), so it is reported through
|
|
50
|
+
* the new session's `ui.notify` and the outcome is `switched`.
|
|
51
|
+
*/
|
|
52
|
+
export async function restoreNode(
|
|
53
|
+
ctx: RestoreContext,
|
|
54
|
+
sessionFile: string,
|
|
55
|
+
entryId: string,
|
|
56
|
+
options: RestoreOptions = NO_SUMMARY,
|
|
57
|
+
): Promise<EnterOutcome> {
|
|
58
|
+
const current = isCurrentSession(ctx, sessionFile);
|
|
59
|
+
const manager = current ? ctx.sessionManager : openSessionFile(sessionFile);
|
|
60
|
+
if (!manager.getEntry(entryId)) throw new Error(`entry ${entryId} not found in session`);
|
|
61
|
+
if (isEffectiveLeaf(manager, entryId)) return resumeSession(ctx, sessionFile);
|
|
62
|
+
if (current) {
|
|
63
|
+
await navigateTo(ctx, entryId, options);
|
|
64
|
+
return "restored";
|
|
65
|
+
}
|
|
66
|
+
let outcome: EnterOutcome = "restored";
|
|
67
|
+
await resumeSession(ctx, sessionFile, {
|
|
68
|
+
withSession: async (next) => {
|
|
69
|
+
try {
|
|
70
|
+
await navigateTo(next, entryId, options);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
outcome = "switched";
|
|
73
|
+
next.ui.notify(`restore failed: ${(err as Error).message}`, "error");
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
return outcome;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `navigateTree` refuses to run while pi is still streaming, so do what the
|
|
82
|
+
* built-in /tree does once the user has committed: abort the response, wait for
|
|
83
|
+
* pi to go idle, then move the leaf.
|
|
84
|
+
*
|
|
85
|
+
* 摘要要等模型写完,这期间面板是隐藏的,所以进度写到 pi 自己的 footer 上(`ui.setStatus`),
|
|
86
|
+
* 结束后清掉。扩展 ctx 的 `navigateTree` 把摘要被中止(aborted)也折叠成 `cancelled: true`,
|
|
87
|
+
* 这里分不出是被中止还是被别的扩展否决,只能统一报 cancelled。
|
|
88
|
+
*/
|
|
89
|
+
async function navigateTo(ctx: NavigateContext, entryId: string, options: RestoreOptions): Promise<void> {
|
|
90
|
+
if (!ctx.isIdle()) {
|
|
91
|
+
ctx.abort();
|
|
92
|
+
await withTimeout(ctx.waitForIdle(), IDLE_TIMEOUT_MS, "pi is still busy; try again once the current response has stopped");
|
|
93
|
+
}
|
|
94
|
+
if (options.summarize) ctx.ui.setStatus(EXTENSION_ID, t("status.summarizing"));
|
|
95
|
+
try {
|
|
96
|
+
const result = await ctx.navigateTree(entryId, navigateOptions(options));
|
|
97
|
+
if (result.cancelled) {
|
|
98
|
+
throw new Error(options.summarize ? "branch summary cancelled" : "restore cancelled by an extension");
|
|
99
|
+
}
|
|
100
|
+
} finally {
|
|
101
|
+
if (options.summarize) ctx.ui.setStatus(EXTENSION_ID, undefined);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The `navigateTree` options for a choice; `customInstructions` is only set when there is one. */
|
|
106
|
+
function navigateOptions(options: RestoreOptions): { summarize: boolean; customInstructions?: string } {
|
|
107
|
+
return options.customInstructions
|
|
108
|
+
? { summarize: options.summarize, customInstructions: options.customInstructions }
|
|
109
|
+
: { summarize: options.summarize };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Reject with `message` if `promise` has not settled within `ms`. */
|
|
113
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
114
|
+
return new Promise<T>((resolve, reject) => {
|
|
115
|
+
const timer = setTimeout(() => reject(new Error(message)), ms);
|
|
116
|
+
promise.then(
|
|
117
|
+
(value) => {
|
|
118
|
+
clearTimeout(timer);
|
|
119
|
+
resolve(value);
|
|
120
|
+
},
|
|
121
|
+
(err) => {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
reject(err);
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Copy the full text of a tree node to the system clipboard.
|
|
131
|
+
* Resolves to `false` when the entry has no text to copy (like /tree's
|
|
132
|
+
* "Selected entry has no text to copy").
|
|
133
|
+
*/
|
|
134
|
+
export async function copyNodeText(sessionFile: string, entryId: string): Promise<boolean> {
|
|
135
|
+
const text = loadNodeText(sessionFile, entryId);
|
|
136
|
+
if (!text) return false;
|
|
137
|
+
await copyToClipboard(text);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Set (or clear with `undefined` / "") a label on a tree node.
|
|
143
|
+
*
|
|
144
|
+
* 面板操作的可能是任意历史会话文件:如果就是 pi 当前打开的会话,走 `pi.setLabel`
|
|
145
|
+
* 让 pi 内存里的 SessionManager 同步;否则单独打开该文件追加 label 条目
|
|
146
|
+
* (SessionManager.open 会持久化到 .jsonl)。
|
|
147
|
+
*/
|
|
148
|
+
export async function labelNode(
|
|
149
|
+
pi: Pick<ExtensionAPI, "setLabel">,
|
|
150
|
+
ctx: Pick<ExtensionCommandContext, "sessionManager">,
|
|
151
|
+
sessionFile: string,
|
|
152
|
+
entryId: string,
|
|
153
|
+
label: string | undefined,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const value = label?.trim() || undefined;
|
|
156
|
+
if (isCurrentSession(ctx, sessionFile)) {
|
|
157
|
+
pi.setLabel(entryId, value);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
SessionManager.open(sessionFile).appendLabelChange(entryId, value);
|
|
161
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User configuration loading.
|
|
3
|
+
*
|
|
4
|
+
* Reads `~/.pi/agent/lazy-panel.json` (if present) and merges it over the
|
|
5
|
+
* built-in defaults. Only this module should know where the file lives.
|
|
6
|
+
*
|
|
7
|
+
* 用户配置示例(所有字段可选):
|
|
8
|
+
* {
|
|
9
|
+
* "locale": "zh",
|
|
10
|
+
* "defaultScope": "all",
|
|
11
|
+
* "keymap": {
|
|
12
|
+
* "global": { "help": "F1", "scope-all": ["A", "ctrl+space"] },
|
|
13
|
+
* "sessions": { "session-delete": "ctrl+d", "session-share": null }
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* 同一个 action 的用户键位会整体替换默认键位;值为 null 表示解绑。
|
|
17
|
+
* locale 缺省时按系统语言自动检测(见 i18n/index.ts)。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFile } from "node:fs/promises";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { CONFIG_FILE_NAME, KEY_SCOPES, SESSION_SORT_MODES } from "../constants.ts";
|
|
23
|
+
import { type Locale, SUPPORTED_LOCALES } from "../i18n/index.ts";
|
|
24
|
+
import type { ActionId, KeyChord, Keymap, KeyScope, PaneKeymap, SessionSortMode, UserConfig, UserPaneKeymap } from "../types.ts";
|
|
25
|
+
import { DEFAULT_KEYMAP } from "./keymap.ts";
|
|
26
|
+
|
|
27
|
+
/** Fully resolved configuration used at runtime. */
|
|
28
|
+
export interface ResolvedConfig {
|
|
29
|
+
keymap: Keymap;
|
|
30
|
+
/** UI 语言;缺省(未在用户配置里指定)时按系统语言自动检测。 */
|
|
31
|
+
locale?: Locale;
|
|
32
|
+
defaultScope: NonNullable<UserConfig["defaultScope"]>;
|
|
33
|
+
defaultSort: NonNullable<UserConfig["defaultSort"]>;
|
|
34
|
+
leftColumnRatio: number;
|
|
35
|
+
/** Non-fatal problems found while reading the user file (shown in the footer). */
|
|
36
|
+
warnings: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CONFIG: ResolvedConfig = {
|
|
40
|
+
keymap: DEFAULT_KEYMAP,
|
|
41
|
+
defaultScope: "current-folder",
|
|
42
|
+
defaultSort: "recent",
|
|
43
|
+
leftColumnRatio: 0.25,
|
|
44
|
+
warnings: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Load user config from disk and merge over defaults.
|
|
49
|
+
* A missing file yields the defaults; a broken file yields the defaults plus a warning.
|
|
50
|
+
* @param agentDir pi agent directory (usually ~/.pi/agent)
|
|
51
|
+
*/
|
|
52
|
+
export async function loadConfig(agentDir: string): Promise<ResolvedConfig> {
|
|
53
|
+
const file = join(agentDir, CONFIG_FILE_NAME);
|
|
54
|
+
let raw: string;
|
|
55
|
+
try {
|
|
56
|
+
raw = await readFile(file, "utf8");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_CONFIG;
|
|
59
|
+
return { ...DEFAULT_CONFIG, warnings: [`config: cannot read ${file}: ${(err as Error).message}`] };
|
|
60
|
+
}
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { ...DEFAULT_CONFIG, warnings: [`config: invalid JSON in ${file}: ${(err as Error).message}`] };
|
|
66
|
+
}
|
|
67
|
+
return resolveConfig(parsed);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Pure merge step, separated from I/O so it can be unit-tested. */
|
|
71
|
+
export function resolveConfig(user: unknown): ResolvedConfig {
|
|
72
|
+
const warnings: string[] = [];
|
|
73
|
+
if (!isRecord(user)) {
|
|
74
|
+
return { ...DEFAULT_CONFIG, warnings: ["config: top level must be an object"] };
|
|
75
|
+
}
|
|
76
|
+
const u = user as UserConfig;
|
|
77
|
+
|
|
78
|
+
// locale 只接受我们发布的语言(en / zh),非法值退回系统语言检测(不写入 locale)。
|
|
79
|
+
let locale: Locale | undefined;
|
|
80
|
+
if (u.locale !== undefined) {
|
|
81
|
+
if ((SUPPORTED_LOCALES as readonly string[]).includes(u.locale as string)) {
|
|
82
|
+
locale = u.locale;
|
|
83
|
+
} else {
|
|
84
|
+
warnings.push(`config: unknown locale "${String(u.locale)}"`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const defaultScope =
|
|
89
|
+
u.defaultScope === "all" || u.defaultScope === "current-folder" ? u.defaultScope : DEFAULT_CONFIG.defaultScope;
|
|
90
|
+
if (u.defaultScope !== undefined && defaultScope !== u.defaultScope) warnings.push(`config: unknown defaultScope "${String(u.defaultScope)}"`);
|
|
91
|
+
|
|
92
|
+
const defaultSort = (SESSION_SORT_MODES as readonly string[]).includes(u.defaultSort as string)
|
|
93
|
+
? (u.defaultSort as SessionSortMode)
|
|
94
|
+
: DEFAULT_CONFIG.defaultSort;
|
|
95
|
+
if (u.defaultSort !== undefined && defaultSort !== u.defaultSort) warnings.push(`config: unknown defaultSort "${String(u.defaultSort)}"`);
|
|
96
|
+
|
|
97
|
+
let leftColumnRatio = DEFAULT_CONFIG.leftColumnRatio;
|
|
98
|
+
if (u.leftColumnRatio !== undefined) {
|
|
99
|
+
if (typeof u.leftColumnRatio === "number" && u.leftColumnRatio >= 0.15 && u.leftColumnRatio <= 0.6) {
|
|
100
|
+
leftColumnRatio = u.leftColumnRatio;
|
|
101
|
+
} else {
|
|
102
|
+
warnings.push("config: leftColumnRatio must be a number between 0.15 and 0.6");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const keymap = mergeKeymap(DEFAULT_KEYMAP, u.keymap, warnings);
|
|
107
|
+
// locale 未指定时不写入该键(exactOptionalPropertyTypes),运行时按系统语言。
|
|
108
|
+
return { keymap, defaultScope, defaultSort, leftColumnRatio, warnings, ...(locale !== undefined ? { locale } : {}) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deep-merge the user keymap over the defaults, scope by scope, action by action.
|
|
113
|
+
* 用户为某个 action 提供的键位整体替换默认值(不是追加),null 表示解绑。
|
|
114
|
+
*/
|
|
115
|
+
export function mergeKeymap(base: Keymap, user: UserConfig["keymap"], warnings: string[] = []): Keymap {
|
|
116
|
+
const out = {} as Keymap;
|
|
117
|
+
for (const scope of KEY_SCOPES) out[scope] = { ...base[scope] };
|
|
118
|
+
if (user === undefined) return out;
|
|
119
|
+
if (!isRecord(user)) {
|
|
120
|
+
warnings.push("config: keymap must be an object");
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
for (const [scopeName, paneMap] of Object.entries(user)) {
|
|
124
|
+
if (!KEY_SCOPES.includes(scopeName as KeyScope)) {
|
|
125
|
+
warnings.push(`config: unknown keymap scope "${scopeName}"`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!isRecord(paneMap)) {
|
|
129
|
+
warnings.push(`config: keymap.${scopeName} must be an object`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const target: PaneKeymap = out[scopeName as KeyScope];
|
|
133
|
+
for (const [action, value] of Object.entries(paneMap as UserPaneKeymap)) {
|
|
134
|
+
if (value === null) {
|
|
135
|
+
delete target[action as ActionId];
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (isChordValue(value)) {
|
|
139
|
+
target[action as ActionId] = value;
|
|
140
|
+
} else {
|
|
141
|
+
warnings.push(`config: keymap.${scopeName}.${action} must be a string, string[] or null`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isChordValue(v: unknown): v is KeyChord | KeyChord[] {
|
|
149
|
+
if (typeof v === "string") return v.trim().length > 0;
|
|
150
|
+
return Array.isArray(v) && v.length > 0 && v.every((c) => typeof c === "string" && c.trim().length > 0);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
154
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
155
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default keymap.
|
|
3
|
+
*
|
|
4
|
+
* Bindings follow docs/design.md and lazygit / vim conventions.
|
|
5
|
+
* Users can override any of these via `~/.pi/agent/lazy-panel.json`
|
|
6
|
+
* (see ./config.ts). Chord syntax (see ./keys.ts):
|
|
7
|
+
* "j" single key "ctrl+d" modifier combo
|
|
8
|
+
* "G" uppercase = shift+g "gg" two-key sequence
|
|
9
|
+
* "ctrl+w h" space-separated multi-step sequence
|
|
10
|
+
*
|
|
11
|
+
* 默认键位是纯数据;解析/匹配逻辑在 keys.ts,合并用户配置在 config.ts。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { TREE_DIALOG_SCOPE } from "../constants.ts";
|
|
15
|
+
import { t } from "../i18n/index.ts";
|
|
16
|
+
import type { ActionId, KeyScope, Keymap, PaneId } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_KEYMAP: Keymap = {
|
|
19
|
+
// 每个 scope 里 key 的书写顺序 = ? 帮助里的展示顺序(buildScopeLines 遍历 Object.keys),
|
|
20
|
+
// 所以按使用频率排:高频在前。键位解析和顺序无关,footer 另有 FOOTER_HINTS 顺序,互不影响。
|
|
21
|
+
global: {
|
|
22
|
+
search: "/",
|
|
23
|
+
// 面板切换参考 lazygit:h/l 前后切换,1/2/3 直接跳到对应编号的面板。
|
|
24
|
+
"focus-next": ["l", "tab"],
|
|
25
|
+
"focus-prev": "h",
|
|
26
|
+
"focus-sessions": "1",
|
|
27
|
+
"focus-tree": "2",
|
|
28
|
+
"focus-content": "3",
|
|
29
|
+
"search-next": "n",
|
|
30
|
+
"search-prev": "N",
|
|
31
|
+
// C / A 各自只切到一种范围,不做 toggle。
|
|
32
|
+
"scope-current": "C",
|
|
33
|
+
"scope-all": "A",
|
|
34
|
+
help: "?",
|
|
35
|
+
// 和 pi 的 /changelog 一样查看 pi 的更新日志(居中大弹窗,可滚动)。
|
|
36
|
+
changelog: "@",
|
|
37
|
+
quit: ["q", "ctrl+c"],
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
sessions: {
|
|
41
|
+
"session-resume": "return",
|
|
42
|
+
"move-down": ["j", "down"],
|
|
43
|
+
"move-up": ["k", "up"],
|
|
44
|
+
"go-top": "gg",
|
|
45
|
+
"go-bottom": "G",
|
|
46
|
+
"session-new": "n",
|
|
47
|
+
"session-delete": "d",
|
|
48
|
+
"session-rename": "r",
|
|
49
|
+
"session-sort": "s",
|
|
50
|
+
"session-info": "i",
|
|
51
|
+
"session-toggle-select": "space",
|
|
52
|
+
// c 压缩光标所在会话(对应 /compact);C(大写)是全局 scope-current,不冲突。
|
|
53
|
+
"session-compact": "c",
|
|
54
|
+
"session-fork": "o",
|
|
55
|
+
"session-clone": "y",
|
|
56
|
+
"session-copy-last-reply": "Y",
|
|
57
|
+
"scroll-content-down": "J",
|
|
58
|
+
"scroll-content-up": "K",
|
|
59
|
+
"session-export": "e",
|
|
60
|
+
"session-import": "I",
|
|
61
|
+
"session-share": "S",
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
tree: {
|
|
65
|
+
"tree-restore": "return",
|
|
66
|
+
"move-down": ["j", "down"],
|
|
67
|
+
"move-up": ["k", "up"],
|
|
68
|
+
"go-top": "gg",
|
|
69
|
+
"go-bottom": "G",
|
|
70
|
+
// 折叠 / 展开光标所在的分支段(vim 的 za)。
|
|
71
|
+
"tree-fold": "z",
|
|
72
|
+
// 小面板只显示部分数据,搜索 / 过滤放在 a 打开的完整树对话框里。
|
|
73
|
+
"tree-open": "a",
|
|
74
|
+
// 打标签用 T,和 pi 自带 /tree 的 shift+T 一致;这样 l 留给全局的“下一个面板”。
|
|
75
|
+
"tree-label": "T",
|
|
76
|
+
"tree-copy": "y",
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// 只读面板:只保留上下滚动 + 顶部/底部(搜索 / 帮助等走 global)。
|
|
80
|
+
content: {
|
|
81
|
+
"move-down": ["j", "down"],
|
|
82
|
+
"move-up": ["k", "up"],
|
|
83
|
+
"go-top": "gg",
|
|
84
|
+
"go-bottom": "G",
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// 树对话框(a 打开):这里只放对话框独有的键;j/k、gg/G、Enter、y、T、z 沿用 tree 面板的绑定,
|
|
88
|
+
// `/` 沿用 global 的 search(在对话框里是聚焦顶部的搜索框)。过滤键和 pi /tree 的 ctrl+d/t/u/l/a 一一对应,
|
|
89
|
+
// 所以 l 在对话框里是 labeled 过滤而不是"下一个面板"(h 没有对话框绑定,切面板在这里被关掉)。
|
|
90
|
+
[TREE_DIALOG_SCOPE]: {
|
|
91
|
+
"tree-filter-default": "d",
|
|
92
|
+
"tree-filter-no-tools": "t",
|
|
93
|
+
"tree-filter-user": "u",
|
|
94
|
+
"tree-filter-labeled": "l",
|
|
95
|
+
"tree-filter-all": "a",
|
|
96
|
+
"tree-dialog-close": "q",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** Short description of `action`, shown in the help overlay (localised). */
|
|
101
|
+
export function actionDescription(action: ActionId): string {
|
|
102
|
+
return t(`action.${action}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Actions of outer scopes that do nothing while `scope` has the keys.
|
|
107
|
+
* Inside the tree dialog, pane switching, list scope, n / N, quitting the
|
|
108
|
+
* panel, `?` (every key is on the dialog's own hint row), `a` (the dialog
|
|
109
|
+
* is already open) and `@` (the changelog) are switched off.
|
|
110
|
+
*
|
|
111
|
+
* 外层 scope 里在这里关掉的动作:对话框里 h/1/2/3/Tab 等不再切换面板(l 被对话框自己的
|
|
112
|
+
* labeled 过滤遮住了),? 也不开帮助——对话框底部一行已经列全了它的键。
|
|
113
|
+
*/
|
|
114
|
+
export const DISABLED_ACTIONS: Partial<Record<KeyScope, ActionId[]>> = {
|
|
115
|
+
[TREE_DIALOG_SCOPE]: [
|
|
116
|
+
"focus-next",
|
|
117
|
+
"focus-prev",
|
|
118
|
+
"focus-sessions",
|
|
119
|
+
"focus-tree",
|
|
120
|
+
"focus-content",
|
|
121
|
+
"scope-current",
|
|
122
|
+
"scope-all",
|
|
123
|
+
"search-next",
|
|
124
|
+
"search-prev",
|
|
125
|
+
"help",
|
|
126
|
+
"quit",
|
|
127
|
+
"tree-open",
|
|
128
|
+
"changelog",
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Is `action` (bound in an outer scope) switched off while `scope` is focused? */
|
|
133
|
+
export function isDisabledIn(scope: KeyScope, action: ActionId): boolean {
|
|
134
|
+
return DISABLED_ACTIONS[scope]?.includes(action) ?? false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Actions merged into a single help line (`?` overlay).
|
|
139
|
+
*
|
|
140
|
+
* 帮助面板里同类操作合并成一行,省空间:例如 1/2/3 显示成 `1..3 Focus pane by number`。
|
|
141
|
+
* 合并只影响帮助展示,不影响键位解析。组内只要有 ≥2 个动作在当前 scope 绑定了键位就合并,
|
|
142
|
+
* 否则退回单条展示;用户自定义键位一样会如实显示。
|
|
143
|
+
*/
|
|
144
|
+
export interface HelpGroup {
|
|
145
|
+
/** Member actions, in the order their keys are listed. */
|
|
146
|
+
actions: ActionId[];
|
|
147
|
+
/** i18n key (under `helpGroup.`) for the merged line's description. */
|
|
148
|
+
key: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Localised description of a merged help line. */
|
|
152
|
+
export function helpGroupText(group: HelpGroup): string {
|
|
153
|
+
return t(`helpGroup.${group.key}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export const HELP_GROUPS: HelpGroup[] = [
|
|
157
|
+
{ actions: ["focus-prev", "focus-next"], key: "focus" },
|
|
158
|
+
{ actions: ["focus-sessions", "focus-tree", "focus-content"], key: "focusNumber" },
|
|
159
|
+
{ actions: ["scope-current", "scope-all"], key: "scope" },
|
|
160
|
+
{ actions: ["search-next", "search-prev"], key: "searchStep" },
|
|
161
|
+
{ actions: ["go-top", "go-bottom"], key: "topBottom" },
|
|
162
|
+
{ actions: ["scroll-content-down", "scroll-content-up"], key: "scrollContent" },
|
|
163
|
+
{
|
|
164
|
+
actions: ["tree-filter-default", "tree-filter-no-tools", "tree-filter-user", "tree-filter-labeled", "tree-filter-all"],
|
|
165
|
+
key: "filters",
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Actions shown as footer hints per pane, in display order (first few that fit).
|
|
171
|
+
*
|
|
172
|
+
* 只留最常用的键,长尾(排序 / 信息 / 压缩 / fork / clone / 复制 / 导出 / 导入 / 分享 / changelog)
|
|
173
|
+
* 都收进 ? 帮助里,避免 footer 挤满一串半高频的键。
|
|
174
|
+
*/
|
|
175
|
+
export const FOOTER_HINTS: Record<PaneId, ActionId[]> = {
|
|
176
|
+
sessions: ["search", "focus-next", "scope-current", "scope-all", "session-resume", "session-delete", "session-rename", "session-new", "help", "quit"],
|
|
177
|
+
tree: ["search", "focus-next", "tree-restore", "tree-fold", "tree-open", "help", "quit"],
|
|
178
|
+
content: ["search", "focus-next", "go-top", "go-bottom", "help", "quit"],
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Hint rows of the tree dialog (its bottom row and the footer), in display
|
|
183
|
+
* order — the first ones survive a narrow terminal, so `q close` comes before
|
|
184
|
+
* the filters; an inner array is one merged hint such as `d/t/u/l/a filter`.
|
|
185
|
+
* Keys come from the resolved keymap (`tree-dialog` scope, then `tree`, then
|
|
186
|
+
* `global`), the wording from treeDialogHintText().
|
|
187
|
+
*/
|
|
188
|
+
export const TREE_DIALOG_FOOTER: ActionId[][] = [
|
|
189
|
+
["search"],
|
|
190
|
+
["move-down", "move-up"],
|
|
191
|
+
["tree-restore"],
|
|
192
|
+
["tree-dialog-close"],
|
|
193
|
+
["tree-fold"],
|
|
194
|
+
["tree-filter-default", "tree-filter-no-tools", "tree-filter-user", "tree-filter-labeled", "tree-filter-all"],
|
|
195
|
+
["tree-copy"],
|
|
196
|
+
["tree-label"],
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
/** Wording of a TREE_DIALOG_FOOTER hint (localised), keyed by its first action. */
|
|
200
|
+
export function treeDialogHintText(action: ActionId): string {
|
|
201
|
+
return t(`treeHint.${action}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Display name of a scope in the help overlay (localised). */
|
|
205
|
+
export function scopeTitle(scope: KeyScope): string {
|
|
206
|
+
return t(`scopeTitle.${scope}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Pane title shown in the frame header (localised); the panel prefixes it with the jump key ("[1] SESSIONS"). */
|
|
210
|
+
export function paneTitleText(pane: PaneId): string {
|
|
211
|
+
return t(`pane.${pane}Title`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Action that focuses each pane, used to derive the "[1]" prefix from the resolved keymap. */
|
|
215
|
+
export const FOCUS_ACTIONS: Record<PaneId, ActionId> = {
|
|
216
|
+
sessions: "focus-sessions",
|
|
217
|
+
tree: "focus-tree",
|
|
218
|
+
content: "focus-content",
|
|
219
|
+
};
|