336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
import { createScrollbackWriter } from "@opentui/solid"
|
|
import { TextRenderable, type ColorInput, type ScrollbackRenderContext, type ScrollbackWriter } from "@opentui/core"
|
|
import { Match, Switch, createMemo } from "solid-js"
|
|
import { entryBody, entryFlags } from "./entry.body"
|
|
import { entryColor, entryLook, entrySyntax } from "./scrollback.shared"
|
|
import { toolFiletype, toolStructuredFinal } from "./tool"
|
|
import { RUN_THEME_FALLBACK, transparent, type RunTheme } from "./theme"
|
|
import type { EntryLayout, RunEntryBody, ScrollbackOptions, StreamCommit } from "./types"
|
|
|
|
function todoText(item: { status: string; content: string }): string {
|
|
if (item.status === "completed") {
|
|
return `[✓] ${item.content}`
|
|
}
|
|
|
|
if (item.status === "cancelled") {
|
|
return `~[ ] ${item.content}~`
|
|
}
|
|
|
|
if (item.status === "in_progress") {
|
|
return `[•] ${item.content}`
|
|
}
|
|
|
|
return `[ ] ${item.content}`
|
|
}
|
|
|
|
function todoColor(theme: RunTheme, status: string) {
|
|
return status === "in_progress" ? theme.footer.warning : theme.block.muted
|
|
}
|
|
|
|
export function entryGroupKey(commit: StreamCommit): string | undefined {
|
|
if (!commit.partID) {
|
|
return undefined
|
|
}
|
|
|
|
if (toolStructuredFinal(commit)) {
|
|
return `tool:${commit.partID}:final`
|
|
}
|
|
|
|
return `${commit.kind}:${commit.partID}`
|
|
}
|
|
|
|
export function sameEntryGroup(left: StreamCommit | undefined, right: StreamCommit): boolean {
|
|
if (!left) {
|
|
return false
|
|
}
|
|
|
|
const current = entryGroupKey(left)
|
|
const next = entryGroupKey(right)
|
|
return Boolean(current && next && current === next)
|
|
}
|
|
|
|
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
|
if (commit.kind === "tool") {
|
|
if (body.type === "structured" || body.type === "markdown") {
|
|
return "block"
|
|
}
|
|
|
|
if (
|
|
commit.phase === "progress" &&
|
|
commit.toolState === "completed" &&
|
|
body.type === "text" &&
|
|
body.content.includes("\n")
|
|
) {
|
|
return "block"
|
|
}
|
|
|
|
return "inline"
|
|
}
|
|
|
|
if (commit.kind === "reasoning") {
|
|
return "block"
|
|
}
|
|
|
|
if (commit.kind === "error") {
|
|
return "block"
|
|
}
|
|
|
|
return "block"
|
|
}
|
|
|
|
export function separatorRows(
|
|
prev: StreamCommit | undefined,
|
|
next: StreamCommit,
|
|
body: RunEntryBody = entryBody(next),
|
|
): number {
|
|
if (!prev || sameEntryGroup(prev, next)) {
|
|
return 0
|
|
}
|
|
|
|
if (entryLayout(prev) === "inline" && entryLayout(next, body) === "inline") {
|
|
return 0
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
export function RunEntryContent(props: {
|
|
commit: StreamCommit
|
|
body?: RunEntryBody
|
|
theme?: RunTheme
|
|
opts?: ScrollbackOptions
|
|
width?: number
|
|
}) {
|
|
const theme = createMemo(() => props.theme ?? RUN_THEME_FALLBACK)
|
|
const body = createMemo(() => props.body ?? entryBody(props.commit))
|
|
const style = createMemo(() => entryLook(props.commit, theme().entry))
|
|
const syntax = createMemo(() => entrySyntax(props.commit, theme()))
|
|
const color = createMemo(() => entryColor(props.commit, theme()))
|
|
const suppressBackgrounds = createMemo(() => props.opts?.suppressBackgrounds === true)
|
|
const diffBg = (color: ColorInput) => (suppressBackgrounds() ? transparent : color)
|
|
const streaming = createMemo(() => props.commit.phase === "progress")
|
|
const text = createMemo(() => {
|
|
const next = body()
|
|
return next.type === "text" ? next : undefined
|
|
})
|
|
const code = createMemo(() => {
|
|
const next = body()
|
|
return next.type === "code" ? next : undefined
|
|
})
|
|
const structured = createMemo(() => {
|
|
const next = body()
|
|
return next.type === "structured" ? next.snapshot : undefined
|
|
})
|
|
const markdown = createMemo(() => {
|
|
const next = body()
|
|
return next.type === "markdown" ? next : undefined
|
|
})
|
|
const code_snapshot = createMemo(() => {
|
|
const next = structured()
|
|
return next?.kind === "code" ? next : undefined
|
|
})
|
|
const diff_snapshot = createMemo(() => {
|
|
const next = structured()
|
|
return next?.kind === "diff" ? next : undefined
|
|
})
|
|
const task_snapshot = createMemo(() => {
|
|
const next = structured()
|
|
return next?.kind === "task" ? next : undefined
|
|
})
|
|
const todo_snapshot = createMemo(() => {
|
|
const next = structured()
|
|
return next?.kind === "todo" ? next : undefined
|
|
})
|
|
const question_snapshot = createMemo(() => {
|
|
const next = structured()
|
|
return next?.kind === "question" ? next : undefined
|
|
})
|
|
|
|
return (
|
|
<Switch fallback={null}>
|
|
<Match when={text()}>
|
|
<text width="100%" wrapMode="word" fg={style().fg} attributes={style().attrs}>
|
|
{text()!.content}
|
|
</text>
|
|
</Match>
|
|
<Match when={code()}>
|
|
<code
|
|
width="100%"
|
|
wrapMode="word"
|
|
filetype={code()!.filetype}
|
|
drawUnstyledText={false}
|
|
streaming={streaming()}
|
|
syntaxStyle={syntax()}
|
|
content={code()!.content}
|
|
fg={color()}
|
|
/>
|
|
</Match>
|
|
<Match when={code_snapshot()}>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{code_snapshot()!.title}
|
|
</text>
|
|
<box width="100%" paddingLeft={1}>
|
|
<line_number width="100%" fg={theme().block.muted} minWidth={3} paddingRight={1}>
|
|
<code
|
|
width="100%"
|
|
wrapMode="char"
|
|
filetype={toolFiletype(code_snapshot()!.file)}
|
|
streaming={false}
|
|
syntaxStyle={syntax()}
|
|
content={code_snapshot()!.content}
|
|
fg={theme().block.text}
|
|
/>
|
|
</line_number>
|
|
</box>
|
|
</box>
|
|
</Match>
|
|
<Match when={diff_snapshot()}>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
{diff_snapshot()!.items.map((item) => (
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{item.title}
|
|
</text>
|
|
{item.diff.trim() ? (
|
|
<box width="100%" paddingLeft={1}>
|
|
<diff
|
|
diff={item.diff}
|
|
view="unified"
|
|
filetype={toolFiletype(item.file)}
|
|
syntaxStyle={syntax()}
|
|
showLineNumbers={true}
|
|
width="100%"
|
|
wrapMode="word"
|
|
fg={theme().block.text}
|
|
addedBg={diffBg(theme().block.diffAddedBg)}
|
|
removedBg={diffBg(theme().block.diffRemovedBg)}
|
|
contextBg={diffBg(theme().block.diffContextBg)}
|
|
addedSignColor={theme().block.diffHighlightAdded}
|
|
removedSignColor={theme().block.diffHighlightRemoved}
|
|
lineNumberFg={theme().block.diffLineNumber}
|
|
lineNumberBg={diffBg(theme().block.diffContextBg)}
|
|
addedLineNumberBg={diffBg(theme().block.diffAddedLineNumberBg)}
|
|
removedLineNumberBg={diffBg(theme().block.diffRemovedLineNumberBg)}
|
|
/>
|
|
</box>
|
|
) : (
|
|
<text width="100%" wrapMode="word" fg={theme().block.diffRemoved}>
|
|
-{item.deletions ?? 0} line{item.deletions === 1 ? "" : "s"}
|
|
</text>
|
|
)}
|
|
</box>
|
|
))}
|
|
</box>
|
|
</Match>
|
|
<Match when={task_snapshot()}>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{task_snapshot()!.title}
|
|
</text>
|
|
<box width="100%" flexDirection="column" gap={0} paddingLeft={1}>
|
|
{task_snapshot()!.rows.map((row) => (
|
|
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
|
{row}
|
|
</text>
|
|
))}
|
|
{task_snapshot()!.tail ? (
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{task_snapshot()!.tail}
|
|
</text>
|
|
) : null}
|
|
</box>
|
|
</box>
|
|
</Match>
|
|
<Match when={todo_snapshot()}>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
# Todos
|
|
</text>
|
|
<box width="100%" flexDirection="column" gap={0}>
|
|
{todo_snapshot()!.items.map((item) => (
|
|
<text width="100%" wrapMode="word" fg={todoColor(theme(), item.status)}>
|
|
{todoText(item)}
|
|
</text>
|
|
))}
|
|
{todo_snapshot()!.tail ? (
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{todo_snapshot()!.tail}
|
|
</text>
|
|
) : null}
|
|
</box>
|
|
</box>
|
|
</Match>
|
|
<Match when={question_snapshot()}>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
# Questions
|
|
</text>
|
|
<box width="100%" flexDirection="column" gap={1}>
|
|
{question_snapshot()!.items.map((item) => (
|
|
<box width="100%" flexDirection="column" gap={0}>
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{item.question}
|
|
</text>
|
|
<text width="100%" wrapMode="word" fg={theme().block.text}>
|
|
{item.answer}
|
|
</text>
|
|
</box>
|
|
))}
|
|
{question_snapshot()!.tail ? (
|
|
<text width="100%" wrapMode="word" fg={theme().block.muted}>
|
|
{question_snapshot()!.tail}
|
|
</text>
|
|
) : null}
|
|
</box>
|
|
</box>
|
|
</Match>
|
|
<Match when={markdown()}>
|
|
<markdown
|
|
width="100%"
|
|
syntaxStyle={syntax()}
|
|
streaming={streaming()}
|
|
content={markdown()!.content}
|
|
fg={color()}
|
|
tableOptions={{ widthMode: "content" }}
|
|
/>
|
|
</Match>
|
|
</Switch>
|
|
)
|
|
}
|
|
|
|
export function entryWriter(input: {
|
|
commit: StreamCommit
|
|
body?: RunEntryBody
|
|
theme?: RunTheme
|
|
opts?: ScrollbackOptions
|
|
}): ScrollbackWriter {
|
|
return createScrollbackWriter(
|
|
(ctx) => (
|
|
<RunEntryContent
|
|
commit={input.commit}
|
|
body={input.body}
|
|
theme={input.theme}
|
|
opts={{ ...input.opts, suppressBackgrounds: true }}
|
|
width={ctx.width}
|
|
/>
|
|
),
|
|
entryFlags(input.commit),
|
|
)
|
|
}
|
|
|
|
export function spacerWriter(): ScrollbackWriter {
|
|
return (ctx: ScrollbackRenderContext) => ({
|
|
root: new TextRenderable(ctx.renderContext, {
|
|
id: "run-scrollback-spacer",
|
|
width: Math.max(1, Math.trunc(ctx.width)),
|
|
height: 1,
|
|
content: "",
|
|
}),
|
|
width: Math.max(1, Math.trunc(ctx.width)),
|
|
height: 1,
|
|
startOnNewLine: true,
|
|
trailingNewline: true,
|
|
})
|
|
}
|