···2828- Fix `session_exit` sometimes missing from the events log when the daemon was killed via SIGTERM (`pty kill` and similar). The event was queued on the `EventWriter` chain but the daemon exited before the append flushed. `close()` now waits for the child process's `onExit` (bounded at 2s) and then drains the writer before resolving.
29293030### Interactive TUI
3131+- **"Create new session..." is now a one-keystroke action.** Pressing Enter spawns `$SHELL` (fallback `bash`) in `$HOME` with a random id and no `displayName`. No wizard, no directory picker, no name/command prompts. Use `pty rename` and `pty exec` from inside the new session to promote it into something specific. Remote "Create new session..." mirrors the same one-shot flow via `pty-relay connect <url> --spawn <random-id>` (the relay is responsible for the remote-side shell/cwd defaults).
3132- Add `--preselect-new` flag: `pty --preselect-new` opens the interactive TUI with "Create new session..." pre-selected (useful for pty-layout panes that should land on the create prompt)
3233- Add `--filter-tag key=value` flag (repeatable): filters the TUI to sessions matching all given tags AND auto-applies those tags to any session created from this TUI instance — so new sessions (local and remote) stay in the filtered view (e.g., pty-layout layouts)
3334- Remote session spawns forward filter tags to pty-relay as `--tag key=value` so remote sessions created from a filtered TUI are tagged on the remote side and stay in the filtered view
+52-400
src/tui/interactive.ts
···11// Interactive session list — built with the declarative TUI framework + app()
22import * as fs from "node:fs";
33+import * as os from "node:os";
34import * as path from "node:path";
55+import { randomBytes } from "node:crypto";
46import { execFileSync, spawn as spawnChild, spawnSync } from "node:child_process";
57import { attach } from "../client.ts";
68import {
77- listSessions, validateName, acquireLock, releaseLock,
99+ listSessions, acquireLock, releaseLock,
810 cleanupAll, getSession, getSessionDir, type SessionInfo,
911} from "../sessions.ts";
1010-import { spawnDaemon, resolveCommand } from "../spawn.ts";
1212+import { spawnDaemon } from "../spawn.ts";
1113import { matchesAllTags } from "../tags.ts";
1214import {
1315 app, screen, signal, computed, batch,
1414- text, row, spacer, panel, selectable, footer, canvas,
1616+ text, panel, footer,
1517 groupedSelectable, type SelectableGroup,
1618 updateScrollRegion, themes,
1719 type KeyEvent, type ScreenContext, type UINode,
1820} from "./index.ts";
1921// Reuse utility functions from the existing screen modules
2022import { sortSessions, shortPath, timeAgo } from "./screen-list.ts";
2121-import { dedupName, listDirs } from "./screen-create.ts";
2223import { fuzzyMatch } from "./fuzzy.ts";
23242424-/** Generate a session name from dir and command. */
2525-function autoName(dir: string, cmd: string, cmdArgs: string[]): string {
2626- const dirPart = path.basename(dir);
2727- const cmdBase = path.basename(cmd);
2828- const firstArg = cmdArgs.find(a => !a.startsWith("-") && a.length < 30);
2929- let cmdPart = cmdBase;
3030- if (firstArg) {
3131- const argBase = path.basename(firstArg).replace(/\.[^.]+$/, "");
3232- if (argBase && /^[a-zA-Z0-9._-]+$/.test(argBase)) {
3333- cmdPart = `${cmdBase}-${argBase}`;
3434- }
3535- }
3636- const raw = `${dirPart}-${cmdPart}`;
3737- return raw.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
2525+/** Short random id matching src/cli.ts's randomSessionName — 8 chars of
2626+ * Crockford-ish base32. Used when spawning sessions from the TUI where
2727+ * the user didn't pick a name. */
2828+function randomSessionName(): string {
2929+ const alphabet = "23456789abcdefghjkmnpqrstuvwxyz";
3030+ const bytes = randomBytes(8);
3131+ let out = "";
3232+ for (const b of bytes) out += alphabet[b % alphabet.length];
3333+ return out;
3434+}
3535+3636+/** The directory the shell spawned from the TUI opens in. Defaults to the
3737+ * user's home so "Create new session" is predictable regardless of where
3838+ * the `pty` binary was invoked. */
3939+function defaultCwd(): string {
4040+ return process.env.HOME ?? os.homedir();
4141+}
4242+4343+/** The shell to spawn for a one-keystroke "Create new session". */
4444+function defaultShell(): string {
4545+ return process.env.SHELL ?? "bash";
3846}
39474048// ============================================================
···4452const sessions = signal<SessionInfo[]>([]);
4553const filterText = signal("");
4654const selectedIndex = signal(0);
4747-const currentScreen = signal<"list" | "create" | "remote-create">("list");
48554956/** Tag filter from `--filter-tag key=value`. Filters the list AND auto-applies
5057 * to any session created from this TUI instance. */
···7885function currentTheme() {
7986 return themes[themeNames[themeIndex.get()]];
8087}
8181-8282-// Create wizard state
8383-const createStep = signal<"dir-initial" | "dir-browse" | "name-command">("dir-initial");
8484-const cwdPath = signal(process.cwd());
8585-const browsePath = signal(process.cwd());
8686-const browseFilter = signal("");
8787-const createSelectedIndex = signal(0);
8888-const sessionName = signal("");
8989-const sessionCommand = signal("");
9090-const nameManuallyEdited = signal(false);
9191-const focusedField = signal<"name" | "command">("command");
9292-const existingNames = signal<Set<string>>(new Set());
9393-9494-// Remote create wizard state
9595-const remoteCreateHost = signal<RelayHost | null>(null);
9696-const remoteSessionName = signal("");
9797-const remoteSessionCommand = signal("bash");
9898-const remoteFocusedField = signal<"name" | "command">("name");
998810089// ============================================================
10190// Relay integration
···417406 const item = getItemAtIndex(idx);
418407 if (!item) return true;
419408 if (item.type === "create") {
420420- batch(() => {
421421- currentScreen.set("create");
422422- createStep.set("dir-initial");
423423- createSelectedIndex.set(0);
424424- existingNames.set(new Set(sessions.peek().map(s => s.name)));
425425- });
409409+ // One-keystroke local create: spawn the user's shell in $HOME with a
410410+ // random id and no displayName. The user can `pty rename` and/or
411411+ // `pty exec` from inside to promote it.
412412+ doCreate(defaultCwd(), randomSessionName(), defaultShell());
426413 return true;
427414 }
428415 if (item.type === "remote-create" && item.remoteHost) {
429429- batch(() => {
430430- remoteCreateHost.set(item.remoteHost!);
431431- remoteSessionName.set("");
432432- remoteSessionCommand.set("bash");
433433- remoteFocusedField.set("name");
434434- currentScreen.set("remote-create");
435435- });
416416+ // One-keystroke remote create: ask pty-relay to spawn a shell on the
417417+ // remote host with a random id. The relay is responsible for the
418418+ // remote-side shell/cwd defaults.
419419+ doSpawnRemote(item.remoteHost, randomSessionName());
436420 return true;
437421 }
438422 if (item.type === "remote" && item.remote) {
···476460});
477461478462// ============================================================
479479-// Create screen (multi-step wizard)
480480-// ============================================================
481481-482482-function renderDirInitialUI(ctx: ScreenContext): UINode[] {
483483- const cwd = cwdPath.get();
484484- const idx = createSelectedIndex.get();
485485- const items = [
486486- { label: shortPath(cwd) + " (current directory)" },
487487- { label: "Choose disk location\u2026" },
488488- ];
489489- const region = updateScrollRegion(
490490- { offset: 0, selectedIndex: idx, totalItems: items.length, viewportHeight: 10 },
491491- items.length, 10,
492492- );
493493- return [
494494- panel("New Session \u2014 Choose Directory", [
495495- selectable(region, items, (item, _i, selected) => [
496496- text(selected ? " " + item.label : " " + item.label,
497497- selected ? "accent" : "primary", { bold: selected, truncate: true }),
498498- ]),
499499- ]),
500500- footer("\u2191\u2193 select \u23ce confirm esc back"),
501501- ];
502502-}
503503-504504-function renderDirBrowseUI(ctx: ScreenContext): UINode[] {
505505- const bp = browsePath.get();
506506- const bf = browseFilter.get();
507507- const dirs = listDirs(bp, bf);
508508- const idx = createSelectedIndex.get();
509509-510510- const items = [
511511- { label: "[Select this directory]", dim: true },
512512- { label: "..", dim: true },
513513- ...dirs.map(d => ({ label: d + "/", dim: false })),
514514- ];
515515- const region = updateScrollRegion(
516516- { offset: 0, selectedIndex: idx, totalItems: items.length, viewportHeight: Math.max(1, ctx.rows - 6) },
517517- items.length, Math.max(1, ctx.rows - 6),
518518- );
519519-520520- const filterLine = bf
521521- ? text(" Filter: " + bf, "primary")
522522- : null;
523523-524524- return [
525525- panel("Browse \u2014 " + shortPath(bp), [
526526- ...(filterLine ? [filterLine, text("", "muted")] : []),
527527- selectable(region, items, (item, _i, selected) => [
528528- text(selected ? " " + item.label : " " + item.label,
529529- selected ? "accent" : (item.dim ? "muted" : "primary"),
530530- { bold: selected, truncate: true }),
531531- ]),
532532- ]),
533533- footer("\u2191\u2193 select \u23ce enter esc back type to filter"),
534534- ];
535535-}
536536-537537-function renderNameCommandUI(ctx: ScreenContext): UINode[] {
538538- const bp = browsePath.peek();
539539- const cwd = cwdPath.peek();
540540- const dir = bp !== cwd ? bp : cwd;
541541- const focused = focusedField.get();
542542- const name = sessionName.get();
543543- const cmd = sessionCommand.get();
544544-545545- return [
546546- panel("New Session", [
547547- text(" Directory: " + shortPath(dir), "muted"),
548548- text("", "muted"),
549549- row(
550550- text(" Name: ", focused === "name" ? "accent" : "muted", { bold: focused === "name" }),
551551- text(name + (focused === "name" ? "\u2588" : ""), "primary"),
552552- ),
553553- row(
554554- text(" Command: ", focused === "command" ? "accent" : "muted", { bold: focused === "command" }),
555555- text(cmd + (focused === "command" ? "\u2588" : ""), "primary"),
556556- ),
557557- canvas(() => {}, {}), // flex spacer
558558- ]),
559559- footer("\u21e5 switch field \u23ce create esc back"),
560560- ];
561561-}
562562-563563-const createScreen = screen({
564564- id: "create",
565565-566566- render(ctx: ScreenContext): UINode[] {
567567- const step = createStep.get();
568568- if (step === "dir-initial") return renderDirInitialUI(ctx);
569569- if (step === "dir-browse") return renderDirBrowseUI(ctx);
570570- return renderNameCommandUI(ctx);
571571- },
572572-573573- handleKey(key: KeyEvent, _ctx: ScreenContext): boolean {
574574- const step = createStep.peek();
575575- if (step === "dir-initial") return handleDirInitialKey(key);
576576- if (step === "dir-browse") return handleDirBrowseKey(key);
577577- return handleNameCommandKey(key);
578578- },
579579-});
580580-581581-function handleDirInitialKey(key: KeyEvent): boolean {
582582- if (key.name === "up") { createSelectedIndex.set(0); return true; }
583583- if (key.name === "down") { createSelectedIndex.set(1); return true; }
584584- if (key.name === "return") {
585585- if (createSelectedIndex.peek() === 0) {
586586- batch(() => {
587587- createStep.set("name-command");
588588- sessionName.set(dedupName(path.basename(cwdPath.peek()), existingNames.peek()));
589589- nameManuallyEdited.set(false);
590590- sessionCommand.set("");
591591- });
592592- } else {
593593- batch(() => {
594594- createStep.set("dir-browse");
595595- browsePath.set(cwdPath.peek());
596596- createSelectedIndex.set(0);
597597- browseFilter.set("");
598598- });
599599- }
600600- return true;
601601- }
602602- if (key.name === "escape" || (key.name === "c" && key.ctrl)) {
603603- batch(() => { currentScreen.set("list"); });
604604- return true;
605605- }
606606- return true;
607607-}
608608-609609-function handleDirBrowseKey(key: KeyEvent): boolean {
610610- const dirs = listDirs(browsePath.peek(), browseFilter.peek());
611611- const totalItems = 2 + dirs.length;
612612- const idx = createSelectedIndex.peek();
613613-614614- if (key.name === "up") { createSelectedIndex.set(Math.max(0, idx - 1)); return true; }
615615- if (key.name === "down") { createSelectedIndex.set(Math.min(totalItems - 1, idx + 1)); return true; }
616616- if (key.name === "return") {
617617- if (idx === 0) {
618618- batch(() => {
619619- createStep.set("name-command");
620620- sessionName.set(dedupName(path.basename(browsePath.peek()), existingNames.peek()));
621621- nameManuallyEdited.set(false);
622622- sessionCommand.set("");
623623- });
624624- } else if (idx === 1) {
625625- const parent = path.dirname(browsePath.peek());
626626- if (parent !== browsePath.peek()) {
627627- batch(() => { browsePath.set(parent); createSelectedIndex.set(0); browseFilter.set(""); });
628628- }
629629- } else {
630630- const dirName = dirs[idx - 2];
631631- if (dirName) {
632632- batch(() => {
633633- browsePath.set(path.join(browsePath.peek(), dirName));
634634- createSelectedIndex.set(0);
635635- browseFilter.set("");
636636- });
637637- }
638638- }
639639- return true;
640640- }
641641- if (key.name === "escape") {
642642- if (browseFilter.peek()) {
643643- batch(() => { browseFilter.set(""); createSelectedIndex.set(0); });
644644- return true;
645645- }
646646- batch(() => { createStep.set("dir-initial"); createSelectedIndex.set(0); });
647647- return true;
648648- }
649649- if (key.name === "c" && key.ctrl) {
650650- batch(() => { currentScreen.set("list"); });
651651- return true;
652652- }
653653- if (key.name === "backspace") {
654654- if (browseFilter.peek().length > 0) {
655655- const newFilter = browseFilter.peek().slice(0, -1);
656656- const newDirs = listDirs(browsePath.peek(), newFilter);
657657- batch(() => {
658658- browseFilter.set(newFilter);
659659- createSelectedIndex.set(Math.min(idx, 1 + newDirs.length));
660660- });
661661- }
662662- return true;
663663- }
664664- if (key.char && !key.ctrl && !key.alt) {
665665- batch(() => { browseFilter.set(browseFilter.peek() + key.char); createSelectedIndex.set(2); });
666666- return true;
667667- }
668668- return true;
669669-}
670670-671671-function handleNameCommandKey(key: KeyEvent): boolean {
672672- if (key.name === "tab") {
673673- focusedField.set(focusedField.peek() === "name" ? "command" : "name");
674674- return true;
675675- }
676676- if (key.name === "return") {
677677- const name = sessionName.peek().trim();
678678- const cmd = sessionCommand.peek().trim();
679679- if (name && cmd) {
680680- const dir = browsePath.peek() !== cwdPath.peek() ? browsePath.peek() : cwdPath.peek();
681681- doCreate(dir, name, cmd);
682682- }
683683- return true;
684684- }
685685- if (key.name === "escape") {
686686- batch(() => { createStep.set("dir-initial"); createSelectedIndex.set(0); });
687687- return true;
688688- }
689689- if (key.name === "c" && key.ctrl) {
690690- batch(() => { currentScreen.set("list"); });
691691- return true;
692692- }
693693- if (key.name === "backspace") {
694694- if (focusedField.peek() === "name") {
695695- nameManuallyEdited.set(true);
696696- sessionName.set(sessionName.peek().slice(0, -1));
697697- } else {
698698- sessionCommand.set(sessionCommand.peek().slice(0, -1));
699699- regenerateNameIfAuto();
700700- }
701701- return true;
702702- }
703703- if (key.char && !key.ctrl && !key.alt) {
704704- if (focusedField.peek() === "name") {
705705- nameManuallyEdited.set(true);
706706- sessionName.set(sessionName.peek() + key.char);
707707- } else {
708708- sessionCommand.set(sessionCommand.peek() + key.char);
709709- regenerateNameIfAuto();
710710- }
711711- return true;
712712- }
713713- return true;
714714-}
715715-716716-function regenerateNameIfAuto(): void {
717717- if (nameManuallyEdited.peek()) return;
718718- const dir = browsePath.peek() !== cwdPath.peek() ? browsePath.peek() : cwdPath.peek();
719719- const cmd = sessionCommand.peek().trim();
720720- if (!cmd) {
721721- sessionName.set(dedupName(path.basename(dir), existingNames.peek()));
722722- return;
723723- }
724724- const parts = cmd.split(/\s+/);
725725- const name = autoName(dir, parts[0], parts.slice(1));
726726- sessionName.set(dedupName(name, existingNames.peek()));
727727-}
728728-729729-// ============================================================
730463// Attach / Create
731464// ============================================================
732465···768501 refreshRelayHosts();
769502 batch(() => {
770503 sessions.set(updated);
771771- currentScreen.set("list");
772504 filterText.set("");
773505 const maxIdx = Math.max(0, totalItems.get() - 1);
774506 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx);
···803535 refreshRelayHosts();
804536 batch(() => {
805537 sessions.set(updated);
806806- currentScreen.set("list");
807538 filterText.set("");
808539 const maxIdx = Math.max(0, totalItems.get() - 1);
809540 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx);
···820551 const updated = await listSessions();
821552 batch(() => {
822553 sessions.set(updated);
823823- currentScreen.set("list");
824554 filterText.set("");
825555 const maxIdx = Math.max(0, totalItems.get() - 1);
826556 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx);
···833563 const updated = await listSessions();
834564 batch(() => {
835565 sessions.set(updated);
836836- currentScreen.set("list");
837566 filterText.set("");
838567 const maxIdx = Math.max(0, totalItems.get() - 1);
839568 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx);
···843572 });
844573}
845574846846-async function doCreate(dir: string, name: string, command: string): Promise<void> {
575575+/** Spawn a new local session with the given shell in the given cwd. Used
576576+ * by the one-keystroke "Create new session" path; name is a random id
577577+ * generated by the caller. */
578578+async function doCreate(dir: string, name: string, shell: string): Promise<void> {
847579 myApp?.pause();
848580849849- try {
850850- validateName(name);
851851- } catch (e: any) {
852852- console.error(e.message);
853853- const updated = await listSessions();
854854- sessions.set(updated);
855855- currentScreen.set("list");
856856- myApp?.resume();
857857- return;
858858- }
859859-860860- let existing;
861861- try {
862862- existing = await getSession(name);
863863- } catch {
864864- existing = null;
865865- }
866866-867867- if (existing?.status === "running") {
868868- doAttach(name);
869869- return;
870870- }
871871-581581+ // Random names come from randomSessionName() — no collision check needed,
582582+ // but we still acquireLock to guard against multi-process races during
583583+ // creation.
872584 if (!acquireLock(name)) {
873585 console.error(`Session "${name}" is being created by another process.`);
874586 const updated = await listSessions();
875587 sessions.set(updated);
876876- currentScreen.set("list");
877588 myApp?.resume();
878589 return;
879590 }
880591881881- if (existing?.status === "exited") {
882882- cleanupAll(name);
883883- }
884884-885885- // Spawn via sh -c so the command field supports quotes, pipes, env vars, etc.
886886- const shellCmd = "/bin/sh";
887887- const shellArgs = ["-c", command];
888888-889592 // Auto-apply --filter-tag tags so the new session stays in the layout.
890593 const tags = filterTags.peek();
891891- const spawnOpts = { name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir, ...(Object.keys(tags).length > 0 ? { tags } : {}) };
594594+ // displayName intentionally unset — matches `pty run --no-display-name`.
595595+ // Users run `pty rename` / `pty exec` from inside to promote it.
596596+ const spawnOpts = {
597597+ name,
598598+ command: shell,
599599+ args: [] as string[],
600600+ displayCommand: shell,
601601+ cwd: dir,
602602+ ...(Object.keys(tags).length > 0 ? { tags } : {}),
603603+ };
892604893605 try {
894606 await spawnDaemon(spawnOpts);
···897609 console.error(e.message);
898610 const updated = await listSessions();
899611 sessions.set(updated);
900900- currentScreen.set("list");
901612 myApp?.resume();
902613 return;
903614 } finally {
···908619}
909620910621// ============================================================
911911-// Remote create screen
912912-// ============================================================
913913-914914-const remoteCreateScreen = screen({
915915- id: "remote-create",
916916-917917- render(_ctx: ScreenContext): UINode[] {
918918- const host = remoteCreateHost.get();
919919- if (!host) return [text("No host selected", "error")];
920920-921921- const name = remoteSessionName.get();
922922-923923- return [
924924- panel(`Spawn on ${host.label}`, [
925925- text("", "muted"),
926926- text(" Session name: " + name + "\u2588", "primary"),
927927- text("", "muted"),
928928- text(" Enter to spawn, Escape to cancel", "muted", { dim: true }),
929929- ]),
930930- ];
931931- },
932932-933933- handleKey(key: KeyEvent, _ctx: ScreenContext): boolean {
934934- if (key.name === "escape") {
935935- batch(() => {
936936- currentScreen.set("list");
937937- remoteCreateHost.set(null);
938938- });
939939- return true;
940940- }
941941-942942- if (key.name === "return") {
943943- const host = remoteCreateHost.peek();
944944- const name = remoteSessionName.peek().trim();
945945- if (!host || !name) return true;
946946- doSpawnRemote(host, name);
947947- return true;
948948- }
949949-950950- if (key.name === "backspace") {
951951- remoteSessionName.set(remoteSessionName.peek().slice(0, -1));
952952- return true;
953953- }
954954-955955- if (key.char && !key.ctrl && !key.alt) {
956956- remoteSessionName.set(remoteSessionName.peek() + key.char);
957957- return true;
958958- }
959959-960960- return true;
961961- },
962962-});
963963-964964-// ============================================================
965622// Entry point
966623// ============================================================
967624···999656 refreshRelayHosts();
10006571001658 myApp = app({
10021002- screen: () => {
10031003- const s = currentScreen.get();
10041004- if (s === "remote-create") return remoteCreateScreen;
10051005- if (s === "create") return createScreen;
10061006- return listScreen;
10071007- },
659659+ screen: () => listScreen,
1008660 theme: () => currentTheme(),
1009661 onKey: (key) => {
1010662 if (key.name === "g" && key.ctrl) { cycleTheme(); return true; }