Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGESET.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ Newest first. Each entry names the upstream range brought in.

### Features

- **#161** The TUI collapses each run of consecutive encrypted reasoning parts from OpenAI Responses models, such as GPT-6 Luna and Sol through `swx-azure`, into one `Thought <count> · <duration>` line that updates as each part arrives, in the live stream and in loaded history. A tool call, visible text, or a message error starts a new line; non-encrypted reasoning renders as before (swxtchio/swx-opencode#53). _Upstreamable._
- **#159** Add `skills.exclude`, glob patterns for `SKILL.md` files that skip copies in every skill root, such as hidden per-host installs and `.trash` folders. Patterns match relative to each root, and excluded skills are not loaded, so they cannot shadow the canonical skill of the same name (swxtchio/swx-opencode#59). _Upstreamable._
- **#73** V1 server prompt queue core (swxtchio/swx-opencode#68): every V1 prompt except `noReply` is admitted to the durable `session_prompt_queue` table before it becomes a user message. `prompt`, `prompt_async` and `command` take `delivery: "steer" | "queue"`; the default steer reaches the next step as before, while `queue` waits until the run would go idle and then runs as its own turn, one per boundary in admission order. Steers wait behind a pending compaction; an abort or error parks pending prompts until the next admission or wake; `/session/:id/queue` lists, withdraws (404 `QueueItemNotPending` once delivered), restores and re-delivers them, with `session.queue.updated` carrying the full list. A sync prompt answers with the final reply of the turn its prompt joined, recorded for it where the loop ends that turn: steers share the reply of the turn they steered, and a queued prompt gets its own turn's; an editor's withdraw that wins answers it with 409 `PromptWithdrawn`. Deliberately, a compaction whose summary turn stopped on an abort or error is not retried, where upstream V1 retried it on the next prompt with that prompt as its parent (summarising it instead of answering it); if the context is still too large, the next overflow check starts a new compaction. Also closes the V1 lost wakeup where a prompt admitted as a run finished joined it and was never answered. Known ordering-only residuals: a `noReply` prompt and the synthetic message a subtask writes to have its output summarised are stored directly rather than through the queue, so they can land out of admission order relative to pending items. _Fork-only; the lost-wakeup fix is upstreamable._
- **#64** Show each configured router member's request count and share so far in `llmrouter/auto` labels in the TUI and run CLI. (swxtchio/swx-opencode#60). _Fork-only._
Expand Down
131 changes: 87 additions & 44 deletions packages/tui/src/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { AssistantModelLabel } from "./message-model"
import { groupOpaqueReasoning, reasoningText } from "./opaque-reasoning"
import type { SessionStepMessage } from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { sessionEpilogue } from "../../util/presentation"
Expand Down Expand Up @@ -1386,7 +1387,7 @@ export function Session() {
/>
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage
<AssistantMessageView
last={lastAssistant()?.id === message.id}
message={message as AssistantMessage}
parts={sync.data.part[message.id] ?? []}
Expand Down Expand Up @@ -1570,7 +1571,7 @@ function UserMessage(props: {
)
}

function AssistantMessage(props: {
export function AssistantMessageView(props: {
message: AssistantMessage
parts: Part[]
last: boolean
Expand All @@ -1597,22 +1598,32 @@ function AssistantMessage(props: {
return props.message.time.completed - user.time.created
})

// Grouping is per message: an error renders after the parts, so it ends a run by position.
const grouped = createMemo(() => groupOpaqueReasoning(props.parts))

const childShortcut = useCommandShortcut("session.child.first")
const backgroundShortcut = useCommandShortcut("session.background")

return (
<>
<For each={props.parts}>
<For each={grouped().rows}>
{(part, index) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
return (
<Show when={component()}>
<Dynamic
last={index() === props.parts.length - 1}
component={component()}
part={part as any}
message={props.message}
/>
<Show
when={grouped().runs.has(part.id)}
fallback={
<Show when={component()}>
<Dynamic
last={index() === grouped().rows.length - 1}
component={component()}
part={part as any}
message={props.message}
/>
</Show>
}
>
<OpaqueReasoningRunView members={() => grouped().runs.get(part.id) ?? []} message={props.message} />
</Show>
)
}}
Expand Down Expand Up @@ -1714,42 +1725,20 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m
// layout never shifts. Click to open the full markdown block, click to close.
const [expanded, setExpanded] = createSignal(false)

const content = createMemo(() => {
// OpenRouter encrypts some reasoning blocks; drop the placeholder.
return props.part.text.replace("[REDACTED]", "").trim()
})
const opaque = createMemo(() => !content() && Boolean(props.part.metadata))
const content = createMemo(() => reasoningText(props.part))
const status = createMemo(() => assistantStatus(sync, props.message))
const unresolved = createMemo(
() =>
props.part.time.end === undefined &&
props.message.time.completed === undefined &&
props.message.error === undefined &&
status() === "unknown",
)
const isDone = createMemo(
() =>
props.part.time.end !== undefined ||
props.message.time.completed !== undefined ||
props.message.error !== undefined ||
status() === "failed" ||
unresolved(),
)
const state = createMemo(() => reasoningState(props.part, props.message, status()))
const inMinimal = createMemo(() => ctx.thinkingMode() === "hide")
const duration = createMemo(() => {
const end = props.part.time.end
return end === undefined ? 0 : Math.max(0, end - props.part.time.start)
})
const summary = createMemo(() => reasoningSummary(content()))
const syntax = createSyntaxStyleMemo(() => generateSubtleSyntax(theme))

const toggle = () => {
if (!inMinimal() || opaque()) return
if (!inMinimal()) return
setExpanded((prev) => !prev)
}

return (
<Show when={content() || opaque()}>
<Show when={content()}>
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
paddingLeft={3}
Expand All @@ -1759,16 +1748,15 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m
>
<box onMouseUp={toggle}>
<ReasoningHeader
toggleable={inMinimal() && !opaque()}
toggleable={inMinimal()}
open={!inMinimal() || expanded()}
done={isDone()}
unknown={unresolved()}
done={state().done}
unknown={state().unresolved}
title={summary().title}
duration={isDone() ? Locale.duration(duration()) : undefined}
encrypted={opaque()}
duration={state().done ? Locale.duration(state().duration) : undefined}
/>
</box>
<Show when={!opaque() && (!inMinimal() || expanded()) && summary().body}>
<Show when={(!inMinimal() || expanded()) && summary().body}>
<box paddingLeft={inMinimal() ? 2 : 0} marginTop={1}>
<code
filetype="markdown"
Expand All @@ -1786,22 +1774,77 @@ export function ReasoningPartView(props: { last: boolean; part: ReasoningPart; m
)
}

function OpaqueReasoningRunView(props: { members: () => ReasoningPart[]; message: AssistantMessage }) {
const ctx = use()
const { theme } = useTheme()
const sync = useSync()
const status = createMemo(() => assistantStatus(sync, props.message))
const states = createMemo(() => props.members().map((part) => reasoningState(part, props.message, status())))
const done = createMemo(() => states().every((state) => state.done))
const unknown = createMemo(() => states().some((state) => state.unresolved))
const duration = createMemo(() => states().reduce((total, state) => total + state.duration, 0))
const fg = () =>
ctx.thinkingMode() !== "hide"
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
// The label is one text node for the whole lifecycle; only its content and the spinner beside it change.
// A selection on the label therefore survives the in-flight and completed transitions.
const label = () => {
if (unknown()) return "Thinking status unknown"
if (!done() && states().length === 1) return "Thinking"
return `Thought${states().length > 1 ? ` ${states().length}` : ""} · ${Locale.duration(duration())}`
}

return (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
paddingLeft={3}
marginTop={1}
flexDirection="column"
flexShrink={0}
>
<box flexDirection="row" gap={1}>
<Show when={!done() && !unknown()}>
<Spinner color={fg()} />
</Show>
<text fg={unknown() ? theme.textMuted : fg()} wrapMode="none">
{label()}
</text>
</box>
</box>
)
}

function reasoningState(part: ReasoningPart, message: AssistantMessage, status: ReturnType<typeof assistantStatus>) {
const unresolved =
part.time.end === undefined &&
message.time.completed === undefined &&
message.error === undefined &&
status === "unknown"
const done =
part.time.end !== undefined ||
message.time.completed !== undefined ||
message.error !== undefined ||
status === "failed" ||
unresolved
const end = part.time.end
return { done, unresolved, duration: end === undefined ? 0 : Math.max(0, end - part.time.start) }
}

function ReasoningHeader(props: {
toggleable: boolean
open: boolean
done: boolean
unknown?: boolean
title: string | null
duration?: string
encrypted?: boolean
}) {
const { theme } = useTheme()
const fg = () =>
props.open
? RGBA.fromValues(theme.warning.r, theme.warning.g, theme.warning.b, theme.thinkingOpacity)
: theme.warning
const completed = () => {
if (props.encrypted) return `Thought${props.duration ? ` · ${props.duration}` : ""}`
const detail = [props.title, props.duration].filter(Boolean).join(" · ")
return `${props.toggleable ? (props.open ? "- " : "+ ") : ""}Thought${detail ? `: ${detail}` : ""}`
}
Expand Down
47 changes: 47 additions & 0 deletions packages/tui/src/routes/session/opaque-reasoning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Part, ReasoningPart } from "@opencode-ai/sdk/v2"

// A run's row is its first part, the same object on every recompute, so the view keyed on it persists while
// the run grows. The later members are not rows; they reach the view through `runs`.
export type OpaqueReasoningGroups = {
rows: Part[]
runs: Map<string, ReasoningPart[]>
}

// OpenRouter encrypts some reasoning blocks and marks them with a placeholder.
export function reasoningText(part: ReasoningPart) {
return part.text.replace("[REDACTED]", "").trim()
}

// Encrypted reasoning from OpenAI Responses has provider metadata and no text,
// so it has nothing to show but a duration.
export function isOpaqueReasoning(part: ReasoningPart) {
return !reasoningText(part) && Boolean(part.metadata)
}

// A run ends at a part that renders a line of its own. Every other part stays in
// order, so a part type the view does not render yet still reaches the view.
export function groupOpaqueReasoning(parts: readonly Part[]): OpaqueReasoningGroups {
const breakers = parts.flatMap((part, index) => (breaksRun(part) ? [index] : []))
const starts = [0, ...breakers.map((index) => index + 1)]
const runs = new Map<string, ReasoningPart[]>()
const rows = starts.flatMap((start, index) => {
const end = breakers[index] ?? parts.length
const segment = parts.slice(start, end)
const members = segment.filter(isOpaquePart)
const leader = members[0]
if (leader) runs.set(leader.id, members)
return [...segment.filter((part) => !isOpaquePart(part) || part === leader), ...parts.slice(end, end + 1)]
})
return { rows, runs }
}

function breaksRun(part: Part) {
if (part.type === "tool") return true
if (part.type === "text") return part.text.trim() !== ""
if (part.type === "reasoning") return reasoningText(part) !== ""
return false
}

function isOpaquePart(part: Part): part is ReasoningPart {
return part.type === "reasoning" && isOpaqueReasoning(part)
}
60 changes: 42 additions & 18 deletions packages/tui/test/routes/session/assistant-activity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { onCleanup } from "solid-js"
import type { JSX } from "@opentui/solid"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { AssistantMessage, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk/v2"
import { LocalProvider } from "../../../src/context/local"
import { LocationProvider } from "../../../src/context/location"
import { RouteProvider } from "../../../src/context/route"
import { useSync } from "../../../src/context/sync"
Expand All @@ -18,7 +19,7 @@ import {
activeTaskRetry,
assistantStatus,
SessionContext,
ReasoningPartView,
AssistantMessageView,
ToolPartView,
} from "../../../src/routes/session"
import { SPINNER_FRAMES } from "../../../src/component/spinner"
Expand Down Expand Up @@ -147,17 +148,33 @@ function History(props: {
return (
<TuiConfigProvider config={config}>
<ThemeProvider mode="dark">
<LocationProvider>
<SessionContext.Provider value={sessionContext}>
<box flexDirection="column">
<ReasoningPartView last={true} message={props.old.info} part={props.oldReasoning} />
<ToolPartView last={true} message={props.old.info} part={props.oldRead} />
<ToolPartView last={true} message={props.shell.info} part={props.oldShell} />
<ReasoningPartView last={true} message={props.current.info} part={props.currentReasoning} />
{props.currentRead && <ToolPartView last={true} message={props.current.info} part={props.currentRead} />}
</box>
</SessionContext.Provider>
</LocationProvider>
<TaskInteractionProviders sessionID={props.sessionID}>
<LocalProvider>
<LocationProvider>
<SessionContext.Provider value={sessionContext}>
<box flexDirection="column">
<AssistantMessageView
last={true}
message={props.old.info}
parts={[props.oldReasoning]}
modelMessages={undefined}
/>
<ToolPartView last={true} message={props.old.info} part={props.oldRead} />
<ToolPartView last={true} message={props.shell.info} part={props.oldShell} />
<AssistantMessageView
last={true}
message={props.current.info}
parts={[props.currentReasoning]}
modelMessages={undefined}
/>
{props.currentRead && (
<ToolPartView last={true} message={props.current.info} part={props.currentRead} />
)}
</box>
</SessionContext.Provider>
</LocationProvider>
</LocalProvider>
</TaskInteractionProviders>
</ThemeProvider>
</TuiConfigProvider>
)
Expand Down Expand Up @@ -198,12 +215,19 @@ function PreAssistantHistory(props: {
<SessionContext.Provider value={sessionContext}>
<TaskInteractionProviders sessionID={props.sessionID}>
<TaskSeed prepare={props.prepare}>
<box flexDirection="column">
<ReasoningPartView last={true} message={props.old.info} part={props.oldReasoning} />
<ToolPartView last={true} message={props.old.info} part={props.oldRead} />
<ToolPartView last={true} message={props.shell.info} part={props.shellPart} />
<ToolPartView last={true} message={props.task.info} part={props.taskPart} />
</box>
<LocalProvider>
<box flexDirection="column">
<AssistantMessageView
last={true}
message={props.old.info}
parts={[props.oldReasoning]}
modelMessages={undefined}
/>
<ToolPartView last={true} message={props.old.info} part={props.oldRead} />
<ToolPartView last={true} message={props.shell.info} part={props.shellPart} />
<ToolPartView last={true} message={props.task.info} part={props.taskPart} />
</box>
</LocalProvider>
</TaskSeed>
</TaskInteractionProviders>
</SessionContext.Provider>
Expand Down
Loading
Loading