diff --git a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts index 71520fa1ec..aa7001a3d2 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -105,6 +105,7 @@ declare module 'vscode' { isComplete?: boolean; toolSpecificData?: ChatTerminalToolInvocationData; fromSubAgent?: boolean; + presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; constructor(toolName: string, toolCallId: string, isError?: boolean); } diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index bd4e624430..772fc387b9 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -95,6 +95,11 @@ declare module 'vscode' { */ description?: string | MarkdownString; + /** + * An optional badge that provides additional context about the chat session. + */ + badge?: string | MarkdownString; + /** * An optional status indicating the current state of the session. */ diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index 9dd9002c84..2b0135f05e 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -2877,15 +2877,157 @@ const ownedByMe: AsyncPredicate = async repo => { export const byRemoteName = (name: string): Predicate => ({ remote: { remoteName } }) => remoteName === name; +/** + * Unwraps lines that were wrapped for conventional commit message formatting (typically at 72 characters). + * Similar to GitHub's behavior when converting commit messages to PR descriptions. + * + * Rules: + * - Preserves blank lines as paragraph breaks + * - Preserves fenced code blocks (```) + * - Preserves list items (-, *, +, numbered) + * - Preserves blockquotes (>) + * - Preserves indented code blocks (4+ spaces at start, when not in a list context) + * - Joins consecutive plain text lines that appear to be wrapped mid-sentence + */ +function unwrapCommitMessageBody(body: string): string { + if (!body) { + return body; + } + + // Pattern to detect list item markers at the start of a line + const LIST_ITEM_PATTERN = /^[ \t]*([*+\-]|\d+\.)\s/; + // Pattern to detect blockquote markers + const BLOCKQUOTE_PATTERN = /^[ \t]*>/; + // Pattern to detect fenced code block markers + const FENCE_PATTERN = /^[ \t]*```/; + + const lines = body.split('\n'); + const result: string[] = []; + let i = 0; + let inFencedBlock = false; + let inListContext = false; + + while (i < lines.length) { + const line = lines[i]; + + // Preserve blank lines + if (line.trim() === '') { + result.push(line); + i++; + inListContext = false; // Reset list context on blank line + continue; + } + + // Check for fenced code block markers + if (FENCE_PATTERN.test(line)) { + inFencedBlock = !inFencedBlock; + result.push(line); + i++; + continue; + } + + // Preserve everything inside fenced code blocks + if (inFencedBlock) { + result.push(line); + i++; + continue; + } + + // Check if this line is a list item + const isListItem = LIST_ITEM_PATTERN.test(line); + + // Check if this line is a blockquote + const isBlockquote = BLOCKQUOTE_PATTERN.test(line); + + // Check if this line is indented (4+ spaces) but NOT a list continuation + // List continuations have leading spaces but we're in list context + const leadingSpaces = line.match(/^[ \t]*/)?.[0].length || 0; + const isIndentedCode = leadingSpaces >= 4 && !inListContext; + + // Determine if this line should be preserved (not joined) + const shouldPreserveLine = isListItem || isBlockquote || isIndentedCode; + + if (shouldPreserveLine) { + result.push(line); + i++; + // If this is a list item, we're now in list context + if (isListItem) { + inListContext = true; + } + continue; + } + + // If we have leading spaces but we're in a list context, this is a list continuation + // We should preserve it to maintain list formatting + if (inListContext && leadingSpaces >= 2) { + result.push(line); + i++; + continue; + } + + // Start accumulating lines that should be joined (plain text) + let joinedLine = line; + i++; + + // Keep joining lines until we hit a blank line or a line that shouldn't be joined + while (i < lines.length) { + const nextLine = lines[i]; + + // Stop at blank lines + if (nextLine.trim() === '') { + break; + } + + // Stop at fenced code blocks + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + // Stop at list items + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + // Stop at blockquotes + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + // Check if next line is indented code (4+ spaces, not in list context) + const nextLeadingSpaces = nextLine.match(/^[ \t]*/)?.[0].length || 0; + const nextIsIndentedCode = nextLeadingSpaces >= 4 && !inListContext; + + if (nextIsIndentedCode) { + break; + } + + // If in list context and next line is indented, it's a list continuation + if (inListContext && nextLeadingSpaces >= 2) { + break; + } + + // Join this line with a space + joinedLine += ' ' + nextLine; + i++; + } + + result.push(joinedLine); + } + + return result.join('\n'); +} + export const titleAndBodyFrom = async (promise: Promise): Promise<{ title: string; body: string } | undefined> => { const message = await promise; if (!message) { return; } const idxLineBreak = message.indexOf('\n'); + const hasBody = idxLineBreak !== -1; + const rawBody = hasBody ? message.slice(idxLineBreak + 1).trim() : ''; return { - title: idxLineBreak === -1 ? message : message.substr(0, idxLineBreak), + title: hasBody ? message.slice(0, idxLineBreak) : message, - body: idxLineBreak === -1 ? '' : message.slice(idxLineBreak + 1).trim(), + body: unwrapCommitMessageBody(rawBody), }; }; diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index ad1cbff0f6..574a69a32e 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -94,4 +94,132 @@ describe('titleAndBodyFrom', function () { assert.strictEqual(result?.title, 'title'); assert.strictEqual(result?.body, ''); }); + + it('unwraps wrapped lines in body', async function () { + const message = Promise.resolve('title\n\nThis is a long line that has been wrapped at 72 characters\nto fit the conventional commit message format.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a long line that has been wrapped at 72 characters to fit the conventional commit message format.'); + }); + + it('preserves blank lines as paragraph breaks', async function () { + const message = Promise.resolve('title\n\nFirst paragraph that is wrapped\nacross multiple lines.\n\nSecond paragraph that is also wrapped\nacross multiple lines.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'First paragraph that is wrapped across multiple lines.\n\nSecond paragraph that is also wrapped across multiple lines.'); + }); + + it('preserves list items', async function () { + const message = Promise.resolve('title\n\n- First item\n- Second item\n- Third item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- First item\n- Second item\n- Third item'); + }); + + it('preserves numbered list items', async function () { + const message = Promise.resolve('title\n\n1. First item\n2. Second item\n3. Third item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. First item\n2. Second item\n3. Third item'); + }); + + it('preserves indented lines', async function () { + const message = Promise.resolve('title\n\nNormal paragraph.\n\n Indented code block\n More code'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Normal paragraph.\n\n Indented code block\n More code'); + }); + + it('unwraps but preserves asterisk list items', async function () { + const message = Promise.resolve('title\n\n* First item\n* Second item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* First item\n* Second item'); + }); + + it('handles mixed content with wrapped paragraphs and lists', async function () { + const message = Promise.resolve('title\n\nThis is a paragraph that has been wrapped\nat 72 characters.\n\n- Item 1\n- Item 2\n\nAnother wrapped paragraph\nthat continues here.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a paragraph that has been wrapped at 72 characters.\n\n- Item 1\n- Item 2\n\nAnother wrapped paragraph that continues here.'); + }); + + it('preserves lines with special characters at the start', async function () { + const message = Promise.resolve('title\n\n> Quote line 1\n> Quote line 2'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '> Quote line 1\n> Quote line 2'); + }); + + it('handles wrapped lines with punctuation', async function () { + const message = Promise.resolve('title\n\nThis is a sentence.\nThis is another sentence on a new line.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is a sentence. This is another sentence on a new line.'); + }); + + it('preserves fenced code blocks', async function () { + const message = Promise.resolve('title\n\nSome text before.\n\n```\ncode line 1\ncode line 2\n```\n\nSome text after.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some text before.\n\n```\ncode line 1\ncode line 2\n```\n\nSome text after.'); + }); + + it('preserves fenced code blocks with language', async function () { + const message = Promise.resolve('title\n\nSome text.\n\n```javascript\nconst x = 1;\nconst y = 2;\n```\n\nMore text.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Some text.\n\n```javascript\nconst x = 1;\nconst y = 2;\n```\n\nMore text.'); + }); + + it('preserves nested list items with proper indentation', async function () { + const message = Promise.resolve('title\n\n- Item 1\n - Nested item 1.1\n - Nested item 1.2\n- Item 2'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- Item 1\n - Nested item 1.1\n - Nested item 1.2\n- Item 2'); + }); + + it('preserves list item continuations', async function () { + const message = Promise.resolve('title\n\n- This is a list item that is long\n and continues on the next line\n- Second item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '- This is a list item that is long\n and continues on the next line\n- Second item'); + }); + + it('preserves indented code blocks but not list continuations', async function () { + const message = Promise.resolve('title\n\nRegular paragraph.\n\n This is code\n More code\n\nAnother paragraph.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Regular paragraph.\n\n This is code\n More code\n\nAnother paragraph.'); + }); + + it('unwraps regular text but preserves list item continuations', async function () { + const message = Promise.resolve('title\n\nThis is wrapped text\nthat should be joined.\n\n- List item with\n continuation\n- Another item'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'This is wrapped text that should be joined.\n\n- List item with\n continuation\n- Another item'); + }); + + it('handles complex nested lists with wrapped paragraphs', async function () { + const message = Promise.resolve('title\n\nWrapped paragraph\nacross lines.\n\n- Item 1\n - Nested item\n More nested content\n- Item 2\n\nAnother wrapped paragraph\nhere.'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, 'Wrapped paragraph across lines.\n\n- Item 1\n - Nested item\n More nested content\n- Item 2\n\nAnother wrapped paragraph here.'); + }); });