From 9445cf86b684b40f18f139b6f527f3c902e4487f Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Thu, 23 Apr 2026 17:22:52 +0200 Subject: [PATCH] Make Active Agents jump to live task terminals first Operators already use the Active Agents tree as a runtime control surface, so the session row should reveal the live terminal before it offers lower-value diff navigation. This patch swaps the inline diff action for a terminal action, matches live terminals by session pid when possible, falls back to a worktree terminal when needed, and routes Stop through Ctrl+C before the CLI fallback. Constraint: Active session metadata exposes pid and worktree path but no durable VS Code terminal id Rejected: Keep Open Diff inline | lower operator value than terminal reveal for live task control Confidence: high Scope-risk: narrow Directive: Keep session terminal matching pid-first unless the launcher gains a stable terminal identity field Tested: node --test test/vscode-active-agents-session-state.test.js; openspec validate agent-codex-active-agents-terminal-controls-2026-04-23-17-11 --type change --strict; openspec validate --specs Not-tested: Real VS Code terminal matching against existing interactive terminals outside the mock processId flow --- .../proposal.md | 15 ++ .../vscode-active-agents-extension/spec.md | 38 ++++ .../tasks.md | 38 ++++ .../vscode/guardex-active-agents/extension.js | 167 +++++++++++------- .../vscode/guardex-active-agents/package.json | 16 +- ...vscode-active-agents-session-state.test.js | 147 ++++++++++----- vscode/guardex-active-agents/extension.js | 167 +++++++++++------- vscode/guardex-active-agents/package.json | 16 +- 8 files changed, 413 insertions(+), 191 deletions(-) create mode 100644 openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/proposal.md create mode 100644 openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/specs/vscode-active-agents-extension/spec.md create mode 100644 openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/tasks.md diff --git a/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/proposal.md b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/proposal.md new file mode 100644 index 0000000..a0575da --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/proposal.md @@ -0,0 +1,15 @@ +## Why + +The VS Code Active Agents tree already exposes session-scoped inline actions, but the current mix still forces the user back into raw repo surfaces for the most common runtime operation: jump straight to the terminal where the agent is running. The inline `Open Diff` action is lower-value in this operator loop, and the current `Stop` action runs a background stop command instead of signaling the live terminal first. + +## What Changes + +- Replace the session-row `Open Diff` inline action with a `Show Terminal` action that reveals the matching integrated terminal for the selected session when one is available. +- Fallback to opening a worktree-scoped terminal when no live integrated terminal can be matched to the session yet. +- Update the `Stop` action so it sends `Ctrl+C` to the matching session terminal first and only falls back to `gx agents stop --pid` when no live terminal can be found. +- Refresh the focused Active Agents tests and extension manifests for the new terminal-first operator flow. + +## Impact + +- Affected surfaces: `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`, and this change workspace. +- No Active Agents session-schema changes are required; the extension can match live terminals from the existing session `pid`. diff --git a/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/specs/vscode-active-agents-extension/spec.md b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/specs/vscode-active-agents-extension/spec.md new file mode 100644 index 0000000..ad085b8 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/specs/vscode-active-agents-extension/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Active Agents exposes terminal-first inline session controls +The VS Code Active Agents companion SHALL prioritize jumping into the live session terminal over opening a per-file diff from the session row. + +#### Scenario: Session row offers terminal access instead of diff access +- **WHEN** the extension contributes inline actions for a `gitguardex.session` row +- **THEN** it contributes a `Show Terminal` action for that row +- **AND** it does NOT contribute the old `Open Diff` inline action for that row. + +### Requirement: Show Terminal focuses the live session terminal when possible +The VS Code Active Agents companion SHALL reveal the live integrated terminal that owns the selected session whenever the session metadata can be matched to a VS Code terminal process. + +#### Scenario: Session `pid` matches an open terminal +- **GIVEN** a session record has a positive integer `pid` +- **AND** VS Code already has an integrated terminal whose `processId` resolves to that same pid +- **WHEN** the operator triggers `Show Terminal` +- **THEN** the extension reveals that existing terminal with focus +- **AND** it does NOT open a replacement terminal for the session. + +#### Scenario: No live terminal match exists yet +- **WHEN** the operator triggers `Show Terminal` for a session without a matching live terminal +- **THEN** the extension opens a new integrated terminal rooted at the session worktree +- **AND** it focuses that terminal so the operator lands in the task sandbox immediately. + +### Requirement: Stop signals the terminal before falling back to the CLI stopper +The VS Code Active Agents companion SHALL stop live sessions through the matched terminal first so the operator sees and controls the running task directly. + +#### Scenario: Stop uses terminal interrupt when a live terminal is known +- **GIVEN** the selected session matches an open integrated terminal by `pid` +- **WHEN** the operator confirms `Stop` +- **THEN** the extension reveals that terminal +- **AND** it sends `Ctrl+C` to that terminal instead of spawning a separate `gx agents stop --pid` process. + +#### Scenario: Stop falls back when no terminal can be matched +- **WHEN** the operator confirms `Stop` for a session without a matching live terminal +- **THEN** the extension falls back to `gx agents stop --pid ` +- **AND** it preserves the existing repo-targeted stop behavior for that fallback path. diff --git a/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/tasks.md b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/tasks.md new file mode 100644 index 0000000..e2ed6f3 --- /dev/null +++ b/openspec/changes/agent-codex-active-agents-terminal-controls-2026-04-23-17-11/tasks.md @@ -0,0 +1,38 @@ +## Definition of Done + +This change is complete only when **all** of the following are true: + +- Every checkbox below is checked. +- The agent branch reaches `MERGED` state on `origin` and the PR URL + state are recorded in the completion handoff. +- If any step blocks (test failure, conflict, ambiguous result), append a `BLOCKED:` line under section 4 explaining the blocker and **STOP**. Do not tick remaining cleanup boxes; do not silently skip the cleanup pipeline. + +Handoff: 2026-04-23 15:11Z codex owns `vscode/guardex-active-agents/*`, `templates/vscode/guardex-active-agents/*`, `test/vscode-active-agents-session-state.test.js`, and this change workspace to replace the low-value `Open Diff` inline action with terminal-first runtime controls. + +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-active-agents-terminal-controls-2026-04-23-17-11`. +- [x] 1.2 Define normative requirements in `specs/vscode-active-agents-extension/spec.md`. + +## 2. Implementation + +- [x] 2.1 Replace the session-row `Open Diff` inline action with `Show Terminal` in the live/template Active Agents manifests. +- [x] 2.2 Reveal the matching integrated terminal for a session when the stored session `pid` matches a VS Code terminal `processId`, and open a worktree terminal when no live match exists. +- [x] 2.3 Update `Stop` to send `Ctrl+C` to the matched session terminal first and keep `gx agents stop --pid` as the no-terminal fallback. +- [x] 2.4 Refresh focused tests plus mock terminal plumbing for the new terminal-first behavior. + +## 3. Verification + +- [x] 3.1 Run `node --test test/vscode-active-agents-session-state.test.js`. +- [x] 3.2 Run `openspec validate agent-codex-active-agents-terminal-controls-2026-04-23-17-11 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +Verification notes: +- `node --test test/vscode-active-agents-session-state.test.js` passed `52/52`. +- `openspec validate agent-codex-active-agents-terminal-controls-2026-04-23-17-11 --type change --strict` returned `Change 'agent-codex-active-agents-terminal-controls-2026-04-23-17-11' is valid`. +- `openspec validate --specs` returned `No items found to validate.` in this checkout. + +## 4. Cleanup + +- [ ] 4.1 Run `gx branch finish --branch "agent/codex/active-agents-terminal-controls-2026-04-23-17-11" --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone (`git worktree list` no longer shows the agent path; `git branch -a` shows no surviving local/remote refs for the branch). diff --git a/templates/vscode/guardex-active-agents/extension.js b/templates/vscode/guardex-active-agents/extension.js index 27fc758..3c4f07d 100644 --- a/templates/vscode/guardex-active-agents/extension.js +++ b/templates/vscode/guardex-active-agents/extension.js @@ -1670,6 +1670,82 @@ function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { terminal.sendText(commandText, true); } +function sessionTerminalLabel(session) { + return `GitGuardex Terminal: ${sessionDisplayLabel(session)}`; +} + +function listWindowTerminals() { + return Array.isArray(vscode.window.terminals) ? vscode.window.terminals : []; +} + +function focusTerminal(terminal) { + terminal?.show?.(false); +} + +async function terminalProcessId(terminal) { + if (!terminal?.processId) { + return null; + } + + try { + const pid = await terminal.processId; + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch (_error) { + return null; + } +} + +function findFallbackSessionTerminal(session) { + const label = sessionTerminalLabel(session); + return listWindowTerminals().find((terminal) => terminal?.name === label) || null; +} + +async function findSessionTerminal(session) { + const pid = Number(session?.pid); + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + + for (const terminal of listWindowTerminals()) { + if (await terminalProcessId(terminal) === pid) { + return terminal; + } + } + + return null; +} + +function openFallbackSessionTerminal(session, worktreePath) { + const existingTerminal = findFallbackSessionTerminal(session); + if (existingTerminal) { + focusTerminal(existingTerminal); + return existingTerminal; + } + + const terminal = vscode.window.createTerminal({ + name: sessionTerminalLabel(session), + cwd: worktreePath, + iconPath: new vscode.ThemeIcon('terminal'), + }); + focusTerminal(terminal); + return terminal; +} + +async function showSessionTerminal(session) { + const worktreePath = ensureSessionWorktree(session, 'show terminal'); + if (!worktreePath) { + return; + } + + const terminal = await findSessionTerminal(session); + if (terminal) { + focusTerminal(terminal); + return; + } + + openFallbackSessionTerminal(session, worktreePath); +} + function finishSession(session) { if (!session?.branch) { showSessionMessage('Cannot finish session: missing branch name.'); @@ -1708,6 +1784,14 @@ function execFileAsync(command, args, options = {}) { }); } +function buildStopSessionCommandText(session, pid) { + const parts = ['gx', 'agents', 'stop', '--pid', String(pid)]; + if (session?.repoRoot) { + parts.push('--target', session.repoRoot); + } + return parts.map(shellQuote).join(' '); +} + async function stopSession(session, refresh) { const pid = Number(session?.pid); if (!Number.isInteger(pid) || pid <= 0) { @@ -1719,15 +1803,29 @@ async function stopSession(session, refresh) { return; } + const sessionTerminal = await findSessionTerminal(session); + const stopCommandText = buildStopSessionCommandText(session, pid); const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Run gx agents stop --pid ${pid}.` }, + { + modal: true, + detail: sessionTerminal + ? 'Send Ctrl+C to the live session terminal.' + : `No live session terminal found. Run ${stopCommandText}.`, + }, 'Stop', ); if (confirmed !== 'Stop') { return; } + if (sessionTerminal) { + focusTerminal(sessionTerminal); + sessionTerminal.sendText('\u0003', false); + refresh(); + return; + } + try { const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); const args = ['agents', 'stop', '--pid', String(pid)]; @@ -1747,71 +1845,6 @@ async function stopSession(session, refresh) { } } -function sessionChangedPaths(session) { - const directPaths = Array.isArray(session?.changedPaths) - ? session.changedPaths.map(normalizeRelativePath).filter(Boolean) - : []; - if (directPaths.length > 0) { - return [...new Set(directPaths)]; - } - if (!session?.repoRoot || !session?.branch) { - return []; - } - - const liveSession = readActiveSessions(session.repoRoot) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session)); - return Array.isArray(liveSession?.changedPaths) - ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))] - : []; -} - -async function pickSessionDiffPath(session) { - const changedPaths = sessionChangedPaths(session); - if (changedPaths.length === 0) { - return ''; - } - if (changedPaths.length === 1 || !vscode.window.showQuickPick) { - return changedPaths[0]; - } - - const picks = changedPaths.map((relativePath) => ({ - label: path.basename(relativePath), - description: relativePath, - relativePath, - })); - const selection = await vscode.window.showQuickPick(picks, { - placeHolder: `Select a changed file for ${sessionDisplayLabel(session)}`, - ignoreFocusOut: true, - }); - return selection?.relativePath || ''; -} - -async function openSessionDiff(session) { - const worktreePath = ensureSessionWorktree(session, 'open diff'); - if (!worktreePath) { - return; - } - - const relativePath = await pickSessionDiffPath(session); - if (!relativePath) { - showSessionMessage(`No changed files to diff for ${sessionDisplayLabel(session)}.`); - return; - } - - const repoRoot = session?.repoRoot || worktreePath; - const absolutePath = path.resolve(repoRoot, relativePath); - const resourceUri = vscode.Uri.file(absolutePath); - try { - await vscode.commands.executeCommand('git.openChange', resourceUri); - } catch (error) { - if (fs.existsSync(absolutePath)) { - await vscode.commands.executeCommand('vscode.open', resourceUri); - return; - } - showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`); - } -} - function readGitDirPath(targetPath) { const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; if (!normalizedTargetPath) { @@ -3321,10 +3354,10 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => { inspectPanelManager.open(session || provider.getSelectedSession()); }), + vscode.commands.registerCommand('gitguardex.activeAgents.showSessionTerminal', showSessionTerminal), vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, diff --git a/templates/vscode/guardex-active-agents/package.json b/templates/vscode/guardex-active-agents/package.json index 911fc96..9b2aeb6 100644 --- a/templates/vscode/guardex-active-agents/package.json +++ b/templates/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.16", + "version": "0.0.17", "license": "MIT", "icon": "icon.png", "engines": { @@ -66,9 +66,9 @@ "icon": "$(debug-stop)" }, { - "command": "gitguardex.activeAgents.openSessionDiff", - "title": "Open Diff", - "icon": "$(diff)" + "command": "gitguardex.activeAgents.showSessionTerminal", + "title": "Show Terminal", + "icon": "$(terminal)" } ], "viewsContainers": { @@ -134,22 +134,22 @@ "group": "inline" }, { - "command": "gitguardex.activeAgents.finishSession", + "command": "gitguardex.activeAgents.showSessionTerminal", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.syncSession", + "command": "gitguardex.activeAgents.finishSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.stopSession", + "command": "gitguardex.activeAgents.syncSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.openSessionDiff", + "command": "gitguardex.activeAgents.stopSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" } diff --git a/test/vscode-active-agents-session-state.test.js b/test/vscode-active-agents-session-state.test.js index 95dc10c..2b0e2b3 100644 --- a/test/vscode-active-agents-session-state.test.js +++ b/test/vscode-active-agents-session-state.test.js @@ -264,6 +264,7 @@ function createMockVscode(tempRoot) { executedCommands: [], sourceControls: [], terminals: [], + nextTerminalPid: 7000, openedDocuments: [], shownDocuments: [], infoMessages: [], @@ -474,6 +475,7 @@ function createMockVscode(tempRoot) { }, }, window: { + terminals: registrations.terminals, showInformationMessage: async (...args) => { registrations.infoMessages.push(args); if (typeof args[0] === 'string') { @@ -497,10 +499,14 @@ function createMockVscode(tempRoot) { createTerminal: (options) => { const terminal = { options, + name: options?.name, + processId: Promise.resolve(options?.processId ?? registrations.nextTerminalPid++), shown: false, + showArgs: [], sentTexts: [], - show() { + show(preserveFocus) { this.shown = true; + this.showArgs.push(preserveFocus); }, sendText(text, addNewLine) { this.sentTexts.push({ text, addNewLine }); @@ -3357,12 +3363,107 @@ test('active-agents extension opens and refreshes the inspect panel from shared } }); -test('active-agents extension confirms stop and routes through gx agents stop --pid', async () => { +test('active-agents extension reveals the matching session terminal and opens a fallback worktree terminal when needed', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-show-terminal-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-show-terminal-worktree-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; + + extension.activate(context); + + const liveTerminal = vscode.window.createTerminal({ + name: `GitGuardex: ${path.basename(tempRoot)}`, + cwd: tempRoot, + processId: 4242, + }); + await registrations.commands.get('gitguardex.activeAgents.showSessionTerminal')({ + label: 'live-task', + branch: 'agent/codex/live-task', + pid: 4242, + repoRoot: tempRoot, + worktreePath, + }); + + assert.equal(registrations.terminals.length, 1); + assert.equal(liveTerminal.shown, true); + assert.deepEqual(liveTerminal.showArgs, [false]); + assert.deepEqual(liveTerminal.sentTexts, []); + + await registrations.commands.get('gitguardex.activeAgents.showSessionTerminal')({ + label: 'fallback-task', + branch: 'agent/codex/fallback-task', + pid: 9001, + repoRoot: tempRoot, + worktreePath, + }); + + assert.equal(registrations.terminals.length, 2); + assert.equal(registrations.terminals[1].options.name, 'GitGuardex Terminal: fallback-task'); + assert.equal(registrations.terminals[1].options.cwd, worktreePath); + assert.equal(registrations.terminals[1].options.iconPath.id, 'terminal'); + assert.equal(registrations.terminals[1].shown, true); + assert.deepEqual(registrations.terminals[1].showArgs, [false]); + assert.deepEqual(registrations.terminals[1].sentTexts, []); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension stops matching session terminals with Ctrl+C before gx fallback', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-')); const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-')); const { registrations, vscode } = createMockVscode(tempRoot); const extension = loadExtensionWithMockVscode(vscode); const context = { subscriptions: [] }; + + vscode.window.showWarningMessage = async (...args) => { + registrations.warningMessages.push(args); + return 'Stop'; + }; + + extension.activate(context); + const provider = registrations.providers[0].provider; + await flushAsyncWork(); + provider.onDidChangeTreeDataEmitter.fireCount = 0; + + const liveTerminal = vscode.window.createTerminal({ + name: `GitGuardex: ${path.basename(tempRoot)}`, + cwd: tempRoot, + processId: 4242, + }); + + await registrations.commands.get('gitguardex.activeAgents.stopSession')({ + label: 'live-task', + branch: 'agent/codex/live-task', + pid: 4242, + repoRoot: tempRoot, + worktreePath, + }); + await flushAsyncWork(); + + assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); + assert.equal(registrations.warningMessages.length, 1); + assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); + assert.match(registrations.warningMessages[0][1].detail, /Ctrl\+C/); + assert.equal(liveTerminal.shown, true); + assert.deepEqual(liveTerminal.showArgs, [false]); + assert.deepEqual(liveTerminal.sentTexts, [ + { text: '\u0003', addNewLine: false }, + ]); + + for (const subscription of context.subscriptions) { + subscription.dispose?.(); + } +}); + +test('active-agents extension confirms stop and routes through gx agents stop --pid when no live terminal matches', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-session-fallback-')); + const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-stop-worktree-fallback-')); + const { registrations, vscode } = createMockVscode(tempRoot); + const extension = loadExtensionWithMockVscode(vscode); + const context = { subscriptions: [] }; let execCall = null; const originalExecFile = cp.execFile; @@ -3405,45 +3506,9 @@ test('active-agents extension confirms stop and routes through gx agents stop -- assert.ok(registrations.providers[0].provider.onDidChangeTreeDataEmitter.fireCount >= 1); assert.equal(registrations.warningMessages.length, 1); assert.match(registrations.warningMessages[0][0], /Stop live-task\?/); - assert.match(registrations.warningMessages[0][1].detail, /gx agents stop --pid 4242/); - - for (const subscription of context.subscriptions) { - subscription.dispose?.(); - } -}); - -test('active-agents extension opens the selected changed file through the Git diff UI', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-')); - const worktreePath = fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-vscode-open-diff-worktree-')); - initGitRepo(worktreePath); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\n', 'utf8'); - runGit(worktreePath, ['add', 'tracked.txt']); - runGit(worktreePath, ['commit', '-m', 'baseline']); - fs.writeFileSync(path.join(worktreePath, 'tracked.txt'), 'base\nchanged\n', 'utf8'); - - const { registrations, vscode } = createMockVscode(tempRoot); - const extension = loadExtensionWithMockVscode(vscode); - const context = { subscriptions: [] }; - - extension.activate(context); - - const relativePath = path.relative(tempRoot, path.join(worktreePath, 'tracked.txt')).replace(/\\/g, '/'); - await registrations.commands.get('gitguardex.activeAgents.openSessionDiff')({ - label: 'live-task', - repoRoot: tempRoot, - worktreePath, - changedPaths: [relativePath], - }); - - assert.equal(registrations.openedDocuments.length, 0); - assert.equal(registrations.shownDocuments.length, 0); - const openChangeCalls = registrations.executedCommands - .filter((entry) => entry.command === 'git.openChange') - .map((entry) => [entry.command, entry.args[0]?.fsPath || null]); - assert.deepEqual( - openChangeCalls, - [['git.openChange', path.join(worktreePath, 'tracked.txt')]], - ); + assert.match(registrations.warningMessages[0][1].detail, /--pid/); + assert.match(registrations.warningMessages[0][1].detail, /4242/); + assert.match(registrations.warningMessages[0][1].detail, /--target/); for (const subscription of context.subscriptions) { subscription.dispose?.(); diff --git a/vscode/guardex-active-agents/extension.js b/vscode/guardex-active-agents/extension.js index 27fc758..3c4f07d 100644 --- a/vscode/guardex-active-agents/extension.js +++ b/vscode/guardex-active-agents/extension.js @@ -1670,6 +1670,82 @@ function runSessionTerminalCommand(session, actionLabel, iconId, commandText) { terminal.sendText(commandText, true); } +function sessionTerminalLabel(session) { + return `GitGuardex Terminal: ${sessionDisplayLabel(session)}`; +} + +function listWindowTerminals() { + return Array.isArray(vscode.window.terminals) ? vscode.window.terminals : []; +} + +function focusTerminal(terminal) { + terminal?.show?.(false); +} + +async function terminalProcessId(terminal) { + if (!terminal?.processId) { + return null; + } + + try { + const pid = await terminal.processId; + return Number.isInteger(pid) && pid > 0 ? pid : null; + } catch (_error) { + return null; + } +} + +function findFallbackSessionTerminal(session) { + const label = sessionTerminalLabel(session); + return listWindowTerminals().find((terminal) => terminal?.name === label) || null; +} + +async function findSessionTerminal(session) { + const pid = Number(session?.pid); + if (!Number.isInteger(pid) || pid <= 0) { + return null; + } + + for (const terminal of listWindowTerminals()) { + if (await terminalProcessId(terminal) === pid) { + return terminal; + } + } + + return null; +} + +function openFallbackSessionTerminal(session, worktreePath) { + const existingTerminal = findFallbackSessionTerminal(session); + if (existingTerminal) { + focusTerminal(existingTerminal); + return existingTerminal; + } + + const terminal = vscode.window.createTerminal({ + name: sessionTerminalLabel(session), + cwd: worktreePath, + iconPath: new vscode.ThemeIcon('terminal'), + }); + focusTerminal(terminal); + return terminal; +} + +async function showSessionTerminal(session) { + const worktreePath = ensureSessionWorktree(session, 'show terminal'); + if (!worktreePath) { + return; + } + + const terminal = await findSessionTerminal(session); + if (terminal) { + focusTerminal(terminal); + return; + } + + openFallbackSessionTerminal(session, worktreePath); +} + function finishSession(session) { if (!session?.branch) { showSessionMessage('Cannot finish session: missing branch name.'); @@ -1708,6 +1784,14 @@ function execFileAsync(command, args, options = {}) { }); } +function buildStopSessionCommandText(session, pid) { + const parts = ['gx', 'agents', 'stop', '--pid', String(pid)]; + if (session?.repoRoot) { + parts.push('--target', session.repoRoot); + } + return parts.map(shellQuote).join(' '); +} + async function stopSession(session, refresh) { const pid = Number(session?.pid); if (!Number.isInteger(pid) || pid <= 0) { @@ -1719,15 +1803,29 @@ async function stopSession(session, refresh) { return; } + const sessionTerminal = await findSessionTerminal(session); + const stopCommandText = buildStopSessionCommandText(session, pid); const confirmed = await vscode.window.showWarningMessage( `Stop ${sessionDisplayLabel(session)}?`, - { modal: true, detail: `Run gx agents stop --pid ${pid}.` }, + { + modal: true, + detail: sessionTerminal + ? 'Send Ctrl+C to the live session terminal.' + : `No live session terminal found. Run ${stopCommandText}.`, + }, 'Stop', ); if (confirmed !== 'Stop') { return; } + if (sessionTerminal) { + focusTerminal(sessionTerminal); + sessionTerminal.sendText('\u0003', false); + refresh(); + return; + } + try { const commandCwd = session?.repoRoot || sessionWorktreePath(session) || process.cwd(); const args = ['agents', 'stop', '--pid', String(pid)]; @@ -1747,71 +1845,6 @@ async function stopSession(session, refresh) { } } -function sessionChangedPaths(session) { - const directPaths = Array.isArray(session?.changedPaths) - ? session.changedPaths.map(normalizeRelativePath).filter(Boolean) - : []; - if (directPaths.length > 0) { - return [...new Set(directPaths)]; - } - if (!session?.repoRoot || !session?.branch) { - return []; - } - - const liveSession = readActiveSessions(session.repoRoot) - .find((entry) => sessionSelectionKey(entry) === sessionSelectionKey(session)); - return Array.isArray(liveSession?.changedPaths) - ? [...new Set(liveSession.changedPaths.map(normalizeRelativePath).filter(Boolean))] - : []; -} - -async function pickSessionDiffPath(session) { - const changedPaths = sessionChangedPaths(session); - if (changedPaths.length === 0) { - return ''; - } - if (changedPaths.length === 1 || !vscode.window.showQuickPick) { - return changedPaths[0]; - } - - const picks = changedPaths.map((relativePath) => ({ - label: path.basename(relativePath), - description: relativePath, - relativePath, - })); - const selection = await vscode.window.showQuickPick(picks, { - placeHolder: `Select a changed file for ${sessionDisplayLabel(session)}`, - ignoreFocusOut: true, - }); - return selection?.relativePath || ''; -} - -async function openSessionDiff(session) { - const worktreePath = ensureSessionWorktree(session, 'open diff'); - if (!worktreePath) { - return; - } - - const relativePath = await pickSessionDiffPath(session); - if (!relativePath) { - showSessionMessage(`No changed files to diff for ${sessionDisplayLabel(session)}.`); - return; - } - - const repoRoot = session?.repoRoot || worktreePath; - const absolutePath = path.resolve(repoRoot, relativePath); - const resourceUri = vscode.Uri.file(absolutePath); - try { - await vscode.commands.executeCommand('git.openChange', resourceUri); - } catch (error) { - if (fs.existsSync(absolutePath)) { - await vscode.commands.executeCommand('vscode.open', resourceUri); - return; - } - showSessionMessage(`Failed to open diff for ${sessionDisplayLabel(session)}: ${formatGitCommandFailure(error)}`); - } -} - function readGitDirPath(targetPath) { const normalizedTargetPath = typeof targetPath === 'string' ? targetPath.trim() : ''; if (!normalizedTargetPath) { @@ -3321,10 +3354,10 @@ function activate(context) { vscode.commands.registerCommand('gitguardex.activeAgents.inspect', (session) => { inspectPanelManager.open(session || provider.getSelectedSession()); }), + vscode.commands.registerCommand('gitguardex.activeAgents.showSessionTerminal', showSessionTerminal), vscode.commands.registerCommand('gitguardex.activeAgents.finishSession', finishSession), vscode.commands.registerCommand('gitguardex.activeAgents.syncSession', syncSession), vscode.commands.registerCommand('gitguardex.activeAgents.stopSession', (session) => stopSession(session, refresh)), - vscode.commands.registerCommand('gitguardex.activeAgents.openSessionDiff', openSessionDiff), vscode.workspace.onDidChangeWorkspaceFolders(handleWorkspaceFoldersChanged), activeSessionsWatcher, lockWatcher, diff --git a/vscode/guardex-active-agents/package.json b/vscode/guardex-active-agents/package.json index 911fc96..9b2aeb6 100644 --- a/vscode/guardex-active-agents/package.json +++ b/vscode/guardex-active-agents/package.json @@ -3,7 +3,7 @@ "displayName": "GitGuardex Active Agents", "description": "Shows live Guardex sandbox sessions and repo changes in a dedicated VS Code Active Agents sidebar.", "publisher": "recodeee", - "version": "0.0.16", + "version": "0.0.17", "license": "MIT", "icon": "icon.png", "engines": { @@ -66,9 +66,9 @@ "icon": "$(debug-stop)" }, { - "command": "gitguardex.activeAgents.openSessionDiff", - "title": "Open Diff", - "icon": "$(diff)" + "command": "gitguardex.activeAgents.showSessionTerminal", + "title": "Show Terminal", + "icon": "$(terminal)" } ], "viewsContainers": { @@ -134,22 +134,22 @@ "group": "inline" }, { - "command": "gitguardex.activeAgents.finishSession", + "command": "gitguardex.activeAgents.showSessionTerminal", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.syncSession", + "command": "gitguardex.activeAgents.finishSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.stopSession", + "command": "gitguardex.activeAgents.syncSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }, { - "command": "gitguardex.activeAgents.openSessionDiff", + "command": "gitguardex.activeAgents.stopSession", "when": "view == gitguardex.activeAgents && viewItem == gitguardex.session", "group": "inline" }