Compare commits

...

2 Commits

Author SHA1 Message Date
Kit Langton 2ce2379697 refactor(protocol): derive simulation wire contracts 2026-08-14 14:05:57 -04:00
Kit Langton f75244d795 fix(tui): preserve tab drag source (#42619) 2026-08-14 17:59:18 +00:00
4 changed files with 478 additions and 170 deletions
+277 -125
View File
@@ -4,6 +4,10 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc"
const JsonRpcID = Schema.Union([Schema.String, Schema.Number, Schema.Null])
const decodeJson = Schema.decodeUnknownSync(Schema.Json)
// Generated schema unions lose mapped tuple types even though both come from the same operation tuple.
// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion, typescript-eslint/no-unnecessary-type-parameters
const decoded = <Type>(value: unknown) => value as Type
export namespace JsonRpc {
export const RequestFields = {
jsonrpc: Schema.Literal("2.0"),
@@ -22,13 +26,33 @@ export namespace JsonRpc {
data: Schema.optional(Schema.Json),
})
export const Response = Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: JsonRpcID,
result: Schema.optional(Schema.Json),
error: Schema.optional(ErrorObject),
})
export interface Response extends Schema.Schema.Type<typeof Response> {}
export type Response =
| {
readonly jsonrpc: "2.0"
readonly id: string | number | null
readonly result: Schema.Schema.Type<typeof Schema.Json>
readonly error?: never
}
| {
readonly jsonrpc: "2.0"
readonly id: string | number | null
readonly error: Schema.Schema.Type<typeof ErrorObject>
readonly result?: never
}
export const Response = decoded<Schema.Decoder<Response>>(
Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
id: JsonRpcID,
result: Schema.optionalKey(Schema.Json),
error: Schema.optionalKey(ErrorObject),
}).check(
Schema.makeFilter((response) =>
"result" in response === "error" in response
? "JSON-RPC responses must contain exactly one of result or error"
: undefined,
),
),
)
export const decodeRequest = Schema.decodeUnknownSync(Request)
@@ -49,6 +73,161 @@ export namespace JsonRpc {
}
}
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
"SimulationRequestError",
{
method: Schema.String,
code: Schema.Number,
message: Schema.String,
data: Schema.optionalKey(Schema.Json),
},
) {}
const request = <
const Tag extends string,
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
Success extends Schema.Top = typeof Schema.Void,
>(
tag: Tag,
options?: {
readonly payload?: Payload
readonly success?: Success
},
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
function operation<const Tag extends string, Success extends Schema.Decoder<unknown>>(method: Tag, success: Success) {
return {
method,
success,
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method) }),
rpc: request(method, { success }),
}
}
function operationWithPayload<
const Tag extends string,
Payload extends Schema.Decoder<unknown>,
Success extends Schema.Decoder<unknown>,
>(method: Tag, payload: Payload, success: Success) {
return {
method,
payload,
success,
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method), params: payload }),
rpc: request(method, { payload, success }),
}
}
function operationWithRpcPayload<
const Tag extends string,
Payload extends Schema.Decoder<unknown>,
RpcPayload extends Schema.Decoder<unknown>,
Success extends Schema.Decoder<unknown>,
>(method: Tag, payload: Payload, rpcPayload: RpcPayload, success: Success) {
return {
method,
payload,
success,
request: Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal(method), params: payload }),
rpc: request(method, { payload: rpcPayload, success }),
}
}
function notification<const Method extends string, Payload extends Schema.Decoder<unknown>>(
method: Method,
payload: Payload,
) {
return {
method,
payload,
schema: Schema.Struct({
jsonrpc: Schema.Literal("2.0"),
method: Schema.Literal(method),
params: payload,
}),
}
}
type OperationRequest<Operation> = Operation extends {
readonly method: infer Method extends string
readonly payload: infer Payload extends Schema.Top
}
? Omit<JsonRpc.Request, "method" | "params"> & {
readonly method: Method
readonly params: Schema.Schema.Type<Payload>
}
: Operation extends { readonly method: infer Method extends string }
? Omit<JsonRpc.Request, "method" | "params"> & { readonly method: Method }
: never
type EndpointRequest<Operations extends ReadonlyArray<{ readonly method: string }>> =
| Handshake.Request
| OperationRequest<Operations[number]>
type EndpointNotification<Notifications extends ReadonlyArray<{ readonly schema: Schema.Top }>> = Schema.Schema.Type<
Notifications[number]["schema"]
>
function endpoint<
const Operations extends ReadonlyArray<{
readonly method: string
readonly success: Schema.Top
readonly rpc: Rpc.Any
readonly request: Schema.Decoder<unknown>
readonly payload?: Schema.Decoder<unknown>
}>,
const Notifications extends ReadonlyArray<{
readonly method: string
readonly payload: Schema.Decoder<unknown>
readonly schema: Schema.Decoder<unknown>
}>,
const Capabilities extends ReadonlyArray<Handshake.Capability>,
>(
operations: Operations,
notifications: Notifications,
features: ReadonlyArray<Handshake.Capability>,
Capabilities: Capabilities,
) {
const requests: ReadonlyArray<Schema.Decoder<unknown>> = [
Handshake.Request,
...operations.map((operation) => operation.request),
]
const Request = Schema.Union(requests)
const Notification =
notifications.length === 0 ? Schema.Never : Schema.Union(notifications.map((notification) => notification.schema))
const decodeRequest = Schema.decodeUnknownSync(Request)
const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
const decodeNotification = Schema.decodeUnknownSync(Notification)
const decodeNotificationEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Notification))
const handshake = request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response })
const rpcs = RpcGroup.make(handshake, ...operations.map((operation) => operation.rpc)) as RpcGroup.RpcGroup<
typeof handshake | Operations[number]["rpc"]
>
const derived = [
...operations.map((operation) => operation.method),
...notifications.map((notification) => notification.method),
...features,
]
if (
new Set(Capabilities).size !== Capabilities.length ||
derived.length !== Capabilities.length ||
derived.some((capability) => !Capabilities.includes(capability))
)
throw new Error("Simulation capabilities must exactly match endpoint operations")
return {
Capabilities,
Request: decoded<Schema.Decoder<EndpointRequest<Operations>>>(Request),
Notification: decoded<Schema.Decoder<EndpointNotification<Notifications>>>(Notification),
decodeRequest: (input: unknown) => decoded<EndpointRequest<Operations>>(decodeRequest(input)),
decodeRequestEffect: (input: string) =>
decodeRequestEffect(input).pipe(Effect.map(decoded<EndpointRequest<Operations>>)),
decodeNotification: (input: unknown) => decoded<EndpointNotification<Notifications>>(decodeNotification(input)),
decodeNotificationEffect: (input: string) =>
decodeNotificationEffect(input).pipe(Effect.map(decoded<EndpointNotification<Notifications>>)),
rpcs,
}
}
export namespace Handshake {
export const ProtocolVersion = Schema.Literal(1)
export type ProtocolVersion = Schema.Schema.Type<typeof ProtocolVersion>
@@ -81,7 +260,7 @@ export namespace Handshake {
protocolVersion: ProtocolVersion,
role: EndpointRole,
server: Identity,
capabilities: Schema.Array(Capability),
capabilities: Schema.Array(Capability).check(Schema.isUnique()),
})
export interface Response extends Schema.Schema.Type<typeof Response> {}
@@ -165,23 +344,6 @@ export namespace Handshake {
}
export namespace Frontend {
export const Capabilities = [
"ui.type",
"ui.press",
"ui.enter",
"ui.arrow",
"ui.focus",
"ui.click",
"ui.click.semantic",
"ui.resize",
"ui.matches",
"ui.state",
"ui.snapshot",
"ui.capture",
"ui.recording.finish",
] as const satisfies ReadonlyArray<Handshake.Capability>
export type Capability = (typeof Capabilities)[number]
export const KeyModifiers = Schema.Struct({
ctrl: Schema.optional(Schema.Boolean),
shift: Schema.optional(Schema.Boolean),
@@ -337,44 +499,51 @@ export namespace Frontend {
export const ResizeParams = Schema.Struct({ cols: Schema.Number, rows: Schema.Number })
export interface ResizeParams extends Schema.Schema.Type<typeof ResizeParams> {}
}
export const Request = Schema.Union([
Handshake.Request,
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.type"), params: TypeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.press"), params: PressParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.arrow"), params: ArrowParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.focus"), params: FocusParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.click"), params: ClickParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.resize"), params: ResizeParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.matches"), params: MatchesParams }),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["ui.enter", "ui.state", "ui.snapshot", "ui.recording.finish"]),
}),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("ui.capture") }),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
const FrontendOperations = [
operation("ui.state", Frontend.State),
operation("ui.snapshot", Frontend.SemanticSnapshot),
operation("ui.capture", Frontend.CapturedFrame),
operationWithPayload("ui.matches", Frontend.MatchesParams, Frontend.Matches),
operation("ui.recording.finish", Frontend.RecordingFinish),
operationWithPayload("ui.type", Frontend.TypeParams, Frontend.State),
operationWithPayload("ui.press", Frontend.PressParams, Frontend.State),
operation("ui.enter", Frontend.State),
operationWithPayload("ui.arrow", Frontend.ArrowParams, Frontend.State),
operationWithPayload("ui.focus", Frontend.FocusParams, Frontend.State),
operationWithPayload("ui.click", Frontend.ClickParams, Frontend.State),
operationWithPayload("ui.resize", Frontend.ResizeParams, Frontend.State),
] as const
const FrontendCapabilities = [
"ui.type",
"ui.press",
"ui.enter",
"ui.arrow",
"ui.focus",
"ui.click",
"ui.click.semantic",
"ui.resize",
"ui.matches",
"ui.state",
"ui.snapshot",
"ui.capture",
"ui.recording.finish",
] as const
const FrontendEndpoint = endpoint(FrontendOperations, [], ["ui.click.semantic"], FrontendCapabilities)
type FrontendRequest = EndpointRequest<typeof FrontendOperations>
export namespace Frontend {
export const Capabilities: typeof FrontendCapabilities = FrontendEndpoint.Capabilities
export type Capability = (typeof Capabilities)[number]
export const Request: Schema.Decoder<FrontendRequest> = FrontendEndpoint.Request
export type Request = FrontendRequest
export const decodeRequest: (input: unknown) => Request = FrontendEndpoint.decodeRequest
export const decodeRequestEffect: (input: string) => Effect.Effect<Request, Schema.SchemaError> =
FrontendEndpoint.decodeRequestEffect
}
export namespace Backend {
export const Capabilities = [
"llm.attach",
"llm.chunk",
"llm.finish",
"llm.disconnect",
"llm.pending",
"llm.request",
"llm.tool-input-delta",
"tool.attach",
"tool.update",
"tool.finish",
"tool.fail",
"tool.invocation",
"tool.cancel",
] as const satisfies ReadonlyArray<Handshake.Capability>
export const Item = Schema.Union([
Schema.Struct({ type: Schema.Literal("textDelta"), text: Schema.String }),
Schema.Struct({ type: Schema.Literal("reasoningDelta"), text: Schema.String }),
@@ -533,24 +702,6 @@ export namespace Backend {
export const DisconnectParams = Schema.Struct({ id: Schema.String })
export interface DisconnectParams extends Schema.Schema.Type<typeof DisconnectParams> {}
export const Request = Schema.Union([
Handshake.Request,
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.chunk"), params: ChunkParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.finish"), params: FinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("llm.disconnect"), params: DisconnectParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.attach"), params: ToolAttachParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.update"), params: ToolUpdateParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.finish"), params: ToolFinishParams }),
Schema.Struct({ ...JsonRpc.RequestFields, method: Schema.Literal("tool.fail"), params: ToolFailParams }),
Schema.Struct({
...JsonRpc.RequestFields,
method: Schema.Literals(["llm.attach", "llm.pending"]),
}),
])
export type Request = Schema.Schema.Type<typeof Request>
export const decodeRequest = Schema.decodeUnknownSync(Request)
export const decodeRequestEffect = Schema.decodeUnknownEffect(Schema.fromJsonString(Request))
export const ProviderInvocation = Schema.Struct({ id: Schema.String, url: Schema.String, body: Schema.Json })
export interface ProviderInvocation extends Schema.Schema.Type<typeof ProviderInvocation> {}
@@ -566,53 +717,54 @@ export namespace Backend {
export interface NetworkLogEntry extends Schema.Schema.Type<typeof NetworkLogEntry> {}
}
export class SimulationRequestError extends Schema.TaggedErrorClass<SimulationRequestError>()(
"SimulationRequestError",
{
method: Schema.String,
code: Schema.Number,
message: Schema.String,
data: Schema.optionalKey(Schema.Json),
},
) {}
const BackendOperations = [
operation("llm.attach", Backend.Attached),
operation("llm.pending", Backend.Pending),
operationWithPayload("llm.chunk", Backend.ChunkParams, Backend.Ok),
operationWithRpcPayload("llm.finish", Backend.FinishParams, Backend.FinishPayload, Backend.Ok),
operationWithPayload("llm.disconnect", Backend.DisconnectParams, Backend.Ok),
operationWithPayload("tool.attach", Backend.ToolAttachParams, Backend.Attached),
operationWithPayload("tool.update", Backend.ToolUpdateParams, Backend.Ok),
operationWithPayload("tool.finish", Backend.ToolFinishParams, Backend.Ok),
operationWithPayload("tool.fail", Backend.ToolFailParams, Backend.Ok),
] as const
const BackendNotifications = [
notification("llm.request", Backend.ProviderInvocation),
notification("tool.invocation", Backend.ToolInvocation),
notification("tool.cancel", Backend.ToolCancellation),
] as const
const BackendCapabilities = [
"llm.attach",
"llm.chunk",
"llm.finish",
"llm.disconnect",
"llm.pending",
"llm.request",
"llm.tool-input-delta",
"tool.attach",
"tool.update",
"tool.finish",
"tool.fail",
"tool.invocation",
"tool.cancel",
] as const
const BackendEndpoint = endpoint(BackendOperations, BackendNotifications, ["llm.tool-input-delta"], BackendCapabilities)
type BackendRequest = EndpointRequest<typeof BackendOperations>
type BackendNotification = EndpointNotification<typeof BackendNotifications>
const request = <
const Tag extends string,
Payload extends Schema.Top | Schema.Struct.Fields = typeof Schema.Void,
Success extends Schema.Top = typeof Schema.Void,
>(
tag: Tag,
options?: {
readonly payload?: Payload
readonly success?: Success
},
) => Rpc.make(tag, { ...options, error: SimulationRequestError })
export namespace Backend {
export const Capabilities: typeof BackendCapabilities = BackendEndpoint.Capabilities
export const Request: Schema.Decoder<BackendRequest> = BackendEndpoint.Request
export type Request = BackendRequest
export const decodeRequest: (input: unknown) => Request = BackendEndpoint.decodeRequest
export const decodeRequestEffect: (input: string) => Effect.Effect<Request, Schema.SchemaError> =
BackendEndpoint.decodeRequestEffect
export const Notification: Schema.Decoder<BackendNotification> = BackendEndpoint.Notification
export type Notification = BackendNotification
export const decodeNotification: (input: unknown) => Notification = BackendEndpoint.decodeNotification
export const decodeNotificationEffect: (input: string) => Effect.Effect<Notification, Schema.SchemaError> =
BackendEndpoint.decodeNotificationEffect
}
export const UiRpcs = RpcGroup.make(
request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response }),
request("ui.state", { success: Frontend.State }),
request("ui.snapshot", { success: Frontend.SemanticSnapshot }),
request("ui.capture", { success: Frontend.CapturedFrame }),
request("ui.matches", { payload: Frontend.MatchesParams, success: Frontend.Matches }),
request("ui.recording.finish", { success: Frontend.RecordingFinish }),
request("ui.type", { payload: Frontend.TypeParams, success: Frontend.State }),
request("ui.press", { payload: Frontend.PressParams, success: Frontend.State }),
request("ui.enter", { success: Frontend.State }),
request("ui.arrow", { payload: Frontend.ArrowParams, success: Frontend.State }),
request("ui.focus", { payload: Frontend.FocusParams, success: Frontend.State }),
request("ui.click", { payload: Frontend.ClickParams, success: Frontend.State }),
request("ui.resize", { payload: Frontend.ResizeParams, success: Frontend.State }),
)
export const BackendRpcs = RpcGroup.make(
request("simulation.handshake", { payload: Handshake.Params, success: Handshake.Response }),
request("llm.attach", { success: Backend.Attached }),
request("llm.pending", { success: Backend.Pending }),
request("llm.chunk", { payload: Backend.ChunkParams, success: Backend.Ok }),
request("llm.finish", { payload: Backend.FinishPayload, success: Backend.Ok }),
request("llm.disconnect", { payload: Backend.DisconnectParams, success: Backend.Ok }),
request("tool.attach", { payload: Backend.ToolAttachParams, success: Backend.Attached }),
request("tool.update", { payload: Backend.ToolUpdateParams, success: Backend.Ok }),
request("tool.finish", { payload: Backend.ToolFinishParams, success: Backend.Ok }),
request("tool.fail", { payload: Backend.ToolFailParams, success: Backend.Ok }),
)
export const UiRpcs = FrontendEndpoint.rpcs
export const BackendRpcs = BackendEndpoint.rpcs
+105 -1
View File
@@ -1,6 +1,110 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { Backend, Frontend, Handshake } from "../src/protocol"
import { Backend, BackendRpcs, Frontend, Handshake, JsonRpc, UiRpcs } from "../src/protocol"
const uiCapability: Frontend.Capability = "ui.state"
// @ts-expect-error capability literals must remain narrow for consumers
const invalidUiCapability: Frontend.Capability = "ui.future"
const successResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1, result: null }
// @ts-expect-error responses require one outcome
const missingResponse: Schema.Schema.Type<typeof JsonRpc.Response> = { jsonrpc: "2.0", id: 1 }
// @ts-expect-error responses cannot contain both outcomes
const invalidResponse: Schema.Schema.Type<typeof JsonRpc.Response> = {
jsonrpc: "2.0",
id: 1,
result: null,
error: { code: -32600, message: "Invalid request" },
}
void [uiCapability, invalidUiCapability, successResponse, missingResponse, invalidResponse]
test("preserves capability order and keeps request capabilities aligned with RPC groups", () => {
expect(Frontend.Capabilities).toEqual([
"ui.type",
"ui.press",
"ui.enter",
"ui.arrow",
"ui.focus",
"ui.click",
"ui.click.semantic",
"ui.resize",
"ui.matches",
"ui.state",
"ui.snapshot",
"ui.capture",
"ui.recording.finish",
])
expect(Backend.Capabilities).toEqual([
"llm.attach",
"llm.chunk",
"llm.finish",
"llm.disconnect",
"llm.pending",
"llm.request",
"llm.tool-input-delta",
"tool.attach",
"tool.update",
"tool.finish",
"tool.fail",
"tool.invocation",
"tool.cancel",
])
expect(new Set<string>(Frontend.Capabilities.filter((capability) => capability !== "ui.click.semantic"))).toEqual(
new Set(Array.from(UiRpcs.requests.keys()).filter((method) => method !== "simulation.handshake")),
)
expect(
new Set<string>(
Backend.Capabilities.filter(
(capability) => !["llm.request", "llm.tool-input-delta", "tool.invocation", "tool.cancel"].includes(capability),
),
),
).toEqual(new Set(Array.from(BackendRpcs.requests.keys()).filter((method) => method !== "simulation.handshake")))
})
test("normalizes an omitted finish reason", () => {
expect(Backend.decodeRequest({ jsonrpc: "2.0", id: 1, method: "llm.finish", params: { id: "inv_1" } })).toMatchObject(
{ params: { id: "inv_1", reason: "stop" } },
)
})
test("decodes typed backend notifications", () => {
expect(
Backend.decodeNotification({
jsonrpc: "2.0",
method: "tool.cancel",
params: { id: "tool_1", reason: "interrupted" },
}),
).toEqual({
jsonrpc: "2.0",
method: "tool.cancel",
params: { id: "tool_1", reason: "interrupted" },
})
expect(() =>
Backend.decodeNotification({
jsonrpc: "2.0",
method: "tool.cancel",
params: { id: "tool_1", reason: "unknown" },
}),
).toThrow()
})
test("requires exactly one JSON-RPC response outcome", () => {
const decode = Schema.decodeUnknownSync(JsonRpc.Response)
expect(decode({ jsonrpc: "2.0", id: 1, result: null })).toEqual({ jsonrpc: "2.0", id: 1, result: null })
expect(decode({ jsonrpc: "2.0", id: 1, error: { code: -32600, message: "Invalid request" } })).toEqual({
jsonrpc: "2.0",
id: 1,
error: { code: -32600, message: "Invalid request" },
})
expect(() => decode({ jsonrpc: "2.0", id: 1 })).toThrow()
expect(() =>
decode({
jsonrpc: "2.0",
id: 1,
result: null,
error: { code: -32600, message: "Invalid request" },
}),
).toThrow()
})
test("decodes ui.matches text params", () => {
expect(
@@ -83,7 +83,7 @@ test("streams a Drive-controlled provider response and removes the finished invo
jsonrpc: "2.0",
id: 3,
method: "llm.finish",
params: { id: params.id, reason: "stop" },
params: { id: params.id },
}),
)
expect(yield* Queue.take(messages)).toMatchObject({ id: 3, result: { ok: true } })
+95 -43
View File
@@ -307,6 +307,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
@@ -343,6 +344,9 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
let rail: { screenX: number; screenY: number } | undefined
let scroll: ScrollBoxRenderable | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
createEffect(() => {
const pending = preview()
@@ -364,6 +368,29 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}
})
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
tabs.select(source)
}
const drag = (event: MouseEvent) => {
if (!rail) return
const source = dragging()
if (!source) return
didDrag = true
const target = Math.max(
0,
Math.min(tabs.tabs().length - 1, Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3)),
)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== sourceIndex && preview()?.index !== target) setPreview({ sessionID: source, index: target })
}
return (
<box
ref={(element) => (rail = element)}
@@ -375,6 +402,15 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
paddingTop={1}
backgroundColor={theme.background.default}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
>
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
<box flexShrink={0} flexDirection="column" gap={1}>
@@ -522,12 +558,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
: color
return separator ? tint(faded, pulseBackground(), 0.55) : faded
}
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
tabs.select(tab.sessionID)
}
return (
<box
height={2}
@@ -539,6 +569,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
if (!rail) return
setContextMenu({
@@ -551,26 +582,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (!rail) return
const target = Math.max(
0,
Math.min(
tabs.tabs().length - 1,
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
),
)
if (target !== index() && preview()?.index !== target)
setPreview({ sessionID: tab.sessionID, index: target })
}}
onMouseDragEnd={release}
>
<TabPulse
top={-1}
@@ -677,8 +692,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (hovered() !== tab.sessionID) return
event.stopPropagation()
tabs.close(tab.sessionID)
@@ -737,6 +758,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event: MouseEvent) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
if (!rail) return
setContextMenu({ x: event.x, y: event.y })
@@ -745,6 +768,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={(event: MouseEvent) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!newTab()) tabs.add?.()
}}
>
@@ -774,6 +798,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
selectable={false}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
if (!addHovered()) return
event.stopPropagation()
tabs.close()
@@ -803,6 +828,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
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,
@@ -810,6 +836,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
let didDrag = false
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
@@ -931,6 +960,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
return layout().before + layout().widths.length - 1
}
const release = () => {
const source = dragging()
if (!source) return
if (didDrag) suppressClick = true
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === source) tabs.move(pending.sessionID, pending.index)
if (source === NEW_SESSION_TAB.sessionID) return
tabs.select(source)
}
const drag = (event: MouseEvent) => {
const source = dragging()
if (!source || source === NEW_SESSION_TAB.sessionID) return
didDrag = true
const slot = slotAt(event.x)
const target = slot === undefined ? undefined : Math.min(slot, tabs.tabs().length - 1)
const sourceIndex = items().findIndex((item) => item.sessionID === source)
if (target !== undefined && target !== sourceIndex && preview()?.index !== target) {
setPreview({ sessionID: source, index: target })
}
}
return (
<box
ref={(element) => (strip = element)}
@@ -940,6 +992,15 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
flexDirection="row"
zIndex={1}
onMouseOut={marquee.leaveHovered}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
if (!didDrag) return
didDrag = false
queueMicrotask(() => (suppressClick = false))
}}
onMouseDrag={drag}
onMouseDragEnd={release}
renderAfter={function (buffer) {
const x = Math.max(0, this.screenX)
const y = this.screenY + this.height
@@ -1051,15 +1112,6 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
// Releasing a drag (or a plain click) selects the tab, matching browser tab strips and
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
const pending = preview()
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
return (
<box
width={width()}
@@ -1070,6 +1122,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
setContextMenu({
x: event.x,
@@ -1081,20 +1134,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
event.stopPropagation()
return
}
didDrag = false
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
}}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1)
setPreview({ sessionID: tab.sessionID, index: slot })
}}
onMouseDragEnd={release}
>
<TabPulse
enabled={animations()}
@@ -1140,8 +1183,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
selectable={false}
onMouseOver={() => setCloseHovered(true)}
onMouseOut={() => setCloseHovered(false)}
onMouseDown={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON || hovered() !== tab.sessionID) return
didDrag = false
event.stopPropagation()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) 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
@@ -1170,6 +1219,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event) => {
didDrag = false
setDragging(undefined)
if (event.button !== RIGHT_MOUSE_BUTTON) return
setContextMenu({ x: event.x, y: event.y })
event.preventDefault()
@@ -1177,6 +1228,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
if (suppressClick) return
tabs.add?.()
}}
>