Skip to content

Commit 39c88f9

Browse files
authored
Improve v2 session message rendering (#25634)
1 parent 0df2bb0 commit 39c88f9

17 files changed

Lines changed: 677 additions & 275 deletions

File tree

packages/core/src/global.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ export const layer = Layer.effect(
7171
Effect.sync(() => Service.of(make())),
7272
)
7373

74+
export const defaultLayer = layer
75+
7476
export const layerWith = (input: Partial<Interface>) =>
7577
Layer.effect(
7678
Service,

packages/opencode/src/cli/cmd/tui/context/sync-v2.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
1111
import { useSDK } from "./sdk"
1212

1313
function activeAssistant(messages: SessionMessage[]) {
14-
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
14+
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
1515
if (index < 0) return
1616
const assistant = messages[index]
1717
return assistant?.type === "assistant" ? assistant : undefined
1818
}
1919

2020
function activeCompaction(messages: SessionMessage[]) {
21-
const index = messages.findLastIndex((message) => message.type === "compaction")
21+
const index = messages.findIndex((message) => message.type === "compaction")
2222
if (index < 0) return
2323
const compaction = messages[index]
2424
return compaction?.type === "compaction" ? compaction : undefined
2525
}
2626

2727
function activeShell(messages: SessionMessage[], callID: string) {
28-
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
28+
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
2929
if (index < 0) return
3030
const shell = messages[index]
3131
return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
7474
switch (event.type) {
7575
case "session.next.prompted": {
7676
update(event.properties.sessionID, (draft) => {
77-
draft.push({
77+
draft.unshift({
7878
id: event.id,
7979
type: "user",
8080
text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
8787
}
8888
case "session.next.synthetic":
8989
update(event.properties.sessionID, (draft) => {
90-
draft.push({
90+
draft.unshift({
9191
id: event.id,
9292
type: "synthetic",
9393
sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
9898
break
9999
case "session.next.shell.started":
100100
update(event.properties.sessionID, (draft) => {
101-
draft.push({
101+
draft.unshift({
102102
id: event.id,
103103
type: "shell",
104104
callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
120120
update(event.properties.sessionID, (draft) => {
121121
const currentAssistant = activeAssistant(draft)
122122
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
123-
draft.push({
123+
draft.unshift({
124124
id: event.id,
125125
type: "assistant",
126126
agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
259259
break
260260
case "session.next.compaction.started":
261261
update(event.properties.sessionID, (draft) => {
262-
draft.push({
262+
draft.unshift({
263263
id: event.id,
264264
type: "compaction",
265265
reason: event.properties.reason,

packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx

Lines changed: 122 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
55
import { useTheme } from "@tui/context/theme"
66
import { useLocal } from "@tui/context/local"
77
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
8-
import type { SyntaxStyle } from "@opentui/core"
8+
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
99
import { Locale } from "@/util/locale"
1010
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
1111
import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
4444
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
4545
const renderedMessages = createMemo(() => messages().toReversed())
4646
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
47+
const lastUserCreated = (index: number) =>
48+
renderedMessages()
49+
.slice(0, index)
50+
.findLast((message) => message.type === "user")?.time.created
4751

4852
createEffect(() => {
4953
void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
8387
last={lastAssistant()?.id === message.id}
8488
syntax={syntax()}
8589
subtleSyntax={subtleSyntax()}
90+
start={lastUserCreated(index())}
8691
/>
8792
</Match>
8893
<Match when={message.type === "synthetic"}>
89-
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
94+
<></>
9095
</Match>
9196
<Match when={message.type === "shell"}>
9297
<ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
146151
<box
147152
id={props.message.id}
148153
border={["left"]}
149-
borderColor={theme.primary}
154+
borderColor={theme.secondary}
150155
customBorderChars={SplitBorder.customBorderChars}
151156
marginTop={props.index === 0 ? 0 : 1}
152157
flexShrink={0}
153-
>
154-
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
155-
<Show
156-
when={props.message.text.trim()}
157-
fallback={
158-
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
159-
}
160-
>
161-
<text fg={theme.text}>{props.message.text}</text>
162-
</Show>
163-
<Show when={attachments().length}>
164-
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
165-
<For each={props.message.files ?? []}>
166-
{(file) => (
167-
<text fg={theme.text}>
168-
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
169-
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
170-
</text>
171-
)}
172-
</For>
173-
<For each={props.message.agents ?? []}>
174-
{(agent) => (
175-
<text fg={theme.text}>
176-
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
177-
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
178-
</text>
179-
)}
180-
</For>
181-
</box>
182-
</Show>
183-
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
184-
</box>
185-
</box>
186-
)
187-
}
188-
189-
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
190-
const { theme } = useTheme()
191-
return (
192-
<box
193-
id={props.message.id}
194-
border={["left"]}
195-
borderColor={theme.backgroundElement}
196-
customBorderChars={SplitBorder.customBorderChars}
197-
marginTop={props.index === 0 ? 0 : 1}
198-
paddingLeft={2}
199158
paddingTop={1}
200159
paddingBottom={1}
160+
paddingLeft={2}
201161
backgroundColor={theme.backgroundPanel}
202-
flexShrink={0}
203162
>
204-
<text fg={theme.textMuted}>Synthetic</text>
205163
<text fg={theme.text}>{props.message.text}</text>
164+
<Show when={attachments().length}>
165+
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
166+
<For each={props.message.files ?? []}>
167+
{(file) => (
168+
<text fg={theme.text}>
169+
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
170+
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
171+
</text>
172+
)}
173+
</For>
174+
<For each={props.message.agents ?? []}>
175+
{(agent) => (
176+
<text fg={theme.text}>
177+
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
178+
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
179+
</text>
180+
)}
181+
</For>
182+
</box>
183+
</Show>
206184
</box>
207185
)
208186
}
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
237215
}
238216

239217
function CompactionMessage(props: { message: SessionMessageCompaction }) {
240-
const { theme } = useTheme()
218+
const { theme, syntax } = useTheme()
241219
return (
242220
<box
243221
marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
248226
flexShrink={0}
249227
>
250228
<Show when={props.message.summary}>
251-
<text fg={theme.textMuted}>{props.message.summary}</text>
229+
{(summary) => (
230+
<box paddingLeft={3} paddingTop={1}>
231+
<code
232+
filetype="markdown"
233+
drawUnstyledText={false}
234+
streaming={false}
235+
syntaxStyle={syntax()}
236+
content={summary().trim()}
237+
conceal={true}
238+
fg={theme.text}
239+
/>
240+
</box>
241+
)}
252242
</Show>
253243
</box>
254244
)
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
294284
last: boolean
295285
syntax: SyntaxStyle
296286
subtleSyntax: SyntaxStyle
287+
start?: number
297288
}) {
298289
const { theme } = useTheme()
299290
const local = useLocal()
300291
const duration = createMemo(() => {
301292
if (!props.message.time.completed) return 0
302-
return props.message.time.completed - props.message.time.created
293+
return props.message.time.completed - (props.start ?? props.message.time.created)
303294
})
304295
const model = createMemo(() => {
305296
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
361352
const { theme } = useTheme()
362353
return (
363354
<Show when={props.part.text.trim()}>
364-
<box paddingLeft={3} marginTop={1} flexShrink={0}>
355+
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
365356
<code
366357
filetype="markdown"
367358
drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
521512
part: SessionMessageAssistantTool
522513
}) {
523514
const { theme } = useTheme()
515+
const renderer = useRenderer()
516+
const [margin, setMargin] = createSignal(0)
517+
const [hover, setHover] = createSignal(false)
518+
const [showError, setShowError] = createSignal(false)
524519
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
520+
const complete = createMemo(() => !!props.complete)
525521
const denied = createMemo(() => {
526522
const message = error()
527523
if (!message) return false
528524
return (
529525
message.includes("QuestionRejectedError") ||
530526
message.includes("rejected permission") ||
527+
message.includes("specified a rule") ||
531528
message.includes("user dismissed")
532529
)
533530
})
531+
const fg = createMemo(() => {
532+
if (error()) return theme.error
533+
if (complete()) return theme.textMuted
534+
return theme.text
535+
})
536+
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
534537
return (
535-
<box marginTop={1} paddingLeft={3} flexShrink={0}>
536-
<Switch>
537-
<Match when={props.spinner}>
538-
<Spinner color={theme.text}>{props.children}</Spinner>
539-
</Match>
540-
<Match when={true}>
541-
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
542-
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
543-
{props.icon} {props.children}
544-
</Show>
545-
</text>
546-
</Match>
547-
</Switch>
548-
<Show when={error() && !denied()}>
549-
<text fg={theme.error}>{error()}</text>
550-
</Show>
538+
<box
539+
marginTop={margin()}
540+
paddingLeft={3}
541+
flexShrink={0}
542+
flexDirection="row"
543+
gap={1}
544+
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
545+
onMouseOver={() => error() && setHover(true)}
546+
onMouseOut={() => setHover(false)}
547+
onMouseUp={() => {
548+
if (!error()) return
549+
if (renderer.getSelection()?.getSelectedText()) return
550+
setShowError((prev) => !prev)
551+
}}
552+
renderBefore={function () {
553+
const el = this as BoxRenderable
554+
const parent = el.parent
555+
if (!parent) return
556+
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
557+
if (!previous) {
558+
setMargin(0)
559+
return
560+
}
561+
if (previous.id.startsWith("text")) setMargin(1)
562+
}}
563+
>
564+
<box flexShrink={0}>
565+
<Switch>
566+
<Match when={props.spinner}>
567+
<Spinner color={theme.text} />
568+
</Match>
569+
<Match when={complete()}>
570+
<text fg={fg()} attributes={attributes()}>
571+
{props.icon}
572+
</text>
573+
</Match>
574+
<Match when={true}>
575+
<text fg={fg()} attributes={attributes()}>
576+
~
577+
</text>
578+
</Match>
579+
</Switch>
580+
</box>
581+
<box flexGrow={1}>
582+
<box>
583+
<Switch>
584+
<Match when={complete()}>
585+
<text fg={fg()} attributes={attributes()}>
586+
{props.children}
587+
</text>
588+
</Match>
589+
<Match when={true}>
590+
<text fg={fg()} attributes={attributes()}>
591+
{props.pending}
592+
</text>
593+
</Match>
594+
</Switch>
595+
</box>
596+
<Show when={showError() && error()}>
597+
<box>
598+
<text fg={theme.error}>{error()}</text>
599+
</box>
600+
</Show>
601+
</box>
551602
</box>
552603
)
553604
}

packages/opencode/src/id/id.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const prefixes = {
1313
tool: "tool",
1414
workspace: "wrk",
1515
entry: "ent",
16+
account: "act",
1617
} as const
1718

1819
export function schema(prefix: keyof typeof prefixes) {

packages/opencode/src/session/processor.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as Log from "@opencode-ai/core/util/log"
2222
import { isRecord } from "@/util/record"
2323
import { EventV2 } from "@/v2/event"
2424
import { SessionEvent } from "@/v2/session-event"
25+
import { Modelv2 } from "@/v2/model"
2526
import * as DateTime from "effect/DateTime"
2627

2728
const DOOM_LOOP_THRESHOLD = 3
@@ -432,9 +433,9 @@ export const layer: Layer.Layer<
432433
sessionID: ctx.sessionID,
433434
agent: input.assistantMessage.agent,
434435
model: {
435-
id: ctx.model.id,
436-
providerID: ctx.model.providerID,
437-
variant: input.assistantMessage.variant,
436+
id: Modelv2.ID.make(ctx.model.id),
437+
providerID: Modelv2.ProviderID.make(ctx.model.providerID),
438+
variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
438439
},
439440
snapshot: ctx.snapshot,
440441
timestamp: DateTime.makeUnsafe(Date.now()),
@@ -655,7 +656,7 @@ export const layer: Layer.Layer<
655656
EventV2.run(SessionEvent.Step.Failed.Sync, {
656657
sessionID: ctx.sessionID,
657658
error: {
658-
type: error.name,
659+
type: "unknown",
659660
message: errorMessage(e),
660661
},
661662
timestamp: DateTime.makeUnsafe(Date.now()),

0 commit comments

Comments
 (0)