Skip to content

Commit 475a9a3

Browse files
committed
refactor: generalize session terminal metadata
1 parent 8536172 commit 475a9a3

File tree

8 files changed

+328
-44
lines changed

8 files changed

+328
-44
lines changed

src/mcp/daemonState.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,7 @@ export class HunkDaemonState {
118118
title: entry.registration.title,
119119
sourceLabel: entry.registration.sourceLabel,
120120
launchedAt: entry.registration.launchedAt,
121-
tty: entry.registration.tty,
122-
tmuxPane: entry.registration.tmuxPane,
121+
terminal: entry.registration.terminal,
123122
fileCount: entry.registration.files.length,
124123
files: entry.registration.files,
125124
snapshot: entry.snapshot,

src/mcp/sessionRegistration.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
22
import { spawnSync } from "node:child_process";
33
import type { AppBootstrap } from "../core/types";
44
import { hunkLineRange } from "../core/liveComments";
5+
import { resolveSessionTerminalMetadata } from "./sessionTerminalMetadata";
56
import type { HunkSessionRegistration, HunkSessionSnapshot, SessionFileSummary } from "./types";
67

78
/** Resolve the TTY device path for the current process, if available. */
@@ -40,6 +41,8 @@ function buildSessionFiles(bootstrap: AppBootstrap): SessionFileSummary[] {
4041

4142
/** Build the daemon-facing metadata for one live Hunk TUI session. */
4243
export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionRegistration {
44+
const terminal = resolveSessionTerminalMetadata({ tty: ttyname() });
45+
4346
return {
4447
sessionId: randomUUID(),
4548
pid: process.pid,
@@ -49,8 +52,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR
4952
title: bootstrap.changeset.title,
5053
sourceLabel: bootstrap.changeset.sourceLabel,
5154
launchedAt: new Date().toISOString(),
52-
tty: ttyname(),
53-
tmuxPane: process.env.TMUX_PANE || undefined,
55+
terminal,
5456
files: buildSessionFiles(bootstrap),
5557
};
5658
}

src/mcp/sessionTerminalMetadata.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import type { SessionTerminalLocation, SessionTerminalMetadata } from "./types";
2+
3+
function trimmed(value: string | undefined) {
4+
const normalized = value?.trim();
5+
return normalized && normalized.length > 0 ? normalized : undefined;
6+
}
7+
8+
function sameLocation(left: SessionTerminalLocation, right: SessionTerminalLocation) {
9+
return (
10+
left.source === right.source &&
11+
left.tty === right.tty &&
12+
left.windowId === right.windowId &&
13+
left.tabId === right.tabId &&
14+
left.paneId === right.paneId &&
15+
left.terminalId === right.terminalId &&
16+
left.sessionId === right.sessionId
17+
);
18+
}
19+
20+
function pushLocation(locations: SessionTerminalLocation[], location: SessionTerminalLocation) {
21+
if (!locations.some((existing) => sameLocation(existing, location))) {
22+
locations.push(location);
23+
}
24+
}
25+
26+
function inferLocationSource(program: string | undefined) {
27+
const normalized = program?.trim().toLowerCase();
28+
if (!normalized) {
29+
return "terminal";
30+
}
31+
32+
if (normalized === "iterm.app" || normalized === "iterm2") {
33+
return "iterm2";
34+
}
35+
36+
if (normalized === "ghostty") {
37+
return "ghostty";
38+
}
39+
40+
if (normalized === "apple_terminal" || normalized === "apple terminal") {
41+
return "terminal.app";
42+
}
43+
44+
return "terminal";
45+
}
46+
47+
function parseHierarchicalIds(sessionId: string) {
48+
const prefix = sessionId.split(":", 1)[0]?.trim();
49+
if (!prefix) {
50+
return {};
51+
}
52+
53+
const match = /^w(?<window>\d+)t(?<tab>\d+)(?:p(?<pane>\d+))?$/i.exec(prefix);
54+
if (!match?.groups) {
55+
return {};
56+
}
57+
58+
return {
59+
windowId: match.groups.window,
60+
tabId: match.groups.tab,
61+
paneId: match.groups.pane,
62+
} satisfies Pick<SessionTerminalLocation, "windowId" | "tabId" | "paneId">;
63+
}
64+
65+
/**
66+
* Capture terminal- and multiplexer-facing location metadata for one Hunk TUI session.
67+
*
68+
* The structure is intentionally generic so we can layer tmux, iTerm2, Ghostty,
69+
* and future terminal integrations without adding a new top-level field for each one.
70+
*/
71+
export function resolveSessionTerminalMetadata({
72+
env = process.env,
73+
tty,
74+
}: {
75+
env?: NodeJS.ProcessEnv;
76+
tty?: string;
77+
} = {}): SessionTerminalMetadata | undefined {
78+
const termProgram = trimmed(env.TERM_PROGRAM);
79+
const lcTerminal = trimmed(env.LC_TERMINAL);
80+
const program =
81+
termProgram?.toLowerCase() === "tmux" && lcTerminal ? lcTerminal : (termProgram ?? lcTerminal);
82+
const locations: SessionTerminalLocation[] = [];
83+
84+
const ttyPath = trimmed(tty);
85+
if (ttyPath) {
86+
pushLocation(locations, { source: "tty", tty: ttyPath });
87+
}
88+
89+
const tmuxPane = trimmed(env.TMUX_PANE);
90+
if (tmuxPane) {
91+
pushLocation(locations, { source: "tmux", paneId: tmuxPane });
92+
}
93+
94+
const iTermSessionId = trimmed(env.ITERM_SESSION_ID);
95+
if (iTermSessionId) {
96+
pushLocation(locations, {
97+
source: "iterm2",
98+
sessionId: iTermSessionId,
99+
...parseHierarchicalIds(iTermSessionId),
100+
});
101+
}
102+
103+
const terminalSessionId = trimmed(env.TERM_SESSION_ID);
104+
if (terminalSessionId && terminalSessionId !== iTermSessionId) {
105+
pushLocation(locations, {
106+
source: inferLocationSource(program),
107+
sessionId: terminalSessionId,
108+
...parseHierarchicalIds(terminalSessionId),
109+
});
110+
}
111+
112+
if (!program && locations.length === 0) {
113+
return undefined;
114+
}
115+
116+
return {
117+
program,
118+
locations,
119+
};
120+
}

src/mcp/types.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ export interface SelectedHunkSummary {
2222
newRange?: [number, number];
2323
}
2424

25+
export interface SessionTerminalLocation {
26+
source: string;
27+
tty?: string;
28+
windowId?: string;
29+
tabId?: string;
30+
paneId?: string;
31+
terminalId?: string;
32+
sessionId?: string;
33+
}
34+
35+
export interface SessionTerminalMetadata {
36+
program?: string;
37+
locations: SessionTerminalLocation[];
38+
}
39+
2540
export interface HunkSessionRegistration {
2641
sessionId: string;
2742
pid: number;
@@ -31,8 +46,7 @@ export interface HunkSessionRegistration {
3146
title: string;
3247
sourceLabel: string;
3348
launchedAt: string;
34-
tty?: string;
35-
tmuxPane?: string;
49+
terminal?: SessionTerminalMetadata;
3650
files: SessionFileSummary[];
3751
}
3852

@@ -239,8 +253,7 @@ export interface ListedSession {
239253
title: string;
240254
sourceLabel: string;
241255
launchedAt: string;
242-
tty?: string;
243-
tmuxPane?: string;
256+
terminal?: SessionTerminalMetadata;
244257
fileCount: number;
245258
files: SessionFileSummary[];
246259
snapshot: HunkSessionSnapshot;

src/session/commands.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type {
2626
RemovedCommentResult,
2727
SelectedSessionContext,
2828
SessionLiveCommentSummary,
29+
SessionTerminalLocation,
30+
SessionTerminalMetadata,
2931
} from "../mcp/types";
3032
import {
3133
HUNK_SESSION_API_PATH,
@@ -359,36 +361,99 @@ function formatSelectedSummary(session: ListedSession) {
359361
return filePath === "(none)" ? filePath : `${filePath} hunk ${hunkNumber}`;
360362
}
361363

364+
function formatTerminalLocation(location: SessionTerminalLocation) {
365+
const parts: string[] = [];
366+
367+
if (location.tty) {
368+
parts.push(location.tty);
369+
}
370+
371+
if (location.windowId) {
372+
parts.push(`window ${location.windowId}`);
373+
}
374+
375+
if (location.tabId) {
376+
parts.push(`tab ${location.tabId}`);
377+
}
378+
379+
if (location.paneId) {
380+
parts.push(`pane ${location.paneId}`);
381+
}
382+
383+
if (location.terminalId) {
384+
parts.push(`terminal ${location.terminalId}`);
385+
}
386+
387+
if (location.sessionId) {
388+
parts.push(`session ${location.sessionId}`);
389+
}
390+
391+
return parts.length > 0 ? parts.join(", ") : "present";
392+
}
393+
394+
function resolveSessionTerminal(session: ListedSession) {
395+
return session.terminal;
396+
}
397+
398+
function formatTerminalLines(
399+
terminal: SessionTerminalMetadata | undefined,
400+
{
401+
headerLabel,
402+
locationLabel,
403+
}: {
404+
headerLabel: string;
405+
locationLabel: string;
406+
},
407+
) {
408+
if (!terminal) {
409+
return [];
410+
}
411+
412+
return [
413+
...(terminal.program ? [`${headerLabel}: ${terminal.program}`] : []),
414+
...terminal.locations.map(
415+
(location) => `${locationLabel}[${location.source}]: ${formatTerminalLocation(location)}`,
416+
),
417+
];
418+
}
419+
362420
function formatListOutput(sessions: ListedSession[]) {
363421
if (sessions.length === 0) {
364422
return "No active Hunk sessions.\n";
365423
}
366424

367425
return `${sessions
368-
.map((session) =>
369-
[
426+
.map((session) => {
427+
const terminal = resolveSessionTerminal(session);
428+
return [
370429
`${session.sessionId} ${session.title}`,
371430
` repo: ${session.repoRoot ?? session.cwd}`,
372-
...(session.tty ? [` tty: ${session.tty}`] : []),
373-
...(session.tmuxPane ? [` tmux pane: ${session.tmuxPane}`] : []),
431+
...formatTerminalLines(terminal, {
432+
headerLabel: " terminal",
433+
locationLabel: " location",
434+
}),
374435
` focus: ${formatSelectedSummary(session)}`,
375436
` files: ${session.fileCount}`,
376437
` comments: ${session.snapshot.liveCommentCount}`,
377-
].join("\n"),
378-
)
438+
].join("\n");
439+
})
379440
.join("\n\n")}\n`;
380441
}
381442

382443
function formatSessionOutput(session: ListedSession) {
444+
const terminal = resolveSessionTerminal(session);
445+
383446
return [
384447
`Session: ${session.sessionId}`,
385448
`Title: ${session.title}`,
386449
`Source: ${session.sourceLabel}`,
387450
`Repo: ${session.repoRoot ?? session.cwd}`,
388451
`Input: ${session.inputKind}`,
389452
`Launched: ${session.launchedAt}`,
390-
...(session.tty ? [`TTY: ${session.tty}`] : []),
391-
...(session.tmuxPane ? [`Tmux pane: ${session.tmuxPane}`] : []),
453+
...formatTerminalLines(terminal, {
454+
headerLabel: "Terminal",
455+
locationLabel: "Location",
456+
}),
392457
`Selected: ${formatSelectedSummary(session)}`,
393458
`Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`,
394459
`Live comments: ${session.snapshot.liveCommentCount}`,

0 commit comments

Comments
 (0)