From 7cc78dd5d8f306f9c7c18031372b01508de2343e Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 3 Aug 2026 01:21:56 +0200 Subject: [PATCH 1/6] feat(dev-dashboard): one tmux/ttyd identity: Claude /rename pane-title sync, hub ttyd tabs, dd-* naming --- src/dev-dashboard/config.ts | 2 + src/dev-dashboard/contract/dto.ts | 7 + .../lib/tmux/claude-pane-title.test.ts | 35 ++++ .../lib/tmux/claude-pane-title.ts | 46 +++++ src/dev-dashboard/lib/tmux/hub.test.ts | 38 +++- src/dev-dashboard/lib/tmux/hub.ts | 33 +++- src/dev-dashboard/lib/tmux/naming.test.ts | 12 ++ src/dev-dashboard/lib/tmux/naming.ts | 5 +- src/dev-dashboard/lib/ttyd/label.ts | 14 +- src/dev-dashboard/lib/ttyd/naming.test.ts | 19 +- src/dev-dashboard/lib/ttyd/naming.ts | 36 +++- src/dev-dashboard/lib/ttyd/types.ts | 9 +- src/dev-dashboard/server/routes/cmux.ts | 6 +- .../ui/src/components/TmuxSessionName.tsx | 3 +- .../ui/src/components/TmuxSessionsPanel.tsx | 40 +++- .../terminal-shell/MobileTerminalShell.tsx | 28 ++- src/dev-dashboard/ui/src/lib/api.ts | 7 + .../ui/src/lib/terminal-tabs.test.ts | 19 +- src/dev-dashboard/ui/src/routes/ttyd.tsx | 3 +- src/tmux/commands/sessions-format.ts | 34 ++++ src/tmux/commands/sessions.test.ts | 24 +++ src/tmux/commands/sessions.ts | 181 ++++++++++++++---- 22 files changed, 518 insertions(+), 83 deletions(-) create mode 100644 src/dev-dashboard/lib/tmux/claude-pane-title.test.ts create mode 100644 src/dev-dashboard/lib/tmux/claude-pane-title.ts create mode 100644 src/dev-dashboard/lib/tmux/naming.test.ts create mode 100644 src/tmux/commands/sessions-format.ts create mode 100644 src/tmux/commands/sessions.test.ts diff --git a/src/dev-dashboard/config.ts b/src/dev-dashboard/config.ts index 4d9e405b3..3105af06c 100644 --- a/src/dev-dashboard/config.ts +++ b/src/dev-dashboard/config.ts @@ -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({ diff --git a/src/dev-dashboard/contract/dto.ts b/src/dev-dashboard/contract/dto.ts index 62b7a9e47..4545ae78e 100644 --- a/src/dev-dashboard/contract/dto.ts +++ b/src/dev-dashboard/contract/dto.ts @@ -114,6 +114,13 @@ export interface TmuxHubSession { attached: number; windows: number; ttydTabIds: string[]; + ttydTabs: Array<{ + id: string; + port: number; + label: string; + cwd?: string; + lastCommand?: string; + }>; canAttachInTtyd: boolean; cmuxSurfaces: Array<{ workspaceId: string; surfaceId: string; title: string }>; inCmux: boolean; diff --git a/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts b/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts new file mode 100644 index 000000000..24f3401e2 --- /dev/null +++ b/src/dev-dashboard/lib/tmux/claude-pane-title.test.ts @@ -0,0 +1,35 @@ +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("keeps multi-word titles and sanitizes colons", () => { + expect(parseClaudePaneTitle("✳ Debug formatting: spacing")).toBe("Debug formatting- spacing"); + }); + + 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); + }); +}); diff --git a/src/dev-dashboard/lib/tmux/claude-pane-title.ts b/src/dev-dashboard/lib/tmux/claude-pane-title.ts new file mode 100644 index 000000000..4796e09de --- /dev/null +++ b/src/dev-dashboard/lib/tmux/claude-pane-title.ts @@ -0,0 +1,46 @@ +/** + * Claude Code sets the tmux pane title via OSC to `✳ ` (working) or `⠐ ` (idle) + * after `/rename`. Strip that marker so we can mirror the name onto the tmux session + ttyd tab. + * + * @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 `:`. 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"; +} diff --git a/src/dev-dashboard/lib/tmux/hub.test.ts b/src/dev-dashboard/lib/tmux/hub.test.ts index d3c4b3307..c128d2483 100644 --- a/src/dev-dashboard/lib/tmux/hub.test.ts +++ b/src/dev-dashboard/lib/tmux/hub.test.ts @@ -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", () => { @@ -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([]); }); }); diff --git a/src/dev-dashboard/lib/tmux/hub.ts b/src/dev-dashboard/lib/tmux/hub.ts index ada473c46..bdd7ff6cc 100644 --- a/src/dev-dashboard/lib/tmux/hub.ts +++ b/src/dev-dashboard/lib/tmux/hub.ts @@ -1,8 +1,18 @@ +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; + label: string; + cwd?: string; + lastCommand?: string; +} + export interface TmuxHubSession extends TmuxSessionInfo { ttydTabIds: string[]; + ttydTabs: TtydHubTab[]; canAttachInTtyd: boolean; cmuxSurfaces: CmuxTmuxSurfaceRef[]; inCmux: boolean; @@ -10,7 +20,12 @@ export interface TmuxHubSession extends TmuxSessionInfo { interface TtydBinding { id: string; + port: number; + command: string; + cwd: string; tmuxSessionName?: string; + name?: string; + lastCommand?: string; } export function enrichSessionsForHub( @@ -18,26 +33,34 @@ export function enrichSessionsForHub( ttydSessions: TtydBinding[], cmuxBySession: Map = new Map() ): TmuxHubSession[] { - const ttydByTmux = new Map(); + const ttydByTmux = new Map(); 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, + }; 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, }; diff --git a/src/dev-dashboard/lib/tmux/naming.test.ts b/src/dev-dashboard/lib/tmux/naming.test.ts new file mode 100644 index 000000000..1b8a8581c --- /dev/null +++ b/src/dev-dashboard/lib/tmux/naming.test.ts @@ -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}$/); + }); +}); diff --git a/src/dev-dashboard/lib/tmux/naming.ts b/src/dev-dashboard/lib/tmux/naming.ts index 7c861858b..47d7846ad 100644 --- a/src/dev-dashboard/lib/tmux/naming.ts +++ b/src/dev-dashboard/lib/tmux/naming.ts @@ -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"); } diff --git a/src/dev-dashboard/lib/ttyd/label.ts b/src/dev-dashboard/lib/ttyd/label.ts index 962acd488..a11c84056 100644 --- a/src/dev-dashboard/lib/ttyd/label.ts +++ b/src/dev-dashboard/lib/ttyd/label.ts @@ -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); } diff --git a/src/dev-dashboard/lib/ttyd/naming.test.ts b/src/dev-dashboard/lib/ttyd/naming.test.ts index eeb929fc7..a53c28b88 100644 --- a/src/dev-dashboard/lib/ttyd/naming.test.ts +++ b/src/dev-dashboard/lib/ttyd/naming.test.ts @@ -28,15 +28,15 @@ describe("isMeaningfulCommand", () => { }); }); -describe("deriveTtydDisplayName precedence (manual wins — auto never overwrites)", () => { - it("a manual name beats a meaningful lastCommand", () => { +describe("deriveTtydDisplayName precedence (tmux identity over live command)", () => { + it("a manual name beats tmux session name and lastCommand", () => { const s = session({ name: "My Server", lastCommand: "vim", tmuxSessionName: "dev-dashboard-abc12345" }); expect(deriveTtydDisplayName(s)).toBe("My Server"); }); - it("auto-names from lastCommand when no manual name", () => { + it("prefers tmux session name over a meaningful lastCommand (shared hub identity)", () => { const s = session({ lastCommand: "claude", tmuxSessionName: "dev-dashboard-abc12345" }); - expect(deriveTtydDisplayName(s)).toBe("claude"); + expect(deriveTtydDisplayName(s)).toBe("dev-dashboard-abc12345"); }); it("falls back to tmux session name when lastCommand is just a shell", () => { @@ -44,13 +44,18 @@ describe("deriveTtydDisplayName precedence (manual wins — auto never overwrite expect(deriveTtydDisplayName(s)).toBe("dev-dashboard-abc12345"); }); - it("falls back to command when there is no tmux binding and no meaningful command", () => { + it("uses meaningful lastCommand when unbound", () => { + const s = session({ lastCommand: "node", command: "/bin/zsh" }); + expect(deriveTtydDisplayName(s)).toBe("node"); + }); + + it("falls back to command:port when unbound and no meaningful command", () => { const s = session({ command: "/bin/zsh", lastCommand: "zsh" }); - expect(deriveTtydDisplayName(s)).toBe("/bin/zsh"); + expect(deriveTtydDisplayName(s)).toBe("zsh :4001"); }); it("a whitespace-only manual name is treated as unset", () => { const s = session({ name: " ", lastCommand: "node", tmuxSessionName: "t" }); - expect(deriveTtydDisplayName(s)).toBe("node"); + expect(deriveTtydDisplayName(s)).toBe("t"); }); }); diff --git a/src/dev-dashboard/lib/ttyd/naming.ts b/src/dev-dashboard/lib/ttyd/naming.ts index c412bf013..71783e638 100644 --- a/src/dev-dashboard/lib/ttyd/naming.ts +++ b/src/dev-dashboard/lib/ttyd/naming.ts @@ -1,9 +1,16 @@ import type { TtydSession } from "@app/dev-dashboard/lib/ttyd/types"; +/** + * The subset of {@link TtydSession} the display-name derivation actually reads. Structural on + * purpose: hub enrichment passes contract-shaped bindings that lack `pid`/`startedAt`. + */ +export type TtydNameSource = Pick; + /** * Commands that are not meaningful as an auto-name — a session sitting at a shell prompt should fall * back to its tmux name / spawn command, not be labeled "zsh". Long-running foreground processes - * (claude, vim, node, …) ARE meaningful, so anything not in this set becomes the auto-name. + * (claude, vim, node, …) ARE meaningful, so anything not in this set becomes the auto-name when + * there is no tmux binding. */ const UNINTERESTING_COMMANDS = new Set(["zsh", "bash", "sh", "fish", "-zsh", "-bash", "login", "tmux"]); @@ -17,25 +24,36 @@ export function isMeaningfulCommand(command: string | undefined): command is str return trimmed.length > 0 && !UNINTERESTING_COMMANDS.has(trimmed); } +function portFallback(session: TtydNameSource): string { + return `${session.command.split("/").pop()} :${session.port}`; +} + /** - * The display name for a ttyd session, honoring the precedence the user asked for — a MANUAL name - * always wins, so the automatic command-derived name never overwrites a hand-set one: + * The display name for a ttyd session. Manual rename always wins; otherwise the **tmux session + * name** is the shared identity with Session Hub (so tabs like `zsh :60586` never diverge from the + * hub row). Meaningful foreground commands only auto-name when there is no tmux binding. * - * 1. `name` — explicit rename (the in-terminal pencil or a cmux rename). Sticky. - * 2. `lastCommand` — the live foreground command, when it is meaningful (auto-name). - * 3. `tmuxSessionName` — the bound tmux session. - * 4. `command` — the spawn command (always present). + * 1. `name` — explicit rename (tab pencil or hub rename). Sticky. + * 2. `tmuxSessionName` — the bound tmux session (shared with Session Hub). + * 3. `lastCommand` — live foreground command, when meaningful and unbound. + * 4. `command`:`port` — spawn fallback. */ -export function deriveTtydDisplayName(session: TtydSession): string { +export function deriveTtydDisplayName(session: TtydNameSource): string { const manual = session.name?.trim(); if (manual) { return manual; } + const tmux = session.tmuxSessionName?.trim(); + + if (tmux) { + return tmux; + } + if (isMeaningfulCommand(session.lastCommand)) { return session.lastCommand.trim(); } - return session.tmuxSessionName ?? session.command; + return portFallback(session); } diff --git a/src/dev-dashboard/lib/ttyd/types.ts b/src/dev-dashboard/lib/ttyd/types.ts index 3ce953c49..56f9ff902 100644 --- a/src/dev-dashboard/lib/ttyd/types.ts +++ b/src/dev-dashboard/lib/ttyd/types.ts @@ -6,12 +6,15 @@ export interface TtydSession { pid: number; startedAt: string; tmuxSessionName?: string; - /** User-set display name; falls back to " :" when unset. */ + /** + * User-set display name. When set (including after a unified rename), tabs and Session Hub share + * this identity with `tmuxSessionName`. Falls back via {@link deriveTtydDisplayName}. + */ name?: string; /** * Live command in the bound tmux session's active pane (`#{pane_current_command}`), refreshed on - * every `listTtyd()`. Drives an auto-name when the user has not set `name`. NOT persisted — it is - * a derived live fact, recomputed each read. Absent when the session has no tmux binding. + * every `listTtyd()`. Surfaced as secondary meta in the hub / CLI — not the primary tab label when + * a tmux binding exists. NOT persisted — derived live fact, recomputed each read. */ lastCommand?: string; } diff --git a/src/dev-dashboard/server/routes/cmux.ts b/src/dev-dashboard/server/routes/cmux.ts index 53b151696..69902edbf 100644 --- a/src/dev-dashboard/server/routes/cmux.ts +++ b/src/dev-dashboard/server/routes/cmux.ts @@ -131,13 +131,13 @@ export function cmuxRoutes(): RouteDef[] { title: body.title, }); - // A cmux rename is an explicit user action, so it sets the ttyd MANUAL name - // (same tier as the in-terminal pencil) — best-effort, never fails the rename. + // One identity: cmux rename → ttyd display name + bound tmux session. + // Best-effort — never fails the cmux rename itself. if (ttydId) { try { await renameTtyd(ttydId, body.title); } catch (err) { - logger.debug({ err, ttydId }, "cmux rename: ttyd display-name propagation failed"); + logger.debug({ err, ttydId }, "cmux rename: ttyd/tmux sync failed"); } } } else { diff --git a/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx b/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx index 24f9317da..d39b5457e 100644 --- a/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx +++ b/src/dev-dashboard/ui/src/components/TmuxSessionName.tsx @@ -131,7 +131,8 @@ export function TmuxSessionName({ name, editable = true, size = "sm", className setEditing(true); }} className="shrink-0 rounded-md p-1 text-[var(--dd-text-muted)] transition-colors hover:bg-white/5 hover:text-[var(--dd-accent-from)]" - aria-label="Rename tmux session" + aria-label="Rename session (tmux + ttyd)" + title="Renames the tmux session and the ttyd tab together" > diff --git a/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx b/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx index 39e93fa2d..77f9a14de 100644 --- a/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx +++ b/src/dev-dashboard/ui/src/components/TmuxSessionsPanel.tsx @@ -89,7 +89,8 @@ export function TmuxSessionsPanel({ open, onOpenChange, onFocusTtydTab }: Props) Session hub Tmux sessions - Attach in ttyd or send to cmux — shared tmux I/O across surfaces. + Same names as the terminal tabs — rename once, tmux + ttyd both update. Attach in ttyd + or send to cmux. @@ -181,7 +182,11 @@ function SessionRow({ onRemove?: () => void; onRenamed: (nextName: string) => void; }) { - const alreadyInTtyd = session.ttydTabIds.length > 0; + const alreadyInTtyd = (session.ttydTabs?.length ?? session.ttydTabIds.length) > 0; + const primaryTab = session.ttydTabs?.[0]; + const cwd = primaryTab?.cwd; + const lastCommand = primaryTab?.lastCommand; + const shortCwd = cwd ? shortenPath(cwd) : null; return (

{session.windows} window(s) · {session.attached} attached - {alreadyInTtyd ? ` · ttyd ×${session.ttydTabIds.length}` : ""} + {alreadyInTtyd + ? ` · ttyd ${ + (session.ttydTabs ?? []).map((t) => `:${t.port}`).join(" ") || + `×${session.ttydTabIds.length}` + }` + : ""} {session.inCmux && session.cmuxSurfaces.length > 0 ? ` · cmux ×${session.cmuxSurfaces.length}` : ""}

+ {shortCwd || lastCommand ? ( +

+ {lastCommand ? {lastCommand} : null} + {lastCommand && shortCwd ? · : null} + {shortCwd ? {shortCwd} : null} +

+ ) : null}