Compare commits

...
Sign in to create a new pull request.

6 commits

Author SHA1 Message Date
Dax Raad
34968f0b9f fix(tui): align v2 inline tool content 2026-05-03 15:18:54 -04:00
Dax Raad
d46b22c281 fix(tui): calculate v2 assistant duration from user prompt 2026-05-03 15:11:05 -04:00
Dax Raad
a1bfbd7852 fix(tui): keep v2 realtime messages newest first 2026-05-03 15:09:45 -04:00
Dax Raad
59814286af fix(tui): collapse v2 inline tool spacing 2026-05-03 15:08:18 -04:00
Dax Raad
2364c2cc9b chore(v2): remove session message model converter 2026-05-03 14:03:51 -04:00
Dax Raad
a8b02aec38 feat(v2): add session message model conversion 2026-05-03 14:01:24 -04:00
8 changed files with 160 additions and 46 deletions

View file

@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) { function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed) const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return if (index < 0) return
const assistant = messages[index] const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined return assistant?.type === "assistant" ? assistant : undefined
} }
function activeCompaction(messages: SessionMessage[]) { function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction") const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return if (index < 0) return
const compaction = messages[index] const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined return compaction?.type === "compaction" ? compaction : undefined
} }
function activeShell(messages: SessionMessage[], callID: string) { function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID) const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return if (index < 0) return
const shell = messages[index] const shell = messages[index]
return shell?.type === "shell" ? shell : undefined return shell?.type === "shell" ? shell : undefined
@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) { switch (event.type) {
case "session.next.prompted": { case "session.next.prompted": {
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "user", type: "user",
text: event.properties.prompt.text, text: event.properties.prompt.text,
@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
} }
case "session.next.synthetic": case "session.next.synthetic":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "synthetic", type: "synthetic",
sessionID: event.properties.sessionID, sessionID: event.properties.sessionID,
@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.shell.started": case "session.next.shell.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "shell", type: "shell",
callID: event.properties.callID, callID: event.properties.callID,
@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft) const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "assistant", type: "assistant",
agent: event.properties.agent, agent: event.properties.agent,
@ -143,6 +143,15 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot } currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.properties.snapshot }
}) })
break break
case "session.next.step.failed":
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (!currentAssistant) return
currentAssistant.time.completed = event.properties.timestamp
currentAssistant.finish = "error"
currentAssistant.error = event.properties.error
})
break
case "session.next.text.started": case "session.next.text.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
activeAssistant(draft)?.content.push({ type: "text", text: "" }) activeAssistant(draft)?.content.push({ type: "text", text: "" })
@ -210,7 +219,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
match.time.completed = event.properties.timestamp match.time.completed = event.properties.timestamp
}) })
break break
case "session.next.tool.error": case "session.next.tool.failed":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
const match = latestTool(activeAssistant(draft), event.properties.callID) const match = latestTool(activeAssistant(draft), event.properties.callID)
if (match?.state.status !== "running") return if (match?.state.status !== "running") return
@ -250,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break break
case "session.next.compaction.started": case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => { update(event.properties.sessionID, (draft) => {
draft.push({ draft.unshift({
id: event.id, id: event.id,
type: "compaction", type: "compaction",
reason: event.properties.reason, reason: event.properties.reason,

View file

@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme" import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core" import type { BoxRenderable, SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path" import path from "path"
@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? []) const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed()) const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant")) const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => { createEffect(() => {
void sync.session.message.sync(props.sessionID) void sync.session.message.sync(props.sessionID)
@ -83,6 +87,7 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id} last={lastAssistant()?.id === message.id}
syntax={syntax()} syntax={syntax()}
subtleSyntax={subtleSyntax()} subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/> />
</Match> </Match>
<Match when={message.type === "synthetic"}> <Match when={message.type === "synthetic"}>
@ -294,12 +299,13 @@ function AssistantMessage(props: {
last: boolean last: boolean
syntax: SyntaxStyle syntax: SyntaxStyle
subtleSyntax: SyntaxStyle subtleSyntax: SyntaxStyle
start?: number
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const local = useLocal() const local = useLocal()
const duration = createMemo(() => { const duration = createMemo(() => {
if (!props.message.time.completed) return 0 if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created return props.message.time.completed - (props.start ?? props.message.time.created)
}) })
const model = createMemo(() => { const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : "" const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@ -521,6 +527,7 @@ function InlineTool(props: {
part: SessionMessageAssistantTool part: SessionMessageAssistantTool
}) { }) {
const { theme } = useTheme() const { theme } = useTheme()
const [margin, setMargin] = createSignal(0)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined)) const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const denied = createMemo(() => { const denied = createMemo(() => {
const message = error() const message = error()
@ -532,21 +539,46 @@ function InlineTool(props: {
) )
}) })
return ( return (
<box marginTop={1} paddingLeft={3} flexShrink={0}> <box
<Switch> marginTop={margin()}
<Match when={props.spinner}> paddingLeft={3}
<Spinner color={theme.text}>{props.children}</Spinner> flexShrink={0}
</Match> renderBefore={function () {
<Match when={true}> const el = this as BoxRenderable
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}> const parent = el.parent
<Show fallback={<>~ {props.pending}</>} when={props.complete}> if (!parent) return
{props.icon} {props.children} if (el.height > 1) {
</Show> setMargin(1)
</text> return
</Match> }
</Switch> const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.height > 1 || previous.id.startsWith("text-")) setMargin(1)
}}
>
<box flexDirection="row">
<box width={3} flexShrink={0}>
<Show
when={props.spinner}
fallback={<text fg={props.complete ? theme.textMuted : theme.text}>{props.complete ? props.icon : "~"}</text>}
>
<Spinner color={theme.text} />
</Show>
</box>
<text fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={props.pending} when={props.complete || props.spinner}>
{props.children}
</Show>
</text>
</box>
<Show when={error() && !denied()}> <Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text> <box flexDirection="row">
<box width={3} flexShrink={0} />
<text fg={theme.error}>{error()}</text>
</box>
</Show> </Show>
</box> </box>
) )

View file

@ -405,7 +405,7 @@ export const layer: Layer.Layer<
case "tool-error": { case "tool-error": {
const toolCall = yield* readToolCall(value.toolCallId) const toolCall = yield* readToolCall(value.toolCallId)
// TODO(v2): Temporary dual-write while migrating session messages to v2 events. // TODO(v2): Temporary dual-write while migrating session messages to v2 events.
EventV2.run(SessionEvent.Tool.Error.Sync, { EventV2.run(SessionEvent.Tool.Failed.Sync, {
sessionID: ctx.sessionID, sessionID: ctx.sessionID,
callID: value.toolCallId, callID: value.toolCallId,
error: { error: {
@ -650,6 +650,17 @@ export const layer: Layer.Layer<
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error }) yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return return
} }
if (!ctx.assistantMessage.summary) {
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID,
error: {
type: error.name,
message: errorMessage(e),
},
timestamp: DateTime.makeUnsafe(Date.now()),
})
}
ctx.assistantMessage.error = error ctx.assistantMessage.error = error
yield* bus.publish(Session.Event.Error, { yield* bus.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID, sessionID: ctx.assistantMessage.sessionID,

View file

@ -161,6 +161,9 @@ export default [
SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.Step.Ended.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data }) update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
}), }),
SyncEvent.project(SessionEvent.Step.Failed.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data })
}),
SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.Text.Started.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data }) update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
}), }),
@ -181,8 +184,8 @@ export default [
SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.Tool.Success.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data }) update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
}), }),
SyncEvent.project(SessionEvent.Tool.Error.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.Tool.Failed.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.error", data }) update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data })
}), }),
SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => { SyncEvent.project(SessionEvent.Reasoning.Started.Sync, (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data }) update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })

View file

@ -22,6 +22,11 @@ const Base = {
sessionID: SessionID, sessionID: SessionID,
} }
const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
})
export const AgentSwitched = EventV2.define({ export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched", type: "session.next.agent.switched",
aggregate: "sessionID", aggregate: "sessionID",
@ -128,6 +133,16 @@ export namespace Step {
}, },
}) })
export type Ended = Schema.Schema.Type<typeof Ended> export type Ended = Schema.Schema.Type<typeof Ended>
export const Failed = EventV2.define({
type: "session.next.step.failed",
aggregate: "sessionID",
schema: {
...Base,
error: Error,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
} }
export namespace Text { export namespace Text {
@ -275,23 +290,20 @@ export namespace Tool {
}) })
export type Success = Schema.Schema.Type<typeof Success> export type Success = Schema.Schema.Type<typeof Success>
export const Error = EventV2.define({ export const Failed = EventV2.define({
type: "session.next.tool.error", type: "session.next.tool.failed",
aggregate: "sessionID", aggregate: "sessionID",
schema: { schema: {
...Base, ...Base,
callID: Schema.String, callID: Schema.String,
error: Schema.Struct({ error: Error,
type: Schema.String,
message: Schema.String,
}),
provider: Schema.Struct({ provider: Schema.Struct({
executed: Schema.Boolean, executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
}), }),
}, },
}) })
export type Error = Schema.Schema.Type<typeof Error> export type Failed = Schema.Schema.Type<typeof Failed>
} }
export const RetryError = Schema.Struct({ export const RetryError = Schema.Struct({
@ -359,6 +371,7 @@ export const All = Schema.Union(
Shell.Ended, Shell.Ended,
Step.Started, Step.Started,
Step.Ended, Step.Ended,
Step.Failed,
Text.Started, Text.Started,
Text.Delta, Text.Delta,
Text.Ended, Text.Ended,
@ -368,7 +381,7 @@ export const All = Schema.Union(
Tool.Called, Tool.Called,
Tool.Progress, Tool.Progress,
Tool.Success, Tool.Success,
Tool.Error, Tool.Failed,
Reasoning.Started, Reasoning.Started,
Reasoning.Delta, Reasoning.Delta,
Reasoning.Ended, Reasoning.Ended,

View file

@ -199,6 +199,17 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
) )
} }
}, },
"session.next.step.failed": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = "error"
draft.error = event.data.error
}),
)
}
},
"session.next.text.started": () => { "session.next.text.started": () => {
if (currentAssistant) { if (currentAssistant) {
adapter.updateAssistant( adapter.updateAssistant(
@ -314,7 +325,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
) )
} }
}, },
"session.next.tool.error": (event) => { "session.next.tool.failed": (event) => {
if (currentAssistant) { if (currentAssistant) {
adapter.updateAssistant( adapter.updateAssistant(
produce(currentAssistant, (draft) => { produce(currentAssistant, (draft) => {

View file

@ -152,7 +152,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Message.Assistan
write: Schema.Finite, write: Schema.Finite,
}), }),
}).pipe(Schema.optional), }).pipe(Schema.optional),
error: Schema.String.pipe(Schema.optional), error: SessionEvent.Step.Failed.fields.data.fields.error.pipe(Schema.optional),
time: Schema.Struct({ time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis, created: V2Schema.DateTimeUtcFromMillis,
completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional), completed: V2Schema.DateTimeUtcFromMillis.pipe(Schema.optional),

View file

@ -58,6 +58,7 @@ export type Event =
| EventSessionNextShellEnded | EventSessionNextShellEnded
| EventSessionNextStepStarted | EventSessionNextStepStarted
| EventSessionNextStepEnded | EventSessionNextStepEnded
| EventSessionNextStepFailed
| EventSessionNextTextStarted | EventSessionNextTextStarted
| EventSessionNextTextDelta | EventSessionNextTextDelta
| EventSessionNextTextEnded | EventSessionNextTextEnded
@ -70,7 +71,7 @@ export type Event =
| EventSessionNextToolCalled | EventSessionNextToolCalled
| EventSessionNextToolProgress | EventSessionNextToolProgress
| EventSessionNextToolSuccess | EventSessionNextToolSuccess
| EventSessionNextToolError | EventSessionNextToolFailed
| EventSessionNextRetried | EventSessionNextRetried
| EventSessionNextCompactionStarted | EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta | EventSessionNextCompactionDelta
@ -823,6 +824,7 @@ export type GlobalEvent = {
| EventSessionNextShellEnded | EventSessionNextShellEnded
| EventSessionNextStepStarted | EventSessionNextStepStarted
| EventSessionNextStepEnded | EventSessionNextStepEnded
| EventSessionNextStepFailed
| EventSessionNextTextStarted | EventSessionNextTextStarted
| EventSessionNextTextDelta | EventSessionNextTextDelta
| EventSessionNextTextEnded | EventSessionNextTextEnded
@ -835,7 +837,7 @@ export type GlobalEvent = {
| EventSessionNextToolCalled | EventSessionNextToolCalled
| EventSessionNextToolProgress | EventSessionNextToolProgress
| EventSessionNextToolSuccess | EventSessionNextToolSuccess
| EventSessionNextToolError | EventSessionNextToolFailed
| EventSessionNextRetried | EventSessionNextRetried
| EventSessionNextCompactionStarted | EventSessionNextCompactionStarted
| EventSessionNextCompactionDelta | EventSessionNextCompactionDelta
@ -857,6 +859,7 @@ export type GlobalEvent = {
| SyncEventSessionNextShellEnded | SyncEventSessionNextShellEnded
| SyncEventSessionNextStepStarted | SyncEventSessionNextStepStarted
| SyncEventSessionNextStepEnded | SyncEventSessionNextStepEnded
| SyncEventSessionNextStepFailed
| SyncEventSessionNextTextStarted | SyncEventSessionNextTextStarted
| SyncEventSessionNextTextDelta | SyncEventSessionNextTextDelta
| SyncEventSessionNextTextEnded | SyncEventSessionNextTextEnded
@ -869,7 +872,7 @@ export type GlobalEvent = {
| SyncEventSessionNextToolCalled | SyncEventSessionNextToolCalled
| SyncEventSessionNextToolProgress | SyncEventSessionNextToolProgress
| SyncEventSessionNextToolSuccess | SyncEventSessionNextToolSuccess
| SyncEventSessionNextToolError | SyncEventSessionNextToolFailed
| SyncEventSessionNextRetried | SyncEventSessionNextRetried
| SyncEventSessionNextCompactionStarted | SyncEventSessionNextCompactionStarted
| SyncEventSessionNextCompactionDelta | SyncEventSessionNextCompactionDelta
@ -1973,6 +1976,22 @@ export type SyncEventSessionNextStepEnded = {
} }
} }
export type SyncEventSessionNextStepFailed = {
type: "sync"
name: "session.next.step.failed.1"
id: string
seq: number
aggregateID: "sessionID"
data: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
}
}
export type SyncEventSessionNextTextStarted = { export type SyncEventSessionNextTextStarted = {
type: "sync" type: "sync"
name: "session.next.text.started.1" name: "session.next.text.started.1"
@ -2157,9 +2176,9 @@ export type SyncEventSessionNextToolSuccess = {
} }
} }
export type SyncEventSessionNextToolError = { export type SyncEventSessionNextToolFailed = {
type: "sync" type: "sync"
name: "session.next.tool.error.1" name: "session.next.tool.failed.1"
id: string id: string
seq: number seq: number
aggregateID: "sessionID" aggregateID: "sessionID"
@ -2710,6 +2729,19 @@ export type EventSessionNextStepEnded = {
} }
} }
export type EventSessionNextStepFailed = {
id: string
type: "session.next.step.failed"
properties: {
timestamp: number
sessionID: string
error: {
type: string
message: string
}
}
}
export type EventSessionNextTextStarted = { export type EventSessionNextTextStarted = {
id: string id: string
type: "session.next.text.started" type: "session.next.text.started"
@ -2870,9 +2902,9 @@ export type EventSessionNextToolSuccess = {
} }
} }
export type EventSessionNextToolError = { export type EventSessionNextToolFailed = {
id: string id: string
type: "session.next.tool.error" type: "session.next.tool.failed"
properties: { properties: {
timestamp: number timestamp: number
sessionID: string sessionID: string
@ -3162,7 +3194,10 @@ export type SessionMessageAssistant = {
write: number write: number
} }
} }
error?: string error?: {
type: string
message: string
}
} }
export type SessionMessageCompaction = { export type SessionMessageCompaction = {