Skip to content

Commit ef47d35

Browse files
committed
Merge PR integration batch: sst#4898, sst#4791, sst#4900, sst#4709 fixes
Merged upstream PRs: - sst#4898: Search in messages (Ctrl+F) - sst#4791: Bash output viewer with ANSI color support - sst#4900: Double Ctrl+C to exit - sst#4709: Token counting fixes for synthetic/noReply messages Updated README for shuvcode fork project.
2 parents 832fa1f + 3d0bb87 commit ef47d35

File tree

12 files changed

+859
-159
lines changed

12 files changed

+859
-159
lines changed

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
# shuvcode
2+
3+
> **A fork of [sst/opencode](https://github.com/sst/opencode)** - The AI coding agent built for the terminal.
4+
5+
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.
6+
7+
---
8+
9+
## Merged PRs (Pending Upstream)
10+
11+
The following PRs have been merged into this fork and are awaiting merge into upstream:
12+
13+
| PR | Title | Status | Description |
14+
| -------------------------------------------------- | --------------------------------- | ------ | ------------------------------------------------------------------ |
15+
| [#4898](https://github.com/sst/opencode/pull/4898) | Search in messages | Open | Ctrl+F to search through session messages with highlighting |
16+
| [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | Open | Full terminal emulation for bash output with color support |
17+
| [#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 |
18+
| [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | Open | Real-time token tracking and display during model responses |
19+
| [#4773](https://github.com/sst/opencode/pull/4773) | Configurable subagent visibility | Open | Allow agents to restrict which subagents they can invoke |
20+
21+
_Last updated: 2025-11-29_
22+
23+
---
24+
125
<p align="center">
226
<a href="https://opencode.ai">
327
<picture>

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@
256256
"jsonc-parser": "3.3.1",
257257
"minimatch": "10.0.3",
258258
"open": "10.1.2",
259+
"opentui-ansi-vt": "1.2.7",
259260
"opentui-spinner": "0.0.6",
260261
"partial-json": "0.1.7",
261262
"remeda": "catalog:",
@@ -2969,6 +2970,8 @@
29692970

29702971
"openid-client": ["[email protected]", "", { "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=="],
29712972

2973+
"opentui-ansi-vt": ["[email protected]", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-mcumATXHkagt7JUK+5mpBPaUW0LHrwysp8JQqDBnZG22vb1TTh/HmGjWFXBWe4oo4Rshw7s7NWIbt0/jCWOgcw=="],
2974+
29722975
"opentui-spinner": ["[email protected]", "", { "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=="],
29732976

29742977
"own-keys": ["[email protected]", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],

flake.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

nix/hashes.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"nodeModules": "sha256-+PJZG5jNxBGkxblpnNa4lvfBi9YEvHaGQRE0+avNwHY="
2+
"nodeModules": "sha256-49BR28UJHRoBHvNpaqVnjB/asDkcLfZI0uL3w0aQ05o="
33
}

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@
8282
"jsonc-parser": "3.3.1",
8383
"minimatch": "10.0.3",
8484
"open": "10.1.2",
85+
"opentui-ansi-vt": "1.2.7",
8586
"opentui-spinner": "0.0.6",
8687
"partial-json": "0.1.7",
8788
"remeda": "catalog:",

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useRenderer } from "@opentui/solid"
1717
import { Editor } from "@tui/util/editor"
1818
import { useExit } from "../../context/exit"
1919
import { Clipboard } from "../../util/clipboard"
20+
import { useToast } from "../../ui/toast"
2021
import type { FilePart } from "@opencode-ai/sdk"
2122
import { TuiEvent } from "../../event"
2223
import { iife } from "@/util/iife"
@@ -30,10 +31,12 @@ export type PromptProps = {
3031
ref?: (ref: PromptRef) => void
3132
hint?: JSX.Element
3233
showPlaceholder?: boolean
34+
initialValue?: string
3335
}
3436

3537
export type PromptRef = {
3638
focused: boolean
39+
text: string
3740
set(prompt: PromptInfo): void
3841
reset(): void
3942
blur(): void
@@ -277,6 +280,10 @@ export function Prompt(props: PromptProps) {
277280

278281
onMount(() => {
279282
promptPartTypeId = input.extmarks.registerType("prompt-part")
283+
if (props.initialValue) {
284+
input.setText(props.initialValue)
285+
setStore("prompt", "input", props.initialValue)
286+
}
280287
})
281288

282289
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
@@ -361,6 +368,9 @@ export function Prompt(props: PromptProps) {
361368
get focused() {
362369
return input.focused
363370
},
371+
get text() {
372+
return input.plainText
373+
},
364374
focus() {
365375
input.focus()
366376
},
@@ -496,6 +506,22 @@ export function Prompt(props: PromptProps) {
496506
input.clear()
497507
}
498508
const exit = useExit()
509+
const toast = useToast()
510+
let lastExitAttempt = 0
511+
512+
async function tryExit() {
513+
const now = Date.now()
514+
if (now - lastExitAttempt < 2000) {
515+
await exit()
516+
return
517+
}
518+
lastExitAttempt = now
519+
toast.show({
520+
variant: "warning",
521+
message: "Press again to exit",
522+
duration: 2000,
523+
})
524+
}
499525

500526
function pasteText(text: string, virtualText: string) {
501527
const currentOffset = input.visualCursor.offset
@@ -680,7 +706,7 @@ export function Prompt(props: PromptProps) {
680706
return
681707
}
682708
if (keybind.match("app_exit", e)) {
683-
await exit()
709+
await tryExit()
684710
return
685711
}
686712
if (e.name === "!" && input.visualCursor.offset === 0) {
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { BoxRenderable, TextareaRenderable, type KeyBinding } from "@opentui/core"
2+
import { createEffect, createMemo, createSignal, type JSX, onMount, Show } from "solid-js"
3+
import { useTheme } from "@tui/context/theme"
4+
import { EmptyBorder } from "@tui/component/border"
5+
import { createStore } from "solid-js/store"
6+
import { useKeybind } from "@tui/context/keybind"
7+
import { Locale } from "@/util/locale"
8+
import { useLocal } from "@tui/context/local"
9+
import { RGBA } from "@opentui/core"
10+
import { useSDK } from "@tui/context/sdk"
11+
import { useSync } from "@tui/context/sync"
12+
import { useExit } from "../../context/exit"
13+
14+
export type SearchInputProps = {
15+
disabled?: boolean
16+
onSubmit?: (query: string) => void
17+
onExit?: () => void
18+
onInput?: (query: string) => void
19+
onNext?: () => void
20+
onPrevious?: () => void
21+
matchInfo?: { current: number; total: number }
22+
sessionID?: string
23+
ref?: (ref: SearchInputRef) => void
24+
placeholder?: string
25+
}
26+
27+
export type SearchInputRef = {
28+
focused: boolean
29+
reset(): void
30+
blur(): void
31+
focus(): void
32+
getValue(): string
33+
}
34+
35+
export function SearchInput(props: SearchInputProps) {
36+
let input: TextareaRenderable
37+
let anchor: BoxRenderable
38+
39+
const exit = useExit()
40+
const keybind = useKeybind()
41+
const local = useLocal()
42+
const sdk = useSDK()
43+
const sync = useSync()
44+
const { theme } = useTheme()
45+
46+
const highlight = createMemo(() => {
47+
const agent = local.agent.current()
48+
if (agent?.color) return RGBA.fromHex(agent.color)
49+
const agents = local.agent.list()
50+
const index = agents.findIndex((x) => x.name === "search")
51+
const colors = [theme.secondary, theme.accent, theme.success, theme.warning, theme.primary, theme.error]
52+
if (index === -1) return colors[0]
53+
return colors[index % colors.length]
54+
})
55+
56+
const textareaKeybindings = createMemo(() => {
57+
const submitBindings = keybind.all.input_submit || []
58+
return [
59+
{ name: "return", action: "submit" },
60+
...submitBindings.map((binding) => ({
61+
name: binding.name,
62+
ctrl: binding.ctrl || undefined,
63+
meta: binding.meta || undefined,
64+
shift: binding.shift || undefined,
65+
action: "submit" as const,
66+
})),
67+
] satisfies KeyBinding[]
68+
})
69+
70+
const [store, setStore] = createStore<{
71+
input: string
72+
}>({
73+
input: "",
74+
})
75+
76+
createEffect(() => {
77+
if (props.disabled) input.cursorColor = theme.backgroundElement
78+
if (!props.disabled) input.cursorColor = theme.primary
79+
})
80+
81+
props.ref?.({
82+
get focused() {
83+
return input.focused
84+
},
85+
focus() {
86+
input.focus()
87+
},
88+
blur() {
89+
input.blur()
90+
},
91+
reset() {
92+
input.clear()
93+
setStore("input", "")
94+
},
95+
getValue() {
96+
return store.input
97+
},
98+
})
99+
100+
function submit() {
101+
if (props.disabled) return
102+
if (!store.input) return
103+
props.onSubmit?.(store.input)
104+
input.clear()
105+
setStore("input", "")
106+
}
107+
108+
onMount(() => {
109+
input.focus()
110+
})
111+
112+
return (
113+
<>
114+
<box ref={(r) => (anchor = r)}>
115+
<box
116+
border={["left"]}
117+
borderColor={highlight()}
118+
customBorderChars={{
119+
...EmptyBorder,
120+
vertical: "┃",
121+
bottomLeft: "╹",
122+
}}
123+
>
124+
<box
125+
paddingLeft={2}
126+
paddingRight={1}
127+
paddingTop={1}
128+
flexShrink={0}
129+
backgroundColor={theme.backgroundElement}
130+
flexGrow={1}
131+
>
132+
<textarea
133+
placeholder={props.placeholder}
134+
textColor={theme.text}
135+
focusedTextColor={theme.text}
136+
minHeight={1}
137+
maxHeight={6}
138+
onContentChange={() => {
139+
const text = input.plainText.trim()
140+
setStore("input", text)
141+
props.onInput?.(text)
142+
}}
143+
keyBindings={textareaKeybindings()}
144+
onKeyDown={async (e) => {
145+
if (props.disabled) {
146+
e.preventDefault()
147+
return
148+
}
149+
150+
if (e.name === "down") {
151+
e.preventDefault()
152+
props.onNext?.()
153+
return
154+
}
155+
156+
if (e.name === "up") {
157+
e.preventDefault()
158+
props.onPrevious?.()
159+
return
160+
}
161+
162+
if (e.name === "escape" || (e.ctrl && e.name === "f")) {
163+
props.onExit?.()
164+
e.preventDefault()
165+
return
166+
}
167+
168+
if (keybind.match("app_exit", e)) {
169+
await exit()
170+
return
171+
}
172+
}}
173+
onSubmit={submit}
174+
ref={(r: TextareaRenderable) => (input = r)}
175+
focusedBackgroundColor={theme.backgroundElement}
176+
cursorColor={highlight()}
177+
/>
178+
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
179+
<text fg={highlight()}>Search</text>
180+
<Show
181+
when={props.matchInfo && props.matchInfo.total > 0}
182+
fallback={<text fg={theme.textMuted}>{store.input ? "No matches" : "Go through session history"}</text>}
183+
>
184+
<text fg={theme.text}>
185+
{props.matchInfo!.current + 1} of {props.matchInfo!.total}
186+
</text>
187+
</Show>
188+
</box>
189+
</box>
190+
</box>
191+
<box
192+
height={1}
193+
border={["left"]}
194+
borderColor={highlight()}
195+
customBorderChars={{
196+
...EmptyBorder,
197+
vertical: "╹",
198+
}}
199+
>
200+
<box
201+
height={1}
202+
border={["bottom"]}
203+
borderColor={theme.backgroundElement}
204+
customBorderChars={
205+
theme.background.a != 0
206+
? {
207+
...EmptyBorder,
208+
horizontal: "▀",
209+
}
210+
: {
211+
...EmptyBorder,
212+
horizontal: " ",
213+
}
214+
}
215+
/>
216+
</box>
217+
<box flexDirection="row" justifyContent="flex-end">
218+
<box gap={2} flexDirection="row">
219+
<text fg={theme.text}>
220+
↑/↓ <span style={{ fg: theme.textMuted }}>navigate</span>
221+
</text>
222+
<text fg={theme.text}>
223+
esc <span style={{ fg: theme.textMuted }}>exit</span>
224+
</text>
225+
</box>
226+
</box>
227+
</box>
228+
</>
229+
)
230+
}

0 commit comments

Comments
 (0)