Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
684146c
feat: add automatic list continuation in prompt input
ariane-emory Jan 18, 2026
780284c
fix: clean up trailing empty list items on submit
ariane-emory Jan 18, 2026
d596ca1
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 19, 2026
91cdff5
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 20, 2026
2f4afc6
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 22, 2026
e89de51
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 23, 2026
6171e8c
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 26, 2026
a5ca223
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 26, 2026
e84a969
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 27, 2026
34d99e3
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 29, 2026
5b22b6d
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 29, 2026
9674677
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Jan 30, 2026
506a408
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 1, 2026
9731767
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 3, 2026
23ea30a
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 3, 2026
c1959c9
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 5, 2026
77b0555
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 5, 2026
80a6492
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 6, 2026
0a88c0e
Merge branch 'dev' into feat/automatic-list-continuation
ariane-emory Feb 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
import { DialogSkill } from "../dialog-skill"

export type PromptProps = {
Expand Down Expand Up @@ -87,6 +88,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 @@ -516,7 +521,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 @@ -533,7 +545,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 @@ -808,12 +820,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 @@ -826,7 +826,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