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 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
+ [![npm](https://img.shields.io/npm/v/pisesh?style=for-the-badge&logo=npm&color=CB3837&logoColor=white)](https://www.npmjs.com/package/pisesh)
4
+ [![ci](https://img.shields.io/github/actions/workflow/status/Blue-B/pisesh/ci.yml?branch=main&style=for-the-badge&logo=github-actions&logoColor=white&label=CI)](https://github.com/Blue-B/pisesh/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/github/license/Blue-B/pisesh?style=for-the-badge&color=blue)](LICENSE)
6
+ [![node](https://img.shields.io/badge/node-%E2%89%A518-339933?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/)
7
+ [![deps](https://img.shields.io/badge/dependencies-0-brightgreen?style=for-the-badge)](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
+ [![Node.js](https://img.shields.io/badge/Node.js-339933?style=for-the-badge&logo=node.js&logoColor=white)](https://nodejs.org/) [![JavaScript](https://img.shields.io/badge/JavaScript-F7DF1E?style=for-the-badge&logo=javascript&logoColor=000)](https://developer.mozilla.org/docs/Web/JavaScript) [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![pi](https://img.shields.io/badge/pi--coding--agent-5C4EE5?style=for-the-badge)](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
+ [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub-EA4AAA?style=for-the-badge&logo=github-sponsors&logoColor=white)](https://github.com/sponsors/Blue-B) [![Buy Me A Coffee](https://img.shields.io/badge/One%E2%80%91time_$3-Buy_Me_A_Coffee-FFDD00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=000)](https://buymeacoffee.com/beckycode7h) [![PayPal](https://img.shields.io/badge/Donate-PayPal-00457C?style=for-the-badge&logo=paypal&logoColor=white)](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
+ ![Repobeats analytics image](https://repobeats.axiom.co/api/embed/blue-b-pisesh.svg "Repobeats analytics image")
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
+ }