feat(desktop): attach terminal output to composer

This commit is contained in:
DragonnZhang 2026-04-26 01:10:37 +08:00
parent 3f007a350a
commit f640c4ea9d
9 changed files with 293 additions and 38 deletions

View file

@ -15,8 +15,9 @@ Slice 13 basic scoped terminal.
5. Copy the terminal transcript and verify the UI reports copy success. 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 6. Start a command that waits for stdin, send input through the drawer, and
verify the command output includes that stdin. verify the command output includes that stdin.
7. Send the terminal output to the active AI thread and approve the fake ACP 7. Attach the terminal output to the composer, verify no AI turn starts until
command request. the composer Send action is clicked, then approve the fake ACP command
request.
8. Start a long-running command and click Kill. 8. Start a long-running command and click Kill.
9. Click Clear and verify the drawer output resets. 9. Click Clear and verify the drawer output resets.
@ -32,8 +33,9 @@ Slice 13 basic scoped terminal.
are visible in the bottom drawer and do not use Node integration. are visible in the bottom drawer and do not use Node integration.
- Copy output uses the preload-whitelisted Electron clipboard IPC, not renderer - Copy output uses the preload-whitelisted Electron clipboard IPC, not renderer
Node integration or an unbounded IPC channel. Node integration or an unbounded IPC channel.
- Send to AI uses the existing authenticated WebSocket user-message path with - Attach Output appends a bounded terminal transcript to the composer draft,
a bounded terminal transcript. does not touch the session WebSocket immediately, and requires the normal
composer Send action before a new agent turn starts.
## Diagnostics on Failure ## Diagnostics on Failure
@ -97,6 +99,34 @@ Additional artifacts collected:
- `completed-layout.json` - `completed-layout.json`
- `completed-workspace.png` - `completed-workspace.png`
## Automated Coverage Added In Codex Alignment Iteration 5
Iteration 5 changes terminal follow-up from direct AI send to explicit
attach-to-composer and updates the real Electron CDP smoke to cover the safer
workflow:
1. Launch real Electron with isolated HOME/runtime/user-data and fake ACP.
2. Open the fake Git project, create a thread from the project composer, and
complete the existing approval/review/commit path.
3. Expand Terminal, run stdout and stdin commands, then click `Attach Output`.
4. Assert the composer contains the bounded terminal transcript, the terminal
action is labeled `Attach Output`, the legacy `Send to AI` action is absent,
and no new `Approve Once` request appears before composer Send.
5. Click composer `Send`, approve the fake ACP request, and verify the fake ACP
response includes the terminal-output prompt.
Executable harness:
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
Additional artifacts collected:
- `terminal-attachment.json`
- `terminal-expanded-layout.json`
- `terminal-expanded.png`
- `completed-layout.json`
- `completed-workspace.png`
## Execution Results ## Execution Results
Codex alignment iteration 4: Codex alignment iteration 4:
@ -110,6 +140,17 @@ Codex alignment iteration 4:
- Success artifacts: - Success artifacts:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-00-08-461Z/`. `.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-00-08-461Z/`.
Codex alignment iteration 5:
- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
passed: 5 tests.
- `cd packages/desktop && npm run typecheck` passed.
- `cd packages/desktop && npm run lint` passed.
- `cd packages/desktop && npm run build` passed.
- `cd packages/desktop && npm run e2e:cdp` passed.
- Success artifacts:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-08-17-022Z/`.
Slice 16: Slice 16:
- `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests. - `npm run test --workspace=packages/desktop` passed: 9 files, 55 tests.

View file

@ -22,7 +22,101 @@ execution order, verification, decisions, and remaining work.
## Codex Alignment Progress ## Codex Alignment Progress
### Active Slice: Collapsed Terminal Status Strip Alignment ### Completed Slice: Terminal Attach-to-Composer Workflow
Status: completed in iteration 5.
Goal: change terminal output follow-up from an immediate `Send to AI` action
into an explicit attach-to-composer flow, so users can review and edit command
output before deciding whether to send it to the agent.
User-visible value: terminal output becomes contextual material in the task
composer rather than a hidden second send path that can unexpectedly trigger a
new agent turn. This keeps the conversation-first workbench aligned with
`home.jpg` while preserving the terminal as a supporting tool.
Expected files:
- `packages/desktop/src/renderer/App.tsx`
- `packages/desktop/src/renderer/api/websocket.ts`
- `packages/desktop/src/renderer/components/layout/WorkspacePage.tsx`
- `packages/desktop/src/renderer/components/layout/TerminalDrawer.tsx`
- `packages/desktop/src/renderer/components/layout/SidebarIcons.tsx`
- `packages/desktop/src/renderer/components/layout/WorkspacePage.test.tsx`
- `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- `.qwen/e2e-tests/electron-desktop/terminal-drawer.md`
- `design/qwen-code-electron-desktop-implementation-plan.md`
Acceptance criteria:
- The expanded Terminal action is labeled as attaching output to the composer,
not sending directly to AI.
- Attaching terminal output appends a bounded terminal transcript to the
existing composer text and shows a clear success notice.
- The attach action works whenever terminal output exists, including before a
thread is selected, and does not require or write to the session WebSocket.
- The user must still click Send from the composer before a new agent turn is
created.
- Copy, clear, kill, run command, stdin, expand, and collapse behavior is
unchanged.
Verification:
- Unit/component test command:
`cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
- Build/typecheck/lint commands:
`cd packages/desktop && npm run typecheck && npm run lint && npm run build`
- Real Electron harness:
`cd packages/desktop && npm run e2e:cdp`
- Harness path: `packages/desktop/scripts/e2e-cdp-smoke.mjs`
- E2E scenario steps: launch real Electron with isolated HOME/runtime/user-data
and fake ACP, open the fake Git project, create a composer-first thread,
approve the fake command, review and commit changes, expand Terminal, run
stdout and stdin commands, attach the resulting output to the composer,
assert no fake ACP follow-up happens until Send is clicked, then send the
composer text and approve the fake command request.
- E2E assertions: attach button is present and `Send to AI` is absent; composer
contains the terminal transcript after attach; terminal notice confirms the
attachment; the output stays editable in the composer; console errors and
failed local requests are absent.
- Diagnostic artifacts: CDP screenshots, terminal layout JSON, composer attach
JSON, Electron log, summary JSON under
`.qwen/e2e-tests/electron-desktop/artifacts/`.
- Required skills applied: `frontend-design` for prototype-constrained terminal
action wording and compact composer-centric hierarchy; `electron-desktop-dev`
for renderer changes and real Electron CDP verification.
Notes and decisions:
- The prototype keeps the composer as the task control center, so terminal
output should land there for user review rather than bypassing it.
- This slice intentionally preserves the transcript formatting and bounding
logic from the existing send path, but changes the destination from WebSocket
send to composer draft text.
- The WebSocket helper no longer needs a separate terminal-output send method
because the final send is the same explicit user-message path as any other
composer submit.
Verification results:
- `cd packages/desktop && SHELL=/bin/bash npx vitest run src/renderer/components/layout/WorkspacePage.test.tsx`
passed with 5 tests.
- `cd packages/desktop && npm run typecheck` passed.
- `cd packages/desktop && npm run lint` passed.
- `cd packages/desktop && npm run build` passed.
- `cd packages/desktop && npm run e2e:cdp` passed after launch through real
Electron over CDP.
- Passing artifacts:
`.qwen/e2e-tests/electron-desktop/artifacts/2026-04-25T17-08-17-022Z/`.
Next work:
- Improve review safety by replacing `Accept`/`Revert` terminology with
Stage/Unstage/Discard and confirming destructive discard paths.
- Continue prototype fidelity work in the conversation timeline by hiding
protocol/session noise and adding inline changed-file summaries.
### Completed Slice: Collapsed Terminal Status Strip Alignment
Status: completed in iteration 4. Status: completed in iteration 4.

View file

@ -136,8 +136,10 @@ async function main() {
await clickButton('Send Input'); await clickButton('Send Input');
await waitForText('Input sent.'); await waitForText('Input sent.');
await waitForText('stdin:desktop-e2e-stdin'); await waitForText('stdin:desktop-e2e-stdin');
await clickButton('Send to AI'); await clickButton('Attach Output');
await waitForText('Sent terminal output to AI.'); await waitForText('Attached terminal output to composer.');
await assertTerminalOutputAttached('terminal-attachment.json');
await clickButton('Send');
await waitForText('Approve Once'); await waitForText('Approve Once');
await clickButton('Approve Once'); await clickButton('Approve Once');
await waitForText( await waitForText(
@ -747,6 +749,59 @@ async function assertTerminalExpandedLayout(fileName) {
} }
} }
async function assertTerminalOutputAttached(fileName) {
const snapshot = await evaluate(`(() => {
const textarea = document.querySelector('textarea[aria-label="Message"]');
const terminalActions = document.querySelector('.terminal-actions');
const text = textarea?.value ?? '';
return {
composerValue: text,
hasTerminalPrompt: text.includes('Review this terminal output'),
hasCommand: text.includes('$ node -e'),
hasStdinOutput: text.includes('stdin:desktop-e2e-stdin'),
hasAttachAction: Boolean(
document.querySelector('button[aria-label="Attach Output"]')
),
hasLegacySendAction: Boolean(
document.querySelector('button[aria-label="Send to AI"]')
) || (terminalActions?.textContent ?? '').includes('Send to AI'),
hasPendingApproval: document.body.innerText.includes('Approve Once')
};
})()`);
await writeFile(
join(artifactDir, fileName),
`${JSON.stringify(snapshot, null, 2)}\n`,
'utf8',
);
if (!snapshot.hasAttachAction) {
throw new Error('Terminal attach action was not rendered.');
}
if (snapshot.hasLegacySendAction) {
throw new Error('Terminal should attach output, not show Send to AI.');
}
if (
!snapshot.hasTerminalPrompt ||
!snapshot.hasCommand ||
!snapshot.hasStdinOutput
) {
throw new Error(
`Terminal output was not attached to composer: ${JSON.stringify(
snapshot,
)}`,
);
}
if (snapshot.hasPendingApproval) {
throw new Error(
'Attaching terminal output should not create an agent approval request.',
);
}
}
async function assertSettingsPageLayout(fileName) { async function assertSettingsPageLayout(fileName) {
const metrics = await evaluate(`(() => { const metrics = await evaluate(`(() => {
const rectFor = (selector) => { const rectFor = (selector) => {

View file

@ -713,14 +713,9 @@ export function App() {
} }
}, [terminal]); }, [terminal]);
const sendTerminalOutputToAi = useCallback(() => { const attachTerminalOutputToComposer = useCallback(() => {
if ( if (!terminal) {
!activeSessionId || setTerminalError('Run a terminal command before attaching output.');
!socketRef.current ||
chatState.connection !== 'connected' ||
!terminal
) {
setTerminalError('Open a thread before sending terminal output to AI.');
return; return;
} }
@ -730,12 +725,15 @@ export function App() {
return; return;
} }
const content = buildTerminalOutputPrompt(terminal); const content = buildTerminalAttachmentDraft(terminal);
dispatchChat({ type: 'append_user_message', content }); setMessageText((current) =>
socketRef.current.sendTerminalOutput(content); current.trim().length > 0
? `${current.trimEnd()}\n\n${content}`
: content,
);
setTerminalError(null); setTerminalError(null);
setTerminalNotice('Sent terminal output to AI.'); setTerminalNotice('Attached terminal output to composer.');
}, [activeSessionId, chatState.connection, terminal]); }, [terminal]);
useEffect(() => { useEffect(() => {
if (loadState.state !== 'ready' || terminal?.status !== 'running') { if (loadState.state !== 'ready' || terminal?.status !== 'running') {
@ -972,7 +970,7 @@ export function App() {
onRevertReviewTarget={revertReviewTarget} onRevertReviewTarget={revertReviewTarget}
onRunTerminalCommand={runTerminalCommand} onRunTerminalCommand={runTerminalCommand}
onSaveSettings={saveSettings} onSaveSettings={saveSettings}
onSendTerminalOutputToAi={sendTerminalOutputToAi} onAttachTerminalOutput={attachTerminalOutputToComposer}
onSelectProject={selectProject} onSelectProject={selectProject}
onSelectSession={selectSession} onSelectSession={selectSession}
onSendMessage={sendMessage} onSendMessage={sendMessage}
@ -1041,7 +1039,7 @@ function formatTerminalTranscript(terminal: DesktopTerminal): string {
return `$ ${terminal.command}\n[${terminal.status}]${exitText}\n${terminal.output}`; return `$ ${terminal.command}\n[${terminal.status}]${exitText}\n${terminal.output}`;
} }
function buildTerminalOutputPrompt(terminal: DesktopTerminal): string { function buildTerminalAttachmentDraft(terminal: DesktopTerminal): string {
const transcript = formatTerminalTranscript(terminal); const transcript = formatTerminalTranscript(terminal);
const boundedTranscript = const boundedTranscript =
transcript.length > 12_000 transcript.length > 12_000

View file

@ -20,7 +20,6 @@ export interface SessionSocketHandlers {
export interface SessionSocketClient { export interface SessionSocketClient {
sendUserMessage(content: string): void; sendUserMessage(content: string): void;
sendTerminalOutput(content: string): void;
respondToPermission(requestId: string, optionId: string): void; respondToPermission(requestId: string, optionId: string): void;
respondToAskUserQuestion( respondToAskUserQuestion(
requestId: string, requestId: string,
@ -55,9 +54,6 @@ export function connectSessionSocket(
sendUserMessage(content: string): void { sendUserMessage(content: string): void {
sendClientMessage(socket, { type: 'user_message', content }); sendClientMessage(socket, { type: 'user_message', content });
}, },
sendTerminalOutput(content: string): void {
sendClientMessage(socket, { type: 'user_message', content });
},
respondToPermission(requestId: string, optionId: string): void { respondToPermission(requestId: string, optionId: string): void {
sendClientMessage(socket, { sendClientMessage(socket, {
type: 'permission_response', type: 'permission_response',

View file

@ -312,6 +312,33 @@ export function CopyIcon(props: SidebarIconProps) {
); );
} }
export function PaperclipIcon(props: SidebarIconProps) {
return (
<svg
aria-hidden="true"
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
{...props}
>
<path
d="m8.1 12.1 6.2-6.2c1.4-1.4 3.6-1.4 5 0s1.4 3.6 0 5l-8.1 8.1c-1.9 1.9-5 1.9-6.9 0s-1.9-5 0-6.9l7.8-7.8"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
/>
<path
d="m8.3 15.8 7.3-7.3"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.7"
/>
</svg>
);
}
export function SendIcon(props: SidebarIconProps) { export function SendIcon(props: SidebarIconProps) {
return ( return (
<svg <svg

View file

@ -9,7 +9,7 @@ import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
CopyIcon, CopyIcon,
SendIcon, PaperclipIcon,
StopIcon, StopIcon,
TerminalIcon, TerminalIcon,
TrashIcon, TrashIcon,
@ -21,13 +21,13 @@ export function TerminalDrawer({
isExpanded, isExpanded,
input, input,
notice, notice,
onAttachOutput,
onClear, onClear,
onCommandChange, onCommandChange,
onCopyOutput, onCopyOutput,
onInputChange, onInputChange,
onKill, onKill,
onRun, onRun,
onSendOutputToAi,
onToggleExpanded, onToggleExpanded,
onWriteInput, onWriteInput,
project, project,
@ -38,13 +38,13 @@ export function TerminalDrawer({
isExpanded: boolean; isExpanded: boolean;
input: string; input: string;
notice: string | null; notice: string | null;
onAttachOutput: () => void;
onClear: () => void; onClear: () => void;
onCommandChange: (command: string) => void; onCommandChange: (command: string) => void;
onCopyOutput: () => void; onCopyOutput: () => void;
onInputChange: (input: string) => void; onInputChange: (input: string) => void;
onKill: () => void; onKill: () => void;
onRun: () => void; onRun: () => void;
onSendOutputToAi: () => void;
onToggleExpanded: () => void; onToggleExpanded: () => void;
onWriteInput: () => void; onWriteInput: () => void;
project: DesktopProject | null; project: DesktopProject | null;
@ -110,15 +110,15 @@ export function TerminalDrawer({
<span className="sr-only">Copy Output</span> <span className="sr-only">Copy Output</span>
</button> </button>
<button <button
aria-label="Send to AI" aria-label="Attach Output"
className="terminal-icon-button" className="terminal-icon-button"
disabled={!hasOutput} disabled={!hasOutput}
title="Send to AI" title="Attach Output to Composer"
type="button" type="button"
onClick={onSendOutputToAi} onClick={onAttachOutput}
> >
<SendIcon /> <PaperclipIcon />
<span className="sr-only">Send to AI</span> <span className="sr-only">Attach Output</span>
</button> </button>
<button <button
aria-label="Clear Terminal" aria-label="Clear Terminal"

View file

@ -14,6 +14,7 @@ import type {
DesktopGitDiff, DesktopGitDiff,
DesktopProject, DesktopProject,
DesktopSessionSummary, DesktopSessionSummary,
DesktopTerminal,
} from '../../api/client.js'; } from '../../api/client.js';
import { createInitialChatState } from '../../stores/chatStore.js'; import { createInitialChatState } from '../../stores/chatStore.js';
import { createInitialModelState } from '../../stores/modelStore.js'; import { createInitialModelState } from '../../stores/modelStore.js';
@ -95,6 +96,12 @@ describe('WorkspacePage', () => {
expect( expect(
renderedContainer.querySelector('button[aria-label="Copy Output"]'), renderedContainer.querySelector('button[aria-label="Copy Output"]'),
).toBeTruthy(); ).toBeTruthy();
expect(
renderedContainer.querySelector('button[aria-label="Attach Output"]'),
).toBeTruthy();
expect(
renderedContainer.querySelector('button[aria-label="Send to AI"]'),
).toBeNull();
act(() => { act(() => {
clickButton(renderedContainer, 'Open Changes'); clickButton(renderedContainer, 'Open Changes');
@ -219,6 +226,30 @@ describe('WorkspacePage', () => {
HTMLFormElement.prototype.requestSubmit = originalRequestSubmit; HTMLFormElement.prototype.requestSubmit = originalRequestSubmit;
} }
}); });
it('routes terminal output through an attach action', () => {
const onAttachTerminalOutput = vi.fn();
const renderedContainer = renderWorkspace({
onAttachTerminalOutput,
terminal,
});
act(() => {
clickButton(renderedContainer, 'Expand Terminal');
});
const attachButton = renderedContainer.querySelector(
'button[aria-label="Attach Output"]',
);
expect(attachButton).toBeInstanceOf(HTMLButtonElement);
expect((attachButton as HTMLButtonElement).disabled).toBe(false);
act(() => {
clickButton(renderedContainer, 'Attach Output');
});
expect(onAttachTerminalOutput).toHaveBeenCalledTimes(1);
});
}); });
type WorkspacePageProps = Parameters<typeof WorkspacePage>[0]; type WorkspacePageProps = Parameters<typeof WorkspacePage>[0];
@ -270,7 +301,7 @@ function renderWorkspace(
onRevertReviewTarget: vi.fn(), onRevertReviewTarget: vi.fn(),
onRunTerminalCommand: vi.fn(), onRunTerminalCommand: vi.fn(),
onSaveSettings: vi.fn(), onSaveSettings: vi.fn(),
onSendTerminalOutputToAi: vi.fn(), onAttachTerminalOutput: vi.fn(),
onSelectProject: vi.fn(), onSelectProject: vi.fn(),
onSelectSession: vi.fn(), onSelectSession: vi.fn(),
onSendMessage: (event) => event.preventDefault(), onSendMessage: (event) => event.preventDefault(),
@ -348,6 +379,19 @@ const session: DesktopSessionSummary = {
cwd: project.path, cwd: project.path,
}; };
const terminal: DesktopTerminal = {
id: 'terminal-1',
projectId: project.id,
cwd: project.path,
command: 'printf terminal-output',
status: 'exited',
output: 'terminal-output',
exitCode: 0,
signal: null,
createdAt: '2026-04-25T00:00:00.000Z',
updatedAt: '2026-04-25T00:00:01.000Z',
};
const gitDiff: DesktopGitDiff = { const gitDiff: DesktopGitDiff = {
ok: true, ok: true,
generatedAt: '2026-04-25T00:00:00.000Z', generatedAt: '2026-04-25T00:00:00.000Z',

View file

@ -69,7 +69,7 @@ export function WorkspacePage({
onRevertReviewTarget, onRevertReviewTarget,
onRunTerminalCommand, onRunTerminalCommand,
onSaveSettings, onSaveSettings,
onSendTerminalOutputToAi, onAttachTerminalOutput,
onSelectProject, onSelectProject,
onSelectSession, onSelectSession,
onSendMessage, onSendMessage,
@ -119,7 +119,7 @@ export function WorkspacePage({
onRevertReviewTarget: (target: DesktopGitReviewTarget) => void; onRevertReviewTarget: (target: DesktopGitReviewTarget) => void;
onRunTerminalCommand: () => void; onRunTerminalCommand: () => void;
onSaveSettings: () => void; onSaveSettings: () => void;
onSendTerminalOutputToAi: () => void; onAttachTerminalOutput: () => void;
onSelectProject: (projectId: string) => void; onSelectProject: (projectId: string) => void;
onSelectSession: (sessionId: string) => void; onSelectSession: (sessionId: string) => void;
onSendMessage: (event: FormEvent<HTMLFormElement>) => void; onSendMessage: (event: FormEvent<HTMLFormElement>) => void;
@ -259,7 +259,7 @@ export function WorkspacePage({
onKill={onKillTerminal} onKill={onKillTerminal}
onInputChange={onTerminalInputChange} onInputChange={onTerminalInputChange}
onRun={onRunTerminalCommand} onRun={onRunTerminalCommand}
onSendOutputToAi={onSendTerminalOutputToAi} onAttachOutput={onAttachTerminalOutput}
onToggleExpanded={() => onToggleExpanded={() =>
setIsTerminalExpanded((current) => !current) setIsTerminalExpanded((current) => !current)
} }