Skip to content
Draft
Show file tree
Hide file tree
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
105 changes: 105 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -1084,6 +1085,96 @@ export function Session() {
// snap to bottom when session changes
createEffect(on(() => route.sessionID, toBottom))

const [stickyUserID, setStickyUserID] = createSignal<string>()
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<ReturnType<typeof stickyTurn>>) {
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 (
<PathFormatterProvider path={session()?.directory}>
<context.Provider
Expand All @@ -1107,6 +1198,19 @@ export function Session() {
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box flexGrow={1} minHeight={0} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={stickyPromptEnabled() && stickyTurn()}>
{(turn) => (
<StickyUserPrompt
turn={turn()}
expanded={stickyExpanded()}
width={contentWidth()}
showTimestamps={showTimestamps()}
color={local.agent.color(turn().user.agent)}
onExpand={() => setStickyExpanded((prev) => !prev)}
onJump={() => scrollToAssistantStart(turn())}
/>
)}
</Show>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
Expand All @@ -1124,6 +1228,7 @@ export function Session() {
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
renderBefore={scheduleStickyUserSync}
>
<box height={1} />
<For each={messages()}>
Expand Down
171 changes: 171 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sticky-prompt.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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 (
<Show when={text()}>
<box
border={["left"]}
borderColor={props.color}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.backgroundPanel}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
marginBottom={1}
flexShrink={0}
zIndex={10}
>
<box flexDirection="row" gap={1}>
<box flexGrow={1} minWidth={0}>
<For each={shown()}>
{(line) => (
<text fg={theme.text} wrapMode="none" truncate>
{line}
</text>
)}
</For>
<Show when={props.expanded && files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={files()}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}>
{" "}
{MIME_BADGE[file.mime] ?? file.mime}{" "}
</span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
</text>
)}
</For>
</box>
</Show>
<Show when={props.expanded && props.showTimestamps}>
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.turn.user.time.created)}</text>
</Show>
</box>
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={overflow() || props.expanded}>
<text
fg={theme.textMuted}
bg={expandBg()}
paddingLeft={1}
paddingRight={1}
onMouseOver={() => setHoverExpand(true)}
onMouseOut={() => setHoverExpand(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onExpand()
}}
>
{props.expanded ? "[-]" : "[+]"}
</text>
</Show>
<text
fg={theme.text}
bg={buttonBg()}
paddingLeft={1}
paddingRight={1}
onMouseOver={() => setHoverJump(true)}
onMouseOut={() => setHoverJump(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
props.onJump()
}}
>
[^]
</text>
</box>
</box>
</box>
</Show>
)
}
Loading