Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
71b4bad
chore(porch): 1412 init pir
amrmelsayed Aug 11, 2026
f93d351
[PIR #1412] Plan draft
amrmelsayed Aug 11, 2026
660fda7
chore(porch): 1412 plan-approval gate-requested
amrmelsayed Aug 11, 2026
1ea365e
[PIR #1412] Plan: rebase on main, correct extension.ts line refs
amrmelsayed Aug 12, 2026
a1c3828
chore(porch): 1412 plan-approval gate-approved
amrmelsayed Aug 12, 2026
29a4443
chore(porch): 1412 implement phase-transition
amrmelsayed Aug 12, 2026
9017f4d
[PIR #1412] Clickable #N / PR #N terminal references
amrmelsayed Aug 12, 2026
a561c00
[PIR #1412] Tests: terminal ref detection and resolution routing
amrmelsayed Aug 12, 2026
d1250a3
chore(porch): 1412 dev-approval gate-requested
amrmelsayed Aug 12, 2026
cb12cab
[PIR #1412] Add click feedback + drop redundant fetch on terminal ref…
amrmelsayed Aug 12, 2026
ee6dea3
[PIR #1412] Reword issueTarget setting: drop internal 'bare #N' jargon
amrmelsayed Aug 12, 2026
17c198a
chore(porch): 1412 dev-approval gate-approved
amrmelsayed Aug 12, 2026
ad14622
chore(porch): 1412 review phase-transition
amrmelsayed Aug 12, 2026
326ed83
[PIR #1412] Review + retrospective
amrmelsayed Aug 12, 2026
9a6f90c
chore(porch): 1412 record PR #1418
amrmelsayed Aug 12, 2026
7f236c0
chore(porch): 1412 review build-complete
amrmelsayed Aug 12, 2026
3b511bb
[PIR #1412] Docs: amend plan for adopted latency approach; fix stale …
amrmelsayed Aug 12, 2026
4c92067
chore(porch): 1412 pr gate-requested
amrmelsayed Aug 12, 2026
572b8e7
[PIR #1412] Update builder thread: consultation all-clear, pr gate pe…
amrmelsayed Aug 12, 2026
b55ead7
chore(porch): 1412 pr gate-approved
amrmelsayed Aug 12, 2026
a330848
chore(porch): 1412 protocol complete
amrmelsayed Aug 12, 2026
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
10 changes: 10 additions & 0 deletions apps/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,16 @@
"default": "editor",
"description": "Where to open Codev terminals (editor area or bottom panel)"
},
"codev.terminalLinks.issueTarget": {
"type": "string",
"enum": ["editor", "browser"],
"enumDescriptions": [
"Open issue references (#N) in the in-editor issue viewer.",
"Open issue references (#N) in your browser."
],
"default": "editor",
"markdownDescription": "Where Cmd+clicking an issue reference (`#N`) in a terminal opens it. Pull-request references (`PR #N`) always open in the browser (there is no in-editor PR preview)."
},
"codev.diffCodelensMode": {
"type": "string",
"enum": ["comment", "forward"],
Expand Down
170 changes: 170 additions & 0 deletions apps/vscode/src/__tests__/terminal-ref-link-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* PIR #1412: clickable `#N` / `PR #N` terminal references.
*
* Two halves: `IssueRefTerminalLinkProvider.provideTerminalLinks` (detection —
* spans, flags, multiple refs per line) and `openTerminalRef` (resolution —
* issue-first with the url-based PR discriminator, and the `issueTarget`
* setting). Both import `vscode` at module load, so we stub it (the established
* pattern from open-issue-by-id.test.ts). We also stub `open-pr-by-id.js` — the
* one reuse helper still called directly (for `PR #N` and the PR fallthrough);
* the browser-issue and editor paths are asserted via the stubbed
* `vscode.env.openExternal` / `vscode.commands.executeCommand`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';

const showErrorMessage = vi.fn();
const showWarningMessage = vi.fn();
const getConfiguration = vi.fn();
const executeCommand = vi.fn();
const openExternal = vi.fn();
// withProgress just runs the task; the status-bar spinner is UI-only.
const withProgress = vi.fn((_opts: unknown, task: () => unknown) => task());

vi.mock('vscode', () => ({
window: { showErrorMessage, showWarningMessage, withProgress },
workspace: { getConfiguration },
commands: { executeCommand },
env: { openExternal },
Uri: { parse: (s: string) => ({ __parsed: s }) },
ProgressLocation: { Window: 10 },
}));

const openPRInBrowser = vi.fn();

vi.mock('../commands/open-pr-by-id.js', () => ({ openPRInBrowser }));

// terminal-link-provider.ts pulls in terminal-adapter.js (via RECONNECT_LINK_TEXT)
// and terminal-manager types; stub the adapter so the module loads in isolation.
vi.mock('../terminal-adapter.js', () => ({ RECONNECT_LINK_TEXT: '[reconnect]' }));

const { IssueRefTerminalLinkProvider } = await import('../terminal-link-provider.js');
const { openTerminalRef } = await import('../commands/open-terminal-ref.js');

function makeProvider() {
return new (IssueRefTerminalLinkProvider as unknown as new (cm: unknown) => {
provideTerminalLinks(ctx: { line: string; terminal: unknown }): Array<{
startIndex: number; length: number; number: string; isPR: boolean; tooltip: string;
}>;
})({});
}

describe('PIR #1412 — IssueRefTerminalLinkProvider detection', () => {
it('produces no link on an ordinary line', () => {
expect(makeProvider().provideTerminalLinks({ line: 'nothing to see here', terminal: {} }))
.toHaveLength(0);
});

it('claims a bare #N and spans exactly the token', () => {
const line = 'see #915 for context';
const links = makeProvider().provideTerminalLinks({ line, terminal: {} });
expect(links).toHaveLength(1);
expect(line.substr(links[0].startIndex, links[0].length)).toBe('#915');
expect(links[0]).toMatchObject({ number: '915', isPR: false });
});

it('claims the whole `PR #N` span (not the inner #N)', () => {
const line = 'merged PR #1402 today';
const links = makeProvider().provideTerminalLinks({ line, terminal: {} });
expect(links).toHaveLength(1);
expect(line.substr(links[0].startIndex, links[0].length)).toBe('PR #1402');
expect(links[0]).toMatchObject({ number: '1402', isPR: true });
});

it('detects multiple refs per line with correct flags and spans', () => {
const line = 'see #12 and PR #34 before #56';
const links = makeProvider().provideTerminalLinks({ line, terminal: {} });
expect(links).toHaveLength(3);
expect(links.map((l) => ({ number: l.number, isPR: l.isPR }))).toEqual([
{ number: '12', isPR: false },
{ number: '34', isPR: true },
{ number: '56', isPR: false },
]);
for (const l of links) {
expect(line.substr(l.startIndex, l.length)).toMatch(/^(PR )?#\d+$/);
}
});

it('ignores non-numeric (#fff) and spaced (# 1) forms', () => {
expect(makeProvider().provideTerminalLinks({ line: 'color #fff and # 1 heading', terminal: {} }))
.toHaveLength(0);
});

it('is case-insensitive on the PR prefix', () => {
const links = makeProvider().provideTerminalLinks({ line: 'pr #7', terminal: {} });
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({ number: '7', isPR: true });
});
});

describe('PIR #1412 — openTerminalRef resolution', () => {
const getIssue = vi.fn();
const connected = {
getClient: () => ({ getIssue }),
getWorkspacePath: () => '/work',
getState: () => 'connected',
};

function withSetting(target: string) {
getConfiguration.mockReturnValue({ get: () => target });
}

beforeEach(() => {
vi.clearAllMocks();
withSetting('editor');
});

it('routes an explicit PR ref straight to openPRInBrowser without a discriminator fetch', async () => {
await openTerminalRef(connected as never, { number: '1402', isPR: true });
expect(openPRInBrowser).toHaveBeenCalledWith(connected, '1402');
expect(getIssue).not.toHaveBeenCalled();
});

it('opens a genuine issue in the editor viewer by default', async () => {
getIssue.mockResolvedValue({ url: 'https://github.com/o/r/issues/915' });
await openTerminalRef(connected as never, { number: '915', isPR: false });
expect(executeCommand).toHaveBeenCalledWith('codev.viewBacklogIssue', '915');
expect(openExternal).not.toHaveBeenCalled();
expect(openPRInBrowser).not.toHaveBeenCalled();
});

it('opens a genuine issue in the browser via the already-resolved url when issueTarget = browser', async () => {
withSetting('browser');
getIssue.mockResolvedValue({ url: 'https://github.com/o/r/issues/915' });
await openTerminalRef(connected as never, { number: '915', isPR: false });
expect(openExternal).toHaveBeenCalledWith({ __parsed: 'https://github.com/o/r/issues/915' });
expect(executeCommand).not.toHaveBeenCalled();
});

it('opens a browser-target issue in the editor preview when the forge supplies no url', async () => {
withSetting('browser');
getIssue.mockResolvedValue({ url: undefined });
await openTerminalRef(connected as never, { number: '915', isPR: false });
expect(executeCommand).toHaveBeenCalledWith('codev.viewBacklogIssue', '915');
expect(openExternal).not.toHaveBeenCalled();
});

it('falls through to the PR page via the resolved /pull/ url when a bare #N is actually a PR', async () => {
getIssue.mockResolvedValue({ url: 'https://github.com/o/r/pull/1405' });
await openTerminalRef(connected as never, { number: '1405', isPR: false });
expect(openExternal).toHaveBeenCalledWith({ __parsed: 'https://github.com/o/r/pull/1405' });
expect(executeCommand).not.toHaveBeenCalled();
expect(openPRInBrowser).not.toHaveBeenCalled();
});

it('warns and opens nothing when the number is unresolvable', async () => {
getIssue.mockResolvedValue(null);
await openTerminalRef(connected as never, { number: '999999', isPR: false });
expect(showWarningMessage).toHaveBeenCalledWith(expect.stringContaining('#999999'));
expect(executeCommand).not.toHaveBeenCalled();
expect(openExternal).not.toHaveBeenCalled();
expect(openPRInBrowser).not.toHaveBeenCalled();
});

it('errors on a bare #N click when not connected', async () => {
const disconnected = { getClient: () => null, getWorkspacePath: () => null, getState: () => 'disconnected' };
await openTerminalRef(disconnected as never, { number: '915', isPR: false });
expect(showErrorMessage).toHaveBeenCalledWith('Codev: Not connected to Tower');
expect(getIssue).not.toHaveBeenCalled();
});
});
97 changes: 97 additions & 0 deletions apps/vscode/src/commands/open-terminal-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Codev: resolve a `#N` / `PR #N` reference clicked in a terminal (#1412) and
* open it in the right surface. This is the resolution half of the terminal
* link feature; the detection half is `IssueRefTerminalLinkProvider` in
* `terminal-link-provider.ts`.
*
* Latency + feedback (#1412 dev-approval): a terminal-link click gets no
* built-in VSCode feedback, so the whole resolution runs inside a status-bar
* `withProgress` — the user sees "Opening #N…" the instant they click, even
* while the forge fetch is in flight. And a bare `#N` fetches the forge exactly
* once: that single `getIssue` both discriminates issue-vs-PR AND yields the
* canonical `url`, which we then open directly rather than making a reuse
* helper re-fetch it. Only the in-editor issue preview fetches a second time,
* because that fetch renders the content the user is about to read.
*
* The discriminator is the resolved `url`, not fetch-failure. GitHub's
* `gh issue view` (the `issue-view` forge concept) resolves a PR *number*
* successfully — issues and PRs share one number space — returning a
* `.../pull/N` url. A genuine issue returns `.../issues/N`. So a bare `#N`
* whose `getIssue` url is a `/pull/` url is actually a PR and opens the PR page,
* exactly as the decided design intends.
*/

import * as vscode from 'vscode';
import type { ConnectionManager } from '../connection-manager.js';
import { openPRInBrowser } from './open-pr-by-id.js';

/** A `#N` or `PR #N` reference detected in a terminal line. */
export interface TerminalRef {
/** Bare numeric id, e.g. `"1402"`. */
number: string;
/** True when the reference carried an explicit `PR ` prefix. */
isPR: boolean;
}

/**
* Open the surface for a clicked terminal reference, with a status-bar spinner
* for the duration.
*
* - `PR #N` → the PR's forge page in the browser (no in-editor PR preview exists).
* - bare `#N` → the in-editor issue viewer by default, or the browser when
* `codev.terminalLinks.issueTarget` is `browser`; but if the number resolves
* to a PR, the browser PR page regardless of the setting.
* - unresolvable number → warning toast (matches `openIssueInBrowser`'s grammar).
*/
export function openTerminalRef(
connectionManager: ConnectionManager,
ref: TerminalRef,
): Thenable<void> {
return vscode.window.withProgress(
{ location: vscode.ProgressLocation.Window, title: `Codev: Opening #${ref.number}…` },
() => resolveRef(connectionManager, ref),
);
}

async function resolveRef(connectionManager: ConnectionManager, ref: TerminalRef): Promise<void> {
if (ref.isPR) {
await openPRInBrowser(connectionManager, ref.number);
return;
}

// Bare `#N`: one fetch does double duty — discriminate issue vs PR by the
// resolved url, and hand us that url to open. This guard mirrors the reuse
// helpers so an unconnected click fails the same way.
const client = connectionManager.getClient();
const workspacePath = connectionManager.getWorkspacePath();
if (!client || !workspacePath || connectionManager.getState() !== 'connected') {
vscode.window.showErrorMessage('Codev: Not connected to Tower');
return;
}

const issue = await client.getIssue(ref.number, workspacePath);
if (!issue) {
vscode.window.showWarningMessage(
`Codev: Could not open #${ref.number} (not found, or forge unavailable).`,
);
return;
}

// The number is actually a PR — open the `/pull/` url we already hold.
if (issue.url && /\/pull\/\d/.test(issue.url)) {
await vscode.env.openExternal(vscode.Uri.parse(issue.url));
return;
}

// Genuine issue. Browser target opens the resolved url directly (no re-fetch);
// editor target — and any forge that supplied no url — renders the in-editor
// preview, which fetches once to build its content.
const target = vscode.workspace
.getConfiguration('codev')
.get<string>('terminalLinks.issueTarget', 'editor');
if (target === 'browser' && issue.url) {
await vscode.env.openExternal(vscode.Uri.parse(issue.url));
return;
}
await vscode.commands.executeCommand('codev.viewBacklogIssue', ref.number);
}
9 changes: 8 additions & 1 deletion apps/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import { submitReview, discardReviewComments } from './review-queue/submit.js';
import { activateSubmitReviewStatusBar } from './review-queue/status-bar.js';
import { MarkdownPreviewProvider } from './markdown-preview/preview-provider.js';
import { BuilderSpawnHandler } from './builder-spawn-handler.js';
import { BuilderTerminalLinkProvider, ReconnectTerminalLinkProvider } from './terminal-link-provider.js';
import { BuilderTerminalLinkProvider, ReconnectTerminalLinkProvider, IssueRefTerminalLinkProvider } from './terminal-link-provider.js';
import { computeBuildersToClose, roleIdsFromBuilders } from './prune-builder-terminals.js';
import { buildBuilderPickRows } from './builder-pick-rows.js';
import { readBuildersFileViewAsTree } from './builders-config.js';
Expand Down Expand Up @@ -1449,6 +1449,13 @@ export async function activate(context: vscode.ExtensionContext) {
),
);

// Make #N / PR #N references clickable in any terminal output (#1412)
context.subscriptions.push(
vscode.window.registerTerminalLinkProvider(
new IssueRefTerminalLinkProvider(connectionManager),
),
);

// IDE empty-window surface (#1144 Part 3). Runtime code by hard
// constraint: extension-contributed configurationDefaults register
// asynchronously and race whatever renders at startup, so nothing
Expand Down
44 changes: 44 additions & 0 deletions apps/vscode/src/terminal-link-provider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as vscode from 'vscode';
import type { TerminalManager } from './terminal-manager.js';
import type { ConnectionManager } from './connection-manager.js';
import { RECONNECT_LINK_TEXT } from './terminal-adapter.js';
import { openTerminalRef } from './commands/open-terminal-ref.js';

// Matches Codev builder role names like `builder-spir-153`, `builder-bugfix-42`.
const BUILDER_REGEX = /\bbuilder-[a-z]+-[a-z0-9]+\b/g;
Expand Down Expand Up @@ -66,3 +68,45 @@ export class ReconnectTerminalLinkProvider implements vscode.TerminalLinkProvide
this.terminalManager.reconnectByTerminal(link.terminal);
}
}

interface IssueRefLink extends vscode.TerminalLink {
number: string;
isPR: boolean;
}

/**
* Makes `#N` and `PR #N` references in terminal output clickable (#1412).
* `PR #N` opens the PR's forge page in the browser; a bare `#N` opens the
* in-editor issue viewer (or the browser, per `codev.terminalLinks.issueTarget`),
* falling through to the PR browser-open when the number turns out to be a PR.
* Claiming the span also stops VSCode's useless fallback word-search over it.
*/
export class IssueRefTerminalLinkProvider implements vscode.TerminalLinkProvider<IssueRefLink> {
constructor(private connectionManager: ConnectionManager) {}

provideTerminalLinks(context: vscode.TerminalLinkContext): IssueRefLink[] {
// Built per call, not shared at module scope: the VSCode d.ts warns that
// provideTerminalLinks may be re-entered before a prior call resolves, and
// a shared /g regex's lastIndex would race across those overlapping calls.
// The optional greedy `PR ` prefix claims the whole `PR #N` span in one
// match, so the inner `#N` is not separately matched.
const re = /(?<pr>\bPR\s+)?#(?<num>\d+)/gi;
const links: IssueRefLink[] = [];
for (const m of context.line.matchAll(re)) {
const num = m.groups!.num;
const isPR = Boolean(m.groups?.pr);
links.push({
startIndex: m.index,
length: m[0].length,
tooltip: isPR ? `Open PR #${num} in browser` : `Open issue #${num}`,
number: num,
isPR,
});
}
return links;
}

handleTerminalLink(link: IssueRefLink): Thenable<void> {
return openTerminalRef(this.connectionManager, { number: link.number, isPR: link.isPR });
}
}
Loading
Loading