pisesh 0.1.2 → 0.1.4

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 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,6 +1,6 @@
1
1
  # pisesh
2
2
 
3
- **English** | [한국어](docs/README.ko.md)
3
+ **English** | [한국어](https://github.com/Blue-B/pisesh/blob/main/docs/README.ko.md)
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/pisesh?style=for-the-badge&logo=npm&color=CB3837&logoColor=white)](https://www.npmjs.com/package/pisesh)
6
6
  [![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)
@@ -18,7 +18,25 @@
18
18
  <img src="assets/preview.png" alt="pisesh — Favorites tab in a real Windows Terminal session" width="100%">
19
19
  </p>
20
20
 
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>
21
+ <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>
22
+
23
+ ## Terminal walkthrough
24
+
25
+ What the TUI actually looks like, screen by screen. (Demo data — not real sessions.)
26
+
27
+ **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:
28
+
29
+ <p align="center"><img src="assets/screen-list.png" alt="pisesh main list — Favorites tab with Today / Here / All tabs, [NOW] badge, and a renamed session" width="100%"></p>
30
+
31
+ **`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:
32
+
33
+ <p align="center"><img src="assets/screen-rename.png" alt="pisesh edit-name panel — setting a custom display title" width="100%"></p>
34
+
35
+ **`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:
36
+
37
+ <p align="center"><img src="assets/screen-cwd.png" alt="pisesh cwd browser — arrow-key directory picker for the resume / Here directory" width="100%"></p>
38
+
39
+ 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
40
 
23
41
  ## Why pisesh
24
42
 
@@ -36,13 +54,16 @@ pisesh is a **single-file Node script** (no dependencies, ~600 LoC) that gives y
36
54
  | Need | What you get |
37
55
  | ------------------------------------------ | ---------------------------------------------------------------------------- |
38
56
  | Mark important sessions | ⭐ Star/unstar with one keystroke; favorites persist to one global JSON |
57
+ | Give a thread a real name | `e` sets a custom title (marked `✎`); overrides the first-prompt label |
58
+ | See only the current project's sessions | `Here` tab filters to sessions whose cwd matches where you launched pisesh |
59
+ | Fix where a session resumes | `p` opens an arrow-key directory browser; sets the cwd pi `cd`s into |
39
60
  | Find a session by what you said | `/` searches id + project + first user prompt |
40
61
  | Know which session you're attached to | `[NOW]` badge on the live session (passed from pi via env var) |
41
62
  | Keep your terminal clean | Alt-screen buffer — exit restores your terminal byte-for-byte (like vim) |
42
63
  | Read Korean / Chinese / Japanese prompts | Display-width-aware truncation; columns never blow up on CJK |
43
64
  | Open from anywhere | Run as standalone `pisesh` shell command, or `/sesh` inside pi |
44
65
  | Zero install pain | No build step, no native deps, runs on Node 18+ everywhere |
45
- | Trust it with your history | pisesh only writes one favorites file; session jsonl files are read-only |
66
+ | Trust it with your history | pisesh writes only two small JSON files (favorites + overrides); session jsonl files are read-only |
46
67
 
47
68
  ## Getting started
48
69
 
@@ -80,9 +101,11 @@ Pi-extension side: drop `extensions/sesh.ts` into `~/.pi/agent/extensions/` and
80
101
  | Key | Action |
81
102
  | ---------------------------- | ------------------------------------------------------------ |
82
103
  | `↑` `↓` / `j` `k` | move cursor |
83
- | `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `All`) |
104
+ | `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `Here` → `All`) |
84
105
  | `f` / `Space` | star / unstar the selected session |
85
106
  | `Enter` | resume — spawns `pi --session <id> --session-dir <dir>` |
107
+ | `e` | edit name — set a custom display title (marked `✎` in list) |
108
+ | `p` | edit cwd — arrow-key directory browser; sets resume / `Here` dir |
86
109
  | `d` | session details (full prompt, file, byte size, timestamps) |
87
110
  | `/` | search by id / project / first user prompt |
88
111
  | `Esc` | clear search first, then quit |
@@ -152,6 +175,7 @@ The current pi session is paused, not lost. When you finish with the resumed ses
152
175
  | What | Where |
153
176
  | ---------- | ----------------------------------------------------------- |
154
177
  | Favorites | `~/.pi/agent/favorites.json` |
178
+ | Overrides | `~/.pi/agent/pisesh-meta.json` (per-session custom title / cwd, keyed by session id) |
155
179
  | Sessions | `~/.pi/agent/sessions/<projectSlug>/<timestamp>_<uuid>.jsonl` (pi's native layout — pisesh never writes here) |
156
180
 
157
181
  Favorites file shape:
@@ -173,9 +197,9 @@ It's a single global file (not per-project). Back it up by syncing one file.
173
197
  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
198
 
175
199
  ```text
176
- aitapps 지금 디렉토리에 앱인토스 제출용앱을 만들었는데…
177
- 공모전 신소재 공학 관련 졸업과제 도와줘…
178
- WhisperSubTrans 이슈 #26 이전 버전 아닌가 확인…
200
+ webapp 로그인 만들고 인증 엔드포인트 연결…
201
+ 가계부앱 이번 지출 분석 화면 설계…
202
+ docs-site 시작하기 가이드 다시 작성…
179
203
  ```
180
204
 
181
205
  (Previously: Korean prompts overflowed to a second line and broke the table.)
@@ -194,12 +218,14 @@ Korean / Chinese / Japanese / fullwidth characters render **2 cells wide** in te
194
218
  | Status | Item |
195
219
  | ------ | ------------------------------------------------------------------------------- |
196
220
  | ✅ | Tabs, star/unstar, search, alt-screen, CJK width, `[NOW]` badge, pi `/sesh` |
221
+ | ✅ | `Here` tab — filter to sessions matching the launch directory |
222
+ | ✅ | Inline rename (`e`) — custom display title that overrides the first prompt |
223
+ | ✅ | Edit cwd (`p`) — arrow-key directory browser sets the resume / `Here` dir |
197
224
  | 🚧 | `n` / `N` jump to next / previous search match (less-style) |
198
225
  | 🚧 | Highlight matched substring in yellow |
199
226
  | 🚧 | Filter by `today/yesterday/this-week` |
200
227
  | 🧠 | Optional summarize first-N user prompts via local model for richer titles |
201
228
  | 🧠 | Export starred sessions as a single bundle (share / archive) |
202
- | 🧠 | Inline rename / label (`n` to add a custom title that overrides first prompt) |
203
229
 
204
230
  PRs welcome for anything in the 🚧 lane.
205
231
 
@@ -234,22 +260,6 @@ If pisesh saves you context-switching time or just makes pi nicer to live in, su
234
260
  - [interactive-shell example extension](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/interactive-shell.ts) — pattern reference for `ui.custom` + `tui.stop` TTY handoff.
235
261
  - Inspiration for the favorites + tabs UX: [droid CLI](https://github.com/factory-ai/droid) and tmux's [sesh](https://github.com/joshmedeski/sesh).
236
262
 
237
- ## Contributors
238
-
239
- Thanks to everyone who helps make pisesh better! 🙏
240
-
241
- <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>
242
-
243
- ## Repository activity
244
-
245
- ![Repobeats analytics image](https://repobeats.axiom.co/api/embed/a21cb8addd5d2f0ea4ec229c69da5b23855911a8.svg "Repobeats analytics image")
246
-
247
- ## Star History
248
-
249
- <a href="https://star-history.com/#Blue-B/pisesh&Date">
250
- <img src="https://api.star-history.com/svg?repos=Blue-B/pisesh&type=Date&v=20260531" alt="Star History Chart" width="600" />
251
- </a>
252
-
253
263
  ## License
254
264
 
255
265
  MIT © [Blue-B](https://github.com/Blue-B). See [LICENSE](LICENSE).
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.cwd), 14) + A.R;
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
- 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}`;
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 + ' next tab '
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 + 'r' + A.R + ' refresh '
378
- + A.D + 'q/Esc' + A.R + ' quit' + '\n';
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.cwd, A.mag);
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
- // Spawn pi in the original cwd with the session restored
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(s.cwd) ? s.cwd : process.cwd(),
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; Esc clears first, then quits)
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
 
@@ -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(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
- });
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
- 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
- }
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
- let currentId: string | undefined;
52
- try {
53
- currentId = ctx.sessionManager?.getSessionId?.();
54
- } catch {
55
- currentId = undefined;
56
- }
60
+ let currentId: string | undefined;
61
+ try {
62
+ currentId = ctx.sessionManager?.getSessionId?.();
63
+ } catch {
64
+ currentId = undefined;
65
+ }
57
66
 
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");
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
- runPisesh(currentId).then((exitCode) => {
64
- // Restore pi's TUI
65
- tui.start();
66
- tui.requestRender(true);
67
- done(exitCode);
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
- // Return a no-op component (custom() requires one synchronously)
71
- return { render: () => [], invalidate: () => {} };
72
- });
80
+ // Return a no-op component (custom() requires one synchronously)
81
+ return { render: () => [], invalidate: () => {} };
82
+ },
83
+ );
73
84
 
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
- });
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pisesh",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Bookmark, search, and resume pi coding-agent sessions with a fast keyboard-driven TUI.",
5
5
  "keywords": [
6
6
  "pi-package",