Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4460e4a
timeline tokens
pschiel Jan 15, 2026
1fbb8c2
Message stats and details dialog
xl0 Jan 25, 2026
6094ca6
Fix viewport scrolling for agent messages in session timeline
ariane-emory Jan 26, 2026
283f5ab
Add n and p keybinds for user message navigation in timeline
ariane-emory Jan 26, 2026
8523d9c
Add n and p keybinds for user message navigation in timeline
ariane-emory Jan 26, 2026
760aee0
tidy: whitespace
ariane-emory Jan 26, 2026
d3dcf25
Merge dev into pschiel--timeline-tokens
ariane-emory Jan 26, 2026
56b60f9
Fix missing createSignal import
ariane-emory Jan 26, 2026
7510a09
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Jan 27, 2026
fe87d67
Merge dev into feat/pschiel--timeline-tokens
ariane-emory Jan 29, 2026
28e9fa7
Fix TypeScript errors introduced by merge
ariane-emory Jan 29, 2026
2a9d8ce
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Jan 29, 2026
1bfecbc
Merge dev into feat/pschiel--timeline-tokens
ariane-emory Jan 30, 2026
41a1b5a
Change n/p keybinds to use Alt modifier
ariane-emory Jan 31, 2026
77c6cb4
Filter out '[no content]' messages from timeline display and selection
ariane-emory Jan 31, 2026
cce833d
Merge dev into feat/pschiel--timeline-tokens
ariane-emory Feb 1, 2026
d8fccec
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 2, 2026
6234a1d
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 3, 2026
9cd08b5
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 4, 2026
89019c4
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 4, 2026
11d2268
Merge branch dev into feat/pschiel--timeline-tokens
ariane-emory Feb 6, 2026
ba8b500
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 6, 2026
d94c296
Merge branch 'dev' into feat/pschiel--timeline-tokens
ariane-emory Feb 6, 2026
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
5 changes: 3 additions & 2 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, createSignal, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
Expand Down Expand Up @@ -30,7 +30,8 @@ import FileTree from "@/components/file-tree"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { UserMessage, AssistantMessage } from "@opencode-ai/sdk/v2"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
Expand Down
282 changes: 282 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/dialog-inspect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { TextAttributes, ScrollBoxRenderable } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { useDialog } from "../../ui/dialog"
import { useTheme } from "@tui/context/theme"
import type { Part, AssistantMessage } from "@opencode-ai/sdk/v2"
import { Clipboard } from "../../util/clipboard"
import { useToast } from "../../ui/toast"
import { createSignal, Show } from "solid-js"

interface DialogInspectProps {
message: AssistantMessage
parts: Part[]
}

function toYaml(obj: any, indent = 0): string {
if (obj === null) return "null"
if (obj === undefined) return "undefined"
if (typeof obj !== "object") return String(obj)

const spaces = " ".repeat(indent)

if (Array.isArray(obj)) {
if (obj.length === 0) return "[]"
return obj
.map((item) => {
if (typeof item === "object" && item !== null) {
return `\n${spaces}- ${toYaml(item, indent + 2).trimStart()}`
}
return `\n${spaces}- ${String(item)}`
})
.join("")
}

const keys = Object.keys(obj)
if (keys.length === 0) return "{}"

return keys
.map((key) => {
const value = obj[key]
if (typeof value === "object" && value !== null) {
if (Array.isArray(value) && value.length === 0) return `\n${spaces}${key}: []`
if (Object.keys(value).length === 0) return `\n${spaces}${key}: {}`
return `\n${spaces}${key}:${toYaml(value, indent + 2)}`
}
if (typeof value === "string" && value.includes("\n")) {
return `\n${spaces}${key}: |\n${value
.split("\n")
.map((l) => spaces + " " + l)
.join("\n")}`
}
return `\n${spaces}${key}: ${String(value)}`
})
.join("")
}

function PartView(props: { part: Part; theme: any; syntax: any }) {
const { part, theme, syntax } = props

if (part.type === "text") {
return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
Text
</text>
<text fg={theme.text}>{part.text}</text>
</box>
)
}

if (part.type === "patch") {
return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
Patch ({part.hash.substring(0, 7)})
</text>
<text fg={theme.text}>Updated files:</text>
<box flexDirection="column" marginLeft={2}>
{part.files.map((f) => (
<text fg={theme.text}>- {f}</text>
))}
</box>
</box>
)
}

if (part.type === "tool") {
return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
Tool Use: {part.tool} ({part.state.status})
</text>
<box marginTop={1}>
<text fg={theme.textMuted}>Input:</text>
<text fg={theme.text}>{toYaml(part.state.input).trim()}</text>
</box>
<Show when={part.state.status === "completed" && (part.state as any).output}>
<box marginTop={1}>
<text fg={theme.textMuted}>Output:</text>
<text fg={theme.text}>{(part.state as any).output}</text>
</box>
</Show>
<Show when={part.state.status === "error" && (part.state as any).error}>
<box marginTop={1}>
<text fg={theme.error}>Error:</text>
<text fg={theme.error}>{(part.state as any).error}</text>
</box>
</Show>
</box>
)
}

if (part.type === "reasoning") {
return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
Reasoning
</text>
<text fg={theme.text}>{part.text}</text>
</box>
)
}

if (part.type === "file") {
return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
File Attachment
</text>
<text fg={theme.text}>Name: {part.filename || "Unknown"}</text>
<text fg={theme.textMuted}>Mime: {part.mime}</text>
<text fg={theme.textMuted}>URL: {part.url}</text>
</box>
)
}

return (
<box flexDirection="column" borderColor={theme.borderSubtle} borderStyle="single" padding={1}>
<text attributes={TextAttributes.BOLD} fg={theme.textMuted}>
{part.type}
</text>
<code
filetype="json"
content={JSON.stringify(part, null, 2)}
syntaxStyle={syntax()}
drawUnstyledText={true}
fg={theme.text}
/>
</box>
)
}

export function DialogInspect(props: DialogInspectProps) {
const { theme, syntax } = useTheme()
const dialog = useDialog()
const toast = useToast()

// State for raw mode
const [showRaw, setShowRaw] = createSignal(false)

// Set dialog size to large
dialog.setSize("xlarge")

// Ref to scrollbox for keyboard scrolling
let scrollRef: ScrollBoxRenderable | undefined

const handleCopy = () => {
Clipboard.copy(JSON.stringify(props.parts, null, 2))
.then(() => toast.show({ message: "Message copied to clipboard", variant: "success" }))
.catch(() => toast.show({ message: "Failed to copy message", variant: "error" }))
}

const handleToggleRaw = () => {
setShowRaw((prev) => !prev)
}

// Keyboard shortcuts
useKeyboard((evt) => {
// C - Copy
if (evt.name === "c" && !evt.ctrl && !evt.meta) {
evt.preventDefault()
handleCopy()
}

// S - Toggle raw/parsed
if (evt.name === "s" && !evt.ctrl && !evt.meta) {
evt.preventDefault()
handleToggleRaw()
}

// Arrow keys - scroll 1 line
if (evt.name === "down") {
evt.preventDefault()
scrollRef?.scrollBy(1)
}

if (evt.name === "up") {
evt.preventDefault()
scrollRef?.scrollBy(-1)
}

// Page keys - scroll page
if (evt.name === "pagedown") {
evt.preventDefault()
if (scrollRef) {
scrollRef.scrollBy(scrollRef.height)
}
}

if (evt.name === "pageup") {
evt.preventDefault()
if (scrollRef) {
scrollRef.scrollBy(-scrollRef.height)
}
}
})

return (
<box paddingLeft={2} paddingRight={2} gap={1} height="100%">
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Message Inspection ({props.message.id})
</text>
<box onMouseUp={() => dialog.clear()}>
<text fg={theme.textMuted}>[esc]</text>
</box>
</box>

<scrollbox
ref={(r: ScrollBoxRenderable) => {
scrollRef = r
}}
flexGrow={1}
border={["bottom", "top"]}
borderColor={theme.borderSubtle}
>
<Show
when={!showRaw()}
fallback={
<code
filetype="json"
content={JSON.stringify(props.parts, null, 2)}
syntaxStyle={syntax()}
drawUnstyledText={true}
fg={theme.text}
/>
}
>
<box flexDirection="column" gap={1}>
{props.parts
.filter((p) => !["step-start", "step-finish", "reasoning"].includes(p.type))
.map((part) => (
<PartView part={part} theme={theme} syntax={syntax} />
))}
</box>
</Show>
</scrollbox>

<box flexDirection="row" justifyContent="space-between" paddingBottom={1} flexShrink={0} gap={1}>
<box flexDirection="row" gap={2}>
<text fg={theme.textMuted}>↑↓ scroll</text>
<text fg={theme.textMuted}>PgUp/PgDn page</text>
<text fg={theme.textMuted}>S toggle</text>
<text fg={theme.textMuted}>C copy</text>
</box>
<box flexDirection="row" gap={1}>
<box
paddingLeft={2}
paddingRight={2}
borderStyle="single"
borderColor={theme.borderSubtle}
onMouseUp={handleToggleRaw}
>
<text fg={theme.text}>{showRaw() ? "Show Parsed" : "Show Raw"}</text>
</box>
<box paddingLeft={2} paddingRight={2} borderStyle="single" borderColor={theme.border} onMouseUp={handleCopy}>
<text fg={theme.text}>Copy</text>
</box>
</box>
</box>
</box>
)
}
Loading