Skip to content
Draft
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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Markdown editing helpers in `/edit`** ([#463](https://github.com/raiseCatError/notMyShell/issues/463), first slice; Markdown files only). **Enter** continues a bullet, numbered or task list (same marker and indentation, next number, unchecked box) and splits an item at the caret; on an empty item it ends the list (a nested one steps out a level). **Tab / Shift+Tab** on list items indent or outdent the item with its children (selected items together); elsewhere Tab is a tab as before. **F1** opens the Markdown actions: toggle a task checkbox, renumber the list at the caret, insert a link or image around the selection, wrap the selection in bold, italic, code or strikethrough (again to unwrap). Every action changes only the lines it names and is one undo step; text is never reflowed or regenerated.
- **Crash recovery for `/edit` and the document workspace** ([#469](https://github.com/raiseCatError/notMyShell/issues/469)). Unsaved changes are written, a moment after you stop typing, to a private draft in NMSh's data folder (`recovery/`, mode 0700; files 0600). After a crash, a kill or a closed window, opening the file offers **Restore draft**, **Compare with file**, **Discard draft** or **Keep for later**; nothing is applied until you choose, a draft made from an older version of the file says so, and the file itself is only ever written by the editor's normal checked save (which also retires the draft). Two windows on one file keep separate drafts, and a draft of a window that is still running is never offered. Drafts are bounded (2 MiB each, 50 in all, 30 days) and are never sent anywhere.

### Changed

- **Startup session picker** ([design](docs/design/session-interaction-ux.md)). With several detached sessions, Enter now resumes the highlighted session when none is selected (it used to start a new one), N or Esc starts a new session, and the footer says what Enter and X will act on. X kills the selected sessions, or the highlighted one: the confirmation names the count and lists them, so A then X is Kill all; every target is listed by name (never "and N more"); sessions attached in another window, or attached while the confirmation was open, are never touched, and a line reports what happened to each, failures first. A service that cannot be reached is reported as a failure, never as "already ended"; a session this window ended whose transcript could not be archived yet says so (the spool is kept and the next launch archives it); and a session being ended can no longer be attached by another window in the moment before its shell exits. In the one-session prompt, a kill the service refused or could not perform returns to the prompt with the reason instead of starting fresh as if it had worked. Rows and the one-session prompt show the session's name and shell and the same evidence-based status as `/resume` (idle, what runs, waiting for input) instead of always saying `zsh`, and the screen redraws on resize.

## [0.23.0] - 2026-10-21 — Vespyr Arcade

The Arcade grows from five games to 33, and it can now be played while a command keeps running.
Expand Down
6 changes: 3 additions & 3 deletions docs/design/session-interaction-ux.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,11 +135,11 @@ Live sessions are owned by the per-user session service (`nmshd`), which listens
- **Session limit:** at most 16 live sessions per service (`NMSH_MAX_SESSIONS`). When the limit is reached, a new window falls back to an in-process shell with a notice. That window's shell ends when the window closes; it cannot be detached or reattached. Detached sessions are never ended to make room; end one, or kill it from `/resume`.
- **Startup restore:** two settings in Config → Sessions.
- **Startup restore:** Ask (default), Always or Never.
- Ask: with one detached session, the launch shows its directory, what it is running and its age, then offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D). A and D also save the setting.
- Ask: with one detached session, the launch shows its name, directory and shell, and the same evidence-based status as `/resume` (idle or what it runs, for how long, whether it waits for input), then offers Resume (R/Enter), Not now (N/Esc), Always resume (A) or Don't resume at startup (D). A and D also save the setting.
- Never only skips restoring at launch. It never ends a session, and `/resume`, detach and reattach are unaffected.
- **Multiple detached sessions:** Ask which (default) or Open all.
- The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions. Esc, or Enter with nothing selected, starts fresh.
- Both screens have one destructive key, `X`, which only opens a confirmation (Enter/Y kills, Esc/N/Ctrl+C cancels). The kill is the same confirmed termination `/resume` uses; the transcript is archived and stays in `/resume`. Sessions attached elsewhere are never touched, and killing the last one starts a fresh session. `--new` still skips restoring, and `--attach <id>` attaches one session explicitly.
- Each row shows the session's name, directory, shell and status. The picker uses ↑↓ to move, Space to select, A to select all (A again clears), and Enter to resume the selected sessions, or the highlighted one when none is selected; the footer says which ("Enter resume this", "Enter resume 2 selected"). N or Esc starts a new session and leaves every listed one running. The screen redraws when the terminal is resized.
- Both screens have one destructive key, `X`, which only opens a confirmation (Enter/Y kills, Esc/N/Ctrl+C cancels). In the picker it targets the selected sessions, or the highlighted one when none is selected; the confirmation names how many and lists them, so A then X is Kill all. Every target is listed by name and nothing else is ended. Each kill is the same confirmed termination `/resume` uses, performed one at a time and addressed by the service's session id (never a PID): the service's list is read again first, and the service itself refuses the kill if a window holds the session at that moment. The transcript is archived and stays in `/resume`; if archiving fails after the shell ended, the line says so and the spool is kept for the next launch. Once a kill is sent the service refuses to attach that session until its shell exits. An unreachable service is reported as a failure, never as "already ended". Sessions attached elsewhere are never touched (a line says how many were left), and killing the last one starts a fresh session. Launching or quitting NMSh never ends a session by itself. `--new` still skips restoring, and `--attach <id>` attaches one session explicitly.
- When several sessions are restored, this window attaches the first. Each other one opens in a new window of the host terminal, running `nmsh --attach <id>`:
- Ghostty on macOS: Ghostty's AppleScript API (`new surface configuration`, then `new window with configuration`). It opens a normal window in the running Ghostty app. The command words are passed as `osascript` arguments and shell-quoted with `quoted form of`, never written into the script. macOS asks once for Automation permission. If AppleScript is disabled or permission is denied, NMSh names the session's `nmsh --attach` command instead.
- Ghostty on Linux: `ghostty -e`.
Expand Down
18 changes: 8 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,16 +174,14 @@ if (args[0] === 'packs') {
const config = loadPromptConfiguration();
// Same confirmed termination as /resume. Service truth is re-read first: a session that ended
// meanwhile is simply gone, and one attached elsewhere is never touched.
const killDetached = async (session: (typeof live)[number]): Promise<'killed' | 'gone' | 'attached'> => {
const [{killAndArchive}, {TranscriptStore}] = await Promise.all([import('./session/liveSessions.js'), import('./sessions/TranscriptStore.js')]);
const current = (await listLiveSessions().catch(() => [])).find(item => item.id === session.id);
if (!current) return 'gone';
if (current.state !== 'detached') return 'attached';
try { await killAndArchive(current, {store: new TranscriptStore(), origin: 'the startup picker'}); return 'killed'; }
catch (error) {
if (!(await listLiveSessions().catch(() => [current])).some(item => item.id === session.id)) return 'gone';
throw error;
}
// An unreachable service is a failure, never "already ended"; only a shell this call ended counts as killed.
const killDetached = async (session: (typeof live)[number]) => {
const [{endDetachedSession, archiveKilled}, {killSession}, {defaultRuntimeDir, socketPathFor}, {TranscriptStore}] = await Promise.all([
import('./session/liveSessions.js'), import('./session/SocketSessionClient.js'), import('./session/runtimeDir.js'), import('./sessions/TranscriptStore.js')]);
const store = new TranscriptStore();
return endDetachedSession(session, {list: () => listLiveSessions(),
kill: current => killSession(socketPathFor(defaultRuntimeDir()), current.id),
archive: current => archiveKilled(current, {store, origin: 'the startup picker'})});
};
const restored = await restoreAtStartup(live, {
// After a failed return, never silently attach a different session: ask instead of Always.
Expand Down
6 changes: 6 additions & 0 deletions src/session/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ interface ManagedSession {
tasks: SessionTasks;
/** The attached frontend's task listener, when it listed 'background-tasks'. */
taskListener?: TaskListener;
/** A confirmed kill was sent and the shell has not reported its exit yet: nothing may attach to it now. */
ending?: true;
}

export {AlternateScreenTracker} from './TerminalModes.js';
Expand Down Expand Up @@ -247,6 +249,7 @@ export class SessionService {
const session = this.sessions.get(message.sessionId);
if (!session) { send({type: 'error', code: 'unknown', message: 'no live session with that id'}); break; }
if (session.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; }
if (session.ending) { send({type: 'error', code: 'unknown', message: 'that session is ending'}); break; }
owned = session;
this.bind(session, send, features);
// Opening a session is focusing it: its notice is no longer news in any window.
Expand Down Expand Up @@ -369,6 +372,9 @@ export class SessionService {
// Never pull a shell out from under a frontend that is using it.
if (target.controller) { send({type: 'error', code: 'attached', message: 'session is attached to another frontend'}); break; }
target.shell.once('exit', () => send({type: 'killed', sessionId: message.sessionId}));
// Between the kill and the shell's exit event no window may attach; a second kill just waits for the same exit.
if (target.ending) break;
target.ending = true;
target.shell.kill();
break;
}
Expand Down
Loading
Loading