Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton 2f0eef5c2a fix(tui): stop disabled tab pulse rendering 2026-08-13 11:32:00 -04:00
Kit Langton c253d4d311 fix(tui): highlight queued prompts on hover (#42219) 2026-08-12 22:32:30 -04:00
5 changed files with 49 additions and 91 deletions
+16 -36
View File
@@ -2,7 +2,7 @@ export * as FileSystemSearch from "./search.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { Context, Duration, Effect, Layer, Schema, Stream } from "effect"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { Fff } from "#fff"
import fuzzysort from "fuzzysort"
import { FileSystem } from "../filesystem.js"
@@ -10,7 +10,6 @@ import { Location } from "../location.js"
import { Ripgrep } from "../ripgrep.js"
import { RelativePath } from "../schema.js"
import { Protected } from "./protected.js"
import { Watcher } from "./watcher.js"
export interface Interface {
readonly find: (input: FileSystem.FindInput) => Effect.Effect<FileSystem.Entry[]>
@@ -28,48 +27,33 @@ export const ripgrepLayer = Layer.effect(
Effect.gen(function* () {
const location = yield* Location.Service
const ripgrep = yield* Ripgrep.Service
const watcher = yield* Watcher.Service
const scope = yield* Scope.Scope
const files: string[] = []
const directories = new Set<string>()
const home = Protected.isHome(location.directory)
const scan = ripgrep
yield* ripgrep
.find({
cwd: location.directory,
pattern: "*",
limit: location.vcs && !home ? Number.MAX_SAFE_INTEGER : 100_000,
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
files.push(entry.path)
const parts = entry.path.split("/")
parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep))
}),
})
.pipe(
Effect.orDie,
Effect.map((entries) => {
const files = entries.map((entry) => entry.path)
return {
files,
directories: new Set(
files.flatMap((file) => {
const parts = file.split("/")
return parts.slice(0, -1).map((_, index) => parts.slice(0, index + 1).join("/") + path.sep)
}),
),
}
}),
)
const [snapshot, invalidate] = yield* Effect.cachedInvalidateWithTTL(scan, Duration.infinity)
const updates = yield* watcher.subscribe({ path: location.directory, type: "directory" })
yield* updates.pipe(
Stream.runForEach(() => invalidate),
Effect.forkScoped,
)
yield* Effect.yieldNow
yield* snapshot.pipe(Effect.forkScoped)
.pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope))
return Service.of({
find: (input) =>
Effect.gen(function* () {
const index = yield* snapshot
const items =
input.type === "file"
? index.files
? files
: input.type === "directory"
? Array.from(index.directories)
: [...index.files, ...index.directories]
? Array.from(directories)
: [...files, ...directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
@@ -163,11 +147,7 @@ export const layer = (options?: Options) =>
)
export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Location.node, Ripgrep.node, Watcher.node],
})
return makeLocationNode({ service: Service, layer: layer(options), deps: [Location.node, Ripgrep.node] })
}
export const node = configured()
+4 -53
View File
@@ -1,12 +1,11 @@
import { describe, expect, test } from "bun:test"
import os from "os"
import path from "path"
import { Effect, Layer, PubSub, Stream } from "effect"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Protected } from "@opencode-ai/core/filesystem/protected"
import { FileSystemSearch } from "@opencode-ai/core/filesystem/search"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Location } from "@opencode-ai/core/location"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
@@ -34,14 +33,15 @@ describe("FileSystemSearch", () => {
find: (input) =>
Effect.gen(function* () {
observed = input
return [FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })]
if (input.onEntry)
yield* input.onEntry(FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" }))
return []
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[Watcher.node, Watcher.testLayer],
])
await Effect.runPromise(
@@ -56,53 +56,4 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
test("refreshes the ripgrep index after files change", async () => {
const root = AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-refresh"))
let scans = 0
const updates = Effect.runSync(PubSub.unbounded<Watcher.Update>())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: root })))],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: () =>
Effect.sync(() => {
scans++
return [
FileSystem.Entry.make({ path: RelativePath.make("src/old.ts"), type: "file" }),
...(scans > 1
? [FileSystem.Entry.make({ path: RelativePath.make("src/new.ts"), type: "file" })]
: []),
]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
[
Watcher.node,
Layer.succeed(
Watcher.Service,
Watcher.Service.of({ subscribe: () => Effect.succeed(Stream.fromPubSub(updates)) }),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
expect((yield* search.find({ query: "new", type: "file" })).length).toBe(0)
yield* PubSub.publish(
updates,
{ type: "create", path: path.join(root, "src/new.ts") } satisfies Watcher.Update,
)
yield* Effect.sleep("100 millis")
expect((yield* search.find({ query: "new", type: "file" }))[0]?.path).toBe(RelativePath.make("src/new.ts"))
}).pipe(Effect.provide(layer), Effect.scoped),
)
})
})
+1 -1
View File
@@ -190,7 +190,7 @@ class PulseState {
}
get live() {
return this.active || this.breathing || this.envelopes.some(envelopeActive)
return this.enabled && (this.active || this.breathing || this.envelopes.some(envelopeActive))
}
get running() {
+4 -1
View File
@@ -2089,6 +2089,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOpen: () => void }) {
const theme = useTheme("elevated")
const [hover, setHover] = createSignal(false)
const next = createMemo(() => props.prompts[0]?.text.replaceAll("\n", " "))
return (
@@ -2096,6 +2097,8 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
border={["left"]}
borderColor={theme.border.default}
customBorderChars={SplitBorder.customBorderChars}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={props.onOpen}
>
<box
@@ -2104,7 +2107,7 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme.background.default}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexDirection="row"
>
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1} minWidth={0}>
@@ -1,6 +1,10 @@
import { expect, test } from "bun:test"
/** @jsxImportSource @opentui/solid */
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { createSignal } from "solid-js"
import {
TabPulse,
blendTabPulseColor,
completionPulseOpacity,
glowIgnitionLevel,
@@ -9,6 +13,26 @@ import {
} from "../../src/component/tab-pulse"
import { tint } from "../../src/theme/color"
test("a disabled pulse stays idle when it becomes active", async () => {
const background = RGBA.fromHex("#101010")
const [active, setActive] = createSignal(false)
const app = await testRender(
() => <TabPulse enabled={false} active={active()} color={background} backgroundColor={background} />,
{ width: 8, height: 1 },
)
try {
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
setActive(true)
await app.renderOnce()
expect(app.renderer.root.liveCount).toBe(0)
} finally {
app.renderer.destroy()
}
})
test("completion pulse rises quickly and fades over the remaining duration", () => {
expect(completionPulseOpacity(0)).toBe(0)
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)