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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/README.zh.md +104 -0
  4. package/docs/design.md +235 -0
  5. package/docs/keybindings.md +172 -0
  6. package/docs/keybindings.zh.md +172 -0
  7. package/package.json +67 -0
  8. package/src/actions/session-actions.ts +585 -0
  9. package/src/actions/tree-actions.ts +161 -0
  10. package/src/config/config.ts +155 -0
  11. package/src/config/keymap.ts +219 -0
  12. package/src/config/keys.ts +279 -0
  13. package/src/config/pi-settings.ts +54 -0
  14. package/src/constants.ts +48 -0
  15. package/src/data/changelog.ts +63 -0
  16. package/src/data/content.ts +178 -0
  17. package/src/data/search.ts +183 -0
  18. package/src/data/sessions.ts +148 -0
  19. package/src/data/tree-fold.ts +163 -0
  20. package/src/data/tree.ts +258 -0
  21. package/src/i18n/index.ts +103 -0
  22. package/src/i18n/locales/en.json +341 -0
  23. package/src/i18n/locales/zh.json +341 -0
  24. package/src/index.ts +155 -0
  25. package/src/types.ts +346 -0
  26. package/src/ui/app.ts +2892 -0
  27. package/src/ui/frame.ts +116 -0
  28. package/src/ui/mouse-input.ts +197 -0
  29. package/src/ui/mouse.ts +99 -0
  30. package/src/ui/panes/content-pane.ts +132 -0
  31. package/src/ui/panes/sessions-pane.ts +161 -0
  32. package/src/ui/panes/tree-pane.ts +152 -0
  33. package/src/ui/search-highlight.ts +108 -0
  34. package/src/ui/tree-lines.ts +111 -0
  35. package/src/ui/tree-outline.ts +80 -0
  36. package/src/ui/widgets/changelog-dialog.ts +196 -0
  37. package/src/ui/widgets/compact-dialog.ts +29 -0
  38. package/src/ui/widgets/confirm-dialog.ts +98 -0
  39. package/src/ui/widgets/export-dialog.ts +62 -0
  40. package/src/ui/widgets/footer.ts +117 -0
  41. package/src/ui/widgets/fork-dialog.ts +28 -0
  42. package/src/ui/widgets/help-overlay.ts +220 -0
  43. package/src/ui/widgets/import-dialog.ts +32 -0
  44. package/src/ui/widgets/input-dialog.ts +136 -0
  45. package/src/ui/widgets/label-dialog.ts +28 -0
  46. package/src/ui/widgets/new-session-dialog.ts +29 -0
  47. package/src/ui/widgets/prompt-bar.ts +92 -0
  48. package/src/ui/widgets/rename-dialog.ts +29 -0
  49. package/src/ui/widgets/restore-dialog.ts +60 -0
  50. package/src/ui/widgets/search-bar.ts +65 -0
  51. package/src/ui/widgets/select-dialog.ts +193 -0
  52. package/src/ui/widgets/session-info-dialog.ts +189 -0
  53. package/src/ui/widgets/tree-dialog.ts +293 -0
  54. package/src/utils/format.ts +83 -0
  55. package/src/utils/paths.ts +33 -0
@@ -0,0 +1,172 @@
1
+ **English** | [中文](./keybindings.zh.md)
2
+
3
+ # Keybindings & configuration
4
+
5
+ The single source of truth for the default keys is `src/config/keymap.ts`. You can override any binding under `keymap.<scope>.<action-id>` in `~/.pi/agent/lazy-panel.json` (scopes: `global`, `sessions`, `tree`, `content`, `tree-dialog`). An action id is exactly a member of the `ActionId` union in `src/types.ts`.
6
+
7
+ ## Configuration
8
+
9
+ The config file lives at `~/.pi/agent/lazy-panel.json`. Every field is optional and falls back to a built-in default when omitted:
10
+
11
+ ```json
12
+ {
13
+ "locale": "zh",
14
+ "defaultScope": "all",
15
+ "defaultSort": "recent",
16
+ "leftColumnRatio": 0.25,
17
+ "keymap": {
18
+ "global": { "help": "F1", "scope-all": ["A", "ctrl+space"] },
19
+ "sessions": { "session-delete": "ctrl+d", "session-share": null }
20
+ }
21
+ }
22
+ ```
23
+
24
+ | Field | Description | Values / range | Default |
25
+ | --- | --- | --- | --- |
26
+ | `locale` | UI language | `"en"` / `"zh"` | follows system language |
27
+ | `defaultScope` | session scope on open | `"current-folder"` / `"all"` | `"current-folder"` |
28
+ | `defaultSort` | session list ordering | `"recent"` / `"created"` / `"title"` / `"threaded"` | `"recent"` |
29
+ | `leftColumnRatio` | left column width ratio | `0.15` ~ `0.6` | `0.25` |
30
+ | `keymap` | custom keybindings (see below) | grouped by scope | built-in keys |
31
+
32
+ - Invalid `locale` / `defaultScope` / `defaultSort` fall back to the default, an out-of-range `leftColumnRatio` is ignored too, and the panel reports it in the footer when it opens.
33
+ - When `locale` is omitted it is auto-detected from the system language (see `src/i18n/index.ts`).
34
+
35
+ ## Custom keybindings
36
+
37
+ ```json
38
+ {
39
+ "keymap": {
40
+ "global": { "help": "F1", "scope-all": ["A", "ctrl+space"], "focus-sessions": "F5" },
41
+ "sessions": { "session-delete": "ctrl+d", "session-share": null }
42
+ }
43
+ }
44
+ ```
45
+
46
+ - A value is **one chord** or an **array of chords**. It **replaces** the action's default binding (it does not append), and `null` unbinds the action.
47
+ - **The config name is the action id** (the middle column in each table below); just place it under the matching scope — e.g. to change SESSIONS delete to `ctrl+d`: `{ "keymap": { "sessions": { "session-delete": "ctrl+d" } } }`. Each pane section's scope is in its heading: global → `global`, SESSIONS → `sessions`, TREE → `tree`, CONTENT → `content`, tree dialog → `tree-dialog` (the tables note the exceptions that belong to another scope).
48
+
49
+ - Chord syntax (`src/config/keys.ts`):
50
+ - Single key: `j`, `?`, `/`, `1`, `tab`, `enter`, `escape`, `space`, `up`, `pageDown`, `f1`
51
+ - Modifiers: `ctrl+d`, `shift+tab`, `alt+x`, `ctrl+shift+p` (any order)
52
+ - An uppercase letter is shorthand for shift: `G` = `shift+g`
53
+ - Multi-key sequences: `gg`, `yy` (written back-to-back), or space-separated `ctrl+w h`
54
+ - Pane bindings shadow the global binding of the same key (e.g. `n` is "new session" in the SESSIONS pane and "next match" elsewhere). `h` / `l` are not shadowed by any default pane binding, so switching panes works the same everywhere.
55
+ - The tree dialog resolves in the order `tree-dialog` → `tree` → `global`: its own keys (`d` `t` `u` `l` `a` `q`) shadow the pane and global bindings (`a` becomes filter instead of open-dialog, `l` becomes the "labeled-only" filter instead of "next pane", `q` becomes close-dialog instead of quit); everything else follows the tree pane's bindings.
56
+ - An invalid chord or unknown scope is skipped and shown in the footer when the panel opens.
57
+ - A multi-key sequence waits at most 1 second for the next key; `Esc` discards a half-typed sequence.
58
+ - The pane title shows the jump key bound to `focus-<pane>` (`[1] SESSIONS`), so rebinding it changes the title too.
59
+
60
+ ## Global keybindings
61
+
62
+ | Key | Config name | Action |
63
+ | --- | --- | --- |
64
+ | `l` / `Tab` | `focus-next` | Focus the next pane |
65
+ | `h` | `focus-prev` | Focus the previous pane |
66
+ | `1` `2` `3` | `focus-sessions` / `focus-tree` / `focus-content` | Focus SESSIONS / TREE / CONTENT directly |
67
+ | `C` | `scope-current` | List scope: current folder |
68
+ | `A` | `scope-all` | List scope: all |
69
+ | `?` | `help` | Shortcut help for the current pane (`?` / `Esc` / `q` closes, `j` / `k` scrolls) |
70
+ | `q` / `Ctrl+c` | `quit` | Quit the panel |
71
+ | `@` | `changelog` | pi's changelog (`/changelog`) as a large popup: `j` `k` `↑` `↓` scroll, `Ctrl+d` / `Ctrl+u` half-page, `g` / `G` top / bottom, `Esc` / `q` / `@` close. The whole file renders slowly, so the popup first shows a spinning square `◰ Loading changelog…` in the center, then the content (cached, so a second `@` opens instantly). The newest version is on top (pi's own `/changelog` puts it last). Not available inside the tree dialog |
72
+ | `Esc` | — (built-in, not configurable) | In order: discard an unfinished keystroke → clear the current pane's search → clear the SESSIONS multi-selection → quit |
73
+ | `/` | `search` | Search the current pane (see below): the bottom search bar searches as you type, `Enter` keeps the keyword, `Esc` cancels |
74
+ | `n` / `N` | `search-next` / `search-prev` | Next / previous match of the current pane's search, wrapping around. While a search is active they take priority over the pane binding of the same key (`n` is "new session" only in SESSIONS when there is no search) |
75
+
76
+ `C` and `A` are one-way: pressing `A` again when already in *All* does nothing.
77
+
78
+ The `?` help popup lists every binding, sorted by frequency of use (the most common first, e.g. `Enter` before the vim movement keys). The footer only hints at a few keys per pane; the long tail (sort / info / compact / fork / clone / export / import / share / changelog) is only visible in `?`.
79
+
80
+ ## Search (`/`)
81
+
82
+ lazygit-style, tracked per pane: list rows are never filtered, the cursor jumps between matches. `/` opens the current pane's `Search:` bar at the bottom; every keystroke jumps to "the first match at or after the cursor" (wrapping to the first when it hits the end), and puts the cursor back where it was when there is no match. `Enter` keeps the keyword and hands keystrokes back to the pane; `Esc` in the search bar discards the keyword and restores the cursor (also restoring the fold state in TREE). Afterwards the pane title shows `2/7 matches` (`7 matches` when the cursor is not on a match, `no matches` when there is none), the footer shows the keyword and `n next N prev Esc clear`, and `n` / `N` step through matches, wrapping at both ends. `Esc` in the pane ends that pane's search. Each pane keeps its own keyword, so switching panes does not lose it; only the currently focused pane's search is stepped by `n` / `N`, while the other panes' hits stay highlighted. Matched text is highlighted; the current match (the one under the cursor) is inverted to distinguish it from the other hits.
83
+
84
+ Keywords are case-insensitive and every word must appear. A bare word (with no qualifier) only matches "what names a row" (session name / preview, tree node label / body, content body); model, path, role and date all need a `key:value` qualifier (the last of a duplicate qualifier wins, an unknown key and an unparseable date are searched as bare words, and a qualifier for a field a pane does not have is ignored):
85
+
86
+ | Pane | Fields a bare word matches | Qualifiers |
87
+ | --- | --- | --- |
88
+ | SESSIONS | Session name and first-message preview (the row's title); model and working directory do not participate in bare-word matching | `name:` (name only), `model:` (model only), `path:` (working directory, full path or `~/…` form), `after:` / `before:` (last updated, `YYYY-MM-DD`); `tag:` is ignored |
89
+ | TREE | Label and body; folded rows count too. Role does not participate in bare-word matching (unlike `/tree`) | `tag:` (label only), `role:` (`user` / `assistant` / `system` / `tool`), `after:` / `before:` (node time); `name:` `model:` `path:` are ignored |
90
+ | CONTENT | The rendered message body lines (skipping the message-box header); a hit is scrolled to the top of the pane | none (qualifiers are dropped, only bare words are searched) |
91
+
92
+ Jumping to a TREE match inside a folded branch expands it, and CONTENT on the right follows the cursor as usual. The tree dialog (`a`) has its own search row, which **filters** rather than jumps; the two are independent.
93
+
94
+ ## SESSIONS pane
95
+
96
+ When the panel opens, the cursor lands on the session pi currently has open (so TREE / CONTENT show the current conversation); a brand-new session that is not listed yet leaves the cursor on the first row. After toggling scope with `C` / `A` it still starts from the top. `/` searches the session name and the first-message preview, while `model:` / `path:` reach the model and working directory (see *Search* above); while that search is active `n` / `N` step through matches rather than `n` creating a new session.
97
+
98
+ | Key | Config name | Action |
99
+ | --- | --- | --- |
100
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | Move the cursor |
101
+ | `gg` / `G` | `go-top` / `go-bottom` | Top / bottom |
102
+ | `J` / `K` | `scroll-content-down` / `scroll-content-up` | Scroll the CONTENT pane on the right |
103
+ | `Enter` | `session-resume` | Resume the session: pi switches to it and the panel closes; on failure (missing file, switch cancelled) it stays with a footer message |
104
+ | `d` | `session-delete` | Delete: a centered *Delete session?* box asks Yes / No (cursor on No, `y` / `n` selects directly, `Enter` confirms the highlighted item, `Esc` cancels). Like pi's `/resume`, it moves to the system trash when the `trash` CLI exists, otherwise it unlinks directly (the footer says which); the session pi has open is refused without asking. The list reloads, the cursor is clamped back into range, and TREE / CONTENT follow |
105
+ | `r` | `session-rename` | Rename in a centered box prefilled with the current name (`Enter` saves, `Esc` cancels, an empty value clears the name; equivalent to `/name` or ctrl+r in `/resume`). The list reloads and the cursor stays on that session |
106
+ | `n` | `session-new` | New session (`/new`): a centered *New session* box asks for a name (`Enter` creates, `Esc` cancels, **leaving it empty starts an unnamed session** — the `[name]` argument of `/name` is not set). pi switches to the new session and the panel closes |
107
+ | `o` | `session-fork` | Fork (`/fork`): a centered picker lists the session's user messages (cursor on the last one, matching pi's own `/fork`; `j` / `k` move, long lists scroll), and after `Enter` a *Fork session?* Yes / No box confirms (`Esc` / No returns to the picker). The fork starts **before** that message, and pi refills its text into the editor. A non-current session is switched to first; when there is no user message it reports *No messages to fork from* |
108
+ | `y` | `session-clone` | Clone (`/clone`): a *Clone session?* Yes / No box confirms (cursor on No), then the active branch is copied to a new session file and pi opens it |
109
+ | `Y` | `session-copy-last-reply` | Copy the last assistant reply to the clipboard (`/copy`): only its text fragments, skipping thinking and tool calls; the panel stays open and the footer reports the result (*copied last reply* or *no assistant reply to copy*) |
110
+ | `Space` | `session-toggle-select` | Toggle multi-select: no marker glyph is drawn — a selected row's title is colored (accent) to stand out in a long list — and the title starts with `3 selected`. With a selection, `d` deletes them all after a single *Delete N sessions?* confirmation (skipping the session pi has open; failures stay listed and selected, the first error goes to the footer), while `r` `o` `y` `e` `S` refuse (*cannot act on multiple sessions*). `Esc` clears the selection first and only quits on the next press |
111
+ | `e` | `session-export` | Export (`/export`): a centered *Export as* menu picks **HTML** (the whole tree, rendered by pi's own `pi --export`) or **JSONL** (the active branch, re-importable), then an *Export to* box prefilled with pi's default path (`pi-session-<file>.html` / `session-<time>.jsonl` under pi's working directory). Relative paths and `~` work on all platforms; a directory (existing, or an input ending in `/`) uses the default filename inside it. `Esc` in the box returns to the menu; an existing file first asks *Overwrite file?*. Any session can be exported, not just the current one; the panel stays open and the footer says where the file went |
112
+ | `I` | `session-import` | Import (`/import`): a centered box asks for a session `.jsonl` (relative to pi's working directory, `~` allowed), an *Import and switch to it?* Yes / No box confirms, then the file is copied into the current session directory (a name clash gets a `-1` suffix), pi switches to it, and the panel closes. A missing / empty / non-pi file is reported in the footer and nothing is copied |
113
+ | `S` | `session-share` | Share (`/share`): an *Upload as secret gist?* Yes / No box confirms (cursor on No), then the session is rendered to HTML and uploaded with `gh gist create --public=false`; the pi.dev view link is copied to the clipboard and shown in the footer. Requires a logged-in GitHub CLI (otherwise it warns in pi's wording); pi's Radius upload is not available to extensions |
114
+ | `s` | `session-sort` | Cycle the sort: recent (last updated) → created (creation time) → title (by the displayed title — the name if present, otherwise the first-message preview — A–Z, empty sessions last) → threaded (forks indented under their parent) → …; the title shows the current sort and the cursor follows its session |
115
+ | `i` | `session-info` | The centered session-info box (what `/session` shows: name, model, message count, tokens, cost, created / updated, path, id); `y` copies all the text, `Esc` / `q` closes |
116
+ | `c` | `session-compact` | Compact (`/compact`): a centered *Compact* box asks for an optional focus instruction (`Enter` compacts, `Esc` cancels, empty uses pi's default). After compacting the active branch of the session under the cursor, pi opens it — a non-current session is switched to first (that is "entering the conversation"). During compaction pi's own footer shows a spinner; on success the panel closes, on failure (no model, session too small, already compacted) it stays with a footer message. `c` refuses when multiple sessions are selected |
117
+
118
+ ## TREE pane
119
+
120
+ The pane shows the tree as a collapsible outline: `▸` a folded side branch, `▾` an expanded side branch, `─` a side branch that was never continued; rows within a branch indent two columns per level (up to four levels, deeper ones replaced by `… `). Side branches are folded by default and the active branch is expanded. Filtering lives in the tree dialog (`a`), which draws the same rows with pi-style guide lines and has its own live search row to narrow the list. `/` in the pane is the jump-style search from *Search* above (label / body, `tag:` matches the label, `role:` matches the role; a match inside a folded branch is expanded). A filter chosen in the dialog is kept: the pane lists the same filtered tree and marks it in the title (`2/12 · user-only`); when the panel opens it adopts pi's own `treeFilterMode` setting (the filter `/tree` starts with).
121
+
122
+ | Key | Config name | Action |
123
+ | --- | --- | --- |
124
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | Move the cursor |
125
+ | `gg` / `G` | `go-top` / `go-bottom` | Top / bottom |
126
+ | `Enter` | `tree-restore` | Restore to the node, like `/tree`: a centered menu asks *No summary / Summarize / Summarize with custom prompt* (`j`/`k`/`↑`/`↓` move, `Enter` selects, `Esc` returns to the tree); the custom prompt is a single-line input (`Enter` summarizes, `Esc` returns to the menu). Switches to the session first if needed. No menu appears when the node is itself a leaf (`Enter` just closes the panel) or when pi's `branchSummary.skipPrompt` is on |
127
+ | `z` | `tree-fold` | Fold / unfold the branch under the cursor: toggles on a `▸` / `▾` row, and on any row inside a branch folds that branch and jumps to its head (vim's `zc`); the trunk of a linear conversation has no foldable section |
128
+ | `y` | `tree-copy` | Copy the node text (the full text, equivalent to `Ctrl+x` in `/tree`) |
129
+ | `T` | `tree-label` | Add / edit a label in a centered dialog, like lazygit's commit popup (`Enter` saves, `Esc` cancels, an empty value deletes it; equivalent to `Shift+T` in `/tree`) |
130
+ | `a` | `tree-open` | Open the tree dialog: a large popup of the whole tree (search row on top, key hints at the bottom) that shares fold state with the pane; keys below |
131
+
132
+ ## Tree dialog (press `a` in the TREE pane)
133
+
134
+ A large popup of the whole tree: a search row on top, rows in the middle (pi-style guide lines, a folded row shows `⊞` at the junction), key hints at the bottom (the footer repeats them, so there is no `?` help inside the dialog). The dialog has its own cursor; on close the pane cursor moves onto it. Keys resolve `tree-dialog` scope first, then the tree pane's, then global, so `j` `k`, `gg` `G`, `Enter`, `y`, `T`, `z` and `/` are the pane's keys (and follow their rebindings), while the dialog's own keys are under `keymap."tree-dialog"`. Switching panes (`h` `Tab` `1`..`3`), list scope (`C` `A`), `n` `N`, `?`, `@` and quitting the panel (`Ctrl+c`) all do nothing here.
135
+
136
+ In the table below the parenthesis after "Config name" marks which scope it belongs to (change these keys under that scope): the filter keys and the close key are in `tree-dialog`, while move / restore / copy / label / fold follow `tree` and search follows `global`.
137
+
138
+ | Key | Config name (scope) | Action |
139
+ | --- | --- | --- |
140
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` (tree) | Move the dialog's cursor |
141
+ | `gg` / `G` | `go-top` / `go-bottom` (tree) | Top / bottom |
142
+ | `/` | `search` (global) | Focus the search row; typing filters rows live, like `/tree`: every word must appear in the row's label / body (case-insensitive), `tag:x` narrows to the label, `role:user` to the role, `after:2026-09-01` / `before:2026-09-20` to the date. `Esc` hands keystrokes back to the list and keeps the keyword (rows stay narrowed); press `/` again to edit it, and deleting all the text clears it. `Enter` in the search row has no meaning. While a keyword is active all matches are shown (folds are cleared, like `/tree`); once the keyword is cleared or the dialog is closed the fold state is restored |
143
+ | `Enter` | `tree-restore` (tree) | Restore to that row, exactly like the pane (including the summary menu) |
144
+ | `y` | `tree-copy` (tree) | Copy that row's text |
145
+ | `T` | `tree-label` (tree) | Add / edit that row's label (the Label dialog stacks over the tree dialog) |
146
+ | `z` | `tree-fold` (tree) | Fold / unfold the branch under the cursor (same rules and fold state as the pane) |
147
+ | `d` `t` `u` `l` `a` | `tree-filter-default` / `tree-filter-no-tools` / `tree-filter-user` / `tree-filter-labeled` / `tree-filter-all` (tree-dialog) | Filter: default (hide accounting info) / no tool results / user only / labeled only / all; pressing `t` `u` `l` `a` again returns to default (matching pi's `Ctrl+d/t/u/l/a`). The tree is reloaded and folds are cleared, and the pane then shows the same filter |
148
+ | `Esc` / `q` | `tree-dialog-close` (tree-dialog; `Esc` is built-in and not configurable) | Close (while on the list): the pane cursor lands on the dialog's row (expanding the fold that hides it), and CONTENT follows. In the search row `Esc` only leaves the search row, and `q` is just a letter |
149
+
150
+ ## CONTENT pane (read-only)
151
+
152
+ Only scrolling and search; copying a message is done in the TREE pane (`y`).
153
+
154
+ | Key | Config name | Action |
155
+ | --- | --- | --- |
156
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | Scroll |
157
+ | `gg` / `G` | `go-top` / `go-bottom` | Top / bottom |
158
+ | `/` | `search` (global) | Search the rendered message body (bare words only, no qualifiers); a matching line is scrolled to the top and `n` / `N` step through hits |
159
+
160
+ ## Mouse
161
+
162
+ The core is still the keyboard; the mouse is only lightly supported. Both regular (default) and fullscreen TUI modes are supported: in fullscreen pi dispatches events to the panel, while in regular mode the plugin turns on SGR mouse reporting itself (needs a terminal that supports SGR, which virtually all modern terminals do; while the panel is open the terminal's own selection / scrolling is taken over, and restored on close). See `docs/issues.md`.
163
+
164
+ | Action | Behavior |
165
+ | --- | --- |
166
+ | Wheel / three-finger up-down | Scroll the view of the pane under the pointer (a list only scrolls, it does not move the selection; CONTENT scrolls by line), without changing focus |
167
+ | Single click | Focus moves to the pane under the pointer; clicking a list item also moves the cursor to it |
168
+ | Double-click SESSIONS | Enter that session (equivalent to `Enter` / resume) |
169
+ | Double-click TREE | Fold / unfold the branch under the cursor (equivalent to `z`) |
170
+
171
+
172
+
@@ -0,0 +1,172 @@
1
+ [English](./keybindings.md) | **中文**
2
+
3
+ # 快捷键与配置
4
+
5
+ 默认键位的唯一真实来源是 `src/config/keymap.ts`。可在 `~/.pi/agent/lazy-panel.json` 的 `keymap.<scope>.<action-id>` 下覆盖任意键位(scope:`global`、`sessions`、`tree`、`content`、`tree-dialog`)。动作 id 就是 `src/types.ts` 里的 `ActionId` 联合类型。
6
+
7
+ ## 配置文件
8
+
9
+ 配置文件位于 `~/.pi/agent/lazy-panel.json`,所有字段均为可选,缺省时使用内置默认值:
10
+
11
+ ```json
12
+ {
13
+ "locale": "zh",
14
+ "defaultScope": "all",
15
+ "defaultSort": "recent",
16
+ "leftColumnRatio": 0.25,
17
+ "keymap": {
18
+ "global": { "help": "F1", "scope-all": ["A", "ctrl+space"] },
19
+ "sessions": { "session-delete": "ctrl+d", "session-share": null }
20
+ }
21
+ }
22
+ ```
23
+
24
+ | 字段 | 说明 | 可选值 / 范围 | 默认 |
25
+ | --- | --- | --- | --- |
26
+ | `locale` | UI 语言 | `"en"` / `"zh"` | 跟随系统语言 |
27
+ | `defaultScope` | 打开时的会话范围 | `"current-folder"` / `"all"` | `"current-folder"` |
28
+ | `defaultSort` | 会话列表排序 | `"recent"` / `"created"` / `"title"` / `"threaded"` | `"recent"` |
29
+ | `leftColumnRatio` | 左侧列宽占比 | `0.15` ~ `0.6` | `0.25` |
30
+ | `keymap` | 自定义快捷键(见下) | 按 scope 分组 | 内置键位 |
31
+
32
+ - 非法的 `locale` / `defaultScope` / `defaultSort` 会退回默认值,越界的 `leftColumnRatio` 也会被忽略,并在面板打开时于 footer 提示。
33
+ - `locale` 缺省时按系统语言自动检测(见 `src/i18n/index.ts`)。
34
+
35
+ ## 自定义快捷键
36
+
37
+ ```json
38
+ {
39
+ "keymap": {
40
+ "global": { "help": "F1", "scope-all": ["A", "ctrl+space"], "focus-sessions": "F5" },
41
+ "sessions": { "session-delete": "ctrl+d", "session-share": null }
42
+ }
43
+ }
44
+ ```
45
+
46
+ - 一个值是**一个 chord** 或 **chord 数组**。它**替换**该动作的默认键位(不是追加),`null` 表示解绑该动作。
47
+ - **配置名称就是动作 id**(下面每张表格中间那一列),把它放到对应 scope 下即可,例如给 SESSIONS 的删除换成 `ctrl+d`:`{ "keymap": { "sessions": { "session-delete": "ctrl+d" } } }`。每个面板小节的 scope 见其标题:全局→`global`、SESSIONS→`sessions`、TREE→`tree`、CONTENT→`content`、树对话框→`tree-dialog`(表格里标注了非本 scope 的例外)。
48
+
49
+ - Chord 语法(`src/config/keys.ts`):
50
+ - 单键:`j`、`?`、`/`、`1`、`tab`、`enter`、`escape`、`space`、`up`、`pageDown`、`f1`
51
+ - 修饰键:`ctrl+d`、`shift+tab`、`alt+x`、`ctrl+shift+p`(顺序任意)
52
+ - 大写字母是 shift 的简写:`G` = `shift+g`
53
+ - 多键序列:`gg`、`yy`(原样连写),或空格分隔的 `ctrl+w h`
54
+ - 面板键位会遮蔽同一个键的全局键位(例如 `n` 在 SESSIONS 面板是「新建会话」,在其它面板是「下一个匹配」)。`h` / `l` 没有被任何默认面板键位遮蔽,所以切换面板在哪里都一样。
55
+ - 树对话框按 `tree-dialog` → `tree` → `global` 顺序解析:它自己的键(`d` `t` `u` `l` `a` `q`)遮蔽面板与全局键位(`a` 变成过滤而不是打开对话框,`l` 变成「只看有标签」过滤而不是「下一个面板」,`q` 变成关闭对话框而不是退出),其余都沿用树面板的键位。
56
+ - 无效的 chord 或未知 scope 会被跳过,并在面板打开时显示在 footer。
57
+ - 多键序列最多等待下一个键 1 秒;`Esc` 丢弃打了一半的序列。
58
+ - 面板标题显示绑定到 `focus-<pane>` 的跳转键(`[1] SESSIONS`),所以重绑它标题也会跟着变。
59
+
60
+ ## 全局快捷键
61
+
62
+ | 键 | 配置名称 | 操作 |
63
+ | --- | --- | --- |
64
+ | `l` / `Tab` | `focus-next` | 聚焦下一个面板 |
65
+ | `h` | `focus-prev` | 聚焦上一个面板 |
66
+ | `1` `2` `3` | `focus-sessions` / `focus-tree` / `focus-content` | 直接聚焦 SESSIONS / TREE / CONTENT |
67
+ | `C` | `scope-current` | 列表范围:当前文件夹(Current folder) |
68
+ | `A` | `scope-all` | 列表范围:全部(All) |
69
+ | `?` | `help` | 当前面板的快捷键帮助(`?` / `Esc` / `q` 关闭,`j` / `k` 滚动) |
70
+ | `q` / `Ctrl+c` | `quit` | 退出面板 |
71
+ | `@` | `changelog` | pi 的 changelog(`/changelog`)大弹窗:`j` `k` `↑` `↓` 滚动,`Ctrl+d` / `Ctrl+u` 半页,`g` / `G` 顶部 / 底部,`Esc` / `q` / `@` 关闭。整份文件渲染较慢,弹窗先在中间显示旋转方块 `◰ Loading changelog…`,随后显示内容(有缓存,第二次按 `@` 瞬间打开)。最新版本在最上面(pi 自己的 `/changelog` 放在最后)。树对话框里不可用 |
72
+ | `Esc` | —(内置,不可配置) | 依次:丢弃未完成的按键 → 清除当前面板的搜索 → 清除 SESSIONS 的多选 → 退出 |
73
+ | `/` | `search` | 搜索当前面板(见下):底部搜索栏边输入边搜,`Enter` 保留关键字,`Esc` 取消 |
74
+ | `n` / `N` | `search-next` / `search-prev` | 当前面板搜索的下一个 / 上一个匹配,循环回绕。搜索生效时它们优先于同名的面板键位(`n` 只有在 SESSIONS 且没有搜索时才是「新建会话」) |
75
+
76
+ `C` 和 `A` 是单向的:已经在 *All* 时再按 `A` 无效。
77
+
78
+ `?` 帮助弹窗列出全部键位,按使用频率排序(最常用的在前,如 `Enter` 排在 vim 移动键之前)。底部 footer 只提示每个面板的少数几个键;长尾(排序 / 信息 / 压缩 / 分叉 / 克隆 / 导出 / 导入 / 分享 / changelog)只在 `?` 里能看到。
79
+
80
+ ## 搜索(`/`)
81
+
82
+ lazygit 风格,每个面板各记各的:列表行永不过滤,光标在匹配之间跳转。`/` 在底部打开当前面板的 `搜索:` 栏;每敲一个键都跳到「光标位置或其之后的第一个匹配」(到底则回绕到第一个),没有匹配时把光标放回原位。`Enter` 保留关键字并把按键交回面板,搜索栏里的 `Esc` 丢弃关键字并恢复光标(在 TREE 里还恢复折叠)。之后面板标题显示 `2/7 matches`(光标不在匹配上时显示 `7 matches`,无匹配显示 `no matches`),footer 显示关键字和 `n next N prev Esc clear`,`n` / `N` 在匹配间步进、两端回绕。面板里的 `Esc` 结束该面板的搜索。每个面板保留自己的关键字,所以切换面板不会丢失;只有当前聚焦面板的搜索会被 `n` / `N` 步进,其它面板的命中仍保持高亮。命中文字会高亮;当前匹配(光标所在的那个)反色,以便和其它命中区分。
83
+
84
+ 关键字大小写不敏感、且每个词都必须出现。裸词(不带限定词)只匹配「命名一行的内容」(会话名 / 预览、树节点 label / 正文、内容正文);模型、路径、角色、日期都需要用 `key:value` 限定词(同类限定词以最后一个为准,未知 key 和无法解析的日期当作裸词搜索,某个面板没有的字段对应的限定词被忽略):
85
+
86
+ | 面板 | 裸词匹配的字段 | 限定词 |
87
+ | --- | --- | --- |
88
+ | SESSIONS | 会话名和首条消息预览(行的标题);模型和工作目录不参与裸词匹配 | `name:`(仅会话名)、`model:`(仅模型)、`path:`(工作目录,完整路径或 `~/…` 写法)、`after:` / `before:`(最后更新,`YYYY-MM-DD`);`tag:` 被忽略 |
89
+ | TREE | label 和正文;折叠的行也算。角色不参与裸词匹配(与 `/tree` 不同) | `tag:`(仅 label)、`role:`(`user` / `assistant` / `system` / `tool`)、`after:` / `before:`(节点时间);`name:` `model:` `path:` 被忽略 |
90
+ | CONTENT | 消息渲染后的正文行(跳过消息框头部);命中会被滚到面板顶部 | 无(限定词被丢弃,只搜裸词) |
91
+
92
+ 跳到折叠分支里的 TREE 匹配会展开它,右侧 CONTENT 照常跟随光标。树对话框(`a`)有自己的搜索行,它是**过滤**而不是跳转,两者互不影响。
93
+
94
+ ## SESSIONS 面板
95
+
96
+ 面板打开时光标落在 pi 当前打开的会话上(因此 TREE / CONTENT 显示当前对话);一个尚未列出的全新会话则把光标停在第一行。`C` / `A` 切换范围后仍从顶部开始。`/` 搜索会话名和首条消息预览,`model:` / `path:` 触及模型和工作目录(见上文*搜索*);该搜索生效期间 `n` / `N` 步进匹配,而不是 `n` 新建会话。
97
+
98
+ | 键 | 配置名称 | 操作 |
99
+ | --- | --- | --- |
100
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | 移动光标 |
101
+ | `gg` / `G` | `go-top` / `go-bottom` | 顶部 / 底部 |
102
+ | `J` / `K` | `scroll-content-down` / `scroll-content-up` | 滚动右侧 CONTENT 面板 |
103
+ | `Enter` | `session-resume` | 恢复会话:pi 切换过去、面板关闭;失败(文件缺失、切换取消)停留在 footer |
104
+ | `d` | `session-delete` | 删除:居中的 *Delete session?* 框问 Yes / No(光标停在 No,`y` / `n` 直接选,`Enter` 确认高亮项,`Esc` 取消)。和 pi 的 `/resume` 一样,有 `trash` CLI 时移入系统回收站,否则直接 unlink(footer 说明用了哪种);pi 当前打开的会话会被拒绝、不询问。列表重新加载,光标夹回范围内,TREE / CONTENT 跟随 |
105
+ | `r` | `session-rename` | 在预填当前名字的居中框里改名(`Enter` 保存,`Esc` 取消,空值清除名字;等同 `/name` 或 `/resume` 里的 ctrl+r)。列表重新加载,光标仍停在该会话 |
106
+ | `n` | `session-new` | 新建会话(`/new`):居中的 *New session* 框询问名字(`Enter` 创建,`Esc` 取消,**留空则以未命名启动** —— `/name` 的 `[name]` 参数不设置)。pi 切换到新会话、面板关闭 |
107
+ | `o` | `session-fork` | 分叉(`/fork`):居中选择器列出会话的用户消息(光标停在最后一条,和 pi 自带 `/fork` 一致;`j` / `k` 移动,长列表可滚动),`Enter` 选中后 *Fork session?* Yes / No 框确认(`Esc` / No 退回选择器)。分叉从那条消息**之前**开始,pi 把它的文本填回编辑器。非当前会话会先切过去;没有用户消息时提示 *No messages to fork from* |
108
+ | `y` | `session-clone` | 克隆(`/clone`):*Clone session?* Yes / No 框确认(光标停在 No),随后把活动分支复制到新会话文件、pi 打开它 |
109
+ | `Y` | `session-copy-last-reply` | 复制最后一条 assistant 回复到剪贴板(`/copy`):只取其文本片段,跳过思考和工具调用;面板保持打开,footer 报告结果(*copied last reply* 或 *no assistant reply to copy*) |
110
+ | `Space` | `session-toggle-select` | 切换多选:不画标记字形 —— 选中行的标题着色(accent),在长列表里凸显 —— 标题以 `3 selected` 开头。有选中时 `d` 在一次 *Delete N sessions?* 确认后删除全部(跳过 pi 打开的会话;失败的仍列出并保持选中,第一条错误进 footer),而 `r` `o` `y` `e` `S` 拒绝(*cannot act on multiple sessions*)。`Esc` 先清空选中、再按才退出 |
111
+ | `e` | `session-export` | 导出(`/export`):居中 *Export as* 菜单选 **HTML**(整棵树,由 pi 自己的 `pi --export` 渲染)或 **JSONL**(活动分支,可重新导入),然后 *Export to* 框预填 pi 的默认路径(pi 工作目录下的 `pi-session-<file>.html` / `session-<time>.jsonl`)。相对路径和 `~` 在所有平台通用;目录(已存在,或以 `/` 结尾输入)会在里面用默认文件名。框里 `Esc` 退回菜单;已存在的文件先问 *Overwrite file?*。任意会话都能导出,不只当前会话;面板保持打开,footer 说明文件去向 |
112
+ | `I` | `session-import` | 导入(`/import`):居中框询问一个会话 `.jsonl`(相对 pi 工作目录,允许 `~`),*Import and switch to it?* Yes / No 框确认,随后文件被复制进当前会话目录(重名加 `-1` 后缀)、pi 切换过去、面板关闭。缺失 / 空 / 非 pi 文件会在 footer 报告,不复制任何东西 |
113
+ | `S` | `session-share` | 分享(`/share`):*Upload as secret gist?* Yes / No 框确认(光标停在 No),随后会话渲染为 HTML 并用 `gh gist create --public=false` 上传;pi.dev 查看链接复制到剪贴板并显示在 footer。需要已登录的 GitHub CLI(否则用 pi 的措辞提示);pi 的 Radius 上传对扩展不可用 |
114
+ | `s` | `session-sort` | 循环排序:recent(最后更新)→ created(创建时间)→ title(按显示的标题 —— 有名字用名字,否则用首条消息预览 —— A–Z,空会话排最后)→ threaded(分叉缩进挂在父会话下)→ …;标题显示当前排序,光标跟随其会话 |
115
+ | `i` | `session-info` | 会话信息居中框(即 `/session` 展示的:名称、模型、消息数、token、费用、创建 / 更新、路径、id);`y` 复制全部文本,`Esc` / `q` 关闭 |
116
+ | `c` | `session-compact` | 压缩(`/compact`):居中 *Compact* 框询问可选的聚焦指令(`Enter` 压缩,`Esc` 取消,留空用 pi 默认)。压缩光标会话的活动分支后 pi 打开它 —— 非当前会话会先切过去(这就是「进入对话」)。压缩期间 pi 自己的 footer 显示旋转 spinner;成功后面板关闭,失败(无模型、会话太小、已压缩)停留在 footer。选中多个会话时 `c` 拒绝 |
117
+
118
+ ## TREE 面板
119
+
120
+ 面板把树显示为折叠大纲:`▸` 折叠的旁支,`▾` 展开的旁支,`─` 从未继续的旁支;分支内的行每层缩进两列(最多四层,更深以 `… ` 代替)。旁支默认折叠,活动分支展开。过滤住在树对话框(`a`)里,对话框用 pi 风格的引导线画同样的行,并有自己的实时搜索行来收窄列表。面板里的 `/` 是上文*搜索*的跳转式搜索(label / 正文,`tag:` 匹配 label,`role:` 匹配角色;折叠分支里的匹配会被展开)。在对话框里选的过滤会保留:面板列出同样过滤后的树,标题也标出(`2/12 · user-only`);面板打开时采用 pi 自己的 `treeFilterMode` 设置(`/tree` 启动时的过滤)。
121
+
122
+ | 键 | 配置名称 | 操作 |
123
+ | --- | --- | --- |
124
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | 移动光标 |
125
+ | `gg` / `G` | `go-top` / `go-bottom` | 顶部 / 底部 |
126
+ | `Enter` | `tree-restore` | 恢复到节点,和 `/tree` 一样:居中菜单问 *No summary / Summarize / Summarize with custom prompt*(`j`/`k`/`↑`/`↓` 移动,`Enter` 选,`Esc` 退回树);自定义提示是单行输入(`Enter` 摘要,`Esc` 退回菜单)。需要时先切换到该会话。节点本身就是叶子(`Enter` 直接关闭面板)或 pi 的 `branchSummary.skipPrompt` 开启时不弹菜单 |
127
+ | `z` | `tree-fold` | 折叠 / 展开光标所在分支:在 `▸` / `▾` 行上切换,在分支内任意行则折叠该分支并跳到段头(vim 的 `zc`);线性对话的主干没有可折叠的段 |
128
+ | `y` | `tree-copy` | 复制节点文本(完整文本,等同 `/tree` 里的 `Ctrl+x`) |
129
+ | `T` | `tree-label` | 在居中对话框里添加 / 编辑 label,类似 lazygit 的 commit 弹窗(`Enter` 保存,`Esc` 取消,空值删除;等同 `/tree` 里的 `Shift+T`) |
130
+ | `a` | `tree-open` | 打开树对话框:整棵树的大弹窗(顶部搜索行,底部键位提示),和面板共享折叠状态;键位见下 |
131
+
132
+ ## 树对话框(在 TREE 面板按 `a`)
133
+
134
+ 整棵树的大弹窗:顶部搜索行,中间是行(pi 风格引导线,折叠的行在连接处显示 `⊞`),底部键位提示(footer 也重复它们,所以对话框内没有 `?` 帮助)。对话框有自己的光标;关闭时面板光标移到它上面。按键先用 `tree-dialog` scope,再树面板的键位,最后全局的,所以 `j` `k`、`gg` `G`、`Enter`、`y`、`T`、`z` 和 `/` 是面板的键(并跟随它们的重绑),而对话框自己的键在 `keymap."tree-dialog"` 下。切换面板(`h` `Tab` `1`..`3`)、列表范围(`C` `A`)、`n` `N`、`?`、`@` 和退出面板(`Ctrl+c`)在这里都无效。
135
+
136
+ 下表「配置名称」后括号标出它属于哪个 scope(要改这些键就在对应 scope 下改):过滤键和关闭键在 `tree-dialog`,移动 / 恢复 / 复制 / 标签 / 折叠沿用 `tree`,搜索沿用 `global`。
137
+
138
+ | 键 | 配置名称(scope) | 操作 |
139
+ | --- | --- | --- |
140
+ | `j` `k` `↑` `↓` | `move-down` / `move-up`(tree) | 移动对话框的光标 |
141
+ | `gg` / `G` | `go-top` / `go-bottom`(tree) | 顶部 / 底部 |
142
+ | `/` | `search`(global) | 聚焦搜索行;输入实时过滤行,和 `/tree` 一样:每个词都要出现在行的 label / 正文里(大小写不敏感),`tag:x` 收窄到 label,`role:user` 到角色,`after:2026-09-01` / `before:2026-09-20` 到日期。`Esc` 把按键交回列表并保留关键字(行保持收窄);再按 `/` 编辑它,删光文本即清除。搜索行里 `Enter` 无含义。关键字生效时全部匹配都显示(折叠被清除,和 `/tree` 一样);关键字清空或关闭对话框后折叠恢复 |
143
+ | `Enter` | `tree-restore`(tree) | 恢复到该行,和面板完全一样(含摘要菜单) |
144
+ | `y` | `tree-copy`(tree) | 复制该行的文本 |
145
+ | `T` | `tree-label`(tree) | 添加 / 编辑该行的 label(Label 对话框叠在树对话框上面) |
146
+ | `z` | `tree-fold`(tree) | 折叠 / 展开光标所在分支(规则和折叠状态与面板相同) |
147
+ | `d` `t` `u` `l` `a` | `tree-filter-default` / `tree-filter-no-tools` / `tree-filter-user` / `tree-filter-labeled` / `tree-filter-all`(tree-dialog) | 过滤:default(隐藏记账信息)/ 无工具结果 / 只看 user / 只看有 label / 全部;`t` `u` `l` `a` 再按一次回到 default(对应 pi 的 `Ctrl+d/t/u/l/a`)。树被重新加载、折叠被清除,面板随后显示同样的过滤 |
148
+ | `Esc` / `q` | `tree-dialog-close`(tree-dialog;`Esc` 内置不可配置) | 关闭(在列表上):面板光标落到对话框的行上(展开挡住它的折叠),CONTENT 跟随。在搜索行里 `Esc` 只离开搜索行,`q` 只是一个字母 |
149
+
150
+ ## CONTENT 面板(只读)
151
+
152
+ 只有滚动和搜索;复制消息在 TREE 面板做(`y`)。
153
+
154
+ | 键 | 配置名称 | 操作 |
155
+ | --- | --- | --- |
156
+ | `j` `k` `↑` `↓` | `move-down` / `move-up` | 滚动 |
157
+ | `gg` / `G` | `go-top` / `go-bottom` | 顶部 / 底部 |
158
+ | `/` | `search`(global) | 搜索渲染后的消息正文(只搜裸词,无限定词);匹配行被滚到顶部,`n` / `N` 步进命中 |
159
+
160
+ ## 鼠标
161
+
162
+ 核心仍是键盘,鼠标只做轻量适配。regular(默认)和 fullscreen 两种 TUI 模式都支持:fullscreen 由 pi 把事件派给面板,regular 模式插件自己打开 SGR 鼠标上报(需要终端支持 SGR,现代终端基本都行;面板打开期间终端自己的选中 / 滚动被接管,关闭后恢复)。见 `docs/issues.md`。
163
+
164
+ | 操作 | 行为 |
165
+ | --- | --- |
166
+ | 滚轮 / 三指上下 | 滚动指针所在面板的视图(列表只滚动、不移动选中项;CONTENT 按行滚动),不改变焦点 |
167
+ | 单击 | 焦点切到指针所在面板;点在列表项上时同时把光标移到该项 |
168
+ | 双击 SESSIONS | 进入该会话(等价于 `Enter` / resume) |
169
+ | 双击 TREE | 折叠 / 展开光标所在分支(等价于 `z`) |
170
+
171
+
172
+
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "pi-lazy-panel",
3
+ "version": "0.1.0",
4
+ "description": "A lazygit-style TUI for browsing, resuming and managing pi sessions (/lazy-panel)",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "tui",
10
+ "sessions",
11
+ "lazygit"
12
+ ],
13
+ "license": "MIT",
14
+ "files": [
15
+ "src",
16
+ "docs/design.md",
17
+ "docs/keybindings.md",
18
+ "docs/keybindings.zh.md",
19
+ "README.md"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "tsc --noEmit",
28
+ "test": "node --import tsx --test \"test/**/*.test.ts\"",
29
+ "dev": "pi -e ./src/index.ts",
30
+ "install:pi": "pi install .",
31
+ "uninstall:pi": "pi remove .",
32
+ "i18n:scan": "node scripts/i18n-scan.mjs",
33
+ "i18n:check": "node scripts/i18n-scan.mjs --check",
34
+ "lint": "eslint .",
35
+ "lint:fix": "eslint . --fix",
36
+ "ci": "node scripts/ci-check.mjs",
37
+ "prepare": "node scripts/install-hooks.mjs"
38
+ },
39
+ "dependencies": {
40
+ "i18next": "23.16.8"
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*",
44
+ "@earendil-works/pi-tui": "*",
45
+ "typebox": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@commitlint/cli": "^21.2.3",
49
+ "@commitlint/config-conventional": "^21.2.3",
50
+ "@earendil-works/pi-coding-agent": "0.85.1",
51
+ "@earendil-works/pi-tui": "0.85.1",
52
+ "@eslint/js": "^10.0.1",
53
+ "@stylistic/eslint-plugin": "^5.10.0",
54
+ "@types/node": "^22.14.0",
55
+ "eslint": "^10.11.0",
56
+ "eslint-plugin-unused-imports": "^4.4.1",
57
+ "globals": "^17.12.0",
58
+ "husky": "^9.1.7",
59
+ "tsx": "^4.19.0",
60
+ "typebox": "1.3.7",
61
+ "typescript": "^5.6.0",
62
+ "typescript-eslint": "^8.70.1"
63
+ },
64
+ "engines": {
65
+ "node": ">=20"
66
+ }
67
+ }