Skip to content
Merged
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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,20 @@ is shorthand for `moshcode start claude`. In the TUI, use `/agents <engine>` for
autonomous mode or `/start <engine>` 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 <engine>` in each tab and switch between them with tmux's
`Ctrl-b n`, `Ctrl-b p`, or `Ctrl-b <number>` 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.

Expand Down
2 changes: 2 additions & 0 deletions src/cli-schema.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<engine> [args…]", cli: "start",
Expand Down
130 changes: 130 additions & 0 deletions src/tabs.mjs
Original file line number Diff line number Diff line change
@@ -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 };
}
21 changes: 20 additions & 1 deletion src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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("▸ ");

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 <number>…"
: "opening a two-tab mosh workspace — switch with Ctrl-b n/p or Ctrl-b <number>…"));
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");
Expand Down
48 changes: 48 additions & 0 deletions test/tabs.test.mjs
Original file line number Diff line number Diff line change
@@ -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")));
});
7 changes: 7 additions & 0 deletions test/tui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url> -H
// "Authorization: Bearer …"`, `/secrets`, `/coinpay`, `!export TOKEN=…`). The
Expand Down
Loading