Files
ClosedCode/packages/opencode/src/cli/cmd/opentui/session.tsx
T

519 lines
16 KiB
TypeScript

import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type Component } from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "./context/route"
import { useSync } from "./context/sync"
import { SplitBorder } from "./component/border"
import { Theme } from "./context/theme"
import { BoxRenderable, hastToStyledText, RGBA, ScrollBoxRenderable, SyntaxStyle } from "@opentui/core"
import { Prompt } from "./component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage } from "@opencode-ai/sdk"
import type { TextPart } from "ai"
import { useLocal } from "./context/local"
import { Locale } from "../../../util/locale"
import type { Tool } from "../../../tool/tool"
import { highlightHast, Language } from "tree-sitter-highlight"
import type { ReadTool } from "../../../tool/read"
import type { WriteTool } from "../../../tool/write"
import { BashTool } from "../../../tool/bash"
import type { GlobTool } from "../../../tool/glob"
import { Instance } from "../../../project/instance"
import { TodoWriteTool } from "../../../tool/todo"
import type { GrepTool } from "../../../tool/grep"
import type { ListTool } from "../../../tool/ls"
import type { EditTool } from "../../../tool/edit"
import type { PatchTool } from "../../../tool/patch"
import type { WebFetchTool } from "../../../tool/webfetch"
import type { TaskTool } from "../../../tool/task"
import { useKeyboard, type BoxProps, type JSX } from "@opentui/solid"
import { createStore } from "solid-js/store"
export function Session() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const todo = createMemo(() => sync.data.todo[route.sessionID] ?? [])
let scroll: ScrollBoxRenderable
createEffect(() => sync.session.sync(route.sessionID))
useKeyboard((evt) => {
if (evt.name === "pageup") scroll.scrollBy(-scroll.height / 2)
if (evt.name === "pagedown") scroll.scrollBy(scroll.height / 2)
})
const [store, setStore] = createStore({
items: [] as { multiline: boolean }[],
})
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1} maxHeight="100%">
<Show when={session()}>
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement}>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text>
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
</box>
<scrollbox
ref={(r: any) => (scroll = r)}
scrollbarOptions={{ visible: false }}
stickyScroll={true}
stickyStart="bottom"
paddingTop={1}
paddingBottom={1}
>
<For each={messages()}>
{(message) => (
<Switch>
<Match when={message.role === "user"}>
<UserMessage message={message as UserMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
<Match when={message.role === "assistant"}>
<AssistantMessage message={message as AssistantMessage} parts={sync.data.part[message.id] ?? []} />
</Match>
</Switch>
)}
</For>
</scrollbox>
<Show when={todo().length > 0 && false}>
<box paddingBottom={1}>
<For each={todo()}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
</box>
</Show>
<box flexShrink={0}>
<Prompt sessionID={route.sessionID} />
</box>
</Show>
</box>
)
}
function UserMessage(props: { message: UserMessage; parts: Part[] }) {
const text = createMemo(() => props.parts.flatMap((x) => (x.type === "text" && !x.synthetic ? [x] : []))[0])
const sync = useSync()
return (
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
marginTop={1}
backgroundColor={Theme.backgroundPanel}
customBorderChars={SplitBorder.customBorderChars}
borderColor={Theme.secondary}
>
<text>{text()?.text}</text>
<text>
{sync.data.config.username ?? "You"}{" "}
<span style={{ fg: Theme.textMuted }}>({Locale.time(props.message.time.created)})</span>
</text>
</box>
)
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[] }) {
return (
<For each={props.parts}>
{(part) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
function resize(el: BoxRenderable) {
const parent = el.parent
if (!parent) return
const children = parent.getChildren()
const index = children.indexOf(el)
const previous = children[index - 1]
if (!previous) return
console.log(previous.height, el.height)
if (el.height > 1 || previous.height > 1) {
el.marginTop = 1
}
}
return (
<Show when={component()}>
<box
onSizeChange={function () {
resize(this)
}}
ref={(el) => resize(el!)}
>
<Dynamic component={component()} part={part as any} message={props.message} />
</box>
</Show>
)
}}
</For>
)
}
const PART_MAPPING = {
text: TextPart,
tool: ToolPart,
}
function TextPart(props: { part: TextPart; message: AssistantMessage }) {
const sync = useSync()
const agent = createMemo(() => sync.data.agent.find((x) => x.name === props.message.mode)!)
const local = useLocal()
return (
<box paddingLeft={3}>
<text>{props.part.text.trim()}</text>
<text>
<span style={{ fg: local.agent.color(agent().name) }}>{Locale.titlecase(agent().name)}</span>{" "}
<span style={{ fg: Theme.textMuted }}>{props.message.providerID + "/" + props.message.modelID}</span>
</text>
</box>
)
}
// Pending messages moved to individual tool pending functions
function ToolPart(props: { part: ToolPart; message: AssistantMessage }) {
const component = createMemo(() => {
const ready = ToolRegistry.ready(props.part.tool)
if (!ready) return
const metadata = props.part.state.status === "pending" ? {} : (props.part.state.metadata ?? {})
const input = props.part.state.input
const container: BoxProps =
ToolRegistry.container(props.part.tool) === "block"
? {
border: ["left"] as const,
paddingTop: 1,
paddingBottom: 1,
paddingLeft: 2,
gap: 1,
backgroundColor: Theme.backgroundPanel,
customBorderChars: SplitBorder.customBorderChars,
borderColor: Theme.background,
}
: {
paddingLeft: 3,
}
return (
<box {...container}>
<Dynamic
component={ready}
input={input}
metadata={metadata}
output={props.part.state.status === "completed" ? props.part.state.output : undefined}
/>
{props.part.state.status === "error" && (
<box paddingLeft={2}>
<text fg={Theme.error}>{props.part.state.error.replace("Error: ", "")}</text>
</box>
)}
</box>
)
})
return <Show when={component()}>{component()}</Show>
}
type ToolProps<T extends Tool.Info> = {
input: Partial<Tool.InferParameters<T>>
metadata: Partial<Tool.InferMetadata<T>>
output?: string
}
const ToolRegistry = (() => {
const state: Record<string, { name: string; container: "inline" | "block"; ready?: Component<ToolProps<any>> }> = {}
function register<T extends Tool.Info>(input: {
name: string
container: "inline" | "block"
ready?: Component<ToolProps<T>>
}) {
state[input.name] = input
return input
}
return {
register,
container(name: string) {
return state[name]?.container
},
ready(name: string) {
return state[name]?.ready
},
}
})()
function ToolTitle(props: { fallback: string; when: any; icon: string; children: JSX.Element }) {
return (
<text paddingLeft={3} fg={props.when ? Theme.textMuted : Theme.text}>
<Show fallback={<>~ {props.fallback}</>} when={props.when}>
<span style={{ bold: true }}>{props.icon}</span> {props.children}
</Show>
</text>
)
}
ToolRegistry.register<typeof BashTool>({
name: "bash",
container: "block",
ready(props) {
return (
<>
<ToolTitle icon="#" fallback="Writing command..." when={props.input.command}>
{props.input.description}
</ToolTitle>
<Show when={props.input.command}>
<text fg={Theme.text}>$ {props.input.command}</text>
</Show>
<Show when={props.output?.trim()}>
<box>
<text fg={Theme.text}>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
const syntax = new SyntaxStyle({
keyword: { fg: RGBA.fromHex(Theme.syntaxKeyword), bold: true },
string: { fg: RGBA.fromHex(Theme.syntaxString) },
comment: { fg: RGBA.fromHex(Theme.syntaxComment), italic: true },
number: { fg: RGBA.fromHex(Theme.syntaxNumber) },
function: { fg: RGBA.fromHex(Theme.syntaxFunction) },
type: { fg: RGBA.fromHex(Theme.syntaxType) },
operator: { fg: RGBA.fromHex(Theme.syntaxOperator) },
variable: { fg: RGBA.fromHex(Theme.syntaxVariable) },
bracket: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
punctuation: { fg: RGBA.fromHex(Theme.syntaxPunctuation) },
default: { fg: RGBA.fromHex(Theme.syntaxVariable) },
})
ToolRegistry.register<typeof ReadTool>({
name: "read",
container: "inline",
ready(props) {
return (
<>
<ToolTitle icon="→" fallback="Reading file..." when={props.input.filePath}>
Read {normalizePath(props.input.filePath!)}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof WriteTool>({
name: "write",
container: "block",
ready(props) {
const lines = createMemo(() => {
return props.input.content?.split("\n") ?? []
})
const code = createMemo(() => {
if (!props.input.content) return ""
const text = props.input.content
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
const numbers = createMemo(() => {
const pad = lines().length.toString().length
return lines()
.map((_, index) => index + 1)
.map((x) => x.toString().padStart(pad, " "))
})
return (
<>
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
Wrote {props.input.filePath}
</ToolTitle>
<box flexDirection="row">
<box>
<For each={numbers()}>{(value) => <text style={{ fg: Theme.textMuted }}>{value}</text>}</For>
</box>
<box paddingLeft={1}>
<text>{code()}</text>
</box>
</box>
</>
)
},
})
ToolRegistry.register<typeof GlobTool>({
name: "glob",
container: "inline",
ready(props) {
return (
<>
<ToolTitle icon="✱" fallback="Finding files..." when={props.input.pattern}>
Glob "{props.input.pattern}" <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof GrepTool>({
name: "grep",
container: "inline",
ready(props) {
return (
<ToolTitle icon="✱" fallback="Searching content..." when={props.input.pattern}>
Grep "{props.input.pattern}" <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
</ToolTitle>
)
},
})
ToolRegistry.register<typeof ListTool>({
name: "list",
container: "inline",
ready(props) {
const dir = createMemo(() => {
if (props.input.path) {
return normalizePath(props.input.path)
}
return ""
})
return (
<>
<ToolTitle icon="→" fallback="Listing directory..." when={props.input.path !== undefined}>
List {dir()}
</ToolTitle>
</>
)
},
})
ToolRegistry.register<typeof TaskTool>({
name: "task",
container: "block",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Delegating..." when={props.input.description}>
Task {props.input.description}
</ToolTitle>
<Show when={props.metadata.summary?.length}>
<box>
<For each={props.metadata.summary ?? []}>
{(task) => (
<text style={{ fg: Theme.textMuted }}>
{task.tool} {task.state.status === "completed" ? task.state.title : ""}
</text>
)}
</For>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof WebFetchTool>({
name: "webfetch",
container: "block",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Fetching from the web..." when={(props.input as any).url}>
WebFetch {(props.input as any).url}
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof EditTool>({
name: "edit",
container: "block",
ready(props) {
const code = createMemo(() => {
if (!props.metadata.diff) return ""
const text = props.metadata.diff.split("\n").slice(5).join("\n")
const hast = highlightHast(text, Language.TS)
const styled = hastToStyledText(hast as any, syntax)
return styled
})
return (
<>
<ToolTitle icon="←" fallback="Preparing edit..." when={props.input.filePath}>
Edit {normalizePath(props.input.filePath!)}
</ToolTitle>
<Show when={code()}>
<box paddingLeft={1}>
<text>{code()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof PatchTool>({
name: "patch",
container: "block",
ready(props) {
return (
<>
<ToolTitle icon="%" fallback="Preparing patch..." when={true}>
Patch
</ToolTitle>
<Show when={props.output}>
<box>
<text>{props.output?.trim()}</text>
</box>
</Show>
</>
)
},
})
ToolRegistry.register<typeof TodoWriteTool>({
name: "todowrite",
container: "block",
ready(props) {
return (
<For each={props.input.todos ?? []}>
{(todo) => (
<text style={{ fg: todo.status === "in_progress" ? Theme.success : Theme.textMuted }}>
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
</text>
)}
</For>
)
},
})
function normalizePath(input: string) {
if (path.isAbsolute(input)) {
return path.relative(Instance.directory, input) || "."
}
return input
}