mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 12:45:07 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e763bac34 | |||
| bbc9307ec2 | |||
| b24b1b3f16 | |||
| f2bfdef450 | |||
| 686214dd7e | |||
| 930b0751b1 | |||
| f06a86eeac | |||
| 653b7d79cd | |||
| c74be8312e | |||
| 70ce0d0970 | |||
| 0777e84598 | |||
| bef795b2fe |
@@ -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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user