Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
17b8164
Update Nix flake.lock and hashes
actions-user Nov 26, 2025
71e4bdf
feat(tui): implement search functionality in session view
OpeOginni Nov 26, 2025
d887d11
chore: format code
actions-user Nov 26, 2025
9b55c81
feat(tui): add bash output viewer with ANSI color support
remorses Nov 27, 2025
32716f5
limit to MAX_OUTPUT_LENGTH
remorses Nov 27, 2025
7b75a33
remove ANSI from model output with ptyToText
remorses Nov 27, 2025
ec52beb
use 120 rows for terminal output
remorses Nov 27, 2025
0bb9de4
Update Nix flake.lock and hashes
actions-user Nov 27, 2025
f3d9ab6
chore: update opentui-ansi-vt to 1.2.7
remorses Nov 27, 2025
0b773bd
Update Nix flake.lock and hashes
actions-user Nov 27, 2025
4d03507
feat(tui): add page up/down support to bash output viewer
remorses Nov 27, 2025
f48fbeb
Merge branch 'dev' into bash-outout-scrollbox-on-click
remorses Nov 28, 2025
aa3661a
Update Nix flake.lock and hashes
actions-user Nov 28, 2025
50e9f05
Merge upstream/dev, resolve conflicts
remorses Nov 29, 2025
e3c2d1a
Update Nix flake.lock and hashes
actions-user Nov 29, 2025
fb9043a
fix(tui): improve match navigation and search highlighting
OpeOginni Nov 29, 2025
c18a37b
chore: format code
actions-user Nov 29, 2025
069de52
Merge branch 'dev' into feat/search
OpeOginni Nov 29, 2025
eca6cb6
Update Nix flake.lock and hashes
actions-user Nov 29, 2025
c104413
chore(tui-search): Removed active match differenciation, since we can…
OpeOginni Nov 29, 2025
ea0def3
feat: implement double Ctrl+C to exit functionality with warning toast
AmineGuitouni Nov 29, 2025
ecffcee
feat(tui): add search in messages (PR #4898)
shuv1337 Nov 30, 2025
b687a6a
feat(tui): add bash output viewer with ANSI support (PR #4791)
shuv1337 Nov 30, 2025
9a8b8b0
feat(tui): implement double Ctrl+C to exit with warning toast (PR #4900)
shuv1337 Nov 30, 2025
04edae1
fix: improve token counting for synthetic and noReply messages (PR #4…
shuv1337 Nov 30, 2025
3d0bb87
docs: update README for shuvcode fork
shuv1337 Nov 30, 2025
ef47d35
Merge PR integration batch: #4898, #4791, #4900, #4709 fixes
shuv1337 Nov 30, 2025
6e9b984
changing sidebar version display to shuvcode
shuv1337 Nov 30, 2025
fe79203
Update Nix flake.lock and hashes
actions-user Nov 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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_

---

<p align="center">
<a href="https://opencode.ai">
<picture>
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion nix/hashes.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-+PJZG5jNxBGkxblpnNa4lvfBi9YEvHaGQRE0+avNwHY="
"nodeModules": "sha256-AFi1XqNAoEF88GCG6OUWNMTHhQDCkI2m5qtwGyuUl38="
}
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
28 changes: 27 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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"]) {
Expand Down Expand Up @@ -361,6 +368,9 @@ export function Prompt(props: PromptProps) {
get focused() {
return input.focused
},
get text() {
return input.plainText
},
focus() {
input.focus()
},
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
230 changes: 230 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/search.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<box ref={(r) => (anchor = r)}>
<box
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingLeft={2}
paddingRight={1}
paddingTop={1}
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
>
<textarea
placeholder={props.placeholder}
textColor={theme.text}
focusedTextColor={theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
const text = input.plainText.trim()
setStore("input", text)
props.onInput?.(text)
}}
keyBindings={textareaKeybindings()}
onKeyDown={async (e) => {
if (props.disabled) {
e.preventDefault()
return
}

if (e.name === "down") {
e.preventDefault()
props.onNext?.()
return
}

if (e.name === "up") {
e.preventDefault()
props.onPrevious?.()
return
}

if (e.name === "escape" || (e.ctrl && e.name === "f")) {
props.onExit?.()
e.preventDefault()
return
}

if (keybind.match("app_exit", e)) {
await exit()
return
}
}}
onSubmit={submit}
ref={(r: TextareaRenderable) => (input = r)}
focusedBackgroundColor={theme.backgroundElement}
cursorColor={highlight()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>Search</text>
<Show
when={props.matchInfo && props.matchInfo.total > 0}
fallback={<text fg={theme.textMuted}>{store.input ? "No matches" : "Go through session history"}</text>}
>
<text fg={theme.text}>
{props.matchInfo!.current + 1} of {props.matchInfo!.total}
</text>
</Show>
</box>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "╹",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.background.a != 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box flexDirection="row" justifyContent="flex-end">
<box gap={2} flexDirection="row">
<text fg={theme.text}>
↑/↓ <span style={{ fg: theme.textMuted }}>navigate</span>
</text>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit</span>
</text>
</box>
</box>
</box>
</>
)
}
Loading