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,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Changelog dialog (`@`, global): pi's `/changelog` in a big scrollable box.
|
|
3
|
+
*
|
|
4
|
+
* ┌─ What's New ─────────────────────────────── 1-38/2140 ─┐
|
|
5
|
+
* │ 0.85.1 - 2026-09-05 │
|
|
6
|
+
* │ ... │
|
|
7
|
+
* └────────────────────────────────────────────────────────┘
|
|
8
|
+
* CHANGELOG │ j/k scroll ctrl+d/u page g/G top/bottom Esc close
|
|
9
|
+
*
|
|
10
|
+
* The markdown is rendered with pi-tui's `Markdown` (CLAUDE.md rule 8) once per
|
|
11
|
+
* width and cached; the box takes the whole terminal but a 2-column / 1-row
|
|
12
|
+
* margin, like the tree dialog. `j` `k` `↑` `↓` scroll a line, `ctrl+d` /
|
|
13
|
+
* `ctrl+u` / PageDown / PageUp half a page, `g` / `G` jump to the top / bottom,
|
|
14
|
+
* Esc / `q` close (the panel also closes it on its own `@` key). Every other key
|
|
15
|
+
* is swallowed. Loading the file is the panel's job (data/changelog.ts).
|
|
16
|
+
*
|
|
17
|
+
* changelog 弹窗:只管画和滚动;Markdown 按宽度缓存,内容由面板通过 DataSource 加载。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Markdown, visibleWidth } from "@earendil-works/pi-tui";
|
|
22
|
+
import { SPINNER_FRAMES } from "../../constants.ts";
|
|
23
|
+
import { matchesKeyId } from "../../config/keys.ts";
|
|
24
|
+
import { t } from "../../i18n/index.ts";
|
|
25
|
+
import type { KeyHint } from "../../types.ts";
|
|
26
|
+
import { frame, overlayCentered } from "../frame.ts";
|
|
27
|
+
|
|
28
|
+
/** Title on the top border (pi's own heading for /changelog, localised). */
|
|
29
|
+
export function changelogTitle(): string {
|
|
30
|
+
return t("dialog.changelogTitle");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Loading line while the (large) changelog markdown is being rendered (localised). */
|
|
34
|
+
export function changelogLoading(): string {
|
|
35
|
+
return t("dialog.changelogLoading");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Footer hints while the dialog is open. */
|
|
39
|
+
export function changelogHints(): KeyHint[] {
|
|
40
|
+
return [
|
|
41
|
+
["j/k", t("hint.scroll")],
|
|
42
|
+
["ctrl+d/u", t("hint.page")],
|
|
43
|
+
["g/G", t("hint.topBottom")],
|
|
44
|
+
["Esc", t("hint.close")],
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChangelogDialogOptions {
|
|
49
|
+
theme: Theme;
|
|
50
|
+
onClose: () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class ChangelogDialog {
|
|
54
|
+
private markdown: string | undefined;
|
|
55
|
+
/** True between `openLoading()` and `setContent()` while the markdown is loaded / rendered. */
|
|
56
|
+
private loading = false;
|
|
57
|
+
/** Rotating-square frame index for the loading line, advanced by the panel. */
|
|
58
|
+
private spinnerFrame = 0;
|
|
59
|
+
private scroll = 0;
|
|
60
|
+
/** Rendered lines cached by (width, markdown) so reopening the same changelog is instant. */
|
|
61
|
+
private cache: { width: number; markdown: string; lines: string[] } | undefined;
|
|
62
|
+
/** Body rows visible at the last render, for paging and clamping. */
|
|
63
|
+
private visible = 10;
|
|
64
|
+
|
|
65
|
+
constructor(private readonly o: ChangelogDialogOptions) {}
|
|
66
|
+
|
|
67
|
+
get isOpen(): boolean {
|
|
68
|
+
return this.loading || this.markdown !== undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True while waiting for the changelog to load / render (the box shows the loading line). */
|
|
72
|
+
get isLoading(): boolean {
|
|
73
|
+
return this.loading;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get hints(): KeyHint[] {
|
|
77
|
+
// 加载中不给滚动提示(还没内容可滚);关闭时也没有。
|
|
78
|
+
return this.markdown !== undefined ? changelogHints() : [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Current top line (for tests). */
|
|
82
|
+
get scrollTop(): number {
|
|
83
|
+
return this.scroll;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Show the loading box while the markdown is fetched / rendered. */
|
|
87
|
+
openLoading(): void {
|
|
88
|
+
this.loading = true;
|
|
89
|
+
this.markdown = undefined;
|
|
90
|
+
this.spinnerFrame = 0;
|
|
91
|
+
this.scroll = 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Advance the loading spinner one frame (driven by the panel's timer). */
|
|
95
|
+
advanceSpinner(): void {
|
|
96
|
+
this.spinnerFrame++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Show the changelog content; the rendered lines are cached across opens so reopening is instant. */
|
|
100
|
+
setContent(markdown: string): void {
|
|
101
|
+
this.markdown = markdown;
|
|
102
|
+
this.loading = false;
|
|
103
|
+
this.scroll = 0;
|
|
104
|
+
// 不主动清 cache:lines() 按 (width, markdown) 判断能否复用,同一份 changelog 再开就秒开。
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
close(): void {
|
|
108
|
+
this.markdown = undefined;
|
|
109
|
+
this.loading = false;
|
|
110
|
+
// 保留 cache:同一宽度、同一份 changelog 下次打开直接复用渲染结果。
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
handleInput(data: string): void {
|
|
114
|
+
if (!this.isOpen) return;
|
|
115
|
+
// 加载中只允许取消(Esc / q),滚动等按键先吞掉。
|
|
116
|
+
if (matchesKeyId(data, "escape") || matchesKeyId(data, "q")) {
|
|
117
|
+
this.o.onClose();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (this.loading) return;
|
|
121
|
+
const half = Math.max(1, Math.floor(this.visible / 2));
|
|
122
|
+
if (matchesKeyId(data, "j") || matchesKeyId(data, "down")) this.scrollBy(1);
|
|
123
|
+
else if (matchesKeyId(data, "k") || matchesKeyId(data, "up")) this.scrollBy(-1);
|
|
124
|
+
else if (matchesKeyId(data, "ctrl+d") || matchesKeyId(data, "pagedown")) this.scrollBy(half);
|
|
125
|
+
else if (matchesKeyId(data, "ctrl+u") || matchesKeyId(data, "pageup")) this.scrollBy(-half);
|
|
126
|
+
else if (matchesKeyId(data, "g")) this.scroll = 0;
|
|
127
|
+
else if (matchesKeyId(data, "shift+g")) this.scroll = Number.MAX_SAFE_INTEGER;
|
|
128
|
+
// 其他按键一律吞掉;越界的滚动位置在 render 时夹回来。
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private scrollBy(delta: number): void {
|
|
132
|
+
this.scroll = Math.max(0, this.scroll + delta);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private lines(width: number): string[] {
|
|
136
|
+
const markdown = this.markdown ?? "";
|
|
137
|
+
if (this.cache?.width === width && this.cache.markdown === markdown) return this.cache.lines;
|
|
138
|
+
const md = new Markdown(markdown, 1, 0, getMarkdownTheme());
|
|
139
|
+
const lines = md.render(width);
|
|
140
|
+
this.cache = { width, markdown, lines };
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Box size for a `termW` × `termH` terminal (same margins as the tree dialog). */
|
|
145
|
+
static size(termW: number, termH: number): { width: number; height: number } {
|
|
146
|
+
return { width: Math.max(24, termW - 4), height: Math.max(5, termH - 2) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render the box itself, every line exactly `width` columns and `height` lines tall. */
|
|
150
|
+
render(width: number, height: number): string[] {
|
|
151
|
+
const { theme } = this.o;
|
|
152
|
+
this.visible = Math.max(1, height - 2);
|
|
153
|
+
if (this.loading) return this.renderLoading(width, height);
|
|
154
|
+
const all = this.lines(width - 2);
|
|
155
|
+
const maxTop = Math.max(0, all.length - this.visible);
|
|
156
|
+
this.scroll = Math.min(this.scroll, maxTop);
|
|
157
|
+
const body = all.slice(this.scroll, this.scroll + this.visible);
|
|
158
|
+
const last = Math.min(all.length, this.scroll + this.visible);
|
|
159
|
+
const meta = all.length > this.visible ? `${this.scroll + 1}-${last}/${all.length}` : "";
|
|
160
|
+
return frame(body, {
|
|
161
|
+
width,
|
|
162
|
+
height,
|
|
163
|
+
title: changelogTitle(),
|
|
164
|
+
...(meta ? { meta } : {}),
|
|
165
|
+
border: (s) => theme.fg("borderAccent", s),
|
|
166
|
+
titleStyle: (s) => theme.bold(theme.fg("accent", s)),
|
|
167
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The loading box: an empty body with a centered spinner + text in its middle. */
|
|
172
|
+
private renderLoading(width: number, height: number): string[] {
|
|
173
|
+
const { theme } = this.o;
|
|
174
|
+
const inner = width - 2;
|
|
175
|
+
const glyph = SPINNER_FRAMES[this.spinnerFrame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
|
|
176
|
+
const msg = `${glyph} ${changelogLoading()}`;
|
|
177
|
+
const pad = Math.max(0, Math.floor((inner - visibleWidth(msg)) / 2));
|
|
178
|
+
const body: string[] = Array.from({ length: this.visible }, () => "");
|
|
179
|
+
// 垂直居中:放在中间那一行。
|
|
180
|
+
body[Math.floor((this.visible - 1) / 2)] = " ".repeat(pad) + theme.fg("dim", msg);
|
|
181
|
+
return frame(body, {
|
|
182
|
+
width,
|
|
183
|
+
height,
|
|
184
|
+
title: changelogTitle(),
|
|
185
|
+
border: (s) => theme.fg("borderAccent", s),
|
|
186
|
+
titleStyle: (s) => theme.bold(theme.fg("accent", s)),
|
|
187
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Composite the box centered over the already-rendered panel `lines`. */
|
|
192
|
+
overlay(lines: string[], termW: number): string[] {
|
|
193
|
+
const { width, height } = ChangelogDialog.size(termW, lines.length);
|
|
194
|
+
return overlayCentered(lines, this.render(width, height), width, termW);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compact prompt (`c` in the sessions pane, what `/compact [instructions]` does):
|
|
3
|
+
* the title and footer hints that ./input-dialog.ts shows while asking for the
|
|
4
|
+
* optional focus instructions, same idea as ./rename-dialog.ts.
|
|
5
|
+
*
|
|
6
|
+
* The panel owns the target session and runs the compaction through the injected
|
|
7
|
+
* actions; the input is optional — an empty submission compacts with pi's default
|
|
8
|
+
* instructions, non-empty text focuses the summary.
|
|
9
|
+
*
|
|
10
|
+
* 压缩会话是通用输入弹窗(InputDialog)的一个预设:标题 + footer 提示;弹窗标题右侧显示
|
|
11
|
+
* 被压缩的会话(首条消息预览),输入可留空(空 = 用 pi 的默认压缩指令)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { t } from "../../i18n/index.ts";
|
|
15
|
+
import type { KeyHint } from "../../types.ts";
|
|
16
|
+
|
|
17
|
+
/** Title on the top border of the box. */
|
|
18
|
+
export function compactDialogTitle(): string {
|
|
19
|
+
return t("dialog.compactTitle");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Footer hints while the dialog is open. */
|
|
23
|
+
export function compactDialogHints(): KeyHint[] {
|
|
24
|
+
return [
|
|
25
|
+
["Enter", t("hint.compact")],
|
|
26
|
+
["Esc", t("hint.cancel")],
|
|
27
|
+
[t("hint.emptyKey"), t("hint.compactDefault")],
|
|
28
|
+
];
|
|
29
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Confirm dialog: the Yes / No menu shown before every destructive or outbound
|
|
3
|
+
* action (delete, fork, clone, share, import, overwriting an export — CLAUDE.md rule 7).
|
|
4
|
+
*
|
|
5
|
+
* ┌─ Delete session? ──────────── FilmRecall ─┐
|
|
6
|
+
* │ Yes │
|
|
7
|
+
* │ › No │
|
|
8
|
+
* └────────────────────────────────────────────┘
|
|
9
|
+
* CONFIRM │ y/n choose Enter confirm Esc cancel <- footer while open
|
|
10
|
+
*
|
|
11
|
+
* A preset of ./select-dialog.ts, like ./restore-dialog.ts: the cursor starts
|
|
12
|
+
* on No so a stray Enter never destroys anything, `y` / `n` pick directly,
|
|
13
|
+
* Enter confirms the highlighted entry and Esc cancels.
|
|
14
|
+
*
|
|
15
|
+
* 删除 / fork 前的确认框是通用选择菜单(SelectDialog)的一个预设:默认停在 No,
|
|
16
|
+
* y / n 直接选,Enter 确认光标所在项,Esc 取消。这里只放文案、顺序和提示,不做 I/O。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { t } from "../../i18n/index.ts";
|
|
20
|
+
import type { KeyHint } from "../../types.ts";
|
|
21
|
+
import type { SelectDialogSpec } from "./select-dialog.ts";
|
|
22
|
+
|
|
23
|
+
/** Menu entries, localised; `CONFIRM_YES_INDEX` / `CONFIRM_NO_INDEX` name them. */
|
|
24
|
+
export function confirmItems(): string[] {
|
|
25
|
+
return [t("confirm.yes"), t("confirm.no")];
|
|
26
|
+
}
|
|
27
|
+
export const CONFIRM_YES_INDEX = 0;
|
|
28
|
+
export const CONFIRM_NO_INDEX = 1;
|
|
29
|
+
|
|
30
|
+
/** Footer hints while a confirmation is open. */
|
|
31
|
+
export function confirmHints(): KeyHint[] {
|
|
32
|
+
return [
|
|
33
|
+
["y/n", t("hint.choose")],
|
|
34
|
+
["Enter", t("hint.confirm")],
|
|
35
|
+
["Esc", t("hint.cancel")],
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Title of the delete-session confirmation. */
|
|
40
|
+
export function deleteSessionTitle(): string {
|
|
41
|
+
return t("confirm.delete");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Title of the batch delete confirmation (`d` with a multi-selection). */
|
|
45
|
+
export function deleteSessionsTitle(count: number): string {
|
|
46
|
+
return t("confirm.deleteN", { count });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Title of the clone-session confirmation (`y`). */
|
|
50
|
+
export function cloneSessionTitle(): string {
|
|
51
|
+
return t("confirm.clone");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Title of the fork confirmation, shown after a message is picked (`o`). */
|
|
55
|
+
export function forkSessionTitle(): string {
|
|
56
|
+
return t("confirm.fork");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Title of the share confirmation (`S`): the session leaves the machine. */
|
|
60
|
+
export function shareSessionTitle(): string {
|
|
61
|
+
return t("confirm.share");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Title of the import confirmation (`I`), pi's "Replace current session with …?". */
|
|
65
|
+
export function importSessionTitle(): string {
|
|
66
|
+
return t("confirm.import");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Title of the confirmation before an export overwrites an existing file (`e`). */
|
|
70
|
+
export function overwriteFileTitle(): string {
|
|
71
|
+
return t("confirm.overwrite");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ConfirmSpecOptions {
|
|
75
|
+
/** Title on the top border, e.g. "Delete session?". */
|
|
76
|
+
title: string;
|
|
77
|
+
/** What is about to be acted on, shown at the right end of the title bar. */
|
|
78
|
+
subject?: string;
|
|
79
|
+
onConfirm: () => void;
|
|
80
|
+
onCancel: () => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The `SelectDialog.open` spec of a confirmation: Yes / No with the cursor on
|
|
85
|
+
* No; picking No is the same as Esc.
|
|
86
|
+
*/
|
|
87
|
+
export function confirmDialogSpec(o: ConfirmSpecOptions): SelectDialogSpec {
|
|
88
|
+
return {
|
|
89
|
+
title: o.title,
|
|
90
|
+
items: confirmItems(),
|
|
91
|
+
initialIndex: CONFIRM_NO_INDEX,
|
|
92
|
+
...(o.subject ? { subject: o.subject } : {}),
|
|
93
|
+
hints: confirmHints(),
|
|
94
|
+
shortcuts: { y: CONFIRM_YES_INDEX, n: CONFIRM_NO_INDEX },
|
|
95
|
+
onSelect: (index) => (index === CONFIRM_YES_INDEX ? o.onConfirm() : o.onCancel()),
|
|
96
|
+
onCancel: o.onCancel,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export dialogs (`e` in the sessions pane, what pi's `/export` does): the
|
|
3
|
+
* format menu and the output-path prompt.
|
|
4
|
+
*
|
|
5
|
+
* ┌─ Export as ─────────────────────────── FilmRecall ─┐
|
|
6
|
+
* │ › HTML whole tree, opens in a browser │
|
|
7
|
+
* │ JSONL active branch, can be imported again │
|
|
8
|
+
* └──────────────────────────────────────────────────────┘
|
|
9
|
+
* ┌─ Export to ──────────────────────────── FilmRecall ─┐
|
|
10
|
+
* │ E:\work\pi-session-2026-09-23T….html │
|
|
11
|
+
* └──────────────────────────────────────────────────────┘
|
|
12
|
+
*
|
|
13
|
+
* Only wording for a ./select-dialog.ts and an ./input-dialog.ts preset, like
|
|
14
|
+
* ./restore-dialog.ts. The path is pre-filled with pi's default (the actions
|
|
15
|
+
* layer resolves it); a directory gets the default file name inside it, and an
|
|
16
|
+
* existing file is confirmed (./confirm-dialog.ts) before it is overwritten.
|
|
17
|
+
*
|
|
18
|
+
* 导出只是两个通用弹窗的预设:先选格式(SelectDialog),再输入输出路径(InputDialog,
|
|
19
|
+
* 预填 pi 的默认路径,Esc 退回格式菜单)。目标文件已存在时再弹确认框。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { t } from "../../i18n/index.ts";
|
|
23
|
+
import type { ExportFormat, KeyHint } from "../../types.ts";
|
|
24
|
+
|
|
25
|
+
/** Title of the format menu (localised). */
|
|
26
|
+
export function exportFormatTitle(): string {
|
|
27
|
+
return t("dialog.exportFormatTitle");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Format order, top to bottom (HTML first, like /export without an extension). */
|
|
31
|
+
export const EXPORT_FORMAT_ORDER: readonly ExportFormat[] = ["html", "jsonl"];
|
|
32
|
+
|
|
33
|
+
/** Menu entries, top to bottom, localised. */
|
|
34
|
+
export function exportFormats(): ReadonlyArray<{ format: ExportFormat; label: string }> {
|
|
35
|
+
return [
|
|
36
|
+
{ format: "html", label: t("dialog.exportHtml") },
|
|
37
|
+
{ format: "jsonl", label: t("dialog.exportJsonl") },
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Footer hints while the format menu is open. */
|
|
42
|
+
export function exportFormatHints(): KeyHint[] {
|
|
43
|
+
return [
|
|
44
|
+
["j/k", t("hint.move")],
|
|
45
|
+
["Enter", t("hint.select")],
|
|
46
|
+
["Esc", t("hint.cancel")],
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Title of the output-path prompt (localised). */
|
|
51
|
+
export function exportPathTitle(): string {
|
|
52
|
+
return t("dialog.exportPathTitle");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Footer hints while the output-path prompt is open. */
|
|
56
|
+
export function exportPathHints(): KeyHint[] {
|
|
57
|
+
return [
|
|
58
|
+
["Enter", t("hint.export")],
|
|
59
|
+
["Esc", t("hint.back")],
|
|
60
|
+
[t("hint.folderKey"), t("hint.defaultInside")],
|
|
61
|
+
];
|
|
62
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer bar.
|
|
3
|
+
*
|
|
4
|
+
* Single line at the bottom of the panel:
|
|
5
|
+
* │ NORMAL │ / Search ? Help l Focus Enter Resume d Delete q Quit
|
|
6
|
+
*
|
|
7
|
+
* Shows the current mode and the most relevant bindings for the focused pane.
|
|
8
|
+
* Hints are derived from the resolved keymap, so custom bindings show up here.
|
|
9
|
+
* When search mode is active the panel renders the search bar instead; while a
|
|
10
|
+
* dialog owns the keyboard (label / restore mode) the panel passes the
|
|
11
|
+
* dialog's own hints via `hints`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { FOOTER_HINTS } from "../../config/keymap.ts";
|
|
17
|
+
import { labelsForFocus } from "../../config/keys.ts";
|
|
18
|
+
import { t } from "../../i18n/index.ts";
|
|
19
|
+
import type { ActionId, KeyHint, Keymap, ListScope, PaneId, PanelMode } from "../../types.ts";
|
|
20
|
+
import { fit } from "../frame.ts";
|
|
21
|
+
|
|
22
|
+
export interface FooterProps {
|
|
23
|
+
mode: PanelMode;
|
|
24
|
+
focus: PaneId;
|
|
25
|
+
keymap: Keymap;
|
|
26
|
+
theme: Theme;
|
|
27
|
+
/** Current list scope; the hint for the scope already active is hidden ("A All" while on Current). */
|
|
28
|
+
scope?: ListScope;
|
|
29
|
+
/** Optional status text (e.g. "loading…" or an error). */
|
|
30
|
+
status?: string;
|
|
31
|
+
/** Replace the keymap-derived hints, e.g. with a dialog's keys while it is open. */
|
|
32
|
+
hints?: KeyHint[];
|
|
33
|
+
/** Extension version, shown muted at the far right (e.g. "0.1.0" → "v0.1.0"). */
|
|
34
|
+
version?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Scope actions are one-way, so only the one that would change something is worth a hint. */
|
|
38
|
+
const SCOPE_ACTION_OF: Record<ListScope, ActionId> = {
|
|
39
|
+
"current-folder": "scope-current",
|
|
40
|
+
all: "scope-all",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Actions with a short footer wording (under `footer.` in the locale files); others fall back to the action id. */
|
|
44
|
+
const HAS_SHORT: ReadonlySet<ActionId> = new Set<ActionId>([
|
|
45
|
+
"search",
|
|
46
|
+
"help",
|
|
47
|
+
"focus-next",
|
|
48
|
+
"scope-current",
|
|
49
|
+
"scope-all",
|
|
50
|
+
"quit",
|
|
51
|
+
"session-resume",
|
|
52
|
+
"session-delete",
|
|
53
|
+
"session-rename",
|
|
54
|
+
"session-new",
|
|
55
|
+
"session-fork",
|
|
56
|
+
"session-clone",
|
|
57
|
+
"session-copy-last-reply",
|
|
58
|
+
"session-export",
|
|
59
|
+
"session-import",
|
|
60
|
+
"session-share",
|
|
61
|
+
"session-sort",
|
|
62
|
+
"session-info",
|
|
63
|
+
"session-compact",
|
|
64
|
+
"tree-restore",
|
|
65
|
+
"tree-open",
|
|
66
|
+
"tree-fold",
|
|
67
|
+
"tree-label",
|
|
68
|
+
"tree-copy",
|
|
69
|
+
"go-top",
|
|
70
|
+
"go-bottom",
|
|
71
|
+
"changelog",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
/** Short footer wording of `action` (localised), falling back to the action id. */
|
|
75
|
+
function footerLabel(action: ActionId): string {
|
|
76
|
+
return HAS_SHORT.has(action) ? t(`footer.${action}`) : action;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderFooter(p: FooterProps, width: number): string[] {
|
|
80
|
+
const { theme } = p;
|
|
81
|
+
const mode = theme.bold(theme.bg("selectedBg", ` ${t(`mode.${p.mode}`)} `));
|
|
82
|
+
const status = p.status ? ` ${theme.fg("warning", p.status)}` : "";
|
|
83
|
+
// 右下角的版本号(muted 弱化显示),先给它预留位置,避免按键提示占满后把它挤没。
|
|
84
|
+
const version = p.version ? theme.fg("muted", `v${p.version}`) : "";
|
|
85
|
+
const versionBudget = version ? visibleWidth(version) + 2 : 0;
|
|
86
|
+
const budget = width - visibleWidth(mode) - visibleWidth(status) - versionBudget - 1;
|
|
87
|
+
|
|
88
|
+
// 只显示放得下的提示,避免窄终端里被截断成半个词。
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
let used = 0;
|
|
91
|
+
for (const [key, text] of p.hints ?? keymapHints(p)) {
|
|
92
|
+
const part = `${theme.bold(theme.fg("accent", key))} ${theme.fg("muted", text)}`;
|
|
93
|
+
const w = visibleWidth(part) + (parts.length ? 3 : 0);
|
|
94
|
+
if (used + w > budget) break;
|
|
95
|
+
parts.push(part);
|
|
96
|
+
used += w;
|
|
97
|
+
}
|
|
98
|
+
const left = `${mode} ${parts.join(" ")}${status}`;
|
|
99
|
+
if (!version) return [fit(left, width)];
|
|
100
|
+
// 版本号靠右:中间用空格撑开,整体再 fit 到宽度(放不下时优先保留左侧提示,版本被截断)。
|
|
101
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(version));
|
|
102
|
+
return [fit(`${left}${" ".repeat(gap)}${version}`, width)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Hints of the focused pane from the resolved keymap, in FOOTER_HINTS order. */
|
|
106
|
+
function keymapHints(p: FooterProps): KeyHint[] {
|
|
107
|
+
// 当前已经是 Current 就不提示 C Current,只提示 A All;反之亦然。
|
|
108
|
+
const activeScopeAction = p.scope ? SCOPE_ACTION_OF[p.scope] : undefined;
|
|
109
|
+
const out: KeyHint[] = [];
|
|
110
|
+
for (const action of FOOTER_HINTS[p.focus]) {
|
|
111
|
+
if (action === activeScopeAction) continue;
|
|
112
|
+
const labels = labelsForFocus(p.keymap, p.focus, action);
|
|
113
|
+
if (labels.length === 0) continue;
|
|
114
|
+
out.push([labels[0]!, footerLabel(action)]);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fork selector (`o` in the sessions pane, what pi's `/fork` shows): the title
|
|
3
|
+
* and footer hints for the centered menu of user messages to fork before.
|
|
4
|
+
*
|
|
5
|
+
* Like ./restore-dialog.ts, this is only the wording for a ./select-dialog.ts
|
|
6
|
+
* preset — the panel builds the item list from the session's fork points, the
|
|
7
|
+
* confirmation (./confirm-dialog.ts) then guards the actual fork.
|
|
8
|
+
*
|
|
9
|
+
* fork 选择器只是通用选择菜单(SelectDialog,带滚动窗口)的一个预设:标题 + 提示。
|
|
10
|
+
* 选项来自会话里的 user 消息(app 层从 data 拿),默认停在最后一条(pi 的默认)。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { t } from "../../i18n/index.ts";
|
|
14
|
+
import type { KeyHint } from "../../types.ts";
|
|
15
|
+
|
|
16
|
+
/** Title on the top border of the fork selector. */
|
|
17
|
+
export function forkDialogTitle(): string {
|
|
18
|
+
return t("dialog.forkTitle");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Footer hints while the fork selector is open. */
|
|
22
|
+
export function forkDialogHints(): KeyHint[] {
|
|
23
|
+
return [
|
|
24
|
+
["j/k", t("hint.move")],
|
|
25
|
+
["Enter", t("hint.select")],
|
|
26
|
+
["Esc", t("hint.cancel")],
|
|
27
|
+
];
|
|
28
|
+
}
|