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,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree dialog — the full conversation tree in a big centered box, opened with
|
|
3
|
+
* `a` from the tree pane (the small pane only shows a slice). Layout follows
|
|
4
|
+
* the `prefix + g` picker of herdr: search box on top, the tree in the middle,
|
|
5
|
+
* key hints at the bottom.
|
|
6
|
+
*
|
|
7
|
+
* ┌─ TREE ─────────────────────────────── 3/12 · default ─┐
|
|
8
|
+
* │ 搜索: hello▏ │
|
|
9
|
+
* ├────────────────────────────────────────────────────────┤
|
|
10
|
+
* │ › • 22:18 [system] │
|
|
11
|
+
* │ ├⊟ 22:18 user: hi │
|
|
12
|
+
* │ │ 22:18 assistant: Hello! │
|
|
13
|
+
* │ └⊞ • 22:19 user: hi │
|
|
14
|
+
* ├────────────────────────────────────────────────────────┤
|
|
15
|
+
* │ / search j/↓/k/↑ move Enter restore z fold … │
|
|
16
|
+
* └────────────────────────────────────────────────────────┘
|
|
17
|
+
*
|
|
18
|
+
* The widget owns the search field, the cursor and the rendering; the rows it
|
|
19
|
+
* lists (already filtered, searched and folded) come from the panel through
|
|
20
|
+
* `open` / `setRows`, and the keys are resolved by the panel (app.ts) against
|
|
21
|
+
* the `tree-dialog` key scope. Search is live: every keystroke reports the
|
|
22
|
+
* query through `onQueryChange` and the panel answers with `setRows`. Esc in
|
|
23
|
+
* the search row hands the keys back to the list and keeps the query (the rows
|
|
24
|
+
* stay narrowed; `/` again edits it, deleting the text clears it), Enter does
|
|
25
|
+
* nothing there; an idle search row shows the query, or a `/ to search` hint
|
|
26
|
+
* when empty.
|
|
27
|
+
*
|
|
28
|
+
* 完整树对话框:搜索框 / 光标 / 画法在这里,行的过滤、搜索、折叠都由 app.ts 算好传进来,
|
|
29
|
+
* 按键也由 app.ts 按 tree-dialog scope 解析后调用这里的方法。搜索框里 Esc 只是回到列表,
|
|
30
|
+
* 关键字保留;Enter 在搜索框里没有含义。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
34
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
35
|
+
import { t } from "../../i18n/index.ts";
|
|
36
|
+
import type { KeyHint, TreeFilter, TreeRow } from "../../types.ts";
|
|
37
|
+
import { FRAME_DIVIDER, frame, overlayCentered } from "../frame.ts";
|
|
38
|
+
import { renderTreeRow } from "../panes/tree-pane.ts";
|
|
39
|
+
import { scrollOffset } from "../panes/sessions-pane.ts";
|
|
40
|
+
import { treePrefixes } from "../tree-lines.ts";
|
|
41
|
+
import { PromptBar } from "./prompt-bar.ts";
|
|
42
|
+
import { searchLabel } from "./search-bar.ts";
|
|
43
|
+
|
|
44
|
+
/** Title on the top border of the dialog (localised). */
|
|
45
|
+
export function treeDialogTitle(): string {
|
|
46
|
+
return t("dialog.treeTitle");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Hints while the search row has the keys (Esc goes back to the list, the query stays). */
|
|
50
|
+
export function treeSearchHints(): KeyHint[] {
|
|
51
|
+
return [["Esc", t("hint.backToList")]];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the dialog shows; passed to `open`. */
|
|
55
|
+
export interface TreeDialogSpec {
|
|
56
|
+
/** Rows to list, top to bottom (already filtered by the panel, descendants of folded rows left out). */
|
|
57
|
+
rows: TreeRow[];
|
|
58
|
+
/** Folded rows, drawn with `⊞` (the pane's fold state, see ../../data/tree-fold.ts). */
|
|
59
|
+
folded?: ReadonlySet<string>;
|
|
60
|
+
/** Cursor position when the dialog opens (clamped into `rows`). */
|
|
61
|
+
initialIndex?: number;
|
|
62
|
+
/** Active filter, shown in the title bar next to the position. */
|
|
63
|
+
filter: TreeFilter;
|
|
64
|
+
/** Hints on the bottom row and in the footer while the list has the keys (built from the resolved keymap). */
|
|
65
|
+
hints?: KeyHint[];
|
|
66
|
+
/** Label of the key that focuses the search row, for the idle row's `/ to search` hint. */
|
|
67
|
+
searchKey?: string;
|
|
68
|
+
/** Live search: called with the new query after every change; the panel answers with `setRows`. */
|
|
69
|
+
onQueryChange?: (query: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface TreeDialogOptions {
|
|
73
|
+
theme: Theme;
|
|
74
|
+
/** Called after every change so the panel can re-render. */
|
|
75
|
+
onChange: () => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Rows above (search + divider) and below (divider + hints) the list, plus the two borders. */
|
|
79
|
+
const CHROME_ROWS = 6;
|
|
80
|
+
|
|
81
|
+
export class TreeDialog {
|
|
82
|
+
private spec: TreeDialogSpec | undefined;
|
|
83
|
+
private rows: TreeRow[] = [];
|
|
84
|
+
private folded: ReadonlySet<string> = new Set();
|
|
85
|
+
private index = 0;
|
|
86
|
+
private query = "";
|
|
87
|
+
private _searchFocused = false;
|
|
88
|
+
private _focused = false;
|
|
89
|
+
/** The search field on the top row. */
|
|
90
|
+
private readonly search: PromptBar;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly o: TreeDialogOptions) {
|
|
93
|
+
this.search = new PromptBar({
|
|
94
|
+
theme: o.theme,
|
|
95
|
+
label: searchLabel(),
|
|
96
|
+
hints: [],
|
|
97
|
+
// Enter 在搜索框里没有含义;Esc 把按键交还给列表,关键字保留(再按 / 接着改)。
|
|
98
|
+
onSubmit: () => {},
|
|
99
|
+
onCancel: () => this.leaveSearch(),
|
|
100
|
+
onChange: () => this.syncQuery(),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** True between `open` and `close`. */
|
|
105
|
+
get isOpen(): boolean {
|
|
106
|
+
return this.spec !== undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Footer hints while the dialog is open: the search row's while it has the keys, else the list's. */
|
|
110
|
+
get hints(): KeyHint[] {
|
|
111
|
+
if (!this.spec) return [];
|
|
112
|
+
return this._searchFocused ? treeSearchHints() : (this.spec.hints ?? []);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Index of the highlighted row. */
|
|
116
|
+
get selectedIndex(): number {
|
|
117
|
+
return this.index;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The highlighted row, if any. */
|
|
121
|
+
get selectedRow(): TreeRow | undefined {
|
|
122
|
+
return this.rows[this.index];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Current search text ("" = no search). */
|
|
126
|
+
get searchQuery(): string {
|
|
127
|
+
return this.query;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Whether keys go to the search row rather than the list. */
|
|
131
|
+
get searchFocused(): boolean {
|
|
132
|
+
return this._searchFocused;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Focusable propagation (IME cursor): reaches the search field only while it has the keys. */
|
|
136
|
+
get focused(): boolean {
|
|
137
|
+
return this._focused;
|
|
138
|
+
}
|
|
139
|
+
set focused(v: boolean) {
|
|
140
|
+
this._focused = v;
|
|
141
|
+
this.search.focused = v && this._searchFocused;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
open(spec: TreeDialogSpec): void {
|
|
145
|
+
this.spec = spec;
|
|
146
|
+
this.rows = spec.rows;
|
|
147
|
+
this.folded = spec.folded ?? new Set();
|
|
148
|
+
this.index = clamp(spec.initialIndex ?? 0, 0, Math.max(0, spec.rows.length - 1));
|
|
149
|
+
this.query = "";
|
|
150
|
+
this._searchFocused = false;
|
|
151
|
+
this.search.reset("");
|
|
152
|
+
this.search.focused = false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Drop the dialog without firing a callback. */
|
|
156
|
+
close(): void {
|
|
157
|
+
this.spec = undefined;
|
|
158
|
+
this._searchFocused = false;
|
|
159
|
+
this.search.focused = false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Replace the listed rows (after a search / filter / fold / label change) and put the cursor on `index`. */
|
|
163
|
+
setRows(rows: TreeRow[], folded: ReadonlySet<string>, index: number): void {
|
|
164
|
+
this.rows = rows;
|
|
165
|
+
this.folded = folded;
|
|
166
|
+
this.index = clamp(index, 0, Math.max(0, rows.length - 1));
|
|
167
|
+
this.o.onChange();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Update the filter shown in the title bar. */
|
|
171
|
+
setFilter(filter: TreeFilter): void {
|
|
172
|
+
if (this.spec) this.spec.filter = filter;
|
|
173
|
+
this.o.onChange();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** `/`: give the keys to the search row, cursor after the current query so typing extends it. */
|
|
177
|
+
focusSearch(): void {
|
|
178
|
+
if (!this.spec) return;
|
|
179
|
+
this._searchFocused = true;
|
|
180
|
+
this.search.reset(this.query, true);
|
|
181
|
+
this.search.focused = this._focused;
|
|
182
|
+
this.o.onChange();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A key while the search row has them (Enter / Esc come back through the prompt's callbacks). */
|
|
186
|
+
handleSearchInput(data: string): void {
|
|
187
|
+
this.search.handleInput(data);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** j / k: move the cursor by `delta` rows, clamped. */
|
|
191
|
+
move(delta: number): void {
|
|
192
|
+
this.moveTo(this.index + delta);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** gg / G and absolute moves, clamped into the listed rows. */
|
|
196
|
+
moveTo(index: number): void {
|
|
197
|
+
const next = clamp(index, 0, Math.max(0, this.rows.length - 1));
|
|
198
|
+
if (next === this.index) return;
|
|
199
|
+
this.index = next;
|
|
200
|
+
this.o.onChange();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** After every keystroke in the search row: report a changed query, then re-render. */
|
|
204
|
+
private syncQuery(): void {
|
|
205
|
+
const q = this.search.getValue();
|
|
206
|
+
if (q !== this.query) {
|
|
207
|
+
this.query = q;
|
|
208
|
+
this.spec?.onQueryChange?.(q);
|
|
209
|
+
}
|
|
210
|
+
this.o.onChange();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Esc in the search row: hand the keys back to the list; the query (and the narrowed rows) stay. */
|
|
214
|
+
private leaveSearch(): void {
|
|
215
|
+
this._searchFocused = false;
|
|
216
|
+
this.search.focused = false;
|
|
217
|
+
this.o.onChange();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Box size for a `termW` × `termH` terminal: as big as it gets with a 2-column / 1-row margin. */
|
|
221
|
+
static size(termW: number, termH: number): { width: number; height: number } {
|
|
222
|
+
return { width: Math.max(24, termW - 4), height: Math.max(CHROME_ROWS + 1, termH - 2) };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Render the box itself, every line exactly `width` columns and `height` lines tall. */
|
|
226
|
+
render(width: number, height: number): string[] {
|
|
227
|
+
const { theme } = this.o;
|
|
228
|
+
const rows = this.rows;
|
|
229
|
+
const inner = width - 2;
|
|
230
|
+
const visible = Math.max(1, height - CHROME_ROWS);
|
|
231
|
+
|
|
232
|
+
const body: string[] = [` ${this.renderSearchRow(inner - 1)}`, FRAME_DIVIDER];
|
|
233
|
+
if (rows.length === 0) {
|
|
234
|
+
body.push(theme.fg("muted", this.query ? ` ${t("dialog.treeNoMatches")}` : ` ${t("pane.treeEmpty")}`));
|
|
235
|
+
} else {
|
|
236
|
+
const prefixes = treePrefixes(rows, this.folded);
|
|
237
|
+
const first = scrollOffset(this.index, rows.length, visible);
|
|
238
|
+
for (let i = first; i < Math.min(rows.length, first + visible); i++) {
|
|
239
|
+
body.push(renderTreeRow(rows[i]!, theme.fg("dim", prefixes[i]!), inner, i === this.index, theme));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
while (body.length < visible + 2) body.push("");
|
|
243
|
+
body.push(FRAME_DIVIDER, ` ${renderHints(this.hints, theme, inner - 1)}`);
|
|
244
|
+
|
|
245
|
+
const position = rows.length ? `${this.index + 1}/${rows.length}` : "0/0";
|
|
246
|
+
return frame(body, {
|
|
247
|
+
width,
|
|
248
|
+
height,
|
|
249
|
+
title: treeDialogTitle(),
|
|
250
|
+
meta: `${position} · ${t(`filter.${this.spec?.filter ?? "default"}`)}`,
|
|
251
|
+
border: (s) => theme.fg("borderAccent", s),
|
|
252
|
+
titleStyle: (s) => theme.bold(theme.fg("accent", s)),
|
|
253
|
+
metaStyle: (s) => theme.fg("dim", s),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Top row: the live input while it has the keys; otherwise the query as
|
|
259
|
+
* plain text (no block cursor) or a hint naming the search key.
|
|
260
|
+
*/
|
|
261
|
+
private renderSearchRow(width: number): string {
|
|
262
|
+
if (this._searchFocused) return this.search.render(width)[0] ?? "";
|
|
263
|
+
const { theme } = this.o;
|
|
264
|
+
const label = theme.bold(theme.fg("accent", searchLabel()));
|
|
265
|
+
const key = this.spec?.searchKey;
|
|
266
|
+
const rest = this.query ? theme.fg("text", this.query) : key ? theme.fg("dim", t("dialog.toSearch", { key })) : "";
|
|
267
|
+
return `${label}${rest}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Composite the box centered over the already-rendered panel `lines`. */
|
|
271
|
+
overlay(lines: string[], termW: number): string[] {
|
|
272
|
+
const { width, height } = TreeDialog.size(termW, lines.length);
|
|
273
|
+
return overlayCentered(lines, this.render(width, height), width, termW);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `key text key text …`, dropping the hints that do not fit in `width` (like the footer). */
|
|
278
|
+
function renderHints(hints: KeyHint[], theme: Theme, width: number): string {
|
|
279
|
+
const parts: string[] = [];
|
|
280
|
+
let used = 0;
|
|
281
|
+
for (const [k, t] of hints) {
|
|
282
|
+
const part = `${theme.bold(theme.fg("accent", k))} ${theme.fg("muted", t)}`;
|
|
283
|
+
const w = visibleWidth(part) + (parts.length ? 3 : 0);
|
|
284
|
+
if (used + w > width) break;
|
|
285
|
+
parts.push(part);
|
|
286
|
+
used += w;
|
|
287
|
+
}
|
|
288
|
+
return parts.join(" ");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function clamp(n: number, min: number, max: number): number {
|
|
292
|
+
return Math.max(min, Math.min(max, n));
|
|
293
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small formatting helpers shared by the UI layer (dates, token counts, cost,
|
|
3
|
+
* path shortening). Pure functions only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
9
|
+
|
|
10
|
+
function pad2(n: number): string {
|
|
11
|
+
return n < 10 ? `0${n}` : String(n);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** "22:18" style time for tree rows. */
|
|
15
|
+
export function formatTime(timestamp: number): string {
|
|
16
|
+
const d = new Date(timestamp);
|
|
17
|
+
if (Number.isNaN(d.getTime())) return "--:--";
|
|
18
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** "Sep 20 22:18" style for dialogs. */
|
|
22
|
+
export function formatDateTime(timestamp: number): string {
|
|
23
|
+
const d = new Date(timestamp);
|
|
24
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
25
|
+
return `${MONTHS[d.getMonth()]} ${d.getDate()} ${formatTime(timestamp)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "09-20 22:18" — compact form used by the sessions pane. */
|
|
29
|
+
export function formatShortDate(timestamp: number): string {
|
|
30
|
+
const d = new Date(timestamp);
|
|
31
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
32
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${formatTime(timestamp)}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 84213 -> "84.2k" */
|
|
36
|
+
export function formatTokens(tokens: number): string {
|
|
37
|
+
if (!Number.isFinite(tokens) || tokens < 0) return "0";
|
|
38
|
+
if (tokens < 1000) return String(Math.round(tokens));
|
|
39
|
+
if (tokens < 1_000_000) return `${trimZero((tokens / 1000).toFixed(1))}k`;
|
|
40
|
+
return `${trimZero((tokens / 1_000_000).toFixed(1))}M`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function trimZero(s: string): string {
|
|
44
|
+
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 1.4211 -> "$1.42" */
|
|
48
|
+
export function formatCost(usd: number): string {
|
|
49
|
+
if (!Number.isFinite(usd)) return "$0.00";
|
|
50
|
+
return `$${usd.toFixed(2)}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Replace the home directory prefix with "~".
|
|
55
|
+
*
|
|
56
|
+
* 跨平台:Windows 下 homedir() 是 `C:\Users\xxx`,pi 存的 cwd 也用 `\`;
|
|
57
|
+
* 但同一台机器上也可能出现 `/`(git bash、手写路径),所以两种分隔符都认。
|
|
58
|
+
* Windows 盘符大小写不敏感,比较前缀时忽略大小写。
|
|
59
|
+
*/
|
|
60
|
+
export function shortenPath(path: string): string {
|
|
61
|
+
const home = homedir();
|
|
62
|
+
if (!home) return path;
|
|
63
|
+
const isWin = process.platform === "win32";
|
|
64
|
+
const norm = (s: string) => (isWin ? s.toLowerCase() : s);
|
|
65
|
+
if (norm(path) !== norm(home) && !norm(path).startsWith(norm(home) + "/") && !norm(path).startsWith(norm(home) + "\\")) {
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
return `~${path.slice(home.length)}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Collapse whitespace/newlines so a piece of text fits on one line. */
|
|
72
|
+
export function singleLine(text: string): string {
|
|
73
|
+
return text.replace(/\s+/g, " ").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `\r\n` / 孤立 `\r` 统一成 `\n`。
|
|
78
|
+
* 实测 pi 在 Windows 上写的 .jsonl 本身不带 `\r`,但工具输出、粘贴内容可能带;
|
|
79
|
+
* 残留的 `\r` 会让 split("\n") 后的行尾多一个不可见字符,影响宽度计算和渲染。
|
|
80
|
+
*/
|
|
81
|
+
export function normalizeNewlines(text: string): string {
|
|
82
|
+
return text.replace(/\r\n?/g, "\n");
|
|
83
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path helpers for paths the user types into a prompt (export target, import source).
|
|
3
|
+
*
|
|
4
|
+
* 用户在输入框里敲的路径:去掉首尾引号、展开开头的 `~`(用 os.homedir(),Windows 下也能用),
|
|
5
|
+
* 相对路径按 pi 的工作目录解析。只做字符串处理,不碰文件系统。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
/** Strip one pair of matching surrounding quotes, as a shell would ("C:\My Files\a.jsonl"). */
|
|
12
|
+
export function stripQuotes(value: string): string {
|
|
13
|
+
const first = value[0];
|
|
14
|
+
if (value.length >= 2 && (first === '"' || first === "'") && value.endsWith(first)) return value.slice(1, -1);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Expand a leading `~` / `~/` / `~\` to the home directory; anything else is returned unchanged. */
|
|
19
|
+
export function expandHome(value: string): string {
|
|
20
|
+
if (value === "~") return homedir();
|
|
21
|
+
if (/^~[\\/]/.test(value)) return join(homedir(), value.slice(2));
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Absolute path of what the user typed: trimmed, unquoted, `~` expanded,
|
|
27
|
+
* relative paths resolved against `cwd`. Returns "" for blank input.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveUserPath(cwd: string, input: string): string {
|
|
30
|
+
const value = stripQuotes(input.trim()).trim();
|
|
31
|
+
if (!value) return "";
|
|
32
|
+
return resolve(cwd, expandHome(value));
|
|
33
|
+
}
|