diff --git a/.qwen/e2e-tests/electron-desktop/terminal-drawer.md b/.qwen/e2e-tests/electron-desktop/terminal-drawer.md index 9d4c4d390..04c028fb4 100644 --- a/.qwen/e2e-tests/electron-desktop/terminal-drawer.md +++ b/.qwen/e2e-tests/electron-desktop/terminal-drawer.md @@ -12,17 +12,28 @@ Slice 13 basic scoped terminal. 2. Open a temporary project directory. 3. Use the bottom Terminal drawer to run a harmless command. 4. Verify command output appears in the drawer. -5. Start a long-running command and click Kill. -6. Click Clear and verify the drawer output resets. +5. Copy the terminal transcript and verify the UI reports copy success. +6. Start a command that waits for stdin, send input through the drawer, and + verify the command output includes that stdin. +7. Send the terminal output to the active AI thread and approve the fake ACP + command request. +8. Start a long-running command and click Kill. +9. Click Clear and verify the drawer output resets. ## Assertions - `POST /api/terminals` requires a registered project id and non-empty command. - Terminal cwd is resolved from the registered project path server-side. - `GET /api/terminals/:id` returns output and exit status. +- `POST /api/terminals/:id/write` writes stdin only while the terminal is + running and returns `terminal_not_running` after completion. - `POST /api/terminals/:id/kill` marks a running terminal as killed. -- Renderer terminal controls are visible in the bottom drawer and do not use - Node integration. +- Renderer terminal controls for run, stdin, copy, kill, clear, and send to AI + are visible in the bottom drawer and do not use Node integration. +- Copy output uses the preload-whitelisted Electron clipboard IPC, not renderer + Node integration or an unbounded IPC channel. +- Send to AI uses the existing authenticated WebSocket user-message path with + a bounded terminal transcript. ## Diagnostics on Failure @@ -32,17 +43,50 @@ Slice 13 basic scoped terminal. - Save DesktopServer terminal route responses. - Save the temporary workspace path and command used. -## Automated Coverage Added This Iteration +## Automated Coverage Added In Slice 13 -The full Electron E2E harness is still pending. This iteration added -server-level coverage in `packages/desktop/src/server/index.test.ts`: +Slice 13 added server-level coverage in +`packages/desktop/src/server/index.test.ts`: - runs `printf terminal-output` scoped to a registered project; - polls `/api/terminals/:id` until output and exit code are available; - starts a long-running Node command and verifies `/kill` returns `killed`. +## Automated Coverage Added In Slice 16 + +Slice 16 adds server and Electron CDP coverage: + +- server test starts a command waiting on stdin, writes to + `/api/terminals/:id/write`, verifies output, and verifies a stale write fails + with `terminal_not_running`; +- CDP smoke runs a command, copies output, starts a stdin-driven command, + sends input, sends the terminal transcript to the fake ACP session, approves + the command request, and verifies the fake ACP response includes the terminal + prompt. +- the real Electron CDP run exposed that browser clipboard fallback is not + reliable from the built `file://` renderer; Slice 16 now covers the + preload-backed Electron clipboard path. + ## Execution Results +Slice 16: + +- `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests. +- `npm run typecheck --workspace=packages/desktop` passed. +- `npm run lint --workspace=packages/desktop` passed. +- `npm run build --workspace=packages/desktop` passed. +- Initial `npm run e2e:cdp --workspace=packages/desktop` failed on the copy + status assertion. Diagnostics: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T04-42-48-004Z/`. +- After adding the preload clipboard IPC, `npm run e2e:cdp + --workspace=packages/desktop` passed. Success artifacts: + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T04-45-53-738Z/`. +- `npm run typecheck` passed across workspaces. +- `npm run build` passed across workspaces. Existing VS Code companion lint + warnings remained warnings only. + +Slice 13: + - `npm run test --workspace=packages/desktop` passed: 8 files, 52 tests. - `npm run typecheck --workspace=packages/desktop` passed. - `npm run lint --workspace=packages/desktop` passed. @@ -50,7 +94,6 @@ server-level coverage in `packages/desktop/src/server/index.test.ts`: ## Remaining Risk -The current terminal is a command runner, not a full interactive PTY. PTY -write/resize, output selection/copy polish, send-output-to-AI, terminal tabs, -history, and real Electron renderer assertions remain required before the MVP -can be marked done. +The current terminal is still a command runner with stdin pipes, not a full +interactive PTY. PTY resize, terminal tabs/history, and richer output +selection remain deferred beyond the P0 path. diff --git a/design/qwen-code-electron-desktop-implementation-plan.md b/design/qwen-code-electron-desktop-implementation-plan.md index 76314b99a..2a1763c40 100644 --- a/design/qwen-code-electron-desktop-implementation-plan.md +++ b/design/qwen-code-electron-desktop-implementation-plan.md @@ -371,6 +371,83 @@ hunkId }` and stages only that current hunk. `.qwen/e2e-tests/electron-desktop/diff-review-commit.md` and `.qwen/e2e-tests/electron-desktop/cdp-renderer-observability.md`. +### Slice 16: Terminal Output to AI and Stdin Write + +- Status: complete in iteration 12 +- Goal: close the P0 terminal gap by allowing running terminal processes to + receive stdin, making output easy to copy, and sending scoped terminal output + back into the active AI thread through the existing session socket. +- Files: + - `packages/desktop/src/server/services/terminalService.ts` + - `packages/desktop/src/server/index.ts` + - `packages/desktop/src/server/index.test.ts` + - `packages/desktop/src/shared/desktopApi.ts` + - `packages/desktop/src/shared/ipcChannels.ts` + - `packages/desktop/src/main/ipc/registerIpc.ts` + - `packages/desktop/src/preload/index.ts` + - `packages/desktop/src/renderer/api/client.ts` + - `packages/desktop/src/renderer/api/websocket.ts` + - `packages/desktop/src/renderer/App.tsx` + - `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx` + - `packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx` + - `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx` + - `packages/desktop/src/renderer/styles.css` + - `packages/desktop/scripts/e2e-cdp-smoke.mjs` + - `.qwen/e2e-tests/electron-desktop/terminal-drawer.md` +- Acceptance criteria: + - `POST /api/terminals/:id/write` writes text to stdin for a running + project-scoped terminal and fails closed for completed terminals. + - Terminal drawer exposes copy output, clear, kill, stdin input, and + send-output-to-AI controls without renderer Node APIs. + - Copy output uses a preload-whitelisted Electron clipboard IPC because + `file://` Electron renderers cannot rely on browser clipboard permission. + - Send-output-to-AI requires an active session and posts a bounded terminal + transcript through the existing authenticated WebSocket prompt path. + - Electron CDP smoke verifies command output, stdin write output, copy status, + and send-output-to-AI fake ACP response without console errors or failed + network requests. +- E2E coverage: + - Updated `.qwen/e2e-tests/electron-desktop/terminal-drawer.md` with the new + stdin/write, copy, and send-output-to-AI scenario and diagnostics. +- UI direction: + - Called `frontend-design` before modifying the terminal drawer. The adopted + direction is a dense developer-tool terminal control strip inside the + existing dark workbench: compact action buttons, explicit stdin row, + visible success/error feedback, and no decorative card nesting or marketing + layout. + - User-visible changes are scoped to `TerminalDrawer`, `WorkspacePage`, + `App`, and terminal drawer CSS. The CDP harness verifies the top bar, + sidebar, chat thread, review panel, terminal drawer, copy status, stdin + output, and send-to-AI response in a real Electron renderer. +- Completed: + - Added token-protected `POST /api/terminals/:id/write` to write stdin to a + running server-owned terminal process and reject stale writes. + - Added renderer terminal controls for copy output, stdin input, and sending + a bounded terminal transcript to the active AI thread. + - Added a preload-whitelisted `writeClipboardText` IPC backed by Electron + clipboard for reliable desktop copy output without enabling renderer Node + access. + - Kept send-output-to-AI on the existing authenticated WebSocket + `user_message` prompt path instead of adding a second prompt transport. + - Extended the Electron CDP smoke to verify copy status, stdin output, and + fake ACP response after sending terminal output to AI. +- Verification: + - `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests. + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run lint --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. + - Initial `npm run e2e:cdp --workspace=packages/desktop` failed at copy + output because the renderer fallback clipboard path was unavailable in the + Electron `file://` page. Diagnostics were written to ignored + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T04-42-48-004Z/`. + - After adding the preload clipboard IPC, `npm run e2e:cdp +--workspace=packages/desktop` passed. Success artifacts were written under + ignored + `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T04-45-53-738Z/`. + - `npm run typecheck` passed across workspaces. + - `npm run build` passed across workspaces. Existing VS Code companion lint + warnings were reported by its build script, with no errors. + ## Decision Log - 2026-04-25: Use a main-process hosted `DesktopServer` for MVP, matching the @@ -412,6 +489,15 @@ hunkId }` and stages only that current hunk. server-derived patch application, not renderer-submitted patches. This keeps hunk accept/revert scoped to the current registered project state and avoids trusting client-provided diff text. +- 2026-04-25: Implement terminal send-output-to-AI by formatting a bounded + transcript in the renderer and submitting it through the existing session + WebSocket user-message path. This reuses ACP prompt concurrency, permissions, + and chat history instead of introducing a second terminal-specific prompt + route. +- 2026-04-25: Use a narrow preload IPC for terminal transcript copy because the + real Electron renderer is loaded from `file://` and browser clipboard + permissions are not reliable there. The renderer still has no Node + integration and the IPC accepts only a non-empty string. ## Verification Log @@ -482,6 +568,20 @@ hunkId }` and stages only that current hunk. - `npm run build --workspace=packages/desktop` passed. - `npm run e2e:cdp --workspace=packages/desktop` passed and reported no renderer console errors or failed network requests. +- 2026-04-25 Slice 16 terminal output-to-AI and stdin write: + - `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests. + - `npm run typecheck --workspace=packages/desktop` passed. + - `npm run lint --workspace=packages/desktop` passed. + - `npm run build --workspace=packages/desktop` passed. + - Initial `npm run e2e:cdp --workspace=packages/desktop` failed at the copy + status assertion, with no console errors or failed network requests; the + DOM showed `Clipboard is unavailable.` + - After adding the preload clipboard IPC, `npm run e2e:cdp +--workspace=packages/desktop` passed and reported no renderer console + errors or failed network requests. + - `npm run typecheck` passed across workspaces. + - `npm run build` passed across workspaces. Existing VS Code companion lint + warnings were reported by its build script, with no errors. ## Self Review Notes @@ -525,8 +625,18 @@ hunkId }` and stages only that current hunk. - Review comments are currently local renderer notes for the active review session. Persisting them into ACP/session artifacts or Git commit metadata is deferred. +- Slice 16 terminal stdin writes only target server-owned running terminal + records. Completed/killed terminals and closed stdin streams fail closed with + `terminal_not_running`. +- Terminal output-to-AI does not bypass ACP permissions: it sends a normal user + prompt to the active session and therefore inherits the same prompt + concurrency, permission bridge, and fake/real ACP behavior as composer sends. +- The current terminal remains a command runner with stdin pipes. Full PTY + resize/write semantics and terminal tabs/history are deferred beyond the P0 + workflow verified by CDP smoke. ## Remaining Work -- Implement terminal PTY/write/send-output-to-AI refinements, run final package - smoke, and complete any remaining MVP polish before creating the DONE marker. +- Run final package smoke and complete the final MVP readiness review before + creating the DONE marker. PTY resize, terminal tabs/history, and persisted + review comments remain deferred beyond P0. diff --git a/packages/desktop/scripts/e2e-cdp-smoke.mjs b/packages/desktop/scripts/e2e-cdp-smoke.mjs index 910ee2376..7d7f26393 100644 --- a/packages/desktop/scripts/e2e-cdp-smoke.mjs +++ b/packages/desktop/scripts/e2e-cdp-smoke.mjs @@ -101,6 +101,27 @@ async function main() { await setFieldByAriaLabel('Terminal command', 'printf desktop-e2e-terminal'); await clickButton('Run'); await waitForText('desktop-e2e-terminal'); + await waitForText('[exited] exit 0'); + await clickButton('Copy Output'); + await waitForText('Copied terminal output.'); + + await setFieldByAriaLabel( + 'Terminal command', + 'node -e "process.stdin.once(\'data\', d => process.stdout.write(\'stdin:\' + d.toString(), () => process.exit(0)))"', + ); + await clickButton('Run'); + await waitForText('[running]'); + await setFieldByAriaLabel('Terminal input', 'desktop-e2e-stdin'); + await clickButton('Send Input'); + await waitForText('Input sent.'); + await waitForText('stdin:desktop-e2e-stdin'); + await clickButton('Send to AI'); + await waitForText('Sent terminal output to AI.'); + await waitForText('Approve Once'); + await clickButton('Approve Once'); + await waitForText( + 'E2E fake ACP response received: Review this terminal output', + ); await saveScreenshot('completed-workspace.png'); await assertNoBrowserErrors(); diff --git a/packages/desktop/src/main/ipc/registerIpc.ts b/packages/desktop/src/main/ipc/registerIpc.ts index bacfeba5e..119ed78de 100644 --- a/packages/desktop/src/main/ipc/registerIpc.ts +++ b/packages/desktop/src/main/ipc/registerIpc.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ipcMain, type BrowserWindow } from 'electron'; +import { clipboard, ipcMain, type BrowserWindow } from 'electron'; import type { DesktopServerInfo } from '../../shared/desktopApi.js'; import { IPC_CHANNELS } from '../../shared/ipcChannels.js'; import { selectDirectory } from '../native/dialogs.js'; @@ -26,6 +26,9 @@ export function registerIpc(options: RegisterIpcOptions): void { ipcMain.handle(IPC_CHANNELS.showItemInFolder, (_event, path: unknown) => { showItemInFolder(requireString(path, 'path')); }); + ipcMain.handle(IPC_CHANNELS.writeClipboardText, (_event, text: unknown) => { + clipboard.writeText(requireString(text, 'text')); + }); ipcMain.handle(IPC_CHANNELS.windowMinimize, () => { options.getMainWindow()?.minimize(); }); diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 5db4210da..80c356552 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -24,6 +24,9 @@ const api: QwenDesktopApi = { showItemInFolder: async (path: string) => { await ipcRenderer.invoke(IPC_CHANNELS.showItemInFolder, path); }, + writeClipboardText: async (text: string) => { + await ipcRenderer.invoke(IPC_CHANNELS.writeClipboardText, text); + }, window: { minimize: async () => { await ipcRenderer.invoke(IPC_CHANNELS.windowMinimize); diff --git a/packages/desktop/src/renderer/App.tsx b/packages/desktop/src/renderer/App.tsx index 16ef8b4ff..78ed678fa 100644 --- a/packages/desktop/src/renderer/App.tsx +++ b/packages/desktop/src/renderer/App.tsx @@ -35,6 +35,7 @@ import { setDesktopSessionModel, stageDesktopProjectChanges, updateDesktopUserSettings, + writeDesktopTerminalInput, type DesktopGitDiff, type DesktopGitReviewTarget, type DesktopProject, @@ -78,8 +79,10 @@ export function App() { const [reviewError, setReviewError] = useState(null); const [commitMessage, setCommitMessage] = useState(''); const [terminalCommand, setTerminalCommand] = useState(''); + const [terminalInput, setTerminalInput] = useState(''); const [terminal, setTerminal] = useState(null); const [terminalError, setTerminalError] = useState(null); + const [terminalNotice, setTerminalNotice] = useState(null); const [messageText, setMessageText] = useState(''); const [chatState, dispatchChat] = useReducer( chatReducer, @@ -492,11 +495,38 @@ export function App() { setTerminal(nextTerminal); setTerminalCommand(''); setTerminalError(null); + setTerminalNotice(null); } catch (error) { setTerminalError(getErrorMessage(error)); } }, [activeProject, loadState, terminalCommand]); + const writeTerminalInput = useCallback(async () => { + if ( + loadState.state !== 'ready' || + !terminal || + terminal.status !== 'running' || + terminalInput.trim().length === 0 + ) { + return; + } + + try { + setTerminal( + await writeDesktopTerminalInput( + loadState.status.serverInfo, + terminal.id, + ensureTerminalInputLine(terminalInput), + ), + ); + setTerminalInput(''); + setTerminalError(null); + setTerminalNotice('Input sent.'); + } catch (error) { + setTerminalError(getErrorMessage(error)); + } + }, [loadState, terminal, terminalInput]); + const killTerminal = useCallback(async () => { if (loadState.state !== 'ready' || !terminal) { return; @@ -507,6 +537,7 @@ export function App() { await killDesktopTerminal(loadState.status.serverInfo, terminal.id), ); setTerminalError(null); + setTerminalNotice('Terminal stopped.'); } catch (error) { setTerminalError(getErrorMessage(error)); } @@ -514,9 +545,49 @@ export function App() { const clearTerminal = useCallback(() => { setTerminal(null); + setTerminalInput(''); setTerminalError(null); + setTerminalNotice(null); }, []); + const copyTerminalOutput = useCallback(async () => { + if (!terminal) { + return; + } + + try { + await writeClipboardText(formatTerminalTranscript(terminal)); + setTerminalError(null); + setTerminalNotice('Copied terminal output.'); + } catch (error) { + setTerminalError(getErrorMessage(error)); + } + }, [terminal]); + + const sendTerminalOutputToAi = useCallback(() => { + if ( + !activeSessionId || + !socketRef.current || + chatState.connection !== 'connected' || + !terminal + ) { + setTerminalError('Open a thread before sending terminal output to AI.'); + return; + } + + const output = terminal.output.trim(); + if (!output) { + setTerminalError('Terminal output is empty.'); + return; + } + + const content = buildTerminalOutputPrompt(terminal); + dispatchChat({ type: 'append_user_message', content }); + socketRef.current.sendTerminalOutput(content); + setTerminalError(null); + setTerminalNotice('Sent terminal output to AI.'); + }, [activeSessionId, chatState.connection, terminal]); + useEffect(() => { if (loadState.state !== 'ready' || terminal?.status !== 'running') { return; @@ -680,12 +751,15 @@ export function App() { terminal={terminal} terminalCommand={terminalCommand} terminalError={terminalError} + terminalInput={terminalInput} + terminalNotice={terminalNotice} onAskUserQuestionResponse={respondToAskUserQuestion} onAuthenticate={authenticate} onChooseWorkspace={chooseWorkspace} onClearTerminal={clearTerminal} onCommit={commitChanges} onCommitMessageChange={setCommitMessage} + onCopyTerminalOutput={copyTerminalOutput} onCreateSession={createSession} onKillTerminal={killTerminal} onMessageTextChange={setMessageText} @@ -697,6 +771,7 @@ export function App() { onRevertReviewTarget={revertReviewTarget} onRunTerminalCommand={runTerminalCommand} onSaveSettings={saveSettings} + onSendTerminalOutputToAi={sendTerminalOutputToAi} onSelectProject={selectProject} onSelectSession={setActiveSessionId} onSendMessage={sendMessage} @@ -704,6 +779,8 @@ export function App() { onStageReviewTarget={stageReviewTarget} onStopGeneration={stopGeneration} onTerminalCommandChange={setTerminalCommand} + onTerminalInputChange={setTerminalInput} + onWriteTerminalInput={writeTerminalInput} /> ); } @@ -745,3 +822,54 @@ function joinProjectFilePath(projectPath: string, filePath: string): string { : projectPath; return `${base}${separator}${filePath}`; } + +function ensureTerminalInputLine(input: string): string { + return input.endsWith('\n') ? input : `${input}\n`; +} + +function formatTerminalTranscript(terminal: DesktopTerminal): string { + const exitText = + terminal.exitCode === null ? '' : ` exit ${String(terminal.exitCode)}`; + return `$ ${terminal.command}\n[${terminal.status}]${exitText}\n${terminal.output}`; +} + +function buildTerminalOutputPrompt(terminal: DesktopTerminal): string { + const transcript = formatTerminalTranscript(terminal); + const boundedTranscript = + transcript.length > 12_000 + ? `...[terminal output truncated]\n${transcript.slice(-12_000)}` + : transcript; + + return `Review this terminal output from the current project and use it to continue the task.\n\n${boundedTranscript}`; +} + +async function writeClipboardText(text: string): Promise { + if (window.qwenDesktop?.writeClipboardText) { + await window.qwenDesktop.writeClipboardText(text); + return; + } + + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back to the DOM copy command below when Clipboard API permission + // is unavailable in a file:// Electron renderer. + } + } + + const textArea = document.createElement('textarea'); + textArea.value = text; + textArea.setAttribute('readonly', 'true'); + textArea.style.position = 'fixed'; + textArea.style.top = '-1000px'; + document.body.append(textArea); + textArea.select(); + const copied = document.execCommand('copy'); + textArea.remove(); + + if (!copied) { + throw new Error('Clipboard is unavailable.'); + } +} diff --git a/packages/desktop/src/renderer/api/client.ts b/packages/desktop/src/renderer/api/client.ts index 55f3455ca..e88f74d7f 100644 --- a/packages/desktop/src/renderer/api/client.ts +++ b/packages/desktop/src/renderer/api/client.ts @@ -348,6 +348,21 @@ export async function killDesktopTerminal( return response.terminal; } +export async function writeDesktopTerminalInput( + serverInfo: DesktopServerInfo, + terminalId: string, + input: string, +): Promise { + const response = await writeJson( + serverInfo, + `/api/terminals/${encodeURIComponent(terminalId)}/write`, + 'POST', + { input }, + isTerminalResponse, + ); + return response.terminal; +} + export async function createDesktopSession( serverInfo: DesktopServerInfo, cwd: string, diff --git a/packages/desktop/src/renderer/api/websocket.ts b/packages/desktop/src/renderer/api/websocket.ts index 71ef30b7e..d1a3b89a6 100644 --- a/packages/desktop/src/renderer/api/websocket.ts +++ b/packages/desktop/src/renderer/api/websocket.ts @@ -20,6 +20,7 @@ export interface SessionSocketHandlers { export interface SessionSocketClient { sendUserMessage(content: string): void; + sendTerminalOutput(content: string): void; respondToPermission(requestId: string, optionId: string): void; respondToAskUserQuestion( requestId: string, @@ -54,6 +55,9 @@ export function connectSessionSocket( sendUserMessage(content: string): void { sendClientMessage(socket, { type: 'user_message', content }); }, + sendTerminalOutput(content: string): void { + sendClientMessage(socket, { type: 'user_message', content }); + }, respondToPermission(requestId: string, optionId: string): void { sendClientMessage(socket, { type: 'permission_response', diff --git a/packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx b/packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx index de38ed331..b8a51a055 100644 --- a/packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx +++ b/packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx @@ -9,22 +9,37 @@ import type { DesktopProject, DesktopTerminal } from '../../api/client.js'; export function TerminalDrawer({ command, error, + input, + notice, onClear, onCommandChange, + onCopyOutput, + onInputChange, onKill, onRun, + onSendOutputToAi, + onWriteInput, project, terminal, }: { command: string; error: string | null; + input: string; + notice: string | null; onClear: () => void; onCommandChange: (command: string) => void; + onCopyOutput: () => void; + onInputChange: (input: string) => void; onKill: () => void; onRun: () => void; + onSendOutputToAi: () => void; + onWriteInput: () => void; project: DesktopProject | null; terminal: DesktopTerminal | null; }) { + const hasOutput = (terminal?.output.trim().length ?? 0) > 0; + const canWriteInput = terminal?.status === 'running'; + return (
{project?.name || 'No project'}
+ + @@ -72,11 +103,34 @@ export function TerminalDrawer({ Run
+
+