Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton 722e0a04b2 feat(tui): add centered session width 2026-08-11 11:59:12 -04:00
21 changed files with 168 additions and 283 deletions
@@ -69,63 +69,6 @@ describe("v2 session reducer", () => {
})
})
test("prefers durable selection predecessors and derives them for older events", () => {
const source: SessionMessageInfo[] = [
{ id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } },
{
id: "msg_previous_model",
type: "model-switched",
model: { id: "old", providerID: "provider" },
time: { created: 1 },
},
]
const reducer = createV2SessionReducer()
const agent = reducer.reduce(
source,
event({
...base,
id: "evt_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan", previous: "review" },
}),
)
const model = reducer.reduce(
source,
event({
...base,
id: "evt_model",
type: "session.model.selected",
data: {
sessionID: "ses_1",
model: { id: "new", providerID: "provider" },
previous: { id: "durable", providerID: "provider" },
},
}),
)
const legacyAgent = reducer.reduce(
source,
event({
...base,
id: "evt_legacy_agent",
type: "session.agent.selected",
data: { sessionID: "ses_1", agent: "plan" },
}),
)
expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" })
expect(model?.messages.at(-1)).toMatchObject({
type: "model-switched",
model: { id: "new" },
previous: { id: "durable" },
})
expect(legacyAgent?.messages.at(-1)).toMatchObject({
type: "agent-switched",
agent: "plan",
previous: "build",
})
})
test("folds tool, retry, and completion events", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -61,12 +61,6 @@ export function createV2SessionReducer() {
type: "agent-switched",
metadata: event.metadata,
agent: event.data.agent,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "agent-switched" | "assistant" }> =>
item.type === "agent-switched" || item.type === "assistant",
)?.agent,
time: { created: event.created },
})
case "session.model.selected":
@@ -75,12 +69,10 @@ export function createV2SessionReducer() {
type: "model-switched",
metadata: event.metadata,
model: event.data.model,
previous:
event.data.previous ??
source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
previous: source.findLast(
(item): item is Extract<SessionMessageInfo, { type: "model-switched" | "assistant" }> =>
item.type === "model-switched" || item.type === "assistant",
)?.model,
time: { created: event.created },
})
case "session.synthetic":
+2 -10
View File
@@ -339,11 +339,7 @@ export type Endpoint5_31Output =
readonly type: "session.agent.selected"
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 agent: Agent.ID
readonly previous?: Agent.ID | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID }
}
| {
readonly id: Event.ID
@@ -352,11 +348,7 @@ export type Endpoint5_31Output =
readonly type: "session.model.selected"
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 model: Model.Ref
readonly previous?: Model.Ref | undefined
}
readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref }
}
| {
readonly id: Event.ID
@@ -436,7 +436,7 @@ export type SessionAgentSelected = {
type: "session.agent.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; agent: string; previous?: string }
data: { sessionID: string; agent: string }
}
export type SessionModelSelected = {
@@ -446,7 +446,7 @@ export type SessionModelSelected = {
type: "session.model.selected"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; model: ModelRef; previous?: ModelRef }
data: { sessionID: string; model: ModelRef }
}
export type SessionMoved = {
+2 -11
View File
@@ -7,7 +7,7 @@ import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { SessionMessage } from "../session/message"
import { SessionSchema } from "../session/schema"
import { KVTable } from "../kv/sql"
import { EventSequenceTable } from "../event/sql"
import { EventSequenceTable, EventTable } from "../event/sql"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
@@ -161,7 +161,6 @@ type NextMessage = {
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info)
const decodePart = Schema.decodeUnknownOption(SessionV1.Part)
@@ -486,15 +485,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
yield* db
.transaction((tx) =>
Effect.gen(function* () {
while (true) {
yield* tx.run(sql`
DELETE FROM event
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
`)
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
if (deleted < EVENT_DELETE_BATCH_SIZE) break
yield* Effect.yieldNow
}
yield* tx.delete(EventTable).run()
yield* tx
.insert(KVTable)
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
+1 -3
View File
@@ -716,11 +716,10 @@ const layer = Layer.effect(
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
}),
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
const session = yield* result.get(input.sessionID)
yield* result.get(input.sessionID)
yield* bus.publish(SessionEvent.AgentSelected, {
sessionID: input.sessionID,
agent: input.agent,
previous: session.agent,
})
}),
switchModel: Effect.fn("Session.switchModel")(function* (input) {
@@ -734,7 +733,6 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.ModelSelected, {
sessionID: input.sessionID,
model: input.model,
previous: session.model,
})
}),
rename: Effect.fn("Session.rename")(function* (input) {
+2 -2
View File
@@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getAgent())
const previous = yield* adapter.getAgent()
yield* adapter.appendMessage(
SessionMessage.AgentSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
@@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.model.selected": (event) => {
return Effect.gen(function* () {
const previous = event.data.previous ?? (yield* adapter.getModel())
const previous = yield* adapter.getModel()
yield* adapter.appendMessage(
SessionMessage.ModelSelected.make({
id: SessionMessage.ID.fromEvent(event.id),
+1 -3
View File
@@ -21,9 +21,7 @@ Usage notes:
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
export const Input = Schema.Struct({
questions: Schema.Array(Question.Prompt)
.check(Schema.isNonEmpty())
.annotate({ description: "Questions to ask" }),
questions: Schema.NonEmptyArray(Question.Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
+3 -11
View File
@@ -654,7 +654,7 @@ describe("Session.create", () => {
expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" })
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)),
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }])
).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }])
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "agent-switched", agent: "plan", previous: "build" },
])
@@ -678,12 +678,7 @@ describe("Session.create", () => {
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const previous = Model.Ref.make({
id: Model.ID.make("haiku"),
providerID: Provider.ID.anthropic,
variant: Model.VariantID.make("default"),
})
const created = yield* session.create({ location, model: previous })
const created = yield* session.create({ location })
const model = Model.Ref.make({
id: Model.ID.make("sonnet"),
providerID: Provider.ID.anthropic,
@@ -697,10 +692,7 @@ describe("Session.create", () => {
yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect),
)
expect(bus).toMatchObject([{ type: "session.model.selected" }])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous })
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([
{ type: "model-switched", model, previous },
])
expect(bus[0]?.data).toEqual({ sessionID: created.id, model })
}),
)
-24
View File
@@ -89,30 +89,6 @@ const it = testEffect(
)
describe("QuestionTool", () => {
it.effect("emits one item schema for the nonempty questions array", () =>
Effect.gen(function* () {
captured = undefined
const registry = yield* Tool.Service
const definition = (yield* toolDefinitions(registry)).find((tool) => tool.name === QuestionTool.name)
expect(definition?.inputSchema).toHaveProperty("properties.questions.type", "array")
expect(definition?.inputSchema).toHaveProperty("properties.questions.minItems", 1)
expect(definition?.inputSchema).toHaveProperty("properties.questions.items")
expect(definition?.inputSchema).not.toHaveProperty("properties.questions.prefixItems")
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-question-empty", name: QuestionTool.name, input: { questions: [] } },
}),
).toMatchObject({
status: "error",
error: { type: "tool.execution", message: expect.stringContaining("Invalid tool input") },
})
expect(capturedInput()).toBeUndefined()
}),
)
it.effect("omits a catalog-denied question and enforces its leaf permission", () =>
Effect.gen(function* () {
captured = undefined
+1 -30
View File
@@ -11,7 +11,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect, Fiber, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { eq, sql } from "drizzle-orm"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { tmpdir } from "./fixture/tmpdir"
@@ -798,35 +798,6 @@ describe("V1Migration database workflow", () => {
)
})
test("yields while clearing stale events in batches", async () => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('stale', 2500)`)
yield* db.run(sql`
WITH RECURSIVE rows(value) AS (
VALUES(1)
UNION ALL
SELECT value + 1 FROM rows WHERE value < 2500
)
INSERT INTO event (id, aggregate_id, seq, created, type, data)
SELECT printf('event_%04d', value), 'stale', value, 1, 'session.renamed.1', '{}'
FROM rows
`)
let yielded = false
const heartbeat = yield* Effect.yieldNow.pipe(
Effect.andThen(Effect.sync(() => (yielded = true))),
Effect.forkChild({ startImmediately: true }),
)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
expect(yielded).toBe(true)
yield* Fiber.join(heartbeat)
expect(yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM event`)).toEqual({ value: 0 })
}),
)
})
test("imports previous V2 sessions and messages as part of the migration", async () => {
await using tmp = await tmpdir()
const filename = path.join(tmp.path, "opencode-next.db")
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
-2
View File
@@ -69,7 +69,6 @@ export const AgentSelected = Event.durable({
schema: {
...Base,
agent: Agent.ID,
previous: Agent.ID.pipe(optional),
},
})
export type AgentSelected = typeof AgentSelected.Type
@@ -80,7 +79,6 @@ export const ModelSelected = Event.durable({
schema: {
...Base,
model: Model.Ref,
previous: Model.Ref.pipe(optional),
},
})
export type ModelSelected = typeof ModelSelected.Type
@@ -60,6 +60,15 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["scroll bar"],
},
{
title: "Max width",
category: "Session",
path: ["session", "max_width"],
default: "auto",
values: ["auto", 80, 100, 120],
labels: ["auto", "80 columns", "100 columns", "120 columns"],
keywords: ["transcript", "composer", "centered", "reading width"],
},
{
title: "Thinking",
category: "Session",
+5
View File
@@ -125,6 +125,11 @@ export const Info = Schema.Struct({
description: "Session sidebar visibility; 'auto' shows it when space permits",
}),
scrollbar: Schema.optional(Schema.Boolean).annotate({ description: "Show the session transcript scrollbar" }),
max_width: Schema.optional(
Schema.Union([Schema.Int.check(Schema.isGreaterThan(4)), Schema.Literal("auto")]),
).annotate({
description: "Session transcript and composer max width, or 'auto' to use the available width",
}),
thinking: Schema.optional(Schema.Literals(["show", "hide"])).annotate({
description: "Show or hide model reasoning by default",
}),
+102 -97
View File
@@ -68,7 +68,7 @@ import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { sessionContentWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
import { projectedPromptInput } from "../../prompt/codec"
import { deduplicateVisibleImages } from "../../prompt/attachment"
import { useEpilogue } from "../../context/epilogue"
@@ -225,6 +225,7 @@ export function Session() {
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
const showThinking = createMemo(() => true)
const showScrollbar = createMemo(() => config.session?.scrollbar ?? false)
const maxWidth = createMemo(() => config.session?.max_width ?? "auto")
const markdownMode = createMemo(() => config.session?.markdown ?? "rendered")
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
@@ -243,7 +244,7 @@ export function Session() {
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => availableWidth() - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => sessionContentWidth(availableWidth(), sidebarVisible(), maxWidth()))
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
@@ -1037,103 +1038,107 @@ export function Session() {
}}
>
<box flexDirection="row" flexGrow={1} minHeight={0}>
<box
flexGrow={1}
minHeight={0}
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
<box flexGrow={1} minHeight={0} alignItems="center">
<box
width="100%"
maxWidth={maxWidth() === "auto" ? undefined : maxWidth()}
flexGrow={1}
minHeight={0}
paddingBottom={1}
paddingLeft={dimensions().width < 44 ? 1 : 2}
paddingRight={dimensions().width < 44 ? 1 : 2}
gap={1}
>
<Show when={session()}>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
trackOptions: {
backgroundColor: theme.raise(theme.background.surface.offset),
foregroundColor: theme.border.default,
},
}}
stickyScroll={!navigationMessage()}
stickyStart="bottom"
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
>
<For each={visibleRows()}>
{(row, index) => (
<SessionRowView
row={row}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
boundaryID={boundaries()[index() + hidden()]}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
/>
)}
</For>
<BackgroundToolHint messages={messages()} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={messagesFromRevert().filter((message) => message.type === "user").length}
files={session()!.revert!.files ?? []}
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
/>
</Show>
<Show when={navigationSlack()}>
{(height) => <box id={NAVIGATION_SLACK_ID} height={height()} flexShrink={0} />}
</Show>
</scrollbox>
<box flexShrink={0}>
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
</Show>
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
<Composer
sessionID={route.sessionID}
open={composer.open || (!!session()?.parentID && forms().length === 0)}
defaultTab={composer.tab ?? (session()?.parentID ? "subagents" : undefined)}
onClose={() => setComposer("open", false)}
/>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={promptedPermissions().length > 0}>
<Show when={promptedPermissions()[0]?.id} keyed>
{(_) => {
const request = promptedPermissions()[0]
return request ? (
<PermissionPrompt request={request} directory={session()?.location.directory} />
) : null
}}
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
<Match when={!disabled()}>
<Prompt
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
</Show>
<Switch>
<Match when={composer.open || (!!session()?.parentID && forms().length === 0)}>{null}</Match>
<Match when={promptedPermissions().length > 0}>
<Show when={promptedPermissions()[0]?.id} keyed>
{(_) => {
const request = promptedPermissions()[0]
return request ? (
<PermissionPrompt request={request} directory={session()?.location.directory} />
) : null
}}
</Show>
</Match>
<Match when={forms().length > 0}>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
return form ? <FormPrompt form={form} /> : null
}}
</Show>
</Match>
<Match when={!disabled()}>
<Prompt
visible={true}
ref={bind}
disabled={false}
onSubmit={() => {
toBottom()
}}
onEmptySubmit={async () => {
const next = queuedPrompts()[0]
if (!next) return false
return mutatePending("steer", next.id)
}}
sessionID={route.sessionID}
/>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
<Show when={sidebarVisible()}>
<Switch>
+7
View File
@@ -1,6 +1,13 @@
export const SESSION_SIDEBAR_WIDTH = 42
const SESSION_CONTENT_MIN_WIDTH = 44
const SESSION_CONTENT_PADDING = 4
export function sessionTabsFitVertically(total: number) {
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
}
export function sessionContentWidth(total: number, sidebar: boolean, maxWidth: number | "auto" = "auto") {
const available = total - (sidebar ? SESSION_SIDEBAR_WIDTH : 0) - SESSION_CONTENT_PADDING
if (maxWidth === "auto") return available
return Math.max(1, Math.min(available, maxWidth - SESSION_CONTENT_PADDING))
}
+16
View File
@@ -27,6 +27,15 @@ test("validates the session tabs setting", () => {
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("validates the session max width setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ session: { max_width: "auto" } })).toEqual({ session: { max_width: "auto" } })
expect(decode({ session: { max_width: 100 } })).toEqual({ session: { max_width: 100 } })
expect(() => decode({ session: { max_width: 4 } })).toThrow()
expect(() => decode({ session: { max_width: 100.5 } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
const config = resolve(
{
@@ -53,6 +62,13 @@ test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
})
test("shows session max width presets in settings", () => {
const setting = settings.find((setting) => setting.path.join(".") === "session.max_width")
expect(setting?.default).toBe("auto")
expect(setting?.values).toEqual(["auto", 80, 100, 120])
})
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
+11 -1
View File
@@ -1,8 +1,18 @@
import { expect, test } from "bun:test"
import { sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
import { sessionContentWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "../../src/ui/layout"
test("vertical tabs match the session sidebar and preserve compact content width", () => {
expect(SESSION_SIDEBAR_WIDTH).toBe(42)
expect(sessionTabsFitVertically(86)).toBe(true)
expect(sessionTabsFitVertically(85)).toBe(false)
})
test("session content uses available width by default", () => {
expect(sessionContentWidth(160, false)).toBe(156)
expect(sessionContentWidth(160, true)).toBe(114)
})
test("session content caps wide sessions and preserves narrow sessions", () => {
expect(sessionContentWidth(160, false, 100)).toBe(96)
expect(sessionContentWidth(80, false, 100)).toBe(76)
})
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],
-6
View File
@@ -14373,9 +14373,6 @@
},
"agent": {
"type": "string"
},
"previous": {
"type": "string"
}
},
"required": ["sessionID", "agent"],
@@ -14444,9 +14441,6 @@
},
"model": {
"$ref": "#/components/schemas/Model.Ref"
},
"previous": {
"$ref": "#/components/schemas/Model.Ref"
}
},
"required": ["sessionID", "model"],