Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Klee f870e771e9 tui: simplify prompt input sync
Drop the burst-aware key interceptor that tried to keep derived
prompt state current for every control binding. Frame-batch content
updates only, and let exit, clear, stash, and autocomplete read or
flush live textarea state at the command boundary instead.
2026-07-30 20:42:51 +02:00
Simon Klee 9f72479515 tui: flush prompt sync before commands
Coalescing burst text to one frame update hid pending
input from submit and other prompt keys. Flush before
bound commands so they see the full text while plain
typing stays frame-batched.
2026-07-30 20:42:51 +02:00
Simon Klee 0fc1d61fdc tui: coalesce prompt sync on text bursts
Per-keystroke store, autocomplete, and extmark work made large
stdin paste bursts stall the input path. Defer one microtask
sync per burst and skip mention scanning when text has no @.
2026-07-30 20:42:50 +02:00
40 changed files with 179 additions and 778 deletions
@@ -153,42 +153,4 @@ describe("v2 session reducer", () => {
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
test("forgets withdrawn input before a later promotion", () => {
const reducer = createV2SessionReducer()
reducer.reduce(
[],
event({
...base,
id: "evt_admitted",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
}),
)
reducer.reduce(
[],
event({
...base,
id: "evt_withdrawn",
type: "session.input.withdrawn",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
[],
event({
...base,
id: "evt_promoted",
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] })
})
})
@@ -29,9 +29,6 @@ export function createV2SessionReducer() {
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.input.withdrawn":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
+1 -19
View File
@@ -370,15 +370,6 @@ export type Endpoint5_26Output =
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.withdrawn"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -850,12 +841,6 @@ export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_29Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_29Input) => Effect.Effect<Endpoint5_29Output, E>
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_30Output = boolean
export type SessionPendingWithdrawOperation<E = never> = (
input: Endpoint5_30Input,
) => Effect.Effect<Endpoint5_30Output, E>
export interface SessionApi<E = never> {
readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E>
@@ -880,10 +865,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly withdraw: SessionPendingWithdrawOperation<E>
}
readonly pending: { readonly list: SessionPendingListOperation<E> }
readonly instructions: {
readonly entry: {
readonly list: SessionInstructionsEntryListOperation<E>
+1 -11
View File
@@ -76,8 +76,6 @@ import type {
Endpoint5_28Output,
Endpoint5_29Input,
Endpoint5_29Output,
Endpoint5_30Input,
Endpoint5_30Output,
Endpoint6_0Input,
Endpoint6_0Output,
Endpoint7_0Input,
@@ -552,14 +550,6 @@ const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29I
),
)
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
preserveEffect<Endpoint5_30Output>()(
raw["session.pending.withdraw"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw),
create: Endpoint5_1(raw),
@@ -580,7 +570,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_16(raw),
revert: { stage: Endpoint5_17(raw), clear: Endpoint5_18(raw), commit: Endpoint5_19(raw) },
context: Endpoint5_20(raw),
pending: { list: Endpoint5_21(raw), withdraw: Endpoint5_30(raw) },
pending: { list: Endpoint5_21(raw) },
instructions: { entry: { list: Endpoint5_22(raw), put: Endpoint5_23(raw), remove: Endpoint5_24(raw) } },
generate: Endpoint5_25(raw),
log: Endpoint5_26(raw),
@@ -70,8 +70,6 @@ import type {
SessionBackgroundOutput,
SessionMessageInput,
SessionMessageOutput,
SessionPendingWithdrawInput,
SessionPendingWithdrawOutput,
MessageListInput,
MessageListOutput,
ModelListInput,
@@ -734,17 +732,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
withdraw: (input: SessionPendingWithdrawInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingWithdrawOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/withdraw`,
successStatus: 200,
declaredStatuses: [404, 400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
instructions: {
entry: {
@@ -637,16 +637,6 @@ export type SessionInputPromoted = {
data: { sessionID: string; inputID: string }
}
export type SessionInputWithdrawn = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.withdrawn"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
id: string
created: number
@@ -2167,7 +2157,6 @@ export type SessionEventDurable =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2263,7 +2252,6 @@ export type V2Event =
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputWithdrawn
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -3219,13 +3207,6 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionPendingWithdrawInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionPendingWithdrawOutput = { data: boolean }["data"]
export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: {
+3 -11
View File
@@ -300,7 +300,7 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.pending uses the public HTTP contract", async () => {
test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -317,22 +317,14 @@ test("session.pending uses the public HTTP contract", async () => {
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push({ method: request.method, url: request.url })
return Response.json({ data: request.method === "GET" ? pending : true })
return Response.json({ data: pending })
},
})
const result = await client.session.pending.list({ sessionID: "ses_test" })
const withdrawn = await client.session.pending.withdraw({ sessionID: "ses_test", inputID: "msg_pending" })
expect(result).toEqual(pending)
expect(withdrawn).toBe(true)
expect(requests).toEqual([
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" },
{
method: "POST",
url: "http://localhost:3000/api/session/ses_test/pending/msg_pending/withdraw",
},
])
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("event.subscribe exposes the Promise event stream wire projection", async () => {
-8
View File
@@ -209,10 +209,6 @@ export interface Interface {
* unhandled compaction barriers.
*/
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly withdraw: (input: {
sessionID: SessionSchema.ID
inputID: SessionMessage.ID
}) => Effect.Effect<boolean, NotFoundError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -544,10 +540,6 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* SessionPending.list(db, sessionID)
}),
withdraw: Effect.fn("Session.withdraw")(function* (input) {
yield* result.get(input.sessionID)
return yield* SessionPending.withdraw(db, bus, input)
}),
log: (input) =>
Stream.unwrap(
result
@@ -175,7 +175,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
"session.forked": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.withdrawn": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
+22 -108
View File
@@ -1,6 +1,6 @@
export * as SessionPending from "./pending"
import { and, asc, eq, or, sql } from "drizzle-orm"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
@@ -38,15 +38,10 @@ const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeAdmittedEvent = Schema.decodeUnknownOption(SessionEvent.InputAdmitted.data)
const decodeWithdrawnEvent = Schema.decodeUnknownOption(SessionEvent.InputWithdrawn.data)
const admittedEventType = Bus.versionedType(
SessionEvent.InputAdmitted.type,
SessionEvent.InputAdmitted.durable.version,
)
const withdrawnEventType = Bus.versionedType(
SessionEvent.InputWithdrawn.type,
SessionEvent.InputWithdrawn.durable.version,
)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
@@ -108,38 +103,6 @@ export const compaction = Effect.fn("SessionPending.compaction")(function* (
return entry.type === "compaction" ? entry : undefined
})
const admittedFromHistory = Effect.fn("SessionPending.admittedFromHistory")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, admittedEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
if (row) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id)
return yield* Effect.die(new LifecycleConflict({ id }))
const base = { id, sessionID, timeCreated: DateTime.makeUnsafe(row.created) }
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
})
/**
* Reconstruct the admitted record for a pending row that was already consumed
* by promotion. The projected `session_message` row proves promotion happened;
@@ -160,30 +123,27 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
if (message === undefined) return undefined
if (message.session_id !== sessionID || (message.type !== "user" && message.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
return yield* admittedFromHistory(db, sessionID, id)
})
const wasWithdrawn = Effect.fn("SessionPending.wasWithdrawn")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
) {
const row = yield* db
.select({ data: EventTable.data })
const rows = yield* db
.select()
.from(EventTable)
.where(
and(
eq(EventTable.aggregate_id, sessionID),
eq(EventTable.type, withdrawnEventType),
sql`json_extract(${EventTable.data}, '$.inputID') = ${id}`,
),
)
.limit(1)
.get()
.where(and(eq(EventTable.aggregate_id, sessionID), eq(EventTable.type, admittedEventType)))
.all()
.pipe(Effect.orDie)
if (!row) return false
const decoded = decodeWithdrawnEvent(row.data)
return decoded._tag === "Some" && decoded.value.inputID === id
for (const row of rows) {
const decoded = decodeAdmittedEvent(row.data)
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
const base = {
id,
sessionID,
timeCreated: DateTime.makeUnsafe(row.created),
}
return decoded.value.input.type === "user"
? User.make({ ...base, ...decoded.value.input })
: Synthetic.make({ ...base, ...decoded.value.input })
}
// A projected message without an admitted event in this aggregate (for
// example fork-copied history) is not a retryable admission.
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionPending.admit")(function* (
@@ -202,8 +162,6 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
}
const promoted = yield* promotedFromHistory(db, request.sessionID, request.id)
if (promoted !== undefined) return promoted
if (yield* wasWithdrawn(db, request.sessionID, request.id))
return yield* admittedFromHistory(db, request.sessionID, request.id)
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
@@ -351,13 +309,14 @@ export const projectCompactionAdmitted = Effect.fn("SessionPending.projectCompac
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
@@ -370,27 +329,6 @@ const consumeInput = Effect.fn("SessionPending.consumeInput")(function* (
return stored
})
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return yield* consumeInput(db, input)
})
export const projectWithdrawn = Effect.fn("SessionPending.projectWithdrawn")(function* (
db: DatabaseService,
input: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
},
) {
return yield* consumeInput(db, input)
})
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
@@ -468,30 +406,6 @@ export const equivalent = (
return false
}
export const withdraw = Effect.fn("SessionPending.withdraw")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const pending = yield* find(db, input.inputID)
if (!pending) return yield* wasWithdrawn(db, input.sessionID, input.inputID)
if (pending.sessionID !== input.sessionID || pending.type === "compaction") return false
yield* bus
.publish(SessionEvent.InputWithdrawn, input)
.pipe(
Effect.catchDefect((defect) =>
wasWithdrawn(db, input.sessionID, input.inputID).pipe(
Effect.flatMap((withdrawn) => (withdrawn ? Effect.void : Effect.die(defect))),
),
),
)
return true
}),
)
})
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
-6
View File
@@ -665,12 +665,6 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InputWithdrawn, (event) =>
SessionPending.projectWithdrawn(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
-46
View File
@@ -996,52 +996,6 @@ describe("Session.pending", () => {
}),
)
it.effect("withdraws an interrupted input before promotion without resurrecting exact retries", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const admitted = yield* session.prompt({
id: SessionMessage.ID.make("msg_withdrawn"),
sessionID,
text: "Withdraw me",
resume: false,
})
yield* session.interrupt(sessionID)
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(true)
const retried = yield* session.prompt({
id: admitted.id,
sessionID,
text: "Withdraw me",
resume: false,
})
expect(retried.id).toBe(admitted.id)
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputWithdrawn.type, 1))).toBe(1)
}),
)
it.effect("leaves promoted input for revert when withdrawal loses the race", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const admitted = yield* session.prompt({ sessionID, text: "Promote me", resume: false })
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.withdraw({ sessionID, inputID: admitted.id })).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([{ id: admitted.id, type: "user" }])
}),
)
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
-16
View File
@@ -628,22 +628,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
}),
),
)
.add(
HttpApiEndpoint.post("session.pending.withdraw", "/api/session/:sessionID/pending/:inputID/withdraw", {
params: { sessionID: Session.ID, inputID: SessionMessage.ID },
success: Schema.Struct({ data: Schema.Boolean }),
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.pending.withdraw",
summary: "Withdraw pending session input",
description:
"Withdraw one admitted input before promotion. Returns true when the input was withdrawn or had already been withdrawn, and false when it is no longer pending so callers can fall back to reverting projected history.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "session",
-11
View File
@@ -155,16 +155,6 @@ export const InputAdmitted = Event.durable({
})
export type InputAdmitted = typeof InputAdmitted.Type
export const InputWithdrawn = Event.durable({
type: "session.input.withdrawn",
...options,
schema: {
...Base,
inputID: SessionMessage.ID,
},
})
export type InputWithdrawn = typeof InputWithdrawn.Type
export namespace Execution {
export const Started = Event.durable({ type: "session.execution.started", ...options, schema: Base })
export type Started = typeof Started.Type
@@ -566,7 +556,6 @@ export const Definitions = Event.inventory(
Forked,
InputPromoted,
InputAdmitted,
InputWithdrawn,
Execution.Started,
Execution.Succeeded,
Execution.Failed,
@@ -108,7 +108,6 @@ describe("public event manifest", () => {
"session.forked.2",
"session.input.promoted.1",
"session.input.admitted.1",
"session.input.withdrawn.1",
"session.execution.started.1",
"session.execution.succeeded.1",
"session.execution.failed.1",
-17
View File
@@ -608,23 +608,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
}
}),
)
.handle(
"session.pending.withdraw",
Effect.fn(function* (ctx) {
return {
data: yield* session.withdraw(ctx.params).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
sessionID: error.sessionID,
message: `Session not found: ${error.sessionID}`,
}),
),
),
),
}
}),
)
.handle(
"session.instructions.entry.list",
Effect.fn(function* (ctx) {
@@ -41,7 +41,6 @@ export const create = Effect.fn("SimulationRenderer.create")(function* (
...options,
width: cols,
height: rows,
kittyKeyboard: Boolean(options.useKittyKeyboard),
...(recording
? {
stdout: recording as unknown as NodeJS.WriteStream,
-19
View File
@@ -43,25 +43,6 @@ test("normalizes named keys for OpenTUI", async () => {
])
})
test("headless input mirrors the configured kitty keyboard protocol", async () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const renderer = yield* SimulationRenderer.create({ useKittyKeyboard: {} })
const harness = createHarness(renderer)
let key: { readonly name: string; readonly source: string } | undefined
renderer.keyInput.once("keypress", (event) => {
key = event
})
yield* execute(harness, { type: "ui.press", key: "escape" })
expect(key).toMatchObject({ name: "escape", source: "kitty" })
}),
),
)
})
test("clicks a target at relative coordinates through descendant text", async () => {
await Effect.runPromise(
Effect.scoped(
+16 -32
View File
@@ -15,6 +15,7 @@ import {
MouseButton,
type CliRenderer,
type CliRendererConfig,
type KeyEvent,
type ThemeMode,
} from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
@@ -105,7 +106,6 @@ const sessionTabBindingCommands = [
"session.tab.next_unread",
"session.tab.previous_unread",
"session.tab.close",
"session.tab.reopen",
"session.tab.select.1",
"session.tab.select.2",
"session.tab.select.3",
@@ -669,7 +669,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})),
{
name: "session.tab.next",
title: "Next tab",
title: "Next open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -677,7 +677,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous",
title: "Previous tab",
title: "Previous open session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -685,7 +685,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.history.back",
title: "Back in tab history",
title: "Back in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -693,7 +693,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.history.forward",
title: "Forward in tab history",
title: "Forward in session tab history",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -701,7 +701,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.next_unread",
title: "Next unread tab",
title: "Next unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -709,7 +709,7 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.previous_unread",
title: "Previous unread tab",
title: "Previous unread session tab",
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -717,21 +717,14 @@ function App(props: { pair?: DialogPairCredentials }) {
},
{
name: "session.tab.close",
title: "Close tab",
title: "Close current session tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.close(),
},
{
name: "session.tab.reopen",
title: "Reopen closed tab",
category: "Session",
enabled: sessionTabs.enabled,
run: () => sessionTabs.reopen(),
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.tab.select.${i + 1}`,
title: `Switch to tab ${i + 1}`,
title: `Switch to session tab ${i + 1}`,
category: "Session",
palette: undefined,
enabled: sessionTabs.enabled,
@@ -970,7 +963,11 @@ function App(props: { pair?: DialogPairCredentials }) {
name: "app.exit",
title: "Exit the app",
slash: { name: "exit", aliases: ["quit", "q"] },
run: () => exit(),
run: (_input: string | undefined, event?: KeyEvent) => {
const current = promptRef.current
if (event?.sequence && current?.focused && !current.empty) return false
exit()
},
category: "System",
},
{
@@ -1126,14 +1123,7 @@ function App(props: { pair?: DialogPairCredentials }) {
bindings: pinnedSessionBindingCommands,
}))
Keymap.createLayer(() => ({
enabled: () => {
const current = promptRef.current
if (!current?.focused) return true
return current.current.text === ""
},
bindings: ["app.exit"],
}))
Keymap.createLayer(() => ({ bindings: ["app.exit"] }))
event.on("tui.command.execute", (evt, { workspace }) => {
if (workspace !== (location.current?.workspaceID ?? data.location.default().workspaceID)) return
@@ -1227,13 +1217,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show
when={
sessionTabs.enabled() &&
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
route.data.type !== "plugin"
}
>
<Show when={sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"}>
<SessionTabs />
</Show>
<Switch>
@@ -69,15 +69,10 @@ export function Autocomplete(props: {
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
let popMode: (() => void) | undefined
const [positionTick, setPositionTick] = createSignal(0)
createEffect(() => {
if (!store.visible) return
const popMode = keymap.mode.push("autocomplete")
onCleanup(popMode)
})
createEffect(() => {
if (store.visible) {
let lastPos = { x: 0, y: 0, width: 0 }
@@ -271,7 +266,7 @@ export function Autocomplete(props: {
const { filename, part } = createFilePart({ path: item, type: "file" }, input.filePath, lineRange)
const index = store.visible === "@" ? store.index : props.input().cursorOffset
setStore("visible", false)
hide(false)
setStore("index", index)
insertPart(filename, part)
}
@@ -500,6 +495,7 @@ export function Autocomplete(props: {
function move(direction: -1 | 1) {
if (!store.visible) return
syncSearch()
if (!options().length) return
let next = store.selected + direction
if (next < 0) next = options().length - 1
@@ -520,6 +516,7 @@ export function Autocomplete(props: {
}
function select() {
syncSearch()
const selected = options()[store.selected]
if (!selected) return
hide()
@@ -591,6 +588,7 @@ export function Autocomplete(props: {
title: "Complete autocomplete item",
group: "Autocomplete",
run() {
syncSearch()
const selected = options()[store.selected]
if (selected?.isDirectory) {
expandDirectory()
@@ -604,15 +602,16 @@ export function Autocomplete(props: {
}))
function show(mode: "@" | "/") {
popMode ??= keymap.mode.push("autocomplete")
setStore({
visible: mode,
index: props.input().cursorOffset,
})
}
function hide() {
function hide(clear = true) {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
if (clear && store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
const cursor = props.input().logicalCursor
props.input().deleteRange(0, 0, cursor.row, cursor.col)
// Sync the prompt store immediately since onContentChange is async
@@ -621,6 +620,8 @@ export function Autocomplete(props: {
})
}
setStore("visible", false)
popMode?.()
popMode = undefined
}
onMount(() => {
@@ -632,35 +633,33 @@ export function Autocomplete(props: {
unsubscribeMention()
})
props.ref({
const ref = {
get visible() {
return store.visible
},
onInput(value) {
onInput(value?: string) {
if (!props.input().focused) return
if (store.visible) {
if (
// Typed text before the trigger
props.input().cursorOffset <= store.index ||
// There is a space between the trigger and the cursor
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/) ||
// "/<command>" is not the sole content
(store.visible === "/" && value.match(/^\S+\s+\S+\s*$/))
props.input().getTextRange(store.index, props.input().cursorOffset).match(/\s/)
) {
hide()
hide(false)
}
return
}
// Check if autocomplete should reopen (e.g., after backspace deleted a space)
const offset = props.input().cursorOffset
if (offset === 0) return
// Check for "/" at position 0 - reopen slash commands
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
const text = value ?? (props.input().getTextRange(0, 1) === "/" ? props.input().getTextRange(0, offset) : "")
if (text.startsWith("/") && !text.slice(0, offset).match(/\s/)) {
show("/")
setStore("index", 0)
return
}
if (value === undefined) return
// Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
const idx = mentionTriggerIndex(value, offset)
@@ -669,9 +668,22 @@ export function Autocomplete(props: {
setStore("index", idx)
}
},
}
props.ref(ref)
const stopInputSync = keymap.intercept("key", () => ref.onInput())
onCleanup(() => {
stopInputSync()
popMode?.()
})
})
function syncSearch() {
const next = props.input().getTextRange(store.index + 1, props.input().cursorOffset)
if (next === search()) return
setSearch(next)
setStore("selected", 0)
}
const height = createMemo(() => {
const count = options().length || 1
if (!store.visible) return Math.min(10, count)
+34 -10
View File
@@ -85,6 +85,7 @@ function pastedFilepath(value: string, platform: string) {
export type PromptRef = {
focused: boolean
empty: boolean
current: PromptInfo
set(prompt: PromptInfo): void
reset(): void
@@ -147,6 +148,7 @@ function argumentSlash(input: string, commands: readonly KeymapCommand[]) {
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
let anchor: BoxRenderable
let promptSyncQueued = false
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
const leader = Keymap.useLeaderActive()
@@ -346,6 +348,7 @@ export function Prompt(props: PromptProps) {
category: "Prompt",
palette: undefined,
run: () => {
if (input.getTextRange(0, 1) === "") return false
clearPrompt()
dialog.clear()
},
@@ -450,6 +453,7 @@ export function Prompt(props: PromptProps) {
name: "prompt.editor",
slash: { name: "editor" },
run: async () => {
if (promptSyncQueued) await flushPromptSync()
dialog.clear()
const editorPrompt = expandPromptInputPastedText(store.prompt, store.prompt.pasted)
@@ -542,6 +546,9 @@ export function Prompt(props: PromptProps) {
get focused() {
return input.focused
},
get empty() {
return input.getTextRange(0, 1) === ""
},
get current() {
return store.prompt
},
@@ -581,6 +588,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
if (promptSyncQueued) void flushPromptSync()
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -706,9 +714,9 @@ export function Prompt(props: PromptProps) {
title: "Stash prompt",
name: "prompt.stash",
category: "Prompt",
enabled: !!store.prompt.text,
run: () => {
if (!store.prompt.text) return
if (input.getTextRange(0, 1) === "") return false
void flushPromptSync()
stash.push({ prompt: store.prompt })
input.extmarks.clear()
input.clear()
@@ -779,7 +787,7 @@ export function Prompt(props: PromptProps) {
Keymap.createLayer(() => {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
enabled: () => inputTarget() !== undefined && !props.disabled,
bindings: ["prompt.clear"],
}
})
@@ -803,6 +811,10 @@ export function Prompt(props: PromptProps) {
title: "Shell mode",
group: "Prompt",
run: () => {
if (input.visualCursor.offset !== 0) {
input.insertText("!")
return
}
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
},
@@ -925,6 +937,7 @@ export function Prompt(props: PromptProps) {
}
async function submitInner() {
if (promptSyncQueued) await flushPromptSync()
// IME: double-defer may fire before onContentChange flushes the last
// composed character (e.g. Korean hangul) to the store, so read
// plainText directly and sync before any downstream reads.
@@ -1202,6 +1215,22 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function flushPromptSync() {
promptSyncQueued = false
renderer.removeFrameCallback(flushPromptSync)
if (!input || input.isDestroyed) return
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
}
function queuePromptSync() {
if (promptSyncQueued) return
promptSyncQueued = true
renderer.setFrameCallback(flushPromptSync)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
const currentOffset = input.cursorOffset
const extmarkStart = currentOffset
@@ -1243,6 +1272,7 @@ export function Prompt(props: PromptProps) {
}
function clearPrompt() {
if (promptSyncQueued) void flushPromptSync()
if (
store.prompt.text.trim().length >= DRAFT_RETENTION_MIN_CHARS ||
store.prompt.pasted.length > 0 ||
@@ -1357,13 +1387,7 @@ export function Prompt(props: PromptProps) {
focusedTextColor={leader() ? theme.text.subdued : theme.text.default}
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onContentChange={queuePromptSync}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
+4 -17
View File
@@ -9,7 +9,6 @@ import {
sessionTabComplete,
seedSessionTabMotion,
sessionTabOverflowWidth,
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { createAnimatable, spring, tween } from "../ui/animation"
@@ -25,19 +24,10 @@ type ContextController = ReturnType<typeof useSessionTabs>
export type SessionTabsStatus = Omit<ReturnType<ContextController["status"]>, "unread"> & {
unread: SessionTabUnread | undefined
}
export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = {
unread: undefined,
promptPulse: 0,
attention: false,
busy: false,
}
export type SessionTabsController = Pick<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
newTab?: () => boolean
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: "New session" }
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
const tabs = props.controller ?? useSessionTabs()
const dimensions = useTerminalDimensions()
@@ -52,9 +42,8 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
const accent = () => theme.hue.accent[hueStep()]
const activeNumber = () => theme.hue.interactive[hueStep()]
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
const newTab = () => tabs.newTab?.() ?? false
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs()))
const activeID = createMemo(tabs.current)
const items = tabs.tabs
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start),
)
@@ -62,7 +51,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
() =>
new Map(
layout().tabs.map((tab) => {
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
const status = tabs.status(tab.sessionID)
return [
tab.sessionID,
{
@@ -273,7 +262,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
// keeping sloppy clicks indistinguishable from clean ones.
const release = () => {
setDragging(undefined)
if (tab === NEW_SESSION_TAB) return
tabs.select(tab.sessionID)
}
return (
@@ -287,7 +275,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
onMouseDown={() => setDragging(tab.sessionID)}
onMouseUp={release}
onMouseDrag={(event) => {
if (tab === NEW_SESSION_TAB) return
const slot = slotAt(event.x)
if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot)
}}
@@ -336,7 +323,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
selectable={false}
onMouseUp={(event) => {
event.stopPropagation()
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
tabs.close(tab.sessionID)
}}
>
{hovered() === tab.sessionID ? "×" : ""}
+3 -3
View File
@@ -127,13 +127,13 @@ export const Info = Schema.Struct({
tabs: Schema.optional(
Schema.Struct({
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Use a persistent tab strip instead of pinned quick-switch sessions",
description: "Use a persistent session tab strip instead of pinned quick-switch sessions",
}),
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
description: "Share tabs globally or keep a separate set for each working directory",
description: "Share session tabs globally or keep a separate set for each working directory",
}),
}),
).annotate({ description: "Tab strip settings" }),
).annotate({ description: "Session tab settings" }),
mini: Schema.optional(
Schema.Struct({
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
+16 -18
View File
@@ -87,14 +87,13 @@ 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,alt+shift+]", "Switch to next open tab"),
session_tab_previous: keybind("ctrl+shift+tab,<leader>left,alt+shift+[", "Switch to previous open tab"),
session_tab_history_back: keybind("ctrl+o", "Go back in tab history"),
session_tab_history_forward: keybind("ctrl+i", "Go forward in tab history"),
session_tab_next_unread: keybind("<leader>down", "Switch to next unread tab"),
session_tab_previous_unread: keybind("<leader>up", "Switch to previous unread tab"),
session_tab_close: keybind("<leader>w", "Close current tab"),
session_tab_reopen: keybind("ctrl+shift+t", "Reopen last closed tab"),
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"),
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_close: keybind("<leader>w", "Close current 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"),
@@ -119,15 +118,15 @@ export const Definitions = {
session_quick_switch_7: keybind("<leader>7", "Switch to session in quick slot 7"),
session_quick_switch_8: keybind("<leader>8", "Switch to session in quick slot 8"),
session_quick_switch_9: keybind("<leader>9", "Switch to session in quick slot 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to tab 6"),
session_tab_select_7: keybind("<leader>7,ctrl+7", "Switch to tab 7"),
session_tab_select_8: keybind("<leader>8,ctrl+8", "Switch to tab 8"),
session_tab_select_9: keybind("<leader>9,ctrl+9", "Switch to tab 9"),
session_tab_select_1: keybind("<leader>1,ctrl+1", "Switch to session tab 1"),
session_tab_select_2: keybind("<leader>2,ctrl+2", "Switch to session tab 2"),
session_tab_select_3: keybind("<leader>3,ctrl+3", "Switch to session tab 3"),
session_tab_select_4: keybind("<leader>4,ctrl+4", "Switch to session tab 4"),
session_tab_select_5: keybind("<leader>5,ctrl+5", "Switch to session tab 5"),
session_tab_select_6: keybind("<leader>6,ctrl+6", "Switch to session tab 6"),
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"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
@@ -298,7 +297,6 @@ export const CommandMap = {
session_tab_next_unread: "session.tab.next_unread",
session_tab_previous_unread: "session.tab.previous_unread",
session_tab_close: "session.tab.close",
session_tab_reopen: "session.tab.reopen",
session_timeline: "session.timeline",
session_fork: "session.fork",
session_rename: "session.rename",
+2 -26
View File
@@ -188,12 +188,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
index.set(item.id, messages.length)
messages.push(item)
},
reindex(messages: SessionMessageInfo[], index: Map<string, number>, start: number) {
for (let position = start; position < messages.length; position++) {
const item = messages[position]
if (item) index.set(item.id, position)
}
},
activeAssistant(messages: SessionMessageInfo[]) {
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
return item?.type === "assistant" ? item : undefined
@@ -401,7 +395,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
existing.time.created = event.created
draft.splice(position, 1)
draft.push(existing)
message.reindex(draft, index, position)
index.clear()
draft.forEach((message, indexValue) => index.set(message.id, indexValue))
})
setStore(
"session",
@@ -443,25 +438,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
)
})
break
case "session.input.withdrawn": {
removePending(event.data.sessionID, event.data.inputID)
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
message.update(event.data.sessionID, (draft, index) => {
const position = index.get(event.data.inputID)
if (position === undefined) return
draft.splice(position, 1)
index.delete(event.data.inputID)
message.reindex(draft, index, position)
})
if (store.session.input[event.data.sessionID]?.includes(event.data.inputID))
setStore(
"session",
"input",
event.data.sessionID,
(store.session.input[event.data.sessionID] ?? []).filter((id) => id !== event.data.inputID),
)
break
}
case "session.instructions.updated":
const instructions = event.metadata?.instructions
if (
@@ -36,39 +36,6 @@ export function closeSessionTab(tabs: readonly SessionTab[], sessionID: string)
}
}
export type ClosedSessionTab = {
tab: SessionTab
index: number
}
const CLOSED_SESSION_TAB_LIMIT = 10
export function recordClosedSessionTab(
stack: readonly ClosedSessionTab[],
tab: SessionTab,
index: number,
): ClosedSessionTab[] {
return [...stack.filter((entry) => entry.tab.sessionID !== tab.sessionID), { tab, index }].slice(
-CLOSED_SESSION_TAB_LIMIT,
)
}
/**
* Pop the most recently closed tab that is not already open and restore it at its original
* position. Entries for already-open sessions are consumed so repeated reopens walk the stack.
*/
export function reopenSessionTab(stack: readonly ClosedSessionTab[], tabs: readonly SessionTab[]) {
const remaining = [...stack]
while (remaining.length > 0) {
const entry = remaining.pop()!
if (tabs.some((tab) => tab.sessionID === entry.tab.sessionID)) continue
const next = [...tabs]
next.splice(Math.min(entry.index, tabs.length), 0, entry.tab)
return { stack: remaining, tabs: next, sessionID: entry.tab.sessionID }
}
return { stack: remaining, tabs: undefined, sessionID: undefined }
}
export function moveSessionTab(tabs: SessionTab[], sessionID: string, index: number): SessionTab[] {
const from = tabs.findIndex((tab) => tab.sessionID === sessionID)
const to = Math.max(0, Math.min(tabs.length - 1, index))
+2 -28
View File
@@ -14,10 +14,7 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
type ClosedSessionTab,
type SessionTab,
type SessionTabHistory,
type SessionTabUnread,
@@ -60,8 +57,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
function state() {
if (config.tabs?.scope === "global") return store.global
@@ -198,8 +193,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
)
onCleanup(
event.on("session.deleted", (evt) => {
const target = root(evt.data.sessionID)
closedTabs = closedTabs.filter((entry) => entry.tab.sessionID !== target)
remove(evt.data.sessionID, enabled())
}),
)
@@ -232,9 +225,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
newTab() {
return route.data.type === "home"
},
current,
status,
select(sessionID: string) {
@@ -245,28 +235,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const target = sessionID ? root(sessionID) : current()
if (!target) {
const previous = moveSessionTabHistory(history, state().tabs, undefined, -1)
history = previous.history
const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID
if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session })
const previous = state().tabs.at(-1)
if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID })
return
}
const index = state().tabs.findIndex((tab) => tab.sessionID === target)
const tab = state().tabs[index]
if (tab) closedTabs = recordClosedSessionTab(closedTabs, tab, index)
remove(target, true)
},
reopen() {
if (!enabled()) return
const result = reopenSessionTab(closedTabs, state().tabs)
closedTabs = result.stack
const tabs = result.tabs
if (!tabs || !result.sessionID) return
update((draft) => {
draft.tabs = tabs
})
route.navigate({ type: "session", sessionID: result.sessionID })
},
move(sessionID: string, index: number) {
if (!enabled()) return
const session = root(sessionID)
@@ -2,11 +2,7 @@ import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import {
EMPTY_SESSION_TAB_STATUS,
SessionTabs,
type SessionTabsController,
} from "../../../component/session-tabs"
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
import type { Story } from "./index"
@@ -27,6 +23,7 @@ const FIXTURE_TABS = [
{ sessionID: "fixture-12", title: "Prepare review" },
]
const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false }
const RUN_DURATION = 1_800
const RESUME_DURATION = 900
@@ -69,7 +66,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
if (!resumed && roll < 0.25) {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), attention: true },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
}))
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
return
@@ -80,7 +77,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
}))
// An untitled session earns its title after its first completed run, like a real summarization.
const index = number(sessionID) - 1
@@ -113,7 +110,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
tabs,
current: active,
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
return statuses()[sessionID] ?? EMPTY_STATUS
},
select,
move(sessionID: string, index: number) {
@@ -153,7 +150,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const startRun = (sessionID: string) => {
setStatuses((current) => ({
...current,
[sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined },
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
}))
setOutcomes((current) => {
const next = { ...current }
@@ -226,7 +223,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
const selectedState = () => {
const current = active()
const status = current ? controller.status(current) : EMPTY_SESSION_TAB_STATUS
const status = current ? controller.status(current) : EMPTY_STATUS
const activity = status.busy
? "running"
: status.unread === "activity"
@@ -354,7 +351,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / tabs</text>
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
<box flexGrow={1} />
<text fg={elevatedTheme.text.subdued}>
space/s run | t add | d close | r reset | / 1-0 move | drag reorders | esc back
@@ -366,6 +363,6 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
export const sessionTabsStory: Story = {
id: "session-tabs",
title: "Tabs",
title: "Session tabs",
render: (context) => <SessionTabsStory context={context} />,
}
@@ -643,10 +643,6 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text)
return
}
if (event.type === "session.input.withdrawn") {
child.prompts.delete(event.data.inputID)
return
}
if (event.type === "session.input.promoted") {
const prompt = child.prompts.get(event.data.inputID)
if (prompt === undefined) return
@@ -911,12 +911,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
})
return
}
if (event.type === "session.input.withdrawn") {
state.admitted.delete(event.data.inputID)
state.pending.delete(event.data.inputID)
syncPending()
return
}
if (event.type === "session.input.promoted") {
const waiting = state.wait?.messageID === event.data.inputID
if (state.wait) promoteWait(state.wait, true, event.data.inputID)
+1 -3
View File
@@ -18,8 +18,6 @@ export const builtins = [
SidebarFooter,
Notifications,
Plugins,
// The storybook is a development tool; keep its route and palette commands out of
// normal launches and register it only for OPENCODE_STORY runs.
...(process.env.OPENCODE_STORY ? [Storybook] : []),
Storybook,
DiffViewer,
]
+3 -2
View File
@@ -37,8 +37,9 @@ export function displayCharAt(value: string, offset: number) {
}
}
export function mentionTriggerIndex(value: string, offset = promptOffsetWidth(value)) {
const text = displaySlice(value, 0, offset)
export function mentionTriggerIndex(value: string, offset?: number) {
if (!value.includes("@")) return
const text = displaySlice(value, 0, offset ?? promptOffsetWidth(value))
const index = text.lastIndexOf("@")
if (index === -1) return
@@ -8,7 +8,6 @@ import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import type { PromptInfo } from "../../prompt/history"
import { projectedPromptInput } from "../../prompt/codec"
import { undoMessage } from "./undo"
export function DialogMessage(props: {
messageID: string
@@ -43,10 +42,9 @@ export function DialogMessage(props: {
pasted: [],
})
}
void undoMessage(client.api, {
sessionID: props.sessionID,
messageID: props.messageID,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void client.api.session.revert
.stage({ sessionID: props.sessionID, messageID: props.messageID })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
dialog.clear()
},
},
+3 -5
View File
@@ -64,7 +64,6 @@ import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { projectedPromptInput } from "../../prompt/codec"
import { undoMessage } from "./undo"
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
@@ -591,10 +590,9 @@ export function Session() {
dialog.clear()
return
}
void undoMessage(client.api, {
sessionID: route.sessionID,
messageID: message.id,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
-11
View File
@@ -1,11 +0,0 @@
import type { OpenCodeClient } from "@opencode-ai/client"
export function undoMessage(
client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string },
) {
const revert = () => client.session.revert.stage(input).then(() => undefined)
return client.session.pending
.withdraw({ sessionID: input.sessionID, inputID: input.messageID })
.then((withdrawn) => (withdrawn ? undefined : revert()))
}
-59
View File
@@ -853,65 +853,6 @@ test("completes exploration when a queued prompt is promoted", async () => {
}
})
test("removes optimistic input when it is withdrawn", async () => {
const events = createEventStream()
const sessionID = "session-withdrawal"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
}, events)
let data!: ReturnType<typeof useData>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
emitEvent(events, {
id: "evt_prompt_admitted",
created: 1,
type: "session.input.admitted",
durable: durable(sessionID, 1),
data: {
sessionID,
inputID: "message-user",
input: { type: "user", data: { text: "Never mind" }, delivery: "steer" },
},
})
await wait(() => data.session.message.get(sessionID, "message-user") !== undefined)
expect(data.session.input.has(sessionID, "message-user")).toBe(true)
emitEvent(events, {
id: "evt_prompt_withdrawn",
created: 2,
type: "session.input.withdrawn",
durable: durable(sessionID, 2),
data: { sessionID, inputID: "message-user" },
})
await wait(() => data.session.message.get(sessionID, "message-user") === undefined)
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
expect(data.session.pending.list(sessionID)).toEqual([])
} finally {
app.renderer.destroy()
}
})
test("classifies live tool rows independently of their call ID", async () => {
const events = createEventStream()
const sessionID = "session-tool-call-id"
-26
View File
@@ -1,26 +0,0 @@
import { expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo"
test.each([
{ withdrawn: true, expected: ["withdraw"] },
{ withdrawn: false, expected: ["withdraw", "revert"] },
])("routes undo for withdrawn=$withdrawn", async ({ withdrawn, expected }) => {
const calls: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const operation = request.url.endsWith("/withdraw") ? "withdraw" : "revert"
calls.push(operation)
return Response.json({ data: operation === "withdraw" ? withdrawn : { messageID: "msg_user" } })
},
{ preconnect: fetch.preconnect },
),
})
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user" })
expect(calls).toEqual([...expected])
})
+2 -4
View File
@@ -106,10 +106,8 @@ test("opens the subagent picker with down", () => {
test("navigates session tabs with leader arrows", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right,alt+shift+]" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([
{ key: "ctrl+shift+tab,<leader>left,alt+shift+[" },
])
expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
@@ -6,9 +6,7 @@ import {
moveSessionTab,
moveSessionTabHistory,
openSessionTab,
recordClosedSessionTab,
recordSessionTabHistory,
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabOverflowWidth,
@@ -138,45 +136,6 @@ describe("session tabs", () => {
expect(moveSessionTabHistory(current, closed.tabs, "b", -1).sessionID).toBe("c")
})
test("reopens the most recently closed tab at its original position", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
const stack = recordClosedSessionTab([], { sessionID: "b", title: "Middle" }, 1)
const reopened = reopenSessionTab(stack, [{ sessionID: "a" }, { sessionID: "c" }])
expect(reopened.sessionID).toBe("b")
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b", title: "Middle" }, { sessionID: "c" }])
expect(reopened.stack).toEqual([])
expect(reopenSessionTab([], tabs)).toEqual({ stack: [], tabs: undefined, sessionID: undefined })
})
test("skips and consumes closed entries that are already open", () => {
const stack = [
{ tab: { sessionID: "a" }, index: 0 },
{ tab: { sessionID: "b" }, index: 1 },
]
const reopened = reopenSessionTab(stack, [{ sessionID: "b" }])
expect(reopened.sessionID).toBe("a")
expect(reopened.tabs).toEqual([{ sessionID: "a" }, { sessionID: "b" }])
expect(reopened.stack).toEqual([])
})
test("clamps restored positions and keeps one entry per session", () => {
const twice = recordClosedSessionTab(recordClosedSessionTab([], { sessionID: "a" }, 5), { sessionID: "a" }, 2)
expect(twice).toEqual([{ tab: { sessionID: "a" }, index: 2 }])
const reopened = reopenSessionTab(twice, [{ sessionID: "b" }])
expect(reopened.tabs).toEqual([{ sessionID: "b" }, { sessionID: "a" }])
const overflow = Array.from({ length: 12 }, (_, index) => ({ sessionID: String(index) })).reduce(
(stack, tab, index) => recordClosedSessionTab(stack, tab, index),
twice,
)
expect(overflow).toHaveLength(10)
expect(overflow.at(-1)?.tab.sessionID).toBe("11")
expect(overflow[0]?.tab.sessionID).toBe("2")
})
test("reveals completion activity only after session work becomes idle", () => {
expect(sessionTabComplete("activity", true)).toBe(false)
expect(sessionTabComplete("activity", false)).toBe(true)
@@ -192,12 +151,12 @@ describe("session tabs", () => {
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76)
})
test("reserves an active tab slot for the new session page", () => {
const tabs = ["a", "b", "c", "d", "new"].map((sessionID) => ({ sessionID }))
const layout = adaptiveSessionTabLayout(tabs, "new", 54)
test("does not reserve an active tab slot on the new session page", () => {
const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID }))
const layout = adaptiveSessionTabLayout(tabs, "dummy", 40)
expect(layout.tabs).toEqual(tabs)
expect(layout.widths).toEqual([8, 8, 8, 8, 22])
expect(layout.widths).toEqual([8, 8, 8, 8, 8])
expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total)
})
+19 -62
View File
@@ -24,7 +24,7 @@ async function wait(fn: () => boolean, timeout = 2_000) {
}
}
async function renderSessionTabs(initialSessionID: string) {
test("user prompt admissions pulse an already-busy background tab", async () => {
const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-"))
const events = createEventStream()
const calls = createFetch(undefined, events)
@@ -44,7 +44,7 @@ async function renderSessionTabs(initialSessionID: string) {
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<RouteProvider initialRoute={{ type: "session", sessionID: initialSessionID }}>
<RouteProvider initialRoute={{ type: "session", sessionID: "background" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<SessionTabsProvider>
@@ -59,20 +59,7 @@ async function renderSessionTabs(initialSessionID: string) {
</TestTuiContexts>
))
await wait(() => client.connection.status() === "connected")
return {
tabs,
route,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
destroy() {
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
},
}
}
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const emit = (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } })
const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({
id: `evt_${inputID}`,
created: Date.now(),
@@ -86,11 +73,13 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
})
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))
setup.route.navigate({ type: "session", sessionID: "active" })
await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2)
await wait(
() => client.connection.status() === "connected" && tabs.tabs().some((tab) => tab.sessionID === "background"),
)
route.navigate({ type: "session", sessionID: "active" })
await wait(() => tabs.current() === "active" && tabs.tabs().length === 2)
setup.emit({
emit({
id: "evt_context",
created: Date.now(),
type: "session.input.admitted",
@@ -102,52 +91,20 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
},
})
await Bun.sleep(20)
expect(setup.tabs.status("background").promptPulse).toBe(0)
expect(tabs.status("background").promptPulse).toBe(0)
setup.emit(admitted("background", "msg_1"))
await wait(
() => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy,
)
emit(admitted("background", "msg_1"))
await wait(() => tabs.status("background").promptPulse === 1 && tabs.status("background").busy)
setup.emit(admitted("background", "msg_2"))
await wait(() => setup.tabs.status("background").promptPulse === 2)
emit(admitted("background", "msg_2"))
await wait(() => tabs.status("background").promptPulse === 2)
setup.emit(admitted("active", "msg_3"))
emit(admitted("active", "msg_3"))
await Bun.sleep(20)
expect(setup.tabs.status("active").promptPulse).toBe(0)
expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
expect(tabs.status("active").promptPulse).toBe(0)
expect(tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true })
} finally {
setup.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "home" })
await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"])
setup.tabs.close()
await wait(() => setup.route.data.type === "session")
expect(setup.route.data).toEqual({ type: "session", sessionID: "first" })
setup.route.navigate({ type: "home" })
await wait(() => setup.tabs.newTab())
setup.route.navigate({ type: "session", sessionID: "third" })
await wait(
() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"),
)
expect(setup.tabs.newTab()).toBe(false)
} finally {
setup.destroy()
app.renderer.destroy()
rmSync(state, { recursive: true, force: true })
}
})