diff --git a/README.md b/README.md
index 799cf00a2a80..acbe7b2a4d67 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,27 @@
+# shuvcode
+
+> **A fork of [sst/opencode](https://github.com/sst/opencode)** - The AI coding agent built for the terminal.
+
+This fork serves as an integration testing ground for upstream PRs before they are merged into the main opencode repository. We merge, test, and validate promising features and fixes to help ensure quality contributions to the upstream project.
+
+---
+
+## Merged PRs (Pending Upstream)
+
+The following PRs have been merged into this fork and are awaiting merge into upstream:
+
+| PR | Title | Status | Description |
+| -------------------------------------------------- | --------------------------------- | ------ | ------------------------------------------------------------------ |
+| [#4898](https://github.com/sst/opencode/pull/4898) | Search in messages | Open | Ctrl+F to search through session messages with highlighting |
+| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | Open | Full terminal emulation for bash output with color support |
+| [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits |
+| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | Open | Real-time token tracking and display during model responses |
+| [#4773](https://github.com/sst/opencode/pull/4773) | Configurable subagent visibility | Open | Allow agents to restrict which subagents they can invoke |
+
+_Last updated: 2025-11-29_
+
+---
+
diff --git a/bun.lock b/bun.lock
index 12e5eb6e03a8..3cbc89a101b0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -256,6 +256,7 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
+ "opentui-ansi-vt": "1.2.7",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
@@ -2969,6 +2970,8 @@
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
+ "opentui-ansi-vt": ["opentui-ansi-vt@1.2.7", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-mcumATXHkagt7JUK+5mpBPaUW0LHrwysp8JQqDBnZG22vb1TTh/HmGjWFXBWe4oo4Rshw7s7NWIbt0/jCWOgcw=="],
+
"opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
diff --git a/flake.lock b/flake.lock
index f35c345f0bad..211be53aa99b 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1764384123,
- "narHash": "sha256-UoliURDJFaOolycBZYrjzd9Cc66zULEyHqGFH3QHEq0=",
+ "lastModified": 1764445028,
+ "narHash": "sha256-ik6H/0Zl+qHYDKTXFPpzuVHSZE+uvVz2XQuQd1IVXzo=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "59b6c96beacc898566c9be1052ae806f3835f87d",
+ "rev": "a09378c0108815dbf3961a0e085936f4146ec415",
"type": "github"
},
"original": {
diff --git a/nix/hashes.json b/nix/hashes.json
index 8f73ecceaba8..162d100ac435 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,3 @@
{
- "nodeModules": "sha256-+PJZG5jNxBGkxblpnNa4lvfBi9YEvHaGQRE0+avNwHY="
+ "nodeModules": "sha256-AFi1XqNAoEF88GCG6OUWNMTHhQDCkI2m5qtwGyuUl38="
}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 8a9b0ce712f9..a27868447a03 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -82,6 +82,7 @@
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",
+ "opentui-ansi-vt": "1.2.7",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 4bd2c0999084..a19f28fc0904 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -17,6 +17,7 @@ import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
+import { useToast } from "../../ui/toast"
import type { FilePart } from "@opencode-ai/sdk"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
@@ -30,10 +31,12 @@ export type PromptProps = {
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
+ initialValue?: string
}
export type PromptRef = {
focused: boolean
+ text: string
set(prompt: PromptInfo): void
reset(): void
blur(): void
@@ -277,6 +280,10 @@ export function Prompt(props: PromptProps) {
onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
+ if (props.initialValue) {
+ input.setText(props.initialValue)
+ setStore("prompt", "input", props.initialValue)
+ }
})
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -361,6 +368,9 @@ export function Prompt(props: PromptProps) {
get focused() {
return input.focused
},
+ get text() {
+ return input.plainText
+ },
focus() {
input.focus()
},
@@ -496,6 +506,22 @@ export function Prompt(props: PromptProps) {
input.clear()
}
const exit = useExit()
+ const toast = useToast()
+ let lastExitAttempt = 0
+
+ async function tryExit() {
+ const now = Date.now()
+ if (now - lastExitAttempt < 2000) {
+ await exit()
+ return
+ }
+ lastExitAttempt = now
+ toast.show({
+ variant: "warning",
+ message: "Press again to exit",
+ duration: 2000,
+ })
+ }
function pasteText(text: string, virtualText: string) {
const currentOffset = input.visualCursor.offset
@@ -680,7 +706,7 @@ export function Prompt(props: PromptProps) {
return
}
if (keybind.match("app_exit", e)) {
- await exit()
+ await tryExit()
return
}
if (e.name === "!" && input.visualCursor.offset === 0) {
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx
new file mode 100644
index 000000000000..60dccbd6fb77
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx
@@ -0,0 +1,230 @@
+import { BoxRenderable, TextareaRenderable, type KeyBinding } from "@opentui/core"
+import { createEffect, createMemo, createSignal, type JSX, onMount, Show } from "solid-js"
+import { useTheme } from "@tui/context/theme"
+import { EmptyBorder } from "@tui/component/border"
+import { createStore } from "solid-js/store"
+import { useKeybind } from "@tui/context/keybind"
+import { Locale } from "@/util/locale"
+import { useLocal } from "@tui/context/local"
+import { RGBA } from "@opentui/core"
+import { useSDK } from "@tui/context/sdk"
+import { useSync } from "@tui/context/sync"
+import { useExit } from "../../context/exit"
+
+export type SearchInputProps = {
+ disabled?: boolean
+ onSubmit?: (query: string) => void
+ onExit?: () => void
+ onInput?: (query: string) => void
+ onNext?: () => void
+ onPrevious?: () => void
+ matchInfo?: { current: number; total: number }
+ sessionID?: string
+ ref?: (ref: SearchInputRef) => void
+ placeholder?: string
+}
+
+export type SearchInputRef = {
+ focused: boolean
+ reset(): void
+ blur(): void
+ focus(): void
+ getValue(): string
+}
+
+export function SearchInput(props: SearchInputProps) {
+ let input: TextareaRenderable
+ let anchor: BoxRenderable
+
+ const exit = useExit()
+ const keybind = useKeybind()
+ const local = useLocal()
+ const sdk = useSDK()
+ const sync = useSync()
+ const { theme } = useTheme()
+
+ const highlight = createMemo(() => {
+ const agent = local.agent.current()
+ if (agent?.color) return RGBA.fromHex(agent.color)
+ const agents = local.agent.list()
+ const index = agents.findIndex((x) => x.name === "search")
+ const colors = [theme.secondary, theme.accent, theme.success, theme.warning, theme.primary, theme.error]
+ if (index === -1) return colors[0]
+ return colors[index % colors.length]
+ })
+
+ const textareaKeybindings = createMemo(() => {
+ const submitBindings = keybind.all.input_submit || []
+ return [
+ { name: "return", action: "submit" },
+ ...submitBindings.map((binding) => ({
+ name: binding.name,
+ ctrl: binding.ctrl || undefined,
+ meta: binding.meta || undefined,
+ shift: binding.shift || undefined,
+ action: "submit" as const,
+ })),
+ ] satisfies KeyBinding[]
+ })
+
+ const [store, setStore] = createStore<{
+ input: string
+ }>({
+ input: "",
+ })
+
+ createEffect(() => {
+ if (props.disabled) input.cursorColor = theme.backgroundElement
+ if (!props.disabled) input.cursorColor = theme.primary
+ })
+
+ props.ref?.({
+ get focused() {
+ return input.focused
+ },
+ focus() {
+ input.focus()
+ },
+ blur() {
+ input.blur()
+ },
+ reset() {
+ input.clear()
+ setStore("input", "")
+ },
+ getValue() {
+ return store.input
+ },
+ })
+
+ function submit() {
+ if (props.disabled) return
+ if (!store.input) return
+ props.onSubmit?.(store.input)
+ input.clear()
+ setStore("input", "")
+ }
+
+ onMount(() => {
+ input.focus()
+ })
+
+ return (
+ <>
+ (anchor = r)}>
+
+
+
+
+
+
+
+
+
+
+ ↑/↓ navigate
+
+
+ esc exit
+
+
+
+
+ >
+ )
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index 88b9616b06a2..03ffbb621a2c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -840,6 +840,14 @@ function getSyntaxRules(theme: Theme) {
foreground: theme.textMuted,
},
},
+ {
+ scope: ["markup.strikethrough"],
+ style: {
+ foreground: theme.background,
+ background: theme.primary,
+ strikethrough: true,
+ },
+ },
// Additional common highlight groups
{
scope: ["string.special", "string.special.url"],
@@ -933,12 +941,6 @@ function getSyntaxRules(theme: Theme) {
foreground: theme.syntaxOperator,
},
},
- {
- scope: ["markup.strikethrough"],
- style: {
- foreground: theme.textMuted,
- },
- },
{
scope: ["markup.underline"],
style: {
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 bb29c6e88214..8e4447364dab 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -62,11 +62,15 @@ import { Clipboard } from "../../util/clipboard"
import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import { Editor } from "../../util/editor"
-import stripAnsi from "strip-ansi"
+import { SearchInput, type SearchInputRef } from "../../component/prompt/search.tsx"
import { Footer } from "./footer.tsx"
+import { extend } from "@opentui/solid"
+import { TerminalBufferRenderable } from "opentui-ansi-vt/terminal-buffer"
addDefaultParsers(parsers.parsers)
+extend({ "terminal-buffer": TerminalBufferRenderable })
+
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
@@ -77,6 +81,18 @@ class CustomSpeedScroll implements ScrollAcceleration {
reset(): void {}
}
+type SearchMatch = {
+ messageID: string
+ partID?: string
+ text: string
+ index: number
+}
+
+type BashOutputView = {
+ command: string
+ output: () => string
+}
+
const context = createContext<{
width: number
conceal: () => boolean
@@ -85,6 +101,11 @@ const context = createContext<{
showTokens: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType
+ searchQuery: () => string
+ currentMatchIndex: () => number
+ matches: () => SearchMatch[]
+ bashOutput: () => BashOutputView | undefined
+ showBashOutput: (view: BashOutputView | undefined) => void
}>()
function use() {
@@ -125,6 +146,8 @@ export function Session() {
const [showThinking, setShowThinking] = createSignal(kv.get("thinking_visibility", true))
const [showTimestamps, setShowTimestamps] = createSignal(kv.get("timestamps", "hide") === "show")
const [showTokens, setShowTokens] = createSignal(kv.get("tokens", "hide") === "show")
+ const [bashOutput, setBashOutput] = createSignal(undefined)
+ const [promptDraft, setPromptDraft] = createSignal("")
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const wide = createMemo(() => dimensions().width > 120)
@@ -189,12 +212,126 @@ export function Session() {
})
let scroll: ScrollBoxRenderable
+ let bashScroll: ScrollBoxRenderable
let prompt: PromptRef
+ let search: SearchInputRef
+ const [searchMode, setSearchMode] = createSignal(false)
+ const [searchQuery, setSearchQuery] = createSignal("")
+ const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0)
const keybind = useKeybind()
+ const matches = createMemo(() => {
+ const query = searchQuery().toLowerCase().trim()
+ if (!query) return []
+
+ const result: SearchMatch[] = []
+ let matchIndex = 0
+
+ for (const message of messages()) {
+ const parts = sync.data.part[message.id] ?? []
+ for (const part of parts) {
+ if (part.type === "text" && !part.synthetic) {
+ const text = part.text.toLowerCase()
+ let pos = 0
+ while ((pos = text.indexOf(query, pos)) !== -1) {
+ result.push({
+ messageID: message.id,
+ partID: part.id,
+ text: part.text.slice(pos, pos + query.length),
+ index: matchIndex++,
+ })
+ pos += query.length
+ }
+ }
+ }
+ }
+ return result
+ })
+
+ createEffect(() => {
+ const m = matches()
+ if (m.length === 0) {
+ setCurrentMatchIndex(0)
+ } else if (currentMatchIndex() >= m.length) {
+ setCurrentMatchIndex(m.length - 1)
+ }
+ })
+
+ function handleNextMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ const current = currentMatchIndex()
+ const next = (current + 1) % m.length
+ setCurrentMatchIndex(next)
+
+ if (m.length === 1 || m[current]?.messageID !== m[next]?.messageID) {
+ scrollToMatch(next)
+ }
+ }
+
+ function handlePrevMatch() {
+ const m = matches()
+ if (m.length === 0) return
+ const current = currentMatchIndex()
+ const next = current === 0 ? m.length - 1 : current - 1
+ setCurrentMatchIndex(next)
+
+ if (m.length === 1 || m[current]?.messageID !== m[next]?.messageID) {
+ scrollToMatch(next)
+ }
+ }
+
+ function scrollToMatch(index: number) {
+ const m = matches()
+ if (index < 0 || index >= m.length) return
+ const match = m[index]
+ const child = scroll?.getChildren?.()?.find((c) => c.id === match.messageID)
+ if (child) {
+ const y = child.y - scroll.y
+ if (y < 0 || y >= scroll.height) {
+ scroll.scrollBy(y - Math.floor(scroll.height / 3))
+ }
+ }
+ }
+
useKeyboard((evt) => {
if (dialog.stack.length > 0) return
+ if (evt.ctrl && evt.name === "f") {
+ setSearchMode(!searchMode())
+ evt.preventDefault()
+ return
+ }
+
+ if (bashOutput()) {
+ const scroll = bashScroll
+ const amount = 3
+ const pageAmount = Math.max(1, dimensions().height - 4)
+ if (evt.name === "escape" || (evt.name === "c" && evt.ctrl)) {
+ setBashOutput(undefined)
+ evt.preventDefault()
+ } else if (evt.name === "up") {
+ scroll?.scrollBy(-amount)
+ evt.preventDefault()
+ } else if (evt.name === "down") {
+ scroll?.scrollBy(amount)
+ evt.preventDefault()
+ } else if (evt.name === "pageup") {
+ scroll?.scrollBy(-pageAmount)
+ evt.preventDefault()
+ } else if (evt.name === "pagedown") {
+ scroll?.scrollBy(pageAmount)
+ evt.preventDefault()
+ } else if (evt.name === "home") {
+ scroll?.scrollTo(0)
+ evt.preventDefault()
+ } else if (evt.name === "end") {
+ scroll?.scrollTo(scroll.scrollHeight)
+ evt.preventDefault()
+ }
+ return
+ }
+
const first = permissions()[0]
if (first) {
const response = iife(() => {
@@ -774,11 +911,19 @@ export function Session() {
return contentWidth()
},
conceal,
+ searchQuery,
+ currentMatchIndex,
+ matches,
showThinking,
showTimestamps,
showTokens,
diffWrapMode,
sync,
+ bashOutput,
+ showBashOutput: (view) => {
+ if (view && prompt) setPromptDraft(prompt.text)
+ setBashOutput(view)
+ },
}}
>
@@ -787,131 +932,182 @@ export function Session() {
- (scroll = r)}
- scrollbarOptions={{
- paddingLeft: 2,
- visible: false,
- trackOptions: {
- backgroundColor: theme.backgroundElement,
- foregroundColor: theme.border,
- },
- }}
- stickyScroll={true}
- stickyStart="bottom"
- flexGrow={1}
- scrollAcceleration={scrollAcceleration()}
- >
-
- {(message, index) => (
-
-
- {(function () {
- const command = useCommandDialog()
- const [hover, setHover] = createSignal(false)
- const dialog = useDialog()
-
- const handleUnrevert = async () => {
- const confirmed = await DialogConfirm.show(
- dialog,
- "Confirm Redo",
- "Are you sure you want to restore the reverted messages?",
- )
- if (confirmed) {
- command.trigger("session.redo")
- }
- }
-
- return (
- setHover(true)}
- onMouseOut={() => setHover(false)}
- onMouseUp={handleUnrevert}
- marginTop={1}
- flexShrink={0}
- border={["left"]}
- customBorderChars={SplitBorder.customBorderChars}
- borderColor={theme.backgroundPanel}
- >
-
- {revert()!.reverted.length} message reverted
-
- {keybind.print("messages_redo")} or /redo to
- restore
-
-
-
-
- {(file) => (
-
- {file.filename}
- 0}>
- +{file.additions}
-
- 0}>
- -{file.deletions}
-
-
- )}
-
+
+
+ {(view) => (
+
+
+ $ {view().command}
+
+ (bashScroll = r)}
+ flexGrow={1}
+ paddingLeft={1}
+ paddingBottom={1}
+ scrollAcceleration={scrollAcceleration()}
+ >
+
+
+
+ ESC to close | ↑/↓ scroll | PgUp/PgDn page | Home/End top/bottom
+
+
+ )}
+
+
+ <>
+ (scroll = r)}
+ scrollbarOptions={{
+ paddingLeft: 2,
+ visible: false,
+ trackOptions: {
+ backgroundColor: theme.backgroundElement,
+ foregroundColor: theme.border,
+ },
+ }}
+ stickyScroll={true}
+ stickyStart="bottom"
+ flexGrow={1}
+ scrollAcceleration={scrollAcceleration()}
+ >
+
+ {(message, index) => (
+
+
+ {(function () {
+ const command = useCommandDialog()
+ const [hover, setHover] = createSignal(false)
+ const dialog = useDialog()
+
+ const handleUnrevert = async () => {
+ const confirmed = await DialogConfirm.show(
+ dialog,
+ "Confirm Redo",
+ "Are you sure you want to restore the reverted messages?",
+ )
+ if (confirmed) {
+ command.trigger("session.redo")
+ }
+ }
+
+ return (
+ setHover(true)}
+ onMouseOut={() => setHover(false)}
+ onMouseUp={handleUnrevert}
+ marginTop={1}
+ flexShrink={0}
+ border={["left"]}
+ customBorderChars={SplitBorder.customBorderChars}
+ borderColor={theme.backgroundPanel}
+ >
+
+ {revert()!.reverted.length} message reverted
+
+ {keybind.print("messages_redo")} or /redo
+ to restore
+
+
+
+
+ {(file) => (
+
+ {file.filename}
+ 0}>
+ +{file.additions}
+
+ 0}>
+ -{file.deletions}
+
+
+ )}
+
+
+
+
-
-
-
- )
- })()}
-
- = revert()!.messageID}>
- <>>
-
-
- {
- if (renderer.getSelection()?.getSelectedText()) return
- dialog.replace(() => (
- prompt.set(promptInfo)}
+ )
+ })()}
+
+ = revert()!.messageID}>
+ <>>
+
+
+ {
+ if (renderer.getSelection()?.getSelectedText()) return
+ dialog.replace(() => (
+ prompt.set(promptInfo)}
+ />
+ ))
+ }}
+ message={message as UserMessage}
+ parts={sync.data.part[message.id] ?? []}
+ pending={pending()}
/>
- ))
+
+
+
+
+
+ )}
+
+
+
+
+ (prompt = r)}
+ disabled={permissions().length > 0}
+ onSubmit={() => {
+ toBottom()
}}
- message={message as UserMessage}
- parts={sync.data.part[message.id] ?? []}
- pending={pending()}
+ sessionID={route.sessionID}
+ initialValue={promptDraft()}
/>
-
-
-
+
+ (search = r)}
+ sessionID={route.sessionID}
+ disabled={permissions().length > 0}
+ onInput={(query) => {
+ setSearchQuery(query)
+ setCurrentMatchIndex(0)
+ }}
+ onNext={handleNextMatch}
+ onPrevious={handlePrevMatch}
+ matchInfo={
+ matches().length > 0 ? { current: currentMatchIndex(), total: matches().length } : undefined
+ }
+ onExit={() => {
+ setSearchMode(false)
+ setSearchQuery("")
+ setCurrentMatchIndex(0)
+ }}
/>
-
-
- )}
-
-
-
- (prompt = r)}
- disabled={permissions().length > 0}
- onSubmit={() => {
- toBottom()
- }}
- sessionID={route.sessionID}
- />
-
-
-
-
+
+
+
+
+
+ >
+
+
@@ -933,6 +1129,72 @@ const MIME_BADGE: Record = {
"application/x-directory": "dir",
}
+function SearchHighlighter(props: { text: string; query: string; messageID: string; partID?: string; fg?: any }) {
+ const ctx = use()
+ const { theme } = useTheme()
+
+ const segments = createMemo(() => {
+ const query = props.query.toLowerCase()
+ if (!query) return [{ text: props.text, highlight: false, isActive: false }]
+
+ const result: { text: string; highlight: boolean; isActive: boolean }[] = []
+ const text = props.text
+ const lower = text.toLowerCase()
+ let lastIndex = 0
+ let matchCount = 0
+
+ // Pre-filter matches for this message/part
+ const partMatches = ctx
+ .matches()
+ .filter((m) => m.messageID === props.messageID && (!props.partID || m.partID === props.partID))
+
+ let pos = 0
+ while ((pos = lower.indexOf(query, pos)) !== -1) {
+ if (pos > lastIndex) {
+ result.push({ text: text.slice(lastIndex, pos), highlight: false, isActive: false })
+ }
+
+ const globalMatch = partMatches[matchCount]
+ const isActive = globalMatch?.index === ctx.currentMatchIndex()
+
+ result.push({
+ text: text.slice(pos, pos + query.length),
+ highlight: true,
+ isActive,
+ })
+ lastIndex = pos + query.length
+ pos = lastIndex
+ matchCount++
+ }
+
+ if (lastIndex < text.length) {
+ result.push({ text: text.slice(lastIndex), highlight: false, isActive: false })
+ }
+
+ return result
+ })
+
+ return (
+
+
+ {(segment) => (
+ {segment.text}>}>
+ keeping as primary to match markdown
+ fg: theme.background,
+ // bold: segment.isActive, // disabled because we cant get working for markdown matches
+ }}
+ >
+ {segment.text}
+
+
+ )}
+
+
+ )
+}
+
function UserMessage(props: {
message: UserMessage
parts: Part[]
@@ -982,7 +1244,14 @@ function UserMessage(props: {
backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
flexShrink={0}
>
- {text()?.text}
+ {text()?.text}}>
+
+
@@ -1114,8 +1383,9 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
return Math.round((cumulativeTokens() / props.contextLimit) * 100)
})
+ // We need to provide Assistant messages with ID to be able to scrolled to when searched
return (
- <>
+
{(part, index) => {
const component = createMemo(() => PART_MAPPING[part.type as keyof typeof PART_MAPPING])
@@ -1175,7 +1445,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
- >
+
)
}
@@ -1214,21 +1484,122 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
)
}
+function MarkdownSearchHighlighter(props: {
+ text: string
+ query: string
+ messageID: string
+ partID?: string
+ syntaxStyle: any
+ conceal: boolean
+}) {
+ const ctx = use()
+ const { theme } = useTheme()
+
+ // const content = createMemo(() => {
+ // if (!props.query) return props.text
+ // const escapedQuery = props.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+ // // Use ~~ for strikethrough to represent the highlighted system
+ // return props.text.replace(new RegExp(escapedQuery, "gi"), "~~$&~~")
+ // })
+
+ const content = createMemo(() => {
+ if (!props.query) return props.text
+ const escapedQuery = props.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+
+ // Regex to find:
+ // 1. Fenced code blocks (```...```)
+ // 2. Inline code blocks (`...`)
+ // 3. The query text (when outside code blocks)
+ const regex = new RegExp(`(\`{3,}[\\s\\S]*?\`{3,}|\`[^\`]*\`)|(${escapedQuery})`, "gi")
+
+ return props.text.replace(regex, (match, codeBlock, queryText) => {
+ if (codeBlock) {
+ // If the code block doesn't contain the query, leave it alone
+ if (!new RegExp(escapedQuery, "gi").test(codeBlock)) {
+ return match
+ }
+
+ // Determine delimiter (``` or `) based on the start of the match
+ const delimiterMatch = match.match(/^`+/)
+ const delimiter = delimiterMatch ? delimiterMatch[0] : "`"
+
+ // Extract the inner text (remove wrapping backticks)
+ const innerContent = match.slice(delimiter.length, -delimiter.length)
+
+ // Split content by query, using capturing group () to keep the matched text
+ const parts = innerContent.split(new RegExp(`(${escapedQuery})`, "gi"))
+
+ return parts
+ .map((part, index) => {
+ // Even indices: Content parts (code)
+ // Odd indices: Matched query (highlight)
+ if (index % 2 === 0) {
+ // If this code segment is empty (e.g. match at start/end), return nothing
+ // This prevents creating empty `` blocks
+ if (!part) return ""
+ return `${delimiter}${part}${delimiter}`
+ } else {
+ // This is the match -> Apply highlight style
+ return `~~${part}~~`
+ }
+ })
+ .join("")
+ }
+
+ // Match found outside of any code block
+ return `~~${match}~~`
+ })
+ })
+
+ return (
+
+ )
+}
+
function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) {
const ctx = use()
const { theme, syntax } = useTheme()
+
+ const hasMatch = createMemo(() => {
+ const query = ctx.searchQuery().toLowerCase()
+ if (!query) return false
+ return props.part.text.toLowerCase().includes(query)
+ })
+
return (
-
+
+ }
+ >
+
+
)
@@ -1381,26 +1752,46 @@ function ToolTitle(props: { fallback: string; when: any; icon: string; children:
)
}
+const BASH_DISPLAY_LINES = 20
+
ToolRegistry.register({
name: "bash",
container: "block",
render(props) {
- const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? ""))
+ const rawOutput = createMemo(() => props.metadata.output?.trim() ?? "")
+ const ctx = use()
const { theme } = useTheme()
+
+ const displayOutput = createMemo(() => {
+ const lines = rawOutput().split("\n")
+ if (lines.length <= BASH_DISPLAY_LINES) return rawOutput()
+ return lines.slice(0, BASH_DISPLAY_LINES).join("\n") + `\n... (${lines.length - BASH_DISPLAY_LINES} more lines)`
+ })
+
+ const truncated = createMemo(() => rawOutput().split("\n").length > BASH_DISPLAY_LINES)
+
return (
- <>
+
{props.input.description || "Shell"}
$ {props.input.command}
-
-
- {output()}
+
+ {/* rows here means that the ANSI is rendered via Ghostty as 2 lines per page. Then the result is returned as 20 lines */}
+
+
+
+ {
+ ctx.showBashOutput({ command: props.input.command!, output: rawOutput })
+ }}
+ >
+ Click to view full output
- >
+
)
},
})
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
index c63f5116ab25..51e8acf2fa68 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
@@ -269,9 +269,9 @@ export function Sidebar(props: { sessionID: string }) {
{directory()}
- • Open
+ • shuv
- Code
+ code
{" "}
{Installation.VERSION}
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 11ea8d1df064..c93816cef875 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -455,7 +455,7 @@ export namespace SessionPrompt {
outputEstimate: lastFinished?.outputEstimate,
reasoningEstimate: lastFinished?.reasoningEstimate,
contextEstimate: lastFinished?.contextEstimate,
- sentEstimate: (lastAssistant?.sentEstimate || 0) + (lastUser.sentEstimate || 0) + toolResultTokens,
+ sentEstimate: (lastAssistant?.sentEstimate || 0) + (lastUser.sentEstimate || 0),
})) as MessageV2.Assistant,
sessionID: sessionID,
model: model.info,
@@ -1103,7 +1103,7 @@ export namespace SessionPrompt {
)
const userText = parts
- .filter((p) => p.type === "text" && !(p as MessageV2.TextPart).synthetic)
+ .filter((p) => p.type === "text" && !p.ignored)
.map((p) => (p as MessageV2.TextPart).text)
.join("")
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index d8af0a77f46f..2fc0cfee137e 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -15,6 +15,7 @@ import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import path from "path"
import { iife } from "@/util/iife"
+import { ptyToText } from "opentui-ansi-vt"
const DEFAULT_MAX_OUTPUT_LENGTH = 30_000
const MAX_OUTPUT_LENGTH = (() => {
@@ -217,6 +218,20 @@ export const BashTool = Tool.define("bash", async () => {
shell,
cwd: Instance.directory,
env: {
+ // Force color output in as many tools as possible
+ FORCE_COLOR: "3",
+ CLICOLOR: "1",
+ CLICOLOR_FORCE: "1",
+ TERM: "xterm-256color",
+ TERM_PROGRAM: "bash-tool",
+ TERM_COLOR: "1",
+ NO_COLOR: "", // Unset to avoid accidental no-color
+ PY_COLORS: "1",
+ ANSICON: "1",
+ COLORTERM: "truecolor",
+ // Some tools respect these too; although most are covered above
+ NODE_DISABLE_COLORS: "",
+ // Inherit rest of env below
...process.env,
},
stdio: ["ignore", "pipe", "pipe"],
@@ -234,7 +249,11 @@ export const BashTool = Tool.define("bash", async () => {
})
const append = (chunk: Buffer) => {
+ if (output.length >= MAX_OUTPUT_LENGTH) return
output += chunk.toString()
+ if (output.length > MAX_OUTPUT_LENGTH) {
+ output = output.slice(0, MAX_OUTPUT_LENGTH)
+ }
ctx.metadata({
metadata: {
output,
@@ -336,7 +355,7 @@ export const BashTool = Tool.define("bash", async () => {
exit: proc.exitCode,
description: params.description,
},
- output,
+ output: ptyToText(output, { rows: 120, cols: 256 }),
}
},
}
diff --git a/packages/opencode/src/util/token.ts b/packages/opencode/src/util/token.ts
index 58b33855c975..fc47a98ae0a5 100644
--- a/packages/opencode/src/util/token.ts
+++ b/packages/opencode/src/util/token.ts
@@ -29,17 +29,21 @@ export namespace Token {
let tokens = 0
for (const part of parts) {
if (part.type === "tool") {
- // Tool input is sent in both completed and error states
- tokens += estimate(JSON.stringify(part.state.input))
+ // Add null check for part.state
+ if (!part.state) continue
+
+ // Safe access to input
+ if (part.state.input) {
+ tokens += estimate(JSON.stringify(part.state.input))
+ }
if (part.state.status === "completed") {
- // Tool result output - check if compacted
- const output = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
+ // Use optional chaining for compacted check
+ const output = part.state.time?.compacted ? "[Old tool result content cleared]" : (part.state.output ?? "")
tokens += estimate(output)
}
- if (part.state.status === "error") {
- // Tool error text is sent back to the API
+ if (part.state.status === "error" && part.state.error) {
tokens += estimate(part.state.error)
}
}