Merge branch 'dev' into sqlite2

This commit is contained in:
Dax
2026-02-01 23:58:01 -05:00
committed by GitHub
20 changed files with 168 additions and 152 deletions
+2 -2
View File
@@ -85,8 +85,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75",
"@opentui/core": "0.1.76",
"@opentui/solid": "0.1.76",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -186,6 +186,7 @@ function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
Clipboard.setRenderer(renderer)
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
@@ -10,7 +10,7 @@ import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { createDebouncedSignal } from "../util/signal"
import "opentui-spinner/solid"
import { Spinner } from "./spinner"
export function DialogSessionList() {
const dialog = useDialog()
@@ -32,8 +32,6 @@ export function DialogSessionList() {
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
const sessions = createMemo(() => searchResults() ?? sync.data.session)
const options = createMemo(() => {
@@ -56,11 +54,7 @@ export function DialogSessionList() {
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : undefined,
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
@@ -0,0 +1,24 @@
import { Show } from "solid-js"
import { useTheme } from "../context/theme"
import { useKV } from "../context/kv"
import type { JSX } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import "opentui-spinner/solid"
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
export function Spinner(props: { children?: JSX.Element; color?: RGBA }) {
const { theme } = useTheme()
const kv = useKV()
const color = () => props.color ?? theme.textMuted
return (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={color()}> {props.children}</text>}>
<box flexDirection="row" gap={1}>
<spinner frames={frames} interval={80} color={color()} />
<Show when={props.children}>
<text fg={color()}>{props.children}</text>
</Show>
</box>
</Show>
)
}
@@ -16,6 +16,7 @@ import path from "path"
import { useRoute, useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import {
BoxRenderable,
@@ -1559,7 +1560,13 @@ function InlineTool(props: {
)
}
function BlockTool(props: { title: string; children: JSX.Element; onClick?: () => void; part?: ToolPart }) {
function BlockTool(props: {
title: string
children: JSX.Element
onClick?: () => void
part?: ToolPart
spinner?: boolean
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
@@ -1582,9 +1589,16 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
props.onClick?.()
}}
>
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
</text>
<Show
when={props.spinner}
fallback={
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
</text>
}
>
<Spinner color={theme.textMuted}>{props.title.replace(/^# /, "")}</Spinner>
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.error}>{error()}</text>
@@ -1799,9 +1813,21 @@ function Task(props: ToolProps<typeof TaskTool>) {
const keybind = useKeybind()
const { navigate } = useRoute()
const local = useLocal()
const sync = useSync()
const current = createMemo(() => props.metadata.summary?.findLast((x) => x.state.status !== "pending"))
const color = createMemo(() => local.agent.color(props.input.subagent_type ?? "unknown"))
const tools = createMemo(() => {
const sessionID = props.metadata.sessionId
const msgs = sync.data.message[sessionID ?? ""] ?? []
return msgs.flatMap((msg) =>
(sync.data.part[msg.id] ?? [])
.filter((part): part is ToolPart => part.type === "tool")
.map((part) => ({ tool: part.tool, state: part.state })),
)
})
const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
const isRunning = createMemo(() => props.part.state.status === "running")
return (
<Switch>
@@ -1814,16 +1840,21 @@ function Task(props: ToolProps<typeof TaskTool>) {
: undefined
}
part={props.part}
spinner={isRunning()}
>
<box>
<text style={{ fg: theme.textMuted }}>
{props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
{props.input.description} ({tools().length} toolcalls)
</text>
<Show when={current()}>
<text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(current()!.tool)}{" "}
{current()!.state.status === "completed" ? current()!.state.title : ""}
</text>
{(item) => {
const title = item().state.status === "completed" ? (item().state as any).title : ""
return (
<text style={{ fg: item().state.status === "error" ? theme.error : theme.textMuted }}>
{Locale.titlecase(item().tool)} {title}
</text>
)
}}
</Show>
</box>
<Show when={props.metadata.sessionId}>
@@ -1,24 +1,12 @@
import { $ } from "bun"
import type { CliRenderer } from "@opentui/core"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
// tmux and screen require DCS passthrough wrapping
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
const rendererRef = { current: undefined as CliRenderer | undefined }
export namespace Clipboard {
export interface Content {
@@ -26,6 +14,10 @@ export namespace Clipboard {
mime: string
}
export function setRenderer(renderer: CliRenderer | undefined): void {
rendererRef.current = renderer
}
export async function read(): Promise<Content | undefined> {
const os = platform()
@@ -154,7 +146,11 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
writeOsc52(text)
const renderer = rendererRef.current
if (renderer) {
const copied = renderer.copyToClipboardOSC52(text)
if (copied) return
}
await getCopyMethod()(text)
}
}
+33 -77
View File
@@ -275,100 +275,56 @@ export namespace Ripgrep {
log.info("tree", input)
const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd, signal: input.signal }))
interface Node {
path: string[]
children: Node[]
name: string
children: Map<string, Node>
}
function getPath(node: Node, parts: string[], create: boolean) {
if (parts.length === 0) return node
let current = node
for (const part of parts) {
let existing = current.children.find((x) => x.path.at(-1) === part)
if (!existing) {
if (!create) return
existing = {
path: current.path.concat(part),
children: [],
}
current.children.push(existing)
}
current = existing
}
return current
function dir(node: Node, name: string) {
const existing = node.children.get(name)
if (existing) return existing
const next = { name, children: new Map() }
node.children.set(name, next)
return next
}
const root: Node = {
path: [],
children: [],
}
const root: Node = { name: "", children: new Map() }
for (const file of files) {
if (file.includes(".opencode")) continue
const parts = file.split(path.sep)
getPath(root, parts, true)
}
function sort(node: Node) {
node.children.sort((a, b) => {
if (!a.children.length && b.children.length) return 1
if (!b.children.length && a.children.length) return -1
return a.path.at(-1)!.localeCompare(b.path.at(-1)!)
})
for (const child of node.children) {
sort(child)
if (parts.length < 2) continue
let node = root
for (const part of parts.slice(0, -1)) {
node = dir(node, part)
}
}
sort(root)
let current = [root]
const result: Node = {
path: [],
children: [],
}
let processed = 0
const limit = input.limit ?? 50
while (current.length > 0) {
const next = []
for (const node of current) {
if (node.children.length) next.push(...node.children)
function count(node: Node): number {
let total = 0
for (const child of node.children.values()) {
total += 1 + count(child)
}
const max = Math.max(...current.map((x) => x.children.length))
for (let i = 0; i < max && processed < limit; i++) {
for (const node of current) {
const child = node.children[i]
if (!child) continue
getPath(result, child.path, true)
processed++
if (processed >= limit) break
}
}
if (processed >= limit) {
for (const node of [...current, ...next]) {
const compare = getPath(result, node.path, false)
if (!compare) continue
if (compare?.children.length !== node.children.length) {
const diff = node.children.length - compare.children.length
compare.children.push({
path: compare.path.concat(`[${diff} truncated]`),
children: [],
})
}
}
break
}
current = next
return total
}
const total = count(root)
const limit = input.limit ?? total
const lines: string[] = []
const queue: { node: Node; path: string }[] = []
for (const child of Array.from(root.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
queue.push({ node: child, path: child.name })
}
function render(node: Node, depth: number) {
const indent = "\t".repeat(depth)
lines.push(indent + node.path.at(-1) + (node.children.length ? "/" : ""))
for (const child of node.children) {
render(child, depth + 1)
let used = 0
for (let i = 0; i < queue.length && used < limit; i++) {
const { node, path } = queue[i]
lines.push(path)
used++
for (const child of Array.from(node.children.values()).sort((a, b) => a.name.localeCompare(b.name))) {
queue.push({ node: child, path: `${path}/${child.name}` })
}
}
result.children.map((x) => render(x, 0))
if (total > used) lines.push(`[${total - used} truncated]`)
return lines.join("\n")
}
@@ -379,6 +379,7 @@ export namespace SessionProcessor {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
SessionStatus.set(input.sessionID, { type: "idle" })
}
if (snapshot) {
const patch = await Snapshot.patch(snapshot)
+7 -4
View File
@@ -62,7 +62,7 @@ export namespace SessionPrompt {
abort: AbortController
callbacks: {
resolve(input: MessageV2.WithParts): void
reject(): void
reject(reason?: any): void
}[]
}
> = {}
@@ -72,7 +72,7 @@ export namespace SessionPrompt {
for (const item of Object.values(current)) {
item.abort.abort()
for (const callback of item.callbacks) {
callback.reject()
callback.reject(new DOMException("Aborted", "AbortError"))
}
}
},
@@ -249,10 +249,13 @@ export namespace SessionPrompt {
log.info("cancel", { sessionID })
const s = state()
const match = s[sessionID]
if (!match) return
if (!match) {
SessionStatus.set(sessionID, { type: "idle" })
return
}
match.abort.abort()
for (const item of match.callbacks) {
item.reject()
item.reject(new DOMException("Aborted", "AbortError"))
}
delete s[sessionID]
SessionStatus.set(sessionID, { type: "idle" })
+3 -3
View File
@@ -36,16 +36,16 @@ export namespace SystemPrompt {
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
`</env>`,
`<files>`,
`<directories>`,
` ${
project.vcs === "git" && false
? await Ripgrep.tree({
cwd: Instance.directory,
limit: 200,
limit: 50,
})
: ""
}`,
`</files>`,
`</directories>`,
].join("\n"),
]
}
-1
View File
@@ -130,7 +130,6 @@ export const TaskTool = Tool.define("task", async (ctx) => {
ctx.metadata({
title: params.description,
metadata: {
summary: Object.values(parts).sort((a, b) => a.id.localeCompare(b.id)),
sessionId: session.id,
model,
},
@@ -292,7 +292,7 @@ test("unicode filenames", async () => {
})
})
test("unicode filenames modification and restore", async () => {
test.skip("unicode filenames modification and restore", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
+2 -8
View File
@@ -9,14 +9,8 @@
"build": "tsc"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./tool": {
"types": "./dist/tool.d.ts",
"import": "./dist/tool.js"
}
".": "./src/index.ts",
"./tool": "./src/tool.ts"
},
"files": [
"dist"