From 7f1c6edecd7c6d77fc330ec3fc3ade025f640ff8 Mon Sep 17 00:00:00 2001 From: Amr Elsayed Date: Sat, 27 Jun 2026 12:12:00 +1000 Subject: [PATCH] feat(vscode): Stream Deck controller support (follow-focus + relay verbs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extension-side changes that let the Codev Stream Deck plugin work. Everything here is inert without a Stream Deck: the follow-focus code is gated behind codev.streamdeck.followFocus (default off), and the relay verbs only run when a controller sends them. No new dependencies; the plugin package lands separately. - Command relay allowlist (command-relay.ts): add 'focus-workspace' -> codev.focusWorkspaceWindow and 'scroll' -> editorScroll. - focus-workspace command (extension.ts): vscode.openFolder(uri,{forceNewWindow:true}) focuses the workspace's existing window (controller -> VSCode), fork-agnostic. - Follow-focus module (streamdeck-focus-sync.ts, new): a PASSIVE deep link (streamdeck=hidden, never foregrounds the app) announcing the focused workspace and the active builder; gives up + warns once if there's no handler. Opt-in setting added to package.json. - Builder follow (extension.ts + terminal-manager.ts): announce the active builder from a focused builder diff, the builder terminal (getActiveBuilderId), or a Codev-sidebar row selection — keyed on the builder id. --- packages/vscode/package.json | 5 ++ packages/vscode/src/command-relay.ts | 7 ++ packages/vscode/src/extension.ts | 55 +++++++++++- packages/vscode/src/streamdeck-focus-sync.ts | 93 ++++++++++++++++++++ packages/vscode/src/terminal-manager.ts | 18 ++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 packages/vscode/src/streamdeck-focus-sync.ts diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 011d83fea..89dc8ff5e 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -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, diff --git a/packages/vscode/src/command-relay.ts b/packages/vscode/src/command-relay.ts index 93eb24c8d..adf03914e 100644 --- a/packages/vscode/src/command-relay.ts +++ b/packages/vscode/src/command-relay.ts @@ -35,6 +35,9 @@ const VERB_COMMANDS: Record = { // 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', @@ -42,7 +45,11 @@ const VERB_COMMANDS: Record = { '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', diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 1d6d1947d..aa39adf57 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -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'; @@ -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 @@ -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 @@ -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 }); @@ -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 @@ -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', () => diff --git a/packages/vscode/src/streamdeck-focus-sync.ts b/packages/vscode/src/streamdeck-focus-sync.ts new file mode 100644 index 000000000..94c643f44 --- /dev/null +++ b/packages/vscode/src/streamdeck-focus-sync.ts @@ -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('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); + } +} diff --git a/packages/vscode/src/terminal-manager.ts b/packages/vscode/src/terminal-manager.ts index 4d8ff53a3..4c7c50c40 100644 --- a/packages/vscode/src/terminal-manager.ts +++ b/packages/vscode/src/terminal-manager.ts @@ -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-`), 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(