Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4778e6e031 | |||
| ac1b802820 | |||
| 5ee4c1082a | |||
| 8a70d70006 | |||
| 91b4634363 | |||
| 4f60bde502 | |||
| 0d3b6d430e |
@@ -17,6 +17,7 @@ export const name = "shell"
|
||||
export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
|
||||
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
|
||||
export const MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
export const PROGRESS_LINES = 25
|
||||
|
||||
const BACKGROUND_STARTED = "The command was moved to the background."
|
||||
const BACKGROUND_INSTRUCTION =
|
||||
@@ -210,6 +211,23 @@ export const Plugin = {
|
||||
}
|
||||
})
|
||||
|
||||
const captureProgress = Effect.fn("ShellTool.captureProgress")(function* () {
|
||||
const latest = yield* shell.output(info.id, { cursor: Number.MAX_SAFE_INTEGER })
|
||||
const start = Math.max(0, latest.size - MAX_CAPTURE_BYTES)
|
||||
const page = yield* shell.output(info.id, { cursor: start, limit: MAX_CAPTURE_BYTES })
|
||||
const trailingNewline = page.output.endsWith("\n")
|
||||
const lines = trailingNewline ? page.output.split("\n").slice(0, -1) : page.output.split("\n")
|
||||
const truncated = start > 0 || lines.length > PROGRESS_LINES
|
||||
const output = lines.slice(-PROGRESS_LINES).join("\n") + (trailingNewline ? "\n" : "")
|
||||
const notice = truncated
|
||||
? `[output truncated; showing last ${PROGRESS_LINES} lines. Full output saved to: ${info.file}]\n\n`
|
||||
: ""
|
||||
return {
|
||||
output: `${notice}${output || "(no output)"}`,
|
||||
truncated,
|
||||
}
|
||||
})
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
|
||||
@@ -258,7 +276,7 @@ export const Plugin = {
|
||||
|
||||
const progress = yield* Effect.sleep("1 second").pipe(
|
||||
Effect.andThen(
|
||||
captureShell().pipe(
|
||||
captureProgress().pipe(
|
||||
Effect.flatMap((capture) =>
|
||||
context.progress({
|
||||
structured: { truncated: capture.truncated },
|
||||
|
||||
@@ -166,10 +166,10 @@ const overflowCommand = (bytes: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 100`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'`
|
||||
const progressOverflowCommand = (bytes: number) =>
|
||||
const progressLinesCommand = (lines: number) =>
|
||||
isWindows
|
||||
? `[Console]::Out.Write(('x' * ${bytes})); Start-Sleep -Milliseconds 1500`
|
||||
: `head -c ${bytes} /dev/zero | tr '\\0' 'x'; sleep 1.5`
|
||||
? `1..${lines} | ForEach-Object { [Console]::Out.WriteLine(('line {0:d2}' -f $_)) }; Start-Sleep -Milliseconds 1500`
|
||||
: `i=1; while [ $i -le ${lines} ]; do printf 'line %02d\\n' "$i"; i=$((i+1)); done; sleep 1.5`
|
||||
|
||||
const withSession = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -417,17 +417,17 @@ describe("ShellTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports bounded output progress for a running command", () =>
|
||||
it.live("reports the latest output lines for a running command", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const bytes = ShellTool.MAX_CAPTURE_BYTES + 1024
|
||||
const lines = ShellTool.PROGRESS_LINES + 10
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const progress: ToolRegistry.Progress[] = []
|
||||
yield* settleTool(registry, {
|
||||
...call({ command: progressOverflowCommand(bytes) }, "call-progress"),
|
||||
...call({ command: progressLinesCommand(lines) }, "call-progress"),
|
||||
progress: (update) => Effect.sync(() => progress.push(update)),
|
||||
})
|
||||
|
||||
@@ -436,9 +436,12 @@ describe("ShellTool", () => {
|
||||
const content = progress[0]?.content[0]
|
||||
expect(content?.type).toBe("text")
|
||||
if (content?.type !== "text") return
|
||||
expect(content.text.indexOf("\n\n[output truncated; full output saved to:")).toBe(
|
||||
ShellTool.MAX_CAPTURE_BYTES,
|
||||
expect(content.text).toStartWith(
|
||||
`[output truncated; showing last ${ShellTool.PROGRESS_LINES} lines. Full output saved to:`,
|
||||
)
|
||||
expect(content.text).not.toContain("line 10\n")
|
||||
expect(content.text).toContain("line 11\n")
|
||||
expect(content.text).toContain(`line ${lines}\n`)
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -134,11 +134,13 @@ export const Definitions = {
|
||||
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
|
||||
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
|
||||
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
|
||||
messages_first: keybind("ctrl+g,home,alt+home", "Navigate to first message"),
|
||||
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||
messages_next: keybind("none", "Navigate to next message"),
|
||||
messages_previous: keybind("none", "Navigate to previous message"),
|
||||
messages_last_user: keybind("none", "Navigate to last user message"),
|
||||
messages_next: keybind("alt+down", "Navigate to next message"),
|
||||
messages_previous: keybind("alt+up", "Navigate to previous message"),
|
||||
messages_next_user: keybind("alt+shift+down", "Navigate to next user message"),
|
||||
messages_previous_user: keybind("alt+shift+up", "Navigate to previous user message"),
|
||||
messages_last_user: keybind("alt+end", "Navigate to last user message"),
|
||||
messages_copy: keybind("<leader>y", "Copy message"),
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
@@ -335,6 +337,8 @@ export const CommandMap = {
|
||||
messages_last: "session.last",
|
||||
messages_next: "session.message.next",
|
||||
messages_previous: "session.message.previous",
|
||||
messages_next_user: "session.message.user.next",
|
||||
messages_previous_user: "session.message.user.previous",
|
||||
messages_last_user: "session.messages_last_user",
|
||||
messages_copy: "messages.copy",
|
||||
messages_undo: "session.undo",
|
||||
|
||||
@@ -50,9 +50,8 @@ function Mcp(props: { context: Plugin.Context }) {
|
||||
}
|
||||
|
||||
function View(props: { context: Plugin.Context }) {
|
||||
const { themeV2, mode, setMode } = useTheme()
|
||||
const { themeV2 } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const modeLabel = createMemo(() => (mode() === "dark" ? "Switch to light" : "Switch to dark"))
|
||||
const mcpWidth = createMemo(() => {
|
||||
const list = props.context.data.location.mcp.server.list(props.context.location) ?? []
|
||||
if (list.length === 0) return 0
|
||||
@@ -73,16 +72,10 @@ function View(props: { context: Plugin.Context }) {
|
||||
>
|
||||
<Directory
|
||||
context={props.context}
|
||||
maxWidth={Math.max(
|
||||
2,
|
||||
dimensions().width - 10 - Bun.stringWidth(InstallationVersion) - Bun.stringWidth(modeLabel()) - mcpWidth(),
|
||||
)}
|
||||
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
|
||||
/>
|
||||
<Mcp context={props.context} />
|
||||
<box flexGrow={1} />
|
||||
<box flexShrink={0} onMouseUp={() => setMode(mode() === "dark" ? "light" : "dark")}>
|
||||
<text fg={themeV2.text.action.secondary()}>{modeLabel()}</text>
|
||||
</box>
|
||||
<box flexShrink={0}>
|
||||
<text fg={themeV2.text.subdued()}>{InstallationVersion}</text>
|
||||
</box>
|
||||
|
||||
@@ -13,7 +13,6 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
const location = useLocation()
|
||||
const client = useClient()
|
||||
const { themeV2 } = useTheme()
|
||||
const fg = themeV2.text.action.primary("focused")
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
@@ -96,11 +95,7 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
|
||||
return (
|
||||
<Show when={composer.active("shell")}>
|
||||
<scrollbox
|
||||
scrollbarOptions={{ visible: false }}
|
||||
maxHeight={5}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No shell commands</text>}>
|
||||
<For each={entries()}>
|
||||
{(shell, index) => {
|
||||
@@ -110,11 +105,11 @@ export function ShellTab(props: { sessionID: string }) {
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={active() ? themeV2.background.action.primary("selected") : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
>
|
||||
<text
|
||||
fg={active() ? fg : themeV2.text()}
|
||||
fg={themeV2.text.action.primary(active() ? "focused" : "default")}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,6 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const { themeV2 } = useTheme()
|
||||
const fg = themeV2.text.action.primary("focused")
|
||||
const navigate = useRoute().navigate
|
||||
const composer = useComposerTab()
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
@@ -39,7 +38,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const siblings = data.session.list().filter((s) => s.parentID === current.parentID)
|
||||
for (const sibling of siblings) {
|
||||
const agentMatch = sibling.title.match(/@(\w+) subagent/)
|
||||
const agent = sibling.agent ? Locale.titlecase(sibling.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
const agent = sibling.agent
|
||||
? Locale.titlecase(sibling.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const name = agentMatch ? sibling.title.replace(agentMatch[0], "").trim() || sibling.title : sibling.title
|
||||
result.push({
|
||||
sessionID: sibling.id,
|
||||
@@ -53,7 +56,11 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const children = data.session.list().filter((s) => s.parentID === props.sessionID)
|
||||
for (const child of children) {
|
||||
const agentMatch = child.title.match(/@(\w+) subagent/)
|
||||
const agent = child.agent ? Locale.titlecase(child.agent) : agentMatch ? Locale.titlecase(agentMatch[1]) : "Subagent"
|
||||
const agent = child.agent
|
||||
? Locale.titlecase(child.agent)
|
||||
: agentMatch
|
||||
? Locale.titlecase(agentMatch[1])
|
||||
: "Subagent"
|
||||
const name = agentMatch ? child.title.replace(agentMatch[0], "").trim() || child.title : child.title
|
||||
result.push({
|
||||
sessionID: child.id,
|
||||
@@ -195,11 +202,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
|
||||
return (
|
||||
<Show when={composer.active("subagents")}>
|
||||
<scrollbox
|
||||
scrollbarOptions={{ visible: false }}
|
||||
maxHeight={5}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
>
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show when={entries().length > 0} fallback={<text fg={themeV2.text.subdued()}> No subagents</text>}>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
@@ -213,7 +216,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
flexDirection="row"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? themeV2.background.action.primary() : RGBA.fromInts(0, 0, 0, 0)}
|
||||
backgroundColor={themeV2.background.action.primary(active() ? "focused" : "default")}
|
||||
onMouseOver={() => setStore("selected", index())}
|
||||
onMouseUp={() => {
|
||||
setStore("selected", index())
|
||||
@@ -222,7 +225,9 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="row">
|
||||
<text
|
||||
fg={active() ? fg : entry.current ? themeV2.background.action.primary() : themeV2.text()}
|
||||
fg={themeV2.text.action.primary(
|
||||
active() ? "focused" : entry.current ? "selected" : "default",
|
||||
)}
|
||||
attributes={active() ? TextAttributes.BOLD : undefined}
|
||||
wrapMode="none"
|
||||
>
|
||||
@@ -230,7 +235,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
</text>
|
||||
</box>
|
||||
<Show when={status()}>
|
||||
<text fg={active() ? fg : themeV2.text.subdued()} wrapMode="none">
|
||||
<text fg={active() ? themeV2.text.action.primary() : themeV2.text.subdued()} wrapMode="none">
|
||||
{status()}
|
||||
</text>
|
||||
</Show>
|
||||
|
||||
@@ -71,11 +71,15 @@ import { usePluginRuntime } from "../../plugin/runtime"
|
||||
import { OPENCODE_BASE_MODE, useBindings, useCommandShortcut } from "../../keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { createSessionRows, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./rows"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
// Exclude temporary bottom space when measuring the real transcript height.
|
||||
const NAVIGATION_SLACK_ID = "session-navigation-slack"
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -180,6 +184,23 @@ export function Session() {
|
||||
const client = useClient()
|
||||
const editor = useEditorContext()
|
||||
const rows = createSessionRows(() => route.sessionID)
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
|
||||
const clearMessageNavigation = () => {
|
||||
setNavigationSlack(0)
|
||||
setNavigationMessage(undefined)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [dimensions().width, dimensions().height] as const,
|
||||
(_, previous) => {
|
||||
if (previous) clearMessageNavigation()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
||||
@@ -239,53 +260,55 @@ export function Session() {
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
// Helper: Find next visible message boundary in direction
|
||||
const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
|
||||
const children = scroll.getChildren()
|
||||
const messagesList = messages()
|
||||
const scrollTop = scroll.y
|
||||
|
||||
// Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
|
||||
const visibleMessages = children
|
||||
.filter((c) => {
|
||||
if (!c.id) return false
|
||||
const message = messagesList.find((m) => m.id === c.id)
|
||||
if (!message) return false
|
||||
|
||||
if (message.type === "user") return Boolean(message.text.trim())
|
||||
return (
|
||||
message.type === "assistant" &&
|
||||
message.content.some((content) => content.type === "text" && content.text.trim())
|
||||
)
|
||||
const alignMessage = (messageID: string, top: number) => {
|
||||
scroll.stickyScroll = false
|
||||
setNavigationMessage(messageID)
|
||||
setNavigationSlack(
|
||||
messageNavigationSlack({
|
||||
top,
|
||||
viewportHeight: scroll.viewport.height,
|
||||
scrollHeight: scroll.scrollHeight,
|
||||
currentSlack: scroll.getRenderable(NAVIGATION_SLACK_ID)?.height ?? 0,
|
||||
}),
|
||||
)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (scroll.isDestroyed || navigationMessage() !== messageID) return
|
||||
scroll.scrollTo(top)
|
||||
})
|
||||
.sort((a, b) => a.y - b.y)
|
||||
|
||||
if (visibleMessages.length === 0) return null
|
||||
|
||||
if (direction === "next") {
|
||||
// Find first message below current position
|
||||
return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
|
||||
}
|
||||
// Find last message above current position
|
||||
return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
|
||||
})
|
||||
}
|
||||
|
||||
// Helper: Scroll to message in direction or fallback to page scroll
|
||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
|
||||
const targetID = findNextVisibleMessage(direction)
|
||||
const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>, userOnly = false) => {
|
||||
const target = findMessageBoundary({
|
||||
direction,
|
||||
children: scroll.getChildren(),
|
||||
messages: messages(),
|
||||
scrollTop: scroll.scrollTop,
|
||||
viewportY: scroll.viewport.y,
|
||||
currentID: navigationMessage(),
|
||||
userOnly,
|
||||
})
|
||||
|
||||
if (!targetID) {
|
||||
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
||||
if (!target) {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const child = scroll.getChildren().find((c) => c.id === targetID)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
alignMessage(target.id, target.top)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
const jumpToMessage = (messageID: string) => {
|
||||
const child = scroll.getRenderable(messageID)
|
||||
if (!child) return
|
||||
const y = scroll.scrollTop + child.y - scroll.viewport.y
|
||||
const message = data.session.message.get(route.sessionID, messageID)
|
||||
alignMessage(messageID, Math.max(0, y - (message?.type === "assistant" ? 1 : 0)))
|
||||
}
|
||||
|
||||
function toBottom() {
|
||||
clearMessageNavigation()
|
||||
setTimeout(() => {
|
||||
if (!scroll || scroll.isDestroyed) return
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
@@ -299,6 +322,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -309,6 +333,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 2)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -319,6 +344,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-1)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -329,6 +355,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(1)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -339,6 +366,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -349,6 +377,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollBy(scroll.height / 4)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -362,6 +391,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollTo(0)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -372,6 +402,7 @@ export function Session() {
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => {
|
||||
clearMessageNavigation()
|
||||
scroll.scrollTo(scroll.scrollHeight)
|
||||
dialog.clear()
|
||||
},
|
||||
@@ -412,8 +443,7 @@ export function Session() {
|
||||
sessionID={route.sessionID}
|
||||
onMove={(messageID) => {
|
||||
if (!messageID) return
|
||||
const child = scroll.getChildren().find((child) => child.id === messageID)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
jumpToMessage(messageID)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
@@ -574,10 +604,7 @@ export function Session() {
|
||||
const message = messages[i]
|
||||
if (!message || message.type !== "user" || !message.text.trim()) continue
|
||||
{
|
||||
const child = scroll.getChildren().find((child) => {
|
||||
return child.id === message.id
|
||||
})
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
jumpToMessage(message.id)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -597,6 +624,20 @@ export function Session() {
|
||||
hidden: true,
|
||||
run: () => scrollToMessage("prev", dialog),
|
||||
},
|
||||
{
|
||||
title: "Next user message",
|
||||
name: "session.message.user.next",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => scrollToMessage("next", dialog, true),
|
||||
},
|
||||
{
|
||||
title: "Previous user message",
|
||||
name: "session.message.user.previous",
|
||||
category: "Session",
|
||||
hidden: true,
|
||||
run: () => scrollToMessage("prev", dialog, true),
|
||||
},
|
||||
{
|
||||
title: "Copy last assistant message",
|
||||
name: "messages.copy",
|
||||
@@ -810,7 +851,10 @@ export function Session() {
|
||||
createEffect(
|
||||
on(
|
||||
() => route.sessionID,
|
||||
() => setComposer("open", false),
|
||||
() => {
|
||||
setComposer("open", false)
|
||||
clearMessageNavigation()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -846,16 +890,17 @@ export function Session() {
|
||||
foregroundColor: themeV2.border(),
|
||||
},
|
||||
}}
|
||||
stickyScroll={true}
|
||||
stickyScroll={!navigationMessage()}
|
||||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
{(row, index) => (
|
||||
<SessionRowView
|
||||
row={row}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index()]}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -870,6 +915,9 @@ export function Session() {
|
||||
files={session()!.revert!.files ?? []}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={navigationSlack()}>
|
||||
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
|
||||
</Show>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Composer
|
||||
@@ -942,9 +990,13 @@ export function Session() {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
||||
function SessionRowView(props: {
|
||||
row: SessionRow
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
boundaryID?: string
|
||||
}) {
|
||||
return (
|
||||
<box marginTop={1} flexShrink={0}>
|
||||
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
|
||||
<Switch>
|
||||
<Match when={props.row.type === "message" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
@@ -1563,7 +1615,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
return (
|
||||
<Show when={props.message.text.trim() || files().length}>
|
||||
<box
|
||||
id={props.message.id}
|
||||
border={["left"]}
|
||||
borderColor={queued() ? themeV2.border() : color()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
@@ -2279,7 +2330,7 @@ function BlockTool(props: {
|
||||
paddingLeft={2}
|
||||
gap={1}
|
||||
backgroundColor={
|
||||
hover() ? themeV2.background.action.secondary() : themeV2.background()
|
||||
hover() ? themeV2.background.action.secondary("focused") : themeV2.background()
|
||||
}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.background()}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client"
|
||||
|
||||
type MessageChild = {
|
||||
readonly id?: string
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export function messageNavigationSlack(input: {
|
||||
top: number
|
||||
viewportHeight: number
|
||||
scrollHeight: number
|
||||
currentSlack: number
|
||||
}) {
|
||||
const contentHeight = input.scrollHeight - input.currentSlack
|
||||
return Math.max(0, Math.ceil(input.top + input.viewportHeight - contentHeight))
|
||||
}
|
||||
|
||||
export function findMessageBoundary(input: {
|
||||
direction: "next" | "prev"
|
||||
children: readonly MessageChild[]
|
||||
messages: readonly SessionMessageInfo[]
|
||||
scrollTop: number
|
||||
viewportY: number
|
||||
currentID?: string
|
||||
userOnly?: boolean
|
||||
}) {
|
||||
const messages = new Map(input.messages.map((message) => [message.id, message]))
|
||||
const visible = input.children
|
||||
.flatMap((child) => {
|
||||
if (!child.id) return []
|
||||
const message = messages.get(child.id)
|
||||
if (!message) return []
|
||||
if (message.type === "user" && message.text.trim()) {
|
||||
const y = input.scrollTop + child.y - input.viewportY
|
||||
return [{ id: child.id, y, top: y }]
|
||||
}
|
||||
if (input.userOnly || message.type !== "assistant") return []
|
||||
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
|
||||
const y = input.scrollTop + child.y - input.viewportY
|
||||
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
||||
})
|
||||
.sort((a, b) => a.y - b.y)
|
||||
|
||||
const current = visible.findIndex((child) => child.id === input.currentID)
|
||||
if (current !== -1) return visible[current + (input.direction === "next" ? 1 : -1)] ?? null
|
||||
if (input.direction === "next") return visible.find((child) => child.y > input.scrollTop) ?? null
|
||||
return visible.findLast((child) => child.y < input.scrollTop) ?? null
|
||||
}
|
||||
@@ -285,6 +285,36 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
}, [])
|
||||
}
|
||||
|
||||
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
return rows.map((row) => {
|
||||
const id = rowBoundaryMessageID(row, byID)
|
||||
if (!id || seen.has(id)) return undefined
|
||||
seen.add(id)
|
||||
return id
|
||||
})
|
||||
}
|
||||
|
||||
function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMessageInfo>) {
|
||||
if (row.type === "message") {
|
||||
const message = messages.get(row.messageID)
|
||||
if (message?.type === "user" && message.text.trim()) return message.id
|
||||
return undefined
|
||||
}
|
||||
const messageID =
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? row.refs[0]?.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
if (message?.type === "assistant") return message.id
|
||||
}
|
||||
|
||||
export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const tool = message.content.find((part) => part.type === "tool" && part.id === partID)
|
||||
if (tool) return tool
|
||||
|
||||
@@ -97,76 +97,87 @@ export const DEFAULT_THEME = {
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
text: {
|
||||
default: "$hue.neutral.900",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.900",
|
||||
subdued: "$hue.neutral.600",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.900", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.900",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.600",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.600",
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.100",
|
||||
surface: {
|
||||
offset: "$hue.neutral.200",
|
||||
overlay: "$hue.neutral.300",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600", $focused: "$hue.interactive.700", $pressed: "$hue.interactive.800",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
secondary: {
|
||||
default: "$hue.neutral.200", $focused: "$hue.neutral.300", $pressed: "$hue.neutral.400",
|
||||
$disabled: "$hue.neutral.200",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$focused: "$background.action.primary.default",
|
||||
feedback: {
|
||||
error: { default: "$hue.red.700", subdued: "$hue.red.600" },
|
||||
warning: { default: "$hue.yellow.800", subdued: "$hue.yellow.700" },
|
||||
success: { default: "$hue.green.700", subdued: "$hue.green.600" },
|
||||
info: { default: "$hue.cyan.700", subdued: "$hue.cyan.600" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.100",
|
||||
surface: {
|
||||
offset: "$hue.neutral.200",
|
||||
overlay: "$hue.neutral.300",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.600",
|
||||
$focused: "$hue.interactive.700",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
$selected: "$hue.interactive.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
secondary: {
|
||||
default: "$hue.neutral.200",
|
||||
$focused: "$hue.neutral.300",
|
||||
$pressed: "$hue.neutral.400",
|
||||
$selected: "$hue.neutral.300",
|
||||
$disabled: "$hue.neutral.200",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.300",
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700", removed: "$hue.red.700", context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
syntax: {
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.300" },
|
||||
scrollbar: { default: "$hue.neutral.400" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.700",
|
||||
removed: "$hue.red.700",
|
||||
context: "$hue.neutral.900",
|
||||
hunkHeader: "$hue.purple.600",
|
||||
},
|
||||
background: { added: "$hue.green.100", removed: "$hue.red.100", context: "$hue.neutral.100" },
|
||||
highlight: { added: "$hue.green.600", removed: "$hue.red.600" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.600",
|
||||
background: { added: "$hue.green.200", removed: "$hue.red.200" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.600",
|
||||
keyword: "$hue.purple.600",
|
||||
function: "$hue.accent.600",
|
||||
@@ -177,7 +188,7 @@ export const DEFAULT_THEME = {
|
||||
operator: "$hue.cyan.600",
|
||||
punctuation: "$hue.neutral.900",
|
||||
},
|
||||
markdown: {
|
||||
markdown: {
|
||||
text: "$hue.neutral.900",
|
||||
heading: "$hue.purple.600",
|
||||
link: "$hue.accent.600",
|
||||
@@ -303,76 +314,87 @@ export const DEFAULT_THEME = {
|
||||
neutral: "$hue.gray",
|
||||
},
|
||||
text: {
|
||||
default: "$hue.neutral.100",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.100",
|
||||
subdued: "$hue.neutral.400",
|
||||
action: {
|
||||
primary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
secondary: { default: "$hue.neutral.100", $disabled: "$hue.neutral.500" },
|
||||
destructive: { default: "$hue.red.100", $disabled: "$hue.neutral.500" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$hue.neutral.100",
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
$focused: "$text.action.primary.default",
|
||||
$pressed: "$hue.neutral.100",
|
||||
$disabled: "$hue.neutral.500",
|
||||
$selected: "$hue.interactive.500",
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.900",
|
||||
surface: {
|
||||
offset: "$hue.neutral.800",
|
||||
overlay: "$hue.neutral.700",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.500", $focused: "$hue.interactive.600", $pressed: "$hue.interactive.800",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
secondary: {
|
||||
default: "$hue.neutral.800", $focused: "$hue.neutral.700", $pressed: "$hue.neutral.900",
|
||||
$disabled: "$hue.neutral.900",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600", $focused: "$hue.red.700", $pressed: "$hue.red.800",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$focused: "$background.action.primary.default",
|
||||
feedback: {
|
||||
error: { default: "$hue.red.300", subdued: "$hue.red.400" },
|
||||
warning: { default: "$hue.yellow.200", subdued: "$hue.yellow.300" },
|
||||
success: { default: "$hue.green.300", subdued: "$hue.green.400" },
|
||||
info: { default: "$hue.cyan.300", subdued: "$hue.cyan.400" },
|
||||
},
|
||||
},
|
||||
background: {
|
||||
default: "$hue.neutral.900",
|
||||
surface: {
|
||||
offset: "$hue.neutral.800",
|
||||
overlay: "$hue.neutral.700",
|
||||
},
|
||||
action: {
|
||||
primary: {
|
||||
default: "$hue.interactive.500",
|
||||
$focused: "$hue.interactive.600",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
$selected: "$hue.interactive.600",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
secondary: {
|
||||
default: "$hue.neutral.800",
|
||||
$focused: "$hue.neutral.700",
|
||||
$pressed: "$hue.neutral.900",
|
||||
$selected: "$hue.neutral.700",
|
||||
$disabled: "$hue.neutral.900",
|
||||
},
|
||||
destructive: {
|
||||
default: "$hue.red.600",
|
||||
$focused: "$hue.red.700",
|
||||
$pressed: "$hue.red.800",
|
||||
$selected: "$hue.red.700",
|
||||
$disabled: "$hue.neutral.800",
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300", removed: "$hue.red.300", context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
formfield: {
|
||||
default: "$background.default",
|
||||
$focused: "$background.action.primary.default",
|
||||
$pressed: "$hue.interactive.800",
|
||||
$disabled: "$background.default",
|
||||
$selected: "$background.formfield.default",
|
||||
},
|
||||
syntax: {
|
||||
feedback: {
|
||||
error: { default: "$background.default" },
|
||||
warning: { default: "$background.default" },
|
||||
success: { default: "$background.default" },
|
||||
info: { default: "$background.default" },
|
||||
},
|
||||
},
|
||||
border: { default: "$hue.neutral.700" },
|
||||
scrollbar: { default: "$hue.neutral.600" },
|
||||
diff: {
|
||||
text: {
|
||||
added: "$hue.green.300",
|
||||
removed: "$hue.red.300",
|
||||
context: "$hue.neutral.100",
|
||||
hunkHeader: "$hue.purple.400",
|
||||
},
|
||||
background: { added: "$hue.green.900", removed: "$hue.red.900", context: "$hue.neutral.900" },
|
||||
highlight: { added: "$hue.green.400", removed: "$hue.red.400" },
|
||||
lineNumber: {
|
||||
text: "$hue.neutral.400",
|
||||
background: { added: "$hue.green.800", removed: "$hue.red.800" },
|
||||
},
|
||||
},
|
||||
syntax: {
|
||||
comment: "$hue.neutral.400",
|
||||
keyword: "$hue.purple.400",
|
||||
function: "$hue.accent.400",
|
||||
@@ -383,7 +405,7 @@ export const DEFAULT_THEME = {
|
||||
operator: "$hue.cyan.400",
|
||||
punctuation: "$hue.neutral.100",
|
||||
},
|
||||
markdown: {
|
||||
markdown: {
|
||||
text: "$hue.neutral.100",
|
||||
heading: "$hue.purple.400",
|
||||
link: "$hue.accent.400",
|
||||
|
||||
@@ -12,7 +12,7 @@ export type HueAlias = Schema.Schema.Type<typeof HueAlias>
|
||||
export const ActionVariant = Schema.Literals(["primary", "secondary", "destructive"])
|
||||
export type ActionVariant = Schema.Schema.Type<typeof ActionVariant>
|
||||
|
||||
export const ActionState = Schema.Literals(["focused", "pressed", "disabled"])
|
||||
export const ActionState = Schema.Literals(["focused", "pressed", "selected", "disabled"])
|
||||
export type ActionState = Schema.Schema.Type<typeof ActionState>
|
||||
export type ActionStateKey = `$${ActionState}`
|
||||
|
||||
@@ -77,6 +77,7 @@ const StatefulColorDefinition = Schema.Struct({
|
||||
default: Schema.optional(ColorValue),
|
||||
$focused: Schema.optional(ColorValue),
|
||||
$pressed: Schema.optional(ColorValue),
|
||||
$selected: Schema.optional(ColorValue),
|
||||
$disabled: Schema.optional(ColorValue),
|
||||
})
|
||||
export type StatefulColorDefinition = Schema.Schema.Type<typeof StatefulColorDefinition>
|
||||
|
||||
@@ -54,6 +54,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
||||
default: "$text.default",
|
||||
$disabled: textMuted,
|
||||
$focused: selected,
|
||||
$selected: primary,
|
||||
},
|
||||
secondary: {
|
||||
default: "$text.default",
|
||||
@@ -82,7 +83,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
||||
overlay: backgroundMenu,
|
||||
},
|
||||
action: {
|
||||
primary: { default: "transparent", $focused: primary },
|
||||
primary: { default: "transparent", $focused: primary, $selected: primary },
|
||||
secondary: {
|
||||
default: "$background.default",
|
||||
$focused: color("backgroundElement"),
|
||||
@@ -150,11 +151,7 @@ function migrateMode(theme: Theme, mode: "light" | "dark"): ThemeFile["light"] {
|
||||
imageText: color("markdownImageText"),
|
||||
codeBlock: color("markdownCodeBlock"),
|
||||
},
|
||||
"@context:elevated": {
|
||||
background: {
|
||||
default: "$background.surface.offset",
|
||||
},
|
||||
},
|
||||
"@context:elevated": { background: { default: "$background.surface.offset" } },
|
||||
"@context:overlay": { background: { default: "$background.surface.overlay" } },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "../../../src/routes/session/message-navigation"
|
||||
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "First", time: { created: 0 } },
|
||||
assistant("assistant-1", "Response"),
|
||||
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
|
||||
]
|
||||
const children = [
|
||||
{ id: "user-1", y: 0 },
|
||||
{ id: "assistant-1", y: 20 },
|
||||
{ id: "user-2", y: 40 },
|
||||
]
|
||||
|
||||
test("adds only enough slack to align the selected message", () => {
|
||||
expect(messageNavigationSlack({ top: 80, viewportHeight: 50, scrollHeight: 100, currentSlack: 0 })).toBe(30)
|
||||
expect(messageNavigationSlack({ top: 20, viewportHeight: 50, scrollHeight: 130, currentSlack: 30 })).toBe(0)
|
||||
})
|
||||
|
||||
test("finds the next user message without stopping at an assistant message", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
}),
|
||||
).toEqual({ id: "user-2", y: 40, top: 40 })
|
||||
})
|
||||
|
||||
test("finds the previous user message without stopping at an assistant message", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
userOnly: true,
|
||||
}),
|
||||
).toEqual({ id: "user-1", y: 0, top: 0 })
|
||||
})
|
||||
|
||||
test("preserves navigation across both user and assistant messages", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
}),
|
||||
).toEqual({ id: "assistant-1", y: 20, top: 19 })
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "prev",
|
||||
children: children.map((child) => ({ ...child, y: child.y - 35 })),
|
||||
messages,
|
||||
scrollTop: 35,
|
||||
viewportY: 0,
|
||||
}),
|
||||
).toEqual({ id: "assistant-1", y: 20, top: 19 })
|
||||
})
|
||||
|
||||
test("uses the selected message when the viewport is too tall to scroll", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
userOnly: true,
|
||||
}),
|
||||
).toEqual({ id: "user-2", y: 40, top: 40 })
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
userOnly: true,
|
||||
}),
|
||||
).toEqual({ id: "user-1", y: 0, top: 0 })
|
||||
})
|
||||
|
||||
test("stops at the first and last selected user message", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
userOnly: true,
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
userOnly: true,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test("keeps the logical boundary when layout temporarily moves it outside the viewport", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
userOnly: true,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test("stops at the first and last message", () => {
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "next",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-2",
|
||||
}),
|
||||
).toBeNull()
|
||||
expect(
|
||||
findMessageBoundary({
|
||||
direction: "prev",
|
||||
children,
|
||||
messages,
|
||||
scrollTop: 0,
|
||||
viewportY: 0,
|
||||
currentID: "user-1",
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
function assistant(id: string, text: string): SessionMessageAssistant {
|
||||
return {
|
||||
type: "assistant",
|
||||
id,
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "test" },
|
||||
content: [{ type: "text", text }],
|
||||
time: { created: 1, completed: 1 },
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
import { messageBoundaryIDs, reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
|
||||
test("assigns assistant boundaries to the first rendered row instead of the first text row", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Question", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
]),
|
||||
]
|
||||
const rows = reduceSessionRows(messages)
|
||||
|
||||
expect(messageBoundaryIDs(rows, messages)).toEqual(["user-1", "assistant-1", undefined, undefined])
|
||||
})
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
|
||||
@@ -86,6 +86,17 @@ test("resolves a session move keybind", () => {
|
||||
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
|
||||
})
|
||||
|
||||
test("resolves message navigation defaults", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
|
||||
expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
|
||||
expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
|
||||
expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
|
||||
expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
|
||||
expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
|
||||
})
|
||||
|
||||
test("opens the subagent picker with down", () => {
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { type TextareaRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import {
|
||||
formatKeySequence,
|
||||
@@ -110,6 +111,64 @@ test("formats navigation keys as arrows", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("dispatches message navigation while the composer is focused", async () => {
|
||||
for (const kittyKeyboard of [false, true]) {
|
||||
const counts = {
|
||||
"session.first": 0,
|
||||
"session.message.previous": 0,
|
||||
"session.message.next": 0,
|
||||
"session.messages_last_user": 0,
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
const renderer = useRenderer()
|
||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
||||
const config = createResolvedKeymapConfig()
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const commands = Object.keys(counts) as (keyof typeof counts)[]
|
||||
const offLayer = keymap.registerLayer({
|
||||
commands: commands.map((name) => ({
|
||||
name,
|
||||
run() {
|
||||
counts[name]++
|
||||
},
|
||||
})),
|
||||
bindings: commands.flatMap((command) => config.keybinds.get(command)),
|
||||
})
|
||||
let textarea: TextareaRenderable
|
||||
onMount(() => textarea.focus())
|
||||
onCleanup(() => {
|
||||
offLayer()
|
||||
offKeymap()
|
||||
})
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<textarea ref={(value) => (textarea = value)} />
|
||||
</OpencodeKeymapProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { kittyKeyboard })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
app.mockInput.pressArrow("up", { meta: true })
|
||||
app.mockInput.pressArrow("down", { meta: true })
|
||||
app.mockInput.pressKey("HOME", { meta: true })
|
||||
app.mockInput.pressKey("END", { meta: true })
|
||||
expect(counts).toEqual({
|
||||
"session.first": 1,
|
||||
"session.message.previous": 1,
|
||||
"session.message.next": 1,
|
||||
"session.messages_last_user": 1,
|
||||
})
|
||||
} finally {
|
||||
app.renderer.currentFocusedEditor?.blur()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const counts: Record<string, Record<string, number>> = {}
|
||||
|
||||
@@ -169,13 +228,13 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 3, "model.list": 0 },
|
||||
autocomplete: {
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"session.first": 3,
|
||||
"model.list": 0,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# TUI theme screenshot gallery
|
||||
|
||||
This gallery runs one repeatable OpenCode Drive workflow for every V1 theme in a directory. Each theme runs in
|
||||
light and dark mode and produces a flat set of screenshots covering the home screen, markdown, permission prompt,
|
||||
form prompt, and session switcher.
|
||||
|
||||
It expects `opencode-drive` to be installed globally and available on `PATH`.
|
||||
|
||||
The checked-in fixtures include selected built-in OpenCode themes and community themes from
|
||||
[`vaprdev/opencode-themes`](https://github.com/vaprdev/opencode-themes). Theme-specific attribution and licenses
|
||||
remain documented in that source repository.
|
||||
|
||||
## Run
|
||||
|
||||
Place theme JSON files in `themes/`, then run from the repository root:
|
||||
|
||||
```sh
|
||||
bun packages/tui/test/theme/gallery/run.ts
|
||||
```
|
||||
|
||||
Custom input and output directories can be passed as positional arguments:
|
||||
|
||||
```sh
|
||||
bun packages/tui/test/theme/gallery/run.ts ./my-themes ./theme-screenshots
|
||||
```
|
||||
|
||||
Output files are named `<theme>-<mode>-<state>.png` in one flat directory. Existing files with the same names are
|
||||
overwritten. Runs are sequential so each isolated OpenCode Drive instance owns its own ports and artifacts.
|
||||
|
||||
The current TUI discovers V1 theme files and migrates them to V2 at runtime. Native V2 JSON files are reported as
|
||||
unsupported and make the runner exit nonzero; they are not converted or silently rendered with a fallback theme.
|
||||
|
||||
Before changing the scenario, typecheck it with:
|
||||
|
||||
```sh
|
||||
opencode-drive check packages/tui/test/theme/gallery/scenario.ts
|
||||
```
|
||||
@@ -1,79 +0,0 @@
|
||||
import path from "node:path"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../../../../..")
|
||||
const themes = path.resolve(Bun.argv[2] ?? path.join(import.meta.dir, "themes"))
|
||||
const screenshots = path.resolve(Bun.argv[3] ?? path.join(import.meta.dir, "screenshots"))
|
||||
const scenario = path.join(import.meta.dir, "scenario.ts")
|
||||
const files = await Array.fromAsync(new Bun.Glob("**/*.json").scan({ cwd: themes, absolute: true }))
|
||||
|
||||
if (!files.length) {
|
||||
console.error(`No JSON themes found in ${themes}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await mkdir(screenshots, { recursive: true })
|
||||
|
||||
const failures: string[] = []
|
||||
const slugs = new Set<string>()
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const source = await Bun.file(file).json().catch(() => undefined)
|
||||
const slug = path.basename(file, ".json").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
||||
if (!slug) {
|
||||
failures.push(`${file}: filename does not produce a usable theme prefix`)
|
||||
continue
|
||||
}
|
||||
if (slugs.has(slug)) {
|
||||
failures.push(`${file}: duplicate theme prefix "${slug}"`)
|
||||
continue
|
||||
}
|
||||
slugs.add(slug)
|
||||
|
||||
if (!isRecord(source)) {
|
||||
failures.push(`${file}: invalid JSON theme object`)
|
||||
continue
|
||||
}
|
||||
if (source.version === 2) {
|
||||
failures.push(`${file}: native V2 theme loading is not supported by the TUI yet`)
|
||||
continue
|
||||
}
|
||||
if (!isRecord(source.theme)) {
|
||||
failures.push(`${file}: expected a V1 theme with a "theme" object`)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const mode of ["light", "dark"] as const) {
|
||||
const name = `theme-gallery-${slug}-${mode}-${process.pid}`
|
||||
console.log(`\n[${slug}/${mode}] capturing screenshots`)
|
||||
const child = Bun.spawn(
|
||||
["opencode-drive", "start", "--name", name, "--script", scenario, "--dev", root],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_THEME_GALLERY_FILE: file,
|
||||
OPENCODE_THEME_GALLERY_MODE: mode,
|
||||
OPENCODE_THEME_GALLERY_OUTPUT: screenshots,
|
||||
OPENCODE_THEME_GALLERY_SLUG: slug,
|
||||
},
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
},
|
||||
)
|
||||
const exit = await child.exited
|
||||
if (exit !== 0) failures.push(`${file} (${mode}): OpenCode Drive exited with ${exit}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error("\nTheme gallery completed with errors:")
|
||||
failures.forEach((failure) => console.error(`- ${failure}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`\nScreenshots written to ${screenshots}`)
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import path from "node:path"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { defineScript, wait } from "opencode-drive"
|
||||
|
||||
const file = required("OPENCODE_THEME_GALLERY_FILE")
|
||||
const mode = required("OPENCODE_THEME_GALLERY_MODE")
|
||||
const output = required("OPENCODE_THEME_GALLERY_OUTPUT")
|
||||
const slug = required("OPENCODE_THEME_GALLERY_SLUG")
|
||||
const themeName = `gallery-${slug}`
|
||||
|
||||
export default defineScript({
|
||||
async setup({ fs, config }) {
|
||||
await fs.writeFile("README.md", "# Theme gallery fixture\n\nA stable project for OpenCode TUI screenshots.\n")
|
||||
await fs.writeFile("src/example.ts", "export const palette = ['neutral', 'accent', 'interactive']\n")
|
||||
await fs.writeFile(`.opencode/themes/${themeName}.json`, await Bun.file(file).text())
|
||||
await fs.writeFile(
|
||||
".opencode/cli.json",
|
||||
`${JSON.stringify({
|
||||
theme: { name: themeName, mode },
|
||||
}, undefined, 2)}\n`,
|
||||
)
|
||||
config.permissions = [
|
||||
{ action: "*", resource: "*", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
]
|
||||
},
|
||||
async run({ llm, ui }) {
|
||||
await mkdir(output, { recursive: true })
|
||||
llm.title((_request, index) => (index === 0 ? "Theme gallery" : `Theme gallery ${index + 1}`))
|
||||
|
||||
await ui.waitFor((state) => state.focused.editor)
|
||||
await ui.waitFor("local")
|
||||
await wait(750)
|
||||
await capture(ui, "01-home")
|
||||
|
||||
await ui.submit("Show me a compact markdown theme specimen")
|
||||
await llm.send(
|
||||
llm.reasoning("I will provide a stable markdown sample for the theme gallery."),
|
||||
llm.text(
|
||||
[
|
||||
"# Theme Review",
|
||||
"",
|
||||
"A compact response with **strong text**, *emphasis*, and an `inline token`.",
|
||||
"",
|
||||
"> Good themes preserve hierarchy without losing contrast.",
|
||||
"",
|
||||
"- Neutral surfaces",
|
||||
"- Interactive accents",
|
||||
"- Success, warning, and error feedback",
|
||||
"",
|
||||
"```ts",
|
||||
"const mode = 'gallery'",
|
||||
"```",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
await ui.waitFor("Theme Review")
|
||||
await capture(ui, "02-markdown")
|
||||
|
||||
const permission = llm.send(
|
||||
llm.toolCall({
|
||||
id: "theme-gallery-permission",
|
||||
index: 0,
|
||||
name: "shell",
|
||||
input: { command: "printf 'theme gallery permission'" },
|
||||
}),
|
||||
)
|
||||
await ui.submit("Run a protected command so I can inspect the permission prompt")
|
||||
await ui.waitFor("Permission required")
|
||||
await capture(ui, "03-permission")
|
||||
await ui.enter()
|
||||
await permission
|
||||
await llm.send(llm.text("The protected command completed successfully."))
|
||||
await ui.waitFor("completed successfully")
|
||||
|
||||
const form = llm.send(
|
||||
llm.toolCall({
|
||||
id: "theme-gallery-question",
|
||||
index: 0,
|
||||
name: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
header: "Theme direction",
|
||||
question: "Which direction should this theme emphasize?",
|
||||
options: [
|
||||
{ label: "Balanced", description: "Keep surfaces and accents evenly weighted" },
|
||||
{ label: "Expressive", description: "Give accent colors more visual presence" },
|
||||
{ label: "Quiet", description: "Favor neutral surfaces and restrained contrast" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await ui.submit("Ask me for a theme direction")
|
||||
await ui.waitFor("Which direction should this theme emphasize?")
|
||||
await capture(ui, "04-form")
|
||||
await ui.enter()
|
||||
await form
|
||||
await llm.send(llm.text("The theme review form was submitted."))
|
||||
await ui.waitFor("form was submitted")
|
||||
|
||||
await ui.submit("/new")
|
||||
await ui.waitFor((state) => state.focused.editor)
|
||||
await ui.submit("Create a second session for the session switcher gallery")
|
||||
await llm.send(llm.text("This second session makes the switcher state visible."))
|
||||
await ui.submit("/sessions")
|
||||
await ui.waitFor("Sessions")
|
||||
await capture(ui, "05-session-switcher")
|
||||
},
|
||||
})
|
||||
|
||||
async function capture(ui: { screenshot(name?: string): Promise<string> }, state: string) {
|
||||
const source = await ui.screenshot(`${slug}-${mode}-${state}`)
|
||||
await Bun.write(path.join(output, `${slug}-${mode}-${state}.png`), Bun.file(source))
|
||||
}
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 23 KiB |