diff --git a/packages/opencode/src/cli/cmd/tui/component/list-continuation.ts b/packages/opencode/src/cli/cmd/tui/component/list-continuation.ts new file mode 100644 index 00000000000..c856feb1e08 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/list-continuation.ts @@ -0,0 +1,153 @@ +/** + * Hook for automatic list continuation in textarea inputs. + * + * When pressing newline on a numbered list line: + * - If line has content (e.g., "1. foo"), inserts next number on new line + * - If line is empty (e.g., "1. "), clears the list marker instead + */ + +// Matches a numbered list line with content after the marker +// Examples: "1. foo", "12. bar", "3. baz" +const NUMBERED_LIST_WITH_CONTENT = /^(\d+)\.\s+\S/ + +// Matches a numbered list line with only whitespace after the marker (or nothing) +// Examples: "1. ", "2. ", "3." +const NUMBERED_LIST_EMPTY = /^(\d+)\.\s*$/ + +export type ListContinuationAction = + | { type: "continue"; insertText: string } + | { type: "clear"; deleteRange: { start: number; end: number }; cursorPosition: number } + +export type LineInfo = { + start: number + end: number + text: string +} + +/** + * Gets information about the line containing the cursor. + */ +export function getCurrentLine(text: string, cursorOffset: number): LineInfo { + // Find line start by looking backward for newline + let start = cursorOffset + while (start > 0 && text[start - 1] !== "\n") { + start-- + } + + // Find line end by looking forward for newline + let end = cursorOffset + while (end < text.length && text[end] !== "\n") { + end++ + } + + return { + start, + end, + text: text.slice(start, end), + } +} + +export type ParsedListItem = { + number: number + hasContent: boolean +} + +/** + * Parses a line to determine if it's a numbered list item. + */ +export function parseNumberedListItem(lineText: string): ParsedListItem | null { + // Check for numbered list with content + const withContent = lineText.match(NUMBERED_LIST_WITH_CONTENT) + if (withContent) { + return { + number: parseInt(withContent[1], 10), + hasContent: true, + } + } + + // Check for numbered list without content (empty item) + const empty = lineText.match(NUMBERED_LIST_EMPTY) + if (empty) { + return { + number: parseInt(empty[1], 10), + hasContent: false, + } + } + + return null +} + +/** + * Determines what action to take when newline is pressed. + * + * @param text - The full text content + * @param cursorOffset - The current cursor position + * @returns Action to perform, or null to use default newline behavior + */ +export function handleNewline(text: string, cursorOffset: number): ListContinuationAction | null { + const line = getCurrentLine(text, cursorOffset) + const parsed = parseNumberedListItem(line.text) + + // Not a numbered list - use default behavior + if (!parsed) { + return null + } + + // Only apply list continuation when cursor is at end of line + if (cursorOffset !== line.end) { + return null + } + + if (parsed.hasContent) { + // Line has content - continue the list with next number + const next = parsed.number + 1 + return { + type: "continue", + insertText: `\n${next}. `, + } + } + + // Line is empty (just the list marker) - clear the line + return { + type: "clear", + deleteRange: { start: line.start, end: line.end }, + cursorPosition: line.start, + } +} + +/** + * Removes trailing empty list items from text before submission. + * For example, "1. foo\n2. " becomes "1. foo" + * + * @param text - The full text content + * @returns Cleaned text with trailing empty list items removed + */ +export function cleanupForSubmit(text: string): string { + const lines = text.split("\n") + + // Work backwards, removing trailing empty list items + while (lines.length > 0) { + const last = lines[lines.length - 1] + const parsed = parseNumberedListItem(last) + + // If last line is an empty list item, remove it + if (parsed && !parsed.hasContent) { + lines.pop() + continue + } + + break + } + + return lines.join("\n") +} + +/** + * Hook that provides list continuation functionality for textarea inputs. + */ +export function useListContinuation() { + return { + handleNewline, + cleanupForSubmit, + } +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index e19c8b70982..d2795513c36 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" +import { useListContinuation } from "../list-continuation" export type PromptProps = { sessionID?: string @@ -86,6 +87,10 @@ export function Prompt(props: PromptProps) { } const textareaKeybindings = useTextareaKeybindings() + const listContinuation = useListContinuation() + + // Filter out newline from keybindings so we can handle it in onKeyDown with list continuation + const promptKeybindings = createMemo(() => textareaKeybindings().filter((b) => b.action !== "newline")) const fileStyleId = syntax().getStyleId("extmark.file")! const agentStyleId = syntax().getStyleId("extmark.agent")! @@ -490,7 +495,14 @@ export function Prompt(props: PromptProps) { if (props.disabled) return if (autocomplete?.visible) return if (!store.prompt.input) return - const trimmed = store.prompt.input.trim() + + // Clean up trailing empty list items before submitting + const cleaned = listContinuation.cleanupForSubmit(store.prompt.input) + if (cleaned !== store.prompt.input) { + setStore("prompt", "input", cleaned) + } + + const trimmed = cleaned.trim() if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") { exit() return @@ -507,7 +519,7 @@ export function Prompt(props: PromptProps) { return sessionID })() const messageID = Identifier.ascending("message") - let inputText = store.prompt.input + let inputText = cleaned // Expand pasted text inline before submitting const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId) @@ -782,12 +794,31 @@ export function Prompt(props: PromptProps) { autocomplete.onInput(value) syncExtmarksWithPromptParts() }} - keyBindings={textareaKeybindings()} + keyBindings={promptKeybindings()} onKeyDown={async (e) => { if (props.disabled) { e.preventDefault() return } + // Handle automatic list continuation on newline + if (keybind.match("input_newline", e)) { + e.preventDefault() + const action = listContinuation.handleNewline(input.plainText, input.cursorOffset) + if (action) { + if (action.type === "continue") { + input.insertText(action.insertText) + } else if (action.type === "clear") { + const before = input.plainText.slice(0, action.deleteRange.start) + const after = input.plainText.slice(action.deleteRange.end) + input.setText(before + after) + input.cursorOffset = action.cursorPosition + } + } else { + // No list continuation - just insert a normal newline + input.insertText("\n") + } + return + } // Handle clipboard paste (Ctrl+V) - check for images first on Windows // This is needed because Windows terminal doesn't properly send image data // through bracketed paste, so we need to intercept the keypress and diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 020e626cba8..a83fc0334aa 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -709,7 +709,7 @@ export namespace Config { input_newline: z .string() .optional() - .default("shift+return,ctrl+return,alt+return,ctrl+j") + .default("shift+return,ctrl+return,alt+return,ctrl+j,linefeed") .describe("Insert newline in input"), input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), diff --git a/packages/opencode/test/cli/tui/list-continuation.test.ts b/packages/opencode/test/cli/tui/list-continuation.test.ts new file mode 100644 index 00000000000..4366112679b --- /dev/null +++ b/packages/opencode/test/cli/tui/list-continuation.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test" +import { + getCurrentLine, + parseNumberedListItem, + handleNewline, + cleanupForSubmit, + type LineInfo, + type ParsedListItem, + type ListContinuationAction, +} from "../../../src/cli/cmd/tui/component/list-continuation" + +describe("list-continuation", () => { + describe("getCurrentLine", () => { + test("returns correct line info for single line text", () => { + const text = "hello world" + const result = getCurrentLine(text, 5) + expect(result).toEqual({ start: 0, end: 11, text: "hello world" }) + }) + + test("returns correct line info when cursor at start", () => { + const text = "hello world" + const result = getCurrentLine(text, 0) + expect(result).toEqual({ start: 0, end: 11, text: "hello world" }) + }) + + test("returns correct line info when cursor at end", () => { + const text = "hello world" + const result = getCurrentLine(text, 11) + expect(result).toEqual({ start: 0, end: 11, text: "hello world" }) + }) + + test("returns correct line for first line of multi-line text", () => { + const text = "first\nsecond\nthird" + const result = getCurrentLine(text, 3) + expect(result).toEqual({ start: 0, end: 5, text: "first" }) + }) + + test("returns correct line for middle line of multi-line text", () => { + const text = "first\nsecond\nthird" + const result = getCurrentLine(text, 8) // cursor in "second" + expect(result).toEqual({ start: 6, end: 12, text: "second" }) + }) + + test("returns correct line for last line of multi-line text", () => { + const text = "first\nsecond\nthird" + const result = getCurrentLine(text, 15) // cursor in "third" + expect(result).toEqual({ start: 13, end: 18, text: "third" }) + }) + + test("handles cursor right after newline", () => { + const text = "first\nsecond" + const result = getCurrentLine(text, 6) // cursor at start of "second" + expect(result).toEqual({ start: 6, end: 12, text: "second" }) + }) + + test("handles empty text", () => { + const text = "" + const result = getCurrentLine(text, 0) + expect(result).toEqual({ start: 0, end: 0, text: "" }) + }) + + test("handles empty line between content", () => { + const text = "first\n\nthird" + const result = getCurrentLine(text, 6) // cursor on empty line + expect(result).toEqual({ start: 6, end: 6, text: "" }) + }) + }) + + describe("parseNumberedListItem", () => { + test("parses numbered list with content", () => { + expect(parseNumberedListItem("1. foo")).toEqual({ number: 1, hasContent: true }) + }) + + test("parses multi-digit numbered list with content", () => { + expect(parseNumberedListItem("12. bar")).toEqual({ number: 12, hasContent: true }) + expect(parseNumberedListItem("100. baz")).toEqual({ number: 100, hasContent: true }) + }) + + test("parses numbered list with multiple spaces", () => { + expect(parseNumberedListItem("1. foo")).toEqual({ number: 1, hasContent: true }) + expect(parseNumberedListItem("1. foo")).toEqual({ number: 1, hasContent: true }) + }) + + test("parses empty numbered list with space", () => { + expect(parseNumberedListItem("1. ")).toEqual({ number: 1, hasContent: false }) + }) + + test("parses empty numbered list with multiple spaces", () => { + expect(parseNumberedListItem("1. ")).toEqual({ number: 1, hasContent: false }) + expect(parseNumberedListItem("2. ")).toEqual({ number: 2, hasContent: false }) + }) + + test("parses numbered list with no space after period", () => { + expect(parseNumberedListItem("1.")).toEqual({ number: 1, hasContent: false }) + }) + + test("returns null for non-list text", () => { + expect(parseNumberedListItem("foo bar")).toBeNull() + expect(parseNumberedListItem("hello world")).toBeNull() + }) + + test("returns null for text without space after period", () => { + // "1.foo" has no space, so the content regex won't match + // and the empty regex won't match because there's non-whitespace after + expect(parseNumberedListItem("1.foo")).toBeNull() + }) + + test("returns null for bullet lists", () => { + expect(parseNumberedListItem("- item")).toBeNull() + expect(parseNumberedListItem("* item")).toBeNull() + expect(parseNumberedListItem("+ item")).toBeNull() + }) + + test("returns null for lines starting with non-digit", () => { + expect(parseNumberedListItem("a. item")).toBeNull() + expect(parseNumberedListItem(". item")).toBeNull() + }) + }) + + describe("handleNewline", () => { + test("returns continue action for numbered list with content at end", () => { + const text = "1. foo" + const result = handleNewline(text, 6) + expect(result).toEqual({ type: "continue", insertText: "\n2. " }) + }) + + test("increments multi-digit numbers correctly", () => { + const text = "99. item" + const result = handleNewline(text, 8) + expect(result).toEqual({ type: "continue", insertText: "\n100. " }) + }) + + test("returns clear action for empty numbered list", () => { + const text = "1. " + const result = handleNewline(text, 3) + expect(result).toEqual({ + type: "clear", + deleteRange: { start: 0, end: 3 }, + cursorPosition: 0, + }) + }) + + test("returns clear action for numbered list with only period", () => { + const text = "1." + const result = handleNewline(text, 2) + expect(result).toEqual({ + type: "clear", + deleteRange: { start: 0, end: 2 }, + cursorPosition: 0, + }) + }) + + test("returns null for non-list text", () => { + const text = "hello world" + const result = handleNewline(text, 11) + expect(result).toBeNull() + }) + + test("returns null when cursor is not at end of line", () => { + const text = "1. foo" + const result = handleNewline(text, 3) // cursor after "1. " + expect(result).toBeNull() + }) + + test("handles numbered list in multi-line text", () => { + const text = "some text\n2. second item" + const result = handleNewline(text, 24) // cursor at end of "2. second item" + expect(result).toEqual({ type: "continue", insertText: "\n3. " }) + }) + + test("handles empty numbered list in multi-line text", () => { + const text = "1. first\n2. " + const result = handleNewline(text, 12) // cursor at end of "2. " + expect(result).toEqual({ + type: "clear", + deleteRange: { start: 9, end: 12 }, + cursorPosition: 9, + }) + }) + + test("returns null for first line that is not a list in multi-line", () => { + const text = "hello\n1. item" + const result = handleNewline(text, 5) // cursor at end of "hello" + expect(result).toBeNull() + }) + + test("handles single digit to double digit transition", () => { + const text = "9. ninth" + const result = handleNewline(text, 8) + expect(result).toEqual({ type: "continue", insertText: "\n10. " }) + }) + }) + + describe("cleanupForSubmit", () => { + test("removes trailing empty list item", () => { + const text = "1. foo\n2. " + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo") + }) + + test("removes trailing empty list item with no space", () => { + const text = "1. foo\n2." + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo") + }) + + test("removes multiple trailing empty list items", () => { + const text = "1. foo\n2. \n3. " + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo") + }) + + test("does not remove list items with content", () => { + const text = "1. foo\n2. bar" + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo\n2. bar") + }) + + test("returns empty string when only empty list item", () => { + const text = "1. " + const result = cleanupForSubmit(text) + expect(result).toBe("") + }) + + test("does not modify text without list items", () => { + const text = "hello world" + const result = cleanupForSubmit(text) + expect(result).toBe("hello world") + }) + + test("preserves non-list trailing lines", () => { + const text = "1. foo\nsome text" + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo\nsome text") + }) + + test("handles text with only whitespace after marker", () => { + const text = "1. foo\n2. " + const result = cleanupForSubmit(text) + expect(result).toBe("1. foo") + }) + }) +})