Compare commits

..

3 Commits

Author SHA1 Message Date
Kit Langton 4b21e42c32 feat(tui): wipe in first generated titles 2026-08-11 22:38:44 -04:00
Kit Langton b1ab9999e5 feat(tui): dim placeholder tab titles 2026-08-11 22:38:35 -04:00
Kit Langton 6b2429a276 feat(tui): storybook tabs follow configured layout 2026-08-11 22:38:35 -04:00
22 changed files with 211 additions and 347 deletions
-2
View File
@@ -157,7 +157,6 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
cost: optionalNull(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.StructWithRest(
Schema.Struct({
@@ -596,7 +595,6 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
cacheWriteInputTokens: cacheWrite,
reasoningTokens: reasoning,
totalTokens: ProviderShared.totalTokens(input, output, usage.total_tokens ?? undefined),
cost: usage.cost ?? undefined,
providerMetadata: { openai: usage },
})
}
-2
View File
@@ -56,8 +56,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
cacheWriteInputTokens: Schema.optional(Schema.Number),
reasoningTokens: Schema.optional(Schema.Number),
totalTokens: Schema.optional(Schema.Number),
/** Provider-reported cost for this physical request, normalized to USD. */
cost: Schema.optional(Schema.Number),
providerMetadata: Schema.optional(ProviderMetadata),
}) {
/**
@@ -246,24 +246,6 @@ describe("OpenRouter", () => {
}),
)
it.effect("reports OpenRouter's streamed USD cost", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents({
choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12, cost: 0.00123 },
}),
),
),
)
expect(response.usage?.cost).toBe(0.00123)
}),
)
it.effect("fails on a mid-stream provider error", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini")
+8 -36
View File
@@ -343,8 +343,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
model: (input) =>
LanguageModel.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) =>
streamLanguage(language, prepared as LanguageModelV3CallOptions, info.providerID === Provider.ID.githubCopilot),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return LanguageModel.make({
id: info.modelID ?? info.id,
@@ -428,7 +427,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions {
toolChoice: toolChoice(request.toolChoice),
headers: request.http?.headers,
providerOptions: providerOptions(request.providerOptions),
includeRawChunks: request.model.provider === ProviderID.make(Provider.ID.githubCopilot),
}
}
@@ -549,15 +547,8 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
interface StreamState {
step: number
toolNames: Record<string, string>
copilot: boolean
cost?: number
}
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions, copilot: boolean) {
const state: StreamState = { step: 0, toolNames: {}, copilot }
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
@@ -580,19 +571,17 @@ function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallO
}
function streamPartEvents(
state: StreamState,
state: { step: number; toolNames: Record<string, string> },
event: LanguageModelV3StreamPart,
): Effect.Effect<ReadonlyArray<LLMEvent>, AIError> {
switch (event.type) {
case "stream-start":
case "response-metadata":
case "raw":
case "file":
case "source":
case "tool-approval-request":
return Effect.succeed([])
case "raw":
if (state.copilot) state.cost = copilotCost(event.rawValue) ?? state.cost
return Effect.succeed([])
case "text-start":
return Effect.succeed([
LLMEvent.textStart({ id: event.id, providerMetadata: providerMetadata(event.providerMetadata) }),
@@ -683,18 +672,16 @@ function streamPartEvents(
}),
])
case "finish":
const normalized = usage(event.usage, state.cost)
state.cost = undefined
return Effect.succeed([
LLMEvent.stepFinish({
index: state.step++,
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: normalized,
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
LLMEvent.finish({
reason: { normalized: finishReason(event.finishReason), raw: event.finishReason.raw },
usage: normalized,
usage: usage(event.usage),
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
@@ -703,10 +690,7 @@ function streamPartEvents(
}
}
function usage(
input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"],
cost?: number,
): UsageInput | undefined {
function usage(input: Extract<LanguageModelV3StreamPart, { type: "finish" }>["usage"]): UsageInput | undefined {
const output = {
inputTokens: input.inputTokens.total,
nonCachedInputTokens: input.inputTokens.noCache,
@@ -718,22 +702,10 @@ function usage(
input.inputTokens.total === undefined || input.outputTokens.total === undefined
? undefined
: input.inputTokens.total + input.outputTokens.total,
cost,
}
return Object.values(output).some((value) => value !== undefined) ? output : undefined
}
function copilotCost(input: unknown): number | undefined {
if (!ProviderShared.isRecord(input)) return undefined
const raw = input
const response = ProviderShared.isRecord(raw.response) ? raw.response : undefined
const usage = raw.copilot_usage ?? response?.copilot_usage
if (!ProviderShared.isRecord(usage)) return undefined
const total = usage.total_nano_aiu
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return undefined
return total / 100_000_000_000
}
function finishReason(value: LanguageModelV3FinishReason): FinishReason {
return value.unified === "other" ? "unknown" : value.unified
}
+4 -2
View File
@@ -272,8 +272,10 @@ const layer = Layer.effect(
snapshot: startSnapshot,
assistantMessageID,
})
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) =>
SessionUsage.record(finish.usage, resolved.cost)
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
})
const captureStepEnd = Effect.fnUntraced(function* () {
const snapshot = yield* snapshots.capture()
@@ -35,7 +35,7 @@ export interface StepRecord {
/** Present once the provider finished the step normally. */
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly usage: Extract<LLMEvent, { type: "step-finish" }>["usage"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -495,7 +495,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
case "step-finish":
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, usage: event.usage }
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+2 -8
View File
@@ -17,6 +17,7 @@ export const tokens = (usage: Usage | undefined): TokenUsage.Info => ({
},
})
// TODO(#35765): Use Copilot's reported billed amount once billing has a dedicated typed runtime contract.
export function calculateCost(costs: Model.Info["cost"], usage: TokenUsage.Info) {
const context = usage.input + usage.cache.read + usage.cache.write
const tier = costs
@@ -37,14 +38,7 @@ export type Recorded = { readonly tokens: TokenUsage.Info; readonly cost: Money.
export const record = (usage: Usage | undefined, costs: Model.Info["cost"]): Recorded => {
const normalized = tokens(usage)
const reported = usage?.cost
return {
tokens: normalized,
cost:
reported !== undefined && Number.isFinite(reported) && reported >= 0
? Money.USD.make(reported)
: calculateCost(costs, normalized),
}
return { tokens: normalized, cost: calculateCost(costs, normalized) }
}
export const add = (a: Recorded, b: Recorded): Recorded => ({
+5 -45
View File
@@ -1,5 +1,5 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { AISDK } from "@opencode-ai/core/aisdk"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
@@ -23,26 +23,21 @@ const model = (packageName: string, settings: Record<string, unknown> = {}) =>
limit: { context: 100, output: 20 },
})
const streamModel = (
events: ReadonlyArray<LanguageModelV3StreamPart>,
inspect?: (options: LanguageModelV3CallOptions) => void,
): LanguageModelV3 => ({
const streamModel = (events: ReadonlyArray<LanguageModelV3StreamPart>): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "test",
modelId: "test",
supportedUrls: {},
doGenerate: () => Promise.reject(new Error("Unexpected non-streaming request")),
doStream: (options) => {
inspect?.(options)
return Promise.resolve({
doStream: () =>
Promise.resolve({
stream: new ReadableStream({
start(controller) {
events.forEach((event) => controller.enqueue(event))
controller.close()
},
}),
})
},
}),
})
const usage = {
@@ -380,41 +375,6 @@ it.effect("emits malformed AI SDK tool input without executing it", () =>
}),
)
it.effect("normalizes Copilot billed usage to USD", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let options: LanguageModelV3CallOptions | undefined
yield* aisdk.hook.sdk((event) => {
event.sdk = {
languageModel: () =>
streamModel(
[
{
type: "raw",
rawValue: { type: "message_delta", copilot_usage: { total_nano_aiu: 4_473_525_000 } },
},
{ type: "finish", finishReason: { unified: "stop", raw: "end_turn" }, usage },
],
(input) => {
options = input
},
),
}
})
const resolved = yield* aisdk.model({
...model("@ai-sdk/github-copilot"),
providerID: Provider.ID.githubCopilot,
})
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "Hello" })).pipe(
Effect.provide(client),
)
expect(options?.includeRawChunks).toBeTrue()
expect(response.usage?.cost).toBeCloseTo(0.04473525)
}),
)
it.effect("keeps malformed provider-executed AI SDK input terminal", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
@@ -1,6 +1,7 @@
import { expect, test } from "bun:test"
import { Cause, Effect, Exit, Schema } from "effect"
import { LLMEvent } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Agent } from "@opencode-ai/core/agent"
@@ -12,7 +13,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { SessionUsage } from "@opencode-ai/core/session/usage"
const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -280,7 +280,6 @@ test("content-filter finish retains failure evidence until step closeout", async
nonCachedInputTokens: 8,
outputTokens: 3,
reasoningTokens: 1,
cost: 1.25,
},
}),
),
@@ -290,13 +289,13 @@ test("content-filter finish retains failure evidence until step closeout", async
const settlement = publisher.record().finish
expect(settlement).toMatchObject({
finish: "content-filter",
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1, cost: 1.25 },
tokens: { input: 8, output: 2, reasoning: 1 },
})
if (!settlement) throw new Error("Expected content-filter settlement")
const recorded = SessionUsage.record(settlement.usage, [])
await Effect.runPromise(
publisher.publishStepFailure({
...recorded,
cost: Money.USD.make(1.25),
tokens: settlement.tokens,
snapshot: Snapshot.ID.make("tree-end"),
files: [RelativePath.make("src/changed.ts")],
}),
-28
View File
@@ -1,28 +0,0 @@
import { expect, test } from "bun:test"
import { Usage } from "@opencode-ai/ai"
import { Money } from "@opencode-ai/schema/money"
import { SessionUsage } from "@opencode-ai/core/session/usage"
const costs = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.make(2),
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
]
test("prefers provider-reported cost", () => {
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0.25 }), costs).cost).toBe(
Money.USD.make(0.25),
)
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: 0 }), costs).cost).toBe(Money.USD.zero)
})
test("falls back to catalog pricing for invalid reported cost", () => {
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: Number.NaN }), costs).cost).toBe(
Money.USD.make(1),
)
expect(SessionUsage.record(new Usage({ nonCachedInputTokens: 1_000_000, cost: -1 }), costs).cost).toBe(
Money.USD.make(1),
)
})
+1 -7
View File
@@ -38,7 +38,6 @@ import {
TuiStartupProvider,
TuiTerminalEnvironmentProvider,
useTuiApp,
useTuiPaths,
useTuiStartup,
type TuiApp,
} from "./context/runtime"
@@ -86,7 +85,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { newSessionLocation } from "./config/new-session-location"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { tuiPluginDirectories } from "./plugin/discovery"
import { PluginRoute, Slot } from "./plugin/render"
@@ -455,7 +453,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const log = useLog({ component: "app" })
const app = useTuiApp()
const startup = useTuiStartup()
const paths = useTuiPaths()
const config = useConfig()
const devtools = createMemo(() => config.data.debug?.devtools ?? app.channel === "local")
const route = useRoute()
@@ -662,13 +659,10 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location: newSessionLocation(
config.data.session.new_location,
paths.cwd,
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
),
})
dialog.clear()
},
+10 -12
View File
@@ -384,18 +384,16 @@ export function DevToolsBar() {
>
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
</Action>
<Show when={Boolean(turnTokens())}>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</Show>
<Action
onClick={() =>
void config.update((draft) => {
draft.debug = { ...draft.debug, turn_tokens: verboseTurnTokens() ? true : "verbose" }
})
}
hoverBackground
>
{verboseTurnTokens() ? "[x]" : "[ ]"} Turn token usage (verbose)
</Action>
</box>
<For each={groups()}>
{(group) => (
@@ -93,15 +93,6 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "New session location",
category: "Session",
path: ["session", "new_location"],
default: "launch",
values: ["launch", "inherit"],
labels: ["launch directory", "active session"],
keywords: ["directory", "cwd", "inherit"],
},
{
title: "Enabled",
category: "Tabs",
+95 -12
View File
@@ -16,6 +16,7 @@ import {
type SessionTab,
type SessionTabUnread,
} from "../context/session-tabs-model"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { createAnimatable, spring, tween } from "../ui/animation"
import { Locale } from "../util/locale"
import { stringWidth } from "../util/string-width"
@@ -60,6 +61,63 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
// A tab title is provisional until the session earns a generated or user-provided one.
function isPlaceholderSessionTitle(value: string | undefined) {
return value === NEW_SESSION_TAB_TITLE || value === "Untitled session" || isFallbackTitle(value)
}
// The soft edge of the title wipe spans a few cells behind the front.
const WIPE_FEATHER = 3
// The outgoing title sits dimmed toward the background while it is being replaced.
const WIPE_OUTGOING_DIM = 0.5
// The first real title wipes in from the left over the placeholder it replaces. Only the
// placeholder → real transition animates; every other title change jumps, so routine
// syncs and renames never lag behind the data (the reason the original wipe was removed).
function createTitleWipe(title: () => string, parts: () => readonly string[], width: () => number, animations: () => boolean) {
const [outgoing, setOutgoing] = createSignal<string>()
const wipe = createAnimatable(
{ front: 1 },
{ enabled: animations, transition: tween({ duration: 0.45, ease: (progress) => 1 - (1 - progress) ** 3 }) },
)
createEffect((previous: string) => {
const next = title()
if (next === previous) return next
if (!isPlaceholderSessionTitle(previous) || isPlaceholderSessionTitle(next)) {
setOutgoing(undefined)
wipe.jump({ front: 1 })
return next
}
setOutgoing(previous)
wipe.jump({ front: 0 })
wipe.animate({ front: 1 })
return next
}, untrack(title))
const active = () => outgoing() !== undefined && wipe.value().front < 1
const displayed = createMemo(() => {
const front = wipe.value().front
const incoming = parts()
const previous = outgoing()
if (previous === undefined || front >= 1) return incoming
const previousParts = Locale.graphemes(Locale.takeWidth(previous, width()))
const length = Math.max(incoming.length, previousParts.length)
const cut = front * length
return Array.from({ length }, (_, index) => (cut - index > 0 ? (incoming[index] ?? " ") : (previousParts[index] ?? " ")))
})
// Tint toward the background per cell: the outgoing text dims as a block (deepening slightly
// as the wipe advances), and freshly revealed characters brighten over the feather behind the
// front, so the edge reads as a soft gradient instead of a hard cut.
const mix = (index: number) => {
if (!active()) return 0
const front = wipe.value().front
const distance = front * displayed().length - index
if (distance <= 0) return Math.min(1, front * 6) * (WIPE_OUTGOING_DIM + 0.25 * front)
if (distance < WIPE_FEATHER) return WIPE_OUTGOING_DIM * (1 - distance / WIPE_FEATHER)
return 0
}
return { parts: displayed, mix, active }
}
function createMarquee(hovered: () => string | undefined, animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
@@ -185,13 +243,20 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const numberWidth = () => 2
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
const title = () => tab.title ?? "Untitled session"
const placeholder = () => isPlaceholderSessionTitle(tab.title)
const scrolling = () => hovered() === tab.sessionID && marquee.offset() > 0
const visibleTitle = createMemo(() =>
scrolling()
? marqueeText(title(), titleWidth(), marquee.offset())
: Locale.takeWidth(title(), titleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const wipe = createTitleWipe(
title,
createMemo(() => Locale.graphemes(visibleTitle())),
titleWidth,
animations,
)
const visibleTitleParts = wipe.parts
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
const value = session()
@@ -215,8 +280,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
}
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return selected() ? theme.text.default : theme.text.subdued
const base =
hovered() === tab.sessionID || selected() ? theme.text.default : theme.text.subdued
// A provisional title reads dimmer than its neighbors until the real one arrives.
return placeholder() ? tint(base, pulseBackground(), 0.35) : base
}
const complete = () => status().complete
const glowHue = () => {
@@ -251,7 +318,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const color = glows()
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
: foreground()
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
pulseBackground(),
@@ -260,6 +327,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
scrolling() ? marquee.leading() : 0,
)
: color
const mix = wipe.mix(index)
return mix > 0 ? tint(faded, pulseBackground(), mix) : faded
}
const release = () => {
setDragging(undefined)
@@ -373,9 +442,12 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
attributes={
(selected() ? TextAttributes.BOLD : 0) | (placeholder() ? TextAttributes.ITALIC : 0) ||
undefined
}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<Show when={glows() || titleFades() || wipe.active()} fallback={visibleTitleParts().join("")}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
</For>
@@ -676,6 +748,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const glowColor = () => feedbackColor() ?? accent()
const glows = () => !selected() && (status().attention || (!status().busy && status().unread !== undefined))
const title = () => tab.title ?? "Untitled session"
const placeholder = () => tab !== NEW_SESSION_TAB && isPlaceholderSessionTitle(tab.title)
const tabNumber = createMemo(() => items().findIndex((item) => item.sessionID === tab.sessionID) + 1)
// Shortcut labels stay one cell wide: 1-9, 0 for ten, then a neutral dot.
const numberWidth = () => 2
@@ -688,20 +761,28 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
? marqueeText(title(), availableTitleWidth(), marquee.offset())
: Locale.takeWidth(title(), availableTitleWidth()),
)
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const wipe = createTitleWipe(
title,
createMemo(() => Locale.graphemes(visibleTitle())),
availableTitleWidth,
animations,
)
const visibleTitleParts = wipe.parts
const titleFades = createMemo(
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
)
const foreground = () => {
if (hovered() === tab.sessionID) return theme.text.default
return tint(theme.text.subdued, theme.text.default, selection())
const base =
hovered() === tab.sessionID ? theme.text.default : tint(theme.text.subdued, theme.text.default, selection())
// A provisional title reads dimmer than its neighbors until the real one arrives.
return placeholder() ? tint(base, background(), 0.35) : base
}
// Title characters sitting over the glow tinge toward its color, following the same
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
const characterColor = (index: number) => {
const base = foreground()
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
return titleFades()
const faded = titleFades()
? fadeTitleColor(
color,
background(),
@@ -710,6 +791,8 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
scrolling() ? marquee.leading() : 0,
)
: color
const mix = wipe.mix(index)
return mix > 0 ? tint(faded, background(), mix) : faded
}
// The running sweep's level under the number cell, reported by the pulse renderable.
const [sweepLevel, setSweepLevel] = createSignal(0)
@@ -782,9 +865,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
attributes={(bold() ?? 0) | (placeholder() ? TextAttributes.ITALIC : 0) || undefined}
>
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
<Show when={glows() || titleFades() || wipe.active()} fallback={visibleTitleParts().join("")}>
<For each={visibleTitleParts()}>
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
</For>
+1 -11
View File
@@ -137,9 +137,6 @@ export const Info = Schema.Struct({
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
new_location: Schema.optional(Schema.Literals(["launch", "inherit"])).annotate({
description: "Start new sessions in the TUI launch directory or inherit the active session location",
}),
}),
).annotate({ description: "Session transcript presentation settings" }),
tabs: Schema.optional(
@@ -205,7 +202,7 @@ export const Info = Schema.Struct({
})
export type Info = Schema.Schema.Type<typeof Info>
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "session" | "tabs"> & {
export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader" | "mouse" | "tabs"> & {
attention: {
enabled: boolean
notifications: boolean
@@ -221,9 +218,6 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
style: "block" | "underline" | "line" | "default"
blinking: boolean
}
session: Omit<NonNullable<Info["session"]>, "new_location"> & {
new_location: "launch" | "inherit"
}
tabs: {
enabled: boolean
scope: "global" | "cwd"
@@ -265,10 +259,6 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
blinking: input.cursor.blinking ?? true,
}
: undefined,
session: {
...input.session,
new_location: input.session?.new_location ?? "launch",
},
tabs: {
...input.tabs,
enabled: input.tabs?.enabled ?? true,
@@ -1,10 +0,0 @@
import type { LocationRef } from "@opencode-ai/client/promise"
export function newSessionLocation(
mode: "launch" | "inherit",
launchDirectory: string,
current?: LocationRef,
): LocationRef {
if (mode === "inherit" && current) return current
return { directory: launchDirectory }
}
@@ -1,6 +1,7 @@
import { Plugin } from "@opencode-ai/plugin/tui"
import { useTerminalDimensions } from "@opentui/solid"
import { batch, createSignal, For, onCleanup } from "solid-js"
import { batch, createSignal, For, onCleanup, Show } from "solid-js"
import { useConfig } from "../../../config"
import { createStore, reconcile } from "solid-js/store"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
import { moveSessionTab } from "../../../context/session-tabs-model"
@@ -38,6 +39,9 @@ const TRANSCRIPT_FILES = [
function SessionTabsStory(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const config = useConfig().data
// The story follows the configured layout so both orientations are exercised.
const orientation = () => (config.tabs.layout === "vertical" ? ("vertical" as const) : undefined)
const theme = props.context.theme
const elevatedTheme = theme.contextual.elevated
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
@@ -320,39 +324,43 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
<box
width={dimensions().width}
height={dimensions().height}
flexDirection="column"
flexDirection={orientation() === "vertical" ? "row" : "column"}
backgroundColor={theme.background.default}
>
<SessionTabs controller={controller} />
<box height={1} />
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
{line.text || " "}
</text>
)}
</For>
</box>
<box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>
selected: {number(active() ?? "")} | state: {selectedState()}
</text>
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
</box>
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / 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
</text>
<SessionTabs controller={controller} orientation={orientation()} />
<box flexGrow={1} flexDirection="column">
<Show when={orientation() === undefined}>
<box height={1} />
</Show>
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
<For each={transcript()}>
{(line) => (
<text fg={line.color} wrapMode="none" selectable={false}>
{line.text || " "}
</text>
)}
</For>
</box>
<box paddingLeft={2} flexDirection="column">
<text fg={theme.text.subdued}>
selected: {number(active() ?? "")} | state: {selectedState()}
</text>
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
</box>
<box
height={1}
flexShrink={0}
backgroundColor={elevatedTheme.background.default}
paddingLeft={1}
paddingRight={1}
flexDirection="row"
>
<text fg={elevatedTheme.text.subdued}>storybook / 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
</text>
</box>
</box>
</box>
)
+1 -8
View File
@@ -11,7 +11,6 @@
import { SessionMessage } from "@opencode-ai/schema/session-message"
import type { LocationRef } from "@opencode-ai/client/promise"
import type { Config } from "../config"
import { newSessionLocation } from "../config/new-session-location"
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
import {
resolveMiniSettings,
@@ -49,7 +48,6 @@ type Reconnect = (signal: AbortSignal) => Promise<RunInput["sdk"]>
type RunRuntimeInput = {
host: MiniHost
directory: string
boot: () => Promise<BootContext>
resolveSession: (sdk: RunInput["sdk"], signal: AbortSignal) => Promise<ResolvedSession>
createSession?: CreateSession
@@ -943,11 +941,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
const created = await createSession(
state.sdk,
{
location: newSessionLocation(
(await tuiConfigTask).session.new_location,
input.directory,
state.location,
),
location: state.location,
agent: state.agent,
model: state.model,
variant: state.activeVariant,
@@ -1105,7 +1099,6 @@ export async function runInteractiveDeferredMode(input: RunDeferredInput, deps?:
return runInteractiveRuntime(
{
host: input.host,
directory: input.directory,
files: input.files,
initialInput: input.initialInput,
thinking: input.thinking,
+1 -1
View File
@@ -392,7 +392,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
export type MiniSettings = {
thinking: "show" | "hide"
+37 -71
View File
@@ -1222,11 +1222,6 @@ function TurnTokenUsage(props: {
}) {
const config = useConfig()
const theme = useTheme()
const renderer = useRenderer()
// Collapsed by default: one summary line for the whole turn. Click to
// open the full per-step table, click again to close.
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
const verbose = () => config.data.debug?.turn_tokens === "verbose"
const steps = createMemo(() => {
let previousCache = props.previousCache
@@ -1262,78 +1257,49 @@ function TurnTokenUsage(props: {
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
}))
const summary = createMemo(() => {
const items = steps()
const last = items[items.length - 1]
return {
count: items.length,
newTokens: items.reduce((sum, item) => sum + item.newTokens, 0),
cached: last?.cached ?? 0,
total: last?.total ?? 0,
reuseDrops: items.filter((item) => item.reuseDrop !== undefined).length,
}
})
return (
<Show when={Boolean(config.data.debug?.turn_tokens) && steps().length > 0}>
<box paddingLeft={3} flexDirection="column">
<box
flexDirection="row"
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
setExpanded((value) => !value)
}}
>
<text fg={hover() ? theme.text.default : theme.text.subdued} wrapMode="none">
<span>{expanded() ? "- " : "+ "}</span>
<span style={{ attributes: TextAttributes.BOLD }}>Tokens</span>
<span>
: {summary().count} {summary().count === 1 ? "step" : "steps"} · {summary().newTokens.toLocaleString()}{" "}
new · {summary().cached.toLocaleString()} cached · {summary().total.toLocaleString()} total
</span>
<Show when={summary().reuseDrops > 0}>
<span style={{ fg: theme.text.feedback.warning.default }}>
{" "}
· ! {summary().reuseDrops} likely cache {summary().reuseDrops === 1 ? "bust" : "busts"}
</span>
</Show>
<box flexDirection="row">
<text width={INLINE_TOOL_ICON_WIDTH} fg={theme.text.subdued}>
</text>
<text fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
Tokens
</text>
</box>
<Show when={expanded()}>
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
{"Step".padEnd(columns().step + 2)}
{"New".padStart(columns().newTokens)}
{" "}
{"Cached".padStart(columns().cached)}
{" "}
{"Total".padStart(columns().total)}
</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
</span>
{" "}
{item.cached.toLocaleString().padStart(columns().cached)}
{" "}
{item.total.toLocaleString().padStart(columns().total)}
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
<text fg={theme.text.subdued} attributes={TextAttributes.ITALIC}>
{"Step".padEnd(columns().step + 2)}
{"New".padStart(columns().newTokens)}
{" "}
{"Cached".padStart(columns().cached)}
{" "}
{"Total".padStart(columns().total)}
</text>
</box>
<For each={steps()}>
{(item) => (
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
<text fg={verbose() && item.finish === "tool-call" ? undefined : theme.text.subdued}>
{item.finish.padEnd(columns().step + 2)}
<span style={{ attributes: TextAttributes.BOLD }}>
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
</span>
{" "}
{item.cached.toLocaleString().padStart(columns().cached)}
{" "}
{item.total.toLocaleString().padStart(columns().total)}
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={theme.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
<TurnTokenToolCalls tools={item.tools} />
<Show when={item.reuseDrop !== undefined}>
<text fg={theme.text.feedback.warning.default}>
! Likely cache bust: {item.reuseDrop?.toLocaleString()} fewer cached tokens than the previous step
</text>
</Show>
</box>
)}
</For>
</Show>
</Show>
</box>
)}
</For>
</box>
</Show>
)
-7
View File
@@ -25,8 +25,6 @@ test("validates the session tabs setting", () => {
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
expect(() => decode({ session: { new_location: "current" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
@@ -47,7 +45,6 @@ test("resolves nested config and keybind defaults", () => {
expect(config.diffs).toEqual({ view: "split" })
expect(config.debug).toEqual({ devtools: true })
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
expect(config.session.new_location).toBe("launch")
})
test("shows resolved tab defaults in settings", () => {
@@ -56,10 +53,6 @@ test("shows resolved tab defaults in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
})
test("shows the new session location default in settings", () => {
expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
})
test("provides config and its host interface", async () => {
const config = resolve({}, { terminalSuspend: true })
let current = {}
@@ -1,19 +0,0 @@
import { expect, test } from "bun:test"
import { newSessionLocation } from "../src/config/new-session-location"
test("uses the launch directory by default", () => {
expect(newSessionLocation("launch", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
directory: "/launch",
})
})
test("inherits the active session location when configured", () => {
expect(newSessionLocation("inherit", "/launch", { directory: "/session", workspaceID: "work-1" })).toEqual({
directory: "/session",
workspaceID: "work-1",
})
})
test("falls back to the launch directory without an active session", () => {
expect(newSessionLocation("inherit", "/launch")).toEqual({ directory: "/launch" })
})