opencode/packages/tui/src/mini/demo.ts

1073 lines
26 KiB
TypeScript

// Demo mode for testing direct interactive mode without a real SDK.
//
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
// presentation commits and footer actions as the live transport. This
// lets you test scrollback formatting, permission UI, canonical Form UI, and tool
// snapshots without making actual model calls. Pass a demo slash command as
// the initial interactive message to trigger a preview immediately.
//
// Slash commands:
// /permission [kind] → triggers a permission request variant
// /form [kind] → triggers a canonical Form request variant
// /fmt <kind> → emits a specific tool/text type (text, reasoning, shell,
// write, edit, patch, subagent, question, error, mix)
//
// Demo mode handles permission and Form replies locally, completing or failing
// the synthetic tool parts through the same callbacks used by the live footer.
import path from "path"
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
import { writeSessionOutput } from "./stream"
import { toolCommit } from "./stream-v2.subagent"
import type {
FooterApi,
FooterView,
FormCancel,
FormReply,
MiniFormRequest,
MiniPermissionRequest,
PermissionReply,
RunPrompt,
StreamCommit,
} from "./types"
const KINDS = [
"markdown",
"table",
"text",
"reasoning",
"shell",
"write",
"edit",
"patch",
"subagent",
"question",
"error",
"mix",
]
const PERMISSIONS = ["edit", "shell", "read", "subagent", "external", "doom"] as const
const FORMS = ["question", "external"] as const
type PermissionKind = (typeof PERMISSIONS)[number]
type FormKind = (typeof FORMS)[number]
function permissionKind(value: string | undefined): PermissionKind | undefined {
const next = (value || "edit").toLowerCase()
return PERMISSIONS.find((item) => item === next)
}
function formKind(value: string | undefined): FormKind | undefined {
const next = (value || "question").toLowerCase()
return FORMS.find((item) => item === next)
}
const SAMPLE_MARKDOWN = [
"# Direct Mode Demo",
"",
"This is a realistic assistant response for direct-mode formatting checks.",
"It mixes **bold**, _italic_, `inline code`, links, code fences, and tables in one streamed reply.",
"",
"## Summary",
"",
"- Restored the final markdown flush so the last block is committed on idle.",
"- Switched markdown scrollback commits back to top-level block boundaries.",
"- Added footer-level regression coverage for split-footer rendering.",
"",
"## Status",
"",
"| Area | Before | After | Notes |",
"| --- | --- | --- | --- |",
"| Direct mode | Missing final rows | Stable | Final markdown block now flushes on idle |",
"| Tables | Dropped in streaming mode | Visible | Block-based commits match the working OpenTUI demo |",
"| Tests | Partial coverage | Broader coverage | Includes a footer-level split render capture |",
"",
"> This sample intentionally includes a wide table so you can spot wrapping and commit bugs quickly.",
"",
"```ts",
"const result = { markdown: true, tables: 2, stable: true }",
"```",
"",
"## Files",
"",
"| File | Change |",
"| --- | --- |",
"| `scrollback.surface.ts` | Align markdown commit logic with the split-footer demo |",
"| `footer.ts` | Keep active surfaces across footer-height-only resizes |",
"| `footer.test.ts` | Capture real split-footer markdown payloads during idle completion |",
"",
"Next step: run `/fmt table` if you want a tighter table-only sample.",
].join("\n")
const SAMPLE_TABLE = [
"# Table Sample",
"",
"| Kind | Example | Notes |",
"| --- | --- | --- |",
"| Pipe | `A\\|B` | Escaped pipes should stay in one cell |",
"| Unicode | `漢字` | Wide characters should remain aligned |",
"| Wrap | `LongTokenWithoutNaturalBreaks_1234567890` | Useful for width stress |",
"| Status | done | Final row should still appear after idle |",
].join("\n")
type Ref = {
msg: string
part: string
call: string
tool: string
input: Record<string, JsonValue>
start: number
}
type FormRequest = {
ref: Ref
kind: FormKind
request: MiniFormRequest
}
type Perm = {
ref: Ref
done: {
title: string
output: string
metadata?: Record<string, JsonValue>
}
}
type Permit = {
ref: Ref
permission: string
patterns: string[]
metadata?: MiniPermissionRequest["metadata"]
always: string[]
done: Perm["done"]
}
type State = {
id: string
thinking: boolean
footer: FooterApi
msg: number
part: number
call: number
perm: number
form: number
perms: Map<string, Perm>
forms: Map<string, FormRequest>
started: Set<string>
}
type Input = {
sessionID: string
thinking: boolean
footer: FooterApi
}
function note(footer: FooterApi, text: string): void {
footer.append({
kind: "system",
text,
phase: "start",
source: "system",
})
}
function clearSubagent(footer: FooterApi): void {
footer.event({
type: "stream.subagent",
state: {
tabs: [],
details: {},
permissions: [],
forms: [],
},
})
}
function showSubagent(
state: State,
input: {
sessionID: string
label: string
description: string
status: "running" | "completed" | "cancelled" | "error"
title?: string
commits: StreamCommit[]
},
) {
state.footer.event({
type: "stream.subagent",
state: {
tabs: [
{
sessionID: input.sessionID,
label: input.label,
description: input.description,
status: input.status,
title: input.title,
lastUpdatedAt: Date.now(),
},
],
details: {
[input.sessionID]: {
commits: input.commits,
},
},
permissions: [],
forms: [],
},
})
}
function wait(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (!signal) {
setTimeout(resolve, ms)
return
}
if (signal.aborted) {
resolve()
return
}
const done = () => {
clearTimeout(timer)
signal.removeEventListener("abort", done)
resolve()
}
const timer = setTimeout(() => {
signal.removeEventListener("abort", done)
resolve()
}, ms)
signal.addEventListener("abort", done, { once: true })
})
}
function split(text: string): string[] {
if (text.length <= 48) {
return [text]
}
const size = Math.ceil(text.length / 3)
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
}
function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string {
state[key] += 1
return `demo_${prefix}_${state[key]}`
}
function present(state: State, commits: StreamCommit[], view?: FooterView): void {
writeSessionOutput(
{ footer: state.footer },
{
commits,
updates: view
? [
{
type: "stream.patch" as const,
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
},
{ type: "stream.view" as const, view },
]
: undefined,
},
)
}
function clearBlocker(state: State): void {
writeSessionOutput(
{ footer: state.footer },
{
commits: [],
updates: [
{ type: "stream.patch", patch: { status: "" } },
{ type: "stream.view", view: { type: "prompt" } },
],
},
)
}
function open(state: State): string {
return take(state, "msg", "msg")
}
async function emitText(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
for (const item of split(body)) {
if (signal?.aborted) {
return
}
present(state, [
{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part },
])
await wait(45, signal)
}
}
async function emitReasoning(state: State, body: string, signal?: AbortSignal): Promise<void> {
const msg = open(state)
const part = take(state, "part", "part")
let first = true
for (const item of split(body)) {
if (signal?.aborted) {
return
}
if (state.thinking) {
present(state, [
{
kind: "reasoning",
source: "reasoning",
text: first ? `Thinking: ${item.replace(/\[REDACTED\]/g, "")}` : item.replace(/\[REDACTED\]/g, ""),
phase: "progress",
messageID: msg,
partID: part,
},
])
first = false
}
await wait(45, signal)
}
}
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
return {
msg: open(state),
part: take(state, "part", "part"),
call: take(state, "call", "call"),
tool,
input,
start: Date.now(),
}
}
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
state.started.add(ref.part)
const part = {
type: "tool" as const,
id: ref.call,
name: ref.tool,
state: { status: "running" as const, input: ref.input, structured, content: [] },
time: { created: ref.start, ran: ref.start },
}
present(state, [toolCommit(part, ref.msg, "start")])
return part
}
function askPermission(state: State, item: Permit): void {
const tool = startTool(state, item.ref)
const id = take(state, "perm", "perm")
state.perms.set(id, {
ref: item.ref,
done: item.done,
})
present(state, [], {
type: "permission",
request: {
id,
sessionID: state.id,
action: item.permission,
resources: item.patterns,
metadata: item.metadata ?? {},
save: item.always,
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
tool,
},
})
}
function doneTool(
state: State,
ref: Ref,
output: {
title: string
output: string
metadata?: Record<string, JsonValue>
},
): void {
if (!state.started.has(ref.part)) startTool(state, ref)
const part: SessionMessageAssistantTool = {
type: "tool",
id: ref.call,
name: ref.tool,
state: {
status: "completed",
input: ref.input,
content: output.output ? [{ type: "text", text: output.output }] : [],
structured: output.metadata ?? {},
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
}
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
}
function failTool(state: State, ref: Ref, error: string): void {
if (!state.started.has(ref.part)) startTool(state, ref)
present(state, [
toolCommit(
{
type: "tool",
id: ref.call,
name: ref.tool,
state: {
status: "error",
input: ref.input,
error: { type: "unknown", message: error },
structured: {},
content: [],
},
time: { created: ref.start, ran: ref.start, completed: Date.now() },
},
ref.msg,
"final",
),
])
}
function emitError(state: State, text: string): void {
present(state, [{ kind: "error", source: "system", text, phase: "start" }])
}
async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
const ref = make(state, "shell", {
command: "git status",
workdir: process.cwd(),
description: "Show git status",
})
startTool(state, ref)
await wait(70, signal)
doneTool(state, ref, {
title: "git status",
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
metadata: {
exit: 0,
},
})
}
function emitWrite(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "write", {
path: file,
content: "export const demo = 42\n",
})
doneTool(state, ref, {
title: "write",
output: "",
metadata: {},
})
}
function emitEdit(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "edit", {
path: file,
})
doneTool(state, ref, {
title: "edit",
output: "",
metadata: {
files: [
{
file,
status: "modified",
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
},
],
},
})
}
function emitPatch(state: State): void {
const file = path.join(process.cwd(), "src", "demo-format.ts")
const ref = make(state, "patch", {
patchText: "*** Begin Patch\n*** End Patch",
})
doneTool(state, ref, {
title: "patch",
output: "",
metadata: {
files: [
{
status: "modified",
file,
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
deletions: 1,
},
{
status: "added",
file: path.join(process.cwd(), "README-demo.md"),
patch: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
deletions: 0,
},
],
},
})
}
function emitTask(state: State): void {
const ref = make(state, "subagent", {
description: "Scan run/* for reducer touchpoints",
agent: "explore",
})
doneTool(state, ref, {
title: "Reducer touchpoints found",
output: "",
metadata: {
sessionID: "sub_demo_1",
status: "completed",
output: "",
},
})
const part = {
type: "tool",
id: "sub_demo_call_1",
name: "read",
state: {
status: "running",
input: {
path: "packages/tui/src/mini/stream.ts",
offset: 1,
limit: 200,
},
structured: {},
content: [],
},
time: { created: Date.now(), ran: Date.now() },
} satisfies SessionMessageAssistantTool
showSubagent(state, {
sessionID: "sub_demo_1",
label: "Explore",
description: "Scan run/* for reducer touchpoints",
status: "completed",
title: "Reducer touchpoints found",
commits: [
{
kind: "user",
text: "Scan run/* for reducer touchpoints",
phase: "start",
source: "system",
},
{
kind: "reasoning",
text: "Thinking: tracing reducer and footer boundaries",
phase: "progress",
source: "reasoning",
messageID: "sub_demo_msg_reasoning",
partID: "sub_demo_reasoning_1",
},
{
kind: "tool",
text: "running read",
phase: "start",
source: "tool",
messageID: "sub_demo_msg_tool",
partID: "sub_demo_tool_1",
tool: "read",
part,
},
{
kind: "assistant",
text: "Footer updates flow through stream.ts into RunFooter",
phase: "progress",
source: "assistant",
messageID: "sub_demo_msg_text",
partID: "sub_demo_text_1",
},
],
})
}
function emitQuestionTool(state: State): void {
const ref = make(state, "question", {
questions: [
{
header: "Style",
question: "Which output style do you want to inspect?",
options: [
{ label: "Diff", description: "Show diff block" },
{ label: "Code", description: "Show code block" },
],
multiple: false,
custom: false,
},
{
header: "Extras",
question: "Pick extra rows",
options: [
{ label: "Usage", description: "Add usage row" },
{ label: "Duration", description: "Add duration row" },
],
multiple: true,
custom: true,
},
],
})
doneTool(state, ref, {
title: "question",
output: "",
metadata: {
answers: [["Diff"], ["Usage", "custom-note"]],
},
})
}
function emitPermission(state: State, kind: PermissionKind = "edit"): void {
const root = process.cwd()
const file = path.join(root, "src", "demo-format.ts")
if (kind === "shell") {
const command = "git status --short"
const ref = make(state, "shell", {
command,
workdir: root,
description: "Inspect worktree changes",
})
askPermission(state, {
ref,
permission: "shell",
patterns: [command],
always: ["*"],
done: {
title: "git status --short",
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
metadata: {
exit: 0,
},
},
})
return
}
if (kind === "read") {
const target = path.join(root, "package.json")
const ref = make(state, "read", {
path: target,
offset: 1,
limit: 80,
})
askPermission(state, {
ref,
permission: "read",
patterns: [target],
always: [target],
done: {
title: "read",
output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"),
metadata: {},
},
})
return
}
if (kind === "subagent") {
const ref = make(state, "subagent", {
description: "Inspect footer spacing across direct-mode prompts",
agent: "explore",
})
askPermission(state, {
ref,
permission: "subagent",
patterns: ["explore"],
always: ["*"],
done: {
title: "Footer spacing checked",
output: "",
metadata: {
sessionID: "sub_demo_perm_1",
status: "completed",
output: "",
},
},
})
return
}
if (kind === "external") {
const dir = path.join(path.dirname(root), "demo-shared")
const target = path.join(dir, "README.md")
const ref = make(state, "read", {
path: target,
offset: 1,
limit: 40,
})
askPermission(state, {
ref,
permission: "external_directory",
patterns: [`${dir}/**`],
metadata: {
parentDir: dir,
filepath: target,
},
always: [`${dir}/**`],
done: {
title: "read",
output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
metadata: {},
},
})
return
}
if (kind === "doom") {
const ref = make(state, "subagent", {
description: "Retry the formatter after repeated failures",
agent: "general",
})
askPermission(state, {
ref,
permission: "doom_loop",
patterns: ["*"],
always: ["*"],
done: {
title: "Retry allowed",
output: "Continuing after repeated failures.\n",
metadata: {},
},
})
return
}
const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
const ref = make(state, "edit", {
path: file,
})
askPermission(state, {
ref,
permission: "edit",
patterns: [file],
always: [file],
done: {
title: "edit",
output: "",
metadata: {
files: [{ file, status: "modified", patch: diff }],
},
},
})
}
function demoForm(kind: FormKind): { title: string; fields: MiniFormRequest["fields"]; questions?: JsonValue[] } {
if (kind === "question") {
const questions: JsonValue[] = [
{
header: "Layout",
question: "Which footer view should be the reference for spacing checks?",
options: [
{ label: "Form", description: "Inspect the canonical Form footer" },
{ label: "Prompt", description: "Return to the normal composer" },
],
multiple: false,
custom: true,
},
{
header: "Checks",
question: "Pick formatting previews",
options: [
{ label: "Diff", description: "Emit an edit diff" },
{ label: "Subagent", description: "Emit a subagent card" },
],
multiple: true,
custom: true,
},
]
return {
title: "Questions",
questions,
fields: [
{
key: "q0",
title: "Layout",
description: "Which footer view should be the reference for spacing checks?",
type: "string",
options: [
{ value: "Form", label: "Form", description: "Inspect the canonical Form footer" },
{ value: "Prompt", label: "Prompt", description: "Return to the normal composer" },
],
custom: true,
},
{
key: "q1",
title: "Checks",
description: "Pick formatting previews",
type: "multiselect",
options: [
{ value: "Diff", label: "Diff", description: "Emit an edit diff" },
{ value: "Subagent", label: "Subagent", description: "Emit a subagent card" },
],
custom: true,
},
],
}
}
return {
title: "MCP authorization",
fields: [
{
key: "authorization",
type: "external",
url: "https://example.com/opencode-demo",
title: "Authorize demo MCP server",
description: "Complete authorization in your browser",
},
],
}
}
function emitForm(state: State, kind: FormKind = "question"): void {
const form = demoForm(kind)
const ref = make(state, kind === "question" ? "question" : "mcp_demo", {
...(form.questions ? { questions: form.questions } : { form: kind }),
})
startTool(state, ref)
state.form++
const request: MiniFormRequest = {
id: `frm_demo_${state.form}`,
sessionID: state.id,
title: form.title,
metadata:
kind === "question"
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
fields: form.fields,
}
state.forms.set(request.id, { ref, kind, request })
present(state, [], { type: "form", request })
}
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
if (kind === "text") {
await emitText(state, body || SAMPLE_MARKDOWN, signal)
return true
}
if (kind === "markdown" || kind === "md") {
await emitText(state, body || SAMPLE_MARKDOWN, signal)
return true
}
if (kind === "table") {
await emitText(state, body || SAMPLE_TABLE, signal)
return true
}
if (kind === "reasoning") {
await emitReasoning(state, body || "Planning next steps [REDACTED] while preserving reducer ordering.", signal)
return true
}
if (kind === "shell") {
await emitBash(state, signal)
return true
}
if (kind === "write") {
emitWrite(state)
return true
}
if (kind === "edit") {
emitEdit(state)
return true
}
if (kind === "patch") {
emitPatch(state)
return true
}
if (kind === "subagent") {
emitTask(state)
return true
}
if (kind === "question") {
emitQuestionTool(state)
return true
}
if (kind === "error") {
emitError(state, body || "demo error event")
return true
}
if (kind === "mix") {
await emitText(state, SAMPLE_MARKDOWN, signal)
await wait(50, signal)
await emitReasoning(state, "Thinking through formatter edge cases [REDACTED].", signal)
await wait(50, signal)
await emitBash(state, signal)
emitWrite(state)
emitEdit(state)
emitPatch(state)
emitTask(state)
emitQuestionTool(state)
emitError(state, "demo mixed scenario error")
return true
}
return false
}
function intro(state: State): void {
note(
state.footer,
[
"Demo slash commands enabled for interactive mode.",
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
`- /form [kind] (${FORMS.join(", ")})`,
`- /fmt <kind> (${KINDS.join(", ")})`,
"Examples:",
"- /permission shell",
"- /form question",
"- /form external",
"- /fmt markdown",
"- /fmt table",
"- /fmt text your custom text",
].join("\n"),
)
}
export function createRunDemo(input: Input) {
const state: State = {
id: input.sessionID,
thinking: input.thinking,
footer: input.footer,
msg: 0,
part: 0,
call: 0,
perm: 0,
form: 0,
perms: new Map(),
forms: new Map(),
started: new Set(),
}
const start = async (): Promise<void> => {
intro(state)
}
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
const text = line.text.trim()
const list = text.split(/\s+/)
const cmd = list[0] || ""
clearSubagent(state.footer)
if (cmd === "/help") {
intro(state)
return true
}
if (cmd === "/permission") {
const kind = permissionKind(list[1])
if (!kind) {
note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
return true
}
emitPermission(state, kind)
return true
}
if (cmd === "/form") {
const kind = formKind(list[1])
if (!kind) {
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
return true
}
emitForm(state, kind)
return true
}
if (cmd === "/fmt") {
const kind = (list[1] || "").toLowerCase()
const body = list.slice(2).join(" ")
if (!kind) {
note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
return true
}
const ok = await emitFmt(state, kind, body, signal)
if (ok) {
return true
}
note(state.footer, `Unknown kind "${kind}". Use: ${KINDS.join(", ")}`)
return true
}
return false
}
const permission = (input: PermissionReply): boolean => {
const item = state.perms.get(input.requestID)
if (!item || !input.reply) {
return false
}
state.perms.delete(input.requestID)
clearBlocker(state)
if (input.reply === "reject") {
failTool(state, item.ref, input.message || "permission rejected")
return true
}
doneTool(state, item.ref, item.done)
return true
}
const formReply = (input: FormReply): boolean => {
const form = state.forms.get(input.formID)
if (!form || input.sessionID !== form.request.sessionID) return false
state.forms.delete(input.formID)
clearBlocker(state)
if (form.kind === "question") {
doneTool(state, form.ref, {
title: "question",
output: "",
metadata: {
answers: form.request.fields.map((field) => {
const value = input.answer[field.key]
if (value === undefined) return []
return Array.isArray(value) ? [...value] : [String(value)]
}),
},
})
return true
}
doneTool(state, form.ref, {
title: form.request.title,
output: `Form submitted: ${Object.entries(input.answer)
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
.join("; ")}\n`,
metadata: { answer: input.answer },
})
return true
}
const formCancel = (input: FormCancel): boolean => {
const form = state.forms.get(input.formID)
if (!form || input.sessionID !== form.request.sessionID) return false
state.forms.delete(input.formID)
clearBlocker(state)
failTool(state, form.ref, "form cancelled")
return true
}
return {
start,
prompt,
permission,
formReply,
formCancel,
}
}