From 8536172e800ea42e896580249e757d68e2ddd1e0 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:26:52 -0400 Subject: [PATCH 1/2] feat: include TTY and tmux pane metadata in session registration Add tty and tmuxPane fields to HunkSessionRegistration and ListedSession so `hunk session list` and `hunk session get` can show which terminal a session belongs to. This makes it easier to identify sessions when multiple are running. Closes #89 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mcp/daemonState.ts | 2 + src/mcp/sessionRegistration.ts | 18 ++++++ src/mcp/types.ts | 4 ++ src/session/commands.ts | 4 ++ test/session-commands.test.ts | 98 +++++++++++++++++++++++++++++++ test/session-registration.test.ts | 65 ++++++++++++++++++++ 6 files changed, 191 insertions(+) create mode 100644 test/session-registration.test.ts diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 20318dc..635a578 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -118,6 +118,8 @@ export class HunkDaemonState { title: entry.registration.title, sourceLabel: entry.registration.sourceLabel, launchedAt: entry.registration.launchedAt, + tty: entry.registration.tty, + tmuxPane: entry.registration.tmuxPane, fileCount: entry.registration.files.length, files: entry.registration.files, snapshot: entry.snapshot, diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts index f1515be..314e926 100644 --- a/src/mcp/sessionRegistration.ts +++ b/src/mcp/sessionRegistration.ts @@ -1,8 +1,24 @@ import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; import type { AppBootstrap } from "../core/types"; import { hunkLineRange } from "../core/liveComments"; import type { HunkSessionRegistration, HunkSessionSnapshot, SessionFileSummary } from "./types"; +/** Resolve the TTY device path for the current process, if available. */ +function ttyname(): string | undefined { + if (!process.stdin.isTTY) { + return undefined; + } + + try { + const result = spawnSync("tty", [], { stdio: ["inherit", "pipe", "pipe"] }); + const name = result.stdout?.toString().trim(); + return name && !name.startsWith("not a tty") ? name : undefined; + } catch { + return undefined; + } +} + function inferRepoRoot(bootstrap: AppBootstrap) { return bootstrap.input.kind === "git" || bootstrap.input.kind === "show" || @@ -33,6 +49,8 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, launchedAt: new Date().toISOString(), + tty: ttyname(), + tmuxPane: process.env.TMUX_PANE || undefined, files: buildSessionFiles(bootstrap), }; } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f113364..fc17f84 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -31,6 +31,8 @@ export interface HunkSessionRegistration { title: string; sourceLabel: string; launchedAt: string; + tty?: string; + tmuxPane?: string; files: SessionFileSummary[]; } @@ -237,6 +239,8 @@ export interface ListedSession { title: string; sourceLabel: string; launchedAt: string; + tty?: string; + tmuxPane?: string; fileCount: number; files: SessionFileSummary[]; snapshot: HunkSessionSnapshot; diff --git a/src/session/commands.ts b/src/session/commands.ts index a90b014..3658245 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -369,6 +369,8 @@ function formatListOutput(sessions: ListedSession[]) { [ `${session.sessionId} ${session.title}`, ` repo: ${session.repoRoot ?? session.cwd}`, + ...(session.tty ? [` tty: ${session.tty}`] : []), + ...(session.tmuxPane ? [` tmux pane: ${session.tmuxPane}`] : []), ` focus: ${formatSelectedSummary(session)}`, ` files: ${session.fileCount}`, ` comments: ${session.snapshot.liveCommentCount}`, @@ -385,6 +387,8 @@ function formatSessionOutput(session: ListedSession) { `Repo: ${session.repoRoot ?? session.cwd}`, `Input: ${session.inputKind}`, `Launched: ${session.launchedAt}`, + ...(session.tty ? [`TTY: ${session.tty}`] : []), + ...(session.tmuxPane ? [`Tmux pane: ${session.tmuxPane}`] : []), `Selected: ${formatSelectedSummary(session)}`, `Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`, `Live comments: ${session.snapshot.liveCommentCount}`, diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index d37870e..80fa3d3 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -292,3 +292,101 @@ describe("session command compatibility checks", () => { expect(restartCalls).toEqual([]); }); }); + +describe("session list includes tty and tmux metadata", () => { + test("list output includes tty and tmux pane when present", async () => { + const session = { + ...createListedSession("session-1"), + tty: "/dev/ttys003", + tmuxPane: "%2", + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [session], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "text", + } satisfies SessionCommandInput); + + expect(output).toContain("tty: /dev/ttys003"); + expect(output).toContain("tmux pane: %2"); + }); + + test("list output omits tty and tmux lines when absent", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [createListedSession("session-1")], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "text", + } satisfies SessionCommandInput); + + expect(output).not.toContain("tty:"); + expect(output).not.toContain("tmux pane:"); + }); + + test("get output includes tty and tmux pane when present", async () => { + const session = { + ...createListedSession("session-1"), + tty: "/dev/ttys005", + tmuxPane: "%0", + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSession: async () => session, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "get", + selector: { sessionId: "session-1" }, + output: "text", + } satisfies SessionCommandInput); + + expect(output).toContain("TTY: /dev/ttys005"); + expect(output).toContain("Tmux pane: %0"); + }); + + test("json output includes tty and tmux pane fields", async () => { + const session = { + ...createListedSession("session-1"), + tty: "/dev/ttys003", + tmuxPane: "%2", + }; + + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listSessions: async () => [session], + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "list", + output: "json", + } satisfies SessionCommandInput); + + const parsed = JSON.parse(output); + expect(parsed.sessions[0].tty).toBe("/dev/ttys003"); + expect(parsed.sessions[0].tmuxPane).toBe("%2"); + }); +}); diff --git a/test/session-registration.test.ts b/test/session-registration.test.ts new file mode 100644 index 0000000..6fc070b --- /dev/null +++ b/test/session-registration.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { HunkDaemonState } from "../src/mcp/daemonState"; +import type { HunkSessionRegistration, HunkSessionSnapshot } from "../src/mcp/types"; + +function createRegistration( + overrides: Partial = {}, +): HunkSessionRegistration { + return { + sessionId: "test-session", + pid: 1234, + cwd: "/repo", + repoRoot: "/repo", + inputKind: "diff", + title: "repo diff", + sourceLabel: "/repo", + launchedAt: "2026-03-23T00:00:00.000Z", + files: [], + ...overrides, + }; +} + +function createSnapshot(): HunkSessionSnapshot { + return { + selectedFileId: undefined, + selectedFilePath: undefined, + selectedHunkIndex: 0, + showAgentNotes: false, + liveCommentCount: 0, + liveComments: [], + updatedAt: "2026-03-23T00:00:00.000Z", + }; +} + +function createMockSocket() { + return { send: () => {} }; +} + +describe("session registration tty metadata", () => { + test("daemon state passes tty and tmuxPane through to listed sessions", () => { + const state = new HunkDaemonState(); + const registration = createRegistration({ + tty: "/dev/ttys003", + tmuxPane: "%2", + }); + + state.registerSession(createMockSocket(), registration, createSnapshot()); + + const sessions = state.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.tty).toBe("/dev/ttys003"); + expect(sessions[0]!.tmuxPane).toBe("%2"); + }); + + test("daemon state omits tty and tmuxPane when not set", () => { + const state = new HunkDaemonState(); + const registration = createRegistration(); + + state.registerSession(createMockSocket(), registration, createSnapshot()); + + const sessions = state.listSessions(); + expect(sessions).toHaveLength(1); + expect(sessions[0]!.tty).toBeUndefined(); + expect(sessions[0]!.tmuxPane).toBeUndefined(); + }); +}); From 475a9a385cac4ab47f325a993a626fe50a4764a7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 25 Mar 2026 14:33:04 -0400 Subject: [PATCH 2/2] refactor: generalize session terminal metadata --- src/mcp/daemonState.ts | 3 +- src/mcp/sessionRegistration.ts | 6 +- src/mcp/sessionTerminalMetadata.ts | 120 +++++++++++++++++++++++++ src/mcp/types.ts | 21 ++++- src/session/commands.ts | 81 +++++++++++++++-- test/session-commands.test.ts | 64 +++++++++---- test/session-registration.test.ts | 28 ++++-- test/session-terminal-metadata.test.ts | 49 ++++++++++ 8 files changed, 328 insertions(+), 44 deletions(-) create mode 100644 src/mcp/sessionTerminalMetadata.ts create mode 100644 test/session-terminal-metadata.test.ts diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 635a578..7fc18cb 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -118,8 +118,7 @@ export class HunkDaemonState { title: entry.registration.title, sourceLabel: entry.registration.sourceLabel, launchedAt: entry.registration.launchedAt, - tty: entry.registration.tty, - tmuxPane: entry.registration.tmuxPane, + terminal: entry.registration.terminal, fileCount: entry.registration.files.length, files: entry.registration.files, snapshot: entry.snapshot, diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts index 314e926..6e5dec4 100644 --- a/src/mcp/sessionRegistration.ts +++ b/src/mcp/sessionRegistration.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; import type { AppBootstrap } from "../core/types"; import { hunkLineRange } from "../core/liveComments"; +import { resolveSessionTerminalMetadata } from "./sessionTerminalMetadata"; import type { HunkSessionRegistration, HunkSessionSnapshot, SessionFileSummary } from "./types"; /** Resolve the TTY device path for the current process, if available. */ @@ -40,6 +41,8 @@ function buildSessionFiles(bootstrap: AppBootstrap): SessionFileSummary[] { /** Build the daemon-facing metadata for one live Hunk TUI session. */ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionRegistration { + const terminal = resolveSessionTerminalMetadata({ tty: ttyname() }); + return { sessionId: randomUUID(), pid: process.pid, @@ -49,8 +52,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, launchedAt: new Date().toISOString(), - tty: ttyname(), - tmuxPane: process.env.TMUX_PANE || undefined, + terminal, files: buildSessionFiles(bootstrap), }; } diff --git a/src/mcp/sessionTerminalMetadata.ts b/src/mcp/sessionTerminalMetadata.ts new file mode 100644 index 0000000..fff9b90 --- /dev/null +++ b/src/mcp/sessionTerminalMetadata.ts @@ -0,0 +1,120 @@ +import type { SessionTerminalLocation, SessionTerminalMetadata } from "./types"; + +function trimmed(value: string | undefined) { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : undefined; +} + +function sameLocation(left: SessionTerminalLocation, right: SessionTerminalLocation) { + return ( + left.source === right.source && + left.tty === right.tty && + left.windowId === right.windowId && + left.tabId === right.tabId && + left.paneId === right.paneId && + left.terminalId === right.terminalId && + left.sessionId === right.sessionId + ); +} + +function pushLocation(locations: SessionTerminalLocation[], location: SessionTerminalLocation) { + if (!locations.some((existing) => sameLocation(existing, location))) { + locations.push(location); + } +} + +function inferLocationSource(program: string | undefined) { + const normalized = program?.trim().toLowerCase(); + if (!normalized) { + return "terminal"; + } + + if (normalized === "iterm.app" || normalized === "iterm2") { + return "iterm2"; + } + + if (normalized === "ghostty") { + return "ghostty"; + } + + if (normalized === "apple_terminal" || normalized === "apple terminal") { + return "terminal.app"; + } + + return "terminal"; +} + +function parseHierarchicalIds(sessionId: string) { + const prefix = sessionId.split(":", 1)[0]?.trim(); + if (!prefix) { + return {}; + } + + const match = /^w(?\d+)t(?\d+)(?:p(?\d+))?$/i.exec(prefix); + if (!match?.groups) { + return {}; + } + + return { + windowId: match.groups.window, + tabId: match.groups.tab, + paneId: match.groups.pane, + } satisfies Pick; +} + +/** + * Capture terminal- and multiplexer-facing location metadata for one Hunk TUI session. + * + * The structure is intentionally generic so we can layer tmux, iTerm2, Ghostty, + * and future terminal integrations without adding a new top-level field for each one. + */ +export function resolveSessionTerminalMetadata({ + env = process.env, + tty, +}: { + env?: NodeJS.ProcessEnv; + tty?: string; +} = {}): SessionTerminalMetadata | undefined { + const termProgram = trimmed(env.TERM_PROGRAM); + const lcTerminal = trimmed(env.LC_TERMINAL); + const program = + termProgram?.toLowerCase() === "tmux" && lcTerminal ? lcTerminal : (termProgram ?? lcTerminal); + const locations: SessionTerminalLocation[] = []; + + const ttyPath = trimmed(tty); + if (ttyPath) { + pushLocation(locations, { source: "tty", tty: ttyPath }); + } + + const tmuxPane = trimmed(env.TMUX_PANE); + if (tmuxPane) { + pushLocation(locations, { source: "tmux", paneId: tmuxPane }); + } + + const iTermSessionId = trimmed(env.ITERM_SESSION_ID); + if (iTermSessionId) { + pushLocation(locations, { + source: "iterm2", + sessionId: iTermSessionId, + ...parseHierarchicalIds(iTermSessionId), + }); + } + + const terminalSessionId = trimmed(env.TERM_SESSION_ID); + if (terminalSessionId && terminalSessionId !== iTermSessionId) { + pushLocation(locations, { + source: inferLocationSource(program), + sessionId: terminalSessionId, + ...parseHierarchicalIds(terminalSessionId), + }); + } + + if (!program && locations.length === 0) { + return undefined; + } + + return { + program, + locations, + }; +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts index fc17f84..5f617c0 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -22,6 +22,21 @@ export interface SelectedHunkSummary { newRange?: [number, number]; } +export interface SessionTerminalLocation { + source: string; + tty?: string; + windowId?: string; + tabId?: string; + paneId?: string; + terminalId?: string; + sessionId?: string; +} + +export interface SessionTerminalMetadata { + program?: string; + locations: SessionTerminalLocation[]; +} + export interface HunkSessionRegistration { sessionId: string; pid: number; @@ -31,8 +46,7 @@ export interface HunkSessionRegistration { title: string; sourceLabel: string; launchedAt: string; - tty?: string; - tmuxPane?: string; + terminal?: SessionTerminalMetadata; files: SessionFileSummary[]; } @@ -239,8 +253,7 @@ export interface ListedSession { title: string; sourceLabel: string; launchedAt: string; - tty?: string; - tmuxPane?: string; + terminal?: SessionTerminalMetadata; fileCount: number; files: SessionFileSummary[]; snapshot: HunkSessionSnapshot; diff --git a/src/session/commands.ts b/src/session/commands.ts index 3658245..a8b94d4 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -26,6 +26,8 @@ import type { RemovedCommentResult, SelectedSessionContext, SessionLiveCommentSummary, + SessionTerminalLocation, + SessionTerminalMetadata, } from "../mcp/types"; import { HUNK_SESSION_API_PATH, @@ -359,27 +361,88 @@ function formatSelectedSummary(session: ListedSession) { return filePath === "(none)" ? filePath : `${filePath} hunk ${hunkNumber}`; } +function formatTerminalLocation(location: SessionTerminalLocation) { + const parts: string[] = []; + + if (location.tty) { + parts.push(location.tty); + } + + if (location.windowId) { + parts.push(`window ${location.windowId}`); + } + + if (location.tabId) { + parts.push(`tab ${location.tabId}`); + } + + if (location.paneId) { + parts.push(`pane ${location.paneId}`); + } + + if (location.terminalId) { + parts.push(`terminal ${location.terminalId}`); + } + + if (location.sessionId) { + parts.push(`session ${location.sessionId}`); + } + + return parts.length > 0 ? parts.join(", ") : "present"; +} + +function resolveSessionTerminal(session: ListedSession) { + return session.terminal; +} + +function formatTerminalLines( + terminal: SessionTerminalMetadata | undefined, + { + headerLabel, + locationLabel, + }: { + headerLabel: string; + locationLabel: string; + }, +) { + if (!terminal) { + return []; + } + + return [ + ...(terminal.program ? [`${headerLabel}: ${terminal.program}`] : []), + ...terminal.locations.map( + (location) => `${locationLabel}[${location.source}]: ${formatTerminalLocation(location)}`, + ), + ]; +} + function formatListOutput(sessions: ListedSession[]) { if (sessions.length === 0) { return "No active Hunk sessions.\n"; } return `${sessions - .map((session) => - [ + .map((session) => { + const terminal = resolveSessionTerminal(session); + return [ `${session.sessionId} ${session.title}`, ` repo: ${session.repoRoot ?? session.cwd}`, - ...(session.tty ? [` tty: ${session.tty}`] : []), - ...(session.tmuxPane ? [` tmux pane: ${session.tmuxPane}`] : []), + ...formatTerminalLines(terminal, { + headerLabel: " terminal", + locationLabel: " location", + }), ` focus: ${formatSelectedSummary(session)}`, ` files: ${session.fileCount}`, ` comments: ${session.snapshot.liveCommentCount}`, - ].join("\n"), - ) + ].join("\n"); + }) .join("\n\n")}\n`; } function formatSessionOutput(session: ListedSession) { + const terminal = resolveSessionTerminal(session); + return [ `Session: ${session.sessionId}`, `Title: ${session.title}`, @@ -387,8 +450,10 @@ function formatSessionOutput(session: ListedSession) { `Repo: ${session.repoRoot ?? session.cwd}`, `Input: ${session.inputKind}`, `Launched: ${session.launchedAt}`, - ...(session.tty ? [`TTY: ${session.tty}`] : []), - ...(session.tmuxPane ? [`Tmux pane: ${session.tmuxPane}`] : []), + ...formatTerminalLines(terminal, { + headerLabel: "Terminal", + locationLabel: "Location", + }), `Selected: ${formatSelectedSummary(session)}`, `Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`, `Live comments: ${session.snapshot.liveCommentCount}`, diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index 80fa3d3..0467488 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -293,12 +293,18 @@ describe("session command compatibility checks", () => { }); }); -describe("session list includes tty and tmux metadata", () => { - test("list output includes tty and tmux pane when present", async () => { +describe("session list includes terminal metadata", () => { + test("list output includes generic terminal and location lines when present", async () => { const session = { ...createListedSession("session-1"), - tty: "/dev/ttys003", - tmuxPane: "%2", + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + { source: "iterm2", windowId: "1", tabId: "2", paneId: "3" }, + ], + }, }; setSessionCommandTestHooks({ @@ -315,11 +321,13 @@ describe("session list includes tty and tmux metadata", () => { output: "text", } satisfies SessionCommandInput); - expect(output).toContain("tty: /dev/ttys003"); - expect(output).toContain("tmux pane: %2"); + expect(output).toContain("terminal: iTerm.app"); + expect(output).toContain("location[tty]: /dev/ttys003"); + expect(output).toContain("location[tmux]: pane %2"); + expect(output).toContain("location[iterm2]: window 1, tab 2, pane 3"); }); - test("list output omits tty and tmux lines when absent", async () => { + test("list output omits terminal lines when absent", async () => { setSessionCommandTestHooks({ createClient: () => createClient({ @@ -334,15 +342,20 @@ describe("session list includes tty and tmux metadata", () => { output: "text", } satisfies SessionCommandInput); - expect(output).not.toContain("tty:"); - expect(output).not.toContain("tmux pane:"); + expect(output).not.toContain("terminal:"); + expect(output).not.toContain("location["); }); - test("get output includes tty and tmux pane when present", async () => { + test("get output includes generic terminal location lines when present", async () => { const session = { ...createListedSession("session-1"), - tty: "/dev/ttys005", - tmuxPane: "%0", + terminal: { + program: "ghostty", + locations: [ + { source: "tty", tty: "/dev/ttys005" }, + { source: "tmux", paneId: "%0" }, + ], + }, }; setSessionCommandTestHooks({ @@ -360,15 +373,21 @@ describe("session list includes tty and tmux metadata", () => { output: "text", } satisfies SessionCommandInput); - expect(output).toContain("TTY: /dev/ttys005"); - expect(output).toContain("Tmux pane: %0"); + expect(output).toContain("Terminal: ghostty"); + expect(output).toContain("Location[tty]: /dev/ttys005"); + expect(output).toContain("Location[tmux]: pane %0"); }); - test("json output includes tty and tmux pane fields", async () => { + test("json output includes terminal metadata fields", async () => { const session = { ...createListedSession("session-1"), - tty: "/dev/ttys003", - tmuxPane: "%2", + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + ], + }, }; setSessionCommandTestHooks({ @@ -386,7 +405,14 @@ describe("session list includes tty and tmux metadata", () => { } satisfies SessionCommandInput); const parsed = JSON.parse(output); - expect(parsed.sessions[0].tty).toBe("/dev/ttys003"); - expect(parsed.sessions[0].tmuxPane).toBe("%2"); + expect(parsed.sessions[0].terminal).toEqual({ + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + ], + }); + expect(parsed.sessions[0]).not.toHaveProperty("tty"); + expect(parsed.sessions[0]).not.toHaveProperty("tmuxPane"); }); }); diff --git a/test/session-registration.test.ts b/test/session-registration.test.ts index 6fc070b..83f57c4 100644 --- a/test/session-registration.test.ts +++ b/test/session-registration.test.ts @@ -35,23 +35,34 @@ function createMockSocket() { return { send: () => {} }; } -describe("session registration tty metadata", () => { - test("daemon state passes tty and tmuxPane through to listed sessions", () => { +describe("session registration terminal metadata", () => { + test("daemon state passes generic terminal metadata through to listed sessions", () => { const state = new HunkDaemonState(); const registration = createRegistration({ - tty: "/dev/ttys003", - tmuxPane: "%2", + terminal: { + program: "iTerm.app", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%2" }, + { + source: "iterm2", + windowId: "1", + tabId: "2", + paneId: "3", + sessionId: "w1t2p3:ABC", + }, + ], + }, }); state.registerSession(createMockSocket(), registration, createSnapshot()); const sessions = state.listSessions(); expect(sessions).toHaveLength(1); - expect(sessions[0]!.tty).toBe("/dev/ttys003"); - expect(sessions[0]!.tmuxPane).toBe("%2"); + expect(sessions[0]!.terminal).toEqual(registration.terminal); }); - test("daemon state omits tty and tmuxPane when not set", () => { + test("daemon state omits terminal metadata when nothing is known", () => { const state = new HunkDaemonState(); const registration = createRegistration(); @@ -59,7 +70,6 @@ describe("session registration tty metadata", () => { const sessions = state.listSessions(); expect(sessions).toHaveLength(1); - expect(sessions[0]!.tty).toBeUndefined(); - expect(sessions[0]!.tmuxPane).toBeUndefined(); + expect(sessions[0]!.terminal).toBeUndefined(); }); }); diff --git a/test/session-terminal-metadata.test.ts b/test/session-terminal-metadata.test.ts new file mode 100644 index 0000000..cb72965 --- /dev/null +++ b/test/session-terminal-metadata.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { resolveSessionTerminalMetadata } from "../src/mcp/sessionTerminalMetadata"; + +describe("session terminal metadata", () => { + test("captures tty, tmux, and iTerm2 identifiers in one generic structure", () => { + const terminal = resolveSessionTerminalMetadata({ + env: { + TERM_PROGRAM: "tmux", + LC_TERMINAL: "iTerm2", + ITERM_SESSION_ID: "w1t2p3:ABCDEF", + TMUX_PANE: "%7", + }, + tty: "/dev/ttys003", + }); + + expect(terminal).toEqual({ + program: "iTerm2", + locations: [ + { source: "tty", tty: "/dev/ttys003" }, + { source: "tmux", paneId: "%7" }, + { + source: "iterm2", + windowId: "1", + tabId: "2", + paneId: "3", + sessionId: "w1t2p3:ABCDEF", + }, + ], + }); + }); + + test("keeps terminal program metadata even when no window or pane ids are available", () => { + const terminal = resolveSessionTerminalMetadata({ + env: { + TERM_PROGRAM: "ghostty", + }, + tty: "/dev/pts/4", + }); + + expect(terminal).toEqual({ + program: "ghostty", + locations: [{ source: "tty", tty: "/dev/pts/4" }], + }); + }); + + test("returns undefined when no terminal metadata is available", () => { + expect(resolveSessionTerminalMetadata({ env: {}, tty: undefined })).toBeUndefined(); + }); +});