Skip to content
153 changes: 153 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/list-continuation.ts
Original file line number Diff line number Diff line change
@@ -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,
}
}
37 changes: 34 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")!
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading