Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 8dcb25b295 docs: fix pnpm v2 install command 2026-08-08 20:47:31 +00:00
14 changed files with 37 additions and 150 deletions
+4 -10
View File
@@ -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 = { current: yield* Deferred.make<void>() }
const ready = yield* Deferred.make<void>()
let observed = 0
// Configured local plugin files can live outside config roots, where the
@@ -291,13 +291,7 @@ const layer = Layer.effect(
bus.subscribe([Event.Updated, SdkPlugins.Updated]),
).pipe(
// Make accepted work visible to flush before coalescing the burst.
Stream.mapEffect(() =>
Effect.gen(function* () {
observed++
if (yield* Deferred.isDone(ready.current)) ready.current = yield* Deferred.make<void>()
return observed
}),
),
Stream.mapEffect(() => Effect.sync(() => ++observed)),
)
yield* Stream.concat(Stream.succeed(0), updates).pipe(
// Keep observing updates while activation runs, retaining only the latest generation request.
@@ -306,12 +300,12 @@ const layer = Layer.effect(
Stream.runForEach((target) =>
Effect.gen(function* () {
yield* activate()
if (observed === target) yield* Deferred.succeed(ready.current, undefined)
if (observed === target) yield* Deferred.succeed(ready, undefined)
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload plugins", { cause }))),
),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({ flush: Effect.suspend(() => Deferred.await(ready.current)) })
return Service.of({ flush: Deferred.await(ready) })
}),
)
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -305,13 +305,11 @@ describe("LocationServiceMap", () => {
)
yield* Deferred.await(started)
const flushFiber = yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(
Effect.provide(context),
Effect.forkChild({ startImmediately: true }),
Effect.timeout("1 second"),
)
expect(flushFiber.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(flushFiber)
yield* Deferred.await(completed)
}),
),
+1 -2
View File
@@ -118,7 +118,6 @@ const sessionTabBindingCommands = [
"session.tab.select.7",
"session.tab.select.8",
"session.tab.select.9",
"session.tab.select.10",
] as const
const pinnedSessionBindingCommands = [
@@ -715,7 +714,7 @@ function App(props: { pair?: DialogPairCredentials }) {
enabled: sessionTabs.enabled,
run: () => sessionTabs.reopen(),
},
...Array.from({ length: 10 }, (_, i) => ({
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to tab ${i + 1}`,
category: "Session",
+8 -24
View File
@@ -10,8 +10,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
@@ -142,22 +140,17 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const value = session()
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => 2
const numberWidth = () => String(index() + 1).length + 1
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()))
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return "Start a new session"
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
const projectLabel = projectName(project(), value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(() => stringWidth(detail()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -190,11 +183,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) => {
if (!detailFades() || index < visibleDetailParts().length - FADE_WIDTH) return detailColor()
const position = index - (visibleDetailParts().length - FADE_WIDTH)
return tint(detailColor(), pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -323,7 +311,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
>
{sessionTabShortcutLabel(index())}
{index() + 1}
</text>
<text
width={titleWidth()}
@@ -371,11 +359,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
@@ -571,8 +555,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)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
// The number cell keeps one trailing space, even for double-digit tabs.
const numberWidth = () => String(tabNumber()).length + 1
// 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))
@@ -655,7 +639,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{sessionTabShortcutLabel(tabNumber() - 1)}
{tabNumber()}
</text>
<text
width={availableTitleWidth()}
-2
View File
@@ -126,7 +126,6 @@ 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"),
@@ -330,7 +329,6 @@ 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,22 +7,6 @@ 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 function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
if (!current || current === defaultBranch) return undefined
return current
}
export function sessionTabDetail(project: string, current: string | undefined, defaultBranch: string | undefined) {
const branch = sessionTabBranch(current, defaultBranch)
return branch && project ? `${project}:${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+4 -15
View File
@@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -171,19 +171,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (client.connection.status() !== "connected") return
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.split("\n")
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]),
)
await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location)))
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
+13 -5
View File
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { expect, mock, 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,6 +7,8 @@ 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) => {
@@ -30,7 +32,6 @@ 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({}))),
@@ -45,11 +46,14 @@ 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
@@ -106,7 +110,6 @@ 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({}))),
@@ -131,11 +134,14 @@ 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>()
@@ -180,7 +186,6 @@ 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({}))),
@@ -217,11 +222,14 @@ 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 } }
@@ -271,7 +279,6 @@ 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({}))),
@@ -292,5 +299,6 @@ test("session startup prompt is submitted exactly once", async () => {
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()
}
})
@@ -86,7 +86,6 @@ 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()
@@ -98,7 +97,7 @@ test.each([
const app = await testRender(
() => (
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
<ThemeProvider mode="dark" source={{ discover: () => discovery.promise }}>
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
<Probe />
</ThemeProvider>
</ConfigProvider>
@@ -106,7 +105,6 @@ test.each([
{ width: 20, height: 2 },
)
app.renderer.start()
discovery.resolve({ invalid: source })
try {
await wait(() => themes?.ready === true)
-1
View File
@@ -131,7 +131,6 @@ 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", () => {
@@ -11,42 +11,10 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabBranch,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("shows only non-default session branches", () => {
expect(sessionTabBranch("main", "main")).toBeUndefined()
expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar")
expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar")
expect(sessionTabBranch(undefined, "main")).toBeUndefined()
})
test("separates the project and branch with a colon", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main")).toBe("opencode:feature/sidebar")
expect(sessionTabDetail("opencode", "main", "main")).toBe("opencode")
})
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"])
@@ -27,14 +27,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -51,16 +44,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -70,7 +54,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -120,7 +104,6 @@ async function renderSessionTabs(
route,
data,
sessions,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -151,21 +134,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
+1 -1
View File
@@ -22,7 +22,7 @@ You can also install it with the following package managers.
<Tabs>
<Tab title="npm">```bash npm install -g @opencode-ai/cli@next ```</Tab>
<Tab title="bun">```bash bun install -g --trust @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next ```</Tab>
<Tab title="pnpm">```bash pnpm --allow-build=@opencode-ai/cli add -g @opencode-ai/cli@next ```</Tab>
<Tab title="Yarn">```bash yarn global add @opencode-ai/cli@next ```</Tab>
</Tabs>