pisesh 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/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/bin/pisesh +644 -0
- package/extensions/sesh.ts +83 -0
- package/package.json +50 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.0] — 2026-05-31
|
|
6
|
+
|
|
7
|
+
Initial release.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `pisesh` CLI binary — keyboard-driven TUI that lists every pi session under `~/.pi/agent/sessions/`
|
|
11
|
+
- Tabs: **★ Favorites**, **Today**, **All**
|
|
12
|
+
- Star / unstar with `f` or Space; favorites persist to `~/.pi/agent/favorites.json`
|
|
13
|
+
- Search across id / project / first user prompt with `/`
|
|
14
|
+
- Session details view (`d`): full prompt, file path, byte size, timestamps
|
|
15
|
+
- `Enter` resumes the selected session via `pi --session <id> --session-dir <dir>` in the original cwd
|
|
16
|
+
- `[NOW]` badge marks the session belonging to the pi instance that spawned pisesh (set via `PISESH_CURRENT_SESSION` env var)
|
|
17
|
+
- Alternate screen buffer (`\x1b[?1049h`) — exit restores terminal byte-for-byte; no scrollback pollution
|
|
18
|
+
- CJK-aware truncation and padding (Hangul / CJK ideographs / emoji counted as 2 cells)
|
|
19
|
+
- Signal handlers (`SIGINT`, `SIGTERM`, `exit`) restore cursor + main buffer on unexpected exit
|
|
20
|
+
- Non-TUI CLI: `--list`, `--json`, `--star <id>`, `--unstar <id>`, `--help`
|
|
21
|
+
- Pi extension at `extensions/sesh.ts` — registers `/sesh` slash command which spawns pisesh inside pi via `ui.custom` + `tui.stop()`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Blue-B
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# pisesh
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/pisesh)
|
|
4
|
+
[](https://github.com/Blue-B/pisesh/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://nodejs.org/)
|
|
7
|
+
[](package.json)
|
|
8
|
+
|
|
9
|
+
**Bookmark, search, and resume [pi coding-agent](https://github.com/earendil-works/pi-coding-agent) sessions with a fast keyboard-driven TUI.**
|
|
10
|
+
|
|
11
|
+
> `pi --resume` lists every session you ever started. After a week that's 50+ entries with no titles, no tags, no order — just scroll and pray. **pisesh** adds the one thing that was missing: ⭐ favorites, instant search, and a `[NOW]` badge for the session you're attached to.
|
|
12
|
+
|
|
13
|
+
## Preview
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<img src="assets/preview.png" alt="pisesh — Favorites tab in a real Windows Terminal session" width="100%">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center"><sub>Real capture: ★ starred session at the top, the rest available behind the <b>Today</b> and <b>All</b> tabs. <code>Tab</code> cycles. <code>f</code> stars. <code>Enter</code> resumes.</sub></p>
|
|
20
|
+
|
|
21
|
+
## Why pisesh
|
|
22
|
+
|
|
23
|
+
Pi accumulates sessions across many working directories — your home, several project dirs, scratch tmux panes. The built-in resume picker is alphabetical-ish and forgets context. After a few weeks:
|
|
24
|
+
|
|
25
|
+
- You can't tell which session was "the one where you fixed the auth bug"
|
|
26
|
+
- You can't pin the 3-4 long-running threads you keep going back to
|
|
27
|
+
- You re-open the wrong session and pollute it with unrelated context
|
|
28
|
+
- You waste time searching by timestamp guessing
|
|
29
|
+
|
|
30
|
+
pisesh is a **single-file Node script** (no dependencies, ~600 LoC) that gives you everything `pi --resume` doesn't.
|
|
31
|
+
|
|
32
|
+
### Value at a glance
|
|
33
|
+
|
|
34
|
+
| Need | What you get |
|
|
35
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
36
|
+
| Mark important sessions | ⭐ Star/unstar with one keystroke; favorites persist to one global JSON |
|
|
37
|
+
| Find a session by what you said | `/` searches id + project + first user prompt |
|
|
38
|
+
| Know which session you're attached to | `[NOW]` badge on the live session (passed from pi via env var) |
|
|
39
|
+
| Keep your terminal clean | Alt-screen buffer — exit restores your terminal byte-for-byte (like vim) |
|
|
40
|
+
| Read Korean / Chinese / Japanese prompts | Display-width-aware truncation; columns never blow up on CJK |
|
|
41
|
+
| Open from anywhere | Run as standalone `pisesh` shell command, or `/sesh` inside pi |
|
|
42
|
+
| Zero install pain | No build step, no native deps, runs on Node 18+ everywhere |
|
|
43
|
+
| Trust it with your history | pisesh only writes one favorites file; session jsonl files are read-only |
|
|
44
|
+
|
|
45
|
+
## Getting started
|
|
46
|
+
|
|
47
|
+
### Quick install (recommended)
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Install both the CLI and the /sesh slash command in one go
|
|
51
|
+
pi install npm:pisesh
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This registers pisesh as a pi extension. Inside any pi session, type `/sesh`.
|
|
55
|
+
|
|
56
|
+
### Standalone CLI only
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install -g pisesh
|
|
60
|
+
pisesh
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Use this if you want pisesh as a separate shell command and don't need the pi slash binding.
|
|
64
|
+
|
|
65
|
+
### From source (developers)
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/Blue-B/pisesh.git
|
|
69
|
+
cd pisesh
|
|
70
|
+
npm link # symlink ./bin/pisesh into your global PATH
|
|
71
|
+
pisesh --help
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Pi-extension side: drop `extensions/sesh.ts` into `~/.pi/agent/extensions/` and run `/reload` inside pi.
|
|
75
|
+
|
|
76
|
+
## Keys
|
|
77
|
+
|
|
78
|
+
| Key | Action |
|
|
79
|
+
| ---------------------------- | ------------------------------------------------------------ |
|
|
80
|
+
| `↑` `↓` / `j` `k` | move cursor |
|
|
81
|
+
| `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `All`) |
|
|
82
|
+
| `f` / `Space` | star / unstar the selected session |
|
|
83
|
+
| `Enter` | resume — spawns `pi --session <id> --session-dir <dir>` |
|
|
84
|
+
| `d` | session details (full prompt, file, byte size, timestamps) |
|
|
85
|
+
| `/` | search by id / project / first user prompt |
|
|
86
|
+
| `Esc` | clear search first, then quit |
|
|
87
|
+
| `q` / `Ctrl-C` | quit (terminal restored) |
|
|
88
|
+
| `r` | rescan session files (after pi starts a new session) |
|
|
89
|
+
| `c` (in details view) | copy session id to clipboard (clip.exe / pbcopy / xclip) |
|
|
90
|
+
| `Home` `End` `PgUp` `PgDn` | jump to top / bottom / ±10 |
|
|
91
|
+
|
|
92
|
+
## CLI (non-TUI) usage
|
|
93
|
+
|
|
94
|
+
For scripts and automation:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pisesh --list # print starred session IDs (one per line)
|
|
98
|
+
pisesh --json # full favorites file as JSON
|
|
99
|
+
pisesh --star <partial-uuid> # star a session from a script
|
|
100
|
+
pisesh --unstar <partial-uuid> # unstar
|
|
101
|
+
pisesh --help
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Tech Stack
|
|
105
|
+
|
|
106
|
+
[](https://nodejs.org/) [](https://developer.mozilla.org/docs/Web/JavaScript) [](https://www.typescriptlang.org/) [](https://github.com/earendil-works/pi-coding-agent)
|
|
107
|
+
|
|
108
|
+
| Area | Details |
|
|
109
|
+
| ------------------- | ------------------------------------------------------------------------------------------------ |
|
|
110
|
+
| Runtime | Node.js ≥ 18 (uses only built-in modules: `fs`, `path`, `os`, `child_process`, `readline`) |
|
|
111
|
+
| TUI rendering | Raw ANSI escape sequences (no `blessed` / `ink` / `chalk` dependency) |
|
|
112
|
+
| Alt screen buffer | `\x1b[?1049h` / `\x1b[?1049l` — same primitive as `vim`, `less`, `htop`, droid CLI |
|
|
113
|
+
| Input | Node's `readline.emitKeypressEvents` in raw mode |
|
|
114
|
+
| Width calculation | UAX #11 East Asian Width ranges, compressed to ~10 inline range checks |
|
|
115
|
+
| Pi extension | TypeScript factory using `@earendil-works/pi-coding-agent` extension API (`ui.custom`, `tui.stop`) |
|
|
116
|
+
| Storage | Single JSON file at `~/.pi/agent/favorites.json` (`{ ids: [...], updated: "iso" }`) |
|
|
117
|
+
| Session discovery | Direct filesystem scan of `~/.pi/agent/sessions/<projectSlug>/*.jsonl`; first 96 KB parsed |
|
|
118
|
+
| Process model | Slash command pauses pi's TUI, spawns pisesh with inherited stdio, restarts pi on exit |
|
|
119
|
+
|
|
120
|
+
### What it explicitly does **not** depend on
|
|
121
|
+
|
|
122
|
+
- No `npm install` for the bundled CLI runtime — true zero-dep
|
|
123
|
+
- No native binaries / GPU / ffmpeg / database
|
|
124
|
+
- No network calls, no telemetry, no analytics
|
|
125
|
+
- No daemon / background process
|
|
126
|
+
|
|
127
|
+
## How resume works
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
pi (session A) ── /sesh ──▶ ui.custom + tui.stop()
|
|
131
|
+
│
|
|
132
|
+
└─▶ spawn pisesh (PISESH_CURRENT_SESSION=A)
|
|
133
|
+
│ ↑↓ Tab f / Enter on session B
|
|
134
|
+
│
|
|
135
|
+
└─▶ spawn pi --session B --session-dir <dir>
|
|
136
|
+
│
|
|
137
|
+
│ user works in B …
|
|
138
|
+
│ user types q / ^D
|
|
139
|
+
│
|
|
140
|
+
◀─── inner pi exits, pisesh exits
|
|
141
|
+
│
|
|
142
|
+
◀─── tui.start() + requestRender(true)
|
|
143
|
+
pi (session A) continues exactly where it was
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The current pi session is paused, not lost. When you finish with the resumed session, you pop back to A with full state intact.
|
|
147
|
+
|
|
148
|
+
## Storage
|
|
149
|
+
|
|
150
|
+
| What | Where |
|
|
151
|
+
| ---------- | ----------------------------------------------------------- |
|
|
152
|
+
| Favorites | `~/.pi/agent/favorites.json` |
|
|
153
|
+
| Sessions | `~/.pi/agent/sessions/<projectSlug>/<timestamp>_<uuid>.jsonl` (pi's native layout — pisesh never writes here) |
|
|
154
|
+
|
|
155
|
+
Favorites file shape:
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"ids": [
|
|
160
|
+
"019e79b9-d2c1-741f-81ea-1dcad9a2d712",
|
|
161
|
+
"019e6355-9957-7a30-b4ce-b9db5e3c9ac6"
|
|
162
|
+
],
|
|
163
|
+
"updated": "2026-05-31T01:33:21.234Z"
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
It's a single global file (not per-project). Back it up by syncing one file.
|
|
168
|
+
|
|
169
|
+
## CJK-aware rendering
|
|
170
|
+
|
|
171
|
+
Korean / Chinese / Japanese / fullwidth characters render **2 cells wide** in terminals; pisesh measures display width (not JavaScript code-unit length) when truncating and padding. Korean prompts never wrap, columns stay aligned, and the layout looks identical whether the prompt is `hello world` or `안녕하세요 세상`.
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
✓ aitapps 지금 디렉토리에 앱인토스 제출용앱을 만들었는데…
|
|
175
|
+
✓ 공모전 신소재 공학 관련 졸업과제 도와줘…
|
|
176
|
+
✓ WhisperSubTrans 이슈 #26 이전 버전 아닌가 확인…
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
(Previously: Korean prompts overflowed to a second line and broke the table.)
|
|
180
|
+
|
|
181
|
+
## Requirements
|
|
182
|
+
|
|
183
|
+
- **Node.js ≥ 18** (uses optional chaining, `for…of` on strings — no transpile needed)
|
|
184
|
+
- A terminal with ANSI escape + alternate screen buffer support — basically every modern emulator:
|
|
185
|
+
- Windows: **Windows Terminal**, **WezTerm**, **Alacritty** ✅
|
|
186
|
+
- macOS: **iTerm2**, **Terminal.app**, **WezTerm**, **Alacritty**, **Kitty** ✅
|
|
187
|
+
- Linux: **GNOME Terminal**, **Konsole**, **xterm**, **Alacritty**, **Kitty** ✅
|
|
188
|
+
- [`pi`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) on `$PATH` for the `Enter`-to-resume action
|
|
189
|
+
|
|
190
|
+
## Roadmap
|
|
191
|
+
|
|
192
|
+
| Status | Item |
|
|
193
|
+
| ------ | ------------------------------------------------------------------------------- |
|
|
194
|
+
| ✅ | Tabs, star/unstar, search, alt-screen, CJK width, `[NOW]` badge, pi `/sesh` |
|
|
195
|
+
| 🚧 | `n` / `N` jump to next / previous search match (less-style) |
|
|
196
|
+
| 🚧 | Highlight matched substring in yellow |
|
|
197
|
+
| 🚧 | Filter by `today/yesterday/this-week` |
|
|
198
|
+
| 🧠 | Optional summarize first-N user prompts via local model for richer titles |
|
|
199
|
+
| 🧠 | Export starred sessions as a single bundle (share / archive) |
|
|
200
|
+
| 🧠 | Inline rename / label (`n` to add a custom title that overrides first prompt) |
|
|
201
|
+
|
|
202
|
+
PRs welcome for anything in the 🚧 lane.
|
|
203
|
+
|
|
204
|
+
## Contributing
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
git clone https://github.com/Blue-B/pisesh.git
|
|
208
|
+
cd pisesh
|
|
209
|
+
npm link
|
|
210
|
+
npm test # node --check + smoke test
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Branching: short-lived `feature/<scope>` or `fix/<scope>` → squash-merge into `main`.
|
|
214
|
+
Commits: [Conventional Commits](https://www.conventionalcommits.org/) style (`feat:`, `fix:`, `docs:`, `chore:`).
|
|
215
|
+
|
|
216
|
+
Open a PR — the CI matrix runs on Ubuntu / macOS / Windows × Node 18 / 20 / 22.
|
|
217
|
+
|
|
218
|
+
## Support
|
|
219
|
+
|
|
220
|
+
If pisesh saves you context-switching time or just makes pi nicer to live in, supporting it directly accelerates development:
|
|
221
|
+
|
|
222
|
+
- Your support helps: bug fixes, new keybindings, more search modes, integration with other pi extensions.
|
|
223
|
+
- Transparency: I don't sell data; funds go to development time and a coffee or two.
|
|
224
|
+
- One-time sponsors are credited in README and release notes (opt-out available).
|
|
225
|
+
- Monthly sponsors ($3/mo via GitHub Sponsors) get best-effort priority triage for "Sponsor Request" issues.
|
|
226
|
+
|
|
227
|
+
[](https://github.com/sponsors/Blue-B) [](https://buymeacoffee.com/beckycode7h) [](https://www.paypal.com/ncp/payment/ZEWFKDX595ESJ)
|
|
228
|
+
|
|
229
|
+
## Acknowledgments
|
|
230
|
+
|
|
231
|
+
- [pi-coding-agent](https://github.com/earendil-works/pi-coding-agent) by [@mariozechner](https://github.com/mariozechner) — the agent and its extension API that make `/sesh` possible.
|
|
232
|
+
- [interactive-shell example extension](https://github.com/earendil-works/pi-coding-agent/blob/main/examples/extensions/interactive-shell.ts) — pattern reference for `ui.custom` + `tui.stop` TTY handoff.
|
|
233
|
+
- Inspiration for the favorites + tabs UX: [droid CLI](https://github.com/factory-ai/droid) and tmux's [sesh](https://github.com/joshmedeski/sesh).
|
|
234
|
+
|
|
235
|
+
## Contributors
|
|
236
|
+
|
|
237
|
+
Thanks to everyone who helps make pisesh better! 🙏
|
|
238
|
+
|
|
239
|
+
<a href="https://github.com/Blue-B"><img src="https://github.com/Blue-B.png?size=80" width="80" alt="Blue-B" title="Blue-B" /></a>
|
|
240
|
+
|
|
241
|
+
## Repository activity
|
|
242
|
+
|
|
243
|
+

|
|
244
|
+
|
|
245
|
+
## Star History
|
|
246
|
+
|
|
247
|
+
<a href="https://star-history.com/#Blue-B/pisesh&Date">
|
|
248
|
+
<img src="https://api.star-history.com/svg?repos=Blue-B/pisesh&type=Date" alt="Star History Chart" width="600" />
|
|
249
|
+
</a>
|
|
250
|
+
|
|
251
|
+
## License
|
|
252
|
+
|
|
253
|
+
MIT © [Blue-B](https://github.com/Blue-B). See [LICENSE](LICENSE).
|
|
254
|
+
|
|
255
|
+
The pi extension uses the `@earendil-works/pi-coding-agent` API; check pi's own license for that side. The CLI binary is pure Node and has no other licenses to worry about.
|
package/bin/pisesh
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pisesh — pi session bookmark & resume TUI
|
|
3
|
+
// Tabs: ★ Favorites / Today / All
|
|
4
|
+
// Storage: ~/.pi/agent/favorites.json
|
|
5
|
+
// Sessions: ~/.pi/agent/sessions/<projectSlug>/<ts>_<uuid>.jsonl
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const { spawn } = require('child_process');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
|
|
15
|
+
// ── Paths ─────────────────────────────────────────────
|
|
16
|
+
const HOME = os.homedir();
|
|
17
|
+
const SESSIONS_ROOT = path.join(HOME, '.pi/agent/sessions');
|
|
18
|
+
const FAV_FILE = path.join(HOME, '.pi/agent/favorites.json');
|
|
19
|
+
|
|
20
|
+
// Current session id (set by the /sesh extension before spawning). Lets us
|
|
21
|
+
// flag the currently-attached session in the list with a [NOW] badge.
|
|
22
|
+
const CURRENT_SESSION_ID = (process.env.PISESH_CURRENT_SESSION || '').trim();
|
|
23
|
+
|
|
24
|
+
// ── ANSI ──────────────────────────────────────────────
|
|
25
|
+
const A = {
|
|
26
|
+
clr: '\x1b[2J\x1b[H',
|
|
27
|
+
// Alternate screen buffer: draws into a separate buffer so tab-switching
|
|
28
|
+
// never pushes prior renders into terminal scrollback. On exit the user's
|
|
29
|
+
// pre-pisesh terminal content is restored byte-for-byte. Same trick as
|
|
30
|
+
// less/vim/htop/droid-cli.
|
|
31
|
+
enterAlt: '\x1b[?1049h',
|
|
32
|
+
exitAlt: '\x1b[?1049l',
|
|
33
|
+
hideC: '\x1b[?25l',
|
|
34
|
+
showC: '\x1b[?25h',
|
|
35
|
+
R: '\x1b[0m',
|
|
36
|
+
B: '\x1b[1m',
|
|
37
|
+
D: '\x1b[2m',
|
|
38
|
+
I: '\x1b[7m',
|
|
39
|
+
yel: '\x1b[33m',
|
|
40
|
+
cyn: '\x1b[36m',
|
|
41
|
+
grn: '\x1b[32m',
|
|
42
|
+
mag: '\x1b[35m',
|
|
43
|
+
red: '\x1b[31m',
|
|
44
|
+
gry: '\x1b[90m',
|
|
45
|
+
bgBlu: '\x1b[44m',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ── Persistence ───────────────────────────────────────
|
|
49
|
+
function loadFavorites() {
|
|
50
|
+
try {
|
|
51
|
+
const raw = fs.readFileSync(FAV_FILE, 'utf8');
|
|
52
|
+
const data = JSON.parse(raw);
|
|
53
|
+
return new Set(data.ids || []);
|
|
54
|
+
} catch { return new Set(); }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveFavorites(set) {
|
|
58
|
+
const data = { ids: [...set], updated: new Date().toISOString() };
|
|
59
|
+
try {
|
|
60
|
+
fs.mkdirSync(path.dirname(FAV_FILE), { recursive: true });
|
|
61
|
+
fs.writeFileSync(FAV_FILE, JSON.stringify(data, null, 2));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.error('save favorites failed:', e.message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let favorites = loadFavorites();
|
|
68
|
+
|
|
69
|
+
// ── Project slug decode ───────────────────────────────
|
|
70
|
+
// pi encodes cwd by replacing '/' with '-' and wrapping in '--…--'
|
|
71
|
+
// "--mnt-c-Users-root--" → "/mnt/c/Users/root"
|
|
72
|
+
// "--mnt-c-Users-root-Downloads-instareal--" → "/mnt/c/Users/root/Downloads/instareal"
|
|
73
|
+
// "--home-shell--" → "/home/shell"
|
|
74
|
+
function decodeProjectSlug(slug) {
|
|
75
|
+
let s = slug;
|
|
76
|
+
if (s.startsWith('--')) s = '/' + s.slice(2);
|
|
77
|
+
if (s.endsWith('--')) s = s.slice(0, -2);
|
|
78
|
+
return s.replace(/-/g, '/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function shortCwd(cwd, max = 18) {
|
|
82
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
83
|
+
if (parts.length === 0) return cwd;
|
|
84
|
+
const last = parts[parts.length - 1];
|
|
85
|
+
// Use display-cell truncation so CJK project names don't blow up the column.
|
|
86
|
+
return trunc(last, max);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Scan sessions ─────────────────────────────────────
|
|
90
|
+
function readSessionMeta(file) {
|
|
91
|
+
// Read up to 96 KB to find session line + first user prompt
|
|
92
|
+
let buf;
|
|
93
|
+
try {
|
|
94
|
+
const fd = fs.openSync(file, 'r');
|
|
95
|
+
const tmp = Buffer.alloc(96 * 1024);
|
|
96
|
+
const n = fs.readSync(fd, tmp, 0, tmp.length, 0);
|
|
97
|
+
fs.closeSync(fd);
|
|
98
|
+
buf = tmp.slice(0, n).toString('utf8');
|
|
99
|
+
} catch { return null; }
|
|
100
|
+
|
|
101
|
+
const lines = buf.split('\n');
|
|
102
|
+
let session = null;
|
|
103
|
+
let firstPrompt = '';
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
if (!line.trim()) continue;
|
|
106
|
+
let obj;
|
|
107
|
+
try { obj = JSON.parse(line); }
|
|
108
|
+
catch { continue; } // truncated last line is fine
|
|
109
|
+
|
|
110
|
+
if (!session && obj.type === 'session') session = obj;
|
|
111
|
+
|
|
112
|
+
if (!firstPrompt && obj.type === 'message' && obj.message && obj.message.role === 'user') {
|
|
113
|
+
const content = obj.message.content;
|
|
114
|
+
let text = '';
|
|
115
|
+
if (Array.isArray(content)) {
|
|
116
|
+
for (const c of content) {
|
|
117
|
+
if (c && c.type === 'text' && c.text) { text = c.text; break; }
|
|
118
|
+
}
|
|
119
|
+
} else if (typeof content === 'string') text = content;
|
|
120
|
+
if (text.trim()) firstPrompt = text.trim();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (session && firstPrompt) break;
|
|
124
|
+
}
|
|
125
|
+
if (!session) return null;
|
|
126
|
+
session.firstPrompt = firstPrompt;
|
|
127
|
+
return session;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function scanSessions() {
|
|
131
|
+
let projectDirs;
|
|
132
|
+
try {
|
|
133
|
+
projectDirs = fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true })
|
|
134
|
+
.filter(d => d.isDirectory());
|
|
135
|
+
} catch { return []; }
|
|
136
|
+
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const d of projectDirs) {
|
|
139
|
+
const projectPath = path.join(SESSIONS_ROOT, d.name);
|
|
140
|
+
const projectLabel = decodeProjectSlug(d.name);
|
|
141
|
+
let files;
|
|
142
|
+
try { files = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl')); }
|
|
143
|
+
catch { continue; }
|
|
144
|
+
|
|
145
|
+
for (const f of files) {
|
|
146
|
+
const full = path.join(projectPath, f);
|
|
147
|
+
let st;
|
|
148
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
149
|
+
const meta = readSessionMeta(full);
|
|
150
|
+
if (!meta || !meta.id) continue;
|
|
151
|
+
results.push({
|
|
152
|
+
id: meta.id,
|
|
153
|
+
ts: meta.timestamp,
|
|
154
|
+
mtime: st.mtime,
|
|
155
|
+
cwd: meta.cwd || projectLabel,
|
|
156
|
+
project: projectLabel,
|
|
157
|
+
projectSlug: d.name,
|
|
158
|
+
file: full,
|
|
159
|
+
prompt: meta.firstPrompt || '',
|
|
160
|
+
size: st.size,
|
|
161
|
+
get favored() { return favorites.has(this.id); },
|
|
162
|
+
get isCurrent() { return CURRENT_SESSION_ID !== '' && this.id === CURRENT_SESSION_ID; },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
results.sort((a, b) => b.mtime - a.mtime);
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── State ─────────────────────────────────────────────
|
|
171
|
+
let sessions = [];
|
|
172
|
+
let tabs = ['★ Favorites', 'Today', 'All'];
|
|
173
|
+
let tabIdx = 0;
|
|
174
|
+
let cursor = 0;
|
|
175
|
+
let filter = '';
|
|
176
|
+
let mode = 'list'; // list | filter | details
|
|
177
|
+
|
|
178
|
+
// ── Filtering by tab ──────────────────────────────────
|
|
179
|
+
function visibleSessions() {
|
|
180
|
+
let list = sessions;
|
|
181
|
+
const tab = tabs[tabIdx];
|
|
182
|
+
|
|
183
|
+
if (tab.startsWith('★')) {
|
|
184
|
+
list = list.filter(s => s.favored);
|
|
185
|
+
} else if (tab === 'Today') {
|
|
186
|
+
const today = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD
|
|
187
|
+
list = list.filter(s => new Date(s.mtime).toLocaleDateString('sv-SE') === today);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (filter) {
|
|
191
|
+
const q = filter.toLowerCase();
|
|
192
|
+
list = list.filter(s =>
|
|
193
|
+
s.id.toLowerCase().includes(q) ||
|
|
194
|
+
s.project.toLowerCase().includes(q) ||
|
|
195
|
+
s.prompt.toLowerCase().includes(q)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
return list;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Format helpers ────────────────────────────────────
|
|
202
|
+
function fmtTs(d) {
|
|
203
|
+
const x = new Date(d);
|
|
204
|
+
const m = String(x.getMonth() + 1).padStart(2, '0');
|
|
205
|
+
const day = String(x.getDate()).padStart(2, '0');
|
|
206
|
+
const hh = String(x.getHours()).padStart(2, '0');
|
|
207
|
+
const mm = String(x.getMinutes()).padStart(2, '0');
|
|
208
|
+
return `${m}/${day} ${hh}:${mm}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function fmtSize(n) {
|
|
212
|
+
if (n < 1024) return n + 'B';
|
|
213
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(0) + 'K';
|
|
214
|
+
return (n / 1024 / 1024).toFixed(1) + 'M';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Returns the visible cell width of a character. CJK ideographs,
|
|
218
|
+
// Hangul syllables, fullwidth forms, and most emoji render 2 cells wide
|
|
219
|
+
// in modern terminals; everything else is 1. Without this every row that
|
|
220
|
+
// contains Korean/Chinese/Japanese text would silently wrap, breaking the
|
|
221
|
+
// fixed-column TUI layout. Based on the East Asian Width property
|
|
222
|
+
// (UAX #11) compressed to the ranges that actually appear in practice.
|
|
223
|
+
function charWidth(cp) {
|
|
224
|
+
if (cp < 0x1100) return 1;
|
|
225
|
+
if (
|
|
226
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
227
|
+
cp === 0x2329 || cp === 0x232a ||
|
|
228
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals/Kangxi
|
|
229
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana/Katakana/CJK Symbols
|
|
230
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
|
|
231
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified
|
|
232
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
233
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
234
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat
|
|
235
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
|
|
236
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth ASCII
|
|
237
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
|
238
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) || // Emoji symbols
|
|
239
|
+
(cp >= 0x1f680 && cp <= 0x1f6ff) || // Transport/map
|
|
240
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff) || // Supplemental symbols
|
|
241
|
+
(cp >= 0x20000 && cp <= 0x2fffd) // CJK Ext B-F
|
|
242
|
+
) return 2;
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function displayWidth(s) {
|
|
247
|
+
if (!s) return 0;
|
|
248
|
+
let w = 0;
|
|
249
|
+
for (const ch of s) w += charWidth(ch.codePointAt(0));
|
|
250
|
+
return w;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Pad `s` to exactly `n` display cells. If wider, truncate; if narrower,
|
|
254
|
+
// right-pad with spaces. Operates on cells, not code units.
|
|
255
|
+
function pad(s, n) {
|
|
256
|
+
if (!s) s = '';
|
|
257
|
+
const w = displayWidth(s);
|
|
258
|
+
if (w === n) return s;
|
|
259
|
+
if (w < n) return s + ' '.repeat(n - w);
|
|
260
|
+
// Wider than n — truncate character-by-character.
|
|
261
|
+
let out = '', cur = 0;
|
|
262
|
+
for (const ch of s) {
|
|
263
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
264
|
+
if (cur + cw > n) break;
|
|
265
|
+
out += ch; cur += cw;
|
|
266
|
+
}
|
|
267
|
+
if (cur < n) out += ' '.repeat(n - cur);
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Truncate `s` so that displayWidth(out) ≤ maxWidth, appending '…' when
|
|
272
|
+
// truncated. Whitespace runs (including newlines) collapse to one space.
|
|
273
|
+
function trunc(s, maxWidth) {
|
|
274
|
+
if (!s) return '';
|
|
275
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
276
|
+
if (displayWidth(s) <= maxWidth) return s;
|
|
277
|
+
let out = '', cur = 0;
|
|
278
|
+
for (const ch of s) {
|
|
279
|
+
const cw = charWidth(ch.codePointAt(0));
|
|
280
|
+
if (cur + cw > maxWidth - 1) break; // reserve 1 cell for the …
|
|
281
|
+
out += ch; cur += cw;
|
|
282
|
+
}
|
|
283
|
+
return out + '…';
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function visLen(s) {
|
|
287
|
+
// strip ANSI escapes, then measure in display cells
|
|
288
|
+
return displayWidth(s.replace(/\x1b\[[0-9;]*m/g, ''));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Render ────────────────────────────────────────────
|
|
292
|
+
function render() {
|
|
293
|
+
if (mode === 'details') return renderDetails();
|
|
294
|
+
renderList();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function renderList() {
|
|
298
|
+
const W = process.stdout.columns || 100;
|
|
299
|
+
const H = process.stdout.rows || 30;
|
|
300
|
+
let out = A.clr;
|
|
301
|
+
|
|
302
|
+
// Header
|
|
303
|
+
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' pi session bookmarks ' + A.R;
|
|
304
|
+
out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R + '\n\n';
|
|
305
|
+
|
|
306
|
+
// Tabs
|
|
307
|
+
let tabLine = ' ';
|
|
308
|
+
tabs.forEach((t, i) => {
|
|
309
|
+
if (i === tabIdx) tabLine += A.I + A.B + ' ' + t + ' ' + A.R + ' ';
|
|
310
|
+
else tabLine += A.D + ' ' + t + ' ' + A.R + ' ';
|
|
311
|
+
});
|
|
312
|
+
out += tabLine + '\n';
|
|
313
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
314
|
+
|
|
315
|
+
// Filter line
|
|
316
|
+
if (mode === 'filter' || filter) {
|
|
317
|
+
const caret = mode === 'filter' ? A.I + ' ' + A.R : '';
|
|
318
|
+
out += ' ' + A.cyn + '/' + A.R + ' ' + filter + caret + '\n';
|
|
319
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// List
|
|
323
|
+
const list = visibleSessions();
|
|
324
|
+
if (cursor >= list.length) cursor = Math.max(0, list.length - 1);
|
|
325
|
+
|
|
326
|
+
const headerH = (mode === 'filter' || filter) ? 7 : 5;
|
|
327
|
+
const footerH = 4;
|
|
328
|
+
const usable = Math.max(3, H - headerH - footerH);
|
|
329
|
+
const start = Math.max(0, Math.min(cursor - Math.floor(usable / 2), list.length - usable));
|
|
330
|
+
const end = Math.min(list.length, start + usable);
|
|
331
|
+
|
|
332
|
+
if (list.length === 0) {
|
|
333
|
+
let hint;
|
|
334
|
+
if (filter) {
|
|
335
|
+
hint = '(no matches — Esc clears filter)';
|
|
336
|
+
} else if (tabs[tabIdx].startsWith('★')) {
|
|
337
|
+
hint = '(no favorites yet — press f on any session to star it)';
|
|
338
|
+
} else if (tabs[tabIdx] === 'Today') {
|
|
339
|
+
hint = '(no sessions today — Tab to see All)';
|
|
340
|
+
} else {
|
|
341
|
+
hint = '(no sessions in this view)';
|
|
342
|
+
}
|
|
343
|
+
out += '\n ' + A.D + hint + A.R + '\n';
|
|
344
|
+
} else {
|
|
345
|
+
for (let i = start; i < end; i++) {
|
|
346
|
+
const s = list[i];
|
|
347
|
+
const sel = i === cursor;
|
|
348
|
+
const star = s.favored ? A.yel + '★' + A.R : ' ';
|
|
349
|
+
const arrow = sel ? A.cyn + '▶' + A.R : ' ';
|
|
350
|
+
const ts = A.gry + fmtTs(s.mtime) + A.R;
|
|
351
|
+
const cwd = A.mag + pad(shortCwd(s.cwd), 14) + A.R;
|
|
352
|
+
const badge = s.isCurrent ? A.grn + A.B + '[NOW]' + A.R + ' ' : '';
|
|
353
|
+
const promptMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0));
|
|
354
|
+
const prompt = trunc(s.prompt || `(no prompt) ${s.id.slice(0, 8)}`, promptMax);
|
|
355
|
+
const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${prompt}`;
|
|
356
|
+
if (sel) {
|
|
357
|
+
const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
|
|
358
|
+
out += A.bgBlu + rowText + pad2 + A.R + '\n';
|
|
359
|
+
} else {
|
|
360
|
+
out += rowText + '\n';
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Pad bottom to push footer down
|
|
366
|
+
const linesUsed = (end - start) || 1;
|
|
367
|
+
for (let i = 0; i < usable - linesUsed; i++) out += '\n';
|
|
368
|
+
|
|
369
|
+
// Footer
|
|
370
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
371
|
+
out += ' ' + A.D + '↑↓' + A.R + ' move '
|
|
372
|
+
+ A.D + 'Tab' + A.R + ' next tab '
|
|
373
|
+
+ A.D + 'f' + A.R + ' star '
|
|
374
|
+
+ A.D + 'Enter' + A.R + ' resume '
|
|
375
|
+
+ A.D + 'd' + A.R + ' details '
|
|
376
|
+
+ A.D + '/' + A.R + ' search '
|
|
377
|
+
+ A.D + 'r' + A.R + ' refresh '
|
|
378
|
+
+ A.D + 'q/Esc' + A.R + ' quit' + '\n';
|
|
379
|
+
|
|
380
|
+
process.stdout.write(out);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderDetails() {
|
|
384
|
+
const W = process.stdout.columns || 100;
|
|
385
|
+
const list = visibleSessions();
|
|
386
|
+
const s = list[cursor];
|
|
387
|
+
let out = A.clr;
|
|
388
|
+
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' session details' + A.R + '\n\n';
|
|
389
|
+
if (!s) { out += ' (no session)\n'; process.stdout.write(out); return; }
|
|
390
|
+
|
|
391
|
+
const row = (k, v, color = A.R) => ` ${A.D}${pad(k, 11)}${A.R} ${color}${v}${A.R}\n`;
|
|
392
|
+
out += row('id', s.id, A.cyn);
|
|
393
|
+
if (s.isCurrent) out += row('current', '● this is the attached pi session', A.grn);
|
|
394
|
+
out += row('starred', s.favored ? '★ yes' : '☆ no', s.favored ? A.yel : A.gry);
|
|
395
|
+
out += row('started', new Date(s.ts).toLocaleString());
|
|
396
|
+
out += row('updated', new Date(s.mtime).toLocaleString());
|
|
397
|
+
out += row('cwd', s.cwd, A.mag);
|
|
398
|
+
out += row('project', s.project);
|
|
399
|
+
out += row('size', fmtSize(s.size));
|
|
400
|
+
out += row('file', s.file, A.gry);
|
|
401
|
+
out += '\n ' + A.D + 'first prompt:' + A.R + '\n';
|
|
402
|
+
const prompt = s.prompt || '(no user prompt found in first 96KB)';
|
|
403
|
+
const wrapped = wrap(prompt, W - 4);
|
|
404
|
+
for (const line of wrapped) out += ' ' + line + '\n';
|
|
405
|
+
|
|
406
|
+
out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
407
|
+
out += ' ' + A.D + 'Enter' + A.R + ' resume '
|
|
408
|
+
+ A.D + 'f' + A.R + ' star '
|
|
409
|
+
+ A.D + 'c' + A.R + ' copy id '
|
|
410
|
+
+ A.D + 'Esc/d/q' + A.R + ' back' + '\n';
|
|
411
|
+
|
|
412
|
+
process.stdout.write(out);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function wrap(text, width) {
|
|
416
|
+
if (!text) return [''];
|
|
417
|
+
const out = [];
|
|
418
|
+
let line = '';
|
|
419
|
+
for (const word of text.split(/\s+/)) {
|
|
420
|
+
if ((line + ' ' + word).trim().length > width) {
|
|
421
|
+
if (line) out.push(line);
|
|
422
|
+
line = word;
|
|
423
|
+
} else line = (line + ' ' + word).trim();
|
|
424
|
+
}
|
|
425
|
+
if (line) out.push(line);
|
|
426
|
+
return out.slice(0, 10); // cap at 10 lines
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Input ─────────────────────────────────────────────
|
|
430
|
+
function setupInput() {
|
|
431
|
+
readline.emitKeypressEvents(process.stdin);
|
|
432
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
433
|
+
process.stdin.resume();
|
|
434
|
+
// Enter alt screen FIRST, then hide cursor. The terminal saves the user's
|
|
435
|
+
// visible buffer + cursor position; we restore both on exit.
|
|
436
|
+
process.stdout.write(A.enterAlt + A.hideC);
|
|
437
|
+
|
|
438
|
+
process.stdin.on('keypress', (str, key) => {
|
|
439
|
+
try {
|
|
440
|
+
if (mode === 'filter') handleFilter(str, key);
|
|
441
|
+
else if (mode === 'details') handleDetails(str, key);
|
|
442
|
+
else handleList(str, key);
|
|
443
|
+
render();
|
|
444
|
+
} catch (e) {
|
|
445
|
+
process.stdout.write(A.showC);
|
|
446
|
+
console.error('input error:', e);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
process.stdout.on('resize', render);
|
|
451
|
+
// Safety net: even on unexpected exit (uncaught error, signal), leave alt
|
|
452
|
+
// screen and re-show the hardware cursor so the user's terminal isn't
|
|
453
|
+
// left in a broken state.
|
|
454
|
+
process.on('exit', () => process.stdout.write(A.exitAlt + A.showC));
|
|
455
|
+
process.on('SIGINT', () => quit());
|
|
456
|
+
process.on('SIGTERM', () => quit());
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function handleList(str, key) {
|
|
460
|
+
const list = visibleSessions();
|
|
461
|
+
const k = key.name;
|
|
462
|
+
|
|
463
|
+
if (key.ctrl && k === 'c') return quit();
|
|
464
|
+
if (k === 'q') return quit();
|
|
465
|
+
// Esc: clears filter first (one-step back-out), otherwise quits.
|
|
466
|
+
// Matches `q` so muscle memory works either way.
|
|
467
|
+
if (k === 'escape') {
|
|
468
|
+
if (filter) { filter = ''; cursor = 0; return; }
|
|
469
|
+
return quit();
|
|
470
|
+
}
|
|
471
|
+
if (k === 'up' || k === 'k') cursor = Math.max(0, cursor - 1);
|
|
472
|
+
else if (k === 'down' || k === 'j') cursor = Math.min(list.length - 1, cursor + 1);
|
|
473
|
+
else if (k === 'tab' || (k === 'right' && !filter) || k === 'l') {
|
|
474
|
+
tabIdx = (tabIdx + 1) % tabs.length;
|
|
475
|
+
cursor = 0;
|
|
476
|
+
}
|
|
477
|
+
else if ((k === 'left' && !filter) || k === 'h') {
|
|
478
|
+
tabIdx = (tabIdx - 1 + tabs.length) % tabs.length;
|
|
479
|
+
cursor = 0;
|
|
480
|
+
}
|
|
481
|
+
else if (k === 'f' || str === ' ') {
|
|
482
|
+
const s = list[cursor];
|
|
483
|
+
if (s) {
|
|
484
|
+
if (s.favored) favorites.delete(s.id);
|
|
485
|
+
else favorites.add(s.id);
|
|
486
|
+
saveFavorites(favorites);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
else if (k === 'return') resumeSession(list[cursor]);
|
|
490
|
+
else if (k === 'd') { if (list[cursor]) mode = 'details'; }
|
|
491
|
+
else if (str === '/') { mode = 'filter'; }
|
|
492
|
+
else if (k === 'r') { sessions = scanSessions(); cursor = 0; }
|
|
493
|
+
else if (k === 'home') cursor = 0;
|
|
494
|
+
else if (k === 'end') cursor = list.length - 1;
|
|
495
|
+
else if (k === 'pageup') cursor = Math.max(0, cursor - 10);
|
|
496
|
+
else if (k === 'pagedown') cursor = Math.min(list.length - 1, cursor + 10);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function handleFilter(str, key) {
|
|
500
|
+
const k = key.name;
|
|
501
|
+
if (k === 'return' || k === 'escape') { mode = 'list'; return; }
|
|
502
|
+
if (k === 'backspace') { filter = filter.slice(0, -1); cursor = 0; return; }
|
|
503
|
+
if (key.ctrl && k === 'c') return quit();
|
|
504
|
+
if (key.ctrl && k === 'u') { filter = ''; cursor = 0; return; }
|
|
505
|
+
if (str && str.length === 1 && !key.ctrl) { filter += str; cursor = 0; }
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function handleDetails(str, key) {
|
|
509
|
+
const k = key.name;
|
|
510
|
+
const s = visibleSessions()[cursor];
|
|
511
|
+
if (k === 'escape' || k === 'q' || k === 'd') { mode = 'list'; return; }
|
|
512
|
+
if (key.ctrl && k === 'c') return quit();
|
|
513
|
+
if (k === 'return') return resumeSession(s);
|
|
514
|
+
if (k === 'f' && s) {
|
|
515
|
+
if (s.favored) favorites.delete(s.id); else favorites.add(s.id);
|
|
516
|
+
saveFavorites(favorites);
|
|
517
|
+
}
|
|
518
|
+
if (k === 'c' && s) {
|
|
519
|
+
// Try clipboard via clip.exe (WSL) or pbcopy/xclip
|
|
520
|
+
tryCopy(s.id);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function tryCopy(text) {
|
|
525
|
+
const tryCmd = (cmd, args) => {
|
|
526
|
+
try {
|
|
527
|
+
const p = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
528
|
+
p.stdin.end(text);
|
|
529
|
+
return true;
|
|
530
|
+
} catch { return false; }
|
|
531
|
+
};
|
|
532
|
+
// WSL clip.exe first, then pbcopy, then xclip
|
|
533
|
+
tryCmd('clip.exe', []) || tryCmd('pbcopy', []) || tryCmd('xclip', ['-selection', 'clipboard']);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── Actions ───────────────────────────────────────────
|
|
537
|
+
function resumeSession(s) {
|
|
538
|
+
if (!s) return;
|
|
539
|
+
// Leave alt screen + show cursor so the spawned pi takes over a clean
|
|
540
|
+
// main-buffer terminal (it will manage its own alt screen).
|
|
541
|
+
process.stdout.write(A.exitAlt + A.showC);
|
|
542
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
543
|
+
process.stdin.pause();
|
|
544
|
+
|
|
545
|
+
const projectDir = path.dirname(s.file);
|
|
546
|
+
// Spawn pi in the original cwd with the session restored
|
|
547
|
+
const child = spawn('pi', ['--session', s.id, '--session-dir', projectDir], {
|
|
548
|
+
stdio: 'inherit',
|
|
549
|
+
cwd: fs.existsSync(s.cwd) ? s.cwd : process.cwd(),
|
|
550
|
+
});
|
|
551
|
+
child.on('exit', code => process.exit(code || 0));
|
|
552
|
+
child.on('error', err => {
|
|
553
|
+
console.error('Failed to spawn pi:', err.message);
|
|
554
|
+
process.exit(1);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function quit() {
|
|
559
|
+
// Restore user's pre-pisesh terminal (alt screen exits + cursor back).
|
|
560
|
+
// No explicit clear needed — the terminal restoration handles it and the
|
|
561
|
+
// jump back to the previous prompt feels instant.
|
|
562
|
+
process.stdout.write(A.exitAlt + A.showC);
|
|
563
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
564
|
+
process.exit(0);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// ── Main ──────────────────────────────────────────────
|
|
568
|
+
function printHelp() {
|
|
569
|
+
console.log(`pisesh — pi session bookmarks & resume TUI
|
|
570
|
+
|
|
571
|
+
Usage:
|
|
572
|
+
pisesh Open interactive TUI
|
|
573
|
+
pisesh --list, -l List starred session IDs (one per line)
|
|
574
|
+
pisesh --json Dump favorites file as JSON
|
|
575
|
+
pisesh --star <id> Add a session id (partial UUID ok) to favorites
|
|
576
|
+
pisesh --unstar <id> Remove a session id from favorites
|
|
577
|
+
pisesh --help, -h This help
|
|
578
|
+
|
|
579
|
+
TUI keys:
|
|
580
|
+
↑↓ / j k move cursor
|
|
581
|
+
Tab / h l switch tab (★ Favorites → Today → All)
|
|
582
|
+
f / Space toggle favorite
|
|
583
|
+
Enter resume session (spawns 'pi --session …')
|
|
584
|
+
d show details
|
|
585
|
+
/ search (id / project / prompt; Esc clears first, then quits)
|
|
586
|
+
r rescan session files
|
|
587
|
+
q / Esc / Ctrl-C quit
|
|
588
|
+
|
|
589
|
+
Files:
|
|
590
|
+
Favorites: ${FAV_FILE}
|
|
591
|
+
Sessions : ${SESSIONS_ROOT}/<project>/<ts>_<uuid>.jsonl`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function findIdMatch(prefix) {
|
|
595
|
+
sessions = scanSessions();
|
|
596
|
+
const matches = sessions.filter(s => s.id.startsWith(prefix) || s.id.includes(prefix));
|
|
597
|
+
return matches.map(s => s.id);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function main() {
|
|
601
|
+
const argv = process.argv.slice(2);
|
|
602
|
+
|
|
603
|
+
if (argv.includes('-h') || argv.includes('--help')) { printHelp(); return; }
|
|
604
|
+
if (argv.includes('-l') || argv.includes('--list')) {
|
|
605
|
+
[...favorites].forEach(id => console.log(id));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (argv.includes('--json')) {
|
|
609
|
+
console.log(JSON.stringify({ ids: [...favorites], updated: new Date().toISOString() }, null, 2));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const starIdx = argv.indexOf('--star');
|
|
614
|
+
if (starIdx >= 0 && argv[starIdx + 1]) {
|
|
615
|
+
const ids = findIdMatch(argv[starIdx + 1]);
|
|
616
|
+
if (ids.length === 0) { console.error('no matching session'); process.exit(2); }
|
|
617
|
+
if (ids.length > 1) { console.error('ambiguous:'); ids.forEach(i => console.error(' ', i)); process.exit(2); }
|
|
618
|
+
favorites.add(ids[0]); saveFavorites(favorites);
|
|
619
|
+
console.log('★ starred', ids[0]); return;
|
|
620
|
+
}
|
|
621
|
+
const unstarIdx = argv.indexOf('--unstar');
|
|
622
|
+
if (unstarIdx >= 0 && argv[unstarIdx + 1]) {
|
|
623
|
+
const ids = findIdMatch(argv[unstarIdx + 1]);
|
|
624
|
+
if (ids.length === 0) { console.error('no matching session'); process.exit(2); }
|
|
625
|
+
if (ids.length > 1) { console.error('ambiguous:'); ids.forEach(i => console.error(' ', i)); process.exit(2); }
|
|
626
|
+
favorites.delete(ids[0]); saveFavorites(favorites);
|
|
627
|
+
console.log('☆ unstarred', ids[0]); return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!process.stdin.isTTY) {
|
|
631
|
+
console.error('pisesh: stdin is not a TTY (use a terminal to run the TUI). Try --list or --help.');
|
|
632
|
+
process.exit(1);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
sessions = scanSessions();
|
|
636
|
+
if (sessions.length === 0) {
|
|
637
|
+
console.error('pisesh: no sessions found under', SESSIONS_ROOT);
|
|
638
|
+
process.exit(1);
|
|
639
|
+
}
|
|
640
|
+
setupInput();
|
|
641
|
+
render();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
main();
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pisesh slash command
|
|
3
|
+
*
|
|
4
|
+
* Registers `/sesh` inside pi.
|
|
5
|
+
*
|
|
6
|
+
* Behavior:
|
|
7
|
+
* 1. Pauses pi's TUI (releases the terminal)
|
|
8
|
+
* 2. Spawns the external `pisesh` TUI (bookmark/resume picker)
|
|
9
|
+
* 3. When pisesh exits — whether the user resumed a nested session and quit it,
|
|
10
|
+
* or just pressed `q` — control returns to the original pi session and the
|
|
11
|
+
* TUI is restored.
|
|
12
|
+
*
|
|
13
|
+
* Companion CLI tool: ~/.pi/bin/pisesh (single-file Node TUI, no deps)
|
|
14
|
+
* Favorites file: ~/.pi/agent/favorites.json
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process";
|
|
18
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
|
|
20
|
+
function runPisesh(currentSessionId: string | undefined): Promise<number | null> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
// stdio:"inherit" hands the real TTY to pisesh. pi's tui.stop() has
|
|
23
|
+
// already detached so this is safe.
|
|
24
|
+
// PISESH_CURRENT_SESSION lets pisesh flag the row that belongs to the
|
|
25
|
+
// pi instance that just spawned it (rendered with a [NOW] badge).
|
|
26
|
+
const child = spawn("pisesh", [], {
|
|
27
|
+
stdio: "inherit",
|
|
28
|
+
env: {
|
|
29
|
+
...process.env,
|
|
30
|
+
...(currentSessionId ? { PISESH_CURRENT_SESSION: currentSessionId } : {}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
child.on("exit", (code) => resolve(code));
|
|
34
|
+
child.on("error", (err) => {
|
|
35
|
+
// Surface a readable error in the terminal before we re-render.
|
|
36
|
+
process.stdout.write(`\x1b[31mpisesh failed to launch: ${err.message}\x1b[0m\n`);
|
|
37
|
+
resolve(127);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
pi.registerCommand("sesh", {
|
|
44
|
+
description: "Browse, star, and resume pi sessions (opens pisesh TUI)",
|
|
45
|
+
handler: async (_args, ctx) => {
|
|
46
|
+
if (!ctx.hasUI) {
|
|
47
|
+
ctx.ui?.notify?.("/sesh requires interactive UI", "warning");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let currentId: string | undefined;
|
|
52
|
+
try {
|
|
53
|
+
currentId = ctx.sessionManager?.getSessionId?.();
|
|
54
|
+
} catch {
|
|
55
|
+
currentId = undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const code = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => {
|
|
59
|
+
// Hand over the terminal
|
|
60
|
+
tui.stop();
|
|
61
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
62
|
+
|
|
63
|
+
runPisesh(currentId).then((exitCode) => {
|
|
64
|
+
// Restore pi's TUI
|
|
65
|
+
tui.start();
|
|
66
|
+
tui.requestRender(true);
|
|
67
|
+
done(exitCode);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Return a no-op component (custom() requires one synchronously)
|
|
71
|
+
return { render: () => [], invalidate: () => {} };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (code === 0 || code === null) {
|
|
75
|
+
ctx.ui.notify("Returned from pisesh", "info");
|
|
76
|
+
} else if (code === 127) {
|
|
77
|
+
ctx.ui.notify("pisesh not found on PATH", "error");
|
|
78
|
+
} else {
|
|
79
|
+
ctx.ui.notify(`pisesh exited with code ${code}`, "warning");
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pisesh",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bookmark, search, and resume pi coding-agent sessions with a fast keyboard-driven TUI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-coding-agent",
|
|
8
|
+
"session",
|
|
9
|
+
"bookmark",
|
|
10
|
+
"tui",
|
|
11
|
+
"cli",
|
|
12
|
+
"ai",
|
|
13
|
+
"coding-agent",
|
|
14
|
+
"resume",
|
|
15
|
+
"favorites"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/Blue-B/pisesh#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/Blue-B/pisesh/issues"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/Blue-B/pisesh.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Blue-B <source_vs@naver.com>",
|
|
27
|
+
"type": "commonjs",
|
|
28
|
+
"bin": {
|
|
29
|
+
"pisesh": "./bin/pisesh"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin/",
|
|
33
|
+
"extensions/",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"CHANGELOG.md"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"pi": {
|
|
42
|
+
"extensions": [
|
|
43
|
+
"./extensions/sesh.ts"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "node --check bin/pisesh && echo 'pisesh: syntax OK'",
|
|
48
|
+
"prepublishOnly": "npm test"
|
|
49
|
+
}
|
|
50
|
+
}
|