diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0385f042a10..e75c2183809 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -115,6 +115,34 @@ This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `be Please try to follow the [style guide](./STYLE_GUIDE.md) +### Running Tests + +Run the test suite from the `packages/opencode` directory: + +```bash +bun test +``` + +To run a specific test file: + +```bash +bun test test/tool/tool.test.ts +``` + +#### Test Environment Variables + +Environment variables prefixed with `OPENCODE_TEST_` can be used to alter test behavior. When tests start, any such variables are printed to the console for visibility. + +| Variable | Description | +| ------------------------ | -------------------------------------------------------------------------------------------- | +| `OPENCODE_TEST_SKIP_GIT` | Skip git repository initialization in test fixtures (useful when commit signing is required) | + +Example: + +```bash +OPENCODE_TEST_SKIP_GIT=1 bun test +``` + ### Setting up a Debugger Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points. diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 10e340d7f8f..0e84f1af524 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -27,7 +27,15 @@ import { RGBA, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" -import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" +import type { + AssistantMessage, + Part, + ToolPart, + UserMessage, + TextPart, + ReasoningPart, + Session as SessionType, +} from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" import type { Tool } from "@/tool/tool" @@ -111,6 +119,31 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) + const descendants = createMemo(() => { + const rootID = session()?.parentID ?? session()?.id + if (!rootID) return [] + + const result: SessionType[] = [] + const visited = new Set() + const queue = [rootID] + + while (queue.length > 0) { + const currentID = queue.shift()! + if (visited.has(currentID)) continue + visited.add(currentID) + + const current = sync.data.session.find((x) => x.id === currentID) + if (current) result.push(current) + + for (const s of sync.data.session) { + if (s.parentID === currentID && !visited.has(s.id)) { + queue.push(s.id) + } + } + } + + return result.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) const children = createMemo(() => { const parentID = session()?.parentID ?? session()?.id return sync.data.session @@ -120,11 +153,13 @@ export function Session() { const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return descendants().flatMap((x) => sync.data.permission[x.id] ?? []) }) const questions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return descendants() + .flatMap((x) => sync.data.question[x.id] ?? []) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const pending = createMemo(() => { @@ -1838,29 +1873,75 @@ function TodoWrite(props: ToolProps) { } function Question(props: ToolProps) { - const { theme } = useTheme() + const { theme, syntax } = useTheme() const count = createMemo(() => props.input.questions?.length ?? 0) - function format(answer?: string[]) { - if (!answer?.length) return "(no answer)" - return answer.join(", ") - } - return ( - - - - {(q, i) => ( - - {q.question} - {format(props.metadata.answers?.[i()])} - - )} - - - + {(() => { + const ctx = use() + const allAnswers = () => (props.input.questions ?? []).map((_, idx) => props.metadata.answers?.[idx] ?? []) + const maxAnswerLen = () => Math.max(20, ...allAnswers().flatMap((answers) => answers.map((a) => a.length))) + const tableWidth = () => ctx.width - 6 + const halfWidth = () => Math.floor(tableWidth() * 0.5) + const answerWidth = () => Math.max(20, Math.min(maxAnswerLen() + 2, halfWidth())) + return ( + + + + {(q, i) => { + const answers = () => props.metadata.answers?.[i()] ?? [] + const isLast = () => i() === (props.input.questions?.length ?? 0) - 1 + const isMulti = () => answers().length > 1 + return ( + + + + + + 0} + fallback={ + + (no answer) + + } + > + + {(answer) => ( + + {answer} + + )} + + + + + ) + }} + + + + ) + })()} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index e43f4219b98..f827b3b4d49 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" -import { createMemo, For, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { createEffect, createMemo, For, on, Show } from "solid-js" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme } from "../../context/theme" @@ -12,7 +12,8 @@ import { useDialog } from "../../ui/dialog" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() - const { theme } = useTheme() + const { theme, syntax } = useTheme() + const dimensions = useTerminalDimensions() const keybind = useKeybind() const bindings = useTextareaKeybindings() @@ -41,6 +42,20 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { return store.answers[store.tab]?.includes(value) ?? false }) + createEffect( + on( + () => props.request.id, + () => { + setStore("tab", 0) + setStore("answers", []) + setStore("custom", []) + setStore("selected", 0) + setStore("editing", false) + }, + { defer: true }, + ), + ) + function submit() { const answers = questions().map((_, i) => store.answers[i] ?? []) sdk.client.question.reply({ @@ -184,13 +199,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { setStore("selected", (store.selected + 1) % total) } - if (evt.name === "return") { + if (evt.name === "space" && multi()) { evt.preventDefault() if (other()) { - if (!multi()) { - setStore("editing", true) - return - } const value = input() if (value && customPicked()) { toggle(value) @@ -201,10 +212,29 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } const opt = opts[store.selected] if (!opt) return + toggle(opt.label) + return + } + + if (evt.name === "return") { + evt.preventDefault() if (multi()) { - toggle(opt.label) + const hasSelections = (store.answers[store.tab]?.length ?? 0) > 0 + if (!hasSelections) return + if (single()) { + submit() + return + } + setStore("tab", store.tab + 1) + setStore("selected", 0) return } + if (other()) { + setStore("editing", true) + return + } + const opt = opts[store.selected] + if (!opt) return pick(opt.label) } @@ -222,98 +252,119 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { borderColor={theme.accent} customBorderChars={SplitBorder.customBorderChars} > - - - - - {(q, index) => { - const isActive = () => index() === store.tab - const isAnswered = () => { - return (store.answers[index()]?.length ?? 0) > 0 - } - return ( - - - {q.header} - - - ) - }} - - - Confirm - - - - - - - - - {question()?.question} - {multi() ? " (select all that apply)" : ""} + + + + + + Question from {props.request.from} - - - {(opt, i) => { - const active = () => i() === store.selected - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + + + + + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => { + return (store.answers[index()]?.length ?? 0) > 0 + } return ( - - - - - {i() + 1}. {opt.label} - - - {picked() ? "✓" : ""} - - - {opt.description} - + + + {q.header} + ) }} - - - - - {options().length + 1}. Type your own answer - - - {customPicked() ? "✓" : ""} - - - -