diff --git a/.changeset/response-copy-markdown.md b/.changeset/response-copy-markdown.md
new file mode 100644
index 00000000..e04891cc
--- /dev/null
+++ b/.changeset/response-copy-markdown.md
@@ -0,0 +1,5 @@
+---
+"aicodeman": patch
+---
+
+Response viewer: add a Copy Markdown button that copies the raw source of the newest answer (every part of its turn), so headings, lists and code fences paste intact. An empty viewer and a refused clipboard both say so.
diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md
index d494864b..8149bb4f 100644
--- a/docs/wiki/Mobile-Guide.md
+++ b/docs/wiki/Mobile-Guide.md
@@ -187,8 +187,9 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
## Reading long answers
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
-last answer as scrollable text instead, with a **More** button for additional context.
-Persian, Arabic and Hebrew answers read right to left there.
+last answer as scrollable text instead, with a **More** button for additional context and a
+**Copy Markdown** button for its raw source. Persian, Arabic and Hebrew answers read right to
+left there.
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
enough to fix a typo an agent introduced while you are away from your desk.
diff --git a/docs/wiki/Watching-Agents-Work.md b/docs/wiki/Watching-Agents-Work.md
index 79fd2da2..f4302284 100644
--- a/docs/wiki/Watching-Agents-Work.md
+++ b/docs/wiki/Watching-Agents-Work.md
@@ -77,9 +77,11 @@ Turning on either starts the watcher.
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
rather than terminal output. It exists mostly for phones, where reading a long answer in a
-terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
-answers read right to left here, which the terminal cannot do, while code and the file paths
-it links stay left to right.
+terminal viewport is painful. **More** loads additional context. **Copy Markdown** copies the
+newest answer's raw Markdown, every part of its turn, so headings, lists and code fences paste
+intact (the same answer in both views; tool and status blocks are left out). Persian, Arabic
+and Hebrew answers read right to left here, which the terminal cannot do, while code and the
+file paths it links stay left to right.
## After the fact
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 5b7c86c0..a07c5bab 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -774,6 +774,7 @@ class CodemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
+ this._responseViewerMarkdown = '';
// ── Session detach / undock (beta) ───────────────────────────────────
// A "solo window" is a popped-out browser window showing exactly one
@@ -2948,12 +2949,7 @@ class CodemanApp {
ev.stopPropagation();
const code = copyBtn.closest('.rv-code-wrap')?.querySelector('pre code');
const ok = code ? await this._copyText(code.textContent || '') : false;
- copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
- copyBtn.classList.add(ok ? 'rv-copied' : 'rv-copy-failed');
- clearTimeout(copyBtn._resetTimer);
- copyBtn._resetTimer = setTimeout(() => {
- copyBtn.classList.remove('rv-copied', 'rv-copy-failed');
- }, 1500);
+ this._setCopyControlState(copyBtn, ok);
return;
}
@@ -2997,6 +2993,50 @@ class CodemanApp {
}
}
+ _setCopyControlState(btn, success) {
+ if (!btn) return;
+ btn.classList.remove('rv-copied', 'rv-copy-failed');
+ btn.classList.add(success ? 'rv-copied' : 'rv-copy-failed');
+ clearTimeout(btn._resetTimer);
+ btn._resetTimer = setTimeout(() => {
+ btn.classList.remove('rv-copied', 'rv-copy-failed');
+ }, 1500);
+ }
+
+ /**
+ * The raw Markdown of the newest answer in a message list: every row of its
+ * turn, or the last row when the reader emits no numeric turns (Codex, the
+ * pane parser). Tool and status blocks share the assistant role but are not
+ * the answer.
+ */
+ _responseViewerMarkdownFrom(messages) {
+ const answers = messages.filter(
+ (msg) => msg && msg.role === 'assistant' && (!msg.kind || msg.kind === 'response') && msg.text?.trim()
+ );
+ const last = answers[answers.length - 1];
+ if (!last) return '';
+ if (typeof last.turn !== 'number') return last.text;
+ return answers
+ .filter((msg) => msg.turn === last.turn)
+ .map((msg) => msg.text)
+ .join('\n\n');
+ }
+
+ /** Copy the raw Markdown source of the answer the response viewer is showing. */
+ async copyResponseViewerMarkdown() {
+ const button = document.getElementById('responseViewerCopyMarkdown');
+ const markdown = this._responseViewerMarkdown || '';
+ if (!markdown) {
+ this._setCopyControlState(button, false);
+ this.showToast('No response content to copy', 'warning');
+ return;
+ }
+ const copied = await this._copyText(markdown);
+ this._setCopyControlState(button, copied);
+ if (copied) this.showToast('Copied response as Markdown', 'success');
+ else this.showToast('Failed to copy response as Markdown', 'error');
+ }
+
/** Build one response-viewer message so the brief and full views share markup and CSS. */
_buildResponseViewerMessage(text, role, agentLabel, meta) {
const div = document.createElement('div');
@@ -3239,6 +3279,8 @@ class CodemanApp {
}
}
+ this._responseViewerMarkdown = this._responseViewerMarkdownFrom(turnMessages) || lastResponse;
+
const body = document.getElementById('responseViewerBody');
if (turnMessages.length > 0) {
// The whole last turn, rendered exactly as the full view renders that
@@ -3318,6 +3360,9 @@ class CodemanApp {
return;
}
+ // Keep the brief view's answer when this history holds none of its own.
+ this._responseViewerMarkdown = this._responseViewerMarkdownFrom(messages) || this._responseViewerMarkdown;
+
// Render conversation thread
const agentLabel = this._getResponseViewerAgentLabel();
body.innerHTML = '';
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index fe0fc76b..6d476b92 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -245,6 +245,11 @@
'No full conversation history available for this session': '此会话没有可显示的完整对话历史',
'Last Response': '最近一次回复',
More: '更多',
+ 'Copy Markdown': '复制 Markdown',
+ 'Copy response as Markdown': '将回复复制为 Markdown',
+ 'Copied response as Markdown': '已将回复复制为 Markdown',
+ 'Failed to copy response as Markdown': '无法将回复复制为 Markdown',
+ 'No response content to copy': '没有可复制的回复内容',
'Codeman version': '{name}版本',
Stop: '停止',
Watching: '监视中',
diff --git a/src/web/public/index.html b/src/web/public/index.html
index e8278777..f30e4074 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -733,6 +733,7 @@
Last Response
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 9a8c3a38..3b3cec05 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -1776,6 +1776,27 @@ html.mobile-init .file-browser-panel {
max-height: 92dvh;
}
+ .response-viewer-header {
+ gap: 8px;
+ padding: 12px;
+ }
+
+ .response-viewer-header > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .response-viewer-actions {
+ flex-shrink: 0;
+ gap: 4px;
+ }
+
+ .response-viewer-more {
+ padding: 3px 7px;
+ }
+
.response-viewer-body {
font-size: 14.5px;
line-height: 1.65;
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index eaedb2d5..ebcb25a4 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -14924,6 +14924,20 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
background: var(--control-bg-hover);
}
+.response-viewer-copy-markdown {
+ white-space: nowrap;
+}
+
+.response-viewer-copy-markdown.rv-copied {
+ color: var(--green);
+ border-color: var(--green);
+}
+
+.response-viewer-copy-markdown.rv-copy-failed {
+ color: var(--red);
+ border-color: var(--red);
+}
+
.response-viewer-close {
background: none;
border: none;
diff --git a/test/response-viewer-copy-markdown.test.ts b/test/response-viewer-copy-markdown.test.ts
new file mode 100644
index 00000000..f29c3d13
--- /dev/null
+++ b/test/response-viewer-copy-markdown.test.ts
@@ -0,0 +1,300 @@
+/**
+ * @fileoverview The response viewer's Copy Markdown button.
+ *
+ * The button copies the raw Markdown of the answer the viewer is showing, so a
+ * heading stays `##` and a code fence stays a fence, which rendered text selected
+ * with the mouse cannot do. Pinned here:
+ *
+ * 1. The copied source follows what the viewer rendered: the whole last answered
+ * turn in the brief view, the same selection in the full view, and nothing
+ * left over from an earlier open.
+ * 2. Tool and status blocks (DeepSeek, the external-CLI pane parser) are not the
+ * answer and are never copied, and neither is a prompt.
+ * 3. An empty viewer and a refused clipboard both say so; neither reports success.
+ * 4. The markup, phone layout and zh-CN strings the button depends on.
+ *
+ * app.js is loaded via `vm` with a jsdom document, as in
+ * response-viewer-last-turn.test.ts.
+ * Port: N/A
+ */
+import { readFileSync } from 'node:fs';
+import { performance } from 'node:perf_hooks';
+import { resolve } from 'node:path';
+import vm from 'node:vm';
+import { JSDOM } from 'jsdom';
+import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
+
+const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
+const read = (file: string) => readFileSync(resolve(PUBLIC, file), 'utf8');
+
+const dom = new JSDOM('');
+const { document, NodeFilter } = dom.window;
+
+const appContext = vm.createContext({
+ console,
+ performance,
+ setInterval: vi.fn(),
+ clearInterval: vi.fn(),
+ setTimeout,
+ clearTimeout,
+ requestAnimationFrame: vi.fn(),
+ HTMLCanvasElement: class HTMLCanvasElement {},
+ fetch: vi.fn(),
+ navigator: {},
+ document,
+ NodeFilter,
+ localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
+ window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
+ MobileDetection: {},
+}) as { fetch: unknown; navigator: unknown; __CodemanApp: { prototype: object } };
+vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, appContext);
+
+interface ViewerApp {
+ toggleResponseViewer(): Promise;
+ loadFullContext(): Promise;
+ copyResponseViewerMarkdown(): Promise;
+ _bindResponseViewerInteractions(body: HTMLElement): void;
+ _copyText: ReturnType;
+ showToast: ReturnType;
+ activeSessionId?: string;
+ sessions?: Map;
+}
+
+function mountViewer() {
+ const ids = ['responseViewer', 'responseViewerBackdrop', 'responseViewerBody', 'responseViewerTitle'];
+ const [, , body] = ids.map((id) => {
+ const el = document.createElement('div');
+ el.id = id;
+ document.body.appendChild(el);
+ return el;
+ });
+ const more = document.createElement('button');
+ more.id = 'responseViewerMore';
+ const button = document.createElement('button');
+ button.id = 'responseViewerCopyMarkdown';
+ document.body.append(more, button);
+ return { body, button };
+}
+
+/** `responses` are served in order, one per fetch, inside the real route envelope. */
+function makeApp(...responses: unknown[]): ViewerApp {
+ const app = Object.create(appContext.__CodemanApp.prototype) as ViewerApp;
+ app.activeSessionId = 's1';
+ app.sessions = new Map([['s1', { mode: 'codex' }]]);
+ app._copyText = vi.fn(async () => true);
+ app.showToast = vi.fn();
+ appContext.fetch = vi.fn(async () => ({ json: async () => ({ success: true, data: responses.shift() }) }));
+ return app;
+}
+
+const MARKDOWN = '## Update\n\n- **Keep** the syntax\n- [docs](https://example.com)\n\n```ts\nconst a = 1;\n```';
+
+afterEach(() => {
+ document.body.innerHTML = '';
+ appContext.navigator = {};
+});
+afterAll(() => dom.window.close());
+
+describe('what Copy Markdown copies', () => {
+ it('copies the raw source of a single-text response', async () => {
+ mountViewer();
+ const app = makeApp({ text: MARKDOWN });
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith(MARKDOWN);
+ });
+
+ it('copies every row of the turn the brief view rendered, not only the final one', async () => {
+ mountViewer();
+ const app = makeApp({
+ text: 'Done.',
+ messages: [
+ { role: 'assistant', text: '## Finding', turn: 2 },
+ { role: 'assistant', text: 'The bug is on line 3.', turn: 2 },
+ { role: 'assistant', text: 'Done.', turn: 2 },
+ ],
+ });
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith('## Finding\n\nThe bug is on line 3.\n\nDone.');
+ });
+
+ it('follows a fresh open instead of keeping the previous answer', async () => {
+ mountViewer();
+ const app = makeApp({ text: 'old answer' }, { text: 'new answer' });
+ await app.toggleResponseViewer();
+ await app.toggleResponseViewer(); // close
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith('new answer');
+ });
+
+ it('copies the last answered turn from the full view, ignoring earlier turns and a queued prompt', async () => {
+ mountViewer();
+ const app = makeApp(
+ { text: 'stale brief answer' },
+ {
+ messages: [
+ { role: 'user', text: 'first', turn: 1 },
+ { role: 'assistant', text: 'first answer', turn: 1 },
+ { role: 'user', text: 'second', turn: 2 },
+ { role: 'assistant', text: 'part one', turn: 2 },
+ { role: 'assistant', text: 'part two', turn: 2 },
+ { role: 'user', text: 'queued', turn: 3, queued: true },
+ ],
+ }
+ );
+ await app.toggleResponseViewer();
+ await app.loadFullContext();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith('part one\n\npart two');
+ });
+
+ it('copies only the last response block when blocks have no turns, never a tool or status block', async () => {
+ mountViewer();
+ const app = makeApp(
+ { text: 'brief' },
+ {
+ messages: [
+ { kind: 'prompt', role: 'user', text: 'ask' },
+ { kind: 'response', role: 'assistant', text: 'earlier response' },
+ { kind: 'response', role: 'assistant', text: 'latest response' },
+ { kind: 'tool', role: 'assistant', text: 'ran a tool' },
+ { kind: 'status', role: 'assistant', text: 'max-tokens' },
+ ],
+ }
+ );
+ await app.toggleResponseViewer();
+ await app.loadFullContext();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith('latest response');
+ });
+
+ it('keeps the brief answer when the full view finds no history and leaves it on screen', async () => {
+ mountViewer();
+ const app = makeApp({ text: 'the answer on screen' }, { messages: [] });
+ await app.toggleResponseViewer();
+ await app.loadFullContext();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).toHaveBeenCalledWith('the answer on screen');
+ });
+});
+
+describe('what Copy Markdown reports', () => {
+ it('confirms a successful copy on the button and in a toast', async () => {
+ const { button } = mountViewer();
+ const app = makeApp({ text: MARKDOWN });
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ expect(button.classList.contains('rv-copied')).toBe(true);
+ expect(app.showToast).toHaveBeenCalledWith('Copied response as Markdown', 'success');
+ });
+
+ it('warns and copies nothing when the viewer has no response, even after an earlier one', async () => {
+ const { button } = mountViewer();
+ const app = makeApp({ text: 'old answer' }, { text: '' });
+ await app.toggleResponseViewer();
+ await app.toggleResponseViewer(); // close
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ expect(app._copyText).not.toHaveBeenCalled();
+ expect(button.classList.contains('rv-copy-failed')).toBe(true);
+ expect(app.showToast).toHaveBeenCalledWith('No response content to copy', 'warning');
+ });
+
+ it('reports failure when the Clipboard API rejects and the execCommand fallback also fails', async () => {
+ const { button } = mountViewer();
+ const app = makeApp({ text: MARKDOWN });
+ delete (app as Partial)._copyText; // own stub off: the real prototype method runs
+ const writeText = vi.fn(async () => {
+ throw new Error('NotAllowedError');
+ });
+ appContext.navigator = { clipboard: { writeText } };
+ const execCommand = vi.fn(() => false);
+ (document as unknown as { execCommand: unknown }).execCommand = execCommand;
+
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+
+ expect(writeText).toHaveBeenCalledWith(MARKDOWN);
+ expect(execCommand).toHaveBeenCalledWith('copy');
+ expect(button.classList.contains('rv-copied')).toBe(false);
+ expect(button.classList.contains('rv-copy-failed')).toBe(true);
+ expect(app.showToast).toHaveBeenCalledWith('Failed to copy response as Markdown', 'error');
+ });
+
+ it('a later success clears the failure state', async () => {
+ const { button } = mountViewer();
+ const app = makeApp({ text: MARKDOWN });
+ app._copyText.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
+ await app.toggleResponseViewer();
+ await app.copyResponseViewerMarkdown();
+ await app.copyResponseViewerMarkdown();
+ expect(button.classList.contains('rv-copy-failed')).toBe(false);
+ expect(button.classList.contains('rv-copied')).toBe(true);
+ });
+});
+
+describe('the per-code-block copy button still works through the shared state helper', () => {
+ it('copies the block and marks the button', async () => {
+ const { body } = mountViewer();
+ body.innerHTML =
+ '
npm test
';
+ const app = makeApp();
+ app._bindResponseViewerInteractions(body);
+ const button = body.querySelector('.rv-copy-btn') as HTMLElement;
+ button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
+ await vi.waitFor(() => expect(button.classList.contains('rv-copied')).toBe(true));
+ expect(app._copyText).toHaveBeenCalledWith('npm test');
+ });
+});
+
+describe('Copy Markdown markup, layout and strings', () => {
+ it('sits in the viewer actions as a real button wired to the copy method', () => {
+ const actions = read('index.html').match(/
[\s\S]*?<\/div>/)?.[0] ?? '';
+ const tag = actions.match(/');
+ });
+
+ it('colors copy feedback from skin tokens, like the code-block button', () => {
+ const css = read('styles.css');
+ const rule = (selector: string) => css.split(`${selector} {`)[1]?.split('}')[0] ?? '';
+ expect(rule('.response-viewer-copy-markdown.rv-copied')).toContain('var(--green)');
+ expect(rule('.response-viewer-copy-markdown.rv-copy-failed')).toContain('var(--red)');
+ });
+
+ it('lets a long title shrink on phones so the actions stay on screen', () => {
+ const css = read('mobile.css');
+ const rule = (selector: string) => css.split(`${selector} {`)[1]?.split('}')[0] ?? '';
+ expect(rule('.response-viewer-header > span')).toContain('min-width: 0');
+ expect(rule('.response-viewer-header > span')).toContain('text-overflow: ellipsis');
+ expect(rule('.response-viewer-actions')).toContain('flex-shrink: 0');
+ });
+
+ it('translates the button and every toast into zh-CN', () => {
+ const i18nDom = new JSDOM('', {
+ runScripts: 'outside-only',
+ url: 'http://localhost/',
+ });
+ vm.runInContext(read('i18n.js'), i18nDom.getInternalVMContext(), { filename: 'i18n.js' });
+ const api = (i18nDom.window as unknown as { CodemanI18n: { t(s: string): string; configure(o: object): void } })
+ .CodemanI18n;
+ api.configure({ language: 'zh-CN' });
+
+ const method = read('app.js').match(/\n {2}async copyResponseViewerMarkdown\(\) \{[\s\S]*?\n {2}\}\n/)?.[0] ?? '';
+ const toasts = [...method.matchAll(/'((?:No|Copied|Failed)[^']+)'/g)].map((m) => m[1]);
+ expect(new Set(toasts)).toEqual(
+ new Set(['No response content to copy', 'Copied response as Markdown', 'Failed to copy response as Markdown'])
+ );
+ // Exact strings: a generic rule turns any "Failed to ..." into half-translated text.
+ expect(api.t('No response content to copy')).toBe('没有可复制的回复内容');
+ expect(api.t('Copied response as Markdown')).toBe('已将回复复制为 Markdown');
+ expect(api.t('Failed to copy response as Markdown')).toBe('无法将回复复制为 Markdown');
+ expect(api.t('Copy Markdown')).toBe('复制 Markdown');
+ expect(api.t('Copy response as Markdown')).toBe('将回复复制为 Markdown');
+ i18nDom.window.close();
+ });
+});