diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index ae23d58bcc95..50e09557024e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -90,6 +90,7 @@ import { SessionRetry } from "@/session/retry" import { getRevertDiffFiles } from "../../util/revert-diff" import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut, useOpencodeKeymap } from "../../keymap" import { PathFormatterProvider, usePathFormatter } from "../../context/path-format" +import { StickyUserPrompt } from "./sticky-prompt" addDefaultParsers(parsers.parsers) @@ -1084,6 +1085,96 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) + const [stickyUserID, setStickyUserID] = createSignal() + const [stickyExpanded, setStickyExpanded] = createSignal(false) + const stickyPromptEnabled = createMemo(() => kv.get("sticky_prompt_enabled", true)) + const userMessageIDs = createMemo( + () => + new Set( + messages() + .filter((message) => message.role === "user") + .map((x) => x.id), + ), + ) + const stickyTurn = createMemo(() => { + const userID = stickyUserID() + if (!userID) return + const user = messages().find((message): message is UserMessage => message.id === userID && message.role === "user") + if (!user) return + const assistant = messages().find( + (message): message is AssistantMessage => message.role === "assistant" && message.parentID === user.id, + ) + const fallbackAssistant = messages() + .slice(messages().findIndex((message) => message.id === user.id) + 1) + .find((message): message is AssistantMessage => message.role === "assistant") + const target = assistant ?? fallbackAssistant + if (!target) return + return { + user, + parts: sync.data.part[user.id] ?? [], + target, + } + }) + + createEffect( + on(stickyUserID, () => { + setStickyExpanded(false) + }), + ) + + function messageContentY(child: { y: number }) { + return scroll.scrollTop + child.y - scroll.viewport.y + } + + function syncStickyUser() { + if (!stickyPromptEnabled()) { + if (stickyUserID()) setStickyUserID(undefined) + return + } + if (!scroll || scroll.isDestroyed) return + const users = scroll + .getChildren() + .filter((child) => { + if (!child.id) return false + return userMessageIDs().has(child.id) + }) + .map((child) => ({ child, contentY: messageContentY(child) })) + .sort((left, right) => left.contentY - right.contentY) + const current = users.findLast((item) => item.contentY + item.child.height <= scroll.scrollTop + 1) + if (stickyUserID() === current?.child.id) return + setStickyUserID(current?.child.id) + } + + function scrollNodeToTop(node: { y: number }, offset = 0) { + const delta = node.y - scroll.viewport.y + scroll.scrollBy(delta - offset) + } + + function scrollToAssistantStart(turn: NonNullable>) { + requestAnimationFrame(() => { + if (!scroll || scroll.isDestroyed) return + const parts = sync.data.part[turn.target.id] ?? [] + const text = parts.find((part) => part.type === "text" && part.text.trim()) + const target = + (text ? scroll.getChildren().find((child) => child.id === "text-" + text.id) : undefined) ?? + scroll.getChildren().find((child) => { + if (!child.id?.startsWith("text-")) return false + return parts.some((part) => child.id === "text-" + part.id) + }) + if (target) scrollNodeToTop(target) + }) + } + + let stickySyncScheduled = false + function scheduleStickyUserSync() { + if (stickySyncScheduled) return + stickySyncScheduled = true + requestAnimationFrame(() => { + stickySyncScheduled = false + syncStickyUser() + }) + } + return ( + + {(turn) => ( + setStickyExpanded((prev) => !prev)} + onJump={() => scrollToAssistantStart(turn())} + /> + )} + (scroll = r)} viewportOptions={{ @@ -1124,6 +1228,7 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + renderBefore={scheduleStickyUserSync} > diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sticky-prompt.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sticky-prompt.tsx new file mode 100644 index 000000000000..085cb0994172 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sticky-prompt.tsx @@ -0,0 +1,171 @@ +import { SplitBorder } from "@tui/component/border" +import { useTheme } from "@tui/context/theme" +import { useRenderer } from "@opentui/solid" +import type { RGBA } from "@opentui/core" +import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" +import { Locale } from "@/util/locale" +import { createMemo, createSignal, For, Show } from "solid-js" + +const COLLAPSED_LINES = 2 +const EXPANDED_LINES = 8 +const CONTROL_WIDTH = 10 +const MIN_LINE_WIDTH = 20 + +const MIME_BADGE: Record = { + "text/plain": "txt", + "image/png": "img", + "image/jpeg": "img", + "image/gif": "img", + "image/webp": "img", + "application/pdf": "pdf", + "application/x-directory": "dir", +} + +export type StickyPromptTurn = { + user: UserMessage + parts: Part[] + target: AssistantMessage +} + +function charWidth(char: string) { + if (/[\u0300-\u036f]/.test(char)) return 0 + if ( + /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/.test( + char, + ) + ) + return 2 + return 1 +} + +function wrapText(text: string, columns: number) { + return text.split(/\r?\n/).flatMap((line) => { + const lines: string[] = [] + let current = "" + let currentWidth = 0 + for (const char of line) { + const charColumns = charWidth(char) + if (current && currentWidth + charColumns > columns) { + lines.push(current) + current = char + currentWidth = charColumns + continue + } + current += char + currentWidth += charColumns + } + return current ? [...lines, current] : lines + }) +} + +export function StickyUserPrompt(props: { + turn: StickyPromptTurn + expanded: boolean + width: number + showTimestamps: boolean + color: RGBA + onExpand: () => void + onJump: () => void +}) { + const renderer = useRenderer() + const { theme } = useTheme() + const [hoverJump, setHoverJump] = createSignal(false) + const [hoverExpand, setHoverExpand] = createSignal(false) + const text = createMemo(() => + props.turn.parts + .map((part) => { + if (part.type !== "text") return + if (part.synthetic) return + return part.text + }) + .filter(Boolean) + .join("\n\n") + .trim(), + ) + const files = createMemo(() => props.turn.parts.flatMap((part) => (part.type === "file" ? [part] : []))) + const lines = createMemo(() => wrapText(text(), Math.max(MIN_LINE_WIDTH, props.width - CONTROL_WIDTH))) + const overflow = createMemo(() => lines().length > COLLAPSED_LINES) + const shown = createMemo(() => lines().slice(0, props.expanded ? EXPANDED_LINES : COLLAPSED_LINES)) + const buttonBg = createMemo(() => (hoverJump() ? theme.backgroundMenu : theme.backgroundElement)) + const expandBg = createMemo(() => (hoverExpand() ? theme.backgroundMenu : theme.backgroundElement)) + + return ( + + + + + + {(line) => ( + + {line} + + )} + + + + + {(file) => ( + + + {" "} + {MIME_BADGE[file.mime] ?? file.mime}{" "} + + {file.filename} + + )} + + + + + {Locale.todayTimeOrDateTime(props.turn.user.time.created)} + + + + + setHoverExpand(true)} + onMouseOut={() => setHoverExpand(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onExpand() + }} + > + {props.expanded ? "[-]" : "[+]"} + + + setHoverJump(true)} + onMouseOut={() => setHoverJump(false)} + onMouseUp={() => { + if (renderer.getSelection()?.getSelectedText()) return + props.onJump() + }} + > + [^] + + + + + + ) +}