This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Remove "Create new session" wizard — one-keystroke spawn instead

Nathan Herald (Apr 16, 2026, 12:08 PM +0200) 52296105 26c8dce6

+86 -897
+1
CHANGELOG.md
··· 28 28 - 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. 29 29 30 30 ### Interactive TUI 31 + - **"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). 31 32 - 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) 32 33 - 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) 33 34 - 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
··· 1 1 // Interactive session list — built with the declarative TUI framework + app() 2 2 import * as fs from "node:fs"; 3 + import * as os from "node:os"; 3 4 import * as path from "node:path"; 5 + import { randomBytes } from "node:crypto"; 4 6 import { execFileSync, spawn as spawnChild, spawnSync } from "node:child_process"; 5 7 import { attach } from "../client.ts"; 6 8 import { 7 - listSessions, validateName, acquireLock, releaseLock, 9 + listSessions, acquireLock, releaseLock, 8 10 cleanupAll, getSession, getSessionDir, type SessionInfo, 9 11 } from "../sessions.ts"; 10 - import { spawnDaemon, resolveCommand } from "../spawn.ts"; 12 + import { spawnDaemon } from "../spawn.ts"; 11 13 import { matchesAllTags } from "../tags.ts"; 12 14 import { 13 15 app, screen, signal, computed, batch, 14 - text, row, spacer, panel, selectable, footer, canvas, 16 + text, panel, footer, 15 17 groupedSelectable, type SelectableGroup, 16 18 updateScrollRegion, themes, 17 19 type KeyEvent, type ScreenContext, type UINode, 18 20 } from "./index.ts"; 19 21 // Reuse utility functions from the existing screen modules 20 22 import { sortSessions, shortPath, timeAgo } from "./screen-list.ts"; 21 - import { dedupName, listDirs } from "./screen-create.ts"; 22 23 import { fuzzyMatch } from "./fuzzy.ts"; 23 24 24 - /** Generate a session name from dir and command. */ 25 - function autoName(dir: string, cmd: string, cmdArgs: string[]): string { 26 - const dirPart = path.basename(dir); 27 - const cmdBase = path.basename(cmd); 28 - const firstArg = cmdArgs.find(a => !a.startsWith("-") && a.length < 30); 29 - let cmdPart = cmdBase; 30 - if (firstArg) { 31 - const argBase = path.basename(firstArg).replace(/\.[^.]+$/, ""); 32 - if (argBase && /^[a-zA-Z0-9._-]+$/.test(argBase)) { 33 - cmdPart = `${cmdBase}-${argBase}`; 34 - } 35 - } 36 - const raw = `${dirPart}-${cmdPart}`; 37 - return raw.replace(/[^a-zA-Z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); 25 + /** Short random id matching src/cli.ts's randomSessionName — 8 chars of 26 + * Crockford-ish base32. Used when spawning sessions from the TUI where 27 + * the user didn't pick a name. */ 28 + function randomSessionName(): string { 29 + const alphabet = "23456789abcdefghjkmnpqrstuvwxyz"; 30 + const bytes = randomBytes(8); 31 + let out = ""; 32 + for (const b of bytes) out += alphabet[b % alphabet.length]; 33 + return out; 34 + } 35 + 36 + /** The directory the shell spawned from the TUI opens in. Defaults to the 37 + * user's home so "Create new session" is predictable regardless of where 38 + * the `pty` binary was invoked. */ 39 + function defaultCwd(): string { 40 + return process.env.HOME ?? os.homedir(); 41 + } 42 + 43 + /** The shell to spawn for a one-keystroke "Create new session". */ 44 + function defaultShell(): string { 45 + return process.env.SHELL ?? "bash"; 38 46 } 39 47 40 48 // ============================================================ ··· 44 52 const sessions = signal<SessionInfo[]>([]); 45 53 const filterText = signal(""); 46 54 const selectedIndex = signal(0); 47 - const currentScreen = signal<"list" | "create" | "remote-create">("list"); 48 55 49 56 /** Tag filter from `--filter-tag key=value`. Filters the list AND auto-applies 50 57 * to any session created from this TUI instance. */ ··· 78 85 function currentTheme() { 79 86 return themes[themeNames[themeIndex.get()]]; 80 87 } 81 - 82 - // Create wizard state 83 - const createStep = signal<"dir-initial" | "dir-browse" | "name-command">("dir-initial"); 84 - const cwdPath = signal(process.cwd()); 85 - const browsePath = signal(process.cwd()); 86 - const browseFilter = signal(""); 87 - const createSelectedIndex = signal(0); 88 - const sessionName = signal(""); 89 - const sessionCommand = signal(""); 90 - const nameManuallyEdited = signal(false); 91 - const focusedField = signal<"name" | "command">("command"); 92 - const existingNames = signal<Set<string>>(new Set()); 93 - 94 - // Remote create wizard state 95 - const remoteCreateHost = signal<RelayHost | null>(null); 96 - const remoteSessionName = signal(""); 97 - const remoteSessionCommand = signal("bash"); 98 - const remoteFocusedField = signal<"name" | "command">("name"); 99 88 100 89 // ============================================================ 101 90 // Relay integration ··· 417 406 const item = getItemAtIndex(idx); 418 407 if (!item) return true; 419 408 if (item.type === "create") { 420 - batch(() => { 421 - currentScreen.set("create"); 422 - createStep.set("dir-initial"); 423 - createSelectedIndex.set(0); 424 - existingNames.set(new Set(sessions.peek().map(s => s.name))); 425 - }); 409 + // One-keystroke local create: spawn the user's shell in $HOME with a 410 + // random id and no displayName. The user can `pty rename` and/or 411 + // `pty exec` from inside to promote it. 412 + doCreate(defaultCwd(), randomSessionName(), defaultShell()); 426 413 return true; 427 414 } 428 415 if (item.type === "remote-create" && item.remoteHost) { 429 - batch(() => { 430 - remoteCreateHost.set(item.remoteHost!); 431 - remoteSessionName.set(""); 432 - remoteSessionCommand.set("bash"); 433 - remoteFocusedField.set("name"); 434 - currentScreen.set("remote-create"); 435 - }); 416 + // One-keystroke remote create: ask pty-relay to spawn a shell on the 417 + // remote host with a random id. The relay is responsible for the 418 + // remote-side shell/cwd defaults. 419 + doSpawnRemote(item.remoteHost, randomSessionName()); 436 420 return true; 437 421 } 438 422 if (item.type === "remote" && item.remote) { ··· 476 460 }); 477 461 478 462 // ============================================================ 479 - // Create screen (multi-step wizard) 480 - // ============================================================ 481 - 482 - function renderDirInitialUI(ctx: ScreenContext): UINode[] { 483 - const cwd = cwdPath.get(); 484 - const idx = createSelectedIndex.get(); 485 - const items = [ 486 - { label: shortPath(cwd) + " (current directory)" }, 487 - { label: "Choose disk location\u2026" }, 488 - ]; 489 - const region = updateScrollRegion( 490 - { offset: 0, selectedIndex: idx, totalItems: items.length, viewportHeight: 10 }, 491 - items.length, 10, 492 - ); 493 - return [ 494 - panel("New Session \u2014 Choose Directory", [ 495 - selectable(region, items, (item, _i, selected) => [ 496 - text(selected ? " " + item.label : " " + item.label, 497 - selected ? "accent" : "primary", { bold: selected, truncate: true }), 498 - ]), 499 - ]), 500 - footer("\u2191\u2193 select \u23ce confirm esc back"), 501 - ]; 502 - } 503 - 504 - function renderDirBrowseUI(ctx: ScreenContext): UINode[] { 505 - const bp = browsePath.get(); 506 - const bf = browseFilter.get(); 507 - const dirs = listDirs(bp, bf); 508 - const idx = createSelectedIndex.get(); 509 - 510 - const items = [ 511 - { label: "[Select this directory]", dim: true }, 512 - { label: "..", dim: true }, 513 - ...dirs.map(d => ({ label: d + "/", dim: false })), 514 - ]; 515 - const region = updateScrollRegion( 516 - { offset: 0, selectedIndex: idx, totalItems: items.length, viewportHeight: Math.max(1, ctx.rows - 6) }, 517 - items.length, Math.max(1, ctx.rows - 6), 518 - ); 519 - 520 - const filterLine = bf 521 - ? text(" Filter: " + bf, "primary") 522 - : null; 523 - 524 - return [ 525 - panel("Browse \u2014 " + shortPath(bp), [ 526 - ...(filterLine ? [filterLine, text("", "muted")] : []), 527 - selectable(region, items, (item, _i, selected) => [ 528 - text(selected ? " " + item.label : " " + item.label, 529 - selected ? "accent" : (item.dim ? "muted" : "primary"), 530 - { bold: selected, truncate: true }), 531 - ]), 532 - ]), 533 - footer("\u2191\u2193 select \u23ce enter esc back type to filter"), 534 - ]; 535 - } 536 - 537 - function renderNameCommandUI(ctx: ScreenContext): UINode[] { 538 - const bp = browsePath.peek(); 539 - const cwd = cwdPath.peek(); 540 - const dir = bp !== cwd ? bp : cwd; 541 - const focused = focusedField.get(); 542 - const name = sessionName.get(); 543 - const cmd = sessionCommand.get(); 544 - 545 - return [ 546 - panel("New Session", [ 547 - text(" Directory: " + shortPath(dir), "muted"), 548 - text("", "muted"), 549 - row( 550 - text(" Name: ", focused === "name" ? "accent" : "muted", { bold: focused === "name" }), 551 - text(name + (focused === "name" ? "\u2588" : ""), "primary"), 552 - ), 553 - row( 554 - text(" Command: ", focused === "command" ? "accent" : "muted", { bold: focused === "command" }), 555 - text(cmd + (focused === "command" ? "\u2588" : ""), "primary"), 556 - ), 557 - canvas(() => {}, {}), // flex spacer 558 - ]), 559 - footer("\u21e5 switch field \u23ce create esc back"), 560 - ]; 561 - } 562 - 563 - const createScreen = screen({ 564 - id: "create", 565 - 566 - render(ctx: ScreenContext): UINode[] { 567 - const step = createStep.get(); 568 - if (step === "dir-initial") return renderDirInitialUI(ctx); 569 - if (step === "dir-browse") return renderDirBrowseUI(ctx); 570 - return renderNameCommandUI(ctx); 571 - }, 572 - 573 - handleKey(key: KeyEvent, _ctx: ScreenContext): boolean { 574 - const step = createStep.peek(); 575 - if (step === "dir-initial") return handleDirInitialKey(key); 576 - if (step === "dir-browse") return handleDirBrowseKey(key); 577 - return handleNameCommandKey(key); 578 - }, 579 - }); 580 - 581 - function handleDirInitialKey(key: KeyEvent): boolean { 582 - if (key.name === "up") { createSelectedIndex.set(0); return true; } 583 - if (key.name === "down") { createSelectedIndex.set(1); return true; } 584 - if (key.name === "return") { 585 - if (createSelectedIndex.peek() === 0) { 586 - batch(() => { 587 - createStep.set("name-command"); 588 - sessionName.set(dedupName(path.basename(cwdPath.peek()), existingNames.peek())); 589 - nameManuallyEdited.set(false); 590 - sessionCommand.set(""); 591 - }); 592 - } else { 593 - batch(() => { 594 - createStep.set("dir-browse"); 595 - browsePath.set(cwdPath.peek()); 596 - createSelectedIndex.set(0); 597 - browseFilter.set(""); 598 - }); 599 - } 600 - return true; 601 - } 602 - if (key.name === "escape" || (key.name === "c" && key.ctrl)) { 603 - batch(() => { currentScreen.set("list"); }); 604 - return true; 605 - } 606 - return true; 607 - } 608 - 609 - function handleDirBrowseKey(key: KeyEvent): boolean { 610 - const dirs = listDirs(browsePath.peek(), browseFilter.peek()); 611 - const totalItems = 2 + dirs.length; 612 - const idx = createSelectedIndex.peek(); 613 - 614 - if (key.name === "up") { createSelectedIndex.set(Math.max(0, idx - 1)); return true; } 615 - if (key.name === "down") { createSelectedIndex.set(Math.min(totalItems - 1, idx + 1)); return true; } 616 - if (key.name === "return") { 617 - if (idx === 0) { 618 - batch(() => { 619 - createStep.set("name-command"); 620 - sessionName.set(dedupName(path.basename(browsePath.peek()), existingNames.peek())); 621 - nameManuallyEdited.set(false); 622 - sessionCommand.set(""); 623 - }); 624 - } else if (idx === 1) { 625 - const parent = path.dirname(browsePath.peek()); 626 - if (parent !== browsePath.peek()) { 627 - batch(() => { browsePath.set(parent); createSelectedIndex.set(0); browseFilter.set(""); }); 628 - } 629 - } else { 630 - const dirName = dirs[idx - 2]; 631 - if (dirName) { 632 - batch(() => { 633 - browsePath.set(path.join(browsePath.peek(), dirName)); 634 - createSelectedIndex.set(0); 635 - browseFilter.set(""); 636 - }); 637 - } 638 - } 639 - return true; 640 - } 641 - if (key.name === "escape") { 642 - if (browseFilter.peek()) { 643 - batch(() => { browseFilter.set(""); createSelectedIndex.set(0); }); 644 - return true; 645 - } 646 - batch(() => { createStep.set("dir-initial"); createSelectedIndex.set(0); }); 647 - return true; 648 - } 649 - if (key.name === "c" && key.ctrl) { 650 - batch(() => { currentScreen.set("list"); }); 651 - return true; 652 - } 653 - if (key.name === "backspace") { 654 - if (browseFilter.peek().length > 0) { 655 - const newFilter = browseFilter.peek().slice(0, -1); 656 - const newDirs = listDirs(browsePath.peek(), newFilter); 657 - batch(() => { 658 - browseFilter.set(newFilter); 659 - createSelectedIndex.set(Math.min(idx, 1 + newDirs.length)); 660 - }); 661 - } 662 - return true; 663 - } 664 - if (key.char && !key.ctrl && !key.alt) { 665 - batch(() => { browseFilter.set(browseFilter.peek() + key.char); createSelectedIndex.set(2); }); 666 - return true; 667 - } 668 - return true; 669 - } 670 - 671 - function handleNameCommandKey(key: KeyEvent): boolean { 672 - if (key.name === "tab") { 673 - focusedField.set(focusedField.peek() === "name" ? "command" : "name"); 674 - return true; 675 - } 676 - if (key.name === "return") { 677 - const name = sessionName.peek().trim(); 678 - const cmd = sessionCommand.peek().trim(); 679 - if (name && cmd) { 680 - const dir = browsePath.peek() !== cwdPath.peek() ? browsePath.peek() : cwdPath.peek(); 681 - doCreate(dir, name, cmd); 682 - } 683 - return true; 684 - } 685 - if (key.name === "escape") { 686 - batch(() => { createStep.set("dir-initial"); createSelectedIndex.set(0); }); 687 - return true; 688 - } 689 - if (key.name === "c" && key.ctrl) { 690 - batch(() => { currentScreen.set("list"); }); 691 - return true; 692 - } 693 - if (key.name === "backspace") { 694 - if (focusedField.peek() === "name") { 695 - nameManuallyEdited.set(true); 696 - sessionName.set(sessionName.peek().slice(0, -1)); 697 - } else { 698 - sessionCommand.set(sessionCommand.peek().slice(0, -1)); 699 - regenerateNameIfAuto(); 700 - } 701 - return true; 702 - } 703 - if (key.char && !key.ctrl && !key.alt) { 704 - if (focusedField.peek() === "name") { 705 - nameManuallyEdited.set(true); 706 - sessionName.set(sessionName.peek() + key.char); 707 - } else { 708 - sessionCommand.set(sessionCommand.peek() + key.char); 709 - regenerateNameIfAuto(); 710 - } 711 - return true; 712 - } 713 - return true; 714 - } 715 - 716 - function regenerateNameIfAuto(): void { 717 - if (nameManuallyEdited.peek()) return; 718 - const dir = browsePath.peek() !== cwdPath.peek() ? browsePath.peek() : cwdPath.peek(); 719 - const cmd = sessionCommand.peek().trim(); 720 - if (!cmd) { 721 - sessionName.set(dedupName(path.basename(dir), existingNames.peek())); 722 - return; 723 - } 724 - const parts = cmd.split(/\s+/); 725 - const name = autoName(dir, parts[0], parts.slice(1)); 726 - sessionName.set(dedupName(name, existingNames.peek())); 727 - } 728 - 729 - // ============================================================ 730 463 // Attach / Create 731 464 // ============================================================ 732 465 ··· 768 501 refreshRelayHosts(); 769 502 batch(() => { 770 503 sessions.set(updated); 771 - currentScreen.set("list"); 772 504 filterText.set(""); 773 505 const maxIdx = Math.max(0, totalItems.get() - 1); 774 506 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); ··· 803 535 refreshRelayHosts(); 804 536 batch(() => { 805 537 sessions.set(updated); 806 - currentScreen.set("list"); 807 538 filterText.set(""); 808 539 const maxIdx = Math.max(0, totalItems.get() - 1); 809 540 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); ··· 820 551 const updated = await listSessions(); 821 552 batch(() => { 822 553 sessions.set(updated); 823 - currentScreen.set("list"); 824 554 filterText.set(""); 825 555 const maxIdx = Math.max(0, totalItems.get() - 1); 826 556 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); ··· 833 563 const updated = await listSessions(); 834 564 batch(() => { 835 565 sessions.set(updated); 836 - currentScreen.set("list"); 837 566 filterText.set(""); 838 567 const maxIdx = Math.max(0, totalItems.get() - 1); 839 568 if (selectedIndex.peek() > maxIdx) selectedIndex.set(maxIdx); ··· 843 572 }); 844 573 } 845 574 846 - async function doCreate(dir: string, name: string, command: string): Promise<void> { 575 + /** Spawn a new local session with the given shell in the given cwd. Used 576 + * by the one-keystroke "Create new session" path; name is a random id 577 + * generated by the caller. */ 578 + async function doCreate(dir: string, name: string, shell: string): Promise<void> { 847 579 myApp?.pause(); 848 580 849 - try { 850 - validateName(name); 851 - } catch (e: any) { 852 - console.error(e.message); 853 - const updated = await listSessions(); 854 - sessions.set(updated); 855 - currentScreen.set("list"); 856 - myApp?.resume(); 857 - return; 858 - } 859 - 860 - let existing; 861 - try { 862 - existing = await getSession(name); 863 - } catch { 864 - existing = null; 865 - } 866 - 867 - if (existing?.status === "running") { 868 - doAttach(name); 869 - return; 870 - } 871 - 581 + // Random names come from randomSessionName() — no collision check needed, 582 + // but we still acquireLock to guard against multi-process races during 583 + // creation. 872 584 if (!acquireLock(name)) { 873 585 console.error(`Session "${name}" is being created by another process.`); 874 586 const updated = await listSessions(); 875 587 sessions.set(updated); 876 - currentScreen.set("list"); 877 588 myApp?.resume(); 878 589 return; 879 590 } 880 591 881 - if (existing?.status === "exited") { 882 - cleanupAll(name); 883 - } 884 - 885 - // Spawn via sh -c so the command field supports quotes, pipes, env vars, etc. 886 - const shellCmd = "/bin/sh"; 887 - const shellArgs = ["-c", command]; 888 - 889 592 // Auto-apply --filter-tag tags so the new session stays in the layout. 890 593 const tags = filterTags.peek(); 891 - const spawnOpts = { name, command: shellCmd, args: shellArgs, displayCommand: command, cwd: dir, ...(Object.keys(tags).length > 0 ? { tags } : {}) }; 594 + // displayName intentionally unset — matches `pty run --no-display-name`. 595 + // Users run `pty rename` / `pty exec` from inside to promote it. 596 + const spawnOpts = { 597 + name, 598 + command: shell, 599 + args: [] as string[], 600 + displayCommand: shell, 601 + cwd: dir, 602 + ...(Object.keys(tags).length > 0 ? { tags } : {}), 603 + }; 892 604 893 605 try { 894 606 await spawnDaemon(spawnOpts); ··· 897 609 console.error(e.message); 898 610 const updated = await listSessions(); 899 611 sessions.set(updated); 900 - currentScreen.set("list"); 901 612 myApp?.resume(); 902 613 return; 903 614 } finally { ··· 908 619 } 909 620 910 621 // ============================================================ 911 - // Remote create screen 912 - // ============================================================ 913 - 914 - const remoteCreateScreen = screen({ 915 - id: "remote-create", 916 - 917 - render(_ctx: ScreenContext): UINode[] { 918 - const host = remoteCreateHost.get(); 919 - if (!host) return [text("No host selected", "error")]; 920 - 921 - const name = remoteSessionName.get(); 922 - 923 - return [ 924 - panel(`Spawn on ${host.label}`, [ 925 - text("", "muted"), 926 - text(" Session name: " + name + "\u2588", "primary"), 927 - text("", "muted"), 928 - text(" Enter to spawn, Escape to cancel", "muted", { dim: true }), 929 - ]), 930 - ]; 931 - }, 932 - 933 - handleKey(key: KeyEvent, _ctx: ScreenContext): boolean { 934 - if (key.name === "escape") { 935 - batch(() => { 936 - currentScreen.set("list"); 937 - remoteCreateHost.set(null); 938 - }); 939 - return true; 940 - } 941 - 942 - if (key.name === "return") { 943 - const host = remoteCreateHost.peek(); 944 - const name = remoteSessionName.peek().trim(); 945 - if (!host || !name) return true; 946 - doSpawnRemote(host, name); 947 - return true; 948 - } 949 - 950 - if (key.name === "backspace") { 951 - remoteSessionName.set(remoteSessionName.peek().slice(0, -1)); 952 - return true; 953 - } 954 - 955 - if (key.char && !key.ctrl && !key.alt) { 956 - remoteSessionName.set(remoteSessionName.peek() + key.char); 957 - return true; 958 - } 959 - 960 - return true; 961 - }, 962 - }); 963 - 964 - // ============================================================ 965 622 // Entry point 966 623 // ============================================================ 967 624 ··· 999 656 refreshRelayHosts(); 1000 657 1001 658 myApp = app({ 1002 - screen: () => { 1003 - const s = currentScreen.get(); 1004 - if (s === "remote-create") return remoteCreateScreen; 1005 - if (s === "create") return createScreen; 1006 - return listScreen; 1007 - }, 659 + screen: () => listScreen, 1008 660 theme: () => currentTheme(), 1009 661 onKey: (key) => { 1010 662 if (key.name === "g" && key.ctrl) { cycleTheme(); return true; }
-396
src/tui/screen-create.ts
··· 1 - import * as fs from "node:fs"; 2 - import * as os from "node:os"; 3 - import * as path from "node:path"; 4 - import type { KeyEvent } from "./input.ts"; 5 - import { 6 - moveTo, 7 - drawBox, 8 - bold, 9 - dim, 10 - inverse, 11 - truncate, 12 - pad, 13 - } from "./render.ts"; 14 - 15 - export interface CreateAction { 16 - type: "create" | "cancel" | "none"; 17 - dir?: string; 18 - name?: string; 19 - command?: string; 20 - } 21 - 22 - export type CreateStep = "dir-initial" | "dir-browse" | "name-command"; 23 - 24 - export interface CreateState { 25 - step: CreateStep; 26 - // Dir picker 27 - selectedIndex: number; 28 - cwdPath: string; 29 - browsePath: string; 30 - browseFilter: string; 31 - // Name + command 32 - name: string; 33 - command: string; 34 - focusedField: "name" | "command"; 35 - // Layout 36 - termWidth: number; 37 - termHeight: number; 38 - // For dedup 39 - existingNames: Set<string>; 40 - } 41 - 42 - export function createCreateState( 43 - termWidth: number, 44 - termHeight: number, 45 - existingNames: string[] 46 - ): CreateState { 47 - const cwdPath = process.cwd(); 48 - return { 49 - step: "dir-initial", 50 - selectedIndex: 0, 51 - cwdPath, 52 - browsePath: cwdPath, 53 - browseFilter: "", 54 - name: path.basename(cwdPath), 55 - command: "", 56 - focusedField: "command", 57 - termWidth, 58 - termHeight, 59 - existingNames: new Set(existingNames), 60 - }; 61 - } 62 - 63 - function shortPath(p: string): string { 64 - const home = os.homedir(); 65 - if (p === home) return "~"; 66 - if (p.startsWith(home + "/")) return "~" + p.slice(home.length); 67 - return p; 68 - } 69 - 70 - export function dedupName(base: string, existing: Set<string>): string { 71 - if (!existing.has(base)) return base; 72 - for (let i = 2; ; i++) { 73 - const candidate = `${base}-${i}`; 74 - if (!existing.has(candidate)) return candidate; 75 - } 76 - } 77 - 78 - export function listDirs(dirPath: string, filter: string): string[] { 79 - let entries: fs.Dirent[]; 80 - try { 81 - entries = fs.readdirSync(dirPath, { withFileTypes: true }); 82 - } catch { 83 - return []; 84 - } 85 - const dirs = entries 86 - .filter((e) => { 87 - if (e.name.startsWith(".")) return false; 88 - if (e.isDirectory()) return true; 89 - // Follow symlinks — include if target is a directory 90 - if (e.isSymbolicLink()) { 91 - try { 92 - const target = fs.statSync(path.join(dirPath, e.name)); 93 - return target.isDirectory(); 94 - } catch { 95 - return false; // broken symlink 96 - } 97 - } 98 - return false; 99 - }) 100 - .map((e) => e.name) 101 - .sort(); 102 - if (!filter) return dirs; 103 - const lf = filter.toLowerCase(); 104 - return dirs.filter((d) => d.toLowerCase().includes(lf)); 105 - } 106 - 107 - export function handleCreateKey(state: CreateState, key: KeyEvent): CreateAction { 108 - switch (state.step) { 109 - case "dir-initial": 110 - return handleDirInitial(state, key); 111 - case "dir-browse": 112 - return handleDirBrowse(state, key); 113 - case "name-command": 114 - return handleNameCommand(state, key); 115 - } 116 - } 117 - 118 - function handleDirInitial(state: CreateState, key: KeyEvent): CreateAction { 119 - // Two items: 0 = cwd, 1 = "Choose disk location..." 120 - if (key.name === "up") { 121 - state.selectedIndex = 0; 122 - return { type: "none" }; 123 - } 124 - if (key.name === "down") { 125 - state.selectedIndex = 1; 126 - return { type: "none" }; 127 - } 128 - if (key.name === "return") { 129 - if (state.selectedIndex === 0) { 130 - // Use cwd 131 - state.step = "name-command"; 132 - state.name = dedupName(path.basename(state.cwdPath), state.existingNames); 133 - return { type: "none" }; 134 - } else { 135 - // Browse 136 - state.step = "dir-browse"; 137 - state.browsePath = state.cwdPath; 138 - state.selectedIndex = 0; 139 - state.browseFilter = ""; 140 - return { type: "none" }; 141 - } 142 - } 143 - if (key.name === "escape" || (key.name === "c" && key.ctrl)) { 144 - return { type: "cancel" }; 145 - } 146 - return { type: "none" }; 147 - } 148 - 149 - function handleDirBrowse(state: CreateState, key: KeyEvent): CreateAction { 150 - const dirs = listDirs(state.browsePath, state.browseFilter); 151 - // Items: [Select this directory], .., ...dirs 152 - const totalItems = 2 + dirs.length; 153 - 154 - if (key.name === "up") { 155 - state.selectedIndex = Math.max(0, state.selectedIndex - 1); 156 - return { type: "none" }; 157 - } 158 - if (key.name === "down") { 159 - state.selectedIndex = Math.min(totalItems - 1, state.selectedIndex + 1); 160 - return { type: "none" }; 161 - } 162 - if (key.name === "return") { 163 - if (state.selectedIndex === 0) { 164 - // Select this directory 165 - state.step = "name-command"; 166 - state.name = dedupName(path.basename(state.browsePath), state.existingNames); 167 - return { type: "none" }; 168 - } 169 - if (state.selectedIndex === 1) { 170 - // .. 171 - const parent = path.dirname(state.browsePath); 172 - if (parent !== state.browsePath) { 173 - state.browsePath = parent; 174 - state.selectedIndex = 0; 175 - state.browseFilter = ""; 176 - } 177 - return { type: "none" }; 178 - } 179 - // Navigate into a directory 180 - const dirName = dirs[state.selectedIndex - 2]; 181 - if (dirName) { 182 - state.browsePath = path.join(state.browsePath, dirName); 183 - state.selectedIndex = 0; 184 - state.browseFilter = ""; 185 - } 186 - return { type: "none" }; 187 - } 188 - if (key.name === "escape") { 189 - if (state.browseFilter) { 190 - state.browseFilter = ""; 191 - state.selectedIndex = 0; 192 - return { type: "none" }; 193 - } 194 - state.step = "dir-initial"; 195 - state.selectedIndex = 0; 196 - return { type: "none" }; 197 - } 198 - if (key.name === "c" && key.ctrl) { 199 - return { type: "cancel" }; 200 - } 201 - if (key.name === "backspace") { 202 - if (state.browseFilter.length > 0) { 203 - state.browseFilter = state.browseFilter.slice(0, -1); 204 - state.selectedIndex = Math.min(state.selectedIndex, 1 + listDirs(state.browsePath, state.browseFilter).length); 205 - } 206 - return { type: "none" }; 207 - } 208 - // Typing — filter directories 209 - if (key.char && !key.ctrl && !key.alt) { 210 - state.browseFilter += key.char; 211 - state.selectedIndex = 2; // Jump to first directory match 212 - return { type: "none" }; 213 - } 214 - return { type: "none" }; 215 - } 216 - 217 - function handleNameCommand(state: CreateState, key: KeyEvent): CreateAction { 218 - if (key.name === "tab") { 219 - state.focusedField = state.focusedField === "name" ? "command" : "name"; 220 - return { type: "none" }; 221 - } 222 - if (key.name === "return") { 223 - if (state.name.trim() && state.command.trim()) { 224 - const dir = state.step === "name-command" 225 - ? (state.browsePath !== state.cwdPath ? state.browsePath : state.cwdPath) 226 - : state.cwdPath; 227 - return { 228 - type: "create", 229 - dir, 230 - name: state.name.trim(), 231 - command: state.command.trim(), 232 - }; 233 - } 234 - return { type: "none" }; 235 - } 236 - if (key.name === "escape") { 237 - state.step = "dir-initial"; 238 - state.selectedIndex = 0; 239 - return { type: "none" }; 240 - } 241 - if (key.name === "c" && key.ctrl) { 242 - return { type: "cancel" }; 243 - } 244 - 245 - // Text editing for focused field 246 - const field = state.focusedField; 247 - if (key.name === "backspace") { 248 - if (field === "name") { 249 - state.name = state.name.slice(0, -1); 250 - } else { 251 - state.command = state.command.slice(0, -1); 252 - } 253 - return { type: "none" }; 254 - } 255 - if (key.char && !key.ctrl && !key.alt) { 256 - if (field === "name") { 257 - state.name += key.char; 258 - } else { 259 - state.command += key.char; 260 - } 261 - return { type: "none" }; 262 - } 263 - return { type: "none" }; 264 - } 265 - 266 - export function renderCreate(state: CreateState): string { 267 - switch (state.step) { 268 - case "dir-initial": 269 - return renderDirInitial(state); 270 - case "dir-browse": 271 - return renderDirBrowse(state); 272 - case "name-command": 273 - return renderNameCommand(state); 274 - } 275 - } 276 - 277 - function renderDirInitial(state: CreateState): string { 278 - const { termWidth, termHeight } = state; 279 - const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 280 - const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 281 - const boxHeight = 8; 282 - const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 283 - const contentWidth = boxWidth - 4; 284 - 285 - let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("New Session \u2014 Choose Directory")); 286 - 287 - let row = boxRow + 2; 288 - // Build items as plain text first, then style 289 - const cwdText = shortPath(state.cwdPath); 290 - const plainItems = [ 291 - ` ${cwdText} (current directory)`, 292 - ` Choose disk location\u2026`, 293 - ]; 294 - const styledItems = [ 295 - ` ${cwdText} ${dim("(current directory)")}`, 296 - ` Choose disk location\u2026`, 297 - ]; 298 - for (let i = 0; i < plainItems.length; i++) { 299 - const plain = pad(truncate(plainItems[i], contentWidth), contentWidth); 300 - // Rebuild styled version with correct padding 301 - const styled = styledItems[i]; 302 - const padAmount = contentWidth - plainItems[i].length; 303 - const paddedStyled = padAmount > 0 ? styled + " ".repeat(padAmount) : styled; 304 - out += moveTo(row, boxCol + 2) + (i === state.selectedIndex ? inverse(paddedStyled) : paddedStyled); 305 - row++; 306 - } 307 - 308 - const footerRow = boxRow + boxHeight; 309 - out += moveTo(footerRow, boxCol + 1) + dim("\u2191\u2193 select \u23ce confirm \u238b back"); 310 - 311 - return out; 312 - } 313 - 314 - function renderDirBrowse(state: CreateState): string { 315 - const { termWidth, termHeight } = state; 316 - const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 317 - const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 318 - const contentWidth = boxWidth - 4; 319 - 320 - const dirs = listDirs(state.browsePath, state.browseFilter); 321 - const maxItems = Math.min(dirs.length + 2, termHeight - 6); 322 - const boxHeight = Math.max(8, maxItems + 5); 323 - const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 324 - 325 - let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("Browse \u2014 " + shortPath(state.browsePath))); 326 - 327 - let row = boxRow + 1; 328 - 329 - // Filter line 330 - if (state.browseFilter) { 331 - row++; 332 - out += moveTo(row, boxCol + 2) + `Filter: ${state.browseFilter}`; 333 - } 334 - 335 - row++; 336 - 337 - // Fixed items — build plain first, style after 338 - const plainFixed = [ 339 - " [Select this directory]", 340 - " ..", 341 - ]; 342 - const styledFixed = [ 343 - ` ${dim("[Select this directory]")}`, 344 - ` ${dim("..")}`, 345 - ]; 346 - for (let i = 0; i < plainFixed.length; i++) { 347 - row++; 348 - const padAmount = Math.max(0, contentWidth - plainFixed[i].length); 349 - const line = styledFixed[i] + " ".repeat(padAmount); 350 - out += moveTo(row, boxCol + 2) + (i === state.selectedIndex ? inverse(line) : line); 351 - } 352 - 353 - // Directory entries 354 - const visibleDirs = dirs.slice(0, boxHeight - 7); 355 - for (let i = 0; i < visibleDirs.length; i++) { 356 - row++; 357 - const actualIndex = i + 2; 358 - const line = pad(` ${visibleDirs[i]}/`, contentWidth); 359 - out += moveTo(row, boxCol + 2) + (actualIndex === state.selectedIndex ? inverse(line) : line); 360 - } 361 - 362 - const footerRow = boxRow + boxHeight; 363 - out += moveTo(footerRow, boxCol + 1) + dim("\u2191\u2193 select \u23ce enter \u238b back type to filter"); 364 - 365 - return out; 366 - } 367 - 368 - function renderNameCommand(state: CreateState): string { 369 - const { termWidth, termHeight } = state; 370 - const boxWidth = Math.min(Math.max(40, termWidth - 4), termWidth - 2); 371 - const boxCol = Math.max(1, Math.floor((termWidth - boxWidth) / 2) + 1); 372 - const boxHeight = 10; 373 - const boxRow = Math.max(1, Math.floor((termHeight - boxHeight - 1) / 2) + 1); 374 - 375 - const dir = state.browsePath !== state.cwdPath ? state.browsePath : state.cwdPath; 376 - 377 - let out = drawBox(boxRow, boxCol, boxWidth, boxHeight, bold("New Session")); 378 - 379 - let row = boxRow + 2; 380 - out += moveTo(row, boxCol + 2) + `Directory: ${dim(shortPath(dir))}`; 381 - 382 - row += 2; 383 - const nameLabel = "Name: "; 384 - const nameValue = state.name + (state.focusedField === "name" ? "\u2588" : ""); 385 - out += moveTo(row, boxCol + 2) + (state.focusedField === "name" ? bold(nameLabel) : nameLabel) + nameValue; 386 - 387 - row++; 388 - const cmdLabel = "Command: "; 389 - const cmdValue = state.command + (state.focusedField === "command" ? "\u2588" : ""); 390 - out += moveTo(row, boxCol + 2) + (state.focusedField === "command" ? bold(cmdLabel) : cmdLabel) + cmdValue; 391 - 392 - const footerRow = boxRow + boxHeight; 393 - out += moveTo(footerRow, boxCol + 1) + dim("\u21e5 switch field \u23ce create \u238b back"); 394 - 395 - return out; 396 - }
+33 -101
tests/tui.test.ts
··· 335 335 ); 336 336 337 337 it( 338 - "--filter-tag auto-applies the tag to sessions created from the TUI", 338 + "--filter-tag auto-applies the tag to sessions created from the TUI (one-keystroke create)", 339 339 async () => { 340 340 const sessionDir = makeSessionDir(); 341 341 ··· 346 346 }); 347 347 tuiSessions.push(tui); 348 348 349 - // Create item is preselected; walk the wizard 350 - await tui.waitForText("#role=web", 10000); // filter line visible 349 + // Create item is preselected; a single Enter now spawns the shell 350 + // directly — no prompts. 351 + await tui.waitForText("#role=web", 10000); 351 352 tui.press("return"); 352 - await tui.waitForText("Choose Directory", 5000); 353 - tui.press("return"); // use current directory 354 - await tui.waitForText("Name:", 5000); 355 - tui.type("cat"); 356 - await new Promise((r) => setTimeout(r, 200)); 357 - tui.press("return"); // create 358 353 359 - // Wait for the session metadata file to appear and contain the tag 354 + // Wait for the session metadata file to appear with the tag applied. 360 355 const start = Date.now(); 361 356 let found: any = null; 362 357 while (Date.now() - start < 5000) { ··· 364 359 for (const e of entries) { 365 360 try { 366 361 const meta = JSON.parse(fs.readFileSync(path.join(sessionDir, e), "utf-8")); 367 - if (meta.tags?.role === "web") { 368 - found = meta; 369 - break; 370 - } 362 + if (meta.tags?.role === "web") { found = meta; break; } 371 363 } catch {} 372 364 } 373 365 if (found) break; ··· 375 367 } 376 368 expect(found).not.toBeNull(); 377 369 expect(found.tags.role).toBe("web"); 370 + // No displayName should be set — the "new anonymous" flow matches 371 + // pty run --no-display-name. 372 + expect(found.displayName).toBeUndefined(); 378 373 379 - // Clean up the spawned session 380 374 const pidFiles = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".pid")); 381 375 for (const p of pidFiles) { 382 376 try { ··· 554 548 ); 555 549 556 550 it( 557 - "create wizard: shows directory picker", 558 - async () => { 559 - const sessionDir = makeSessionDir(); 560 - const tui = createTuiSession(sessionDir); 561 - 562 - await tui.waitForText("Create new session...", 10000); 563 - tui.press("return"); 564 - 565 - const ss = await tui.waitForText("Choose Directory", 5000); 566 - expect(ss.text).toContain("current directory"); 567 - }, 568 - 15000 569 - ); 570 - 571 - it( 572 - "create wizard: name auto-fills from directory", 573 - async () => { 574 - const sessionDir = makeSessionDir(); 575 - const tui = createTuiSession(sessionDir); 576 - 577 - await tui.waitForText("Create new session...", 10000); 578 - tui.press("return"); 579 - await tui.waitForText("Choose Directory", 5000); 580 - tui.press("return"); 581 - 582 - const ss = await tui.waitForText("Name:", 5000); 583 - expect(ss.text).toContain("Command:"); 584 - }, 585 - 15000 586 - ); 587 - 588 - it( 589 - "create wizard: name updates as command is typed", 590 - async () => { 591 - const sessionDir = makeSessionDir(); 592 - const tui = createTuiSession(sessionDir); 593 - 594 - await tui.waitForText("Create new session...", 10000); 595 - tui.press("return"); 596 - await tui.waitForText("Choose Directory", 5000); 597 - tui.press("return"); 598 - await tui.waitForText("Name:", 5000); 599 - 600 - // Name should start as the directory basename 601 - let ss = tui.screenshot(); 602 - const nameLine = ss.lines.find(l => l.includes("Name:")); 603 - expect(nameLine).toBeDefined(); 604 - 605 - // Type a command — the name should auto-update to include it 606 - tui.type("node server.js"); 607 - await new Promise(r => setTimeout(r, 300)); 608 - 609 - ss = tui.screenshot(); 610 - const updatedName = ss.lines.find(l => l.includes("Name:")); 611 - expect(updatedName).toBeDefined(); 612 - // Should contain "node-server" somewhere in the name field 613 - expect(updatedName).toMatch(/node-server/i); 614 - }, 615 - 15000 616 - ); 617 - 618 - it( 619 - "create wizard: manually edited name is not overwritten by command typing", 551 + "Enter on 'Create new session...' spawns a shell with a random id and no displayName", 620 552 async () => { 621 553 const sessionDir = makeSessionDir(); 622 554 const tui = createTuiSession(sessionDir); 623 555 624 556 await tui.waitForText("Create new session...", 10000); 625 557 tui.press("return"); 626 - await tui.waitForText("Choose Directory", 5000); 627 - tui.press("return"); 628 - await tui.waitForText("Name:", 5000); 629 558 630 - // Tab to name field, type a custom name 631 - tui.press("tab"); 632 - await new Promise(r => setTimeout(r, 200)); 633 - // Clear auto-filled name and type custom 634 - for (let i = 0; i < 30; i++) tui.press("backspace"); 635 - await new Promise(r => setTimeout(r, 200)); 636 - tui.type("custom"); 637 - await new Promise(r => setTimeout(r, 200)); 559 + // Wait for a session metadata file to materialize; verify it has 560 + // no displayName and a random-looking name. 561 + const start = Date.now(); 562 + let meta: any = null; 563 + while (Date.now() - start < 5000) { 564 + const entries = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".json")); 565 + if (entries.length > 0) { 566 + try { meta = JSON.parse(fs.readFileSync(path.join(sessionDir, entries[0]), "utf-8")); } catch {} 567 + if (meta) break; 568 + } 569 + await new Promise((r) => setTimeout(r, 100)); 570 + } 571 + expect(meta).not.toBeNull(); 572 + expect(meta.displayName).toBeUndefined(); 638 573 639 - // Tab back to command and type something 640 - tui.press("tab"); 641 - await new Promise(r => setTimeout(r, 200)); 642 - tui.type("echo hi"); 643 - await new Promise(r => setTimeout(r, 300)); 644 - 645 - // Name should still be "custom", not auto-generated 646 - const ss = tui.screenshot(); 647 - const nameLine = ss.lines.find(l => l.includes("Name:")); 648 - expect(nameLine).toContain("custom"); 649 - expect(nameLine).not.toContain("echo"); 574 + // Record the spawned session pid for cleanup 575 + const pidFiles = fs.readdirSync(sessionDir).filter((f) => f.endsWith(".pid")); 576 + for (const p of pidFiles) { 577 + try { 578 + const pid = parseInt(fs.readFileSync(path.join(sessionDir, p), "utf-8").trim(), 10); 579 + bgPids.push(pid); 580 + } catch {} 581 + } 650 582 }, 651 - 15000 583 + 20000 652 584 ); 653 585 654 586 it(