Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windo
| Command | Action |
| --- | --- |
| `/optchat` | Status and actions menu. |
| `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
| `/optchat profile` | Select, create, or delete a profile. Ctrl+D deletes the highlighted one. |
| `/optchat settings` | This profile's [settings](#settings). |
| `/optchat model` | Compactor model and effort for this profile. Type to filter the models you are logged in to; the current one is marked. |
| `/optchat agents` | Live agent tree and saved run history. |
Expand Down Expand Up @@ -209,7 +209,7 @@ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPT
| `memory.html` | Snapshot from `/optchat browse` |

- Each profile folder is a local Git repository, committed after each turn. It has no remote, so it is not a backup: copy the folder while Pi is closed.
- To delete a profile, delete its folder. Your original Pi sessions are kept in Pi's normal session directory.
- To delete a profile, press Ctrl+D on it in `/optchat profile` and confirm: the folder goes away, and the last-profile pointer with it when it named the deleted profile. Pi refuses a profile another Pi is using, so that Pi never writes into a folder that is gone. Your original Pi sessions are kept in Pi's normal session directory.

Git checkpoints and the pluggable `Store` seam (for memory on another machine): [docs/storage.md](docs/storage.md).

Expand Down
30 changes: 21 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Type } from 'typebox';
import { Memory } from './memory.ts';
import { atomicWrite, storeFor } from './store.ts';
import { createCompressor, type ModelChoice } from './compactor.ts';
import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
import { createProfile, deleteProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './core/prompts.ts';
import { MASTER, modelsHint } from './prompts.ts';
import { cachePayload, record } from './core/cache.ts';
Expand All @@ -35,7 +35,7 @@ import { openConnectedWindow, registerConnectedRenderer } from './connected-wind
import { createHandoffSummarizer } from './handoff.ts';
import { mainTitle, TabTitle } from './title.ts';
import { showModelPicker, showSettings } from './settings-page.ts';
import { showMenu } from './menu.ts';
import { CREATE_PROFILE, profilePicker, showMenu, type ProfilePick } from './menu.ts';

const binding = 'optchat.profile';
/** The profile a session is bound to; the latest binding wins. */
Expand Down Expand Up @@ -224,13 +224,25 @@ export default function optchat(pi: ExtensionAPI) {
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
// Outside the TUI (print, json, RPC hosts) never pick or create a profile: a dialog would hang the host, and a guess could write into the wrong memory.
if (ctx.mode !== 'tui') return undefined;
const names = listProfiles(), last = lastProfile();
if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
const selected = await ctx.ui.select('OptChat profile', [...names, '+ Create profile']);
if (selected !== '+ Create profile') return selected;
const name = (await ctx.ui.input('New profile name', 'work or personal'))?.trim();
if (!name) return undefined;
createProfile(name); return name;
for (;;) {
const names = listProfiles(), last = lastProfile();
if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
const picked = await ctx.ui.custom<ProfilePick>((tui, theme, keys, done) => profilePicker(theme, keys, names, done, () => tui.requestRender()));
if (!picked) return undefined;
if (typeof picked === 'string') {
if (picked !== CREATE_PROFILE) return picked;
const name = (await ctx.ui.input('New profile name', 'work or personal'))?.trim();
if (!name) return undefined;
createProfile(name); return name;
}
const name = picked.delete;
// The profile this session serves is open in this process: deleting the folder under a live store writes into nothing.
if (name === active?.name) { ctx.ui.notify(`Cannot delete ${name}: this session is using it. Switch profiles first.`, 'error'); continue; }
const confirmed = await ctx.ui.confirm(`Delete profile ${name}?`, 'Its memory, runs, usage ledger and settings are removed from disk. Pi sessions are kept.');
if (!confirmed) continue;
try { await deleteProfile(name); ctx.ui.notify(`Deleted ${name}.`, 'info'); }
catch (error) { ctx.ui.notify(errorText(error), 'error'); }
}
};
// An unbound TUI session skips the picker when the choice is clear (#238): /new keeps the window's profile, then OPTCHAT_PROFILE, then a lone profile.
const defaultProfile = (ctx: ExtensionContext, event: SessionStartEvent): string | undefined => {
Expand Down
60 changes: 45 additions & 15 deletions src/menu.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, SelectList, Spacer, Text } from '@earendil-works/pi-tui';
import { Box, KeybindingsManager, SelectList, Spacer, Text, type SelectItem } from '@earendil-works/pi-tui';
import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
import type { ModelChoice } from './compactor.ts';
import { count, oneLine } from './inspector.ts';
Expand All @@ -8,7 +8,7 @@ import { frame, showModel } from './settings-page.ts';
export const ACTIONS = ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import'] as const;
export type MenuAction = typeof ACTIONS[number];
const DESCRIPTIONS: Record<MenuAction, string> = {
profile: 'Switch or create a profile',
profile: 'Switch, create, or delete a profile',
settings: 'Models, subagent limits, memory options',
model: 'Compactor model and effort',
agents: 'Live agent tree and saved runs',
Expand All @@ -30,27 +30,57 @@ export const statusRows = (s: MenuStatus) => [
const failing = (error: string) => `Summaries failing: ${oneLine(error)}`;

/** Framed like the settings page: title, the profile's status as aligned rows, then the actions with what each opens. */
export function optchatMenu(theme: Theme, title: string, status: MenuStatus | undefined, close: (action?: MenuAction) => void, redraw = () => {}) {
const list = new SelectList(ACTIONS.map(value => ({ value, label: value, description: DESCRIPTIONS[value] })), ACTIONS.length, {
/** The select list and, with it, the j/k movement both menus share: rows styled once, a delete shortcut can pre-empt a key. */
function sharedList(items: SelectItem[], theme: Theme, close: (value?: string) => void, redraw = () => {},
shortcut?: (data: string, current: string | undefined) => boolean, maxVisible = 10) {
const list = new SelectList(items, maxVisible, {
selectedPrefix: t => theme.fg('accent', t), selectedText: t => theme.fg('accent', t),
description: t => theme.fg('muted', t), scrollInfo: t => theme.fg('muted', t), noMatch: t => theme.fg('muted', t) },
{ minPrimaryColumnWidth: 1, maxPrimaryColumnWidth: Math.max(...ACTIONS.map(a => a.length)) + 4 });
list.onSelect = item => close(ACTIONS.find(a => a === item.value));
{ minPrimaryColumnWidth: 1, maxPrimaryColumnWidth: Math.max(...items.map(i => i.label.length)) + 4 });
list.onSelect = item => close(item.value);
list.onCancel = () => close();
const block = new Box(1, 0); block.addChild(list);
return { block, handleInput: (data: string) => {
if (!shortcut?.(data, list.getSelectedItem()?.value)) {
// j and k move too, as in Pi's own select dialog that the pages replace.
const step = data === 'j' ? 1 : data === 'k' ? -1 : 0, at = items.findIndex(item => item.value === list.getSelectedItem()?.value);
if (step) list.setSelectedIndex(Math.max(0, Math.min(items.length - 1, at + step)));
else list.handleInput(data);
}
redraw();
} };
}

/** Framed like the settings page: title, the profile's status as aligned rows, then the actions with what each opens. */
export function optchatMenu(theme: Theme, title: string, status: MenuStatus | undefined, close: (action?: MenuAction) => void, redraw = () => {}) {
const actions = sharedList(ACTIONS.map(value => ({ value, label: value, description: DESCRIPTIONS[value] })), theme,
value => close(ACTIONS.find(a => a === value)), redraw);
const rows = status ? statusRows(status) : [];
const labels = Math.max(0, ...rows.map(([label]) => label.length));
const facts = status ? rows.map(([label, value]) => new Text(`${theme.fg('muted', label.padEnd(labels))} ${value}`, 1, 0)) : [new Text(theme.fg('muted', 'No active profile'), 1, 0)];
if (status?.error) facts.push(new Text(theme.fg('warning', failing(status.error)), 1, 0));
const actions = new Box(1, 0); actions.addChild(list);
const page = frame(theme, title, status?.profile, [new Spacer(1), ...facts, new Spacer(1), actions, new Spacer(1),
const page = frame(theme, title, status?.profile, [new Spacer(1), ...facts, new Spacer(1), actions.block, new Spacer(1),
new Text(theme.fg('dim', '↑↓ select · Enter open · Esc close'), 1, 0)]);
return Object.assign(page, { handleInput: (data: string) => {
// j and k move too, as in Pi's own select dialog that this menu replaces.
const step = data === 'j' ? 1 : data === 'k' ? -1 : 0, at = ACTIONS.findIndex(a => a === list.getSelectedItem()?.value);
if (step) list.setSelectedIndex(Math.max(0, Math.min(ACTIONS.length - 1, at + step)));
else list.handleInput(data);
redraw();
} });
return Object.assign(page, { handleInput: actions.handleInput });
}

/** The profile list's own entry for creating one; never a valid profile name (the pattern allows lowercase only). */
export const CREATE_PROFILE = '+ Create profile';
/** What the picker returns: a profile to open, a profile to delete, or nothing for a cancel. */
export type ProfilePick = string | { delete: string } | undefined;

/** The profile list: Pi's session list for profiles, so Ctrl+D deletes the highlighted one the way it deletes a session there. */
export function profilePicker(theme: Theme, keys: KeybindingsManager, names: string[], close: (pick?: ProfilePick) => void, redraw = () => {}) {
const items = [...names.map(name => ({ value: name, label: name })), { value: CREATE_PROFILE, label: CREATE_PROFILE }];
const picker = sharedList(items, theme, value => close(value as ProfilePick), redraw, (data, current) => {
// Delete goes through the user's keybindings, so it fires in every terminal encoding Pi reads; the create row is not a profile to delete.
if (!keys.matches(data, 'app.session.delete')) return false;
if (current && current !== CREATE_PROFILE) close({ delete: current });
return true;
});
const page = frame(theme, 'OptChat profile', undefined, [new Spacer(1), picker.block, new Spacer(1),
new Text(theme.fg('dim', names.length ? '↑↓ select · Enter open · Ctrl+D delete · Esc cancel' : 'Enter create · Esc cancel'), 1, 0)]);
return Object.assign(page, { handleInput: picker.handleInput });
}

/** The menu in interactive Pi; elsewhere (RPC hosts) Pi's plain select with the same facts and actions. */
Expand Down
36 changes: 32 additions & 4 deletions src/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createHash } from 'node:crypto';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, unlinkSync, writeFileSync, type Stats } from 'node:fs';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, type Stats } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { createConnection, createServer } from 'node:net';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import type { ModelChoice } from './compactor.ts';
import { record } from './core/cache.ts';
import { atomicWrite } from './store.ts';
Expand All @@ -29,6 +29,25 @@ export function listProfiles() {
const root = join(dataHome(), 'profiles');
return existsSync(root) ? readdirSync(root, { withFileTypes: true }).filter(f => f.isDirectory() && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(f.name)).map(f => f.name).sort() : [];
}
/** Removes a profile's folder, and the saved last-profile pointer when it names it. The real lock decides: a profile another Pi is using is open, so deleting it would leave that Pi writing into a folder that no longer exists. */
export async function deleteProfile(name: string) {
const dir = profilePath(name);
if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist.`);
const unlock = await lockProfile(dir, 'deleting');
try {
// Renamed to a hidden sibling before the rmSync: a delete that fails on Windows then cannot leave half a profile in the picker (listProfiles() hides dot names).
const reserved = join(dataHome(), 'profiles', `.deleting-${name}-${Date.now()}`);
renameSync(dir, reserved);
// listProfiles() already hides a name with no folder, but without this a profile created again under the same name would inherit the stale pointer.
// Clear it immediately after the rename, before the recursive removal, so an rmSync failure cannot leave a stale pointer behind.
const last = join(dataHome(), 'last-profile');
try { if (readFileSync(last, 'utf8').trim() === name) unlinkSync(last); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
rmSync(reserved, { recursive: true, force: true, maxRetries: 3 });
return dir;
} finally { await unlock(); }
}

export function createProfile(name: string) {
const dir = profilePath(name);
if (existsSync(dir)) throw new Error(`Profile already exists: ${name}`);
Expand Down Expand Up @@ -67,9 +86,17 @@ export function rememberProfile(name: string) { atomicWrite(join(dataHome(), 'la
export class ProfileBusyError extends Error {
constructor(readonly owner: string) { super(`Profile already running: ${owner}`); }
}
/** POSIX keeps a socket file in the profile itself, so every Pi on the profile finds the same one whatever its TMPDIR, and Git skips it. Windows has no Unix sockets, so the profile path is hashed into a named pipe in the machine-wide pipe namespace. */
/**
* The lock lives outside the profile directory so a deletion can rename the profile away without moving the socket.
* An old server's close therefore cannot unlink a replacement owner's socket at the same path.
* POSIX keeps the lock file under a hidden per-profile directory, so every Pi on the profile finds the same one whatever its TMPDIR, and Git skips it.
* Windows has no Unix sockets, so the profile path is hashed into a named pipe in the machine-wide pipe namespace.
*/
export function profileSocket(dir: string, purpose: 'lock' | 'windows' = 'lock') {
if (!isWindows) return join(dir, `${purpose}.sock`);
if (!isWindows) {
if (purpose === 'lock') return join(dataHome(), '.locks', basename(dir), 'lock.sock');
return join(dir, `${purpose}.sock`);
}
// Hash the directory's real identity, so another casing, a junction or a symlink to the same profile finds the same pipe.
let identity = resolve(dir); try { identity = realpathSync.native(identity); } catch {}
return `${PIPE_PREFIX}optchat-${createHash('sha256').update(identity.toLowerCase()).digest('hex').slice(0, 16)}-${purpose}`;
Expand Down Expand Up @@ -106,6 +133,7 @@ function readOwner(path: string) {
/** OS-owned socket lifetime, no timeout-based stealing of a busy profile. */
export async function lockProfile(dir: string, description: string) {
const socketPath = profileSocket(dir); checkSocketPath(socketPath);
if (!isWindows) mkdirSync(dirname(socketPath), { recursive: true });
const server = createServer(socket => { socket.on('error', () => socket.destroy()); socket.end(description); });
const listen = () => new Promise<void>((resolve, reject) => {
const failed = (error: Error) => { server.off('listening', ready); reject(error); };
Expand Down
28 changes: 27 additions & 1 deletion test/menu.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Theme } from '@earendil-works/pi-coding-agent';
import { optchatMenu, type MenuAction } from '../src/menu.ts';
import { KeybindingsManager } from '@earendil-works/pi-tui';
import { CREATE_PROFILE, optchatMenu, profilePicker, type MenuAction, type ProfilePick } from '../src/menu.ts';
import { defaults } from '../src/profiles.ts';

const plain = { fg: (_color: string, text: string) => text, bold: (text: string) => text } as unknown as Theme;
const status = { profile: 'personal', messages: 5251, pending: 1, compactor: defaults.compactor, agents: defaults.subagent, error: 'Rate limited' };
/** The real matcher, pointing the delete keybinding at Pi's default for the session list. */
const keys = new KeybindingsManager({ 'app.session.delete': { defaultKeys: 'ctrl+d' } } as never);

test('/optchat shows the profile status as aligned rows and returns the chosen action', () => {
const chosen: (MenuAction | undefined)[] = [];
Expand All @@ -25,3 +28,26 @@ test('/optchat without a profile says so and keeps every action', () => {
assert.ok(lines.includes(' OptChat') && lines.includes(' No active profile'));
assert.ok(lines.some(line => line.includes('import') && line.includes('Import history')));
});

test('/optchat profile lists profiles, and its keys decide delete, create and cancel', () => {
const picks: ProfilePick[] = [];
const picker = profilePicker(plain, keys, ['only'], pick => picks.push(pick));
const lines = picker.render(80).map(line => line.trimEnd());
assert.ok(lines.some(line => line.includes('OptChat profile')));
assert.ok(lines.some(line => line.includes('Ctrl+D delete')));
assert.ok(lines.some(line => line.includes(CREATE_PROFILE)));
picker.handleInput('j'); // now on the create row
picker.handleInput('\x04');
assert.deepEqual(picks, [], 'the create row is not a profile to delete');
picker.handleInput('k'); // back on the profile row
picker.handleInput('\x04');
assert.deepEqual(picks, [{ delete: 'only' }]);
// A list with no profiles still offers create; Enter opens the row, Esc cancels.
const fresh: ProfilePick[] = [];
const empty = profilePicker(plain, keys, [], pick => fresh.push(pick));
assert.ok(empty.render(80).some(line => line.includes('Enter create')));
empty.handleInput('j'); empty.handleInput('\r');
assert.deepEqual(fresh, [CREATE_PROFILE]);
empty.handleInput('\x1b');
assert.deepEqual(fresh, [CREATE_PROFILE, undefined]);
});
Loading