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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,9 @@ autonomous mode or `/start <engine>` for raw mode. Running `moshcode agents` or
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.
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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
20 changes: 17 additions & 3 deletions src/tabs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 1 addition & 1 deletion src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number>…"
? "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 <number>…"));
const result = await openNewTab();
if (!result.ok) {
Expand Down
43 changes: 41 additions & 2 deletions test/tabs.test.mjs
Original file line number Diff line number Diff line change
@@ -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'");
Expand Down Expand Up @@ -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",
]);
Expand All @@ -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"],
]);
});
Loading