From 45047c8e8d6e83928fb1f60955b359f9f7269a53 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Sun, 24 May 2026 14:08:27 +0800 Subject: [PATCH 1/2] feat(tui): prototype sticky prompt header --- .../src/cli/cmd/tui/routes/session/index.tsx | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) 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..ce565a5c4b26 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -1084,6 +1084,81 @@ export function Session() { // snap to bottom when session changes createEffect(on(() => route.sessionID, toBottom)) + const [stickyUserID, setStickyUserID] = createSignal() + const [stickyExpanded, setStickyExpanded] = createSignal(false) + 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 (!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) + }) + } + return ( + + {(turn) => ( + setStickyExpanded((prev) => !prev)} + onJump={() => scrollToAssistantStart(turn())} + /> + )} + (scroll = r)} viewportOptions={{ @@ -1124,6 +1209,7 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} + renderBefore={syncStickyUser} > @@ -1399,6 +1485,153 @@ function UserMessage(props: { ) } +function stickyPromptCharWidth(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 wrapStickyPromptText(text: string, width: number) { + return text.split(/\r?\n/).flatMap((line) => { + const lines: string[] = [] + let current = "" + let currentWidth = 0 + for (const char of line) { + const charWidth = stickyPromptCharWidth(char) + if (current && currentWidth + charWidth > width) { + lines.push(current) + current = char + currentWidth = charWidth + continue + } + current += char + currentWidth += charWidth + } + return current ? [...lines, current] : lines + }) +} + +function StickyUserPrompt(props: { + turn: { + user: UserMessage + parts: Part[] + target: AssistantMessage + } + expanded: boolean + onExpand: () => void + onJump: () => void +}) { + const ctx = use() + const local = useLocal() + 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(() => wrapStickyPromptText(text(), Math.max(20, ctx.width - 24))) + const overflow = createMemo(() => lines().length > 2) + const shown = createMemo(() => lines().slice(0, props.expanded ? 8 : 2)) + const color = createMemo(() => local.agent.color(props.turn.user.agent)) + 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() + }} + > + [^] + + + + + + ) +} + function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { const ctx = use() const local = useLocal() From f6924496aaf22adbff14e042b595cb6083d12d68 Mon Sep 17 00:00:00 2001 From: BayesWang <827130441@qq.com> Date: Sun, 24 May 2026 14:40:01 +0800 Subject: [PATCH 2/2] refactor(tui): extract sticky prompt prototype --- .../src/cli/cmd/tui/routes/session/index.tsx | 170 +++-------------- .../cmd/tui/routes/session/sticky-prompt.tsx | 171 ++++++++++++++++++ 2 files changed, 192 insertions(+), 149 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/sticky-prompt.tsx 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 ce565a5c4b26..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) @@ -1086,6 +1087,7 @@ export function Session() { const [stickyUserID, setStickyUserID] = createSignal() const [stickyExpanded, setStickyExpanded] = createSignal(false) + const stickyPromptEnabled = createMemo(() => kv.get("sticky_prompt_enabled", true)) const userMessageIDs = createMemo( () => new Set( @@ -1125,6 +1127,10 @@ export function Session() { } function syncStickyUser() { + if (!stickyPromptEnabled()) { + if (stickyUserID()) setStickyUserID(undefined) + return + } if (!scroll || scroll.isDestroyed) return const users = scroll .getChildren() @@ -1159,6 +1165,16 @@ export function Session() { }) } + let stickySyncScheduled = false + function scheduleStickyUserSync() { + if (stickySyncScheduled) return + stickySyncScheduled = true + requestAnimationFrame(() => { + stickySyncScheduled = false + syncStickyUser() + }) + } + return ( - + {(turn) => ( setStickyExpanded((prev) => !prev)} onJump={() => scrollToAssistantStart(turn())} /> @@ -1209,7 +1228,7 @@ export function Session() { stickyStart="bottom" flexGrow={1} scrollAcceleration={scrollAcceleration()} - renderBefore={syncStickyUser} + renderBefore={scheduleStickyUserSync} > @@ -1485,153 +1504,6 @@ function UserMessage(props: { ) } -function stickyPromptCharWidth(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 wrapStickyPromptText(text: string, width: number) { - return text.split(/\r?\n/).flatMap((line) => { - const lines: string[] = [] - let current = "" - let currentWidth = 0 - for (const char of line) { - const charWidth = stickyPromptCharWidth(char) - if (current && currentWidth + charWidth > width) { - lines.push(current) - current = char - currentWidth = charWidth - continue - } - current += char - currentWidth += charWidth - } - return current ? [...lines, current] : lines - }) -} - -function StickyUserPrompt(props: { - turn: { - user: UserMessage - parts: Part[] - target: AssistantMessage - } - expanded: boolean - onExpand: () => void - onJump: () => void -}) { - const ctx = use() - const local = useLocal() - 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(() => wrapStickyPromptText(text(), Math.max(20, ctx.width - 24))) - const overflow = createMemo(() => lines().length > 2) - const shown = createMemo(() => lines().slice(0, props.expanded ? 8 : 2)) - const color = createMemo(() => local.agent.color(props.turn.user.agent)) - 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() - }} - > - [^] - - - - - - ) -} - function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { const ctx = use() const local = useLocal() 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() + }} + > + [^] + + + + + + ) +}