Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions packages/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,11 @@
"default": false,
"description": "Force Codev terminals to redraw when the VSCode window regains focus. Off by default — terminals already render correctly on open. Enable this only if you still see rendering glitches (stale or misplaced content) after switching back to the window."
},
"codev.streamdeck.followFocus": {
"type": "boolean",
"default": false,
"description": "Tell a Codev Stream Deck controller which workspace just gained focus (via a passive Stream Deck deep link), so the device follows the window you're looking at across workspaces. Off by default; enable only if you use the Codev Stream Deck plugin. Has no effect without it."
},
"codev.autoStartTower": {
"type": "boolean",
"default": true,
Expand Down
7 changes: 7 additions & 0 deletions packages/vscode/src/command-relay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ const VERB_COMMANDS: Record<string, string> = {
// Context verbs (operate on the focused editor; no arg).
'add-comment': 'codev.addReviewComment',
'forward-selection': 'codev.forwardSelectionToBuilder',
// Gate review (arg: builder id). Surfaces the approval modal in the focused
// window — a deliberate, human-confirmed action, never a silent approve.
'approve-gate': 'codev.approveGate',
// Diff-review navigation.
'diff-next-file': 'codev.diffNextFile',
'diff-prev-file': 'codev.diffPreviousFile',
'diff-first-file': 'codev.diffFirstFile',
'diff-next-hunk': 'workbench.action.compareEditor.nextChange',
'diff-prev-hunk': 'workbench.action.compareEditor.previousChange',
'diff-first-hunk': 'codev.diffFirstHunk',
// Viewport scroll of the focused editor (the Scroll dial). Args carry the
// built-in editorScroll options { to, by, value, revealCursor }.
'scroll': 'editorScroll',
// Workspace verbs (configurable Codev Action key / Dev Server key).
'focus-workspace': 'codev.focusWorkspaceWindow',
'open-architect-terminal': 'codev.openArchitectTerminal',
'open-builder-terminal': 'codev.openBuilderTerminal',
'send-message': 'codev.sendMessage',
Expand Down
55 changes: 52 additions & 3 deletions packages/vscode/src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as vscode from 'vscode';
import { ConnectionManager } from './connection-manager.js';
import { wireCommandProvider } from './command-relay.js';
import { announceActiveWorkspace, announceActiveBuilder } from './streamdeck-focus-sync.js';
import { TerminalManager } from './terminal-manager.js';
import { OverviewCache } from './views/overview-data.js';
import { spawnBuilder } from './commands/spawn.js';
Expand Down Expand Up @@ -191,8 +192,17 @@ export async function activate(context: vscode.ExtensionContext) {
vscode.commands.executeCommand(
'setContext', 'codev.terminalFocused',
terminalManager?.isCodevTerminalActive() ?? false);
// Follow the active builder on a Stream Deck controller when its terminal is
// focused (opt-in; passive deep link). Same subscription as the focus context.
const announceActiveBuilderFromTerminal = (): void => {
const id = terminalManager?.getActiveBuilderId();
if (id) { announceActiveBuilder(connectionManager, id); }
};
context.subscriptions.push(
vscode.window.onDidChangeActiveTerminal(syncTerminalFocusContext));
vscode.window.onDidChangeActiveTerminal(() => {
syncTerminalFocusContext();
announceActiveBuilderFromTerminal();
}));
syncTerminalFocusContext(); // seed initial state

// Opt-in: force a terminal repaint when the VSCode window regains focus
Expand All @@ -214,10 +224,19 @@ export async function activate(context: vscode.ExtensionContext) {
if (enabled) {
terminalManager?.repaintAllOnRefocus();
}
// Tell a Codev Stream Deck controller which workspace is now
// focused, so it follows this window (opt-in; passive deep link).
announceActiveWorkspace(connectionManager);
}
windowFocused = state.focused;
}));

// Announce once on activation too, so a Stream Deck controller syncs to
// this window on reload without waiting for a focus bounce.
if (vscode.window.state.focused) {
announceActiveWorkspace(connectionManager);
}

// Drive the `codev.hasDevCommand` context key so the builder-row Run/Stop
// Dev Server menu entries, the dev keybindings, and the workspace-dev palette
// entries only surface when a runnable `worktree.devCommand` is configured
Expand Down Expand Up @@ -379,6 +398,13 @@ export async function activate(context: vscode.ExtensionContext) {
// count; the rest stay on registerTreeDataProvider.
const buildersProvider = new BuildersProvider(overviewCache, builderDiffCache);
buildersView = vscode.window.createTreeView('codev.builders', { treeDataProvider: buildersProvider });
// Follow the active builder on a Stream Deck controller when its row is selected
// in the sidebar (opt-in; passive deep link). Builder tree items carry `builderId`
// (= OverviewBuilder.id); selecting a builder's root node or a file row re-targets it.
context.subscriptions.push(buildersView.onDidChangeSelection((e) => {
const sel = e.selection[0] as { builderId?: string } | undefined;
if (sel?.builderId) { announceActiveBuilder(connectionManager, sel.builderId); }
}));
pullRequestsView = vscode.window.createTreeView('codev.pullRequests', { treeDataProvider: new PullRequestsProvider(overviewCache) });
const backlogProvider = new BacklogProvider(overviewCache, context.workspaceState);
backlogView = vscode.window.createTreeView('codev.backlog', { treeDataProvider: backlogProvider });
Expand Down Expand Up @@ -537,9 +563,23 @@ export async function activate(context: vscode.ExtensionContext) {
// Benign if the row is no longer present (e.g. mid-cleanup).
}
};
// Tell a Stream Deck controller which builder is under review, so its selected
// builder follows the focused builder diff (opt-in; passive deep link). The diff
// session entry already carries the builder id (`OverviewBuilder.id`) — fire it
// directly. Same diff-only gate as the reveal above (registry entry present + not
// a standalone tab), so a plain source file or the diff's base side doesn't
// re-target the controller.
const announceActiveBuilderFromEditor = (): void => {
const fsPath = vscode.window.activeTextEditor?.document.uri.fsPath;
if (!fsPath) { return; }
const entry = getDiffInjectEntry(fsPath);
if (!entry) { return; }
if (isStandaloneTextTab(vscode.window.tabGroups.activeTabGroup?.activeTab?.input)) { return; }
announceActiveBuilder(connectionManager, entry.builderId);
};
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor(() => { revealActiveBuilderFile(); }),
onDidChangeDiffInjectRegistry(() => { revealActiveBuilderFile(); }),
vscode.window.onDidChangeActiveTextEditor(() => { revealActiveBuilderFile(); announceActiveBuilderFromEditor(); }),
onDidChangeDiffInjectRegistry(() => { revealActiveBuilderFile(); announceActiveBuilderFromEditor(); }),
);

// Builders file-view-as-tree: each builder's changed-files list renders
Expand Down Expand Up @@ -1080,6 +1120,15 @@ export async function activate(context: vscode.ExtensionContext) {
reg('codev.viewReviewFile', (arg: vscode.TreeItem | string | undefined) =>
viewReviewFile(connectionManager!, extractBuilderId(arg))),
reg('codev.refreshOverview', () => overviewCache.refresh()),
// Focus the editor window for a workspace path (driven by a controller, e.g.
// the Stream Deck dial). `vscode.openFolder` with forceNewWindow reuses and
// focuses an existing window for that folder rather than replacing this one;
// it opens a new window only if the folder isn't already open.
reg('codev.focusWorkspaceWindow', async (arg: unknown) => {
if (typeof arg !== 'string' || !arg) { return; }
await vscode.commands.executeCommand(
'vscode.openFolder', vscode.Uri.file(arg), { forceNewWindow: true });
}),
reg('codev.enableBuildersAutoCollapse', () =>
vscode.workspace.getConfiguration('codev').update('buildersAutoCollapse', true, vscode.ConfigurationTarget.Global)),
reg('codev.disableBuildersAutoCollapse', () =>
Expand Down
93 changes: 93 additions & 0 deletions packages/vscode/src/streamdeck-focus-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as vscode from 'vscode';
import { execFile } from 'node:child_process';
import type { ConnectionManager } from './connection-manager.js';

const STREAMDECK_PLUGIN_UUID = 'com.cluesmith.codev';

/**
* Set once a deep-link delivery fails (no handler registered for `streamdeck://`,
* or no opener) so we stop spawning a doomed process on every focus change. Reset
* on window reload (module re-init), which is the documented way to retry.
*/
let deliveryDisabled = false;

/**
* Tells the Codev Stream Deck plugin which workspace just gained focus, so a
* physical controller follows the window the user is looking at (otherwise its
* selected workspace drifts out of sync and the workspace-scope filter silently
* drops its commands).
*
* Opt-in via `codev.streamdeck.followFocus`. Uses a PASSIVE deep link
* (`streamdeck=hidden`, Stream Deck 7.0+) delivered with `open -g`, so the
* Stream Deck app is never brought to the foreground. A missing Stream Deck app
* or plugin is a harmless no-op. Call on the unfocused→focused rising edge.
*/
export function announceActiveWorkspace(connectionManager: ConnectionManager | null): void {
announce(connectionManager);
}

/**
* Tells the plugin which BUILDER is now active in VSCode (a focused diff, the
* builder terminal, or its sidebar row), so the controller's selected builder and
* its diff dials follow — closing the gap between what the dials show and the diff
* they act on.
*
* `builderId` is the id the extension uses everywhere: `OverviewBuilder.id` for the
* diff/sidebar signals (what `builderById` matches), or the `roleId` form for the
* terminal. The plugin matches it against both `id` and `roleId` (both on the same
* Tower overview), so any of the extension's id forms resolves.
*/
export function announceActiveBuilder(
connectionManager: ConnectionManager | null,
builderId: string,
): void {
announce(connectionManager, builderId);
}

function announce(connectionManager: ConnectionManager | null, builderId?: string): void {
if (deliveryDisabled || !connectionManager) return;
const enabled = vscode.workspace
.getConfiguration('codev')
.get<boolean>('streamdeck.followFocus', false);
if (!enabled) return;

const workspace = connectionManager.getWorkspacePath();
if (!workspace) return;

let url =
`streamdeck://plugins/message/${STREAMDECK_PLUGIN_UUID}/active` +
`?streamdeck=hidden&workspace=${encodeURIComponent(workspace)}`;
if (builderId) {
url += `&builder=${encodeURIComponent(builderId)}`;
}
openPassiveDeepLink(url);
}

/**
* Deliver a deep link without foregrounding any app (passive). Fire-and-forget:
* the empty callback swallows BOTH a failed launch (e.g. no `xdg-open`, or no
* handler registered for `streamdeck://` on a machine without the Stream Deck
* app) AND a non-zero exit. Passing a callback also prevents an unhandled
* ChildProcess `error` event from crashing the extension host.
*/
function openPassiveDeepLink(url: string): void {
// The opener exits non-zero when there's no handler for `streamdeck://` (or the
// opener itself is missing). On the first such failure, stop trying for this
// session and tell the user once — rather than silently spawning a doomed
// process on every focus change.
const onResult = (err: unknown): void => {
if (!err || deliveryDisabled) return;
deliveryDisabled = true;
void vscode.window.showWarningMessage(
'Codev: could not reach a Stream Deck controller (no handler for the deep link — is the Stream Deck app and Codev plugin installed?). ' +
'Pausing "follow focus" for this window; reload the window to retry, or turn off codev.streamdeck.followFocus.',
);
};
if (process.platform === 'darwin') {
execFile('open', ['-g', url], onResult); // -g = do not bring the handler app forward
} else if (process.platform === 'win32') {
execFile('cmd', ['/c', 'start', '', url], onResult);
} else {
execFile('xdg-open', [url], onResult);
}
}
18 changes: 18 additions & 0 deletions packages/vscode/src/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,24 @@ export class TerminalManager {
return this.getActiveManagedPty() !== null;
}

/**
* The builder id of the currently-focused VSCode terminal, or null when the
* active terminal isn't a Codev *builder* terminal. Recovered from the map key
* (`builder-<builderId>`), the same id the open path was given. Used to follow
* the active builder on a Stream Deck controller.
*/
getActiveBuilderId(): string | null {
const active = vscode.window.activeTerminal;
if (!active) { return null; }
const prefix = 'builder-';
for (const [mapKey, entry] of this.terminals) {
if (entry.terminal === active && entry.type === 'builder' && mapKey.startsWith(prefix)) {
return mapKey.slice(prefix.length);
}
}
return null;
}

// ── Internal ─────────────────────────────────────────────────

private async openTerminal(
Expand Down
Loading