Compare commits

..

12 Commits

Author SHA1 Message Date
Kit Langton 5e763bac34 Merge remote-tracking branch 'origin/v2' into tab-context-menu
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	packages/tui/src/component/session-tabs.tsx
2026-08-12 12:27:54 -04:00
Kit Langton bbc9307ec2 refactor(tui): simplify tab context menu 2026-08-12 12:25:42 -04:00
Kit Langton b24b1b3f16 fix(tui): keep exact-fit tab titles stationary (#42073) 2026-08-12 12:17:45 -04:00
Kit Langton f2bfdef450 fix(tui): match tab menu styling 2026-08-12 12:16:55 -04:00
Kit Langton 686214dd7e fix(tui): clarify tab menu selection 2026-08-12 12:13:34 -04:00
Kit Langton 930b0751b1 fix(core): generate session titles before model execution (#42067) 2026-08-12 12:08:24 -04:00
Matt Robinson f06a86eeac feat(client): support service version ranges (#42023)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-12 09:01:37 -07:00
Kit Langton 653b7d79cd fix(tui): restore navigation keybind defaults (#42066) 2026-08-12 11:57:39 -04:00
Kit Langton c74be8312e feat(tui): add session tab context menu 2026-08-12 11:53:52 -04:00
Kit Langton 70ce0d0970 feat(tui): jump between open menu sections (#42061) 2026-08-12 15:52:51 +00:00
opencode-agent[bot] 0777e84598 fix(tui): fill image message background (#42062)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-12 15:51:19 +00:00
Kit Langton bef795b2fe fix(tui): smooth session tab marquees (#42055) 2026-08-12 15:49:17 +00:00
22 changed files with 598 additions and 80 deletions
+4 -3
View File
@@ -10,6 +10,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
export * from "../service.js"
/** Contents of the local service registration file. */
@@ -37,14 +38,14 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
const info = yield* read(options.file)
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
if (found === undefined || found.legacy) return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return { endpoint: found.endpoint, state: found.state }
})
const discoverLocal = Effect.fnUntraced(function* (options: DiscoverOptions) {
const found = (yield* registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
})
@@ -93,7 +94,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
} else timeouts = undefined
if (service !== undefined) {
spawnDelay = timing.spawnDelay
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return Option.some(service)
if (compatible && service.state === "failed")
return yield* Effect.fail(new Error("Background service failed to start"))
+3 -2
View File
@@ -9,6 +9,7 @@ import {
spawnServiceContender,
} from "../service-contender.js"
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
import { matchesVersion } from "../service-version.js"
import type { ServiceHealth, ServiceStopResponse } from "./generated/types.js"
export * from "../service.js"
@@ -27,7 +28,7 @@ export async function discover(options: DiscoverOptions = {}) {
async function discoverLocal(options: DiscoverOptions) {
const found = (await registered(options.file)).service
if (found?.state !== "ready") return undefined
if (options.version !== undefined && found.version !== options.version) return undefined
if (!matchesVersion(found.version, options)) return undefined
return found
}
@@ -76,7 +77,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
if (registration.service !== undefined) {
spawnDelay = timing.spawnDelay
const service = registration.service
const compatible = !service.legacy && (options.version === undefined || service.version === options.version)
const compatible = !service.legacy && matchesVersion(service.version, options)
if (compatible && service.state === "ready") return service.endpoint
if (compatible && service.state === "failed") throw new Error("Background service failed to start")
if (!compatible) {
+8
View File
@@ -0,0 +1,8 @@
import type { DiscoverOptions } from "./service.js"
export function matchesVersion(version: string | undefined, options: DiscoverOptions) {
if (options.version === undefined) return true
if (version === undefined) return false
if (typeof options.version === "function") return options.version(version)
return version === options.version
}
+2 -2
View File
@@ -17,8 +17,8 @@ export type Endpoint = {
export type DiscoverOptions = {
/** Absolute registration file path. Defaults to the XDG state directory. */
readonly file?: string
/** Required service version. */
readonly version?: string
/** Required exact service version or compatibility predicate. */
readonly version?: string | ((version: string) => boolean)
}
/** Reason ensuring the service requires a new process. */
+15 -1
View File
@@ -1,6 +1,7 @@
import { Effect } from "effect"
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
import type { Session } from "@opencode-ai/schema/session"
import type { DiscoverOptions } from "../src/service"
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
@@ -8,6 +9,9 @@ type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
declare const effectClient: EffectClient
declare const promiseClient: PromiseClient
const exactVersion: DiscoverOptions = { version: "2.0.0" }
const compatibleVersion: DiscoverOptions = { version: (version) => version.startsWith("2.") }
const effectApi: EffectApi<unknown> = effectClient
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
@@ -42,4 +46,14 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
key: "review-notes",
})
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
void [
effectSession,
effectList,
effectPut,
effectRemove,
promiseList,
promisePut,
promiseRemove,
exactVersion,
compatibleVersion,
]
+4 -1
View File
@@ -27,7 +27,10 @@ if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" ||
}
let requests = 0
const version = mode === "old" || mode === "reject-stop" ? "old" : "test"
let version = "test"
if (mode === "old" || mode === "reject-stop") version = "old"
if (mode === "incompatible") version = "1.9.0"
if (mode === "compatible" || mode === "delayed-compatible") version = "2.1.0-next.1"
const id = crypto.randomUUID()
const server = Bun.serve({
port: 0,
@@ -25,6 +25,19 @@ test("discovers a registered service", async () => {
expect(await Service.discover({ file: registration, version: "other" })).toBeUndefined()
})
test("discovers a compatible registered service", async () => {
const registration = await setup("compatible")
expect(await Service.discover({ file: registration, version: "2.1.0" })).toBeUndefined()
expect(await Service.discover({ file: registration, version: "2.1.0-next.1" })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("2.") })).toEqual(
expect.objectContaining({ url: expect.stringMatching(/^http:\/\//) }),
)
expect(await Service.discover({ file: registration, version: (version) => version.startsWith("3.") })).toBeUndefined()
})
test("ensures a missing service with native promises", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+46
View File
@@ -47,6 +47,52 @@ test("a concurrent same-version start cannot invalidate a resolved endpoint", as
expect(await health(resolved.url)).toEqual({ healthy: true, version: "test", pid: original.pid })
})
test("reuses a compatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "compatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [],
onStart: (reason) => starts.push(reason),
}),
)
expect(endpoint.url).toBe((await Bun.file(registration).json()).url)
expect(starts).toEqual([])
expect(existing.exitCode).toBe(null)
})
test("replaces an incompatible registered service", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
const existing = spawn(registration, "incompatible")
await waitForFile(registration)
const starts: EnsureReason[] = []
const endpoint = await run(
ensure({
file: registration,
version: (version) => version.startsWith("2."),
command: [process.execPath, fixture, registration, "delayed-compatible", "10"],
onStart: (reason) => starts.push(reason),
}),
)
const replacement = await Bun.file(registration).json()
expect(await existing.exited).toBe(0)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
process.kill(replacement.pid, "SIGTERM")
await waitForExit(replacement.pid)
})
test("waits for a registered service to finish starting", async () => {
const directory = await temp()
const registration = join(directory, "service.json")
+3 -3
View File
@@ -113,8 +113,8 @@ const layer = Layer.effect(
const compaction = yield* SessionCompaction.Service
const title = yield* SessionTitle.Service
const toolOutput = yield* ToolOutput.Service
// Title generation is a side effect of a successful step; it must not delay continuation.
// The in-flight set coalesces overlapping steps while title presence records success durably.
// Title generation starts once input is visible and must not delay model execution.
// The in-flight set coalesces overlapping prompts while title presence records success durably.
const titlesRunning = new Set<SessionSchema.ID>()
const forkTitle = yield* FiberSet.makeRuntime<never, void, never>()
/**
@@ -144,7 +144,6 @@ const layer = Layer.effect(
let step = 1
while (true) {
const result = yield* runStep(sessionID, promotable, step)
if (step === 1) yield* startTitle(sessionID)
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
@@ -236,6 +235,7 @@ const layer = Layer.effect(
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
const loaded = yield* context.load(selected)
-1
View File
@@ -286,7 +286,6 @@ export const Plugin = {
output,
content,
metadata: {
status: output.status,
truncated: output.truncated,
...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}),
...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}),
+37 -5
View File
@@ -816,7 +816,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) =>
})
describe("SessionRunnerLLM", () => {
it.effect("retries title generation from the first prompt after execution and title failures", () =>
it.effect("generates the title while the first model step is still running", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
@@ -831,16 +831,48 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()))
yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never)
const bus = yield* Bus.Service
const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe(
Stream.filter((event) => event.data.sessionID === sessionID),
Stream.take(1),
Stream.runDrain,
Effect.forkScoped({ startImmediately: true }),
)
const runner = yield* SessionRunner.Service
const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild)
yield* Fiber.join(renamed)
expect((yield* session.get(sessionID)).title).toBe("Generated title")
yield* Fiber.interrupt(fiber)
}),
)
it.effect("retries title generation from the first prompt after title and execution failures", () =>
Effect.gen(function* () {
const session = yield* setup
const agents = yield* Agent.Service
const { db } = yield* Database.Service
yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
yield* agents.transform((draft) =>
draft.update(Agent.ID.make("title"), (agent) => {
agent.mode = "primary"
agent.hidden = true
agent.system = "Generate a title."
}),
)
yield* admit(session, "First prompt")
yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest()))
expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure")
yield* admit(session, "Second prompt")
const titleFailed = yield* Deferred.make<void>()
yield* TestLLM.push(
TestLLM.text("Recovered", "text-recovered"),
Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe(
Stream.ensuring(Deferred.succeed(titleFailed, undefined)),
),
TestLLM.text("Recovered", "text-recovered"),
)
yield* session.resume(sessionID)
yield* Deferred.await(titleFailed)
@@ -856,13 +888,13 @@ describe("SessionRunnerLLM", () => {
)
yield* admit(session, "Third prompt")
yield* TestLLM.push(
TestLLM.text("Recovered again", "text-recovered-again"),
TestLLM.text("Generated title", "text-title"),
TestLLM.text("Recovered again", "text-recovered-again"),
)
yield* session.resume(sessionID)
yield* Fiber.join(renamed)
expect(requests).toHaveLength(5)
expect(requests).toHaveLength(6)
expect(requests[2]?.messages).toContainEqual(Message.user("First prompt"))
expect(requests[4]?.messages).toContainEqual(Message.user("First prompt"))
expect((yield* session.get(sessionID)).title).toBe("Generated title")
+2 -2
View File
@@ -667,7 +667,7 @@ describe("ShellTool", () => {
)
const settled = yield* executeTool(registry, call({ command: idleCommand, timeout: 50, background: true }))
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(settled.metadata).toMatchObject({ truncated: false })
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
@@ -752,7 +752,7 @@ describe("ShellTool", () => {
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
expect(settled.metadata).toMatchObject({ status: "running", truncated: false })
expect(settled.metadata).toMatchObject({ truncated: false })
expect(settled.content?.[0]).toEqual({
type: "text",
text: "The command was moved to the background.",
@@ -151,6 +151,7 @@ export function DialogOpen() {
options={options()}
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
focusCurrent={false}
sectionNavigation={true}
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
+282 -38
View File
@@ -1,4 +1,4 @@
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core"
import { For, Show, createComputed, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
@@ -18,12 +18,15 @@ import {
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
import { tint } from "../theme/color"
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
import { projectName } from "../util/project"
import { marqueeText } from "../util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../util/marquee"
import { useDialog } from "../ui/dialog"
import { DialogSessionRename } from "./dialog-session-rename"
import { Keymap } from "../context/keymap"
import { moveSelection } from "../ui/select-controller"
// A long title fades out over its last cells instead of cutting hard.
const FADE_WIDTH = 4
@@ -31,6 +34,15 @@ const FADE_WIDTH = 4
const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 100
const CONTEXT_MENU_WIDTH = 16
const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
x: number
y: number
sessionID?: string
title?: string
}
type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
@@ -60,27 +72,188 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
let delay: ReturnType<typeof setTimeout> | undefined
let interval: ReturnType<typeof setInterval> | undefined
let cycleWidth = 0
let returning = false
createEffect(() => {
const clear = () => {
if (delay) clearTimeout(delay)
if (interval) clearInterval(interval)
delay = undefined
interval = undefined
}
const scroll = () => {
interval = setInterval(() => setOffset((value) => (value + 1) % cycleWidth), MARQUEE_INTERVAL)
}
const enter = (sessionID: string, title: string, width: number) => {
if (active() === sessionID && !returning) return
clear()
if (active() === sessionID) {
returning = false
return scroll()
}
if (!marqueeOverflows(title, width)) return
cycleWidth = marqueeCycleWidth(title)
setActive(sessionID)
setOffset(0)
returning = false
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
delay = setTimeout(() => {
setOffset(1)
leading.animate({ opacity: 1 })
interval = setInterval(() => setOffset((value) => value + 1), MARQUEE_INTERVAL)
scroll()
}, MARQUEE_DELAY)
onCleanup(() => {
clearTimeout(delay)
if (interval) clearInterval(interval)
}
const leave = (sessionID: string) => {
if (active() !== sessionID) return
clear()
if (offset() === 0) {
setActive(undefined)
return
}
returning = true
interval = setInterval(() => {
setOffset((value) => {
const next = (value + 1) % cycleWidth
if (next !== 0) return next
clear()
returning = false
setActive(undefined)
leading.animate({ opacity: 0 })
return 0
})
}, MARQUEE_INTERVAL)
}
const reset = () => {
clear()
returning = false
setActive(undefined)
setOffset(0)
leading.jump({ opacity: 0 })
}
onCleanup(clear)
return { offset, active, enter, leave, reset, leading: () => leading.value().opacity }
}
function createTabMarquee(animations: () => boolean) {
const [hovered, setHovered] = createSignal<string>()
const marquee = createMarquee(animations)
let hoverClear: ReturnType<typeof setTimeout> | undefined
const enter = (sessionID: string, title: string, width: number) => {
if (hoverClear) clearTimeout(hoverClear)
setHovered(sessionID)
marquee.enter(sessionID, title, width)
}
const leave = (sessionID: string) => {
if (hoverClear) clearTimeout(hoverClear)
hoverClear = setTimeout(() => {
if (hovered() !== sessionID) return
setHovered(undefined)
marquee.leave(sessionID)
})
}
onCleanup(() => {
if (hoverClear) clearTimeout(hoverClear)
})
return { offset, leading: () => leading.value().opacity }
return { ...marquee, hovered, enter, leave }
}
function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsController; onClose: () => void }) {
const dimensions = useTerminalDimensions()
const theme = useTheme("elevated")
const dialog = useDialog()
const keymap = Keymap.use()
const actions = createMemo(() => {
const sessionID = props.state.sessionID
return [
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
? [
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
},
{ title: "Close", run: () => props.tabs.close(sessionID) },
]
: []),
]
})
const [selected, setSelected] = createSignal(0)
const top = () => Math.max(0, Math.min(props.state.y + 1, dimensions().height - actions().length))
const left = () => Math.max(0, Math.min(props.state.x, dimensions().width - CONTEXT_MENU_WIDTH))
const run = (index: number) => {
props.onClose()
actions()[index]?.run()
}
createEffect(() => {
const popMode = keymap.mode.push("modal")
onCleanup(popMode)
})
Keymap.createLayer(() => ({
mode: "modal",
commands: [
{ bind: "escape", title: "Close tab menu", group: "Tabs", run: props.onClose },
{
bind: "up",
title: "Previous tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: -1, policy: "wrap" })),
},
{
bind: "down",
title: "Next tab menu item",
group: "Tabs",
run: () => setSelected(moveSelection(selected(), { count: actions().length, delta: 1, policy: "wrap" })),
},
{ bind: "return", title: "Select tab menu item", group: "Tabs", run: () => run(selected()) },
],
}))
return (
<box
position="absolute"
left={left()}
top={top()}
height={actions().length}
width={CONTEXT_MENU_WIDTH}
zIndex={2500}
flexDirection="column"
backgroundColor={theme.background.default}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
<For each={actions()}>
{(action, index) => (
<box
width="100%"
paddingLeft={1}
paddingRight={1}
backgroundColor={selected() === index() ? theme.background.action.primary.hovered : undefined}
onMouseOver={() => setSelected(index())}
onMouseUp={(event) => {
event.preventDefault()
event.stopPropagation()
run(index())
}}
>
<text fg={theme.text.default} selectable={false}>
{action.title}
</text>
</box>
)}
</For>
</box>
)
}
export function SessionTabs(
@@ -105,11 +278,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04))
const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05))
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? undefined : tabs.current()))
const ordered = createMemo(() => {
@@ -118,6 +292,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
})
const items = ordered
createEffect(() => {
const active = marquee.active()
if (active && !items().some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -137,7 +315,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
),
)
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenY: number } | undefined
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
createEffect(() => {
@@ -167,6 +345,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
height="100%"
flexShrink={0}
flexDirection="column"
position="relative"
paddingTop={1}
backgroundColor={theme.background.default}
>
@@ -183,16 +362,19 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return value ? data.project.get(value.projectID) : undefined
})
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const restingTitleWidth = () => Math.max(1, width() - numberWidth() - 2)
const titleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const titleFades = createMemo(
() => marqueeOverflows(title(), restingTitleWidth()) && titleWidth() > FADE_WIDTH,
)
const detail = createMemo(() => {
const value = session()
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
@@ -274,13 +456,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
position="relative"
flexDirection="column"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
setDragging(undefined)
if (!rail) return
setContextMenu({
x: event.x - rail.screenX,
y: event.y - rail.screenY,
sessionID: tab.sessionID,
title: tab.title,
})
event.preventDefault()
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (!rail) return
const target = Math.max(
@@ -389,6 +587,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -440,7 +639,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
onMouseDown={(event: MouseEvent) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x - rail.screenX, y: event.y - rail.screenY })
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (!newTab()) tabs.add?.()
}}
>
@@ -469,6 +676,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (!addHovered()) return
event.stopPropagation()
tabs.close()
@@ -481,6 +689,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
</Show>
</box>
</scrollbox>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}
@@ -492,15 +703,16 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const { mode } = useThemes()
const config = useConfig().data
const animations = () => props.animations ?? config.animations ?? true
const [hovered, setHovered] = createSignal<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
// per slot crossing; the preview holds after release until the store reflects the move,
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
let strip: { screenX: number } | undefined
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -530,6 +742,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
previous?.start,
),
)
createEffect(() => {
const active = marquee.active()
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
})
const statuses = createMemo(
() =>
new Map(
@@ -680,9 +896,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// 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))
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const restingTitleWidth = () => Math.max(1, width() - 1 - numberWidth())
const availableTitleWidth = () => Math.max(1, restingTitleWidth() - (hovered() === tab.sessionID ? 2 : 0))
const scrolling = () => marquee.active() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), availableTitleWidth(), marquee.offset())
@@ -690,7 +906,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
() => marqueeOverflows(title(), restingTitleWidth()) && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
@@ -741,13 +957,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
backgroundColor={background()}
onMouseOver={() => setHovered(tab.sessionID)}
onMouseOut={() => setHovered(undefined)}
onMouseDown={() => {
setHovered(tab.sessionID)
onMouseOver={() => marquee.enter(tab.sessionID, title(), restingTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
setDragging(undefined)
setContextMenu({
x: event.x - (strip?.screenX ?? 0),
y: event.y - (strip?.screenY ?? 0),
sessionID: tab === NEW_SESSION_TAB ? undefined : tab.sessionID,
title: tab === NEW_SESSION_TAB ? undefined : tab.title,
})
event.preventDefault()
event.stopPropagation()
return
}
marquee.enter(tab.sessionID, title(), restingTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={release}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
@@ -798,6 +1029,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={closeColor()}
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
// The close mark only renders while hovered; without motion events a click can
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
@@ -825,11 +1057,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
onMouseDown={(event) => {
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x - (strip?.screenX ?? 0), y: event.y - (strip?.screenY ?? 0) })
event.preventDefault()
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
tabs.add?.()
}}
>
{" + "}
</text>
</Show>
<Show when={contextMenu()}>
{(state) => <TabContextMenu state={state()} tabs={tabs} onClose={() => setContextMenu(undefined)} />}
</Show>
</box>
)
}
+15 -11
View File
@@ -96,13 +96,15 @@ export const Definitions = {
"session.move": keybind("none", "Move session"),
"session.new": keybind("<leader>n", "Create a new session"),
"session.list": keybind("<leader>l", "List all sessions"),
"session.tab.next": keybind("ctrl+tab,<leader>right", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,<leader>left", "Switch to previous open session tab"),
"session.tab.history.back": keybind("ctrl+o", "Go back in session tab history"),
"open.menu": keybind("ctrl+o", "Open recent sessions and projects"),
"session.tab.next": keybind("ctrl+tab,alt+down", "Switch to next open session tab"),
"session.tab.previous": keybind("ctrl+shift+tab,alt+up", "Switch to previous open session tab"),
"session.tab.history.back": keybind("none", "Go back in session tab history"),
"session.tab.history.forward": keybind("ctrl+i", "Go forward in session tab history"),
"session.tab.next_unread": keybind("<leader>down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("<leader>up", "Switch to previous unread session tab"),
"session.tab.next_unread": keybind("alt+shift+down", "Switch to next unread session tab"),
"session.tab.previous_unread": keybind("alt+shift+up", "Switch to previous unread session tab"),
"session.tab.close": keybind("<leader>w", "Close current session tab"),
"session.tab.reopen": keybind("ctrl+shift+t", "Reopen last closed session tab"),
"session.timeline": keybind("<leader>g", "Show session timeline"),
"session.fork": keybind("none", "Fork session from message"),
"session.rename": keybind("ctrl+r", "Rename session"),
@@ -139,6 +141,7 @@ export const Definitions = {
"session.tab.select.7": keybind("<leader>7,ctrl+7", "Switch to session tab 7"),
"session.tab.select.8": keybind("<leader>8,ctrl+8", "Switch to session tab 8"),
"session.tab.select.9": keybind("<leader>9,ctrl+9", "Switch to session tab 9"),
"session.tab.select.10": keybind("<leader>0,ctrl+0", "Switch to session tab 10"),
"stash.delete": keybind("ctrl+d", "Delete stash entry"),
"model.dialog.provider": keybind("ctrl+a", "Open provider list from model dialog"),
@@ -164,10 +167,10 @@ export const Definitions = {
"session.half.page.down": keybind("ctrl+alt+d", "Scroll messages down by half page"),
"session.first": keybind("ctrl+g,home,alt+home", "Navigate to first message"),
"session.last": keybind("ctrl+alt+g,end", "Navigate to last message"),
"session.message.next": keybind("alt+down", "Navigate to next message"),
"session.message.previous": keybind("alt+up", "Navigate to previous message"),
"session.message.user.next": keybind("alt+shift+down", "Navigate to next user message"),
"session.message.user.previous": keybind("alt+shift+up", "Navigate to previous user message"),
"session.message.next": keybind("none", "Navigate to next message"),
"session.message.previous": keybind("none", "Navigate to previous message"),
"session.message.user.next": keybind("none", "Navigate to next user message"),
"session.message.user.previous": keybind("none", "Navigate to previous user message"),
"session.messages_last_user": keybind("alt+end", "Navigate to last user message"),
"messages.copy": keybind("<leader>y", "Copy message"),
"session.undo": keybind("<leader>u", "Undo message"),
@@ -177,6 +180,7 @@ export const Definitions = {
"prompt.submit": keybind("none", "Submit prompt"),
"prompt.queue": keybind("alt+return", "Queue prompt"),
"prompt.editor_context.clear": keybind("none", "Clear editor context"),
"prompt.images.view": keybind("<leader>i", "View image attachments"),
"prompt.skills": keybind("none", "Open skill selector"),
"prompt.stash": keybind("none", "Stash prompt"),
"prompt.stash.pop": keybind("none", "Pop stashed prompt"),
@@ -202,8 +206,8 @@ export const Definitions = {
"input.visual.line.end": keybind("alt+e", "Move to end of visual line in input"),
"input.select.visual.line.home": keybind("alt+shift+a", "Select to start of visual line in input"),
"input.select.visual.line.end": keybind("alt+shift+e", "Select to end of visual line in input"),
"input.buffer.home": keybind("home", "Move to start of buffer in input"),
"input.buffer.end": keybind("end", "Move to end of buffer in input"),
"input.buffer.home": keybind("none", "Move to start of buffer in input"),
"input.buffer.end": keybind("none", "Move to end of buffer in input"),
"input.select.buffer.home": keybind("shift+home", "Select to start of buffer in input"),
"input.select.buffer.end": keybind("shift+end", "Select to end of buffer in input"),
"input.delete.line": keybind("ctrl+shift+d", "Delete line in input"),
+2 -3
View File
@@ -1983,6 +1983,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={delivery() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={theme.background.default}
>
<SessionImages images={images()} paddingLeft={2} />
<box
@@ -2779,9 +2780,7 @@ function Shell(props: ToolProps) {
const permission = useToolPermission(() => props.part)
const color = createMemo(() => (permission() ? theme.text.feedback.warning.default : theme.text.default))
const shellID = createMemo(() => stringValue(props.metadata.shellID))
const background = createMemo(
() => props.part.state.status === "completed" && props.metadata.status === "running",
)
const background = createMemo(() => Boolean(shellID()) && props.part.state.status !== "running")
const backgroundRunning = createMemo(() => {
const id = shellID()
return Boolean(id && data.shell.get(id))
+26
View File
@@ -40,6 +40,7 @@ export interface DialogSelectProps<T> {
bindings?: readonly KeymapCommand[]
current?: T
focusCurrent?: boolean
sectionNavigation?: boolean
}
type DialogSelectActionBase<T> = {
@@ -327,6 +328,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(moveSelection(store.selected, { count: flat().length, delta: direction, policy: "wrap" }), true)
}
function moveSection(direction: 1 | -1) {
if (props.locked) return
const sections = grouped().filter(([_, options]) => options.length > 0)
if (sections.length === 0) return
const current = sections.findIndex(([category]) => category === selected()?.category)
const section = sections[(current + direction + sections.length) % sections.length]
moveTo(flat().indexOf(section[1][0]), true)
}
function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined)
setStore("selected", next)
@@ -488,6 +498,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
]
: []),
...(props.bindings ?? []),
...(props.sectionNavigation
? [
{
bind: "alt+up",
title: "Previous section",
group: "Dialog",
run: () => moveSection(-1),
},
{
bind: "alt+down",
title: "Next section",
group: "Dialog",
run: () => moveSection(1),
},
]
: []),
],
}
})
+10 -2
View File
@@ -1,14 +1,22 @@
import { Locale } from "./locale"
import { stringWidth } from "./string-width"
const GAP = " "
const GAP = " · "
export function marqueeCycleWidth(value: string) {
return stringWidth(value + GAP)
}
export function marqueeOverflows(value: string, width: number) {
return stringWidth(value) > width
}
export function marqueeText(value: string, width: number, offset: number) {
if (width <= 0) return ""
if (stringWidth(value) <= width || offset <= 0) return Locale.takeWidth(value, width)
const loop = value + GAP
const cursor = offset % stringWidth(loop)
const cursor = offset % marqueeCycleWidth(value)
const segments = Locale.graphemes(loop + loop)
const start = segments.reduce(
(state, segment, index) =>
@@ -186,6 +186,94 @@ test("preserves a moved project when sessions arrive", async () => {
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
return json({
data: [
{
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/tmp/opencode/recent" },
},
],
cursor: {},
})
if (url.pathname === "/api/project")
return json([
{
id: "proj_recent",
canonical: "/tmp/opencode/recent",
name: "Recent project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
])
return undefined
}
const next = await renderOpen(handler)
try {
await next.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
next.app.mockInput.pressArrow("down", { meta: true })
next.app.mockInput.pressEnter()
await next.app.waitFor(() => next.route.data.type === "home")
expect(next.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await next.dispose()
}
const previous = await renderOpen(handler)
try {
await previous.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Recent project"))
previous.app.mockInput.pressArrow("up", { meta: true })
previous.app.mockInput.pressEnter()
await previous.app.waitFor(() => previous.route.data.type === "home")
expect(previous.route.data).toEqual({ type: "home", location: { directory: "/tmp/opencode/recent" } })
} finally {
await previous.dispose()
}
})
test("option arrows stay in the only visible section", async () => {
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname !== "/api/project") return undefined
return json([
{
id: "proj_effect",
canonical: "/tmp/effect",
name: "Effect",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_opencode",
canonical: "/tmp/opencode",
name: "OpenCode",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && frame.includes("OpenCode"))
await fixture.app.mockInput.typeText("Effect")
await fixture.app.waitForFrame((frame) => frame.includes("Effect") && !frame.includes("OpenCode"))
fixture.app.mockInput.pressArrow("down", { meta: true })
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/effect" } })
} finally {
await fixture.dispose()
}
})
async function renderOpen(
handler: FetchHandler,
beforeOpen?: (contexts: {
+19
View File
@@ -74,6 +74,25 @@ test("uses command IDs as keybind keys", () => {
).toBe(true)
})
test("preserves current navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }])
expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
expect(config.keybinds.get("session.message.next")).toEqual([])
expect(config.keybinds.get("session.message.previous")).toEqual([])
expect(config.keybinds.get("session.message.user.next")).toEqual([])
expect(config.keybinds.get("session.message.user.previous")).toEqual([])
expect(config.keybinds.get("input.buffer.home")).toEqual([])
expect(config.keybinds.get("input.buffer.end")).toEqual([])
expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
})
test("preserves migrated v1 keybind defaults", () => {
const pairs = [
["app.exit", "app_exit"],
+14 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { marqueeText } from "../../src/util/marquee"
import { marqueeCycleWidth, marqueeOverflows, marqueeText } from "../../src/util/marquee"
import { stringWidth } from "../../src/util/string-width"
describe("marquee text", () => {
@@ -7,11 +7,22 @@ describe("marquee text", () => {
expect(marqueeText("Short", 10, 8)).toBe("Short")
})
test("does not classify an exact fit as overflow", () => {
expect(marqueeOverflows("Exact fit", 9)).toBe(false)
expect(marqueeOverflows("Exact fit", 8)).toBe(true)
})
test("starts clipped and scrolls through a long title", () => {
expect(marqueeText("A long session title", 8, 0)).toBe("A long s")
expect(marqueeText("A long session title", 8, 2)).toBe("long ses")
expect(marqueeText("A long session title", 8, 15)).toBe("title ")
expect(marqueeText("A long session title", 8, 20)).toBe(" A lo")
expect(marqueeText("A long session title", 8, 15)).toBe("title · ")
expect(marqueeText("A long session title", 8, 20)).toBe(" · A lon")
})
test("loops after one spaced dot separator", () => {
const title = "A long session title"
expect(marqueeText(title, 8, marqueeCycleWidth(title) - 3)).toBe(" · A lon")
expect(marqueeText(title, 8, marqueeCycleWidth(title))).toBe("A long s")
})
test("clips wide graphemes to terminal cells", () => {
+4 -3
View File
@@ -94,13 +94,14 @@ const client = OpenCode.make({
const health = await client.health.get()
```
`Service.ensure()` accepts an optional registration file, required version,
service command, and `onStart` callback:
`Service.ensure()` accepts an optional registration file, version, service
command, and `onStart` callback. `version` accepts either an exact value or a
compatibility predicate:
```ts
const endpoint = await Service.ensure({
file: "/var/run/opencode/service.json",
version: "2.0.0",
version: (version) => version.startsWith("2."),
command: ["opencode", "serve", "--service"],
onStart(reason, previousVersion) {
console.log(reason, previousVersion)