diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 35690030c9..03a66ea3a7 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -469,7 +469,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } > -
{question()?.question}
+
+ {question()?.question} +
{language.t("ui.question.singleHint")}}>
{language.t("ui.question.multiHint")}
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 24d122162a..3829f4488c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -773,7 +773,7 @@ function getSyntaxRules(theme: Theme) { { scope: ["extmark.paste"], style: { - foreground: theme.background, + foreground: selectedForeground(theme, theme.warning), background: theme.warning, bold: true, }, diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx index 5017b77b00..f9217ec403 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/session-v2.tsx @@ -30,6 +30,7 @@ import type { ToolTextContent, } from "@opencode-ai/sdk/v2" import { createEffect, createMemo, createSignal, For, Match, Show, Switch } from "solid-js" +import { collapseToolOutput } from "../../util/collapse-tool-output" const id = "internal:session-v2-debug" const route = "session.v2.messages" @@ -198,26 +199,28 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) { function ShellMessage(props: { message: SessionMessageShell }) { const { theme } = useTheme() + const dimensions = useTerminalDimensions() const output = createMemo(() => stripAnsi(props.message.output.trim())) const [expanded, setExpanded] = createSignal(false) - const lines = createMemo(() => output().split("\n")) - const overflow = createMemo(() => lines().length > 10) + const maxLines = 10 + const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) + const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !overflow()) return output() - return [...lines().slice(0, 10), "…"].join("\n") + if (expanded() || !collapsed().overflow) return output() + return collapsed().output }) return ( setExpanded((prev) => !prev) : undefined} + onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > $ {props.message.command} {limited()} - + {expanded() ? "Click to collapse" : "Click to expand"} @@ -518,14 +521,15 @@ type ToolProps = { function GenericTool(props: ToolProps) { const { theme } = useTheme() + const dimensions = useTerminalDimensions() const output = createMemo(() => props.output?.trim() ?? "") const [expanded, setExpanded] = createSignal(false) - const lines = createMemo(() => output().split("\n")) const maxLines = 3 - const overflow = createMemo(() => lines().length > maxLines) + const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) + const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !overflow()) return output() - return [...lines().slice(0, maxLines), "…"].join("\n") + if (expanded() || !collapsed().overflow) return output() + return collapsed().output }) return ( setExpanded((prev) => !prev) : undefined} + onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > {limited()} - + {expanded() ? "Click to collapse" : "Click to expand"} @@ -702,15 +706,17 @@ function BlockTool(props: { function Bash(props: ToolProps) { const { theme } = useTheme() + const dimensions = useTerminalDimensions() const output = createMemo(() => stripAnsi((stringValue(props.metadata.output) ?? props.output ?? "").trim())) const command = createMemo(() => stringValue(props.input.command) ?? pendingInput(props.part)) const title = createMemo(() => `# ${stringValue(props.input.description) ?? "Shell"}`) const [expanded, setExpanded] = createSignal(false) - const lines = createMemo(() => output().split("\n")) - const overflow = createMemo(() => lines().length > 10) + const maxLines = 10 + const maxChars = createMemo(() => maxLines * Math.max(20, dimensions().width - 6)) + const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !overflow()) return output() - return [...lines().slice(0, 10), "…"].join("\n") + if (expanded() || !collapsed().overflow) return output() + return collapsed().output }) return ( @@ -719,12 +725,12 @@ function Bash(props: ToolProps) { title={title()} part={props.part} spinner={props.part.state.status === "running"} - onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} + onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > $ {command()} {limited()} - + {expanded() ? "Click to collapse" : "Click to expand"} 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 ce651fdbe4..e8e29a40c9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -84,6 +84,7 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { nextThinkingMode, reasoningTitle, useThinkingMode, type ThinkingMode } from "../../context/thinking" import { getScrollAcceleration } from "../../util/scroll" +import { collapseToolOutput } from "../../util/collapse-tool-output" import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime" import { DialogRetryAction } from "../../component/dialog-retry-action" import { SessionRetry } from "@/session/retry" @@ -1696,12 +1697,12 @@ function GenericTool(props: ToolProps) { const ctx = use() const output = createMemo(() => props.output?.trim() ?? "") const [expanded, setExpanded] = createSignal(false) - const lines = createMemo(() => output().split("\n")) const maxLines = 3 - const overflow = createMemo(() => lines().length > maxLines) + const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) + const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !overflow()) return output() - return [...lines().slice(0, maxLines), "…"].join("\n") + if (expanded() || !collapsed().overflow) return output() + return collapsed().output }) return ( @@ -1716,11 +1717,11 @@ function GenericTool(props: ToolProps) { setExpanded((prev) => !prev) : undefined} + onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > {limited()} - + {expanded() ? "Click to collapse" : "Click to expand"} @@ -1871,14 +1872,16 @@ function BlockTool(props: { function Shell(props: ToolProps) { const { theme } = useTheme() const pathFormatter = usePathFormatter() + const ctx = use() const isRunning = createMemo(() => props.part.state.status === "running") const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) - const lines = createMemo(() => output().split("\n")) - const overflow = createMemo(() => lines().length > 10) + const maxLines = 10 + const maxChars = createMemo(() => maxLines * Math.max(20, ctx.width - 6)) + const collapsed = createMemo(() => collapseToolOutput(output(), maxLines, maxChars())) const limited = createMemo(() => { - if (expanded() || !overflow()) return output() - return [...lines().slice(0, 10), "…"].join("\n") + if (expanded() || !collapsed().overflow) return output() + return collapsed().output }) const workdirDisplay = createMemo(() => { @@ -1902,14 +1905,14 @@ function Shell(props: ToolProps) { title={title()} part={props.part} spinner={isRunning()} - onClick={overflow() ? () => setExpanded((prev) => !prev) : undefined} + onClick={collapsed().overflow ? () => setExpanded((prev) => !prev) : undefined} > $ {props.input.command} {limited()} - + {expanded() ? "Click to collapse" : "Click to expand"} diff --git a/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts b/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts new file mode 100644 index 0000000000..da8d739351 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/collapse-tool-output.ts @@ -0,0 +1,13 @@ +export function collapseToolOutput(output: string, maxLines: number, maxChars: number) { + const lines = output.split("\n") + if (lines.length <= maxLines && Array.from(output).length <= maxChars) { + return { output, overflow: false } + } + + const preview = lines.slice(0, maxLines).join("\n") + if (Array.from(preview).length > maxChars) { + return { output: Array.from(preview).slice(0, Math.max(0, maxChars - 1)).join("") + "…", overflow: true } + } + + return { output: [...lines.slice(0, maxLines), "…"].join("\n"), overflow: true } +} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 0dd02d8129..78d574d8a1 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -935,7 +935,7 @@ gap: 6px; margin-top: 12px; padding: 1px 1px 8px; - flex: 1; + flex-shrink: 0; min-height: 0; overflow-y: auto; scrollbar-width: none; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 07b8022abe..acf0c9dee3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -58,6 +58,30 @@ import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" +async function writeClipboard(text: string): Promise { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = text + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return true + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return false + return clipboard.writeText(text).then( + () => true, + () => false, + ) +} + function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined let valueRef: HTMLSpanElement | undefined @@ -1064,9 +1088,10 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const handleCopy = async () => { const content = text() if (!content) return - await navigator.clipboard.writeText(content) - setState("copied", true) - setTimeout(() => setState("copied", false), 2000) + if (await writeClipboard(content)) { + setState("copied", true) + setTimeout(() => setState("copied", false), 2000) + } } const revert = () => { @@ -1490,9 +1515,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const handleCopy = async () => { const content = text() if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + if (await writeClipboard(content)) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } } return ( @@ -1834,9 +1860,10 @@ ToolRegistry.register({ const handleCopy = async () => { const content = text() if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + if (await writeClipboard(content)) { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } } return (