diff --git a/src/@types/vscode.proposed.chatContextProvider.d.ts b/src/@types/vscode.proposed.chatContextProvider.d.ts index 47a9284099..b1f3ea5806 100644 --- a/src/@types/vscode.proposed.chatContextProvider.d.ts +++ b/src/@types/vscode.proposed.chatContextProvider.d.ts @@ -11,16 +11,45 @@ declare module 'vscode' { export namespace chat { /** - * Register a chat context provider. Chat context can be provided: - * - For a resource. Make sure to pass a selector that matches the resource you want to provide context for. - * Providers registered without a selector will not be called for resource-based context. - * - Explicitly. These context items are shown as options when the user explicitly attaches context. + * Register a chat workspace context provider. Workspace context is automatically included in all chat requests. * * To ensure your extension is activated when chat context is requested, make sure to include the following activations events: * - If your extension implements `provideWorkspaceChatContext` or `provideChatContextForResource`, find an activation event which is a good signal to activate. * Ex: `onLanguage:`, `onWebviewPanel:`, etc.` * - If your extension implements `provideChatContextExplicit`, your extension will be automatically activated when the user requests explicit context. * + * @param id Unique identifier for the provider. + * @param provider The chat workspace context provider. + */ + export function registerChatWorkspaceContextProvider(id: string, provider: ChatWorkspaceContextProvider): Disposable; + + /** + * Register a chat explicit context provider. Explicit context items are shown as options when the user explicitly attaches context. + * + * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. + * + * @param id Unique identifier for the provider. + * @param provider The chat explicit context provider. + */ + export function registerChatExplicitContextProvider(id: string, provider: ChatExplicitContextProvider): Disposable; + + /** + * Register a chat resource context provider. Resource context is provided for a specific resource. + * Make sure to pass a selector that matches the resource you want to provide context for. + * + * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. + * + * @param selector Document selector to filter which resources the provider is called for. + * @param id Unique identifier for the provider. + * @param provider The chat resource context provider. + */ + export function registerChatResourceContextProvider(selector: DocumentSelector, id: string, provider: ChatResourceContextProvider): Disposable; + + /** + * Register a chat context provider. + * + * @deprecated Use {@link registerChatWorkspaceContextProvider}, {@link registerChatExplicitContextProvider}, or {@link registerChatResourceContextProvider} instead. + * * @param selector Optional document selector to filter which resources the provider is called for. If omitted, the provider will only be called for explicit context requests. * @param id Unique identifier for the provider. * @param provider The chat context provider. @@ -57,7 +86,7 @@ declare module 'vscode' { command?: Command; } - export interface ChatContextProvider { + export interface ChatWorkspaceContextProvider { /** * An optional event that should be fired when the workspace chat context has changed. @@ -65,15 +94,16 @@ declare module 'vscode' { onDidChangeWorkspaceChatContext?: Event; /** - * TODO @API: should this be a separate provider interface? - * * Provide a list of chat context items to be included as workspace context for all chat requests. * This should be used very sparingly to avoid providing useless context and to avoid using up the context window. * A good example use case is to provide information about which branch the user is working on in a source control context. * * @param token A cancellation token. */ - provideWorkspaceChatContext?(token: CancellationToken): ProviderResult; + provideChatContext(token: CancellationToken): ProviderResult; + } + + export interface ChatExplicitContextProvider { /** * Provide a list of chat context items that a user can choose from. These context items are shown as options when the user explicitly attaches context. @@ -82,7 +112,18 @@ declare module 'vscode' { * * @param token A cancellation token. */ - provideChatContextExplicit?(token: CancellationToken): ProviderResult; + provideChatContext(token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. + * + * @param context The context item to resolve. + * @param token A cancellation token. + */ + resolveChatContext(context: T, token: CancellationToken): ProviderResult; + } + + export interface ChatResourceContextProvider { /** * Given a particular resource, provide a chat context item for it. This is used for implicit context (see the settings `chat.implicitContext.enabled` and `chat.implicitContext.suggestedContext`). @@ -94,10 +135,10 @@ declare module 'vscode' { * @param options Options include the resource for which to provide context. * @param token A cancellation token. */ - provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult; + provideChatContext(options: { resource: Uri }, token: CancellationToken): ProviderResult; /** - * If a chat context item is provided without a `value`, from either of the `provide` methods, this method is called to resolve the `value` for the item. + * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. * * @param context The context item to resolve. * @param token A cancellation token. @@ -105,4 +146,40 @@ declare module 'vscode' { resolveChatContext(context: T, token: CancellationToken): ProviderResult; } + /** + * @deprecated Use {@link ChatWorkspaceContextProvider}, {@link ChatExplicitContextProvider}, or {@link ChatResourceContextProvider} instead. + */ + export interface ChatContextProvider { + + /** + * An optional event that should be fired when the workspace chat context has changed. + * @deprecated Use {@link ChatWorkspaceContextProvider.onDidChangeWorkspaceChatContext} instead. + */ + onDidChangeWorkspaceChatContext?: Event; + + /** + * Provide a list of chat context items to be included as workspace context for all chat requests. + * @deprecated Use {@link ChatWorkspaceContextProvider.provideChatContext} instead. + */ + provideWorkspaceChatContext?(token: CancellationToken): ProviderResult; + + /** + * Provide a list of chat context items that a user can choose from. + * @deprecated Use {@link ChatExplicitContextProvider.provideChatContext} instead. + */ + provideChatContextExplicit?(token: CancellationToken): ProviderResult; + + /** + * Given a particular resource, provide a chat context item for it. + * @deprecated Use {@link ChatResourceContextProvider.provideChatContext} instead. + */ + provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult; + + /** + * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. + * @deprecated Use the `resolveChatContext` method on the specific provider type instead. + */ + resolveChatContext?(context: T, token: CancellationToken): ProviderResult; + } + } diff --git a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts index 8ef3f9335e..1a81d83783 100644 --- a/src/@types/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/@types/vscode.proposed.chatParticipantAdditions.d.ts @@ -96,6 +96,108 @@ declare module 'vscode' { constructor(title: string, message: string | MarkdownString, data: any, buttons?: string[]); } + /** + * An option for a question in a carousel. + */ + export interface ChatQuestionOption { + /** + * Unique identifier for the option. + */ + id: string; + /** + * The display label for the option. + */ + label: string; + /** + * The value returned when this option is selected. + */ + value: unknown; + } + + /** + * The type of question for a chat question carousel. + */ + export enum ChatQuestionType { + /** + * A free-form text input question. + */ + Text = 1, + /** + * A single-select question with radio buttons. + */ + SingleSelect = 2, + /** + * A multi-select question with checkboxes. + */ + MultiSelect = 3 + } + + /** + * A question to be displayed in a question carousel. + */ + export class ChatQuestion { + /** + * Unique identifier for the question. + */ + id: string; + /** + * The type of question: Text for free-form input, SingleSelect for radio buttons, MultiSelect for checkboxes. + */ + type: ChatQuestionType; + /** + * The title/header of the question. + */ + title: string; + /** + * Optional detailed message or description for the question. + */ + message?: string | MarkdownString; + /** + * Options for singleSelect or multiSelect questions. + */ + options?: ChatQuestionOption[]; + /** + * The id(s) of the default selected option(s). + * For SingleSelect, this should be a single option id. + * For MultiSelect, this can be an array of option ids. + */ + defaultValue?: string | string[]; + /** + * Whether to allow free-form text input in addition to predefined options. + * When true, users can provide their own text answer even for SingleSelect or MultiSelect questions. + */ + allowFreeformInput?: boolean; + + constructor( + id: string, + type: ChatQuestionType, + title: string, + options?: { + message?: string | MarkdownString; + options?: ChatQuestionOption[]; + defaultValue?: string | string[]; + allowFreeformInput?: boolean; + } + ); + } + + /** + * A carousel view for presenting multiple questions inline in the chat. + * The UI is displayed but does not block the chat input. + */ + export class ChatResponseQuestionCarouselPart { + /** + * The questions to display in the carousel. + */ + questions: ChatQuestion[]; + /** + * Whether users can skip answering the questions. + */ + allowSkip: boolean; + + constructor(questions: ChatQuestion[], allowSkip?: boolean); + } + export class ChatResponseCodeCitationPart { value: Uri; license: string; @@ -244,7 +346,7 @@ declare module 'vscode' { constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart; + export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart | ChatResponseQuestionCarouselPart; export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -408,6 +510,15 @@ declare module 'vscode' { */ confirmation(title: string, message: string | MarkdownString, data: any, buttons?: string[]): void; + /** + * Show an inline carousel of questions to gather information from the user. + * This is a blocking call that waits for the user to submit or skip the questions. + * @param questions Array of questions to display to the user + * @param allowSkip Whether the user can skip questions without answering + * @returns A promise that resolves with the user's answers, or undefined if skipped + */ + questionCarousel(questions: ChatQuestion[], allowSkip?: boolean): Thenable | undefined>; + /** * Push a warning to this stream. Short-hand for * `push(new ChatResponseWarningPart(message))`. diff --git a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts index 4ab722c122..bbdff837f5 100644 --- a/src/@types/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/@types/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 11 +// version: 12 declare module 'vscode' { diff --git a/src/@types/vscode.proposed.chatSessionsProvider.d.ts b/src/@types/vscode.proposed.chatSessionsProvider.d.ts index 84cd547599..2025d2a66e 100644 --- a/src/@types/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/@types/vscode.proposed.chatSessionsProvider.d.ts @@ -42,7 +42,7 @@ declare module 'vscode' { /** * Creates a new {@link ChatSessionItemController chat session item controller} with the given unique identifier. */ - export function createChatSessionItemController(id: string, refreshHandler: () => Thenable): ChatSessionItemController; + export function createChatSessionItemController(id: string, refreshHandler: (token: CancellationToken) => Thenable): ChatSessionItemController; } /** @@ -97,7 +97,7 @@ declare module 'vscode' { * * This is also called on first load to get the initial set of items. */ - refreshHandler: () => Thenable; + refreshHandler: (token: CancellationToken) => Thenable; /** * Fired when an item's archived state changes. @@ -231,22 +231,7 @@ declare module 'vscode' { /** * Statistics about the chat session. */ - changes?: readonly ChatSessionChangedFile[] | readonly ChatSessionChangedFile2[] | { - /** - * Number of files edited during the session. - */ - files: number; - - /** - * Number of insertions made during the session. - */ - insertions: number; - - /** - * Number of deletions made during the session. - */ - deletions: number; - }; + changes?: readonly ChatSessionChangedFile[] | readonly ChatSessionChangedFile2[]; } export class ChatSessionChangedFile { diff --git a/src/github/folderRepositoryManager.ts b/src/github/folderRepositoryManager.ts index dac84ca592..9bf04e0fe0 100644 --- a/src/github/folderRepositoryManager.ts +++ b/src/github/folderRepositoryManager.ts @@ -3067,8 +3067,8 @@ function unwrapCommitMessageBody(body: string): string { return body; } - // Pattern to detect list item markers at the start of a line - const LIST_ITEM_PATTERN = /^[ \t]*([*+\-]|\d+\.)\s/; + // Pattern to detect list item markers at the start of a line and capture the marker + const LIST_ITEM_PATTERN = /^(?[ \t]*)(?[*+\-]|\d+\.)(?[ \t]+)/; // Pattern to detect blockquote markers const BLOCKQUOTE_PATTERN = /^[ \t]*>/; // Pattern to detect fenced code block markers @@ -3083,11 +3083,22 @@ function unwrapCommitMessageBody(body: string): string { return base.length > 0 && !/\s$/.test(base) ? `${base} ${addition}` : `${base}${addition}`; }; + // Get the content indent for a list item (position where actual content starts) + const getListItemContentIndent = (line: string): number => { + const match = line.match(LIST_ITEM_PATTERN); + if (!match?.groups) { + return 0; + } + // Content indent = leading whitespace + marker + space after marker + return match.groups.leadingWhitespace.length + match.groups.marker.length + match.groups.markerTrailingWhitespace.length; + }; + const lines = body.split('\n'); const result: string[] = []; let i = 0; let inFencedBlock = false; - const listIndentStack: number[] = []; + // Stack stores { markerIndent, contentIndent } for each nesting level + const listStack: { markerIndent: number; contentIndent: number }[] = []; const getNextNonBlankLineInfo = ( startIndex: number, @@ -3106,19 +3117,23 @@ function unwrapCommitMessageBody(body: string): string { return undefined; }; - const getActiveListIndent = (lineIndent: number): number | undefined => { - for (let idx = listIndentStack.length - 1; idx >= 0; idx--) { - const indentForLevel = listIndentStack[idx]; - if (lineIndent >= indentForLevel + 2) { - listIndentStack.length = idx + 1; - return indentForLevel; + // Find the active list context for a given line indent + // Returns the content indent if the line is within an active list context + const getActiveListContentIndent = (lineIndent: number): number | undefined => { + for (let idx = listStack.length - 1; idx >= 0; idx--) { + const { markerIndent, contentIndent } = listStack[idx]; + // A line is part of a list item if it has at least 1 space indent + // (but less than contentIndent + 4 which would be a code block) + if (lineIndent >= 1 && lineIndent >= markerIndent) { + listStack.length = idx + 1; + return contentIndent; } - listIndentStack.pop(); + listStack.pop(); } return undefined; }; - const shouldJoinListContinuation = (lineIndex: number, activeIndent: number, baseLine: string): boolean => { + const shouldJoinListContinuation = (lineIndex: number, contentIndent: number, baseLine: string): boolean => { const currentLine = lines[lineIndex]; if (!currentLine) { return false; @@ -3142,12 +3157,13 @@ function unwrapCommitMessageBody(body: string): string { } const currentIndent = getLeadingWhitespaceLength(currentLine); - if (currentIndent < activeIndent + 2) { + // Need at least 1 space to be a continuation + if (currentIndent < 1) { return false; } - // Treat indented code blocks (4+ spaces beyond the bullet) as preserve-only. - if (currentIndent >= activeIndent + 4) { + // 4+ spaces beyond content indent is an indented code block + if (currentIndent >= contentIndent + 4) { return false; } @@ -3156,8 +3172,12 @@ function unwrapCommitMessageBody(body: string): string { return true; } - if (nextInfo.isListItem && nextInfo.indent <= activeIndent) { - return false; + // If next line is a list item at or before the current list level, don't join + if (nextInfo.isListItem) { + const currentListLevel = listStack.length > 0 ? listStack[listStack.length - 1].markerIndent : 0; + if (nextInfo.indent <= currentListLevel) { + return false; + } } return true; @@ -3166,11 +3186,11 @@ function unwrapCommitMessageBody(body: string): string { while (i < lines.length) { const line = lines[i]; - // Preserve blank lines + // Preserve blank lines but don't clear list context + // (multi-paragraph lists are allowed in GitHub markdown) if (line.trim() === '') { result.push(line); i++; - listIndentStack.length = 0; continue; } @@ -3190,26 +3210,25 @@ function unwrapCommitMessageBody(body: string): string { } const lineIndent = getLeadingWhitespaceLength(line); - const isListItem = LIST_ITEM_PATTERN.test(line); + const listItemMatch = line.match(LIST_ITEM_PATTERN); - if (isListItem) { - while (listIndentStack.length && lineIndent < listIndentStack[listIndentStack.length - 1]) { - listIndentStack.pop(); - } + if (listItemMatch?.groups) { + const markerIndent = listItemMatch.groups.leadingWhitespace.length; + const contentIndent = getListItemContentIndent(line); - if (!listIndentStack.length || lineIndent > listIndentStack[listIndentStack.length - 1]) { - listIndentStack.push(lineIndent); - } else { - listIndentStack[listIndentStack.length - 1] = lineIndent; + // Pop list levels that are at or beyond this indent + while (listStack.length && markerIndent <= listStack[listStack.length - 1].markerIndent) { + listStack.pop(); } + listStack.push({ markerIndent, contentIndent }); result.push(line); i++; continue; } - const activeListIndent = getActiveListIndent(lineIndent); - const codeIndentThreshold = activeListIndent !== undefined ? activeListIndent + 4 : 4; + const activeContentIndent = getActiveListContentIndent(lineIndent); + const codeIndentThreshold = activeContentIndent !== undefined ? activeContentIndent + 4 : 4; const isBlockquote = BLOCKQUOTE_PATTERN.test(line); const isIndentedCode = lineIndent >= codeIndentThreshold; @@ -3219,34 +3238,84 @@ function unwrapCommitMessageBody(body: string): string { continue; } - if (activeListIndent !== undefined && lineIndent >= activeListIndent + 2) { + // Handle list item continuations + if (activeContentIndent !== undefined && lineIndent >= 1) { const baseIndex = result.length - 1; - if (baseIndex >= 0) { - let baseLine = result[baseIndex]; + // Only try to join with previous line if it's not blank + // Multi-paragraph lists have blank lines that should be preserved + const baseLine = baseIndex >= 0 ? result[baseIndex] : ''; + const previousLineIsBlank = baseLine.trim() === ''; + + if (!previousLineIsBlank && baseIndex >= 0) { + let joinedLine = baseLine; let appended = false; let currentIndex = i; while ( currentIndex < lines.length && - shouldJoinListContinuation(currentIndex, activeListIndent, baseLine) + shouldJoinListContinuation(currentIndex, activeContentIndent, joinedLine) ) { const continuationText = lines[currentIndex].trim(); if (continuationText) { - baseLine = appendWithSpace(baseLine, continuationText); + joinedLine = appendWithSpace(joinedLine, continuationText); appended = true; } currentIndex++; } if (appended) { - result[baseIndex] = baseLine; + result[baseIndex] = joinedLine; i = currentIndex; continue; } } - result.push(line); + // For multi-paragraph continuations or standalone indented lines, + // preserve indentation but unwrap consecutive continuation lines + let joinedLine = line; i++; + + while (i < lines.length) { + const nextLine = lines[i]; + + if (nextLine.trim() === '') { + break; + } + + if (FENCE_PATTERN.test(nextLine)) { + break; + } + + if (LIST_ITEM_PATTERN.test(nextLine)) { + break; + } + + if (BLOCKQUOTE_PATTERN.test(nextLine)) { + break; + } + + const nextIndent = getLeadingWhitespaceLength(nextLine); + // Check for code block + if (nextIndent >= activeContentIndent + 4) { + break; + } + + // Must have at least 1 space to be a continuation + if (nextIndent < 1) { + break; + } + + // Check for hard line break + if (hasHardLineBreak(joinedLine)) { + break; + } + + // Join this line - preserve the original indentation for the first line + joinedLine = appendWithSpace(joinedLine, nextLine.trim()); + i++; + } + + result.push(joinedLine); continue; } @@ -3287,7 +3356,7 @@ function unwrapCommitMessageBody(body: string): string { } // Join this line with a space - joinedLine += ' ' + nextLine; + joinedLine = appendWithSpace(joinedLine, nextLine.trim()); i++; } diff --git a/src/test/github/folderRepositoryManager.test.ts b/src/test/github/folderRepositoryManager.test.ts index 6d6f6c3114..530ffaf9e2 100644 --- a/src/test/github/folderRepositoryManager.test.ts +++ b/src/test/github/folderRepositoryManager.test.ts @@ -230,4 +230,108 @@ describe('titleAndBodyFrom', function () { assert.strictEqual(result?.title, 'title'); assert.strictEqual(result?.body, '* This is a list item with two lines that have a line break between them\n * This is a nested list item that also has two lines that should have been merged'); }); + + it('handles basic numeric list continuation', async function () { + const message = Promise.resolve('title\n\n1. Basic numeric list\n continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Basic numeric list continuation. Third line'); + }); + + it('handles additional spaces OK for continuation', async function () { + const message = Promise.resolve('title\n\n2. Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '2. Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles asterisk list with extra spaces', async function () { + const message = Promise.resolve('title\n\n* Additional spaces are\n OK for a continuation (unless it\'s 4 spaces which would be a code block).\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Additional spaces are OK for a continuation (unless it\'s 4 spaces which would be a code block). Third line'); + }); + + it('handles multi-digit numbers (10.)', async function () { + const message = Promise.resolve('title\n\n10. Multi-digit numbers should also\n work for a continuation.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. Multi-digit numbers should also work for a continuation. Third line'); + }); + + it('handles multi-paragraph list - numbered', async function () { + const message = Promise.resolve('title\n\n11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '11. Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles multi-paragraph list - asterisk', async function () { + const message = Promise.resolve('title\n\n* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item.\n Third line'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Multi-paragraph lists are also supported.\n\n Second paragraph in the same list item. Third line'); + }); + + it('handles item with code block - numbered', async function () { + const message = Promise.resolve('title\n\n1. Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles item with code block - asterisk', async function () { + const message = Promise.resolve('title\n\n* Item with code:\n\n ```\n code line\n code line\n ```'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Item with code:\n\n ```\n code line\n code line\n ```'); + }); + + it('handles fewer spaces OK - numbered (1 space)', async function () { + const message = Promise.resolve('title\n\n1. Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles fewer spaces OK - asterisk (1 space)', async function () { + const message = Promise.resolve('title\n\n* Fewer spaces are also OK\n for a list continuation (as long as there\'s at least one space)'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Fewer spaces are also OK for a list continuation (as long as there\'s at least one space)'); + }); + + it('handles nested numbered lists', async function () { + const message = Promise.resolve('title\n\n1. First level item\n continuation of first level\n 1. Nested numbered item\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '1. First level item continuation of first level\n 1. Nested numbered item with continuation'); + }); + + it('handles nested multi-digit numbered lists', async function () { + const message = Promise.resolve('title\n\n10. First level item with\n multi-line content\n 10. Nested with multi-digit\n number and continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '10. First level item with multi-line content\n 10. Nested with multi-digit number and continuation'); + }); + + it('handles nested multi-paragraph lists', async function () { + const message = Promise.resolve('title\n\n* Outer item\n\n Second paragraph of outer\n with continuation\n * Inner item\n\n Second paragraph of inner\n with continuation'); + + const result = await titleAndBodyFrom(message); + assert.strictEqual(result?.title, 'title'); + assert.strictEqual(result?.body, '* Outer item\n\n Second paragraph of outer with continuation\n * Inner item\n\n Second paragraph of inner with continuation'); + }); });