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)}> + + +