mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 12:10:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 722e0a04b2 |
@@ -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":
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -101,6 +101,16 @@ export const Plugin = define({
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("plan"), (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Plan mode. Disallows all edit tools."
|
||||
item.mode = "primary"
|
||||
item.permissions.push(
|
||||
{ action: "question", resource: "*", effect: "allow" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
)
|
||||
})
|
||||
|
||||
draft.update(Agent.ID.make("general"), (item) => {
|
||||
item.name = Agent.Name.make("General")
|
||||
item.description =
|
||||
|
||||
@@ -60,7 +60,6 @@ import { WellKnown } from "../wellknown"
|
||||
import { WriteTool } from "../tool/plugin/write"
|
||||
import { AgentPlugin } from "./agent"
|
||||
import { CommandPlugin } from "./command"
|
||||
import { PlanPlugin } from "./plan"
|
||||
import { ModelsDevPlugin } from "./models-dev"
|
||||
import { ProviderPlugins } from "./provider"
|
||||
import { WebSearchPlugins } from "./websearch"
|
||||
@@ -193,7 +192,6 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
const pre = [
|
||||
WellKnownPlugin.Plugin,
|
||||
AgentPlugin.Plugin,
|
||||
PlanPlugin.Plugin,
|
||||
CommandPlugin.Plugin,
|
||||
SkillPlugin.Plugin,
|
||||
...SystemPromptPlugin.Plugins,
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
export * as PlanPlugin from "./plan"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Agent } from "../agent"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
const enter = `<system-reminder>
|
||||
You are in Plan mode. This is a READ-ONLY environment. You are not allowed to edit files, and you may not ask a subagent to edit them either.
|
||||
</system-reminder>`
|
||||
|
||||
const leave = `<system-reminder>
|
||||
You are no longer in Plan mode. The previous read-only restrictions no longer apply. You may edit files again.
|
||||
</system-reminder>`
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.plan",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(plan, (item) => {
|
||||
item.name = Agent.Name.make("Plan")
|
||||
item.description = "Read-only agent for exploring the codebase and planning work before implementation."
|
||||
item.mode = "primary"
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.agent !== plan) return Effect.void
|
||||
if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void
|
||||
return new ToolFailure({
|
||||
message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`,
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "session.agent.selected"),
|
||||
Stream.runForEach((event) => {
|
||||
if (event.data.agent === event.data.previous) return Effect.void
|
||||
const text = event.data.agent === plan ? enter : event.data.previous === plan ? leave : undefined
|
||||
if (!text) return Effect.void
|
||||
return ctx.session
|
||||
.synthetic({
|
||||
sessionID: event.data.sessionID,
|
||||
text,
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -165,6 +165,7 @@ describe("Agent", () => {
|
||||
"compaction",
|
||||
"explore",
|
||||
"general",
|
||||
"plan",
|
||||
"summary",
|
||||
"title",
|
||||
])
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -14300,15 +14300,9 @@
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -14300,15 +14300,9 @@
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -14300,15 +14300,9 @@
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"previous": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"previous": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"version": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user