mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 11:28:53 +00:00
Compare commits
7
Commits
pnpm-install-docs
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84fd347afa | ||
|
|
e8f215bfbc | ||
|
|
445af9ce70 | ||
|
|
bc51baa9a4 | ||
|
|
ff0a0b0786 | ||
|
|
4eff0ee2db | ||
|
|
cc0061f88b |
@@ -233,7 +233,7 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const ready = { current: yield* Deferred.make<void>() }
|
||||
let observed = 0
|
||||
|
||||
// Configured local plugin files can live outside config roots, where the
|
||||
@@ -291,7 +291,13 @@ const layer = Layer.effect(
|
||||
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
|
||||
).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
Stream.mapEffect(() => Effect.sync(() => ++observed)),
|
||||
Stream.mapEffect(() =>
|
||||
Effect.gen(function* () {
|
||||
observed++
|
||||
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
|
||||
return observed
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* Stream.concat(Stream.succeed(0), updates).pipe(
|
||||
// Keep observing updates while activation runs, retaining only the latest generation request.
|
||||
@@ -300,12 +306,12 @@ const layer = Layer.effect(
|
||||
Stream.runForEach((target) =>
|
||||
Effect.gen(function* () {
|
||||
yield* activate()
|
||||
if (observed === target) yield* Deferred.succeed(ready, undefined)
|
||||
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
return Service.of({ flush: Deferred.await(ready) })
|
||||
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -305,11 +305,13 @@ describe("LocationServiceMap", () => {
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
|
||||
Effect.provide(context),
|
||||
Effect.timeout("1 second"),
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
expect(flushFiber.pollUnsafe()).toBeUndefined()
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(flushFiber)
|
||||
yield* Deferred.await(completed)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1316,7 +1316,15 @@ export function write(
|
||||
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
|
||||
{ concurrency: 8, discard: true },
|
||||
)
|
||||
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
|
||||
// Format the manifest with the same prettier settings as the repo-wide
|
||||
// format pass, so `check:generated` stays clean after the generate bot
|
||||
// reformats the tree.
|
||||
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
|
||||
const manifestContent = yield* Effect.tryPromise({
|
||||
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
|
||||
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
|
||||
})
|
||||
yield* fs.writeFileString(manifest, manifestContent)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
|
||||
|
||||
expect(writes).toEqual([
|
||||
{ path: "/generated/session.ts", content: "export const session = {}\n" },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
|
||||
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provideService(
|
||||
|
||||
@@ -118,6 +118,7 @@ const sessionTabBindingCommands = [
|
||||
"session.tab.select.7",
|
||||
"session.tab.select.8",
|
||||
"session.tab.select.9",
|
||||
"session.tab.select.10",
|
||||
] as const
|
||||
|
||||
const pinnedSessionBindingCommands = [
|
||||
@@ -714,7 +715,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
enabled: sessionTabs.enabled,
|
||||
run: () => sessionTabs.reopen(),
|
||||
},
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
...Array.from({ length: 10 }, (_, i) => ({
|
||||
name: `session.tab.select.${i + 1}`,
|
||||
title: `Switch to tab ${i + 1}`,
|
||||
category: "Session",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
moveSessionTab,
|
||||
NEW_SESSION_TAB_TITLE,
|
||||
sessionTabComplete,
|
||||
sessionTabShortcutLabel,
|
||||
seedSessionTabMotion,
|
||||
sessionTabOverflowWidth,
|
||||
type SessionTab,
|
||||
@@ -140,7 +141,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => String(index() + 1).length + 1
|
||||
const numberWidth = () => 2
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
@@ -311,7 +312,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{index() + 1}
|
||||
{sessionTabShortcutLabel(index())}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
@@ -555,8 +556,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
|
||||
// The number cell keeps one trailing space, even for double-digit tabs.
|
||||
const numberWidth = () => String(tabNumber()).length + 1
|
||||
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
|
||||
const numberWidth = () => 2
|
||||
// Hovering reveals the close mark, so the title's right bound shifts left of it.
|
||||
const availableTitleWidth = () =>
|
||||
Math.max(1, width() - 1 - numberWidth() - (hovered() === tab.sessionID ? 2 : 0))
|
||||
@@ -639,7 +640,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tabNumber()}
|
||||
{sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
|
||||
@@ -126,6 +126,7 @@ export const Definitions = {
|
||||
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
|
||||
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
|
||||
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
|
||||
session_tab_select_10: keybind("<leader>0,ctrl+0", "Switch to tab 10"),
|
||||
|
||||
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||
@@ -329,6 +330,7 @@ export const CommandMap = {
|
||||
session_tab_select_7: "session.tab.select.7",
|
||||
session_tab_select_8: "session.tab.select.8",
|
||||
session_tab_select_9: "session.tab.select.9",
|
||||
session_tab_select_10: "session.tab.select.10",
|
||||
stash_delete: "stash.delete",
|
||||
model_provider_list: "model.dialog.provider",
|
||||
model_favorite_toggle: "model.dialog.favorite",
|
||||
|
||||
@@ -7,6 +7,12 @@ export type SessionTabUnread = "activity" | "error"
|
||||
|
||||
export const NEW_SESSION_TAB_TITLE = "New session"
|
||||
|
||||
export function sessionTabShortcutLabel(index: number) {
|
||||
if (index >= 0 && index < 9) return String(index + 1)
|
||||
if (index === 9) return "0"
|
||||
return "·"
|
||||
}
|
||||
|
||||
export type SessionTabHistory = {
|
||||
entries: readonly string[]
|
||||
index: number
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -7,8 +7,6 @@ import { createEventStream, createFetch, directory, json } from "./fixture/tui-c
|
||||
|
||||
test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
@@ -32,6 +30,7 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -46,14 +45,11 @@ test("SIGHUP clears title and disposes scoped resources once", async () => {
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
let initialTitle!: () => void
|
||||
const initialTitleSet = new Promise<void>((resolve) => {
|
||||
initialTitle = resolve
|
||||
@@ -110,6 +106,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -134,14 +131,11 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session title generated while an untitled session is loading remains visible", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const titles: string[] = []
|
||||
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
|
||||
const generatedTitle = Promise.withResolvers<void>()
|
||||
@@ -186,6 +180,7 @@ test("session title generated while an untitled session is loading remains visib
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -222,14 +217,11 @@ test("session title generated while an untitled session is loading remains visib
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
@@ -279,6 +271,7 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
@@ -299,6 +292,5 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -86,6 +86,7 @@ test.each([
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
let failure: ThemeError | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const discovery = Promise.withResolvers<Record<string, unknown>>()
|
||||
|
||||
function Probe() {
|
||||
const value = useThemes()
|
||||
@@ -97,7 +98,7 @@ test.each([
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => discovery.promise }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
@@ -105,6 +106,7 @@ test.each([
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
discovery.resolve({ invalid: source })
|
||||
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
|
||||
@@ -131,6 +131,7 @@ test("preserves pinned session bindings alongside tab bindings", () => {
|
||||
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
|
||||
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
|
||||
expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
|
||||
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
|
||||
})
|
||||
|
||||
test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
|
||||
|
||||
@@ -12,9 +12,27 @@ import {
|
||||
seedSessionTabMotion,
|
||||
sessionTabComplete,
|
||||
sessionTabOverflowWidth,
|
||||
sessionTabShortcutLabel,
|
||||
} from "../../src/context/session-tabs-model"
|
||||
|
||||
describe("session tabs", () => {
|
||||
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
|
||||
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"·",
|
||||
"·",
|
||||
])
|
||||
})
|
||||
|
||||
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
|
||||
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
|
||||
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])
|
||||
|
||||
Reference in New Issue
Block a user