diff --git a/README.md b/README.md index dd6e34f..52b5605 100644 --- a/README.md +++ b/README.md @@ -104,8 +104,9 @@ autonomous mode or `/start ` for raw mode. Running `moshcode agents` or 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. +tmux, `/new` adds a window to that session and respects its configured window +keys. Otherwise the first `/new` opens an isolated two-tab workspace with those +default keys and 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 diff --git a/package.json b/package.json index 6fcfd0e..dc6ca27 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcode", - "version": "0.22.0", + "version": "0.22.1", "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "bin": { diff --git a/src/tabs.mjs b/src/tabs.mjs index a179ebb..ec0ce55 100644 --- a/src/tabs.mjs +++ b/src/tabs.mjs @@ -51,9 +51,17 @@ export function tabPlan({ 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. + // This server is only for moshcode. Start it without the user's tmux + // config so the advertised Ctrl-b n/p/number bindings stay true even + // when their normal tmux remaps or unbinds those keys. Existing tmux + // sessions take the branch above and keep the user's configuration. + [...server, "-f", "/dev/null", "new-session", "-d", "-s", session, "-c", cwd, "-n", "mosh 1", command], + // Match the visible names to Ctrl-b 1/2. The clean tmux default starts + // at zero, so change the base and renumber the first window before + // adding its sibling. + [...server, "set-option", "-t", session, "base-index", "1"], + [...server, "move-window", "-r", "-t", session], + // Do not use -d: the new tab should be selected when we attach. [...server, "new-window", "-t", session, "-c", cwd, "-n", "mosh 2", command], ], // Presentation is best-effort: an older tmux should still open the tabs. @@ -124,6 +132,12 @@ export async function openNewTab({ if (!plan.attach) return { ok: true, dedicated: false }; const attached = await runAttached(plan.attach, { spawner, env }); if (!attached.ok) { + // Attaching is the last required step, but the private server and its two + // pit processes already exist by then. Do not strand them in the + // background when the terminal cannot attach (for example TERM=dumb or a + // client-side tmux error). As above, only a server created by this call is + // ever eligible for cleanup. + runner("tmux", ["-L", plan.socket, "kill-server"], { stdio: "ignore", env }); 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 cd2fa58..5428ecf 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -512,7 +512,7 @@ export async function tui() { } rl.close(); console.log(info(process.env.TMUX - ? "opening a new mosh tab — switch with Ctrl-b n/p or Ctrl-b …" + ? "opening a new mosh tab — switch with your tmux window keys…" : "opening a two-tab mosh workspace — switch with Ctrl-b n/p or Ctrl-b …")); const result = await openNewTab(); if (!result.ok) { diff --git a/test/tabs.test.mjs b/test/tabs.test.mjs index 1224994..0e5de6a 100644 --- a/test/tabs.test.mjs +++ b/test/tabs.test.mjs @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; import test from "node:test"; -import { tabCommand, tabPlan, tabShellQuote } from "../src/tabs.mjs"; +import { openNewTab, 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'"); @@ -34,10 +35,16 @@ test("/new outside tmux builds a private two-tab workspace", () => { 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", + "-L", "moshcode-42-99", "-f", "/dev/null", "new-session", "-d", "-s", "moshcode-42-99", "-c", "/repo", "-n", "mosh 1", "moshcode-command", ]); assert.deepEqual(plan.required[1], [ + "-L", "moshcode-42-99", "set-option", "-t", "moshcode-42-99", "base-index", "1", + ]); + assert.deepEqual(plan.required[2], [ + "-L", "moshcode-42-99", "move-window", "-r", "-t", "moshcode-42-99", + ]); + assert.deepEqual(plan.required[3], [ "-L", "moshcode-42-99", "new-window", "-t", "moshcode-42-99", "-c", "/repo", "-n", "mosh 2", "moshcode-command", ]); @@ -46,3 +53,35 @@ test("/new outside tmux builds a private two-tab workspace", () => { ]); assert.ok(plan.optional.some((args) => args.includes("bottom"))); }); + +test("/new cleans up its private server when attaching fails", async () => { + const calls = []; + const runner = (cmd, args) => { + calls.push([cmd, args]); + return { status: 0 }; + }; + const spawner = () => { + const child = new EventEmitter(); + queueMicrotask(() => child.emit("exit", 1, null)); + return child; + }; + + const result = await openNewTab({ + cwd: "/repo", + env: { TMUX: "" }, + isTTY: true, + runner, + spawner, + execPath: "/node", + entry: "/moshcode.mjs", + pid: 42, + stamp: 99, + }); + + assert.equal(result.ok, false); + assert.match(result.error.message, /attach exited 1/); + assert.deepEqual(calls.at(-1), [ + "tmux", + ["-L", "moshcode-42-99", "kill-server"], + ]); +});