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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Help overlay (`?`).
|
|
3
|
+
*
|
|
4
|
+
* A centered box drawn over the panel listing the bindings of the focused pane
|
|
5
|
+
* followed by the global ones. Pure rendering: given the resolved keymap and
|
|
6
|
+
* the focused pane, produce lines; the panel composites them over its own
|
|
7
|
+
* output with `overlayCentered` from ../frame.ts.
|
|
8
|
+
*
|
|
9
|
+
* ┌─ HELP · Sessions pane ─────────────────────┐
|
|
10
|
+
* │ j/↓ Move cursor down │
|
|
11
|
+
* │ gg/G Go to top / bottom │
|
|
12
|
+
* │ ... │
|
|
13
|
+
* │ Global │
|
|
14
|
+
* │ h/l/Tab Focus previous / next pane │
|
|
15
|
+
* │ 1..3 Focus pane by number │
|
|
16
|
+
* └─ ? / Esc close ─ j/k scroll ───────────────┘
|
|
17
|
+
*
|
|
18
|
+
* 帮助内容直接来自最终合并后的 keymap,所以用户自定义的键位会如实显示。
|
|
19
|
+
* 描述过长时按描述列宽度折行(续行的 keys 列留空),不再用 … 截断——键位说明是重要信息。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
24
|
+
import { actionDescription, HELP_GROUPS, helpGroupText, isDisabledIn, scopeTitle } from "../../config/keymap.ts";
|
|
25
|
+
import { labelsFor, scopeChain } from "../../config/keys.ts";
|
|
26
|
+
import { t } from "../../i18n/index.ts";
|
|
27
|
+
import type { ActionId, Keymap, KeyScope } from "../../types.ts";
|
|
28
|
+
import { fit, frame, overlayCentered } from "../frame.ts";
|
|
29
|
+
|
|
30
|
+
export interface HelpOverlayProps {
|
|
31
|
+
keymap: Keymap;
|
|
32
|
+
/** Scope the keys currently go to: the focused pane, or the tree dialog while it is open. */
|
|
33
|
+
focus: KeyScope;
|
|
34
|
+
/** First visible row of the help body (for long lists). */
|
|
35
|
+
scroll: number;
|
|
36
|
+
theme: Theme;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One line of the help body, either a section header or a binding. */
|
|
40
|
+
export type HelpLine = { kind: "header"; text: string } | { kind: "binding"; keys: string; text: string } | { kind: "blank" };
|
|
41
|
+
|
|
42
|
+
/** Build the help body for `focus`: its own bindings first, then each outer scope (pane → global; dialog → tree pane → global). */
|
|
43
|
+
export function buildHelpLines(keymap: Keymap, focus: KeyScope): HelpLine[] {
|
|
44
|
+
const out: HelpLine[] = [];
|
|
45
|
+
for (const scope of scopeChain(focus)) {
|
|
46
|
+
if (out.length) out.push({ kind: "blank" });
|
|
47
|
+
out.push({ kind: "header", text: scopeTitle(scope) });
|
|
48
|
+
out.push(...buildScopeLines(keymap, scope, focus));
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Binding lines of one scope. Actions that belong to a HELP_GROUPS entry are
|
|
55
|
+
* merged into one line (placed where the first bound member appears) as long as
|
|
56
|
+
* at least two members are bound in this scope. Outer-scope actions switched
|
|
57
|
+
* off in `focus` (see DISABLED_ACTIONS) are left out.
|
|
58
|
+
*
|
|
59
|
+
* 同组动作合并成一行;组内只剩一个绑定时退回单独一行,避免描述和键位对不上。
|
|
60
|
+
*/
|
|
61
|
+
function buildScopeLines(keymap: Keymap, scope: KeyScope, focus: KeyScope): HelpLine[] {
|
|
62
|
+
const out: HelpLine[] = [];
|
|
63
|
+
const consumed = new Set<ActionId>();
|
|
64
|
+
const bound = (a: ActionId) => labelsFor(keymap, scope, a).length > 0 && !(scope !== focus && isDisabledIn(focus, a));
|
|
65
|
+
const actions = (Object.keys(keymap[scope]) as ActionId[]).filter(bound);
|
|
66
|
+
for (const action of actions) {
|
|
67
|
+
if (consumed.has(action)) continue;
|
|
68
|
+
const labels = labelsFor(keymap, scope, action);
|
|
69
|
+
|
|
70
|
+
const group = HELP_GROUPS.find((g) => g.actions.includes(action));
|
|
71
|
+
const members = group ? group.actions.filter(bound) : [];
|
|
72
|
+
if (group && members.length >= 2) {
|
|
73
|
+
for (const m of members) consumed.add(m);
|
|
74
|
+
const keys = members.flatMap((m) => labelsFor(keymap, scope, m));
|
|
75
|
+
out.push({ kind: "binding", keys: compactKeys(keys), text: helpGroupText(group) });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
out.push({ kind: "binding", keys: compactKeys(labels), text: actionDescription(action) });
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render a list of key labels compactly.
|
|
85
|
+
* ["1","2","3"] -> "1..3" (3+ consecutive single characters)
|
|
86
|
+
* ["d","t","u","L"] -> "d/t/u/L"
|
|
87
|
+
* ["j","↓"] -> "j/↓"
|
|
88
|
+
*/
|
|
89
|
+
export function compactKeys(labels: string[]): string {
|
|
90
|
+
if (labels.length >= 3 && labels.every((l) => [...l].length === 1)) {
|
|
91
|
+
const codes = labels.map((l) => l.codePointAt(0)!);
|
|
92
|
+
const consecutive = codes.every((c, i) => i === 0 || c === codes[i - 1]! + 1);
|
|
93
|
+
if (consecutive) return `${labels[0]}..${labels[labels.length - 1]}`;
|
|
94
|
+
}
|
|
95
|
+
return labels.join("/");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A concrete rendered row of the help body, after wrapping long descriptions. */
|
|
99
|
+
type HelpRenderRow = { kind: "header"; text: string } | { kind: "blank" } | { kind: "binding"; keys: string; text: string };
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Word-wrap `text` into lines of at most `width` visible columns. Words that do
|
|
103
|
+
* not fit on a line of their own are hard-broken by columns.
|
|
104
|
+
*
|
|
105
|
+
* 帮助描述按可见宽度折行:英文按空格断词,单词超宽再按列硬断;中文描述通常够短、不折行。
|
|
106
|
+
*/
|
|
107
|
+
function wrapText(text: string, width: number): string[] {
|
|
108
|
+
if (width <= 0) return [text];
|
|
109
|
+
const out: string[] = [];
|
|
110
|
+
let cur = "";
|
|
111
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
112
|
+
const candidate = cur ? `${cur} ${word}` : word;
|
|
113
|
+
if (visibleWidth(candidate) <= width) {
|
|
114
|
+
cur = candidate;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (cur) out.push(cur);
|
|
118
|
+
// 单词自身就超过一行:按可见列硬断成多段。
|
|
119
|
+
let rest = word;
|
|
120
|
+
while (visibleWidth(rest) > width) {
|
|
121
|
+
let take = "";
|
|
122
|
+
for (const ch of [...rest]) {
|
|
123
|
+
if (visibleWidth(take + ch) > width) break;
|
|
124
|
+
take += ch;
|
|
125
|
+
}
|
|
126
|
+
out.push(take);
|
|
127
|
+
rest = rest.slice(take.length);
|
|
128
|
+
}
|
|
129
|
+
cur = rest;
|
|
130
|
+
}
|
|
131
|
+
if (cur) out.push(cur);
|
|
132
|
+
return out.length ? out : [""];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Expand the logical help lines into concrete rendered rows for a box whose
|
|
137
|
+
* inner width is `inner`: a binding's description is wrapped onto continuation
|
|
138
|
+
* rows (with a blank key column) instead of being truncated. `keyColW` is the
|
|
139
|
+
* shared width of the key column.
|
|
140
|
+
*
|
|
141
|
+
* keyColW / inner 都是终端宽度的确定函数,所以行数在 helpBoxSize、renderHelpBox、
|
|
142
|
+
* helpLineCount 三处算出来一致,滚动和高度不会对不上。
|
|
143
|
+
*/
|
|
144
|
+
function helpLayout(keymap: Keymap, focus: KeyScope, inner: number): { rows: HelpRenderRow[]; keyColW: number } {
|
|
145
|
+
const lines = buildHelpLines(keymap, focus);
|
|
146
|
+
const keyColW = Math.min(16, Math.max(8, ...lines.map((l) => (l.kind === "binding" ? visibleWidth(l.keys) : 0))) + 1);
|
|
147
|
+
// 描述列可用宽度 = 内宽 - 前导空格 - keys 列 - keys 后的一个空格。
|
|
148
|
+
const descW = Math.max(1, inner - keyColW - 2);
|
|
149
|
+
const rows: HelpRenderRow[] = [];
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
if (line.kind === "blank") {
|
|
152
|
+
rows.push({ kind: "blank" });
|
|
153
|
+
} else if (line.kind === "header") {
|
|
154
|
+
rows.push({ kind: "header", text: line.text });
|
|
155
|
+
} else {
|
|
156
|
+
// 首行带 keys,续行 keys 列留空、描述接着往下排。
|
|
157
|
+
wrapText(line.text, descW).forEach((seg, i) => rows.push({ kind: "binding", keys: i === 0 ? line.keys : "", text: seg }));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { rows, keyColW };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Width of the help box for a `termW`-column terminal. */
|
|
164
|
+
export function helpBoxWidth(termW: number): number {
|
|
165
|
+
return Math.max(30, Math.min(termW - 4, 64));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Size of the box for a given terminal size (height grows with the wrapped row count). */
|
|
169
|
+
export function helpBoxSize(termW: number, termH: number, keymap: Keymap, focus: KeyScope): { width: number; height: number } {
|
|
170
|
+
const width = helpBoxWidth(termW);
|
|
171
|
+
const rowCount = helpLayout(keymap, focus, width - 2).rows.length;
|
|
172
|
+
// +2 for borders
|
|
173
|
+
const height = Math.max(6, Math.min(termH - 2, rowCount + 2));
|
|
174
|
+
return { width, height };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Render the help box itself (lines of exactly `width`). */
|
|
178
|
+
export function renderHelpBox(p: HelpOverlayProps, width: number, height: number): string[] {
|
|
179
|
+
const { theme } = p;
|
|
180
|
+
const inner = width - 2;
|
|
181
|
+
const { rows, keyColW } = helpLayout(p.keymap, p.focus, inner);
|
|
182
|
+
const visible = Math.max(1, height - 2);
|
|
183
|
+
const maxScroll = Math.max(0, rows.length - visible);
|
|
184
|
+
const start = Math.min(Math.max(0, p.scroll), maxScroll);
|
|
185
|
+
|
|
186
|
+
const body: string[] = [];
|
|
187
|
+
for (const row of rows.slice(start, start + visible)) {
|
|
188
|
+
if (row.kind === "blank") {
|
|
189
|
+
body.push("");
|
|
190
|
+
} else if (row.kind === "header") {
|
|
191
|
+
body.push(" " + theme.bold(theme.fg("accent", row.text)));
|
|
192
|
+
} else {
|
|
193
|
+
// 续行的 keys 为空,只占位对齐;描述由 helpLayout 折过行,这里不再截断。
|
|
194
|
+
const keys = fit(row.keys ? theme.fg("warning", row.keys) : "", keyColW);
|
|
195
|
+
body.push(` ${keys} ${theme.fg("text", row.text)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const more = maxScroll > 0 ? ` ${start + 1}-${Math.min(rows.length, start + visible)}/${rows.length}` : "";
|
|
200
|
+
return frame(body, {
|
|
201
|
+
width,
|
|
202
|
+
height,
|
|
203
|
+
title: `${t("help.titlePrefix")} · ${scopeTitle(p.focus)}`,
|
|
204
|
+
meta: `${t("help.closeHint")}${maxScroll > 0 ? ` · ${t("help.scrollHint")}` : ""}${more}`,
|
|
205
|
+
border: (s) => theme.fg("borderAccent", s),
|
|
206
|
+
titleStyle: (s) => theme.bold(theme.fg("accent", s)),
|
|
207
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Composite the help box centered over already-rendered panel `lines`. */
|
|
212
|
+
export function overlayHelp(lines: string[], p: HelpOverlayProps, termW: number): string[] {
|
|
213
|
+
const { width, height } = helpBoxSize(termW, lines.length, p.keymap, p.focus);
|
|
214
|
+
return overlayCentered(lines, renderHelpBox(p, width, height), width, termW);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Number of rendered help rows (wrapping included), used by the panel to clamp help scrolling. */
|
|
218
|
+
export function helpLineCount(keymap: Keymap, focus: KeyScope, termW: number): number {
|
|
219
|
+
return helpLayout(keymap, focus, helpBoxWidth(termW) - 2).rows.length;
|
|
220
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import prompt (`I` in the sessions pane, what pi's `/import` does): the title
|
|
3
|
+
* and footer hints ./input-dialog.ts shows while asking for a session JSONL.
|
|
4
|
+
*
|
|
5
|
+
* After Enter the panel asks for confirmation (./confirm-dialog.ts, like pi's
|
|
6
|
+
* "Replace current session with …?"), then the actions layer copies the file
|
|
7
|
+
* into the session directory and switches pi to it; the panel closes.
|
|
8
|
+
*
|
|
9
|
+
* 导入只是通用输入弹窗(InputDialog)的一个预设:输入 JSONL 路径(相对路径按 pi 的工作目录、
|
|
10
|
+
* 支持 ~),回车后确认,再复制进会话目录并切过去。
|
|
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 box. */
|
|
17
|
+
export function importDialogTitle(): string {
|
|
18
|
+
return t("dialog.importTitle");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Right end of the title bar: what the prompt wants. */
|
|
22
|
+
export function importDialogSubject(): string {
|
|
23
|
+
return t("dialog.importSubject");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Footer hints while the dialog is open. */
|
|
27
|
+
export function importDialogHints(): KeyHint[] {
|
|
28
|
+
return [
|
|
29
|
+
["Enter", t("hint.import")],
|
|
30
|
+
["Esc", t("hint.cancel")],
|
|
31
|
+
];
|
|
32
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input dialog — a one-line text field in a box centered over the panel,
|
|
3
|
+
* like lazygit's commit-message popup:
|
|
4
|
+
*
|
|
5
|
+
* ┌─ Label ──────────────────────── user: hi ─┐
|
|
6
|
+
* │ checkpoint▏ │
|
|
7
|
+
* └────────────────────────────────────────────┘
|
|
8
|
+
* LABEL │ Enter save Esc cancel empty removes <- footer while open
|
|
9
|
+
*
|
|
10
|
+
* Generic: the caller passes a title, the pre-filled value, an optional
|
|
11
|
+
* subject for the title bar and the callbacks on each `open`, so the same
|
|
12
|
+
* widget serves node labels (`T`), session renames and any later prompt. The
|
|
13
|
+
* dialog only collects text and draws the box; key hints go to the footer
|
|
14
|
+
* (see `hints`), like lazygit.
|
|
15
|
+
*
|
|
16
|
+
* 通用的居中单行输入弹窗:包装 pi-tui `Input`,标题 / 预填值 / 右上角说明 / 回调都在 open 时传入,
|
|
17
|
+
* 打标签、给 session 起名等场景共用同一个组件;快捷键提示由 app.ts 放在底部 footer。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { Input, truncateToWidth } from "@earendil-works/pi-tui";
|
|
22
|
+
import type { KeyHint } from "../../types.ts";
|
|
23
|
+
import { dialogWidth, frame, metaBudget, overlayCentered } from "../frame.ts";
|
|
24
|
+
|
|
25
|
+
/** Box height: top border, input, bottom border. */
|
|
26
|
+
export const INPUT_DIALOG_HEIGHT = 3;
|
|
27
|
+
|
|
28
|
+
/** Legacy "End" key sequence; pi-tui's `Input` maps it to "move to line end". */
|
|
29
|
+
const END_KEY = "\x1b[F";
|
|
30
|
+
|
|
31
|
+
/** What one prompt looks like; passed to `open`. */
|
|
32
|
+
export interface InputDialogSpec {
|
|
33
|
+
/** Title on the top border, e.g. "Label" / "Rename". */
|
|
34
|
+
title: string;
|
|
35
|
+
/** Pre-filled text; the cursor starts at its end. */
|
|
36
|
+
value?: string;
|
|
37
|
+
/** One-line description shown at the right end of the title bar (e.g. the node being labelled). */
|
|
38
|
+
subject?: string;
|
|
39
|
+
/** Footer hints while this prompt is open. */
|
|
40
|
+
hints: KeyHint[];
|
|
41
|
+
onSubmit: (value: string) => void;
|
|
42
|
+
onCancel: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface InputDialogOptions {
|
|
46
|
+
theme: Theme;
|
|
47
|
+
/** Called after every keystroke so the panel can re-render. */
|
|
48
|
+
onChange: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class InputDialog {
|
|
52
|
+
private input: Input;
|
|
53
|
+
private spec: InputDialogSpec | undefined;
|
|
54
|
+
private _focused = false;
|
|
55
|
+
|
|
56
|
+
constructor(private readonly o: InputDialogOptions) {
|
|
57
|
+
this.input = new Input({ prompt: "" });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Focusable propagation (IME cursor): mirrored from the root component. */
|
|
61
|
+
get focused(): boolean {
|
|
62
|
+
return this._focused;
|
|
63
|
+
}
|
|
64
|
+
set focused(v: boolean) {
|
|
65
|
+
this._focused = v;
|
|
66
|
+
this.input.focused = v;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** True between `open` and `close`. */
|
|
70
|
+
get isOpen(): boolean {
|
|
71
|
+
return this.spec !== undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Footer hints of the current prompt (empty when closed). */
|
|
75
|
+
get hints(): KeyHint[] {
|
|
76
|
+
return this.spec?.hints ?? [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Start a prompt: a fresh field pre-filled with `spec.value`. */
|
|
80
|
+
open(spec: InputDialogSpec): void {
|
|
81
|
+
this.spec = spec;
|
|
82
|
+
this.input = this.createInput(spec.value ?? "");
|
|
83
|
+
this.input.focused = this._focused;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Drop the current prompt without firing a callback. */
|
|
87
|
+
close(): void {
|
|
88
|
+
this.spec = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
getValue(): string {
|
|
92
|
+
return this.input.getValue();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handleInput(data: string): void {
|
|
96
|
+
this.input.handleInput(data);
|
|
97
|
+
this.o.onChange();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Render the box itself, every line exactly `width` columns. */
|
|
101
|
+
render(width: number): string[] {
|
|
102
|
+
const { theme } = this.o;
|
|
103
|
+
const title = this.spec?.title ?? "";
|
|
104
|
+
const inner = width - 2;
|
|
105
|
+
// 输入行左侧留一列空白,光标不会贴着边框。
|
|
106
|
+
const field = ` ${this.input.render(Math.max(1, inner - 2))[0] ?? ""}`;
|
|
107
|
+
// 标题和说明之间至少留 3 个 ─,否则两者会贴在一起。
|
|
108
|
+
const meta = truncateToWidth(this.spec?.subject ?? "", metaBudget(width, title) - 3, "…", false);
|
|
109
|
+
return frame([field], {
|
|
110
|
+
width,
|
|
111
|
+
height: INPUT_DIALOG_HEIGHT,
|
|
112
|
+
title,
|
|
113
|
+
...(meta ? { meta } : {}),
|
|
114
|
+
border: (s) => theme.fg("borderAccent", s),
|
|
115
|
+
titleStyle: (s) => theme.bold(theme.fg("accent", s)),
|
|
116
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Composite the box centered over the already-rendered panel `lines`. */
|
|
121
|
+
overlay(lines: string[], termW: number): string[] {
|
|
122
|
+
const width = dialogWidth(termW);
|
|
123
|
+
return overlayCentered(lines, this.render(width), width, termW);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** New input wired to the current spec; `setValue` leaves the cursor at 0, so move it to the end. */
|
|
127
|
+
private createInput(value: string): Input {
|
|
128
|
+
const input = new Input({ prompt: "" });
|
|
129
|
+
// 回调走 this.spec,而不是捕获当时的 spec:open 换了 spec 后旧 Input 也不会再被用到。
|
|
130
|
+
input.onSubmit = (v) => this.spec?.onSubmit(v);
|
|
131
|
+
input.onEscape = () => this.spec?.onCancel();
|
|
132
|
+
input.setValue(value);
|
|
133
|
+
if (value) input.handleInput(END_KEY);
|
|
134
|
+
return input;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Label prompt (`T` in the tree pane, same as `Shift+T` in pi's `/tree`):
|
|
3
|
+
* the title and footer hints that ./input-dialog.ts shows while labelling.
|
|
4
|
+
*
|
|
5
|
+
* The panel owns the target node and persists the change through the injected
|
|
6
|
+
* actions; the pre-filled value is the node's current label and an empty
|
|
7
|
+
* submission clears it.
|
|
8
|
+
*
|
|
9
|
+
* 打标签只是通用输入弹窗(InputDialog)的一个预设:这里只放标题和 footer 提示,
|
|
10
|
+
* 其他场景(比如给 session 起名)照此再写一个预设即可。
|
|
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 box. */
|
|
17
|
+
export function labelDialogTitle(): string {
|
|
18
|
+
return t("dialog.labelTitle");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Footer hints while the dialog is open. */
|
|
22
|
+
export function labelDialogHints(): KeyHint[] {
|
|
23
|
+
return [
|
|
24
|
+
["Enter", t("hint.save")],
|
|
25
|
+
["Esc", t("hint.cancel")],
|
|
26
|
+
[t("hint.emptyKey"), t("hint.removes")],
|
|
27
|
+
];
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* New-session prompt (`n` in the sessions pane, what `/new` does): the title and
|
|
3
|
+
* footer hints ./input-dialog.ts shows while naming a new session, same idea as
|
|
4
|
+
* ./rename-dialog.ts.
|
|
5
|
+
*
|
|
6
|
+
* The name is optional — an empty submission starts an unnamed session (pi's
|
|
7
|
+
* `/name` [name] argument is left unset). The panel starts the session through
|
|
8
|
+
* the injected actions and closes.
|
|
9
|
+
*
|
|
10
|
+
* 新建会话只是通用输入弹窗(InputDialog)的一个预设:标题 + 提示;名字可留空
|
|
11
|
+
* (对应 /name 的可选 [name] 参数,空则不设置),回车新建并关闭面板。
|
|
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 newSessionDialogTitle(): string {
|
|
19
|
+
return t("dialog.newTitle");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Footer hints while the dialog is open. */
|
|
23
|
+
export function newSessionDialogHints(): KeyHint[] {
|
|
24
|
+
return [
|
|
25
|
+
["Enter", t("hint.create")],
|
|
26
|
+
["Esc", t("hint.cancel")],
|
|
27
|
+
[t("hint.emptyKey"), t("hint.noName")],
|
|
28
|
+
];
|
|
29
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt bar — a one-line text prompt that replaces the footer row.
|
|
3
|
+
*
|
|
4
|
+
* <label>foo▏ Enter <ok> Esc <cancel>
|
|
5
|
+
*
|
|
6
|
+
* Generic building block behind the search bar (`/`). Wraps pi-tui's `Input`
|
|
7
|
+
* so cursor movement, word deletion, paste and the kill-ring come for free.
|
|
8
|
+
* The bar only owns rendering + the input widget; the panel decides what to do
|
|
9
|
+
* on submit / cancel. (Labelling a tree node uses the centered
|
|
10
|
+
* ./input-dialog.ts instead.)
|
|
11
|
+
*
|
|
12
|
+
* 底部一行式输入框:搜索栏用它,只负责画和收键,语义由 app.ts 决定。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Input, visibleWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import type { KeyHint } from "../../types.ts";
|
|
18
|
+
import { fit } from "../frame.ts";
|
|
19
|
+
|
|
20
|
+
/** Legacy "End" key sequence; pi-tui's `Input` maps it to "move to line end". */
|
|
21
|
+
const END_KEY = "\x1b[F";
|
|
22
|
+
|
|
23
|
+
/** One `key description` pair shown at the right end of the bar. */
|
|
24
|
+
export type PromptHint = KeyHint;
|
|
25
|
+
|
|
26
|
+
export interface PromptBarOptions {
|
|
27
|
+
theme: Theme;
|
|
28
|
+
/** Text in front of the input, e.g. "搜索: ". */
|
|
29
|
+
label: string;
|
|
30
|
+
/** Hints rendered after the field; dropped when the terminal is too narrow. */
|
|
31
|
+
hints: PromptHint[];
|
|
32
|
+
onSubmit: (value: string) => void;
|
|
33
|
+
onCancel: () => void;
|
|
34
|
+
/** Called after every keystroke so the panel can re-render. */
|
|
35
|
+
onChange: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PromptBar {
|
|
39
|
+
private readonly input: Input;
|
|
40
|
+
private _focused = false;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly o: PromptBarOptions) {
|
|
43
|
+
// 前面已经有自己的 label(如 "搜索: "),去掉 pi-tui Input 默认的 "> " 提示符。
|
|
44
|
+
this.input = new Input({ prompt: "" });
|
|
45
|
+
this.input.onSubmit = (v) => this.o.onSubmit(v);
|
|
46
|
+
this.input.onEscape = () => this.o.onCancel();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Focusable propagation (IME cursor): mirrored from the root component. */
|
|
50
|
+
get focused(): boolean {
|
|
51
|
+
return this._focused;
|
|
52
|
+
}
|
|
53
|
+
set focused(v: boolean) {
|
|
54
|
+
this._focused = v;
|
|
55
|
+
this.input.focused = v;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getValue(): string {
|
|
59
|
+
return this.input.getValue();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Reset the field, optionally pre-filled; `cursorAtEnd` puts the cursor after the text instead of in front of it. */
|
|
63
|
+
reset(value = "", cursorAtEnd = false): void {
|
|
64
|
+
this.input.setValue(value);
|
|
65
|
+
// setValue 把光标留在开头;要接着往后打字(对话框里再按 / 编辑已有的查询)就移到末尾。
|
|
66
|
+
if (cursorAtEnd && value) this.input.handleInput(END_KEY);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
handleInput(data: string): void {
|
|
70
|
+
this.input.handleInput(data);
|
|
71
|
+
this.o.onChange();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Render the bar as one line of exactly `width`. */
|
|
75
|
+
render(width: number): string[] {
|
|
76
|
+
const { theme } = this.o;
|
|
77
|
+
const label = theme.bold(theme.fg("accent", this.o.label));
|
|
78
|
+
const hint = this.o.hints.map(([k, t]) => `${theme.fg("dim", k)} ${theme.fg("muted", t)}`).join(" ");
|
|
79
|
+
const hintW = visibleWidth(hint);
|
|
80
|
+
const labelW = visibleWidth(this.o.label);
|
|
81
|
+
let inputW = width - labelW - hintW - 2;
|
|
82
|
+
let showHint = hint.length > 0;
|
|
83
|
+
// 太窄就放弃提示,把宽度都留给输入框。
|
|
84
|
+
if (inputW < 10) {
|
|
85
|
+
showHint = false;
|
|
86
|
+
inputW = Math.max(1, width - labelW);
|
|
87
|
+
}
|
|
88
|
+
const field = this.input.render(inputW)[0] ?? "";
|
|
89
|
+
const line = showHint ? `${label}${fit(field, inputW)} ${hint}` : `${label}${field}`;
|
|
90
|
+
return [fit(line, width)];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rename prompt (`r` in the sessions pane, what `/name` and ctrl+r in pi's
|
|
3
|
+
* `/resume` do): the title and footer hints that ./input-dialog.ts shows while
|
|
4
|
+
* renaming, same idea as ./label-dialog.ts.
|
|
5
|
+
*
|
|
6
|
+
* The panel owns the target session and persists the change through the
|
|
7
|
+
* injected actions; the pre-filled value is the session's current name and an
|
|
8
|
+
* empty submission clears it (the row falls back to its first-message preview).
|
|
9
|
+
*
|
|
10
|
+
* 给会话起名只是通用输入弹窗(InputDialog)的一个预设:标题 + footer 提示;
|
|
11
|
+
* 弹窗标题右侧显示被改名的会话(首条消息预览),空值清除名称。
|
|
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 renameDialogTitle(): string {
|
|
19
|
+
return t("dialog.renameTitle");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Footer hints while the dialog is open. */
|
|
23
|
+
export function renameDialogHints(): KeyHint[] {
|
|
24
|
+
return [
|
|
25
|
+
["Enter", t("hint.save")],
|
|
26
|
+
["Esc", t("hint.cancel")],
|
|
27
|
+
[t("hint.emptyKey"), t("hint.removes")],
|
|
28
|
+
];
|
|
29
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore presets (`Enter` in the tree pane, same flow as pi's `/tree`): the
|
|
3
|
+
* "Summarize branch?" menu shown through ./select-dialog.ts and the custom
|
|
4
|
+
* instructions prompt shown through ./input-dialog.ts.
|
|
5
|
+
*
|
|
6
|
+
* The panel owns the target node and hands the choice to the injected
|
|
7
|
+
* actions as `RestoreOptions`; this module only holds the wording, the order
|
|
8
|
+
* of the entries and the footer hints.
|
|
9
|
+
*
|
|
10
|
+
* TREE Enter 的两个弹窗都是通用组件的预设:三选菜单(No summary / Summarize /
|
|
11
|
+
* Summarize with custom prompt,和 pi 内置 /tree 的顺序一致)和自定义摘要指令的输入框。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { t } from "../../i18n/index.ts";
|
|
15
|
+
import type { KeyHint } from "../../types.ts";
|
|
16
|
+
|
|
17
|
+
/** What the user picked in the menu. */
|
|
18
|
+
export type SummaryChoice = "none" | "summarize" | "custom";
|
|
19
|
+
|
|
20
|
+
/** Menu choices in pi's order; the picked index maps to `choice`. */
|
|
21
|
+
export const SUMMARY_CHOICES: readonly SummaryChoice[] = ["none", "summarize", "custom"];
|
|
22
|
+
|
|
23
|
+
/** Title on the top border of the menu (pi's wording, localised). */
|
|
24
|
+
export function summaryMenuTitle(): string {
|
|
25
|
+
return t("dialog.restoreMenuTitle");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Menu entries in pi's order, localised; the picked index maps to `choice`. */
|
|
29
|
+
export function summaryMenu(): ReadonlyArray<{ label: string; choice: SummaryChoice }> {
|
|
30
|
+
return [
|
|
31
|
+
{ label: t("dialog.restoreNone"), choice: "none" },
|
|
32
|
+
{ label: t("dialog.restoreSummarize"), choice: "summarize" },
|
|
33
|
+
{ label: t("dialog.restoreCustom"), choice: "custom" },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Footer hints while the menu is open. */
|
|
38
|
+
export function summaryMenuHints(): KeyHint[] {
|
|
39
|
+
return [
|
|
40
|
+
["j/k", t("hint.move")],
|
|
41
|
+
["Enter", t("hint.select")],
|
|
42
|
+
["Esc", t("hint.cancel")],
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Where the menu reopens after Esc in the custom prompt (pi loops back to the selector). */
|
|
47
|
+
export const CUSTOM_PROMPT_INDEX = SUMMARY_CHOICES.indexOf("custom");
|
|
48
|
+
|
|
49
|
+
/** Title of the custom instructions prompt (pi's wording, localised). */
|
|
50
|
+
export function customPromptTitle(): string {
|
|
51
|
+
return t("dialog.restoreCustomTitle");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Footer hints while the custom prompt is open. */
|
|
55
|
+
export function customPromptHints(): KeyHint[] {
|
|
56
|
+
return [
|
|
57
|
+
["Enter", t("hint.summarize")],
|
|
58
|
+
["Esc", t("hint.back")],
|
|
59
|
+
];
|
|
60
|
+
}
|