diff --git a/README.md b/README.md index c478718..dd6e34f 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,20 @@ is shorthand for `moshcode start claude`. In the TUI, use `/agents ` for autonomous mode or `/start ` for raw mode. Running `moshcode agents` or `/agents` without an engine still lists engines and their install status. +### Parallel pit tabs + +At the mosh prompt, `/new` opens and switches to another independent moshcode +tab. Run `/agents ` in each tab and switch between them with tmux's +`Ctrl-b n`, `Ctrl-b p`, or `Ctrl-b ` keys. If moshcode is already inside +tmux, `/new` adds a window to that session. Otherwise the first `/new` opens a +private two-tab workspace with its tab bar at the bottom. + +Each tab is a separate moshcode process and provider CLIs still receive an +ordinary inherited terminal. Moshcode does not intercept or reinterpret their +input, output, full-screen UI, or provider-specific shortcuts. The feature +requires `tmux`; without it `/new` reports that requirement and leaves the +current pit untouched. + The modes are not identical across providers. In particular, OpenCode `--auto` auto-approves permission requests but continues to enforce explicit deny rules. diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 42ddead..b077447 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -482,6 +482,8 @@ export const VERB_TABLES = { * than a second, thinner copy that drifts. */ export const PIT_COMMANDS = [ + { name: "new", pitOnly: true, + description: "open and switch to another moshcode tab" }, { name: "agents", aliases: ["agent", "engines"], args: "[name]", cli: "agents", description: "list engines, or launch one autonomously" }, { name: "start", args: " [args…]", cli: "start", diff --git a/src/tabs.mjs b/src/tabs.mjs new file mode 100644 index 0000000..a179ebb --- /dev/null +++ b/src/tabs.mjs @@ -0,0 +1,130 @@ +// Tmux-backed tabs for the interactive mosh pit. +// +// The pit deliberately hands a provider CLI the whole terminal with inherited +// stdio. Keeping that contract matters: full-screen TUIs, mouse handling, +// colours, signals, and provider-specific shortcuts should remain native. A +// tab therefore cannot be an in-process readline view. It is another moshcode +// process in another tmux window, with tmux owning the terminal multiplexing. +import { spawn, spawnSync } from "node:child_process"; + +/** POSIX-shell quoting for tmux's single `shell-command` argument. */ +export function tabShellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +/** Command run in every tab. Always opens a fresh pit, never repeats argv. */ +export function tabCommand({ execPath = process.execPath, entry = process.argv[1] } = {}) { + if (!entry) throw new Error("can't locate the moshcode entrypoint"); + return `exec ${tabShellQuote(execPath)} ${tabShellQuote(entry)}`; +} + +/** + * Pure tmux command plan, split out so the safety-sensitive argv is testable + * without opening real windows in the test runner. + */ +export function tabPlan({ + cwd = process.cwd(), + command = tabCommand(), + tmux = process.env.TMUX, + pid = process.pid, + stamp = Date.now(), +} = {}) { + if (tmux) { + return { + dedicated: false, + session: null, + socket: null, + required: [["new-window", "-c", cwd, "-n", "mosh", command]], + optional: [], + attach: null, + }; + } + + // A private server gets the current environment at creation time. Reusing a + // detached default server here could give provider CLIs stale PATH/API vars. + const suffix = `${pid}-${stamp}`.replace(/[^a-zA-Z0-9_-]/g, "-"); + const socket = `moshcode-${suffix}`; + const session = `moshcode-${suffix}`; + const server = ["-L", socket]; + return { + dedicated: true, + session, + socket, + required: [ + [...server, "new-session", "-d", "-s", session, "-c", cwd, "-n", "mosh 1", command], + // Do not use -d: selecting the new window avoids assuming whether the + // user's tmux config starts window indexes at 0 or 1. + [...server, "new-window", "-t", session, "-c", cwd, "-n", "mosh 2", command], + ], + // Presentation is best-effort: an older tmux should still open the tabs. + optional: [ + [...server, "set-option", "-t", session, "status", "on"], + [...server, "set-option", "-t", session, "status-position", "bottom"], + [...server, "set-option", "-t", session, "status-right", " Ctrl-b n/p · /new "], + ], + attach: [...server, "attach-session", "-t", session], + }; +} + +function resultError(result) { + if (result?.error?.code === "ENOENT") return "tmux is not installed"; + if (result?.error) return result.error.message || String(result.error); + const detail = String(result?.stderr || result?.stdout || "").trim(); + return detail || `tmux exited ${result?.status ?? "without a status"}`; +} + +function runAttached(args, { spawner = spawn, env = process.env } = {}) { + return new Promise((resolve) => { + let child; + try { child = spawner("tmux", args, { stdio: "inherit", env }); } + catch (error) { resolve({ ok: false, error }); return; } + child.on("error", (error) => resolve({ ok: false, error })); + child.on("exit", (code, signal) => resolve({ ok: code === 0, code, signal })); + }); +} + +/** + * Open and switch to a new pit tab. + * + * Inside tmux this adds one window to the current session. Outside tmux it + * starts a private two-window workspace and attaches to it; this is the only + * way the already-running, non-tmux pit can gain a sibling without replacing + * the provider-friendly inherited-stdio architecture. + */ +export async function openNewTab({ + cwd = process.cwd(), + env = process.env, + isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY), + runner = spawnSync, + spawner = spawn, + execPath = process.execPath, + entry = process.argv[1], + pid = process.pid, + stamp = Date.now(), +} = {}) { + if (!isTTY) return { ok: false, error: new Error("/new needs an interactive terminal") }; + + let command; + try { command = tabCommand({ execPath, entry }); } + catch (error) { return { ok: false, error }; } + const plan = tabPlan({ cwd, command, tmux: env.TMUX, pid, stamp }); + + for (const args of plan.required) { + const result = runner("tmux", args, { encoding: "utf8", env }); + if (result?.status !== 0) { + // Only a private server created by this call is eligible for cleanup. + if (plan.dedicated) { + runner("tmux", ["-L", plan.socket, "kill-server"], { stdio: "ignore", env }); + } + return { ok: false, error: new Error(resultError(result)) }; + } + } + for (const args of plan.optional) runner("tmux", args, { stdio: "ignore", env }); + + if (!plan.attach) return { ok: true, dedicated: false }; + const attached = await runAttached(plan.attach, { spawner, env }); + if (!attached.ok) { + return { ok: false, error: attached.error || new Error(`tmux attach exited ${attached.code ?? attached.signal ?? "unknown"}`) }; + } + return { ok: true, dedicated: true, session: plan.session, socket: plan.socket }; +} diff --git a/src/tui.mjs b/src/tui.mjs index bb9ba91..cd2fa58 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -22,6 +22,7 @@ import { mcpCommand, skillCommand } from "./integrations.mjs"; import { banner, hr, acid, ash, bone, dim, ok, err, warn, info, moshcodeVersion } from "./ui.mjs"; import { CORE_CLI_COMMAND_NAMES } from "./cli-schema.mjs"; import { findPitCommand, pitHelpModel, renderPitCommand, suggest, wantsHelp } from "./help.mjs"; +import { openNewTab } from "./tabs.mjs"; const PROMPT = () => acid("mosh ") + dim("▸ "); @@ -453,7 +454,7 @@ export async function tui() { printEngines(); console.log(); printTools(); - console.log("\n" + ash(" /help for commands · /quit to leave") + "\n"); + console.log("\n" + ash(" /help for commands · /new for a tab · /quit to leave") + "\n"); const ad = await motd; if (ad) console.log(dim(ad) + "\n"); @@ -503,6 +504,24 @@ export async function tui() { printHelp(cmd); continue; } + if (cmd === "new") { + if (rest.length) { console.log(err("usage: /new")); continue; } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.log(err("/new needs an interactive terminal")); + continue; + } + rl.close(); + console.log(info(process.env.TMUX + ? "opening a new mosh tab — switch with Ctrl-b n/p or Ctrl-b …" + : "opening a two-tab mosh workspace — switch with Ctrl-b n/p or Ctrl-b …")); + const result = await openNewTab(); + if (!result.ok) { + console.log(err(`can't open a tab: ${result.error?.message || result.error}`)); + console.log(ash(" /new uses tmux so every provider CLI still owns a real terminal")); + } + rl = mkrl(); + continue; + } if (cmd === "pwd" || cmd === "where") { printPwd(); continue; } if (cmd === "login") { const device = rest.includes("--device") || rest.includes("device") || rest.includes("-d"); diff --git a/test/tabs.test.mjs b/test/tabs.test.mjs new file mode 100644 index 0000000..1224994 --- /dev/null +++ b/test/tabs.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { tabCommand, tabPlan, tabShellQuote } from "../src/tabs.mjs"; + +test("tab shell quoting keeps paths as one shell word", () => { + assert.equal(tabShellQuote("/tmp/it's here"), "'/tmp/it'\\''s here'"); +}); + +test("a tab command opens a fresh pit with the current entrypoint", () => { + assert.equal( + tabCommand({ execPath: "/opt/node bin/node", entry: "/tmp/mosh coder/bin/moshcode.mjs" }), + "exec '/opt/node bin/node' '/tmp/mosh coder/bin/moshcode.mjs'", + ); +}); + +test("/new inside tmux creates one window in the current session", () => { + const plan = tabPlan({ + cwd: "/work/space here", + command: "moshcode-command", + tmux: "/tmp/tmux,1,0", + }); + + assert.equal(plan.dedicated, false); + assert.deepEqual(plan.required, [[ + "new-window", "-c", "/work/space here", "-n", "mosh", "moshcode-command", + ]]); + assert.equal(plan.attach, null); +}); + +test("/new outside tmux builds a private two-tab workspace", () => { + const plan = tabPlan({ cwd: "/repo", command: "moshcode-command", tmux: "", pid: 42, stamp: 99 }); + + assert.equal(plan.dedicated, true); + assert.equal(plan.socket, "moshcode-42-99"); + assert.deepEqual(plan.required[0], [ + "-L", "moshcode-42-99", "new-session", "-d", "-s", "moshcode-42-99", + "-c", "/repo", "-n", "mosh 1", "moshcode-command", + ]); + assert.deepEqual(plan.required[1], [ + "-L", "moshcode-42-99", "new-window", "-t", "moshcode-42-99", + "-c", "/repo", "-n", "mosh 2", "moshcode-command", + ]); + assert.deepEqual(plan.attach, [ + "-L", "moshcode-42-99", "attach-session", "-t", "moshcode-42-99", + ]); + assert.ok(plan.optional.some((args) => args.includes("bottom"))); +}); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index 035dfe5..4f6dc0f 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -147,6 +147,13 @@ test("TUI /install rejects an Object.prototype name instead of crashing the pit" assert.doesNotMatch(result.stderr, /TypeError/); }); +test("TUI /new requires a real terminal", async () => { + const result = await runTui("/new\n/quit\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /\/new needs an interactive terminal/); +}); + // The pit persists every line typed at the prompt to ~/.moshcode_history, and // the documented flows put secrets on those lines (`/mcp install -H // "Authorization: Bearer …"`, `/secrets`, `/coinpay`, `!export TOKEN=…`). The