pisesh 0.1.12 → 0.2.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 +34 -11
- package/README.md +22 -11
- package/bin/pisesh +653 -43
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.2.0] - 2026-08-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Add background LLM title generation: `g` queues a session using the saved model and effort, while `G` opens generation settings. Up to three titles generate concurrently. Based on #2 by @ahoereth.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Keep extension-backed model providers available while disabling context files, skills, prompt templates, and tools for title generation.
|
|
12
|
+
- Preserve manual titles during queued generation, reject malformed or control-sequence output, and report generation failures visibly.
|
|
13
|
+
- Forward custom agent directories to spawned pi processes and terminate cancelled generation processes safely.
|
|
14
|
+
|
|
15
|
+
## [0.1.13] - 2026-08-01
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Press `o` to resume with the model and thinking recorded in the session while `Enter` uses current defaults.
|
|
19
|
+
- Show the current-default and original-session resume modes in the session details view.
|
|
20
|
+
- Support `PI_AGENT_DIR` and `PI_SESSION_DIR`, including flat custom session directories.
|
|
21
|
+
- Add `pisesh --version` and `pisesh --clean-favorites`; press `x` in the TUI to remove stale favorites.
|
|
22
|
+
- Report successful orphan-call repairs and stop with a visible error when a session cannot be repaired safely.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- Resume favorite sessions with the current default provider, model, and thinking level instead of restoring stale values recorded in the session.
|
|
26
|
+
- Fall back to pi's native session restore when the settings file is unavailable or invalid.
|
|
27
|
+
|
|
5
28
|
## [0.1.12] - 2026-07-20
|
|
6
29
|
|
|
7
30
|
### Fixed
|
|
@@ -14,39 +37,39 @@ All notable changes to this project will be documented in this file.
|
|
|
14
37
|
- `/sesh` now launches the CLI bundled with the pi package instead of requiring `pisesh` on the user's `PATH`.
|
|
15
38
|
- Clarified that the standalone `pisesh` shell command requires `npm install -g pisesh`.
|
|
16
39
|
|
|
17
|
-
## [0.1.10]
|
|
40
|
+
## [0.1.10] - 2026-06-09
|
|
18
41
|
|
|
19
42
|
### Fixed
|
|
20
|
-
- **Resume no longer crashes on sessions interrupted mid-tool-call.** If a session ended while a tool call was still in flight (e.g. an image generation cut off by a usage limit or a closed window), its transcript held a tool call with no recorded result. On resume, pi would re-fire that dead tool and the spawned process could exit non-zero
|
|
43
|
+
- **Resume no longer crashes on sessions interrupted mid-tool-call.** If a session ended while a tool call was still in flight (e.g. an image generation cut off by a usage limit or a closed window), its transcript held a tool call with no recorded result. On resume, pi would re-fire that dead tool and the spawned process could exit non-zero, surfacing as `pisesh exited with code 1`, repeatedly. `resumeSession` now heals the target session first: every orphaned tool call gets a synthetic `[interrupted]` result injected before pi is spawned, so the resumed history is always well-formed and no dead tool is re-run. Idempotent, never throws, and writes a one-time `.bak-orphanheal` backup of the session before modifying it.
|
|
21
44
|
|
|
22
|
-
## [0.1.9]
|
|
45
|
+
## [0.1.9] - 2026-06-03
|
|
23
46
|
|
|
24
47
|
### Fixed
|
|
25
48
|
- Name / cwd editor (`e`) now has a real caret. Arrow keys (and `Home` / `End`) move inside the text you already typed, so you can insert or delete in the middle instead of only at the end. Backspace, forward `Delete`, and inserts all act at the caret. Cursor moves by whole code points, so CJK and emoji never get split.
|
|
26
49
|
|
|
27
|
-
## [0.1.4]
|
|
50
|
+
## [0.1.4] - 2026-06-03
|
|
28
51
|
|
|
29
52
|
### Added
|
|
30
|
-
- **`Here` tab**
|
|
31
|
-
- **Inline rename (`e`)**
|
|
32
|
-
- **Edit cwd (`p`)**
|
|
53
|
+
- **`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**.
|
|
54
|
+
- **Inline rename (`e`)** sets a custom display title that overrides the first-prompt label; renamed sessions are marked with a cyan `✎` in the list.
|
|
55
|
+
- **Edit cwd (`p`)** provides an arrow-key directory browser to re-point the working directory pi resumes into (also drives the `Here` filter).
|
|
33
56
|
- Per-session overrides (custom title / cwd) persist to `~/.pi/agent/pisesh-meta.json`, keyed by session id. Session jsonl files remain read-only.
|
|
34
57
|
- README terminal screenshots for the list view, rename panel, and cwd browser.
|
|
35
58
|
|
|
36
|
-
## [0.1.0]
|
|
59
|
+
## [0.1.0] - 2026-05-31
|
|
37
60
|
|
|
38
61
|
Initial release.
|
|
39
62
|
|
|
40
63
|
### Added
|
|
41
|
-
- `pisesh` CLI binary
|
|
64
|
+
- `pisesh` CLI binary with a keyboard-driven TUI that lists every pi session under `~/.pi/agent/sessions/`
|
|
42
65
|
- Tabs: **★ Favorites**, **Today**, **All**
|
|
43
66
|
- Star / unstar with `f` or Space; favorites persist to `~/.pi/agent/favorites.json`
|
|
44
67
|
- Search across id / project / first user prompt with `/`
|
|
45
68
|
- Session details view (`d`): full prompt, file path, byte size, timestamps
|
|
46
69
|
- `Enter` resumes the selected session via `pi --session <id> --session-dir <dir>` in the original cwd
|
|
47
70
|
- `[NOW]` badge marks the session belonging to the pi instance that spawned pisesh (set via `PISESH_CURRENT_SESSION` env var)
|
|
48
|
-
- Alternate screen buffer (`\x1b[?1049h`)
|
|
71
|
+
- Alternate screen buffer (`\x1b[?1049h`) so exit restores terminal byte-for-byte; no scrollback pollution
|
|
49
72
|
- CJK-aware truncation and padding (Hangul / CJK ideographs / emoji counted as 2 cells)
|
|
50
73
|
- Signal handlers (`SIGINT`, `SIGTERM`, `exit`) restore cursor + main buffer on unexpected exit
|
|
51
74
|
- Non-TUI CLI: `--list`, `--json`, `--star <id>`, `--unstar <id>`, `--help`
|
|
52
|
-
- Pi extension at `extensions/sesh.ts`
|
|
75
|
+
- Pi extension at `extensions/sesh.ts` registers `/sesh` slash command which spawns pisesh inside pi via `ui.custom` + `tui.stop()`
|
package/README.md
CHANGED
|
@@ -45,14 +45,14 @@ Pi accumulates sessions across many working directories: your home, several proj
|
|
|
45
45
|
- You re-open the wrong session and pollute it with unrelated context
|
|
46
46
|
- You waste time searching by timestamp guessing
|
|
47
47
|
|
|
48
|
-
pisesh is a **single-file Node script** (no dependencies, ~
|
|
48
|
+
pisesh is a **single-file Node script** (no dependencies, ~1,600 LoC) that gives you everything `pi --resume` doesn't.
|
|
49
49
|
|
|
50
50
|
### Value at a glance
|
|
51
51
|
|
|
52
52
|
| Need | What you get |
|
|
53
53
|
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
|
54
54
|
| Mark important sessions | ⭐ Star/unstar with one keystroke; favorites persist to one global JSON |
|
|
55
|
-
| Give a thread a real name | `e` sets
|
|
55
|
+
| Give a thread a real name | `e` sets one manually; `g` generates one with a model you choose |
|
|
56
56
|
| See only the current project's sessions | `Here` tab filters to sessions whose cwd matches where you launched pisesh |
|
|
57
57
|
| Fix where a session resumes | `p` opens an arrow-key directory browser; sets the cwd pi `cd`s into |
|
|
58
58
|
| Find a session by what you said | `/` searches id + project + first user prompt + custom title |
|
|
@@ -61,7 +61,7 @@ pisesh is a **single-file Node script** (no dependencies, ~900 LoC) that gives y
|
|
|
61
61
|
| Read Korean / Chinese / Japanese prompts | Display-width-aware truncation; columns never blow up on CJK |
|
|
62
62
|
| Open from anywhere | Run as standalone `pisesh` shell command, or `/sesh` inside pi |
|
|
63
63
|
| Zero install pain | No build step, no native deps, runs on Node 18+ everywhere |
|
|
64
|
-
| Trust it with your history |
|
|
64
|
+
| Trust it with your history | favorites and overrides stay in sidecar JSON; session history changes only for orphan-call repair |
|
|
65
65
|
|
|
66
66
|
## Getting started
|
|
67
67
|
|
|
@@ -100,17 +100,23 @@ For local pi testing, run `pi install .` from the cloned repository so the exten
|
|
|
100
100
|
| `↑` `↓` / `j` `k` | move cursor |
|
|
101
101
|
| `Tab` / `h` / `l` | switch tab (`★ Favorites` → `Today` → `Here` → `All`) |
|
|
102
102
|
| `f` / `Space` | star / unstar the selected session |
|
|
103
|
-
| `
|
|
103
|
+
| `x` | remove favorites whose session files no longer exist |
|
|
104
|
+
| `Enter` | resume using the current default model and thinking settings |
|
|
105
|
+
| `o` | resume using the model and thinking recorded in the session |
|
|
104
106
|
| `e` | edit name: set a custom display title, shown with `✎` in the list |
|
|
107
|
+
| `g` | queue title generation with the saved model and effort; clear a manual title with `e` first |
|
|
108
|
+
| `G` | open title-generation settings to choose the saved model + effort |
|
|
105
109
|
| `p` | edit cwd with an arrow-key directory browser; sets the resume / `Here` dir |
|
|
106
110
|
| `d` | session details (full prompt, file, byte size, timestamps) |
|
|
107
111
|
| `/` | search by id / project / first user prompt / custom title |
|
|
108
|
-
| `Esc`
|
|
109
|
-
| `
|
|
112
|
+
| `Esc` / `q` | cancel generation or clear search first; press again to quit |
|
|
113
|
+
| `Ctrl-C` | cancel generation and quit immediately |
|
|
110
114
|
| `r` | rescan session files (after pi starts a new session) |
|
|
111
115
|
| `c` (in details view) | copy session id to clipboard (clip.exe / pbcopy / xclip) |
|
|
112
116
|
| `Home` `End` `PgUp` `PgDn` | jump to top / bottom / ±10 |
|
|
113
117
|
|
|
118
|
+
Title generation sends up to 16 KB of session text to the selected model provider and may incur provider charges. It excludes tool results and disables context files, skills, prompt templates, and tools.
|
|
119
|
+
|
|
114
120
|
## CLI (non-TUI) usage
|
|
115
121
|
|
|
116
122
|
For scripts and automation:
|
|
@@ -120,6 +126,8 @@ pisesh --list # print starred session IDs (one per line)
|
|
|
120
126
|
pisesh --json # full favorites file as JSON
|
|
121
127
|
pisesh --star <partial-uuid> # star a session from a script
|
|
122
128
|
pisesh --unstar <partial-uuid> # unstar
|
|
129
|
+
pisesh --clean-favorites # remove favorites whose sessions are gone
|
|
130
|
+
pisesh --version # print installed version
|
|
123
131
|
pisesh --help
|
|
124
132
|
```
|
|
125
133
|
|
|
@@ -135,24 +143,27 @@ pisesh --help
|
|
|
135
143
|
| Input | Node's `readline.emitKeypressEvents` in raw mode |
|
|
136
144
|
| Width calculation | UAX #11 East Asian Width ranges, compressed to ~10 inline range checks |
|
|
137
145
|
| Pi extension | TypeScript factory using `@earendil-works/pi-coding-agent` extension API (`ui.custom`, `tui.stop`) |
|
|
138
|
-
| Storage | Two JSON files
|
|
146
|
+
| Storage | Two JSON files under `$PI_AGENT_DIR`: `favorites.json` and `pisesh-meta.json` |
|
|
139
147
|
| Session discovery | Direct filesystem scan of `~/.pi/agent/sessions/<projectSlug>/*.jsonl`; first 96 KB parsed |
|
|
140
148
|
| Process model | Slash command pauses pi's TUI, spawns pisesh with inherited stdio, restarts pi on exit |
|
|
149
|
+
| Resume settings | `Enter` uses current defaults; `o` preserves the model and thinking recorded in the session |
|
|
150
|
+
| Custom paths | Honors `PI_AGENT_DIR` and `PI_SESSION_DIR`, including a flat custom session directory |
|
|
151
|
+
| Title generation | Ephemeral `pi --print --no-session` call using the model and effort selected in pisesh |
|
|
141
152
|
|
|
142
153
|
### What it explicitly does **not** depend on
|
|
143
154
|
|
|
144
155
|
- No `npm install` for the bundled CLI runtime; it's genuinely zero-dependency
|
|
145
156
|
- No native binaries / GPU / ffmpeg / database
|
|
146
|
-
- No
|
|
157
|
+
- No telemetry or analytics; title generation contacts only the provider for the model you select
|
|
147
158
|
- No daemon / background process
|
|
148
159
|
|
|
149
160
|
## Storage
|
|
150
161
|
|
|
151
162
|
| What | Where |
|
|
152
163
|
| ---------- | ----------------------------------------------------------- |
|
|
153
|
-
| Favorites | `~/.pi/agent/favorites.json`
|
|
154
|
-
| Overrides |
|
|
155
|
-
| Sessions |
|
|
164
|
+
| Favorites | `$PI_AGENT_DIR/favorites.json` (defaults to `~/.pi/agent/favorites.json`) |
|
|
165
|
+
| Overrides | `$PI_AGENT_DIR/pisesh-meta.json` (per-session title / cwd plus the saved title model + effort preset) |
|
|
166
|
+
| Sessions | `$PI_SESSION_DIR`, or `$PI_AGENT_DIR/sessions` by default (repaired only when an orphaned tool call would break resume) |
|
|
156
167
|
|
|
157
168
|
Favorites file shape:
|
|
158
169
|
|
package/bin/pisesh
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// pisesh — pi session bookmark & resume TUI
|
|
3
3
|
// Tabs: ★ Favorites / Today / Here / All
|
|
4
|
-
// Storage:
|
|
5
|
-
//
|
|
6
|
-
// Sessions:
|
|
4
|
+
// Storage: $PI_AGENT_DIR/favorites.json (starred session ids)
|
|
5
|
+
// $PI_AGENT_DIR/pisesh-meta.json (per-session title / cwd overrides)
|
|
6
|
+
// Sessions: $PI_SESSION_DIR or $PI_AGENT_DIR/sessions
|
|
7
7
|
|
|
8
8
|
'use strict';
|
|
9
9
|
|
|
@@ -15,9 +15,24 @@ const readline = require('readline');
|
|
|
15
15
|
|
|
16
16
|
// ── Paths ─────────────────────────────────────────────
|
|
17
17
|
const HOME = os.homedir();
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
18
|
+
const AGENT_DIR = path.resolve(process.env.PI_AGENT_DIR || process.env.PI_CODING_AGENT_DIR || path.join(HOME, '.pi/agent'));
|
|
19
|
+
const SESSIONS_ROOT = path.resolve(process.env.PI_SESSION_DIR || process.env.PI_CODING_AGENT_SESSION_DIR || path.join(AGENT_DIR, 'sessions'));
|
|
20
|
+
const FAV_FILE = path.join(AGENT_DIR, 'favorites.json');
|
|
21
|
+
const META_FILE = path.join(AGENT_DIR, 'pisesh-meta.json');
|
|
22
|
+
const SETTINGS_FILE = path.join(AGENT_DIR, 'settings.json');
|
|
23
|
+
function piAgentEnv(baseEnv = process.env, agentDir = AGENT_DIR) {
|
|
24
|
+
return { ...baseEnv, PI_CODING_AGENT_DIR: agentDir };
|
|
25
|
+
}
|
|
26
|
+
const PI_ENV = piAgentEnv();
|
|
27
|
+
const VERSION = (() => {
|
|
28
|
+
for (const file of [
|
|
29
|
+
path.resolve(__dirname, '../package.json'),
|
|
30
|
+
path.join(AGENT_DIR, 'npm/node_modules/pisesh/package.json'),
|
|
31
|
+
]) {
|
|
32
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')).version; } catch {}
|
|
33
|
+
}
|
|
34
|
+
return 'unknown';
|
|
35
|
+
})();
|
|
21
36
|
|
|
22
37
|
// Current session id (set by the /sesh extension before spawning). Lets us
|
|
23
38
|
// flag the currently-attached session in the list with a [NOW] badge.
|
|
@@ -80,12 +95,15 @@ let favorites = loadFavorites();
|
|
|
80
95
|
function loadMeta() {
|
|
81
96
|
try {
|
|
82
97
|
const data = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
|
|
83
|
-
return
|
|
84
|
-
|
|
98
|
+
return {
|
|
99
|
+
overrides: (data && typeof data.overrides === 'object' && data.overrides) || {},
|
|
100
|
+
settings: (data && typeof data.settings === 'object' && data.settings) || {},
|
|
101
|
+
};
|
|
102
|
+
} catch { return { overrides: {}, settings: {} }; }
|
|
85
103
|
}
|
|
86
104
|
|
|
87
|
-
function saveMeta(
|
|
88
|
-
const data = { overrides, updated: new Date().toISOString() };
|
|
105
|
+
function saveMeta() {
|
|
106
|
+
const data = { settings: metaSettings, overrides: meta, updated: new Date().toISOString() };
|
|
89
107
|
try {
|
|
90
108
|
fs.mkdirSync(path.dirname(META_FILE), { recursive: true });
|
|
91
109
|
fs.writeFileSync(META_FILE, JSON.stringify(data, null, 2));
|
|
@@ -94,7 +112,9 @@ function saveMeta(overrides) {
|
|
|
94
112
|
}
|
|
95
113
|
}
|
|
96
114
|
|
|
97
|
-
|
|
115
|
+
const loadedMeta = loadMeta();
|
|
116
|
+
let meta = loadedMeta.overrides;
|
|
117
|
+
let metaSettings = loadedMeta.settings;
|
|
98
118
|
|
|
99
119
|
// Override record for one session id (always returns an object, never null).
|
|
100
120
|
// Used by the per-session getters, which can't read the global `meta`
|
|
@@ -112,7 +132,32 @@ function setOverride(id, field, value) {
|
|
|
112
132
|
else delete entry[field];
|
|
113
133
|
if (Object.keys(entry).length) meta[id] = entry;
|
|
114
134
|
else delete meta[id];
|
|
115
|
-
saveMeta(
|
|
135
|
+
saveMeta();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function saveTitle(id, value, source, model, thinkingLevel) {
|
|
139
|
+
if (!id) return;
|
|
140
|
+
const v = (value || '').trim();
|
|
141
|
+
const entry = meta[id] || {};
|
|
142
|
+
if (v) {
|
|
143
|
+
entry.title = v;
|
|
144
|
+
entry.titleSource = source;
|
|
145
|
+
if (source === 'llm' && model) {
|
|
146
|
+
entry.titleModel = model;
|
|
147
|
+
entry.titleThinkingLevel = thinkingLevel || 'off';
|
|
148
|
+
} else {
|
|
149
|
+
delete entry.titleModel;
|
|
150
|
+
delete entry.titleThinkingLevel;
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
delete entry.title;
|
|
154
|
+
delete entry.titleSource;
|
|
155
|
+
delete entry.titleModel;
|
|
156
|
+
delete entry.titleThinkingLevel;
|
|
157
|
+
}
|
|
158
|
+
if (Object.keys(entry).length) meta[id] = entry;
|
|
159
|
+
else delete meta[id];
|
|
160
|
+
saveMeta();
|
|
116
161
|
}
|
|
117
162
|
|
|
118
163
|
// ── Project slug decode ───────────────────────────────
|
|
@@ -177,16 +222,29 @@ function readSessionMeta(file) {
|
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
function scanSessions() {
|
|
180
|
-
let
|
|
181
|
-
try {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
225
|
+
let rootEntries;
|
|
226
|
+
try { rootEntries = fs.readdirSync(SESSIONS_ROOT, { withFileTypes: true }); }
|
|
227
|
+
catch { return []; }
|
|
228
|
+
|
|
229
|
+
const projectDirs = rootEntries
|
|
230
|
+
.filter(entry => entry.isDirectory())
|
|
231
|
+
.map(entry => ({
|
|
232
|
+
name: entry.name,
|
|
233
|
+
path: path.join(SESSIONS_ROOT, entry.name),
|
|
234
|
+
label: decodeProjectSlug(entry.name),
|
|
235
|
+
}));
|
|
236
|
+
if (rootEntries.some(entry => entry.isFile() && entry.name.endsWith('.jsonl'))) {
|
|
237
|
+
projectDirs.unshift({
|
|
238
|
+
name: path.basename(SESSIONS_ROOT),
|
|
239
|
+
path: SESSIONS_ROOT,
|
|
240
|
+
label: CURRENT_CWD || SESSIONS_ROOT,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
185
243
|
|
|
186
244
|
const results = [];
|
|
187
245
|
for (const d of projectDirs) {
|
|
188
|
-
const projectPath =
|
|
189
|
-
const projectLabel =
|
|
246
|
+
const projectPath = d.path;
|
|
247
|
+
const projectLabel = d.label;
|
|
190
248
|
let files;
|
|
191
249
|
try { files = fs.readdirSync(projectPath).filter(f => f.endsWith('.jsonl')); }
|
|
192
250
|
catch { continue; }
|
|
@@ -211,6 +269,9 @@ function scanSessions() {
|
|
|
211
269
|
get isCurrent() { return CURRENT_SESSION_ID !== '' && this.id === CURRENT_SESSION_ID; },
|
|
212
270
|
// User overrides (sidecar meta). cwd: from is the original recorded cwd.
|
|
213
271
|
get titleOverride() { return (overridesFor(this.id).title || '').trim(); },
|
|
272
|
+
get titleSource() { return overridesFor(this.id).titleSource || (this.titleOverride ? 'manual' : ''); },
|
|
273
|
+
get titleModel() { return overridesFor(this.id).titleModel || ''; },
|
|
274
|
+
get titleThinkingLevel() { return overridesFor(this.id).titleThinkingLevel || ''; },
|
|
214
275
|
get cwdOverride() { return (overridesFor(this.id).cwd || '').trim(); },
|
|
215
276
|
// Name shown in the list: custom title > first prompt > id fallback.
|
|
216
277
|
get title() {
|
|
@@ -225,13 +286,24 @@ function scanSessions() {
|
|
|
225
286
|
return results;
|
|
226
287
|
}
|
|
227
288
|
|
|
289
|
+
function cleanStaleFavorites(knownSessions = sessions) {
|
|
290
|
+
const knownIds = new Set(knownSessions.map(session => session.id));
|
|
291
|
+
const stale = [...favorites].filter(id => !knownIds.has(id));
|
|
292
|
+
if (stale.length > 0) {
|
|
293
|
+
for (const id of stale) favorites.delete(id);
|
|
294
|
+
saveFavorites(favorites);
|
|
295
|
+
}
|
|
296
|
+
return stale;
|
|
297
|
+
}
|
|
298
|
+
|
|
228
299
|
// ── State ─────────────────────────────────────────────
|
|
229
300
|
let sessions = [];
|
|
230
301
|
let tabs = ['★ Favorites', 'Today', 'Here', 'All'];
|
|
231
302
|
let tabIdx = 0;
|
|
232
303
|
let cursor = 0;
|
|
233
304
|
let filter = '';
|
|
234
|
-
let
|
|
305
|
+
let notice = '';
|
|
306
|
+
let mode = 'list'; // list | filter | details | edit | browse | model
|
|
235
307
|
// edit mode state (title text editor)
|
|
236
308
|
let editField = ''; // 'title' | 'cwd'
|
|
237
309
|
let editBuffer = '';
|
|
@@ -241,6 +313,185 @@ let editTarget = null; // the session being edited
|
|
|
241
313
|
let browseDir = ''; // directory currently being browsed (absolute)
|
|
242
314
|
let browseEntries = []; // [{ label, kind:'use'|'up'|'dir', path }]
|
|
243
315
|
let browseSel = 0; // selected row in browseEntries
|
|
316
|
+
// LLM title generation state
|
|
317
|
+
let models = [];
|
|
318
|
+
let modelSel = 0;
|
|
319
|
+
let modelFilter = '';
|
|
320
|
+
let modelStatus = '';
|
|
321
|
+
let modelRequestId = 0;
|
|
322
|
+
let modelThinking = new Set();
|
|
323
|
+
let modelEffort = 'off';
|
|
324
|
+
const EFFORT_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
325
|
+
const TITLE_GENERATION_CONCURRENCY = 3;
|
|
326
|
+
let generationChildren = new Map(); // target id -> ChildProcess
|
|
327
|
+
let generationCancelled = false;
|
|
328
|
+
let settingsPendingTarget = null;
|
|
329
|
+
let generationJobs = []; // queued [{ target, model, effort }]
|
|
330
|
+
let generationActive = false;
|
|
331
|
+
let handingOff = false;
|
|
332
|
+
|
|
333
|
+
function configuredPiModel() {
|
|
334
|
+
try {
|
|
335
|
+
const settings = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
|
336
|
+
if (settings.defaultProvider && settings.defaultModel) {
|
|
337
|
+
return `${settings.defaultProvider}/${settings.defaultModel}`;
|
|
338
|
+
}
|
|
339
|
+
} catch {}
|
|
340
|
+
return '';
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseModelEntries(output) {
|
|
344
|
+
return output.split(/\r?\n/).slice(1).map(line => {
|
|
345
|
+
const cols = line.trim().split(/\s+/);
|
|
346
|
+
return cols.length >= 6 ? { id: `${cols[0]}/${cols[1]}`, thinking: cols[4] === 'yes' } : null;
|
|
347
|
+
}).filter(Boolean);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function parseModelList(output) {
|
|
351
|
+
return parseModelEntries(output).map(entry => entry.id);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function textFromContent(content) {
|
|
355
|
+
if (typeof content === 'string') return content.trim();
|
|
356
|
+
if (!Array.isArray(content)) return '';
|
|
357
|
+
return content.filter(x => x && x.type === 'text' && x.text).map(x => x.text.trim()).filter(Boolean).join('\n');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const TITLE_PROMPT = `Generate a durable, factual topic label for this coding session.
|
|
361
|
+
Describe what the session is about, not what the assistant completed or reported.
|
|
362
|
+
Do not summarize or quote the conversation.
|
|
363
|
+
|
|
364
|
+
Output exactly one line:
|
|
365
|
+
<3-8 word title>: <6-14 word subtitle>
|
|
366
|
+
|
|
367
|
+
Title requirements (highest priority):
|
|
368
|
+
- The title must stand alone because the subtitle may be truncated in the session list
|
|
369
|
+
- Use a base-form action such as Review, Rebase, Refactor, Resolve, Fix, Add, Remove, or Investigate
|
|
370
|
+
- Put the core subject, action, exact target, and distinguishing scope in the title
|
|
371
|
+
- Name human-readable identifiers whenever available: PR number or name, branch, class, function, filename, package, command, or issue
|
|
372
|
+
- Describe the central task or topic independent of its progress or completion state
|
|
373
|
+
- Never encode status or bookkeeping such as completed, done, finished, committed, updated, tests passed, or test counts
|
|
374
|
+
- Use factual, impersonal wording; never use I, we, you, I'll, I will, let's, or going to
|
|
375
|
+
- Never describe the assistant's plan, promise, tool usage, response, next step, or accomplishments
|
|
376
|
+
- Never copy a sentence from the conversation
|
|
377
|
+
- Omit commit hashes; unlike PR numbers, they are not useful human-readable identifiers
|
|
378
|
+
- When referring to a path, prefer the filename alone; include directories only when needed to disambiguate
|
|
379
|
+
- Count every slash-separated path component as one word toward the 3-8 word limit (for example, src/auth/Login.ts counts as three words)
|
|
380
|
+
- Prefer specific technical nouns over generic descriptions
|
|
381
|
+
|
|
382
|
+
Subtitle requirements:
|
|
383
|
+
- Use 6-14 words in one concise, factual imperative sentence
|
|
384
|
+
- Begin with a base-form action verb such as Add, Apply, Check, Extract, Implement, Inspect, Preserve, or Remove
|
|
385
|
+
- Use parallel imperative verb phrases when naming multiple actions
|
|
386
|
+
- Describe technical scope, constraints, or subject matter not already in the title
|
|
387
|
+
- Avoid report-style verbs such as covers, adds, identifies, resolves, or completed
|
|
388
|
+
- Do not report progress, completion, commit history, todo updates, or test counts unless testing is the session's central topic
|
|
389
|
+
- Use assistant evidence only to clarify the topic, target, and scope; ignore its status and process wording
|
|
390
|
+
- Do not repeat or rephrase the title
|
|
391
|
+
|
|
392
|
+
Examples:
|
|
393
|
+
Resolve sample-service Audit Findings: Apply focused refactors, tighten authorization, and bound cache growth.
|
|
394
|
+
Review desktop-client Concurrency Changes: Check lifecycle, thread safety, and event ownership.
|
|
395
|
+
Review PR #42 Token Refresh: Inspect refresh concurrency and add regression coverage.
|
|
396
|
+
Rebase feature/auth onto main: Resolve middleware conflicts and preserve session handling compatibility.
|
|
397
|
+
Refactor SessionManager Retry Logic: Extract backoff policy and remove duplicated state transitions.
|
|
398
|
+
|
|
399
|
+
General requirements:
|
|
400
|
+
- Use exactly one colon to separate the title and subtitle
|
|
401
|
+
- No quotation marks
|
|
402
|
+
- Output only the title and subtitle`;
|
|
403
|
+
|
|
404
|
+
function boundTitleContext(text, maxChars) {
|
|
405
|
+
if (text.length <= maxChars) return text;
|
|
406
|
+
const marker = '\n\n[earlier context omitted]\n\n';
|
|
407
|
+
if (maxChars <= marker.length) return text.slice(0, maxChars);
|
|
408
|
+
const available = maxChars - marker.length;
|
|
409
|
+
const head = Math.floor(available * 0.4);
|
|
410
|
+
return text.slice(0, head) + marker + text.slice(-(available - head));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function buildTitlePrompt(file, maxChars = 16000) {
|
|
414
|
+
const userRequests = [];
|
|
415
|
+
const assistantReports = [];
|
|
416
|
+
let sessionCwd = '';
|
|
417
|
+
try {
|
|
418
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
419
|
+
if (!line.trim()) continue;
|
|
420
|
+
let obj;
|
|
421
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
422
|
+
if (obj.type === 'session' && !sessionCwd && typeof obj.cwd === 'string') {
|
|
423
|
+
sessionCwd = obj.cwd.trim();
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (obj.type !== 'message' || !obj.message) continue;
|
|
427
|
+
const { role, content, stopReason } = obj.message;
|
|
428
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
429
|
+
const text = textFromContent(content);
|
|
430
|
+
if (!text) continue;
|
|
431
|
+
if (role === 'user') {
|
|
432
|
+
userRequests.push(`User request ${userRequests.length + 1}: ${text}`);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const hasToolCall = Array.isArray(content) && content.some(x => x && x.type === 'toolCall');
|
|
436
|
+
if (!hasToolCall && stopReason !== 'error' && stopReason !== 'aborted') {
|
|
437
|
+
assistantReports.push(`Assistant evidence ${assistantReports.length + 1}: ${text}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
} catch (e) { throw new Error(`could not read session: ${e.message}`); }
|
|
441
|
+
if (!userRequests.length && !assistantReports.length) throw new Error('no conversation text found');
|
|
442
|
+
|
|
443
|
+
const userText = userRequests.join('\n\n');
|
|
444
|
+
const assistantText = assistantReports.join('\n\n');
|
|
445
|
+
const totalBudget = Math.max(0, maxChars);
|
|
446
|
+
let userBudget = Math.min(userText.length, Math.floor(totalBudget * 0.7));
|
|
447
|
+
let assistantBudget = Math.min(assistantText.length, totalBudget - userBudget);
|
|
448
|
+
let remaining = totalBudget - userBudget - assistantBudget;
|
|
449
|
+
const extraUser = Math.min(remaining, userText.length - userBudget);
|
|
450
|
+
userBudget += extraUser;
|
|
451
|
+
remaining -= extraUser;
|
|
452
|
+
assistantBudget += Math.min(remaining, assistantText.length - assistantBudget);
|
|
453
|
+
|
|
454
|
+
const sections = [];
|
|
455
|
+
const cwdParts = sessionCwd.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean);
|
|
456
|
+
if (cwdParts.length) sections.push(`Session context:\nWorking directory: ${cwdParts[cwdParts.length - 1]}`);
|
|
457
|
+
if (userText) sections.push(`User requests (authoritative task context):\n${boundTitleContext(userText, userBudget)}`);
|
|
458
|
+
if (assistantText) sections.push(`Assistant evidence (use only for topic, target, and scope; ignore completion and process wording):\n${boundTitleContext(assistantText, assistantBudget)}`);
|
|
459
|
+
return `${TITLE_PROMPT}\n\nConversation evidence:\n${sections.join('\n\n')}`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cleanGeneratedTitle(output) {
|
|
463
|
+
const safeOutput = String(output || '')
|
|
464
|
+
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
465
|
+
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '');
|
|
466
|
+
const lines = safeOutput.split(/\r?\n/)
|
|
467
|
+
.map(line => line.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim())
|
|
468
|
+
.filter(Boolean);
|
|
469
|
+
if (lines.length !== 1) throw new Error('model must return exactly one line');
|
|
470
|
+
const title = lines[0]
|
|
471
|
+
.replace(/^title\s*:\s*/i, '')
|
|
472
|
+
.replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, '')
|
|
473
|
+
.trim();
|
|
474
|
+
const separator = title.indexOf(':');
|
|
475
|
+
if (separator <= 0 || separator !== title.lastIndexOf(':') || separator === title.length - 1) {
|
|
476
|
+
throw new Error('model must return one title and subtitle separated by a colon');
|
|
477
|
+
}
|
|
478
|
+
return [...title].slice(0, 200).join('');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function titleGenerationArgs(model, effort, prompt) {
|
|
482
|
+
return [
|
|
483
|
+
'--print', '--no-session', '--no-context-files',
|
|
484
|
+
'--no-skills', '--no-prompt-templates', '--no-tools',
|
|
485
|
+
'--system-prompt', 'You generate durable factual topic labels, not completion reports or conversation summaries. Use base-form imperative actions in both the title and subtitle. Describe the session’s central task independent of progress, using assistant evidence only for the topic, target, and scope. Never copy status, process, accomplishments, plans, promises, tool usage, first-person wording, or report-style phrasing. Follow the user instructions exactly and output only the requested single-line title and subtitle.',
|
|
486
|
+
'--model', model, '--thinking', effort, prompt,
|
|
487
|
+
];
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function titleGenerationBlocker(target) {
|
|
491
|
+
return target && target.titleSource === 'manual'
|
|
492
|
+
? 'manual title exists; clear it with e before generating'
|
|
493
|
+
: '';
|
|
494
|
+
}
|
|
244
495
|
|
|
245
496
|
// ── Filtering by tab ──────────────────────────────────
|
|
246
497
|
function visibleSessions() {
|
|
@@ -365,6 +616,7 @@ function visLen(s) {
|
|
|
365
616
|
function render() {
|
|
366
617
|
if (mode === 'edit') return renderEdit();
|
|
367
618
|
if (mode === 'browse') return renderBrowse();
|
|
619
|
+
if (mode === 'model') return renderModelPicker();
|
|
368
620
|
if (mode === 'details') return renderDetails();
|
|
369
621
|
renderList();
|
|
370
622
|
}
|
|
@@ -376,7 +628,8 @@ function renderList() {
|
|
|
376
628
|
|
|
377
629
|
// Header
|
|
378
630
|
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' pi session bookmarks ' + A.R;
|
|
379
|
-
out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R
|
|
631
|
+
out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R;
|
|
632
|
+
out += '\n\n';
|
|
380
633
|
|
|
381
634
|
// Tabs
|
|
382
635
|
let tabLine = ' ';
|
|
@@ -424,14 +677,22 @@ function renderList() {
|
|
|
424
677
|
const sel = i === cursor;
|
|
425
678
|
const star = s.favored ? A.yel + '★' + A.R : ' ';
|
|
426
679
|
const arrow = sel ? A.cyn + '▶' + A.R : ' ';
|
|
680
|
+
const currentJob = generationActive && generationChildren.has(s.id);
|
|
681
|
+
const queuedJob = generationActive && generationJobs.some(job => job.target.id === s.id);
|
|
427
682
|
const ts = A.gry + fmtTs(s.mtime) + A.R;
|
|
428
683
|
const cwd = A.mag + pad(shortCwd(s.effectiveCwd), 14) + A.R;
|
|
429
684
|
const badge = s.isCurrent ? A.grn + A.B + '[NOW]' + A.R + ' ' : '';
|
|
430
|
-
//
|
|
431
|
-
const
|
|
432
|
-
|
|
685
|
+
// Generation state and title provenance share one stable marker column.
|
|
686
|
+
const marker = currentJob
|
|
687
|
+
? A.yel + '↻' + A.R + ' '
|
|
688
|
+
: queuedJob
|
|
689
|
+
? A.gry + '…' + A.R + ' '
|
|
690
|
+
: s.titleOverride
|
|
691
|
+
? A.cyn + (s.titleSource === 'llm' ? '◆' : '✎') + A.R + ' '
|
|
692
|
+
: '';
|
|
693
|
+
const titleMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0) - (marker ? 2 : 0));
|
|
433
694
|
const title = trunc(s.title, titleMax);
|
|
434
|
-
const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${
|
|
695
|
+
const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${marker}${title}`;
|
|
435
696
|
if (sel) {
|
|
436
697
|
const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
|
|
437
698
|
out += A.bgBlu + rowText + pad2 + A.R + '\n';
|
|
@@ -450,16 +711,222 @@ function renderList() {
|
|
|
450
711
|
out += ' ' + A.D + '↑↓' + A.R + ' move '
|
|
451
712
|
+ A.D + 'Tab' + A.R + ' tab '
|
|
452
713
|
+ A.D + 'f' + A.R + ' star '
|
|
453
|
-
+ A.D + '
|
|
714
|
+
+ A.D + 'x' + A.R + ' clean '
|
|
715
|
+
+ A.D + 'Enter' + A.R + ' current '
|
|
716
|
+
+ A.D + 'o' + A.R + ' original '
|
|
454
717
|
+ A.D + 'e' + A.R + ' edit name '
|
|
718
|
+
+ A.D + 'g' + A.R + ' queue title '
|
|
719
|
+
+ A.D + 'G' + A.R + ' settings '
|
|
455
720
|
+ A.D + 'p' + A.R + ' edit cwd '
|
|
456
721
|
+ A.D + 'd' + A.R + ' details '
|
|
457
722
|
+ A.D + '/' + A.R + ' search '
|
|
458
723
|
+ A.D + 'q' + A.R + ' quit' + A.R + '\n';
|
|
724
|
+
if (notice) out += ' ' + A.cyn + notice + A.R + '\n';
|
|
459
725
|
|
|
460
726
|
process.stdout.write(out);
|
|
461
727
|
}
|
|
462
728
|
|
|
729
|
+
function filteredModels() {
|
|
730
|
+
const q = modelFilter.toLowerCase();
|
|
731
|
+
return q ? models.filter(m => m.toLowerCase().includes(q)) : models;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function renderModelPicker() {
|
|
735
|
+
const W = process.stdout.columns || 100;
|
|
736
|
+
const H = process.stdout.rows || 30;
|
|
737
|
+
const list = filteredModels();
|
|
738
|
+
if (modelSel >= list.length) modelSel = Math.max(0, list.length - 1);
|
|
739
|
+
let out = A.clr;
|
|
740
|
+
out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' title generation settings' + A.R + '\n\n';
|
|
741
|
+
if (settingsPendingTarget) out += ' ' + A.D + 'generate' + A.R + ' ' + trunc(settingsPendingTarget.title, W - 14) + '\n';
|
|
742
|
+
out += ' ' + A.D + 'filter ' + A.R + A.cyn + modelFilter + A.R + (modelFilter ? A.I + ' ' + A.R : '') + '\n';
|
|
743
|
+
out += A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
744
|
+
if (modelStatus) out += '\n ' + A.D + modelStatus + A.R + '\n';
|
|
745
|
+
else if (!list.length) out += '\n ' + A.D + '(no matching models)' + A.R + '\n';
|
|
746
|
+
else {
|
|
747
|
+
const effortWidths = EFFORT_LEVELS.map(level => level.length + 2);
|
|
748
|
+
const effortTotal = effortWidths.reduce((sum, width) => sum + width, 0);
|
|
749
|
+
const modelWidth = Math.max(12, W - effortTotal - 5);
|
|
750
|
+
out += ' ' + A.D + pad('model', modelWidth) + pad('effort', effortTotal) + A.R + '\n';
|
|
751
|
+
const usable = Math.max(3, H - 10);
|
|
752
|
+
const start = Math.max(0, Math.min(modelSel - Math.floor(usable / 2), list.length - usable));
|
|
753
|
+
for (let i = start; i < Math.min(list.length, start + usable); i++) {
|
|
754
|
+
const selected = i === modelSel;
|
|
755
|
+
const supportsEffort = modelThinking.has(list[i]);
|
|
756
|
+
let row = ' ' + (selected ? A.cyn + '▶' + A.R : ' ') + ' ';
|
|
757
|
+
row += (selected ? A.B : '') + pad(trunc(list[i], modelWidth), modelWidth) + (selected ? A.R : '');
|
|
758
|
+
for (let j = 0; j < EFFORT_LEVELS.length; j++) {
|
|
759
|
+
const level = EFFORT_LEVELS[j];
|
|
760
|
+
const available = supportsEffort || level === 'off';
|
|
761
|
+
const cell = pad(available ? level : '—', effortWidths[j]);
|
|
762
|
+
if (selected && level === modelEffort) row += A.bgBlu + A.B + cell + A.R;
|
|
763
|
+
else row += available ? A.D + cell + A.R : A.gry + cell + A.R;
|
|
764
|
+
}
|
|
765
|
+
out += row + '\n';
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
769
|
+
out += ' ' + A.yel + 'Sends up to 16 KB of session text; provider charges may apply.' + A.R + '\n';
|
|
770
|
+
out += ' ' + A.D + '↑↓' + A.R + ' model ' + A.D + '←→' + A.R + ' effort '
|
|
771
|
+
+ A.D + 'type' + A.R + ' filter ' + A.grn + A.B + 'Enter' + A.R + ' save '
|
|
772
|
+
+ A.D + 'Esc' + A.R + ' cancel\n';
|
|
773
|
+
process.stdout.write(out);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
function openGenerationSettings(pendingTarget = null) {
|
|
777
|
+
settingsPendingTarget = pendingTarget;
|
|
778
|
+
modelFilter = '';
|
|
779
|
+
modelSel = 0;
|
|
780
|
+
mode = 'model';
|
|
781
|
+
modelStatus = 'Loading models from pi…';
|
|
782
|
+
render();
|
|
783
|
+
let stdout = '', stderr = '';
|
|
784
|
+
const requestId = ++modelRequestId;
|
|
785
|
+
const child = spawn('pi', ['--list-models'], {
|
|
786
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
787
|
+
env: PI_ENV,
|
|
788
|
+
});
|
|
789
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
790
|
+
child.stderr.on('data', d => { stderr += d; });
|
|
791
|
+
child.on('error', e => {
|
|
792
|
+
if (mode === 'model' && requestId === modelRequestId) {
|
|
793
|
+
modelStatus = `Could not run pi: ${e.message}`;
|
|
794
|
+
render();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
child.on('close', code => {
|
|
798
|
+
if (mode !== 'model' || requestId !== modelRequestId) return;
|
|
799
|
+
const entries = code === 0 ? parseModelEntries(stdout) : [];
|
|
800
|
+
models = entries.map(entry => entry.id);
|
|
801
|
+
modelThinking = new Set(entries.filter(entry => entry.thinking).map(entry => entry.id));
|
|
802
|
+
modelStatus = models.length ? '' : `Could not list models${stderr.trim() ? `: ${stderr.trim()}` : ''}`;
|
|
803
|
+
const preferred = [metaSettings.titleModel, configuredPiModel()].filter(Boolean);
|
|
804
|
+
const idx = preferred.map(m => models.indexOf(m)).find(i => i >= 0);
|
|
805
|
+
modelSel = idx !== undefined ? idx : 0;
|
|
806
|
+
modelEffort = EFFORT_LEVELS.includes(metaSettings.titleThinkingLevel) ? metaSettings.titleThinkingLevel : 'off';
|
|
807
|
+
if (!modelThinking.has(models[modelSel])) modelEffort = 'off';
|
|
808
|
+
render();
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function takeGenerationBatch(queue, activeCount, limit = TITLE_GENERATION_CONCURRENCY) {
|
|
813
|
+
const available = Math.max(0, limit - activeCount);
|
|
814
|
+
return queue.splice(0, available);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function enqueueTitle(target) {
|
|
818
|
+
if (!target) return;
|
|
819
|
+
const blocked = titleGenerationBlocker(target);
|
|
820
|
+
if (blocked) { notice = blocked; return; }
|
|
821
|
+
if (!metaSettings.titleModel) return openGenerationSettings(target);
|
|
822
|
+
if (generationChildren.has(target.id) || generationJobs.some(job => job.target.id === target.id)) return;
|
|
823
|
+
|
|
824
|
+
generationJobs.push({
|
|
825
|
+
target,
|
|
826
|
+
model: metaSettings.titleModel,
|
|
827
|
+
effort: metaSettings.titleThinkingLevel || 'off',
|
|
828
|
+
});
|
|
829
|
+
if (!generationActive) {
|
|
830
|
+
generationCancelled = false;
|
|
831
|
+
generationActive = true;
|
|
832
|
+
}
|
|
833
|
+
pumpTitleGeneration();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function finishGeneration() {
|
|
837
|
+
generationActive = false;
|
|
838
|
+
generationJobs = [];
|
|
839
|
+
generationChildren.clear();
|
|
840
|
+
generationCancelled = false;
|
|
841
|
+
if (!handingOff) render();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function terminateChild(child) {
|
|
845
|
+
child.kill('SIGTERM');
|
|
846
|
+
const force = setTimeout(() => {
|
|
847
|
+
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
|
848
|
+
}, 1000);
|
|
849
|
+
force.unref();
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function cancelGeneration() {
|
|
853
|
+
if (!generationActive) return false;
|
|
854
|
+
generationCancelled = true;
|
|
855
|
+
generationJobs = [];
|
|
856
|
+
notice = 'title generation cancelled';
|
|
857
|
+
for (const child of generationChildren.values()) terminateChild(child);
|
|
858
|
+
if (generationChildren.size === 0) finishGeneration();
|
|
859
|
+
else if (!handingOff) render();
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function pumpTitleGeneration() {
|
|
864
|
+
if (generationCancelled) {
|
|
865
|
+
if (generationChildren.size === 0) finishGeneration();
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
while (generationJobs.length && generationChildren.size < TITLE_GENERATION_CONCURRENCY) {
|
|
870
|
+
const batch = takeGenerationBatch(generationJobs, generationChildren.size);
|
|
871
|
+
for (const job of batch) startTitleGeneration(job);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (generationJobs.length === 0 && generationChildren.size === 0) finishGeneration();
|
|
875
|
+
else if (!handingOff) render();
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function startTitleGeneration(job) {
|
|
879
|
+
let prompt;
|
|
880
|
+
try { prompt = buildTitlePrompt(job.target.file); }
|
|
881
|
+
catch (e) { notice = `title generation failed: ${e.message}`; return; }
|
|
882
|
+
|
|
883
|
+
let stdout = '', settled = false, timedOut = false;
|
|
884
|
+
const child = spawn('pi', titleGenerationArgs(job.model, job.effort, prompt), {
|
|
885
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
886
|
+
env: PI_ENV,
|
|
887
|
+
});
|
|
888
|
+
generationChildren.set(job.target.id, child);
|
|
889
|
+
const timer = setTimeout(() => {
|
|
890
|
+
if (!settled) { timedOut = true; terminateChild(child); }
|
|
891
|
+
}, 120000);
|
|
892
|
+
child.stdout.on('data', d => { stdout += d; });
|
|
893
|
+
child.stderr.resume();
|
|
894
|
+
const complete = () => {
|
|
895
|
+
if (settled) return;
|
|
896
|
+
settled = true;
|
|
897
|
+
clearTimeout(timer);
|
|
898
|
+
if (generationChildren.get(job.target.id) === child) generationChildren.delete(job.target.id);
|
|
899
|
+
pumpTitleGeneration();
|
|
900
|
+
};
|
|
901
|
+
child.on('error', () => {
|
|
902
|
+
notice = 'title generation failed: could not start pi';
|
|
903
|
+
complete();
|
|
904
|
+
});
|
|
905
|
+
child.on('close', code => {
|
|
906
|
+
if (settled) return;
|
|
907
|
+
if (code === 0 && !generationCancelled) {
|
|
908
|
+
try {
|
|
909
|
+
const title = cleanGeneratedTitle(stdout);
|
|
910
|
+
const current = overridesFor(job.target.id);
|
|
911
|
+
if (current.title && current.titleSource !== 'llm') {
|
|
912
|
+
notice = 'generated title discarded: manual title now exists';
|
|
913
|
+
return complete();
|
|
914
|
+
}
|
|
915
|
+
saveTitle(job.target.id, title, 'llm', job.model, job.effort);
|
|
916
|
+
notice = `generated title: ${trunc(title, 72)}`;
|
|
917
|
+
return complete();
|
|
918
|
+
} catch {
|
|
919
|
+
notice = 'title generation failed: model returned no usable title';
|
|
920
|
+
}
|
|
921
|
+
} else if (!generationCancelled) {
|
|
922
|
+
notice = timedOut
|
|
923
|
+
? 'title generation timed out after 120 seconds'
|
|
924
|
+
: `title generation failed: pi exited ${code ?? 'without a status'}`;
|
|
925
|
+
}
|
|
926
|
+
complete();
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
|
|
463
930
|
// Inline editor for a session's title or cwd. Renders a small focused panel
|
|
464
931
|
// so the user always sees which session + field they're changing.
|
|
465
932
|
function renderEdit() {
|
|
@@ -518,12 +985,21 @@ function renderDetails() {
|
|
|
518
985
|
out += row('id', s.id, A.cyn);
|
|
519
986
|
if (s.isCurrent) out += row('current', '● this is the attached pi session', A.grn);
|
|
520
987
|
out += row('starred', s.favored ? '★ yes' : '☆ no', s.favored ? A.yel : A.gry);
|
|
521
|
-
out += row('title', s.title + (s.titleOverride ? A.cyn +
|
|
988
|
+
out += row('title', s.title + (s.titleOverride ? A.cyn + ` (${s.titleSource === 'llm' ? 'generated' : 'custom'})` + A.R : ''));
|
|
989
|
+
if (s.titleModel) {
|
|
990
|
+
out += row('title model', s.titleModel, A.cyn);
|
|
991
|
+
out += row('title effort', s.titleThinkingLevel || 'off', A.cyn);
|
|
992
|
+
}
|
|
522
993
|
out += row('started', new Date(s.ts).toLocaleString());
|
|
523
994
|
out += row('updated', new Date(s.mtime).toLocaleString());
|
|
524
995
|
out += row('cwd', s.effectiveCwd + (s.cwdOverride ? A.cyn + ' (custom)' + A.R : ''), A.mag);
|
|
525
996
|
if (s.cwdOverride) out += row('recorded', s.cwd, A.gry);
|
|
526
997
|
out += row('project', s.project);
|
|
998
|
+
const defaults = loadResumeDefaults();
|
|
999
|
+
out += row('Enter uses', defaults
|
|
1000
|
+
? `${defaults.model || 'session model'} / thinking ${defaults.thinking || 'session value'}`
|
|
1001
|
+
: 'session model and thinking', A.grn);
|
|
1002
|
+
out += row('o uses', 'session-recorded model and thinking', A.gry);
|
|
527
1003
|
out += row('size', fmtSize(s.size));
|
|
528
1004
|
out += row('file', s.file, A.gry);
|
|
529
1005
|
out += '\n ' + A.D + 'first prompt:' + A.R + '\n';
|
|
@@ -532,9 +1008,11 @@ function renderDetails() {
|
|
|
532
1008
|
for (const line of wrapped) out += ' ' + line + '\n';
|
|
533
1009
|
|
|
534
1010
|
out += '\n' + A.gry + '─'.repeat(Math.max(1, W - 1)) + A.R + '\n';
|
|
535
|
-
out += ' ' + A.D + 'Enter' + A.R + '
|
|
1011
|
+
out += ' ' + A.D + 'Enter' + A.R + ' current defaults '
|
|
1012
|
+
+ A.D + 'o' + A.R + ' original settings '
|
|
536
1013
|
+ A.D + 'f' + A.R + ' star '
|
|
537
1014
|
+ A.D + 'e' + A.R + ' edit name '
|
|
1015
|
+
+ A.D + 'g' + A.R + ' generate title '
|
|
538
1016
|
+ A.D + 'p' + A.R + ' edit cwd '
|
|
539
1017
|
+ A.D + 'c' + A.R + ' copy id '
|
|
540
1018
|
+ A.D + 'Esc/d/q' + A.R + ' back' + A.R + '\n';
|
|
@@ -567,9 +1045,16 @@ function setupInput() {
|
|
|
567
1045
|
|
|
568
1046
|
process.stdin.on('keypress', (str, key) => {
|
|
569
1047
|
try {
|
|
1048
|
+
if (generationActive && key.ctrl && key.name === 'c') return quit();
|
|
1049
|
+
if (generationActive && (mode === 'list' || mode === 'details') && (key.name === 'escape' || key.name === 'q')) {
|
|
1050
|
+
if (generationCancelled) return quit();
|
|
1051
|
+
cancelGeneration();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
570
1054
|
if (mode === 'filter') handleFilter(str, key);
|
|
571
1055
|
else if (mode === 'edit') handleEdit(str, key);
|
|
572
1056
|
else if (mode === 'browse') handleBrowse(str, key);
|
|
1057
|
+
else if (mode === 'model') handleModelPicker(str, key);
|
|
573
1058
|
else if (mode === 'details') handleDetails(str, key);
|
|
574
1059
|
else handleList(str, key);
|
|
575
1060
|
render();
|
|
@@ -593,7 +1078,10 @@ function handleList(str, key) {
|
|
|
593
1078
|
const k = key.name;
|
|
594
1079
|
|
|
595
1080
|
if (key.ctrl && k === 'c') return quit();
|
|
596
|
-
if (k === 'q')
|
|
1081
|
+
if (k === 'q') {
|
|
1082
|
+
if (filter) { filter = ''; cursor = 0; return; }
|
|
1083
|
+
return quit();
|
|
1084
|
+
}
|
|
597
1085
|
// Esc: clears filter first (one-step back-out), otherwise quits.
|
|
598
1086
|
// Matches `q` so muscle memory works either way.
|
|
599
1087
|
if (k === 'escape') {
|
|
@@ -618,9 +1106,16 @@ function handleList(str, key) {
|
|
|
618
1106
|
saveFavorites(favorites);
|
|
619
1107
|
}
|
|
620
1108
|
}
|
|
621
|
-
else if (k === 'return') resumeSession(list[cursor]);
|
|
1109
|
+
else if (k === 'return') resumeSession(list[cursor], true);
|
|
1110
|
+
else if (k === 'o') resumeSession(list[cursor], false);
|
|
1111
|
+
else if (k === 'x') {
|
|
1112
|
+
const removed = cleanStaleFavorites(sessions).length;
|
|
1113
|
+
notice = removed ? `removed ${removed} stale favorite${removed === 1 ? '' : 's'}` : 'no stale favorites';
|
|
1114
|
+
}
|
|
622
1115
|
else if (k === 'd') { if (list[cursor]) mode = 'details'; }
|
|
623
1116
|
else if (k === 'e') startEdit(list[cursor], 'title');
|
|
1117
|
+
else if (k === 'g' && (str === 'G' || key.shift)) openGenerationSettings();
|
|
1118
|
+
else if (k === 'g') enqueueTitle(list[cursor]);
|
|
624
1119
|
else if (k === 'p') startBrowse(list[cursor]);
|
|
625
1120
|
else if (str === '/') { mode = 'filter'; }
|
|
626
1121
|
else if (k === 'r') { sessions = scanSessions(); cursor = 0; }
|
|
@@ -632,7 +1127,8 @@ function handleList(str, key) {
|
|
|
632
1127
|
|
|
633
1128
|
function handleFilter(str, key) {
|
|
634
1129
|
const k = key.name;
|
|
635
|
-
if (k === 'return'
|
|
1130
|
+
if (k === 'return') { mode = 'list'; return; }
|
|
1131
|
+
if (k === 'escape' || k === 'q') { filter = ''; cursor = 0; mode = 'list'; return; }
|
|
636
1132
|
if (k === 'backspace') { filter = filter.slice(0, -1); cursor = 0; return; }
|
|
637
1133
|
if (key.ctrl && k === 'c') return quit();
|
|
638
1134
|
if (key.ctrl && k === 'u') { filter = ''; cursor = 0; return; }
|
|
@@ -644,7 +1140,8 @@ function handleDetails(str, key) {
|
|
|
644
1140
|
const s = visibleSessions()[cursor];
|
|
645
1141
|
if (k === 'escape' || k === 'q' || k === 'd') { mode = 'list'; return; }
|
|
646
1142
|
if (key.ctrl && k === 'c') return quit();
|
|
647
|
-
if (k === 'return') return resumeSession(s);
|
|
1143
|
+
if (k === 'return') return resumeSession(s, true);
|
|
1144
|
+
if (k === 'o') return resumeSession(s, false);
|
|
648
1145
|
if (k === 'f' && s) {
|
|
649
1146
|
if (s.favored) favorites.delete(s.id); else favorites.add(s.id);
|
|
650
1147
|
saveFavorites(favorites);
|
|
@@ -654,9 +1151,52 @@ function handleDetails(str, key) {
|
|
|
654
1151
|
tryCopy(s.id);
|
|
655
1152
|
}
|
|
656
1153
|
if (k === 'e' && s) startEdit(s, 'title');
|
|
1154
|
+
if (k === 'g' && (str === 'G' || key.shift)) openGenerationSettings();
|
|
1155
|
+
else if (k === 'g' && s) enqueueTitle(s);
|
|
657
1156
|
if (k === 'p' && s) startBrowse(s);
|
|
658
1157
|
}
|
|
659
1158
|
|
|
1159
|
+
function handleModelPicker(str, key) {
|
|
1160
|
+
const k = key.name;
|
|
1161
|
+
const list = filteredModels();
|
|
1162
|
+
if (key.ctrl && k === 'c') return quit();
|
|
1163
|
+
if (k === 'escape' || k === 'q') {
|
|
1164
|
+
modelRequestId++;
|
|
1165
|
+
mode = 'list'; settingsPendingTarget = null; modelStatus = ''; return;
|
|
1166
|
+
}
|
|
1167
|
+
if (k === 'up' || k === 'k') {
|
|
1168
|
+
modelSel = Math.max(0, modelSel - 1);
|
|
1169
|
+
if (!modelThinking.has(list[modelSel])) modelEffort = 'off';
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
if (k === 'down' || k === 'j') {
|
|
1173
|
+
modelSel = Math.min(list.length - 1, modelSel + 1);
|
|
1174
|
+
if (!modelThinking.has(list[modelSel])) modelEffort = 'off';
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if ((k === 'left' || k === 'right') && modelThinking.has(list[modelSel])) {
|
|
1178
|
+
const delta = k === 'right' ? 1 : -1;
|
|
1179
|
+
const idx = EFFORT_LEVELS.indexOf(modelEffort);
|
|
1180
|
+
modelEffort = EFFORT_LEVELS[(idx + delta + EFFORT_LEVELS.length) % EFFORT_LEVELS.length];
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (k === 'return') {
|
|
1184
|
+
if (!modelStatus.startsWith('Loading') && list[modelSel]) {
|
|
1185
|
+
metaSettings.titleModel = list[modelSel];
|
|
1186
|
+
metaSettings.titleThinkingLevel = modelEffort;
|
|
1187
|
+
saveMeta();
|
|
1188
|
+
const pending = settingsPendingTarget;
|
|
1189
|
+
settingsPendingTarget = null;
|
|
1190
|
+
mode = 'list';
|
|
1191
|
+
if (pending) enqueueTitle(pending);
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (k === 'backspace') { modelFilter = modelFilter.slice(0, -1); modelSel = 0; return; }
|
|
1196
|
+
if (key.ctrl && k === 'u') { modelFilter = ''; modelSel = 0; return; }
|
|
1197
|
+
if (str && str.length === 1 && !key.ctrl) { modelFilter += str; modelSel = 0; }
|
|
1198
|
+
}
|
|
1199
|
+
|
|
660
1200
|
// Open the inline editor for a session's title or cwd, seeded with the
|
|
661
1201
|
// current override (or recorded cwd, so the user can tweak rather than retype).
|
|
662
1202
|
function startEdit(s, field) {
|
|
@@ -673,7 +1213,10 @@ function handleEdit(str, key) {
|
|
|
673
1213
|
if (key.ctrl && k === 'c') return quit();
|
|
674
1214
|
if (k === 'escape') { mode = 'list'; editTarget = null; return; }
|
|
675
1215
|
if (k === 'return') {
|
|
676
|
-
if (editTarget)
|
|
1216
|
+
if (editTarget) {
|
|
1217
|
+
if (editField === 'title') saveTitle(editTarget.id, editBuffer, 'manual');
|
|
1218
|
+
else setOverride(editTarget.id, editField, editBuffer);
|
|
1219
|
+
}
|
|
677
1220
|
mode = 'list';
|
|
678
1221
|
editTarget = null;
|
|
679
1222
|
return;
|
|
@@ -845,7 +1388,7 @@ function tryCopy(text) {
|
|
|
845
1388
|
// as "pisesh exited with code N". We inject a synthetic "[interrupted]"
|
|
846
1389
|
// toolResult for unfinished valid turns, but not errored/aborted assistant turns
|
|
847
1390
|
// that pi omits during replay. Old incompatible synthetic results are removed.
|
|
848
|
-
// Idempotent
|
|
1391
|
+
// Idempotent. Throws when the session cannot be read, backed up, or written.
|
|
849
1392
|
function healOrphanedToolCalls(file) {
|
|
850
1393
|
try {
|
|
851
1394
|
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
@@ -912,31 +1455,68 @@ function healOrphanedToolCalls(file) {
|
|
|
912
1455
|
}
|
|
913
1456
|
const changed = removedParents.size + inserted;
|
|
914
1457
|
if (changed > 0) {
|
|
915
|
-
|
|
1458
|
+
const bak = file + '.bak-orphanheal';
|
|
1459
|
+
if (!fs.existsSync(bak)) fs.copyFileSync(file, bak);
|
|
916
1460
|
fs.writeFileSync(file, out.join('\n') + '\n');
|
|
917
1461
|
}
|
|
918
1462
|
return changed;
|
|
919
|
-
} catch {
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
throw new Error(`session repair failed: ${error.message}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
function loadResumeDefaults(settingsFile = SETTINGS_FILE) {
|
|
1469
|
+
try {
|
|
1470
|
+
const settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
|
|
1471
|
+
return {
|
|
1472
|
+
model: settings.defaultProvider && settings.defaultModel
|
|
1473
|
+
? `${settings.defaultProvider}/${settings.defaultModel}`
|
|
1474
|
+
: '',
|
|
1475
|
+
thinking: settings.defaultThinkingLevel || '',
|
|
1476
|
+
};
|
|
1477
|
+
} catch { return null; }
|
|
920
1478
|
}
|
|
921
1479
|
|
|
922
|
-
function
|
|
1480
|
+
function buildResumeArgs(s, useCurrentDefaults = true, settingsFile = SETTINGS_FILE) {
|
|
1481
|
+
const args = ['--session', s.id, '--session-dir', path.dirname(s.file)];
|
|
1482
|
+
const defaults = useCurrentDefaults && loadResumeDefaults(settingsFile);
|
|
1483
|
+
if (defaults?.model) args.push('--model', defaults.model);
|
|
1484
|
+
if (defaults?.thinking) args.push('--thinking', defaults.thinking);
|
|
1485
|
+
return args;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function resumeSession(s, useCurrentDefaults = true) {
|
|
923
1489
|
if (!s) return;
|
|
1490
|
+
// A resumed pi takes over this terminal, so stop background title work first
|
|
1491
|
+
// and suppress any final render from its asynchronous close handler.
|
|
1492
|
+
handingOff = true;
|
|
1493
|
+
cancelGeneration();
|
|
924
1494
|
// Heal orphaned tool calls before handing off, so resume can't crash the
|
|
925
1495
|
// spawned pi (see healOrphanedToolCalls).
|
|
926
|
-
|
|
1496
|
+
let repaired;
|
|
1497
|
+
try { repaired = healOrphanedToolCalls(s.file); }
|
|
1498
|
+
catch (error) {
|
|
1499
|
+
process.stdout.write(A.exitAlt + A.showC);
|
|
1500
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
1501
|
+
console.error(`pisesh: ${error.message}`);
|
|
1502
|
+
process.exit(1);
|
|
1503
|
+
}
|
|
927
1504
|
// Leave alt screen + show cursor so the spawned pi takes over a clean
|
|
928
1505
|
// main-buffer terminal (it will manage its own alt screen).
|
|
929
1506
|
process.stdout.write(A.exitAlt + A.showC);
|
|
930
1507
|
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
931
1508
|
process.stdin.pause();
|
|
1509
|
+
if (repaired > 0) {
|
|
1510
|
+
console.log(`pisesh: repaired ${repaired} orphaned session entr${repaired === 1 ? 'y' : 'ies'}; backup: ${s.file}.bak-orphanheal`);
|
|
1511
|
+
}
|
|
932
1512
|
|
|
933
|
-
const projectDir = path.dirname(s.file);
|
|
934
1513
|
// Resume in the session's effective cwd (user override wins over recorded),
|
|
935
1514
|
// falling back to pisesh's own cwd if that directory no longer exists.
|
|
936
1515
|
const targetCwd = s.effectiveCwd;
|
|
937
|
-
const child = spawn('pi',
|
|
1516
|
+
const child = spawn('pi', buildResumeArgs(s, useCurrentDefaults), {
|
|
938
1517
|
stdio: 'inherit',
|
|
939
1518
|
cwd: targetCwd && fs.existsSync(targetCwd) ? targetCwd : process.cwd(),
|
|
1519
|
+
env: PI_ENV,
|
|
940
1520
|
});
|
|
941
1521
|
child.on('exit', code => process.exit(code || 0));
|
|
942
1522
|
child.on('error', err => {
|
|
@@ -946,6 +1526,8 @@ function resumeSession(s) {
|
|
|
946
1526
|
}
|
|
947
1527
|
|
|
948
1528
|
function quit() {
|
|
1529
|
+
handingOff = true;
|
|
1530
|
+
for (const child of generationChildren.values()) child.kill('SIGKILL');
|
|
949
1531
|
// Restore user's pre-pisesh terminal (alt screen exits + cursor back).
|
|
950
1532
|
// No explicit clear needed — the terminal restoration handles it and the
|
|
951
1533
|
// jump back to the previous prompt feels instant.
|
|
@@ -964,21 +1546,29 @@ Usage:
|
|
|
964
1546
|
pisesh --json Dump favorites file as JSON
|
|
965
1547
|
pisesh --star <id> Add a session id (partial UUID ok) to favorites
|
|
966
1548
|
pisesh --unstar <id> Remove a session id from favorites
|
|
1549
|
+
pisesh --clean-favorites Remove favorites whose sessions no longer exist
|
|
1550
|
+
pisesh --version, -v Print the installed version
|
|
967
1551
|
pisesh --help, -h This help
|
|
968
1552
|
|
|
969
1553
|
TUI keys:
|
|
970
1554
|
↑↓ / j k move cursor
|
|
971
1555
|
Tab / h l switch tab (★ Favorites → Today → Here → All)
|
|
972
1556
|
f / Space toggle favorite
|
|
973
|
-
|
|
1557
|
+
x remove favorites whose sessions no longer exist
|
|
1558
|
+
Enter resume with current default model and thinking settings
|
|
1559
|
+
o resume with the session-recorded model and thinking settings
|
|
974
1560
|
e edit name (custom display title)
|
|
1561
|
+
g queue title generation with the saved model + effort
|
|
1562
|
+
(first use opens settings; active/queued threads are ignored)
|
|
1563
|
+
G open title generation model + effort settings
|
|
975
1564
|
p edit cwd — arrow-key directory browser
|
|
976
1565
|
(↑↓ move, →/Enter open dir, ← parent, s select, Esc cancel)
|
|
977
1566
|
sets the working directory used on resume / "Here" tab
|
|
978
1567
|
d show details
|
|
979
1568
|
/ search (id / project / prompt / title)
|
|
980
1569
|
r rescan session files
|
|
981
|
-
q / Esc /
|
|
1570
|
+
q / Esc cancel generation / clear search first; press again to quit
|
|
1571
|
+
Ctrl-C cancel generation and quit immediately
|
|
982
1572
|
|
|
983
1573
|
Tabs:
|
|
984
1574
|
Here only sessions whose cwd matches the directory pisesh was
|
|
@@ -1000,6 +1590,13 @@ function main() {
|
|
|
1000
1590
|
const argv = process.argv.slice(2);
|
|
1001
1591
|
|
|
1002
1592
|
if (argv.includes('-h') || argv.includes('--help')) { printHelp(); return; }
|
|
1593
|
+
if (argv.includes('-v') || argv.includes('--version')) { console.log(VERSION); return; }
|
|
1594
|
+
if (argv.includes('--clean-favorites')) {
|
|
1595
|
+
sessions = scanSessions();
|
|
1596
|
+
const stale = cleanStaleFavorites(sessions);
|
|
1597
|
+
console.log(`removed ${stale.length} stale favorite${stale.length === 1 ? '' : 's'}`);
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1003
1600
|
if (argv.includes('-l') || argv.includes('--list')) {
|
|
1004
1601
|
[...favorites].forEach(id => console.log(id));
|
|
1005
1602
|
return;
|
|
@@ -1041,4 +1638,17 @@ function main() {
|
|
|
1041
1638
|
}
|
|
1042
1639
|
|
|
1043
1640
|
if (require.main === module) main();
|
|
1044
|
-
else module.exports = {
|
|
1641
|
+
else module.exports = {
|
|
1642
|
+
buildResumeArgs,
|
|
1643
|
+
buildTitlePrompt,
|
|
1644
|
+
cleanGeneratedTitle,
|
|
1645
|
+
cleanStaleFavorites,
|
|
1646
|
+
healOrphanedToolCalls,
|
|
1647
|
+
loadResumeDefaults,
|
|
1648
|
+
parseModelEntries,
|
|
1649
|
+
parseModelList,
|
|
1650
|
+
piAgentEnv,
|
|
1651
|
+
takeGenerationBatch,
|
|
1652
|
+
titleGenerationArgs,
|
|
1653
|
+
titleGenerationBlocker,
|
|
1654
|
+
};
|