diff --git a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index fa8786e9..3e6ad53b 100644 --- a/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/src/mendcode/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -902,288 +902,6 @@ function cleanPromptInputText(value: string) { return removeVisibleResolvingText(value) } -export function shouldClearWorkingStartedAt(input: { - statusType: string - hasActiveWorkingAssistant?: boolean - permissionPending?: boolean - interrupted?: boolean - terminalAssistant?: boolean -}) { - if (input.interrupted) return true - if (input.permissionPending) return false - if (input.terminalAssistant) return true - return input.statusType === "idle" && !input.hasActiveWorkingAssistant && !input.permissionPending -} - -export function shouldKeepWorkingStatus(input: { - interrupted?: boolean - submitPreflightActive?: boolean - hasPendingPromptDelivery?: boolean - compactionActive?: boolean - hasActiveWorkingAssistant?: boolean - permissionPending?: boolean - terminalAssistant?: boolean - statusType: string -}) { - if (input.interrupted) return false - // A stale preflight flag can survive the terminal assistant event. Do not - // let that local flag resurrect Generating after a completed turn. - if ( - input.terminalAssistant && - !input.hasPendingPromptDelivery && - !input.compactionActive && - !input.permissionPending && - !input.hasActiveWorkingAssistant - ) - return false - return Boolean( - input.submitPreflightActive || - input.hasPendingPromptDelivery || - input.compactionActive || - !shouldClearWorkingStartedAt({ - statusType: input.statusType, - hasActiveWorkingAssistant: input.hasActiveWorkingAssistant, - permissionPending: input.permissionPending, - terminalAssistant: input.terminalAssistant, - }), - ) -} - -export function promptWorkingIndicatorVisible(input: { - hasSession: boolean - submitPreflightActive: boolean - configuredVisible: boolean - working: boolean -}) { - return (input.hasSession || input.submitPreflightActive) && input.configuredVisible && input.working -} - -export function shouldEnableSessionInterrupt(input: { - statusType: string - hasActiveWorkingAssistant?: boolean - hasPendingPromptDelivery?: boolean - autocompleteVisible?: boolean - promptFocused?: boolean - compactionActive?: boolean - interruptRequested?: boolean -}) { - if (input.interruptRequested) return false - // An active turn owns Esc even when autocomplete/another prompt overlay has - // focus, so the first press can arm cancellation without closing the overlay. - if ( - input.statusType !== "idle" || - input.hasActiveWorkingAssistant || - input.hasPendingPromptDelivery || - input.compactionActive - ) - return true - if (input.autocompleteVisible) return false - return false -} - -export function hasInterruptibleSessionTurn(input: { - statusType: string - hasDraft?: boolean - hasActiveWorkingAssistant?: boolean - hasPendingPromptDelivery?: boolean - compactionActive?: boolean -}) { - return ( - input.statusType !== "idle" || - Boolean(input.hasActiveWorkingAssistant || input.hasPendingPromptDelivery || input.compactionActive) - ) -} - -export function sessionInterruptConfirmationAction(input: { - activeTargetMessageID?: string - armedTargetMessageID?: string -}): "ignore" | "arm" | "interrupt" { - if (!input.activeTargetMessageID) return "ignore" - return input.armedTargetMessageID === input.activeTargetMessageID ? "interrupt" : "arm" -} - -export function sessionInterruptHint(input: { enabled: boolean; working: boolean; armed: boolean }) { - if (!input.enabled || !input.working || !input.armed) return - return "[esc again to interrupt]" -} - -export function clipboardPasteAction(content: { mime?: string; data?: string } | undefined): "image" | "text" | "none" { - if (!content) return "none" - if (content.mime?.startsWith("image/")) return "image" - if (content.mime === "text/plain" && content.data) return "text" - return "none" -} - -export function shouldAcceptPromptInterruptFocus(input: { - inputFocused: boolean - currentFocusedRenderable?: unknown - promptInput?: unknown -}) { - return input.inputFocused && input.currentFocusedRenderable === input.promptInput -} - -export function shouldUseStoredPromptHistoryFallback(input: { - historyItems?: () => readonly PromptInfo[] - messageHistoryCount?: number -}) { - return input.historyItems === undefined -} - -function stablePromptPartForHistory(part: PromptInfo["parts"][number]) { - if (part.type === "text" && part.source?.text) { - return { - ...part, - source: { - ...part.source, - text: { - value: part.source.text.value, - }, - }, - } - } - if (part.type === "file" && part.source?.text) { - return { - ...part, - source: { - ...part.source, - text: { - value: part.source.text.value, - }, - }, - } - } - if (part.type === "agent" && part.source) { - return { - ...part, - source: { - value: part.source.value, - }, - } - } - return part -} - -export function promptHistoryMatchesCurrent(input: { - currentPrompt: PromptInfo - currentMode: "normal" | "shell" - historyIndex: number - historyPrompt?: PromptInfo -}) { - if (input.historyIndex === 0) return input.currentPrompt.input.length === 0 && input.currentPrompt.parts.length === 0 - if (!input.historyPrompt) return false - if (input.currentPrompt.input !== input.historyPrompt.input) return false - if (input.currentMode !== (input.historyPrompt.mode ?? "normal")) return false - return ( - JSON.stringify(input.currentPrompt.parts.map(stablePromptPartForHistory)) === - JSON.stringify(input.historyPrompt.parts.map(stablePromptPartForHistory)) - ) -} - -export function shouldPreferMessagePromptHistory(input: { direction?: 1 | -1; currentPromptMatchesHistory: boolean }) { - if (input.direction === undefined) return false - return input.currentPromptMatchesHistory -} - -function trailingPromptPlaceholderStart(input: { text: string; parts?: readonly PromptInfo["parts"][number][] }) { - const trimmedEnd = input.text.trimEnd().length - return input.parts - ?.flatMap((part) => { - if ((part.type === "text" || part.type === "file") && part.source?.text) return [part.source.text] - if (part.type === "agent" && part.source) return [part.source] - return [] - }) - .filter((source) => source.end >= trimmedEnd) - .map((source) => source.start) - .sort((a, b) => a - b) - .at(0) -} - -export function shouldAttemptPromptHistoryNavigation(input: { - direction?: 1 | -1 - cursorOffset: number - text: string - parts?: readonly PromptInfo["parts"][number][] -}) { - if (input.direction === undefined) return false - if (input.direction === -1) return input.cursorOffset === 0 - if (input.cursorOffset === promptCursorEndOffset(input.text)) return true - const placeholderStart = trailingPromptPlaceholderStart(input) - return placeholderStart !== undefined && input.cursorOffset >= placeholderStart -} - -export function shouldSnapPromptCursorToEnd(input: { - direction?: 1 | -1 - cursorOffset: number - text: string - visualRow: number - totalVisualRows: number - scrollY: number -}) { - if (input.direction !== 1) return false - // OpenTUI reports visualRow relative to the viewport. Use the total wrapped - // row count instead of logical newline count so long lines do not look like - // the end of the prompt while ArrowDown is still scrolling through them. - const absoluteVisualRow = input.visualRow + input.scrollY - return absoluteVisualRow >= input.totalVisualRows - 1 && input.cursorOffset < promptCursorEndOffset(input.text) -} - -export function shouldHandlePromptCursorArrow(input: Pick) { - if (input.ctrl || input.meta || input.shift || input.super) return false - return input.name === "left" || input.name === "right" -} - -export function promptDraftHistoryAction(input: { undo: boolean; redo: boolean; canUndo: boolean; canRedo: boolean }) { - if (input.undo && input.canUndo) return "undo" as const - if (input.redo && input.canRedo) return "redo" as const - - return undefined -} - -export function promptCursorEndOffset(text: string) { - return text.length -} - -export function promptCursorOffsetAfterArrow(input: { - cursorOffset: number - text: string - direction: "left" | "right" - atomicRanges?: readonly { start: number; end: number }[] -}) { - const textEnd = promptCursorEndOffset(input.text) - const cursorOffset = Math.max(0, Math.min(textEnd, input.cursorOffset)) - const atomicRanges = (input.atomicRanges ?? []) - .filter((range) => Number.isFinite(range.start) && Number.isFinite(range.end) && range.end > range.start) - .map((range) => ({ - start: Math.max(0, Math.min(textEnd, range.start)), - end: Math.max(0, Math.min(textEnd, range.end)), - })) - .filter((range) => range.end > range.start) - - if (input.direction === "left") { - const atomicRange = atomicRanges.find((range) => cursorOffset > range.start && cursorOffset <= range.end) - if (atomicRange) return Math.max(0, atomicRange.start - 1) - return Math.max(0, cursorOffset - 1) - } - - const atomicRange = atomicRanges.find((range) => cursorOffset >= range.start && cursorOffset < range.end) - if (atomicRange) return atomicRange.end - return Math.min(textEnd, cursorOffset + 1) -} - -export function promptCursorOffsetFromMouse(input: { visualOffset: number; text: string }) { - return Math.max(0, Math.min(promptCursorEndOffset(input.text), Math.floor(input.visualOffset))) -} - -function removeVisibleResolvingText(value: string) { - return value.replace(/(?:^|\s)(?:[^\w\s]\s*)*Resolving\s+\[\d+\/\d+\]\s*/giu, (match) => - match.startsWith(" ") ? " " : "", - ) -} - -function cleanPromptInputText(value: string) { - return removeVisibleResolvingText(value) -} - export function Prompt(props: PromptProps) { let input: TextareaRenderable let anchor: BoxRenderable