Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/dev-dashboard/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ const TtydSessionSchema = z.object({
pid: z.number().int(),
startedAt: z.string(),
tmuxSessionName: z.string().optional(),
/** User-set display name; also mirrors the tmux session name after a unified rename. */
name: z.string().optional(),
});

const WeatherCoordsSchema = z.object({
Expand Down
16 changes: 16 additions & 0 deletions src/dev-dashboard/contract/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,23 @@ export interface TmuxHubSession {
name: string;
attached: number;
windows: number;
/** Active-pane facts from tmux itself — the only meta available for a session with no ttyd. */
command?: string;
cwd?: string;
/** Raw `#{pane_title}`: Claude's `<spinner> <topic>`, a shell's own title, or absent. */
title?: string;
/** Unix seconds. */
created?: number;
lastActivity?: number;
ttydTabIds: string[];
ttydTabs: Array<{
id: string;
port: number;
label: string;
cwd?: string;
lastCommand?: string;
title?: string;
}>;
Comment on lines +125 to +132

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Define one named ttyd tab contract.

ttydTabs is declared separately in the contract, server, and UI API modules. These declarations can drift without a TypeScript error.

Export a named TtydHubTab DTO here. Import it in src/dev-dashboard/lib/tmux/hub.ts and src/dev-dashboard/ui/src/lib/api.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dev-dashboard/contract/dto.ts` around lines 117 - 123, Define and export
a named TtydHubTab DTO for the existing ttyd tab shape in the contract module,
then update the ttydTabs declarations in the hub module and UI API module to
import and reuse TtydHubTab instead of duplicating the inline object type.

canAttachInTtyd: boolean;
cmuxSurfaces: Array<{ workspaceId: string; surfaceId: string; title: string }>;
inCmux: boolean;
Expand Down
49 changes: 49 additions & 0 deletions src/dev-dashboard/lib/tmux/claude-pane-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { isClaudeForegroundCommand, parseClaudePaneTitle } from "@app/dev-dashboard/lib/tmux/claude-pane-title";

describe("parseClaudePaneTitle", () => {
test("strips working and idle markers", () => {
expect(parseClaudePaneTitle("✳ testt")).toBe("testt");
expect(parseClaudePaneTitle("⠐ templates-todo")).toBe("templates-todo");
expect(parseClaudePaneTitle("* testt")).toBe("testt");
});

test("accepts every animated braille spinner frame, not just U+2810", () => {
// Observed live: a running session's title read `⠂ ttyd-naming` (U+2802) and parsed as null,
// so the sync silently succeeded or failed depending on poll timing.
for (const marker of ["⠂", "⠈", "⠠", "⣾", "⠋", "⠿", "⠀"]) {
expect(parseClaudePaneTitle(`${marker} ttyd-naming`)).toBe("ttyd-naming");
}
});

test("keeps multi-word titles and sanitizes colons", () => {
expect(parseClaudePaneTitle("✳ Debug formatting: spacing")).toBe("Debug formatting- spacing");
});

test("sanitizes dots — tmux target-syntax separators that 3.6a silently munges to _ on rename", () => {
expect(parseClaudePaneTitle("✳ Fix v1.2 bug")).toBe("Fix v1-2 bug");
// Unicode ellipsis is NOT a dot — Claude's truncated auto-topic titles keep it.
expect(parseClaudePaneTitle("✳ Analyze slow HAR file load…")).toBe("Analyze slow HAR file load…");
});

test("rejects non-Claude titles and the stock default", () => {
expect(parseClaudePaneTitle("zsh")).toBeNull();
expect(parseClaudePaneTitle("/Users/me/proj")).toBeNull();
expect(parseClaudePaneTitle("…/Projects/Foo")).toBeNull();
expect(parseClaudePaneTitle("")).toBeNull();
expect(parseClaudePaneTitle(undefined)).toBeNull();
expect(parseClaudePaneTitle("✳ Claude Code")).toBeNull();
expect(parseClaudePaneTitle("⠐ Claude Code")).toBeNull();
});
});

describe("isClaudeForegroundCommand", () => {
test("matches claude binaries only", () => {
expect(isClaudeForegroundCommand("claude")).toBe(true);
expect(isClaudeForegroundCommand("/opt/homebrew/bin/claude")).toBe(true);
expect(isClaudeForegroundCommand("claude-code")).toBe(true);
expect(isClaudeForegroundCommand("bash")).toBe(false);
expect(isClaudeForegroundCommand("cursor")).toBe(false);
expect(isClaudeForegroundCommand(undefined)).toBe(false);
});
});
53 changes: 53 additions & 0 deletions src/dev-dashboard/lib/tmux/claude-pane-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Claude Code sets the tmux pane title via OSC to `<marker> <name>`. Strip the marker so we can
* mirror the name onto the tmux session + ttyd tab.
*
* The marker is `✳` while working, and otherwise an ANIMATED BRAILLE SPINNER FRAME — not a single
* fixed glyph. Matching only `⠐` (U+2810) made the sync succeed or fail depending on which frame
* happened to be current at poll time: a live session observed here read `⠂ ttyd-naming` (U+2802)
* and parsed as null. Accept the whole Braille Patterns block (U+2800–U+28FF).
*
* @see src/cmux/docs/Cmux.md — same title convention on cmux surfaces.
*/
const CLAUDE_PANE_TITLE_RE = /^[✳*⠀-⣿]\s*(.+)$/u;

/** Stock title before the user runs `/rename` — never promote this to a session name. */
const CLAUDE_DEFAULT_TITLES = new Set(["claude code", "claude"]);

export function parseClaudePaneTitle(title: string | undefined | null): string | null {
if (!title) {
return null;
}

const trimmed = title.trim();

if (!trimmed) {
return null;
}

const match = trimmed.match(CLAUDE_PANE_TITLE_RE);

if (!match?.[1]) {
return null;
}

// tmux session names cannot contain `:` or `.` — both are target-syntax separators.
// tmux 3.6a even silently munges a renamed session's dots to `_`, which would desync
// the stored binding name from the real session. Collapse whitespace; keep the rest.
const name = match[1].replace(/[:.]/g, "-").replace(/\s+/g, " ").trim();

if (!name || CLAUDE_DEFAULT_TITLES.has(name.toLowerCase())) {
return null;
}

return name;
}

export function isClaudeForegroundCommand(command: string | undefined): boolean {
if (!command) {
return false;
}

const base = command.trim().split("/").pop()?.toLowerCase() ?? "";
return base === "claude" || base === "claude-code";
}
10 changes: 6 additions & 4 deletions src/dev-dashboard/lib/tmux/create-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,22 @@ import { env } from "@genesiscz/utils/env";
import { makeStandaloneTmuxSessionName } from "@genesiscz/utils/tmux/naming";
import { createTmuxSession, sessionExists } from "@genesiscz/utils/tmux/sessions";

export function createStandaloneTmuxSession(opts: { name?: string; cwd?: string; command?: string } = {}): {
export async function createStandaloneTmuxSession(
opts: { name?: string; cwd?: string; command?: string } = {}
): Promise<{
sessionName: string;
cwd: string;
command: string;
} {
}> {
const sessionName = opts.name?.trim() || makeStandaloneTmuxSessionName();
const cwd = opts.cwd ?? process.cwd();
const command = opts.command ?? env.paths.getShell("/bin/zsh");

if (sessionExists(sessionName)) {
if (await sessionExists(sessionName)) {
throw new Error(`tmux session ${sessionName} already exists`);
}

createTmuxSession(sessionName, cwd, command);
await createTmuxSession(sessionName, cwd, command);

return { sessionName, cwd, command };
}
38 changes: 37 additions & 1 deletion src/dev-dashboard/lib/tmux/hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,48 @@ describe("tmux hub enrichment", () => {
{ name: "free-session", attached: 0, windows: 1 },
{ name: "busy-session", attached: 1, windows: 1 },
],
[{ id: "tab-1", tmuxSessionName: "busy-session" }]
[{ id: "tab-1", port: 60586, command: "/bin/zsh", cwd: "/work", tmuxSessionName: "busy-session" }]
);

expect(enriched[0]?.canAttachInTtyd).toBe(true);
expect(enriched[0]?.ttydTabIds).toEqual([]);
expect(enriched[0]?.ttydTabs).toEqual([]);
expect(enriched[1]?.canAttachInTtyd).toBe(false);
expect(enriched[1]?.ttydTabIds).toEqual(["tab-1"]);
expect(enriched[1]?.ttydTabs).toEqual([
{
id: "tab-1",
port: 60586,
label: "busy-session",
cwd: "/work",
lastCommand: undefined,
},
]);
});

test("surfaces ttyd display name / lastCommand on hub tabs", () => {
const enriched = enrichSessionsForHub(
[{ name: "bridge", attached: 1, windows: 1 }],
[
{
id: "t1",
port: 50100,
command: "/bin/zsh",
cwd: "/Users/me/proj",
tmuxSessionName: "bridge",
name: "My Bridge",
lastCommand: "claude",
},
]
);

expect(enriched[0]?.ttydTabs[0]).toEqual({
id: "t1",
port: 50100,
label: "My Bridge",
cwd: "/Users/me/proj",
lastCommand: "claude",
});
});

test("marks sessions attached in cmux", () => {
Expand All @@ -25,5 +60,6 @@ describe("tmux hub enrichment", () => {

expect(enriched[0]?.inCmux).toBe(true);
expect(enriched[0]?.cmuxSurfaces).toHaveLength(1);
expect(enriched[0]?.ttydTabs).toEqual([]);
});
});
38 changes: 33 additions & 5 deletions src/dev-dashboard/lib/tmux/hub.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,71 @@
import { ttydLabel } from "@app/dev-dashboard/lib/ttyd/label";
import type { CmuxTmuxSurfaceRef } from "@genesiscz/utils/cmux/tmux-bindings";
import type { TmuxSessionInfo } from "@genesiscz/utils/tmux/types";

export interface TtydHubTab {
id: string;
port: number;
/** Identity. Never Claude's topic — see the name-vs-title note in ttyd/manager.ts. */
label: string;
cwd?: string;
lastCommand?: string;
/** Claude's live topic, shown alongside the label rather than replacing it. */
title?: string;
}

export interface TmuxHubSession extends TmuxSessionInfo {
ttydTabIds: string[];
ttydTabs: TtydHubTab[];
canAttachInTtyd: boolean;
cmuxSurfaces: CmuxTmuxSurfaceRef[];
inCmux: boolean;
}

interface TtydBinding {
id: string;
port: number;
command: string;
cwd: string;
tmuxSessionName?: string;
name?: string;
lastCommand?: string;
title?: string;
}

export function enrichSessionsForHub(
sessions: TmuxSessionInfo[],
ttydSessions: TtydBinding[],
cmuxBySession: Map<string, CmuxTmuxSurfaceRef[]> = new Map()
): TmuxHubSession[] {
const ttydByTmux = new Map<string, string[]>();
const ttydByTmux = new Map<string, TtydHubTab[]>();

for (const ttyd of ttydSessions) {
if (!ttyd.tmuxSessionName) {
continue;
}

const tab: TtydHubTab = {
id: ttyd.id,
port: ttyd.port,
label: ttydLabel(ttyd),
cwd: ttyd.cwd,
lastCommand: ttyd.lastCommand,
title: ttyd.title,
};
const existing = ttydByTmux.get(ttyd.tmuxSessionName) ?? [];
existing.push(ttyd.id);
existing.push(tab);
ttydByTmux.set(ttyd.tmuxSessionName, existing);
}

return sessions.map((session) => {
const ttydTabIds = ttydByTmux.get(session.name) ?? [];
const ttydTabs = ttydByTmux.get(session.name) ?? [];
const cmuxSurfaces = cmuxBySession.get(session.name) ?? [];

return {
...session,
ttydTabIds,
canAttachInTtyd: ttydTabIds.length === 0,
ttydTabIds: ttydTabs.map((tab) => tab.id),
ttydTabs,
canAttachInTtyd: ttydTabs.length === 0,
cmuxSurfaces,
inCmux: cmuxSurfaces.length > 0,
};
Expand Down
12 changes: 12 additions & 0 deletions src/dev-dashboard/lib/tmux/naming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { makeCmuxTmuxSessionName, makeTtydTmuxSessionName } from "@app/dev-dashboard/lib/tmux/naming";

describe("tmux session naming", () => {
test("ttyd defaults to dd-<8hex>", () => {
expect(makeTtydTmuxSessionName("58bcf039-aaaa-bbbb-cccc-dddddddddddd")).toBe("dd-58bcf039");
});

test("cmux defaults to dd-cmux-<8hex>", () => {
expect(makeCmuxTmuxSessionName()).toMatch(/^dd-cmux-[0-9a-f]{8}$/);
});
});
5 changes: 3 additions & 2 deletions src/dev-dashboard/lib/tmux/naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { makeStandaloneTmuxSessionName } from "@genesiscz/utils/tmux/naming";

export { DEV_DASHBOARD_WORKSPACE } from "@app/dev-dashboard/lib/tmux/constants";

/** Short default for ttyd-spawned sessions — was `dev-dashboard-<8hex>`, now `dd-<8hex>`. */
export function makeTtydTmuxSessionName(id: string): string {
return `dev-dashboard-${id.slice(0, 8)}`;
return `dd-${id.slice(0, 8)}`;
}

export function makeCmuxTmuxSessionName(): string {
return makeStandaloneTmuxSessionName("dev-dashboard-cmux");
return makeStandaloneTmuxSessionName("dd-cmux");
}
4 changes: 2 additions & 2 deletions src/dev-dashboard/lib/tmux/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function savePreset(input: SavePresetInput, store?: TmuxPresetStore): Tmu
return s.summarize(preset);
}

export function restorePreset(name: string, store?: TmuxPresetStore): RestorePresetResult {
export async function restorePreset(name: string, store?: TmuxPresetStore): Promise<RestorePresetResult> {
const s = resolveStore(store);
const preset = s.read(name);

Expand All @@ -82,7 +82,7 @@ export function restorePreset(name: string, store?: TmuxPresetStore): RestorePre

for (const session of preset.sessions) {
try {
const outcome = restoreTmuxSession(session);
const outcome = await restoreTmuxSession(session);
outcomes.push(outcome);

if (outcome.created) {
Expand Down
10 changes: 8 additions & 2 deletions src/dev-dashboard/lib/tmux/rename.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { retargetTtydTmuxBindings } from "@app/dev-dashboard/lib/ttyd/manager";
import { retargetTtydTmuxBindings, syncTtydDisplayNamesForTmux } from "@app/dev-dashboard/lib/ttyd/manager";
import { renameTmuxSession } from "@genesiscz/utils/tmux/sessions";

/**
* Rename a live tmux session and keep every dashboard surface in sync:
* relaunch bound ttyd processes so `attach-session -t` tracks the new name, and
* mirror the name onto ttyd display labels so tabs match Session Hub.
*/
export async function renameTmuxSessionInHub(fromName: string, toName: string): Promise<string> {
const trimmed = toName.trim();

if (!trimmed) {
throw new Error("Destination tmux session name cannot be empty.");
}

renameTmuxSession(fromName, trimmed);
await renameTmuxSession(fromName, trimmed);
await retargetTtydTmuxBindings(fromName, trimmed);
await syncTtydDisplayNamesForTmux(trimmed, trimmed);

return trimmed;
}
14 changes: 6 additions & 8 deletions src/dev-dashboard/lib/ttyd/label.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import type { TtydSession } from "./types";
import { deriveTtydDisplayName, type TtydNameSource } from "@app/dev-dashboard/lib/ttyd/naming";

/**
* Display label for a ttyd session. Pure — lives in its own module (not
* `manager.ts`) so the browser can import it without dragging the server-only
* manager (node:child_process, config → auth → node:crypto) into the client
* bundle.
*
* Delegates to {@link deriveTtydDisplayName} so tab chrome and Session Hub share
* the same identity (tmux session name), not `zsh :port`.
*/
export function ttydLabel(session: TtydSession): string {
const name = session.name?.trim();
if (name) {
return name;
}

return `${session.command.split("/").pop()} :${session.port}`;
export function ttydLabel(session: TtydNameSource): string {
return deriveTtydDisplayName(session);
}
Loading
Loading