diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 2335a1054d6..7f50c0c377b 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -268,17 +268,17 @@ In headless (`--prompt`) or non-interactive contexts, `/diff` prints a plain-tex Commands for obtaining information and performing system settings. -| Command | Description | Usage Examples | -| --------------- | ----------------------------------------------- | -------------------------------- | -| `/help` | Display help information for available commands | `/help` or `/?` | -| `/status` | Display version information | `/status` or `/about` | -| `/status paths` | Display current session file and log paths | `/status paths` | -| `/stats` | Display detailed statistics for current session | `/stats` | -| `/settings` | Open settings editor | `/settings` | -| `/auth` | Change authentication method | `/auth` | -| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | -| `/copy` | Copy last output content to clipboard | `/copy` | -| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | +| Command | Description | Usage Examples | +| --------------- | ------------------------------------------------------------- | -------------------------------- | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/status` | Display version information | `/status` or `/about` | +| `/status paths` | Display current session file and log paths | `/status paths` | +| `/stats` | Display detailed statistics for current session | `/stats` | +| `/settings` | Open settings editor | `/settings` | +| `/auth` | Change authentication method | `/auth` | +| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | +| `/copy` | Copy AI output to clipboard (`/copy N` = Nth-last AI message) | `/copy` or `/copy 2` | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | ### 1.10 Common Shortcuts diff --git a/docs/users/features/markdown-rendering.md b/docs/users/features/markdown-rendering.md index 1ac79dbc244..51866cdf09b 100644 --- a/docs/users/features/markdown-rendering.md +++ b/docs/users/features/markdown-rendering.md @@ -150,6 +150,25 @@ response: | `/copy code typescript` | Copies the last `typescript` code block. | | `/copy code mermaid 1` | Copies the first `mermaid` code block. | +## Selecting an Earlier AI Message + +By default `/copy` targets the most recent AI message. Prefix the command with +a positive integer to copy from the Nth-last AI message instead — handy when +the latest reply is something low-signal (e.g., a TODO update) and the +substantive output is one or two turns back. + +| Command | Behavior | +| --------------------- | ------------------------------------------------------ | +| `/copy 2` | Copies the second-to-last AI message in full. | +| `/copy 3` | Copies the third-to-last AI message in full. | +| `/copy 2 code python` | Copies the last `python` code block from the 2nd-last. | +| `/copy 3 latex` | Copies the last LaTeX block from the 3rd-last message. | + +`/copy 1` is equivalent to `/copy`. If `N` exceeds the number of AI messages +in the session, `/copy` reports the actual count instead of copying anything. +Without a leading integer, sub-selectors such as `/copy code python 2` keep +their existing meaning (the 2nd `python` block in the last message). + ## Current Limits - Mermaid image rendering depends on Mermaid CLI plus terminal image support. @@ -160,4 +179,5 @@ response: Mermaid layout engine. - Raw mode is global for rendered Markdown blocks; it is not a per-block toggle. - LaTeX rendering covers common symbols and expressions, not full TeX layout. -- Source copy commands operate on the last AI response. +- Source copy commands target the last AI response by default, or the Nth-last + when invoked as `/copy N ...`. diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 3e4e2974b1e..321f6b6f9dd 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -192,8 +192,8 @@ export default { 'obrir la documentació completa de Qwen Code al navegador', 'Configuration not available.': 'Configuració no disponible.', 'Connect an LLM provider': 'Connectar un proveïdor LLM', - 'Copy the last result or code snippet to clipboard': - "Copiar l'últim resultat o fragment de codi al porta-retalls", + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + "Copia l'última resposta de la IA al porta-retalls (/copy N per a l'N-èsima)", // ============================================================================ // Ordres - Agents diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f3fe5762866..fc4089f65b3 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -171,8 +171,8 @@ export default { 'Vollständige Qwen Code Dokumentation im Browser öffnen', 'Configuration not available.': 'Konfiguration nicht verfügbar.', 'Connect an LLM provider': 'LLM-Anbieter verbinden', - 'Copy the last result or code snippet to clipboard': - 'Letztes Ergebnis oder Codeausschnitt in die Zwischenablage kopieren', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + 'Letzte KI-Antwort in die Zwischenablage kopieren (/copy N für die N-letzte)', // ============================================================================ // Commands - Agents diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5776d269ae8..5639ec50ae6 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -194,8 +194,8 @@ export default { 'open full Qwen Code documentation in your browser', 'Configuration not available.': 'Configuration not available.', 'Connect an LLM provider': 'Connect an LLM provider', - 'Copy the last result or code snippet to clipboard': - 'Copy the last result or code snippet to clipboard', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + 'Copy the last AI response to clipboard (/copy N for Nth-latest)', 'Show working-tree change stats versus HEAD': 'Show working-tree change stats versus HEAD', 'Could not determine current working directory.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index bdbf54f014b..39c67b412c4 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -192,8 +192,8 @@ export default { 'ouvrir la documentation complète de Qwen Code dans votre navigateur', 'Configuration not available.': 'Configuration non disponible.', 'Connect an LLM provider': 'Se connecter à un fournisseur LLM', - 'Copy the last result or code snippet to clipboard': - 'Copier le dernier résultat ou extrait de code dans le presse-papiers', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + 'Copier la dernière réponse IA dans le presse-papiers (/copy N pour la Nième)', // ============================================================================ // Commandes - Agents diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 88e6bbd4160..3a934916eb8 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -149,8 +149,8 @@ export default { 'ブラウザで Qwen Code のドキュメントを開く', 'Configuration not available.': '設定が利用できません', 'Connect an LLM provider': 'LLM プロバイダーに接続', - 'Copy the last result or code snippet to clipboard': - '最後の結果またはコードスニペットをクリップボードにコピー', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + '最新のAI応答をクリップボードにコピー(/copy N で新しい方からN番目)', // ============================================================================ // Commands - Agents diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 13b750caa6d..a8ebaa54591 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -185,8 +185,8 @@ export default { 'abrir documentação completa do Qwen Code no seu navegador', 'Configuration not available.': 'Configuração não disponível.', 'Connect an LLM provider': 'Conectar a um provedor LLM', - 'Copy the last result or code snippet to clipboard': - 'Copiar o último resultado ou trecho de código para a área de transferência', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + 'Copiar a última resposta da IA para a área de transferência (/copy N para a N-ésima)', // ============================================================================ // Commands - Agents diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 38691c97ced..e1b09a3dfa2 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -194,8 +194,8 @@ export default { 'Открытие полной документации Qwen Code в браузере', 'Configuration not available.': 'Конфигурация недоступна.', 'Connect an LLM provider': 'Подключить провайдера LLM', - 'Copy the last result or code snippet to clipboard': - 'Копирование последнего результата или фрагмента кода в буфер обмена', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + 'Копировать последний ответ ИИ в буфер обмена (/copy N для N-го с конца)', // ============================================================================ // Команды - Агенты diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 32191f9588f..c9a566bd8dd 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -174,8 +174,8 @@ export default { '在瀏覽器中打開完整的 Qwen Code 文檔', 'Configuration not available.': '配置不可用', 'Connect an LLM provider': '連接 LLM 提供商', - 'Copy the last result or code snippet to clipboard': - '將最後的結果或代碼片段複製到剪貼板', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + '將最近的 AI 回應複製到剪貼簿(/copy N 複製倒數第 N 則)', 'Show working-tree change stats versus HEAD': '顯示工作區相對 HEAD 的變更統計', 'Could not determine current working directory.': '無法確定當前工作目錄。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 84d16ab892b..735a5ae0185 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -185,8 +185,8 @@ export default { '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', 'Connect an LLM provider': '连接 LLM 提供商', - 'Copy the last result or code snippet to clipboard': - '将最后的结果或代码片段复制到剪贴板', + 'Copy the last AI response to clipboard (/copy N for Nth-latest)': + '将最近的 AI 回复复制到剪贴板(/copy N 复制倒数第 N 条)', 'Show working-tree change stats versus HEAD': '显示工作区相对 HEAD 的变更统计', 'Could not determine current working directory.': '无法确定当前工作目录。', diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index aee08e85374..5c41dfa28b8 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -733,6 +733,292 @@ describe('copyCommand', () => { expect(mockCopyToClipboard).not.toHaveBeenCalled(); }); + it('should copy the Nth-last AI message with /copy N', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { role: 'model', parts: [{ text: 'oldest AI reply' }] }, + { role: 'user', parts: [{ text: 'user 1' }] }, + { role: 'model', parts: [{ text: 'middle AI reply' }] }, + { role: 'user', parts: [{ text: 'user 2' }] }, + { role: 'model', parts: [{ text: 'newest AI reply' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, '2'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('middle AI reply'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'AI message 2 copied to the clipboard', + }); + }); + + it('should label the error with AI message N when /copy N has no text', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { role: 'model', parts: [{ image: 'base64data' }] }, + { role: 'user', parts: [{ text: 'user' }] }, + { role: 'model', parts: [{ text: 'newest reply with text' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + + const result = await copyCommand.action(mockContext, '2'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'AI message 2 contains no text to copy.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should label the error with AI message N when /copy N misses', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { role: 'model', parts: [{ text: 'no code blocks here' }] }, + { role: 'user', parts: [{ text: 'user' }] }, + { role: 'model', parts: [{ text: 'newest reply' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + + const result = await copyCommand.action(mockContext, '2 code'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No matching code block found in AI message 2.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should treat /copy 1 the same as /copy (last AI message)', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { role: 'model', parts: [{ text: 'earlier reply' }] }, + { role: 'model', parts: [{ text: 'latest reply' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, '1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('latest reply'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', + }); + }); + + it('should combine /copy N with a code sub-selector', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { + role: 'model', + parts: [ + { + text: [ + '```python', + 'print("from older")', + '```', + '```js', + 'console.log("from older js")', + '```', + ].join('\n'), + }, + ], + }, + { role: 'user', parts: [{ text: 'newer prompt' }] }, + { + role: 'model', + parts: [{ text: 'newer reply (no code)' }], + }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, '2 code python'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('print("from older")'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'python code block 1 copied to the clipboard', + }); + }); + + it('should resolve /copy N code M to the Mth lang block in Nth-last message', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { + role: 'model', + parts: [ + { + text: [ + '```python', + 'first_in_oldest = 1', + '```', + '```python', + 'second_in_oldest = 2', + '```', + ].join('\n'), + }, + ], + }, + { role: 'user', parts: [{ text: 'next' }] }, + { + role: 'model', + parts: [ + { + text: ['```python', 'middle_only = 1', '```'].join('\n'), + }, + ], + }, + { role: 'user', parts: [{ text: 'and then' }] }, + { role: 'model', parts: [{ text: 'newest plain reply' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, '3 code python 2'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('second_in_oldest = 2'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'python code block 2 copied to the clipboard', + }); + }); + + it('should combine /copy N with a latex sub-selector', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + const history = [ + { + role: 'model', + parts: [ + { + text: ['$$', '\\alpha + \\beta', '$$'].join('\n'), + }, + ], + }, + { role: 'user', parts: [{ text: 'next prompt' }] }, + { role: 'model', parts: [{ text: 'plain newer reply' }] }, + ]; + + mockGetHistoryShallow.mockReturnValue(history); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, '2 latex'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('\\alpha + \\beta'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'LaTeX block 1 copied to the clipboard', + }); + }); + + it('should reject /copy 0 with a friendly error', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistoryShallow.mockReturnValue([ + { role: 'model', parts: [{ text: 'reply' }] }, + ]); + + const result = await copyCommand.action(mockContext, '0'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Message index must be a positive integer (1 = last AI message).', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should report when /copy N exceeds the AI message count', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistoryShallow.mockReturnValue([ + { role: 'model', parts: [{ text: 'only reply' }] }, + ]); + + const result = await copyCommand.action(mockContext, '5'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Only 1 AI message in this session.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should pluralize the out-of-range message when multiple AI messages exist', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistoryShallow.mockReturnValue([ + { role: 'model', parts: [{ text: 'first' }] }, + { role: 'model', parts: [{ text: 'second' }] }, + { role: 'model', parts: [{ text: 'third' }] }, + ]); + + const result = await copyCommand.action(mockContext, '99'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Only 3 AI messages in this session.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + + it('should preserve /copy code N as a code-block index, not message index', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistoryShallow.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```python', + 'first = 1', + '```', + '```python', + 'second = 2', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'code python 2'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('second = 2'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'python code block 2 copied to the clipboard', + }); + }); + it('should handle unavailable config service', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index cbf214ae695..d2725b7303b 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -337,50 +337,103 @@ function formatCodeBlockLabel( return `Code block ${block.index}`; } +function parseLeadingMessageIndex(args: string): { + messageIndex: number | null; + subArgs: string; +} { + const trimmed = args.trim(); + if (!trimmed) return { messageIndex: null, subArgs: '' }; + + const firstWhitespace = trimmed.search(/\s/); + const firstToken = + firstWhitespace === -1 ? trimmed : trimmed.slice(0, firstWhitespace); + + if (!/^\d+$/.test(firstToken)) { + return { messageIndex: null, subArgs: args }; + } + + return { + messageIndex: Number(firstToken), + subArgs: firstWhitespace === -1 ? '' : trimmed.slice(firstWhitespace + 1), + }; +} + export const copyCommand: SlashCommand = { name: 'copy', get description() { - return t('Copy the last result or code snippet to clipboard'); + return t('Copy the last AI response to clipboard (/copy N for Nth-latest)'); }, + argumentHint: '[N]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, - action: async (context, _args): Promise => { + action: async (context, args): Promise => { const chat = await context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistoryShallow(); + const aiMessages = history?.filter((item) => item.role === 'model') ?? []; - // Get the last message from the AI (model role) - const lastAiMessage = history - ? history.filter((item) => item.role === 'model').pop() - : undefined; - - if (!lastAiMessage) { + if (aiMessages.length === 0) { return { type: 'message', messageType: 'info', content: 'No output in history', }; } + + const { messageIndex, subArgs } = parseLeadingMessageIndex(args); + + let selectedAiMessage; + if (messageIndex !== null) { + if (messageIndex < 1) { + return { + type: 'message', + messageType: 'info', + content: + 'Message index must be a positive integer (1 = last AI message).', + }; + } + if (messageIndex > aiMessages.length) { + const turnLabel = + aiMessages.length === 1 ? 'AI message' : 'AI messages'; + return { + type: 'message', + messageType: 'info', + content: `Only ${aiMessages.length} ${turnLabel} in this session.`, + }; + } + selectedAiMessage = aiMessages[aiMessages.length - messageIndex]; + } else { + selectedAiMessage = aiMessages[aiMessages.length - 1]; + } + + const isIndexed = messageIndex !== null && messageIndex > 1; + const sourceLabel = isIndexed + ? `AI message ${messageIndex}` + : 'the last AI output'; + const sourceLabelCapitalized = isIndexed + ? `AI message ${messageIndex}` + : 'Last AI output'; + // Extract text from the parts - const lastAiOutput = lastAiMessage.parts + const aiOutput = selectedAiMessage.parts ?.filter((part) => part.text && !part.thought) .map((part) => part.text) .join(''); - if (lastAiOutput) { + if (aiOutput) { try { - const selectedLatexBlock = selectLatexBlock(lastAiOutput, _args); + const selectedLatexBlock = selectLatexBlock(aiOutput, subArgs); if (selectedLatexBlock === null) { return { type: 'message', messageType: 'info', content: - _args + subArgs .trim() .split(/\s+/) .some((token) => token === 'inline') || - _args.trim().toLowerCase().startsWith('inline-latex') - ? 'No matching inline LaTeX expression found in the last AI output.' - : 'No matching LaTeX block found in the last AI output.', + subArgs.trim().toLowerCase().startsWith('inline-latex') + ? `No matching inline LaTeX expression found in ${sourceLabel}.` + : `No matching LaTeX block found in ${sourceLabel}.`, }; } if (selectedLatexBlock !== undefined) { @@ -397,16 +450,16 @@ export const copyCommand: SlashCommand = { }; } - const selectedCodeBlock = selectCodeBlock(lastAiOutput, _args); + const selectedCodeBlock = selectCodeBlock(aiOutput, subArgs); if (selectedCodeBlock === null) { return { type: 'message', messageType: 'info', - content: 'No matching code block found in the last AI output.', + content: `No matching code block found in ${sourceLabel}.`, }; } - const copiedText = selectedCodeBlock?.block.content ?? lastAiOutput; + const copiedText = selectedCodeBlock?.block.content ?? aiOutput; await copyToClipboard(copiedText); return { @@ -414,7 +467,9 @@ export const copyCommand: SlashCommand = { messageType: 'info', content: selectedCodeBlock ? `${selectedCodeBlock.label} copied to the clipboard` - : 'Last output copied to the clipboard', + : isIndexed + ? `AI message ${messageIndex} copied to the clipboard` + : 'Last output copied to the clipboard', }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -430,7 +485,7 @@ export const copyCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'Last AI output contains no text to copy.', + content: `${sourceLabelCapitalized} contains no text to copy.`, }; } },