pisesh 0.1.3 → 0.1.5
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 +9 -0
- package/README.md +34 -10
- package/bin/pisesh +303 -19
- package/extensions/sesh.ts +66 -55
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.1.4] — 2026-06-03
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **`Here` tab** — filters the list to sessions whose effective cwd matches the directory pisesh was launched from. Tab order is now **★ Favorites → Today → Here → All**.
|
|
9
|
+
- **Inline rename (`e`)** — set a custom display title that overrides the first-prompt label; renamed sessions are marked with a cyan `✎` in the list.
|
|
10
|
+
- **Edit cwd (`p`)** — arrow-key directory browser to re-point the working directory pi resumes into (also drives the `Here` filter).
|
|
11
|
+
- Per-session overrides (custom title / cwd) persist to `~/.pi/agent/pisesh-meta.json`, keyed by session id. Session jsonl files remain read-only.
|
|
12
|
+
- README terminal screenshots for the list view, rename panel, and cwd browser.
|
|
13
|
+
|
|
5
14
|
## [0.1.0] — 2026-05-31
|
|
6
15
|
|
|
7
16
|
Initial release.
|
package/README.md
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# pisesh
|
|
2
2
|
|
|
3
|
-
**English** | [한국어](https://github.com/Blue-B/pisesh/blob/main/docs/README.ko.md)
|
|
4
|
-
|
|
5
3
|
[](https://www.npmjs.com/package/pisesh)
|
|
6
4
|
[](https://github.com/Blue-B/pisesh/actions/workflows/ci.yml)
|
|
7
5
|
[](LICENSE)
|
|
@@ -15,10 +13,28 @@
|
|
|
15
13
|
## Preview
|
|
16
14
|
|
|
17
15
|
<p align="center">
|
|
18
|
-
<img src="assets/preview.png" alt="pisesh — Favorites tab in a real Windows Terminal session" width="100%">
|
|
16
|
+
<img src="https://raw.githubusercontent.com/Blue-B/pisesh/main/assets/preview.png" alt="pisesh — Favorites tab in a real Windows Terminal session" width="100%">
|
|
19
17
|
</p>
|
|
20
18
|
|
|
21
|
-
<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>
|
|
19
|
+
<p align="center"><sub>Real capture: ★ starred session at the top, the rest available behind the <b>Today</b>, <b>Here</b>, and <b>All</b> tabs. <code>Tab</code> cycles. <code>f</code> stars. <code>Enter</code> resumes.</sub></p>
|
|
20
|
+
|
|
21
|
+
## Terminal walkthrough
|
|
22
|
+
|
|
23
|
+
What the TUI actually looks like, screen by screen. (Demo data — not real sessions.)
|
|
24
|
+
|
|
25
|
+
**Main list** — the highlighted row is selected, tabs cycle with `Tab`. The green `[NOW]` badge marks the pi session you launched from; the cyan `✎` marks a session you renamed yourself. CJK titles stay column-aligned:
|
|
26
|
+
|
|
27
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Blue-B/pisesh/main/assets/screen-list.png" alt="pisesh main list — Favorites tab with Today / Here / All tabs, [NOW] badge, and a renamed session" width="100%"></p>
|
|
28
|
+
|
|
29
|
+
**`e` — rename a session.** First user prompt is a lousy title for a long-lived thread. Press `e` to set your own; it's stored as an override (the session jsonl is never touched) and the session gets a `✎` marker in the list:
|
|
30
|
+
|
|
31
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Blue-B/pisesh/main/assets/screen-rename.png" alt="pisesh edit-name panel — setting a custom display title" width="100%"></p>
|
|
32
|
+
|
|
33
|
+
**`p` — re-point the working directory** with an arrow-key directory browser. This is the cwd pi will actually `cd` into on resume, and it's what the `Here` tab filters on. `s` locks in the highlighted directory:
|
|
34
|
+
|
|
35
|
+
<p align="center"><img src="https://raw.githubusercontent.com/Blue-B/pisesh/main/assets/screen-cwd.png" alt="pisesh cwd browser — arrow-key directory picker for the resume / Here directory" width="100%"></p>
|
|
36
|
+
|
|
37
|
+
The **`Here` tab** shows only sessions whose effective cwd matches the directory you launched pisesh from — so inside a project you see just that project's threads, no scrolling past your home-dir scratch sessions.
|
|
22
38
|
|
|
23
39
|
## Why pisesh
|
|
24
40
|
|
|
@@ -36,13 +52,16 @@ pisesh is a **single-file Node script** (no dependencies, ~600 LoC) that gives y
|
|
|
36
52
|
| Need | What you get |
|
|
37
53
|
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
38
54
|
| Mark important sessions | ⭐ Star/unstar with one keystroke; favorites persist to one global JSON |
|
|
55
|
+
| Give a thread a real name | `e` sets a custom title (marked `✎`); overrides the first-prompt label |
|
|
56
|
+
| See only the current project's sessions | `Here` tab filters to sessions whose cwd matches where you launched pisesh |
|
|
57
|
+
| Fix where a session resumes | `p` opens an arrow-key directory browser; sets the cwd pi `cd`s into |
|
|
39
58
|
| Find a session by what you said | `/` searches id + project + first user prompt |
|
|
40
59
|
| Know which session you're attached to | `[NOW]` badge on the live session (passed from pi via env var) |
|
|
41
60
|
| Keep your terminal clean | Alt-screen buffer — exit restores your terminal byte-for-byte (like vim) |
|
|
42
61
|
| Read Korean / Chinese / Japanese prompts | Display-width-aware truncation; columns never blow up on CJK |
|
|
43
62
|
| Open from anywhere | Run as standalone `pisesh` shell command, or `/sesh` inside pi |
|
|
44
63
|
| Zero install pain | No build step, no native deps, runs on Node 18+ everywhere |
|
|
45
|
-
| Trust it with your history | pisesh only
|
|
64
|
+
| Trust it with your history | pisesh writes only two small JSON files (favorites + overrides); session jsonl files are read-only |
|
|
46
65
|
|
|
47
66
|
## Getting started
|
|
48
67
|
|
|
@@ -80,9 +99,11 @@ Pi-extension side: drop `extensions/sesh.ts` into `~/.pi/agent/extensions/` and
|
|
|
80
99
|
| Key | Action |
|
|
81
100
|
| ---------------------------- | ------------------------------------------------------------ |
|
|
82
101
|
| `↑` `↓` / `j` `k` | move cursor |
|
|
83
|
-
| `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `All`)
|
|
102
|
+
| `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `Here` → `All`) |
|
|
84
103
|
| `f` / `Space` | star / unstar the selected session |
|
|
85
104
|
| `Enter` | resume — spawns `pi --session <id> --session-dir <dir>` |
|
|
105
|
+
| `e` | edit name — set a custom display title (marked `✎` in list) |
|
|
106
|
+
| `p` | edit cwd — arrow-key directory browser; sets resume / `Here` dir |
|
|
86
107
|
| `d` | session details (full prompt, file, byte size, timestamps) |
|
|
87
108
|
| `/` | search by id / project / first user prompt |
|
|
88
109
|
| `Esc` | clear search first, then quit |
|
|
@@ -152,6 +173,7 @@ The current pi session is paused, not lost. When you finish with the resumed ses
|
|
|
152
173
|
| What | Where |
|
|
153
174
|
| ---------- | ----------------------------------------------------------- |
|
|
154
175
|
| Favorites | `~/.pi/agent/favorites.json` |
|
|
176
|
+
| Overrides | `~/.pi/agent/pisesh-meta.json` (per-session custom title / cwd, keyed by session id) |
|
|
155
177
|
| Sessions | `~/.pi/agent/sessions/<projectSlug>/<timestamp>_<uuid>.jsonl` (pi's native layout — pisesh never writes here) |
|
|
156
178
|
|
|
157
179
|
Favorites file shape:
|
|
@@ -173,9 +195,9 @@ It's a single global file (not per-project). Back it up by syncing one file.
|
|
|
173
195
|
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 `안녕하세요 세상`.
|
|
174
196
|
|
|
175
197
|
```text
|
|
176
|
-
✓
|
|
177
|
-
✓
|
|
178
|
-
✓
|
|
198
|
+
✓ webapp 로그인 폼 만들고 인증 엔드포인트 연결…
|
|
199
|
+
✓ 가계부앱 이번 달 지출 분석 화면 설계…
|
|
200
|
+
✓ docs-site 시작하기 가이드 다시 작성…
|
|
179
201
|
```
|
|
180
202
|
|
|
181
203
|
(Previously: Korean prompts overflowed to a second line and broke the table.)
|
|
@@ -194,12 +216,14 @@ Korean / Chinese / Japanese / fullwidth characters render **2 cells wide** in te
|
|
|
194
216
|
| Status | Item |
|
|
195
217
|
| ------ | ------------------------------------------------------------------------------- |
|
|
196
218
|
| ✅ | Tabs, star/unstar, search, alt-screen, CJK width, `[NOW]` badge, pi `/sesh` |
|
|
219
|
+
| ✅ | `Here` tab — filter to sessions matching the launch directory |
|
|
220
|
+
| ✅ | Inline rename (`e`) — custom display title that overrides the first prompt |
|
|
221
|
+
| ✅ | Edit cwd (`p`) — arrow-key directory browser sets the resume / `Here` dir |
|
|
197
222
|
| 🚧 | `n` / `N` jump to next / previous search match (less-style) |
|
|
198
223
|
| 🚧 | Highlight matched substring in yellow |
|
|
199
224
|
| 🚧 | Filter by `today/yesterday/this-week` |
|
|
200
225
|
| 🧠 | Optional summarize first-N user prompts via local model for richer titles |
|
|
201
226
|
| 🧠 | Export starred sessions as a single bundle (share / archive) |
|
|
202
|
-
| 🧠 | Inline rename / label (`n` to add a custom title that overrides first prompt) |
|
|
203
227
|
|
|
204
228
|
PRs welcome for anything in the 🚧 lane.
|
|
205
229
|
|
package/bin/pisesh
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// pisesh — pi session bookmark & resume TUI
|
|
3
|
-
// Tabs: ★ Favorites / Today / All
|
|
4
|
-
// Storage: ~/.pi/agent/favorites.json
|
|
3
|
+
// Tabs: ★ Favorites / Today / Here / All
|
|
4
|
+
// Storage: ~/.pi/agent/favorites.json (starred session ids)
|
|
5
|
+
// ~/.pi/agent/pisesh-meta.json (per-session title / cwd overrides)
|
|
5
6
|
// Sessions: ~/.pi/agent/sessions/<projectSlug>/<ts>_<uuid>.jsonl
|
|
6
7
|
|
|
7
8
|
'use strict';
|
|
@@ -16,11 +17,17 @@ const readline = require('readline');
|
|
|
16
17
|
const HOME = os.homedir();
|
|
17
18
|
const SESSIONS_ROOT = path.join(HOME, '.pi/agent/sessions');
|
|
18
19
|
const FAV_FILE = path.join(HOME, '.pi/agent/favorites.json');
|
|
20
|
+
const META_FILE = path.join(HOME, '.pi/agent/pisesh-meta.json');
|
|
19
21
|
|
|
20
22
|
// Current session id (set by the /sesh extension before spawning). Lets us
|
|
21
23
|
// flag the currently-attached session in the list with a [NOW] badge.
|
|
22
24
|
const CURRENT_SESSION_ID = (process.env.PISESH_CURRENT_SESSION || '').trim();
|
|
23
25
|
|
|
26
|
+
// Directory pisesh was launched from. The /sesh extension forwards pi's cwd
|
|
27
|
+
// via PISESH_CWD; falling back to process.cwd() keeps standalone use working.
|
|
28
|
+
// Drives the "Here" tab (sessions whose effective cwd matches this dir).
|
|
29
|
+
const CURRENT_CWD = (process.env.PISESH_CWD || process.cwd() || '').replace(/\/+$/, '');
|
|
30
|
+
|
|
24
31
|
// ── ANSI ──────────────────────────────────────────────
|
|
25
32
|
const A = {
|
|
26
33
|
clr: '\x1b[2J\x1b[H',
|
|
@@ -66,6 +73,48 @@ function saveFavorites(set) {
|
|
|
66
73
|
|
|
67
74
|
let favorites = loadFavorites();
|
|
68
75
|
|
|
76
|
+
// ── Per-session overrides (title / cwd) ───────────────────
|
|
77
|
+
// Session jsonl files are treated as read-only history, so user edits to a
|
|
78
|
+
// session's display title or resume cwd live in a sidecar file keyed by id:
|
|
79
|
+
// { overrides: { "<id>": { title?: string, cwd?: string } }, updated }
|
|
80
|
+
function loadMeta() {
|
|
81
|
+
try {
|
|
82
|
+
const data = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
|
|
83
|
+
return (data && typeof data.overrides === 'object' && data.overrides) || {};
|
|
84
|
+
} catch { return {}; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveMeta(overrides) {
|
|
88
|
+
const data = { overrides, updated: new Date().toISOString() };
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.dirname(META_FILE), { recursive: true });
|
|
91
|
+
fs.writeFileSync(META_FILE, JSON.stringify(data, null, 2));
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.error('save meta failed:', e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let meta = loadMeta();
|
|
98
|
+
|
|
99
|
+
// Override record for one session id (always returns an object, never null).
|
|
100
|
+
// Used by the per-session getters, which can't read the global `meta`
|
|
101
|
+
// directly because scanSessions() shadows it with a local variable.
|
|
102
|
+
function overridesFor(id) { return meta[id] || {}; }
|
|
103
|
+
|
|
104
|
+
// Set (or clear, when value is empty) one override field for a session, then
|
|
105
|
+
// persist. Empty string deletes the field so the row falls back to defaults;
|
|
106
|
+
// an emptied override object is dropped entirely to keep the file tidy.
|
|
107
|
+
function setOverride(id, field, value) {
|
|
108
|
+
if (!id) return;
|
|
109
|
+
const v = (value || '').trim();
|
|
110
|
+
const entry = meta[id] || {};
|
|
111
|
+
if (v) entry[field] = v;
|
|
112
|
+
else delete entry[field];
|
|
113
|
+
if (Object.keys(entry).length) meta[id] = entry;
|
|
114
|
+
else delete meta[id];
|
|
115
|
+
saveMeta(meta);
|
|
116
|
+
}
|
|
117
|
+
|
|
69
118
|
// ── Project slug decode ───────────────────────────────
|
|
70
119
|
// pi encodes cwd by replacing '/' with '-' and wrapping in '--…--'
|
|
71
120
|
// "--mnt-c-Users-root--" → "/mnt/c/Users/root"
|
|
@@ -160,6 +209,15 @@ function scanSessions() {
|
|
|
160
209
|
size: st.size,
|
|
161
210
|
get favored() { return favorites.has(this.id); },
|
|
162
211
|
get isCurrent() { return CURRENT_SESSION_ID !== '' && this.id === CURRENT_SESSION_ID; },
|
|
212
|
+
// User overrides (sidecar meta). cwd: from is the original recorded cwd.
|
|
213
|
+
get titleOverride() { return (overridesFor(this.id).title || '').trim(); },
|
|
214
|
+
get cwdOverride() { return (overridesFor(this.id).cwd || '').trim(); },
|
|
215
|
+
// Name shown in the list: custom title > first prompt > id fallback.
|
|
216
|
+
get title() {
|
|
217
|
+
return this.titleOverride || this.prompt || `(no prompt) ${this.id.slice(0, 8)}`;
|
|
218
|
+
},
|
|
219
|
+
// cwd actually used to resume / filter by "Here": override wins.
|
|
220
|
+
get effectiveCwd() { return this.cwdOverride || this.cwd; },
|
|
163
221
|
});
|
|
164
222
|
}
|
|
165
223
|
}
|
|
@@ -169,11 +227,19 @@ function scanSessions() {
|
|
|
169
227
|
|
|
170
228
|
// ── State ─────────────────────────────────────────────
|
|
171
229
|
let sessions = [];
|
|
172
|
-
let tabs = ['★ Favorites', 'Today', 'All'];
|
|
230
|
+
let tabs = ['★ Favorites', 'Today', 'Here', 'All'];
|
|
173
231
|
let tabIdx = 0;
|
|
174
232
|
let cursor = 0;
|
|
175
233
|
let filter = '';
|
|
176
|
-
let mode = 'list'; // list | filter | details
|
|
234
|
+
let mode = 'list'; // list | filter | details | edit | browse
|
|
235
|
+
// edit mode state (title text editor)
|
|
236
|
+
let editField = ''; // 'title' | 'cwd'
|
|
237
|
+
let editBuffer = '';
|
|
238
|
+
let editTarget = null; // the session being edited
|
|
239
|
+
// browse mode state (arrow-key cwd directory picker)
|
|
240
|
+
let browseDir = ''; // directory currently being browsed (absolute)
|
|
241
|
+
let browseEntries = []; // [{ label, kind:'use'|'up'|'dir', path }]
|
|
242
|
+
let browseSel = 0; // selected row in browseEntries
|
|
177
243
|
|
|
178
244
|
// ── Filtering by tab ──────────────────────────────────
|
|
179
245
|
function visibleSessions() {
|
|
@@ -185,6 +251,11 @@ function visibleSessions() {
|
|
|
185
251
|
} else if (tab === 'Today') {
|
|
186
252
|
const today = new Date().toLocaleDateString('sv-SE'); // YYYY-MM-DD
|
|
187
253
|
list = list.filter(s => new Date(s.mtime).toLocaleDateString('sv-SE') === today);
|
|
254
|
+
} else if (tab === 'Here') {
|
|
255
|
+
// Only sessions whose effective cwd is the directory pisesh was launched
|
|
256
|
+
// from (trailing slashes normalized so /foo and /foo/ match).
|
|
257
|
+
const here = CURRENT_CWD.replace(/\/+$/, '');
|
|
258
|
+
list = list.filter(s => (s.effectiveCwd || '').replace(/\/+$/, '') === here);
|
|
188
259
|
}
|
|
189
260
|
|
|
190
261
|
if (filter) {
|
|
@@ -192,7 +263,8 @@ function visibleSessions() {
|
|
|
192
263
|
list = list.filter(s =>
|
|
193
264
|
s.id.toLowerCase().includes(q) ||
|
|
194
265
|
s.project.toLowerCase().includes(q) ||
|
|
195
|
-
s.prompt.toLowerCase().includes(q)
|
|
266
|
+
s.prompt.toLowerCase().includes(q) ||
|
|
267
|
+
s.title.toLowerCase().includes(q)
|
|
196
268
|
);
|
|
197
269
|
}
|
|
198
270
|
return list;
|
|
@@ -290,6 +362,8 @@ function visLen(s) {
|
|
|
290
362
|
|
|
291
363
|
// ── Render ────────────────────────────────────────────
|
|
292
364
|
function render() {
|
|
365
|
+
if (mode === 'edit') return renderEdit();
|
|
366
|
+
if (mode === 'browse') return renderBrowse();
|
|
293
367
|
if (mode === 'details') return renderDetails();
|
|
294
368
|
renderList();
|
|
295
369
|
}
|
|
@@ -337,6 +411,8 @@ function renderList() {
|
|
|
337
411
|
hint = '(no favorites yet — press f on any session to star it)';
|
|
338
412
|
} else if (tabs[tabIdx] === 'Today') {
|
|
339
413
|
hint = '(no sessions today — Tab to see All)';
|
|
414
|
+
} else if (tabs[tabIdx] === 'Here') {
|
|
415
|
+
hint = `(no sessions in ${CURRENT_CWD || 'this dir'} — Tab to see All)`;
|
|
340
416
|
} else {
|
|
341
417
|
hint = '(no sessions in this view)';
|
|
342
418
|
}
|
|
@@ -348,11 +424,13 @@ function renderList() {
|
|
|
348
424
|
const star = s.favored ? A.yel + '★' + A.R : ' ';
|
|
349
425
|
const arrow = sel ? A.cyn + '▶' + A.R : ' ';
|
|
350
426
|
const ts = A.gry + fmtTs(s.mtime) + A.R;
|
|
351
|
-
const cwd = A.mag + pad(shortCwd(s.
|
|
427
|
+
const cwd = A.mag + pad(shortCwd(s.effectiveCwd), 14) + A.R;
|
|
352
428
|
const badge = s.isCurrent ? A.grn + A.B + '[NOW]' + A.R + ' ' : '';
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
const
|
|
429
|
+
// ✎ marks a user-renamed session so it's clear the label isn't the prompt.
|
|
430
|
+
const pen = s.titleOverride ? A.cyn + '✎' + A.R + ' ' : '';
|
|
431
|
+
const titleMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0) - (s.titleOverride ? 2 : 0));
|
|
432
|
+
const title = trunc(s.title, titleMax);
|
|
433
|
+
const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${pen}${title}`;
|
|
356
434
|
if (sel) {
|
|
357
435
|
const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
|
|
358
436
|
out += A.bgBlu + rowText + pad2 + A.R + '\n';
|
|
@@ -369,14 +447,52 @@ function renderList() {
|
|
|
369
447
|
// Footer
|
|
370
448
|
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
371
449
|
out += ' ' + A.D + '↑↓' + A.R + ' move '
|
|
372
|
-
+ A.D + 'Tab' + A.R + '
|
|
450
|
+
+ A.D + 'Tab' + A.R + ' tab '
|
|
373
451
|
+ A.D + 'f' + A.R + ' star '
|
|
374
452
|
+ A.D + 'Enter' + A.R + ' resume '
|
|
453
|
+
+ A.D + 'e' + A.R + ' edit name '
|
|
454
|
+
+ A.D + 'p' + A.R + ' edit cwd '
|
|
375
455
|
+ A.D + 'd' + A.R + ' details '
|
|
376
456
|
+ A.D + '/' + A.R + ' search '
|
|
377
|
-
+ A.D + '
|
|
378
|
-
|
|
457
|
+
+ A.D + 'q' + A.R + ' quit' + A.R + '\n';
|
|
458
|
+
|
|
459
|
+
process.stdout.write(out);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Inline editor for a session's title or cwd. Renders a small focused panel
|
|
463
|
+
// so the user always sees which session + field they're changing.
|
|
464
|
+
function renderEdit() {
|
|
465
|
+
const W = process.stdout.columns || 100;
|
|
466
|
+
const s = editTarget;
|
|
467
|
+
let out = A.clr;
|
|
468
|
+
const label = editField === 'cwd' ? 'working directory (cwd)' : 'display title';
|
|
469
|
+
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' edit ' + label + A.R + '\n\n';
|
|
470
|
+
if (!s) { out += ' (no session)\n'; process.stdout.write(out); return; }
|
|
379
471
|
|
|
472
|
+
const row = (k, v, color = A.R) => ` ${A.D}${pad(k, 11)}${A.R} ${color}${v}${A.R}\n`;
|
|
473
|
+
out += row('id', s.id, A.cyn);
|
|
474
|
+
out += row('title', s.title);
|
|
475
|
+
out += row('cwd', s.effectiveCwd, A.mag);
|
|
476
|
+
if (editField === 'cwd') out += row('recorded', s.cwd, A.gry);
|
|
477
|
+
out += '\n';
|
|
478
|
+
|
|
479
|
+
out += ' ' + A.B + (editField === 'cwd' ? 'New cwd' : 'New title') + ':' + A.R + '\n';
|
|
480
|
+
out += ' ' + A.cyn + '›' + A.R + ' ' + editBuffer + A.I + ' ' + A.R + '\n';
|
|
481
|
+
if (editField === 'cwd') {
|
|
482
|
+
const exists = editBuffer.trim() && fs.existsSync(editBuffer.trim());
|
|
483
|
+
out += ' ' + A.D + (editBuffer.trim()
|
|
484
|
+
? (exists ? A.grn + 'path exists' : A.yel + 'path not found (saved anyway)')
|
|
485
|
+
: 'empty → clears override, falls back to recorded cwd') + A.R + '\n';
|
|
486
|
+
} else {
|
|
487
|
+
out += ' ' + A.D + (editBuffer.trim()
|
|
488
|
+
? 'saves a custom name for this session'
|
|
489
|
+
: 'empty → clears override, falls back to first prompt') + A.R + '\n';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
493
|
+
out += ' ' + A.D + 'Enter' + A.R + ' save '
|
|
494
|
+
+ A.D + 'Ctrl-U' + A.R + ' clear '
|
|
495
|
+
+ A.D + 'Esc' + A.R + ' cancel' + '\n';
|
|
380
496
|
process.stdout.write(out);
|
|
381
497
|
}
|
|
382
498
|
|
|
@@ -392,9 +508,11 @@ function renderDetails() {
|
|
|
392
508
|
out += row('id', s.id, A.cyn);
|
|
393
509
|
if (s.isCurrent) out += row('current', '● this is the attached pi session', A.grn);
|
|
394
510
|
out += row('starred', s.favored ? '★ yes' : '☆ no', s.favored ? A.yel : A.gry);
|
|
511
|
+
out += row('title', s.title + (s.titleOverride ? A.cyn + ' (custom)' + A.R : ''));
|
|
395
512
|
out += row('started', new Date(s.ts).toLocaleString());
|
|
396
513
|
out += row('updated', new Date(s.mtime).toLocaleString());
|
|
397
|
-
out += row('cwd', s.
|
|
514
|
+
out += row('cwd', s.effectiveCwd + (s.cwdOverride ? A.cyn + ' (custom)' + A.R : ''), A.mag);
|
|
515
|
+
if (s.cwdOverride) out += row('recorded', s.cwd, A.gry);
|
|
398
516
|
out += row('project', s.project);
|
|
399
517
|
out += row('size', fmtSize(s.size));
|
|
400
518
|
out += row('file', s.file, A.gry);
|
|
@@ -406,8 +524,10 @@ function renderDetails() {
|
|
|
406
524
|
out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
407
525
|
out += ' ' + A.D + 'Enter' + A.R + ' resume '
|
|
408
526
|
+ A.D + 'f' + A.R + ' star '
|
|
527
|
+
+ A.D + 'e' + A.R + ' edit name '
|
|
528
|
+
+ A.D + 'p' + A.R + ' edit cwd '
|
|
409
529
|
+ A.D + 'c' + A.R + ' copy id '
|
|
410
|
-
+ A.D + 'Esc/d/q' + A.R + ' back' + '\n';
|
|
530
|
+
+ A.D + 'Esc/d/q' + A.R + ' back' + A.R + '\n';
|
|
411
531
|
|
|
412
532
|
process.stdout.write(out);
|
|
413
533
|
}
|
|
@@ -438,6 +558,8 @@ function setupInput() {
|
|
|
438
558
|
process.stdin.on('keypress', (str, key) => {
|
|
439
559
|
try {
|
|
440
560
|
if (mode === 'filter') handleFilter(str, key);
|
|
561
|
+
else if (mode === 'edit') handleEdit(str, key);
|
|
562
|
+
else if (mode === 'browse') handleBrowse(str, key);
|
|
441
563
|
else if (mode === 'details') handleDetails(str, key);
|
|
442
564
|
else handleList(str, key);
|
|
443
565
|
render();
|
|
@@ -488,6 +610,8 @@ function handleList(str, key) {
|
|
|
488
610
|
}
|
|
489
611
|
else if (k === 'return') resumeSession(list[cursor]);
|
|
490
612
|
else if (k === 'd') { if (list[cursor]) mode = 'details'; }
|
|
613
|
+
else if (k === 'e') startEdit(list[cursor], 'title');
|
|
614
|
+
else if (k === 'p') startBrowse(list[cursor]);
|
|
491
615
|
else if (str === '/') { mode = 'filter'; }
|
|
492
616
|
else if (k === 'r') { sessions = scanSessions(); cursor = 0; }
|
|
493
617
|
else if (k === 'home') cursor = 0;
|
|
@@ -519,6 +643,155 @@ function handleDetails(str, key) {
|
|
|
519
643
|
// Try clipboard via clip.exe (WSL) or pbcopy/xclip
|
|
520
644
|
tryCopy(s.id);
|
|
521
645
|
}
|
|
646
|
+
if (k === 'e' && s) startEdit(s, 'title');
|
|
647
|
+
if (k === 'p' && s) startBrowse(s);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Open the inline editor for a session's title or cwd, seeded with the
|
|
651
|
+
// current override (or recorded cwd, so the user can tweak rather than retype).
|
|
652
|
+
function startEdit(s, field) {
|
|
653
|
+
if (!s) return;
|
|
654
|
+
editTarget = s;
|
|
655
|
+
editField = field;
|
|
656
|
+
editBuffer = field === 'cwd' ? (s.cwdOverride || s.cwd || '') : (s.titleOverride || '');
|
|
657
|
+
mode = 'edit';
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function handleEdit(str, key) {
|
|
661
|
+
const k = key.name;
|
|
662
|
+
if (key.ctrl && k === 'c') return quit();
|
|
663
|
+
if (k === 'escape') { mode = 'list'; editTarget = null; return; }
|
|
664
|
+
if (k === 'return') {
|
|
665
|
+
if (editTarget) setOverride(editTarget.id, editField, editBuffer);
|
|
666
|
+
mode = 'list';
|
|
667
|
+
editTarget = null;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (k === 'backspace') { editBuffer = editBuffer.slice(0, -1); return; }
|
|
671
|
+
if (key.ctrl && k === 'u') { editBuffer = ''; return; }
|
|
672
|
+
// Accept any printable char (incl. spaces, '/', CJK) but not control combos.
|
|
673
|
+
if (str && !key.ctrl && str !== '\r' && str !== '\n' && str !== '\t') editBuffer += str;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── cwd directory browser (arrow-key picker) ──────────────────
|
|
677
|
+
// List immediate subdirectories of `dir` (symlinks-to-dirs included),
|
|
678
|
+
// alphabetically, swallowing permission errors so the picker never crashes.
|
|
679
|
+
function listDirs(dir) {
|
|
680
|
+
try {
|
|
681
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
682
|
+
.filter(d => {
|
|
683
|
+
try {
|
|
684
|
+
if (d.isDirectory()) return true;
|
|
685
|
+
if (d.isSymbolicLink()) return fs.statSync(path.join(dir, d.name)).isDirectory();
|
|
686
|
+
} catch { /* unreadable entry */ }
|
|
687
|
+
return false;
|
|
688
|
+
})
|
|
689
|
+
.map(d => d.name)
|
|
690
|
+
.sort((a, b) => a.localeCompare(b));
|
|
691
|
+
} catch { return []; }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Rebuild the browser's entry list for `dir`. Falls back to the nearest
|
|
695
|
+
// existing ancestor (or '/') if the path is gone, so resume targets that no
|
|
696
|
+
// longer exist still open somewhere sensible.
|
|
697
|
+
function buildBrowse(dir) {
|
|
698
|
+
let d = dir || '/';
|
|
699
|
+
// Walk up until we hit something that exists and is a directory.
|
|
700
|
+
while (d && d !== '/' && (!fs.existsSync(d) || !safeIsDir(d))) d = path.dirname(d);
|
|
701
|
+
if (!fs.existsSync(d)) d = '/';
|
|
702
|
+
browseDir = path.resolve(d);
|
|
703
|
+
const entries = [{ label: '[· use THIS directory]', kind: 'use', path: browseDir }];
|
|
704
|
+
if (browseDir !== '/') entries.push({ label: '.. (parent)', kind: 'up', path: path.dirname(browseDir) });
|
|
705
|
+
for (const name of listDirs(browseDir)) entries.push({ label: name + '/', kind: 'dir', path: path.join(browseDir, name) });
|
|
706
|
+
browseEntries = entries;
|
|
707
|
+
browseSel = 0;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function safeIsDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
711
|
+
|
|
712
|
+
// Enter the cwd picker for a session, seeded at its current effective cwd.
|
|
713
|
+
function startBrowse(s) {
|
|
714
|
+
if (!s) return;
|
|
715
|
+
editTarget = s;
|
|
716
|
+
editField = 'cwd';
|
|
717
|
+
buildBrowse(s.cwdOverride || s.cwd || CURRENT_CWD || '/');
|
|
718
|
+
mode = 'browse';
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Persist the currently-browsed directory as this session's cwd override.
|
|
722
|
+
function commitBrowse() {
|
|
723
|
+
if (editTarget) setOverride(editTarget.id, 'cwd', browseDir);
|
|
724
|
+
mode = 'list';
|
|
725
|
+
editTarget = null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function handleBrowse(str, key) {
|
|
729
|
+
const k = key.name;
|
|
730
|
+
if (key.ctrl && k === 'c') return quit();
|
|
731
|
+
if (k === 'escape') { mode = 'list'; editTarget = null; return; }
|
|
732
|
+
if (k === 'up' || k === 'k') browseSel = Math.max(0, browseSel - 1);
|
|
733
|
+
else if (k === 'down' || k === 'j') browseSel = Math.min(browseEntries.length - 1, browseSel + 1);
|
|
734
|
+
else if (k === 'pageup') browseSel = Math.max(0, browseSel - 10);
|
|
735
|
+
else if (k === 'pagedown') browseSel = Math.min(browseEntries.length - 1, browseSel + 10);
|
|
736
|
+
else if (k === 'home') browseSel = 0;
|
|
737
|
+
else if (k === 'end') browseSel = browseEntries.length - 1;
|
|
738
|
+
// Left / h: go to parent directory.
|
|
739
|
+
else if (k === 'left' || k === 'h') { if (browseDir !== '/') buildBrowse(path.dirname(browseDir)); }
|
|
740
|
+
// Right / l / Enter: open the highlighted dir (or commit if it's the
|
|
741
|
+
// "use THIS directory" / parent row).
|
|
742
|
+
else if (k === 'right' || k === 'l' || k === 'return') {
|
|
743
|
+
const e = browseEntries[browseSel];
|
|
744
|
+
if (!e) return;
|
|
745
|
+
if (e.kind === 'use') return commitBrowse();
|
|
746
|
+
buildBrowse(e.path);
|
|
747
|
+
}
|
|
748
|
+
// s: select the directory we're currently inside, from anywhere.
|
|
749
|
+
else if (k === 's') commitBrowse();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Focused full-screen directory picker. ANSI-padded selected row matches the
|
|
753
|
+
// main list look so it feels like the same widget.
|
|
754
|
+
function renderBrowse() {
|
|
755
|
+
const W = process.stdout.columns || 100;
|
|
756
|
+
const H = process.stdout.rows || 30;
|
|
757
|
+
const s = editTarget;
|
|
758
|
+
let out = A.clr;
|
|
759
|
+
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' edit cwd — pick working directory' + A.R + '\n\n';
|
|
760
|
+
if (!s) { out += ' (no session)\n'; process.stdout.write(out); return; }
|
|
761
|
+
|
|
762
|
+
const row = (key, v, color = A.R) => ` ${A.D}${pad(key, 9)}${A.R} ${color}${v}${A.R}\n`;
|
|
763
|
+
out += row('session', trunc(s.title, W - 14));
|
|
764
|
+
out += row('current', s.effectiveCwd, A.mag);
|
|
765
|
+
out += '\n ' + A.B + 'In:' + A.R + ' ' + A.cyn + trunc(browseDir, W - 6) + A.R + '\n';
|
|
766
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
767
|
+
|
|
768
|
+
const headerH = 7;
|
|
769
|
+
const footerH = 3;
|
|
770
|
+
const usable = Math.max(3, H - headerH - footerH);
|
|
771
|
+
if (browseSel >= browseEntries.length) browseSel = Math.max(0, browseEntries.length - 1);
|
|
772
|
+
const start = Math.max(0, Math.min(browseSel - Math.floor(usable / 2), browseEntries.length - usable));
|
|
773
|
+
const end = Math.min(browseEntries.length, start + usable);
|
|
774
|
+
for (let i = start; i < end; i++) {
|
|
775
|
+
const e = browseEntries[i];
|
|
776
|
+
const sel = i === browseSel;
|
|
777
|
+
const arrow = sel ? A.cyn + '▶' + A.R : ' ';
|
|
778
|
+
const color = e.kind === 'use' ? A.grn + A.B : (e.kind === 'up' ? A.yel : A.R);
|
|
779
|
+
const rowText = ` ${arrow} ${color}${trunc(e.label, W - 6)}${A.R}`;
|
|
780
|
+
if (sel) {
|
|
781
|
+
const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
|
|
782
|
+
out += A.bgBlu + rowText + pad2 + A.R + '\n';
|
|
783
|
+
} else out += rowText + '\n';
|
|
784
|
+
}
|
|
785
|
+
const linesUsed = (end - start) || 1;
|
|
786
|
+
for (let i = 0; i < usable - linesUsed; i++) out += '\n';
|
|
787
|
+
|
|
788
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
789
|
+
out += ' ' + A.D + '↑↓' + A.R + ' move '
|
|
790
|
+
+ A.D + '→/Enter' + A.R + ' open dir '
|
|
791
|
+
+ A.D + '←' + A.R + ' parent '
|
|
792
|
+
+ A.grn + A.B + 's' + A.R + A.D + ' SELECT this dir '
|
|
793
|
+
+ A.D + 'Esc' + A.R + ' cancel' + A.R + '\n';
|
|
794
|
+
process.stdout.write(out);
|
|
522
795
|
}
|
|
523
796
|
|
|
524
797
|
function tryCopy(text) {
|
|
@@ -543,10 +816,12 @@ function resumeSession(s) {
|
|
|
543
816
|
process.stdin.pause();
|
|
544
817
|
|
|
545
818
|
const projectDir = path.dirname(s.file);
|
|
546
|
-
//
|
|
819
|
+
// Resume in the session's effective cwd (user override wins over recorded),
|
|
820
|
+
// falling back to pisesh's own cwd if that directory no longer exists.
|
|
821
|
+
const targetCwd = s.effectiveCwd;
|
|
547
822
|
const child = spawn('pi', ['--session', s.id, '--session-dir', projectDir], {
|
|
548
823
|
stdio: 'inherit',
|
|
549
|
-
cwd: fs.existsSync(
|
|
824
|
+
cwd: targetCwd && fs.existsSync(targetCwd) ? targetCwd : process.cwd(),
|
|
550
825
|
});
|
|
551
826
|
child.on('exit', code => process.exit(code || 0));
|
|
552
827
|
child.on('error', err => {
|
|
@@ -578,16 +853,25 @@ Usage:
|
|
|
578
853
|
|
|
579
854
|
TUI keys:
|
|
580
855
|
↑↓ / j k move cursor
|
|
581
|
-
Tab / h l switch tab (★ Favorites → Today → All)
|
|
856
|
+
Tab / h l switch tab (★ Favorites → Today → Here → All)
|
|
582
857
|
f / Space toggle favorite
|
|
583
|
-
Enter resume session (spawns 'pi --session …')
|
|
858
|
+
Enter resume session (spawns 'pi --session …' in its cwd)
|
|
859
|
+
e edit name (custom display title)
|
|
860
|
+
p edit cwd — arrow-key directory browser
|
|
861
|
+
(↑↓ move, →/Enter open dir, ← parent, s select, Esc cancel)
|
|
862
|
+
sets the working directory used on resume / "Here" tab
|
|
584
863
|
d show details
|
|
585
|
-
/ search (id / project / prompt
|
|
864
|
+
/ search (id / project / prompt / title)
|
|
586
865
|
r rescan session files
|
|
587
866
|
q / Esc / Ctrl-C quit
|
|
588
867
|
|
|
868
|
+
Tabs:
|
|
869
|
+
Here only sessions whose cwd matches the directory pisesh was
|
|
870
|
+
launched from (\$PISESH_CWD, else process cwd)
|
|
871
|
+
|
|
589
872
|
Files:
|
|
590
873
|
Favorites: ${FAV_FILE}
|
|
874
|
+
Overrides: ${META_FILE}
|
|
591
875
|
Sessions : ${SESSIONS_ROOT}/<project>/<ts>_<uuid>.jsonl`);
|
|
592
876
|
}
|
|
593
877
|
|
package/extensions/sesh.ts
CHANGED
|
@@ -17,67 +17,78 @@
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
|
|
20
|
-
function runPisesh(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
20
|
+
function runPisesh(
|
|
21
|
+
currentSessionId: string | undefined,
|
|
22
|
+
): Promise<number | null> {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
// stdio:"inherit" hands the real TTY to pisesh. pi's tui.stop() has
|
|
25
|
+
// already detached so this is safe.
|
|
26
|
+
// PISESH_CURRENT_SESSION lets pisesh flag the row that belongs to the
|
|
27
|
+
// pi instance that just spawned it (rendered with a [NOW] badge).
|
|
28
|
+
const child = spawn("pisesh", [], {
|
|
29
|
+
stdio: "inherit",
|
|
30
|
+
env: {
|
|
31
|
+
...process.env,
|
|
32
|
+
// Forward pi's cwd so pisesh's "Here" tab can show only the sessions
|
|
33
|
+
// that belong to the directory this pi instance is attached to.
|
|
34
|
+
PISESH_CWD: process.cwd(),
|
|
35
|
+
...(currentSessionId
|
|
36
|
+
? { PISESH_CURRENT_SESSION: currentSessionId }
|
|
37
|
+
: {}),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
child.on("exit", (code) => resolve(code));
|
|
41
|
+
child.on("error", (err) => {
|
|
42
|
+
// Surface a readable error in the terminal before we re-render.
|
|
43
|
+
process.stdout.write(
|
|
44
|
+
`\x1b[31mpisesh failed to launch: ${err.message}\x1b[0m\n`,
|
|
45
|
+
);
|
|
46
|
+
resolve(127);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
export default function (pi: ExtensionAPI) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
pi.registerCommand("sesh", {
|
|
53
|
+
description: "Browse, star, and resume pi sessions (opens pisesh TUI)",
|
|
54
|
+
handler: async (_args, ctx) => {
|
|
55
|
+
if (!ctx.hasUI) {
|
|
56
|
+
ctx.ui?.notify?.("/sesh requires interactive UI", "warning");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
50
59
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
let currentId: string | undefined;
|
|
61
|
+
try {
|
|
62
|
+
currentId = ctx.sessionManager?.getSessionId?.();
|
|
63
|
+
} catch {
|
|
64
|
+
currentId = undefined;
|
|
65
|
+
}
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
const code = await ctx.ui.custom<number | null>(
|
|
68
|
+
(tui, _theme, _kb, done) => {
|
|
69
|
+
// Hand over the terminal
|
|
70
|
+
tui.stop();
|
|
71
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
runPisesh(currentId).then((exitCode) => {
|
|
74
|
+
// Restore pi's TUI
|
|
75
|
+
tui.start();
|
|
76
|
+
tui.requestRender(true);
|
|
77
|
+
done(exitCode);
|
|
78
|
+
});
|
|
69
79
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
80
|
+
// Return a no-op component (custom() requires one synchronously)
|
|
81
|
+
return { render: () => [], invalidate: () => {} };
|
|
82
|
+
},
|
|
83
|
+
);
|
|
73
84
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
if (code === 0 || code === null) {
|
|
86
|
+
ctx.ui.notify("Returned from pisesh", "info");
|
|
87
|
+
} else if (code === 127) {
|
|
88
|
+
ctx.ui.notify("pisesh not found on PATH", "error");
|
|
89
|
+
} else {
|
|
90
|
+
ctx.ui.notify(`pisesh exited with code ${code}`, "warning");
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
83
94
|
}
|