Skip to content

Delete a profile from the profile picker with Ctrl+D - #270

Open
apoapostolov wants to merge 2 commits into
jonaslsaa:mainfrom
apoapostolov:feat/profile-delete-shortcut
Open

apoapostolov wants to merge 2 commits into
jonaslsaa:mainfrom
apoapostolov:feat/profile-delete-shortcut

Conversation

@apoapostolov

@apoapostolov apoapostolov commented Nov 1, 2026 •

Copy link
Copy Markdown

Closes #269.

What changed

/optchat profile now deletes the highlighted profile with Ctrl+D, the key app.session.delete uses in Pi's own session list, and confirms before touching anything.

────────────────────────────────────────────────────
 OptChat profile

 → demo
   keep
   + Create profile

 ↑↓ select · Enter open · Ctrl+D delete · Esc cancel
────────────────────────────────────────────────────

Then:

 Delete profile keep?
 Its memory, runs, usage ledger and settings are removed from disk. Pi sessions are kept.

 → Yes
   No

Three files carry the feature:

  • deleteProfile() in src/profiles.ts: refuses a profile the lock cannot release, removes the folder, and clears last-profile when it named that profile (lastProfile() hides a missing name, but a profile created again under the same name would otherwise inherit the stale pointer).
  • profilePicker() in src/menu.ts: the profile list as a custom component, so a bare key press reaches it. + Create profile is not a profile, so Ctrl+D there does nothing. It also accepts j/k, like the /optchat menu already does.
  • chooseProfile() in src/index.ts: picks, then loops. A delete gets a confirmation, a refusal from deleteProfile() becomes a notification, and the picker reopens either way.

Why the picker had to change shape

ctx.ui.select answers one question and hands control back, so it cannot see a key that means "delete the row I am on". The picker is now ctx.ui.custom, the same as optchatMenu and the settings page. Non-TUI hosts keep returning undefined before any dialog is shown, so RPC and SDK sessions behave exactly as before.

Two refusals worth calling out

  • Another Pi holds the profile. deleteProfile() asks the lock who owns it, so the removal becomes Profile already running: …. Deleting under a live process would leave it writing into a folder that no longer exists.
  • This session serves the profile. Same reason from inside: the store is open, so the picker refuses with "this session is using it. Switch profiles first."

Evidence

  • npm run check clean.
  • npm test: 376 tests, 374 pass, 1 fail, 1 skipped. The failure is pre-existing on main (agents.test.ts, "a task may name its own model and thinking level…"), same on f724d31 without this branch.
  • New: test/profile-delete.test.ts (folder and pointer removal, another-name pointer survives, a held profile refused then deletable after release, missing profile, invalid name) and three test/menu.test.ts cases for the picker's Enter, Ctrl+D and Escape.
  • Updated: the three profile-busy.test.ts flows that drove the old select now drive the picker through ctx.ui.custom via profilePickerAnswers() in test/support.ts, which renders the component and walks to a row before pressing the key. Two of them cover the delete path end to end: confirmed deletes, declined keeps the profile and reopens the picker.
  • Live TUI check with OPTCHAT_HOME pointed at a throwaway directory: Ctrl+D on demo showed the confirmation, confirming deleted the folder and the notification said Deleted demo.; Ctrl+D on keep then Escape left keep on disk and returned to the list.

Risk

The change is a feature path with no migration: nothing existing changes shape, config.json and the profile layout are untouched, and a user who never presses Ctrl+D sees the same list with one extra hint line. No version bump, on the assumption that releases are cut on this repo.

Pairs with #267

Deleting a profile is the easy way to leave a Pi session bound to a folder that no longer exists, which is #267. This PR cannot see those sessions, so the recovery proposed there (resume as plain Pi, warn once, keep --optchat-profile <name> loud) is the other half of the story. The delete does refuse the two cases it can see: a profile another Pi holds, and the profile the current session serves.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Profile deletion can race with a concurrent opener and remove a live writer’s directory because the owner probe does not maintain exclusive protection through deletion.

3 open issues
What changed in this PR

Adds confirmed profile deletion to OptChat’s TUI profile picker, addressing #269 while preserving non-TUI behavior and keeping Pi session files.

Changes:

  • Adds a custom profile picker with Ctrl+D deletion and confirmation.
  • Adds profile-folder removal, ownership checks, and saved-pointer cleanup.
  • Updates documentation and tests for picker interactions and deletion flows.
File Description
test/​support.ts Adds helpers for driving the custom profile picker.
test/​profile-delete.test.ts Tests deletion, pointer handling, and refusal cases.
test/​profile-busy.test.ts Adapts picker tests and covers confirmed and declined deletion.
test/​menu.test.ts Tests Ctrl+D, Enter, Escape, and the create row.
src/​profiles.ts Adds profile deletion and saved-pointer cleanup.
src/​menu.ts Implements the custom picker and deletion shortcut.
src/​index.ts Coordinates confirmation, active-profile refusal, and picker reopening.
README.md Documents the shortcut and deletion behavior.

Review effort: Balanced

Comment thread src/profiles.ts Resolved
Comment thread src/menu.ts Resolved
Comment thread test/profile-delete.test.ts Resolved
@jonaslsaa

Copy link
Copy Markdown
Owner

Thanks for this! The feature is sound and the refusals work. A few things before we can merge:

  1. Ctrl+D doesn't fire in many terminals. Pi turns on the kitty keyboard protocol / modifyOtherKeys, so Ghostty, kitty and WezTerm send Ctrl+D as \x1b[100;5u or \x1b[27;5;100~, not \x04. Use the keys argument the custom component already gets: keys.matches(data, 'app.session.delete'). That's what Pi's session list does, and it respects user keybindings.
  2. Delete under the real lock. Instead of checking who owns the lock and then removing the folder, take it with lockProfile(dir, 'deleting'), delete inside try, release in finally. That closes the race and lets the isWindows/lstat checks go.
  3. Rename before deleting. Rename to profiles/.deleting-<name>-<ts> first, then rmSync. A failed delete on Windows then can't leave half a profile in the picker (listProfiles already hides dot names).
  4. Share the list code between profilePicker and optchatMenu instead of copying the styling and j/k handling.
  5. Smaller: the 3 picker tests can be 1, the test helper can shrink, and the README line can be shorter.

It also needs a rebase onto main (#278 landed). The only conflict is test/profile-busy.test.ts; two of #278's tests need to use your profilePickerAnswers helper.

About the failing test you saw: it passes on clean main and only fails when provider API keys are set in the environment, because models() then lists built-in models. So it's a fragile test, not a bug from this PR.

If you'd rather not do the rework, let us know and we'll take it over and keep you as author. #267 gets its own small fix after this one.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical profile-deletion race lets an old unlock remove a replacement owner’s live lock socket, allowing concurrent writers to acquire the same profile.

3 open issues
3 resolved since last review

Review effort: Balanced

Comment thread src/profiles.ts
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);
Comment thread src/menu.ts Outdated
/** 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) {
const list = new SelectList(items, items.length, {
Comment thread src/profiles.ts Outdated
Comment on lines +41 to +44
rmSync(reserved, { recursive: true, force: true, maxRetries: 3 });
// listProfiles() already hides a name with no folder, but without this a profile created again under the same name would inherit the stale pointer.
const last = join(dataHome(), 'last-profile');
try { if (readFileSync(last, 'utf8').trim() === name) unlinkSync(last); } catch {}
Rebased onto main (jonaslsaa#278); its two picker-fallthrough tests now use the
profilePickerAnswers helper. After the review:

- Ctrl+D goes through the keybindings argument (keys.matches(data,
  'app.session.delete')), so it fires in kitty, Ghostty and WezTerm
  encodings and follows the user's own bindings, not a raw 0x04.
- deleteProfile takes the real lock (lockProfile(dir, 'deleting'), delete
  in try, release in finally) and drops the isWindows/lstat owner probe.
- The folder is renamed to profiles/.deleting-NAME-TIMESTAMP before
  rmSync, so a failed delete cannot leave half a profile in the picker.
- profilePicker and optchatMenu share one list block: same styling and
  j/k handling instead of a copy.
- The three picker tests are one, the helper no longer swallows
  unexpected openings or a dead try/catch, and the README line is
  shorter.
…anup order, picker scrolling, shorter README
@apoapostolov
apoapostolov force-pushed the feat/profile-delete-shortcut branch from dff607c to ca82e92 Compare November 4, 2026 06:33

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delete a profile from the profile picker with Ctrl+D

3 participants