pisesh 0.1.13 → 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 CHANGED
@@ -2,6 +2,16 @@
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
+
5
15
  ## [0.1.13] - 2026-08-01
6
16
 
7
17
  ### Added
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, ~1,100 LoC) that gives you everything `pi --resume` doesn't.
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 a custom title (marked `✎`); overrides the first-prompt label |
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 |
@@ -104,15 +104,19 @@ For local pi testing, run `pi install .` from the cloned repository so the exten
104
104
  | `Enter` | resume using the current default model and thinking settings |
105
105
  | `o` | resume using the model and thinking recorded in the session |
106
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 |
107
109
  | `p` | edit cwd with an arrow-key directory browser; sets the resume / `Here` dir |
108
110
  | `d` | session details (full prompt, file, byte size, timestamps) |
109
111
  | `/` | search by id / project / first user prompt / custom title |
110
- | `Esc` | clear search first, then quit |
111
- | `q` / `Ctrl-C` | quit (terminal restored) |
112
+ | `Esc` / `q` | cancel generation or clear search first; press again to quit |
113
+ | `Ctrl-C` | cancel generation and quit immediately |
112
114
  | `r` | rescan session files (after pi starts a new session) |
113
115
  | `c` (in details view) | copy session id to clipboard (clip.exe / pbcopy / xclip) |
114
116
  | `Home` `End` `PgUp` `PgDn` | jump to top / bottom / ±10 |
115
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
+
116
120
  ## CLI (non-TUI) usage
117
121
 
118
122
  For scripts and automation:
@@ -139,17 +143,18 @@ pisesh --help
139
143
  | Input | Node's `readline.emitKeypressEvents` in raw mode |
140
144
  | Width calculation | UAX #11 East Asian Width ranges, compressed to ~10 inline range checks |
141
145
  | Pi extension | TypeScript factory using `@earendil-works/pi-coding-agent` extension API (`ui.custom`, `tui.stop`) |
142
- | Storage | Two JSON files: `~/.pi/agent/favorites.json` (starred ids) + `~/.pi/agent/pisesh-meta.json` (per-session title / cwd overrides) |
146
+ | Storage | Two JSON files under `$PI_AGENT_DIR`: `favorites.json` and `pisesh-meta.json` |
143
147
  | Session discovery | Direct filesystem scan of `~/.pi/agent/sessions/<projectSlug>/*.jsonl`; first 96 KB parsed |
144
148
  | Process model | Slash command pauses pi's TUI, spawns pisesh with inherited stdio, restarts pi on exit |
145
149
  | Resume settings | `Enter` uses current defaults; `o` preserves the model and thinking recorded in the session |
146
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 |
147
152
 
148
153
  ### What it explicitly does **not** depend on
149
154
 
150
155
  - No `npm install` for the bundled CLI runtime; it's genuinely zero-dependency
151
156
  - No native binaries / GPU / ffmpeg / database
152
- - No network calls, no telemetry, no analytics
157
+ - No telemetry or analytics; title generation contacts only the provider for the model you select
153
158
  - No daemon / background process
154
159
 
155
160
  ## Storage
@@ -157,7 +162,7 @@ pisesh --help
157
162
  | What | Where |
158
163
  | ---------- | ----------------------------------------------------------- |
159
164
  | Favorites | `$PI_AGENT_DIR/favorites.json` (defaults to `~/.pi/agent/favorites.json`) |
160
- | Overrides | `$PI_AGENT_DIR/pisesh-meta.json` (per-session custom title / cwd, keyed by session id) |
165
+ | Overrides | `$PI_AGENT_DIR/pisesh-meta.json` (per-session title / cwd plus the saved title model + effort preset) |
161
166
  | Sessions | `$PI_SESSION_DIR`, or `$PI_AGENT_DIR/sessions` by default (repaired only when an orphaned tool call would break resume) |
162
167
 
163
168
  Favorites file shape:
package/bin/pisesh CHANGED
@@ -15,11 +15,15 @@ const readline = require('readline');
15
15
 
16
16
  // ── Paths ─────────────────────────────────────────────
17
17
  const HOME = os.homedir();
18
- const AGENT_DIR = path.resolve(process.env.PI_AGENT_DIR || path.join(HOME, '.pi/agent'));
19
- const SESSIONS_ROOT = path.resolve(process.env.PI_SESSION_DIR || path.join(AGENT_DIR, 'sessions'));
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
20
  const FAV_FILE = path.join(AGENT_DIR, 'favorites.json');
21
21
  const META_FILE = path.join(AGENT_DIR, 'pisesh-meta.json');
22
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();
23
27
  const VERSION = (() => {
24
28
  for (const file of [
25
29
  path.resolve(__dirname, '../package.json'),
@@ -91,12 +95,15 @@ let favorites = loadFavorites();
91
95
  function loadMeta() {
92
96
  try {
93
97
  const data = JSON.parse(fs.readFileSync(META_FILE, 'utf8'));
94
- return (data && typeof data.overrides === 'object' && data.overrides) || {};
95
- } catch { return {}; }
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: {} }; }
96
103
  }
97
104
 
98
- function saveMeta(overrides) {
99
- const data = { overrides, updated: new Date().toISOString() };
105
+ function saveMeta() {
106
+ const data = { settings: metaSettings, overrides: meta, updated: new Date().toISOString() };
100
107
  try {
101
108
  fs.mkdirSync(path.dirname(META_FILE), { recursive: true });
102
109
  fs.writeFileSync(META_FILE, JSON.stringify(data, null, 2));
@@ -105,7 +112,9 @@ function saveMeta(overrides) {
105
112
  }
106
113
  }
107
114
 
108
- let meta = loadMeta();
115
+ const loadedMeta = loadMeta();
116
+ let meta = loadedMeta.overrides;
117
+ let metaSettings = loadedMeta.settings;
109
118
 
110
119
  // Override record for one session id (always returns an object, never null).
111
120
  // Used by the per-session getters, which can't read the global `meta`
@@ -123,7 +132,32 @@ function setOverride(id, field, value) {
123
132
  else delete entry[field];
124
133
  if (Object.keys(entry).length) meta[id] = entry;
125
134
  else delete meta[id];
126
- saveMeta(meta);
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();
127
161
  }
128
162
 
129
163
  // ── Project slug decode ───────────────────────────────
@@ -235,6 +269,9 @@ function scanSessions() {
235
269
  get isCurrent() { return CURRENT_SESSION_ID !== '' && this.id === CURRENT_SESSION_ID; },
236
270
  // User overrides (sidecar meta). cwd: from is the original recorded cwd.
237
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 || ''; },
238
275
  get cwdOverride() { return (overridesFor(this.id).cwd || '').trim(); },
239
276
  // Name shown in the list: custom title > first prompt > id fallback.
240
277
  get title() {
@@ -266,7 +303,7 @@ let tabIdx = 0;
266
303
  let cursor = 0;
267
304
  let filter = '';
268
305
  let notice = '';
269
- let mode = 'list'; // list | filter | details | edit | browse
306
+ let mode = 'list'; // list | filter | details | edit | browse | model
270
307
  // edit mode state (title text editor)
271
308
  let editField = ''; // 'title' | 'cwd'
272
309
  let editBuffer = '';
@@ -276,6 +313,185 @@ let editTarget = null; // the session being edited
276
313
  let browseDir = ''; // directory currently being browsed (absolute)
277
314
  let browseEntries = []; // [{ label, kind:'use'|'up'|'dir', path }]
278
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
+ }
279
495
 
280
496
  // ── Filtering by tab ──────────────────────────────────
281
497
  function visibleSessions() {
@@ -400,6 +616,7 @@ function visLen(s) {
400
616
  function render() {
401
617
  if (mode === 'edit') return renderEdit();
402
618
  if (mode === 'browse') return renderBrowse();
619
+ if (mode === 'model') return renderModelPicker();
403
620
  if (mode === 'details') return renderDetails();
404
621
  renderList();
405
622
  }
@@ -411,7 +628,8 @@ function renderList() {
411
628
 
412
629
  // Header
413
630
  out += ' ' + A.B + A.cyn + 'pisesh' + A.R + A.D + ' pi session bookmarks ' + A.R;
414
- out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R + '\n\n';
631
+ out += A.gry + `${sessions.length} sessions · ${favorites.size} starred` + A.R;
632
+ out += '\n\n';
415
633
 
416
634
  // Tabs
417
635
  let tabLine = ' ';
@@ -459,14 +677,22 @@ function renderList() {
459
677
  const sel = i === cursor;
460
678
  const star = s.favored ? A.yel + '★' + A.R : ' ';
461
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);
462
682
  const ts = A.gry + fmtTs(s.mtime) + A.R;
463
683
  const cwd = A.mag + pad(shortCwd(s.effectiveCwd), 14) + A.R;
464
684
  const badge = s.isCurrent ? A.grn + A.B + '[NOW]' + A.R + ' ' : '';
465
- // marks a user-renamed session so it's clear the label isn't the prompt.
466
- const pen = s.titleOverride ? A.cyn + '✎' + A.R + ' ' : '';
467
- const titleMax = Math.max(20, W - 38 - (s.isCurrent ? 7 : 0) - (s.titleOverride ? 2 : 0));
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));
468
694
  const title = trunc(s.title, titleMax);
469
- const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${pen}${title}`;
695
+ const rowText = ` ${arrow} ${star} ${ts} ${cwd} ${badge}${marker}${title}`;
470
696
  if (sel) {
471
697
  const pad2 = ' '.repeat(Math.max(0, W - visLen(rowText) - 1));
472
698
  out += A.bgBlu + rowText + pad2 + A.R + '\n';
@@ -489,6 +715,8 @@ function renderList() {
489
715
  + A.D + 'Enter' + A.R + ' current '
490
716
  + A.D + 'o' + A.R + ' original '
491
717
  + A.D + 'e' + A.R + ' edit name '
718
+ + A.D + 'g' + A.R + ' queue title '
719
+ + A.D + 'G' + A.R + ' settings '
492
720
  + A.D + 'p' + A.R + ' edit cwd '
493
721
  + A.D + 'd' + A.R + ' details '
494
722
  + A.D + '/' + A.R + ' search '
@@ -498,6 +726,207 @@ function renderList() {
498
726
  process.stdout.write(out);
499
727
  }
500
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
+
501
930
  // Inline editor for a session's title or cwd. Renders a small focused panel
502
931
  // so the user always sees which session + field they're changing.
503
932
  function renderEdit() {
@@ -556,7 +985,11 @@ function renderDetails() {
556
985
  out += row('id', s.id, A.cyn);
557
986
  if (s.isCurrent) out += row('current', '● this is the attached pi session', A.grn);
558
987
  out += row('starred', s.favored ? '★ yes' : '☆ no', s.favored ? A.yel : A.gry);
559
- out += row('title', s.title + (s.titleOverride ? A.cyn + ' (custom)' + A.R : ''));
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
+ }
560
993
  out += row('started', new Date(s.ts).toLocaleString());
561
994
  out += row('updated', new Date(s.mtime).toLocaleString());
562
995
  out += row('cwd', s.effectiveCwd + (s.cwdOverride ? A.cyn + ' (custom)' + A.R : ''), A.mag);
@@ -579,6 +1012,7 @@ function renderDetails() {
579
1012
  + A.D + 'o' + A.R + ' original settings '
580
1013
  + A.D + 'f' + A.R + ' star '
581
1014
  + A.D + 'e' + A.R + ' edit name '
1015
+ + A.D + 'g' + A.R + ' generate title '
582
1016
  + A.D + 'p' + A.R + ' edit cwd '
583
1017
  + A.D + 'c' + A.R + ' copy id '
584
1018
  + A.D + 'Esc/d/q' + A.R + ' back' + A.R + '\n';
@@ -611,9 +1045,16 @@ function setupInput() {
611
1045
 
612
1046
  process.stdin.on('keypress', (str, key) => {
613
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
+ }
614
1054
  if (mode === 'filter') handleFilter(str, key);
615
1055
  else if (mode === 'edit') handleEdit(str, key);
616
1056
  else if (mode === 'browse') handleBrowse(str, key);
1057
+ else if (mode === 'model') handleModelPicker(str, key);
617
1058
  else if (mode === 'details') handleDetails(str, key);
618
1059
  else handleList(str, key);
619
1060
  render();
@@ -637,7 +1078,10 @@ function handleList(str, key) {
637
1078
  const k = key.name;
638
1079
 
639
1080
  if (key.ctrl && k === 'c') return quit();
640
- if (k === 'q') return quit();
1081
+ if (k === 'q') {
1082
+ if (filter) { filter = ''; cursor = 0; return; }
1083
+ return quit();
1084
+ }
641
1085
  // Esc: clears filter first (one-step back-out), otherwise quits.
642
1086
  // Matches `q` so muscle memory works either way.
643
1087
  if (k === 'escape') {
@@ -670,6 +1114,8 @@ function handleList(str, key) {
670
1114
  }
671
1115
  else if (k === 'd') { if (list[cursor]) mode = 'details'; }
672
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]);
673
1119
  else if (k === 'p') startBrowse(list[cursor]);
674
1120
  else if (str === '/') { mode = 'filter'; }
675
1121
  else if (k === 'r') { sessions = scanSessions(); cursor = 0; }
@@ -681,7 +1127,8 @@ function handleList(str, key) {
681
1127
 
682
1128
  function handleFilter(str, key) {
683
1129
  const k = key.name;
684
- if (k === 'return' || k === 'escape') { mode = 'list'; return; }
1130
+ if (k === 'return') { mode = 'list'; return; }
1131
+ if (k === 'escape' || k === 'q') { filter = ''; cursor = 0; mode = 'list'; return; }
685
1132
  if (k === 'backspace') { filter = filter.slice(0, -1); cursor = 0; return; }
686
1133
  if (key.ctrl && k === 'c') return quit();
687
1134
  if (key.ctrl && k === 'u') { filter = ''; cursor = 0; return; }
@@ -704,9 +1151,52 @@ function handleDetails(str, key) {
704
1151
  tryCopy(s.id);
705
1152
  }
706
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);
707
1156
  if (k === 'p' && s) startBrowse(s);
708
1157
  }
709
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
+
710
1200
  // Open the inline editor for a session's title or cwd, seeded with the
711
1201
  // current override (or recorded cwd, so the user can tweak rather than retype).
712
1202
  function startEdit(s, field) {
@@ -723,7 +1213,10 @@ function handleEdit(str, key) {
723
1213
  if (key.ctrl && k === 'c') return quit();
724
1214
  if (k === 'escape') { mode = 'list'; editTarget = null; return; }
725
1215
  if (k === 'return') {
726
- if (editTarget) setOverride(editTarget.id, editField, editBuffer);
1216
+ if (editTarget) {
1217
+ if (editField === 'title') saveTitle(editTarget.id, editBuffer, 'manual');
1218
+ else setOverride(editTarget.id, editField, editBuffer);
1219
+ }
727
1220
  mode = 'list';
728
1221
  editTarget = null;
729
1222
  return;
@@ -994,6 +1487,10 @@ function buildResumeArgs(s, useCurrentDefaults = true, settingsFile = SETTINGS_F
994
1487
 
995
1488
  function resumeSession(s, useCurrentDefaults = true) {
996
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();
997
1494
  // Heal orphaned tool calls before handing off, so resume can't crash the
998
1495
  // spawned pi (see healOrphanedToolCalls).
999
1496
  let repaired;
@@ -1019,6 +1516,7 @@ function resumeSession(s, useCurrentDefaults = true) {
1019
1516
  const child = spawn('pi', buildResumeArgs(s, useCurrentDefaults), {
1020
1517
  stdio: 'inherit',
1021
1518
  cwd: targetCwd && fs.existsSync(targetCwd) ? targetCwd : process.cwd(),
1519
+ env: PI_ENV,
1022
1520
  });
1023
1521
  child.on('exit', code => process.exit(code || 0));
1024
1522
  child.on('error', err => {
@@ -1028,6 +1526,8 @@ function resumeSession(s, useCurrentDefaults = true) {
1028
1526
  }
1029
1527
 
1030
1528
  function quit() {
1529
+ handingOff = true;
1530
+ for (const child of generationChildren.values()) child.kill('SIGKILL');
1031
1531
  // Restore user's pre-pisesh terminal (alt screen exits + cursor back).
1032
1532
  // No explicit clear needed — the terminal restoration handles it and the
1033
1533
  // jump back to the previous prompt feels instant.
@@ -1058,13 +1558,17 @@ TUI keys:
1058
1558
  Enter resume with current default model and thinking settings
1059
1559
  o resume with the session-recorded model and thinking settings
1060
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
1061
1564
  p edit cwd — arrow-key directory browser
1062
1565
  (↑↓ move, →/Enter open dir, ← parent, s select, Esc cancel)
1063
1566
  sets the working directory used on resume / "Here" tab
1064
1567
  d show details
1065
1568
  / search (id / project / prompt / title)
1066
1569
  r rescan session files
1067
- q / Esc / Ctrl-C quit
1570
+ q / Esc cancel generation / clear search first; press again to quit
1571
+ Ctrl-C cancel generation and quit immediately
1068
1572
 
1069
1573
  Tabs:
1070
1574
  Here only sessions whose cwd matches the directory pisesh was
@@ -1134,4 +1638,17 @@ function main() {
1134
1638
  }
1135
1639
 
1136
1640
  if (require.main === module) main();
1137
- else module.exports = { buildResumeArgs, cleanStaleFavorites, healOrphanedToolCalls, loadResumeDefaults };
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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pisesh",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "Bookmark, search, and resume pi coding-agent sessions with a fast keyboard-driven TUI.",
5
5
  "keywords": [
6
6
  "pi-package",