feat(tui): group sequential thinking (#36901)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
This commit is contained in:
co-authored by
Kit Langton
parent
b67bed061a
commit
016545513f
Binary file not shown.
@@ -968,7 +968,10 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
|
|||||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={props.row.type === "group" ? props.row : undefined}>
|
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||||
|
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||||
|
</Match>
|
||||||
|
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||||
{(row) => (
|
{(row) => (
|
||||||
<SessionGroupView
|
<SessionGroupView
|
||||||
refs={row().refs}
|
refs={row().refs}
|
||||||
@@ -1078,6 +1081,114 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SessionReasoningGroupView(props: {
|
||||||
|
refs: PartRef[]
|
||||||
|
completed: boolean
|
||||||
|
message: (messageID: string) => SessionMessageInfo | undefined
|
||||||
|
}) {
|
||||||
|
const ctx = use()
|
||||||
|
const { theme, syntax } = useTheme()
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const [expanded, setExpanded] = createSignal(false)
|
||||||
|
const parts = createMemo<{ message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[]>(
|
||||||
|
(previous) => {
|
||||||
|
const next = props.refs.flatMap((ref) => {
|
||||||
|
const message = props.message(ref.messageID)
|
||||||
|
if (message?.type !== "assistant") return []
|
||||||
|
const part = resolvePart(message, ref.partID)
|
||||||
|
if (part?.type !== "reasoning" || !part.text.replace("[REDACTED]", "").trim()) return []
|
||||||
|
return [{ message, part }]
|
||||||
|
})
|
||||||
|
return next.length > 0 ? next : previous
|
||||||
|
},
|
||||||
|
[] as { message: SessionMessageAssistant; part: SessionMessageAssistantReasoning }[],
|
||||||
|
)
|
||||||
|
const latest = createMemo((previous: string | null) => {
|
||||||
|
const item = parts().at(-1)
|
||||||
|
if (!item) return previous
|
||||||
|
const title = reasoningSummary(item.part.text.replace("[REDACTED]", "").trim()).title
|
||||||
|
if (title) return title
|
||||||
|
if (item.part.time?.completed !== undefined || item.message.time.completed !== undefined) return null
|
||||||
|
return previous
|
||||||
|
}, null)
|
||||||
|
const duration = createMemo(() =>
|
||||||
|
parts().reduce((total, item) => {
|
||||||
|
const end = item.part.time?.completed ?? item.message.time.completed
|
||||||
|
const start = item.part.time?.created ?? item.message.time.created
|
||||||
|
return total + (end === undefined ? 0 : Math.max(0, end - start))
|
||||||
|
}, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={parts().length > 0}>
|
||||||
|
<Show
|
||||||
|
when={ctx.thinkingMode() === "hide"}
|
||||||
|
fallback={
|
||||||
|
<For each={parts()}>{(item) => <ReasoningPart part={item.part} message={item.message} last={false} />}</For>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
|
||||||
|
<box
|
||||||
|
onMouseUp={() => {
|
||||||
|
if (renderer.getSelection()?.getSelectedText()) return
|
||||||
|
setExpanded((value) => !value)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={props.completed}
|
||||||
|
fallback={<Spinner color={theme.warning}>{latest() ? `Thinking: ${latest()}` : "Thinking"}</Spinner>}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={expanded()}
|
||||||
|
fallback={
|
||||||
|
<text fg={theme.warning} wrapMode="none">
|
||||||
|
+ Thought
|
||||||
|
<Show when={latest()}>: {latest()}</Show>
|
||||||
|
<Show when={parts().length > 1}> · {parts().length} steps</Show>
|
||||||
|
<Show when={duration()}> · {Locale.duration(duration())}</Show>
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<text fg={theme.warning} wrapMode="none">
|
||||||
|
- Thought
|
||||||
|
<Show when={parts().length > 1}> · {parts().length} steps</Show>
|
||||||
|
<Show when={duration()}> · {Locale.duration(duration())}</Show>
|
||||||
|
</text>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
<Show when={expanded()}>
|
||||||
|
<For each={parts()}>
|
||||||
|
{(item) => {
|
||||||
|
const content = createMemo(() => item.part.text.replace("[REDACTED]", "").trim())
|
||||||
|
const summary = createMemo(() => reasoningSummary(content()))
|
||||||
|
const markdown = createMemo(() => {
|
||||||
|
if (!summary().title) return content()
|
||||||
|
if (!summary().body) return `**${summary().title}**`
|
||||||
|
return `**${summary().title}** · ${summary().body}`
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<box marginTop={1}>
|
||||||
|
<code
|
||||||
|
filetype="markdown"
|
||||||
|
drawUnstyledText={false}
|
||||||
|
streaming={false}
|
||||||
|
syntaxStyle={syntax()}
|
||||||
|
content={markdown()}
|
||||||
|
conceal={ctx.markdownMode() === "rendered"}
|
||||||
|
fg={theme.textMuted}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SessionGroupView(props: {
|
function SessionGroupView(props: {
|
||||||
refs: PartRef[]
|
refs: PartRef[]
|
||||||
pending: PartRef[]
|
pending: PartRef[]
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export type SessionRow =
|
|||||||
| { type: "message"; messageID: string }
|
| { type: "message"; messageID: string }
|
||||||
| { type: "compaction-queued"; inputID: string }
|
| { type: "compaction-queued"; inputID: string }
|
||||||
| { type: "part"; ref: PartRef }
|
| { type: "part"; ref: PartRef }
|
||||||
|
| {
|
||||||
|
type: "group"
|
||||||
|
kind: "reasoning"
|
||||||
|
refs: PartRef[]
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "group"
|
type: "group"
|
||||||
kind: "exploration"
|
kind: "exploration"
|
||||||
@@ -131,6 +137,16 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
if (hasPart(draft, ref)) return
|
if (hasPart(draft, ref)) return
|
||||||
const index = queuedStart(draft)
|
const index = queuedStart(draft)
|
||||||
|
if (ref.partID.startsWith("reasoning:")) {
|
||||||
|
const previous = draft[index - 1]
|
||||||
|
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||||
|
previous.refs.push(ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completePrevious(draft, index)
|
||||||
|
draft.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (name && exploration(name)) {
|
if (name && exploration(name)) {
|
||||||
const previous = draft[index - 1]
|
const previous = draft[index - 1]
|
||||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||||
@@ -213,7 +229,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
data.on("session.agent.selected", message),
|
data.on("session.agent.selected", message),
|
||||||
data.on("session.model.selected", message),
|
data.on("session.model.selected", message),
|
||||||
data.on("session.text.delta", (event) => {
|
data.on("session.text.delta", (event) => {
|
||||||
if (event.data.sessionID === sessionID())
|
if (event.data.sessionID === sessionID() && event.data.delta.trim())
|
||||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||||
}),
|
}),
|
||||||
data.on("session.text.ended", (event) => {
|
data.on("session.text.ended", (event) => {
|
||||||
@@ -290,6 +306,16 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
function append(rows: SessionRow[], ref: PartRef, part: SessionMessageAssistant["content"][number]) {
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
const previous = rows.at(-1)
|
||||||
|
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||||
|
previous.refs.push(ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completePrevious(rows)
|
||||||
|
rows.push({ type: "group", kind: "reasoning", refs: [ref], completed: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
if (exploration(part.name)) {
|
if (exploration(part.name)) {
|
||||||
const previous = rows.at(-1)
|
const previous = rows.at(-1)
|
||||||
@@ -313,7 +339,7 @@ function completePrevious(rows: SessionRow[], index = rows.length) {
|
|||||||
|
|
||||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||||
rows.forEach((row) => {
|
rows.forEach((row) => {
|
||||||
if (row.type !== "group") return
|
if (row.type !== "group" || row.kind !== "exploration") return
|
||||||
const refs = [...row.refs, ...row.pending]
|
const refs = [...row.refs, ...row.pending]
|
||||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||||
@@ -328,6 +354,7 @@ function hasPart(rows: SessionRow[], ref: PartRef) {
|
|||||||
return rows.some((row) => {
|
return rows.some((row) => {
|
||||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||||
if (row.type !== "group") return false
|
if (row.type !== "group") return false
|
||||||
return [...row.refs, ...row.pending].some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
|
||||||
|
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||||||
{
|
{
|
||||||
id: "dialog.prompt.submit",
|
id: "dialog.prompt.submit",
|
||||||
title: "Submit dialog prompt",
|
title: "Submit dialog prompt",
|
||||||
|
bind: "return",
|
||||||
group: "Dialog",
|
group: "Dialog",
|
||||||
run: confirm,
|
run: confirm,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { TextareaRenderable } from "@opentui/core"
|
import { TextareaRenderable } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { testRender } from "@opentui/solid"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -27,31 +26,26 @@ async function mountPrompt(input: {
|
|||||||
const state = path.join(input.root, "state")
|
const state = path.join(input.root, "state")
|
||||||
await mkdir(state, { recursive: true })
|
await mkdir(state, { recursive: true })
|
||||||
|
|
||||||
const [
|
const [{ DialogProvider }, { DialogPrompt }, { ThemeProvider }, { ConfigProvider }, { ToastProvider }, { Keymap }] =
|
||||||
{ DialogProvider },
|
await Promise.all([
|
||||||
{ DialogPrompt },
|
|
||||||
{ ThemeProvider },
|
|
||||||
{ ConfigProvider },
|
|
||||||
{ ToastProvider },
|
|
||||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
|
||||||
] = await Promise.all([
|
|
||||||
import("../../../src/ui/dialog"),
|
import("../../../src/ui/dialog"),
|
||||||
import("../../../src/ui/dialog-prompt"),
|
import("../../../src/ui/dialog-prompt"),
|
||||||
import("../../../src/context/theme"),
|
import("../../../src/context/theme"),
|
||||||
import("../../../src/config"),
|
import("../../../src/config"),
|
||||||
import("../../../src/ui/toast"),
|
import("../../../src/ui/toast"),
|
||||||
import("../../../src/keymap"),
|
import("../../../src/context/keymap"),
|
||||||
])
|
])
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
const resolvedConfig = createTuiResolvedConfig({
|
const resolvedConfig = createTuiResolvedConfig({
|
||||||
keybinds: input.keybinds,
|
keybinds: input.keybinds,
|
||||||
leader: { timeout: 1000 },
|
leader: { timeout: 1000 },
|
||||||
})
|
})
|
||||||
const off = registerOpencodeKeymap(keymap, renderer, resolvedConfig)
|
|
||||||
onCleanup(off)
|
function Prompt() {
|
||||||
|
onCleanup(Keymap.use().mode.push("modal"))
|
||||||
|
return <DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TestTuiContexts
|
<TestTuiContexts
|
||||||
@@ -62,22 +56,23 @@ async function mountPrompt(input: {
|
|||||||
worktree: input.root,
|
worktree: input.root,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<ConfigProvider config={resolvedConfig}>
|
<ConfigProvider config={resolvedConfig}>
|
||||||
|
<Keymap.Provider>
|
||||||
<ThemeProvider mode="dark">
|
<ThemeProvider mode="dark">
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<DialogPrompt title="Rename Session" value="draft" onConfirm={input.onConfirm} />
|
<Prompt />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
|
const app = await testRender(() => <Harness />, { kittyKeyboard: true })
|
||||||
|
app.renderer.start()
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { InputRenderable } from "@opentui/core"
|
import { InputRenderable } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { testRender } from "@opentui/solid"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
@@ -21,35 +20,20 @@ async function renderSelect(
|
|||||||
const state = path.join(root, "state")
|
const state = path.join(root, "state")
|
||||||
await mkdir(state, { recursive: true })
|
await mkdir(state, { recursive: true })
|
||||||
const config = createTuiResolvedConfig()
|
const config = createTuiResolvedConfig()
|
||||||
const [
|
const [{ ConfigProvider }, { ThemeProvider }, { Keymap }, { DialogProvider }, { DialogSelect }, { ToastProvider }] =
|
||||||
{ ConfigProvider },
|
await Promise.all([
|
||||||
{ ThemeProvider },
|
|
||||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
|
||||||
{ DialogProvider },
|
|
||||||
{ DialogSelect },
|
|
||||||
{ ToastProvider },
|
|
||||||
] = await Promise.all([
|
|
||||||
import("../../../src/config"),
|
import("../../../src/config"),
|
||||||
import("../../../src/context/theme"),
|
import("../../../src/context/theme"),
|
||||||
import("../../../src/keymap"),
|
import("../../../src/context/keymap"),
|
||||||
import("../../../src/ui/dialog"),
|
import("../../../src/ui/dialog"),
|
||||||
import("../../../src/ui/dialog-select"),
|
import("../../../src/ui/dialog-select"),
|
||||||
import("../../../src/ui/toast"),
|
import("../../../src/ui/toast"),
|
||||||
])
|
])
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
function Select() {
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
onCleanup(Keymap.use().mode.push("modal"))
|
||||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
|
||||||
onCleanup(off)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<ConfigProvider config={config}>
|
|
||||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
|
||||||
<ToastProvider>
|
|
||||||
<DialogProvider>
|
|
||||||
<DialogSelect
|
<DialogSelect
|
||||||
title="Items"
|
title="Items"
|
||||||
options={options}
|
options={options}
|
||||||
@@ -68,11 +52,22 @@ async function renderSelect(
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||||
|
<ConfigProvider config={config}>
|
||||||
|
<Keymap.Provider>
|
||||||
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
|
<ToastProvider>
|
||||||
|
<DialogProvider>
|
||||||
|
<Select />
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -91,14 +86,14 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
|||||||
const [
|
const [
|
||||||
{ ConfigProvider },
|
{ ConfigProvider },
|
||||||
{ ThemeProvider },
|
{ ThemeProvider },
|
||||||
{ OpencodeKeymapProvider, registerOpencodeKeymap },
|
{ Keymap },
|
||||||
{ DialogProvider, useDialog },
|
{ DialogProvider, useDialog },
|
||||||
{ DialogSelect },
|
{ DialogSelect },
|
||||||
{ ToastProvider },
|
{ ToastProvider },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
import("../../../src/config"),
|
import("../../../src/config"),
|
||||||
import("../../../src/context/theme"),
|
import("../../../src/context/theme"),
|
||||||
import("../../../src/keymap"),
|
import("../../../src/context/keymap"),
|
||||||
import("../../../src/ui/dialog"),
|
import("../../../src/ui/dialog"),
|
||||||
import("../../../src/ui/dialog-select"),
|
import("../../../src/ui/dialog-select"),
|
||||||
import("../../../src/ui/toast"),
|
import("../../../src/ui/toast"),
|
||||||
@@ -109,12 +104,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
|||||||
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
|
let replaceOptions!: (options: DialogSelectOption<string>[]) => void
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
|
||||||
const [options, setOptions] = createSignal(initial)
|
const [options, setOptions] = createSignal(initial)
|
||||||
replaceOptions = setOptions
|
replaceOptions = setOptions
|
||||||
onCleanup(off)
|
|
||||||
|
|
||||||
function Fixture() {
|
function Fixture() {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -133,8 +124,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<ConfigProvider config={config}>
|
<ConfigProvider config={config}>
|
||||||
|
<Keymap.Provider>
|
||||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
@@ -142,8 +133,8 @@ async function mountSelect(root: string, initial: DialogSelectOption<string>[])
|
|||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { testRender } from "@opentui/solid"
|
||||||
import { testRender, useRenderer } from "@opentui/solid"
|
|
||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { mkdir } from "node:fs/promises"
|
import { mkdir } from "node:fs/promises"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { onCleanup } from "solid-js"
|
|
||||||
import { ClipboardProvider } from "../../../src/context/clipboard"
|
import { ClipboardProvider } from "../../../src/context/clipboard"
|
||||||
import type { FormWithLocation } from "../../../src/context/data"
|
import type { FormWithLocation } from "../../../src/context/data"
|
||||||
import { ClientProvider } from "../../../src/context/client"
|
import { ClientProvider } from "../../../src/context/client"
|
||||||
import { ThemeProvider } from "../../../src/context/theme"
|
import { ThemeProvider } from "../../../src/context/theme"
|
||||||
|
import { Keymap } from "../../../src/context/keymap"
|
||||||
import { ConfigProvider } from "../../../src/config"
|
import { ConfigProvider } from "../../../src/config"
|
||||||
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/keymap"
|
|
||||||
import { ToastProvider } from "../../../src/ui/toast"
|
import { ToastProvider } from "../../../src/ui/toast"
|
||||||
import { tmpdir } from "../../fixture/fixture"
|
import { tmpdir } from "../../fixture/fixture"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
@@ -51,11 +49,6 @@ async function mountForm(root: string, width = 80) {
|
|||||||
const { FormPrompt } = await import("../../../src/routes/session/form")
|
const { FormPrompt } = await import("../../../src/routes/session/form")
|
||||||
|
|
||||||
function Harness() {
|
function Harness() {
|
||||||
const renderer = useRenderer()
|
|
||||||
const keymap = createDefaultOpenTuiKeymap(renderer)
|
|
||||||
const off = registerOpencodeKeymap(keymap, renderer, config)
|
|
||||||
onCleanup(off)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TestTuiContexts
|
<TestTuiContexts
|
||||||
directory={root}
|
directory={root}
|
||||||
@@ -73,8 +66,8 @@ async function mountForm(root: string, width = 80) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<OpencodeKeymapProvider keymap={keymap}>
|
|
||||||
<ConfigProvider config={config}>
|
<ConfigProvider config={config}>
|
||||||
|
<Keymap.Provider>
|
||||||
<ClientProvider api={createApi(transport.fetch)}>
|
<ClientProvider api={createApi(transport.fetch)}>
|
||||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({}) }}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
@@ -82,8 +75,8 @@ async function mountForm(root: string, width = 80) {
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
|
</Keymap.Provider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
</OpencodeKeymapProvider>
|
|
||||||
</ClipboardProvider>
|
</ClipboardProvider>
|
||||||
</TestTuiContexts>
|
</TestTuiContexts>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,9 +74,49 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
|||||||
|
|
||||||
expect(reduceSessionRows(messages)).toEqual([
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "reasoning",
|
||||||
|
completed: true,
|
||||||
|
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||||
|
},
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:1" } },
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "reasoning",
|
||||||
|
completed: false,
|
||||||
|
refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||||
|
const messages: SessionMessageInfo[] = [
|
||||||
|
assistant("assistant-1", [
|
||||||
|
{ type: "reasoning", text: "First" },
|
||||||
|
{ type: "reasoning", text: "Second" },
|
||||||
|
{ type: "text", text: "Visible" },
|
||||||
|
{ type: "reasoning", text: "Third" },
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "reasoning",
|
||||||
|
completed: true,
|
||||||
|
refs: [
|
||||||
|
{ messageID: "assistant-1", partID: "reasoning:0" },
|
||||||
|
{ messageID: "assistant-1", partID: "reasoning:1" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "reasoning",
|
||||||
|
completed: false,
|
||||||
|
refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,7 +133,12 @@ test("groups across empty assistant reasoning parts", () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
expect(reduceSessionRows(messages)).toEqual([
|
expect(reduceSessionRows(messages)).toEqual([
|
||||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
{
|
||||||
|
type: "group",
|
||||||
|
kind: "reasoning",
|
||||||
|
completed: true,
|
||||||
|
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "group",
|
type: "group",
|
||||||
kind: "exploration",
|
kind: "exploration",
|
||||||
|
|||||||
Reference in New Issue
Block a user