Skip to content

Commit 81d9da5

Browse files
committed
feat(terminal): add clickable file path links
File paths in terminal output are now highlighted and clickable. Supports absolute, relative, bare, and @Scoped paths with :line:col suffixes. Trailing punctuation is stripped. An onFileLink callback prop allows parent components to handle specific file types.
1 parent bc8a127 commit 81d9da5

4 files changed

Lines changed: 64 additions & 0 deletions

File tree

electron/ipc/channels.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export enum IPC {
9898
// System
9999
GetSystemFonts = 'get_system_fonts',
100100

101+
// File links
102+
OpenPath = 'open_path',
103+
101104
// Notifications
102105
ShowNotification = 'show_notification',
103106
NotificationClicked = 'notification_clicked',

electron/ipc/register.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ export function registerAllHandlers(win: BrowserWindow): void {
452452
cancelAskAboutCode(args.requestId);
453453
});
454454

455+
// --- File links ---
456+
ipcMain.handle(IPC.OpenPath, (_e, args) => {
457+
validatePath(args.filePath, 'filePath');
458+
return shell.openPath(args.filePath);
459+
});
460+
455461
// --- System ---
456462
ipcMain.handle(IPC.GetSystemFonts, () => getSystemMonospaceFonts());
457463

electron/preload.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ const ALLOWED_CHANNELS = new Set([
9090
'cancel_ask_about_code',
9191
// System
9292
'get_system_fonts',
93+
// File links
94+
'open_path',
9395
// Notifications
9496
'show_notification',
9597
'notification_clicked',

src/components/TerminalView.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface TerminalViewProps {
5454
}) => void;
5555
onData?: (data: Uint8Array) => void;
5656
onPromptDetected?: (text: string) => void;
57+
onFileLink?: (filePath: string) => void;
5758
onReady?: (focusFn: () => void) => void;
5859
onBufferReady?: (getBuffer: () => string) => void;
5960
fontSize?: number;
@@ -103,6 +104,58 @@ export function TerminalView(props: TerminalViewProps) {
103104
);
104105

105106
term.open(containerRef);
107+
108+
// File path link provider — makes file paths clickable in terminal output
109+
// Must be registered after term.open() so the DOM is available.
110+
term.registerLinkProvider({
111+
provideLinks(y, callback) {
112+
if (!term) {
113+
callback(undefined);
114+
return;
115+
}
116+
const line = term.buffer.active.getLine(y - 1)?.translateToString(true) ?? '';
117+
// Match file paths: absolute, ./ or ../ relative, and bare relative with /
118+
// Supports @scoped packages, line:col suffixes like foo.ts:42:10
119+
const regex =
120+
/(?:~?\/[\w@./-]+|\.{1,2}\/[\w@./-]+|[\w@][\w@./-]*\/[\w@./-]+)(?::\d+(?::\d+)?)?/g;
121+
const links: { startIndex: number; length: number; text: string }[] = [];
122+
let match: RegExpExecArray | null;
123+
while ((match = regex.exec(line)) !== null) {
124+
// Strip trailing punctuation that's not part of the path
125+
const text = match[0].replace(/[.,;:!?)]+$/, '');
126+
if (!text) continue;
127+
// Must contain a dot somewhere (file extension) to avoid matching plain directories
128+
if (!text.includes('.')) continue;
129+
links.push({
130+
startIndex: match.index,
131+
length: text.length,
132+
text,
133+
});
134+
}
135+
callback(
136+
links.map((link) => ({
137+
range: {
138+
start: { x: link.startIndex + 1, y },
139+
end: { x: link.startIndex + link.length + 1, y },
140+
},
141+
text: link.text,
142+
activate(event: MouseEvent, _text: string) {
143+
// Strip line:col suffix for opening
144+
const filePath = link.text.replace(/:\d+(:\d+)?$/, '');
145+
// Resolve relative paths against the task's working directory
146+
const resolved = filePath.startsWith('/') ? filePath : `${props.cwd}/${filePath}`;
147+
// Cmd+click always opens externally; otherwise use callback if provided
148+
if (!event.metaKey && props.onFileLink) {
149+
props.onFileLink(resolved);
150+
} else {
151+
invoke(IPC.OpenPath, { filePath: resolved }).catch(console.error);
152+
}
153+
},
154+
})),
155+
);
156+
},
157+
});
158+
106159
props.onReady?.(() => term?.focus());
107160
props.onBufferReady?.(() => {
108161
if (!term) return '';

0 commit comments

Comments
 (0)