Skip to content
Merged
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super">) {
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
Expand Down
Loading