Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -494,14 +494,22 @@ function App(props: { pair?: DialogPairCredentials }) {
const clipboard = useClipboard()
const terminalEnvironment = useTuiTerminalEnvironment()
let systemThemeTimeout: ReturnType<typeof setTimeout> | undefined
let terminalTitleTimeout: ReturnType<typeof setTimeout> | undefined
const [terminalTitleReady, setTerminalTitleReady] = createSignal(false)
const prepareSystemTheme = () => {
// The native writer can still be flushing the frame when FRAME fires. Keep OSC probes behind visible app output.
systemThemeTimeout = setTimeout(themes.prepareSystem, 50)
}
onMount(() => renderer.once(CliRenderEvents.FRAME, prepareSystemTheme))
const finishFirstFrame = () => {
// Native terminal updates serialize behind frame output, so keep them from forcing an empty frame ahead of Home.
prepareSystemTheme()
terminalTitleTimeout = setTimeout(() => setTerminalTitleReady(true), 50)
}
onMount(() => renderer.once(CliRenderEvents.FRAME, finishFirstFrame))
onCleanup(() => {
renderer.off(CliRenderEvents.FRAME, prepareSystemTheme)
renderer.off(CliRenderEvents.FRAME, finishFirstFrame)
if (systemThemeTimeout) clearTimeout(systemThemeTimeout)
if (terminalTitleTimeout) clearTimeout(terminalTitleTimeout)
})
createEffect(() => {
if (client.connection.status() !== "connected") return
Expand Down Expand Up @@ -616,6 +624,7 @@ function App(props: { pair?: DialogPairCredentials }) {
const session = route.data.type === "session" ? data.session.get(route.data.sessionID) : undefined
if (session) active = { id: session.id, title: session.title }
if (!terminalTitleEnabled()) return
if (!terminalTitleReady()) return

if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode")
Expand All @@ -639,6 +648,7 @@ function App(props: { pair?: DialogPairCredentials }) {
})

const args = useArgs()
const promptFirstHome = () => route.data.type === "home" && !args.sessionID && !args.continue
const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
onMount(() => {
batch(() => {
Expand Down Expand Up @@ -1323,14 +1333,14 @@ function App(props: { pair?: DialogPairCredentials }) {
<SessionTabs orientation="vertical" width={tabsResize.size()} />
</Show>
<box flexGrow={1} minWidth={0} flexDirection="column">
<Show when={plugins.ready()}>
<Show when={promptFirstHome() || plugins.ready()}>
<box flexGrow={1} minHeight={0} flexDirection="column">
<Show when={tabsVisible() && !tabsVertical()}>
<SessionTabs />
</Show>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
<Home ready={plugins.ready()} />
</Match>
<Match when={route.data.type === "session"}>
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
Expand All @@ -1351,7 +1361,9 @@ function App(props: { pair?: DialogPairCredentials }) {
</Match>
</Switch>
</box>
<Slot path="app" />
<Show when={plugins.ready()}>
<Slot path="app" />
</Show>
</Show>
</box>
<Show when={verticalTabsVisible()}>
Expand All @@ -1361,7 +1373,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
<DevToolsBar />
</Show>
<Show when={!startup.skipInitialLoading}>
<Show when={!startup.skipInitialLoading && !promptFirstHome()}>
<StartupLoading ready={plugins.ready} />
</Show>
<Show when={showReconnecting()}>
Expand Down
22 changes: 21 additions & 1 deletion packages/tui/src/component/logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import { useTerminalDimensions } from "@opentui/solid"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { go, logo } from "../logo"
import { stringWidth } from "../util/string-width"

export function logoSize(width: number, height: number) {
if (height < 12) return { width: 0, height: 0 }
if (width < 22)
return {
width: Math.max(...go.right.slice(1).map((line) => stringWidth(line))),
height: go.right.length - 1,
}
if (width < 44) {
const lines = [...logo.left.slice(1), ...logo.right]
return { width: Math.max(...lines.map((line) => stringWidth(line))), height: lines.length }
}
return {
width: Math.max(
...logo.left.map((line, index) => stringWidth(line) + 1 + stringWidth(logo.right[index] ?? "")),
),
height: logo.left.length,
}
}

export function Logo() {
const theme = useTheme()
Expand Down Expand Up @@ -50,7 +70,7 @@ export function Logo() {
}

return (
<box>
<box id="home-logo">
{dimensions().height < 12 ? null : dimensions().width < 22 ? (
<For each={go.right.slice(1)}>
{(line) => <box flexDirection="row">{renderLine(line, theme.text.default, true)}</box>}
Expand Down
3 changes: 2 additions & 1 deletion packages/tui/src/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ const themeContext = createSimpleContext({
draft.lock = lock
const active = config.theme?.name ?? "opencode"
draft.active = typeof active === "string" ? active : "opencode"
draft.ready = false
// Built-ins are complete synchronously; custom and system themes still gate their first paint on discovery.
draft.ready = active !== "system" && Boolean(draft.themes[active])
}),
)

Expand Down
16 changes: 5 additions & 11 deletions packages/tui/src/feature-plugins/home/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,7 @@ import { Plugin } from "@opencode/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { useTerminalDimensions } from "@opentui/solid"
import { usePlugin } from "../../plugin/context"

export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
import { homeFooterHeight, homeFooterVisibility } from "../../ui/layout"

function Mcp(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
Expand Down Expand Up @@ -76,13 +69,14 @@ function Plugins(props: { context: Plugin.Context }) {
function View(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
const height = createMemo(() => homeFooterHeight(dimensions().width, dimensions().height))

return (
<Show when={dimensions().height >= 12 && dimensions().width >= 44}>
<Show when={height() > 0}>
<box
width="100%"
paddingTop={dimensions().height < 16 ? 0 : 1}
paddingBottom={dimensions().height < 16 ? 0 : 1}
paddingTop={height() === 1 ? 0 : 1}
paddingBottom={height() === 1 ? 0 : 1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
Expand Down
25 changes: 19 additions & 6 deletions packages/tui/src/routes/home.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Prompt, type PromptRef } from "../component/prompt"
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
import { Logo } from "../component/logo"
import { Logo, logoSize } from "../component/logo"
import { useArgs } from "../context/args"
import { useRouteData } from "../context/route"
import { usePromptRef } from "../context/prompt"
Expand All @@ -15,14 +15,15 @@ import { useTheme } from "../context/theme"
import { useUpdateNotification } from "../context/update-notification"
import { useExit } from "../context/exit"
import { FadeInText } from "../component/fade-in-text"
import { homeFooterHeight } from "../ui/layout"

let once = false
const placeholder = {
normal: ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"],
shell: ["ls -la", "git status", "pwd"],
}

export function Home() {
export function Home(props: { ready: boolean }) {
const route = useRouteData("home")
const promptRef = usePromptRef()
const [ref, setRef] = createSignal<PromptRef | undefined>()
Expand All @@ -33,6 +34,7 @@ export function Home() {
const location = useLocation()
const dimensions = useTerminalDimensions()
const [logoWidth, setLogoWidth] = createSignal(0)
const reservedLogoSize = createMemo(() => logoSize(dimensions().width, dimensions().height))
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const currentLocation = () => route.location ?? data.location.default()
const forms = createMemo(() => data.session.form.list("global", currentLocation()) ?? [])
Expand Down Expand Up @@ -88,12 +90,16 @@ export function Home() {
<box flexGrow={1} minHeight={0} />
<box height={3} minHeight={0} flexShrink={1} />
<box
width={reservedLogoSize().width}
height={reservedLogoSize().height}
flexShrink={0}
onSizeChange={function () {
setLogoWidth(this.width)
}}
>
<Logo />
<Show when={props.ready}>
<Logo />
</Show>
</box>
<box height={1} flexShrink={0} />
<UpdateNotification width={logoWidth()} />
Expand All @@ -102,9 +108,16 @@ export function Home() {
</box>
<box flexGrow={1} minHeight={0} />
</box>
<box width="100%" flexShrink={0}>
<Slot path="home.footer" />
</box>
<Show
when={props.ready}
fallback={
<box width="100%" height={homeFooterHeight(dimensions().width, dimensions().height)} flexShrink={0} />
}
>
<box width="100%" flexShrink={0}>
<Slot path="home.footer" />
</box>
</Show>
<Show when={forms()[0]?.id} keyed>
{(_) => {
const form = forms()[0]
Expand Down
13 changes: 13 additions & 0 deletions packages/tui/src/ui/layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,16 @@ export function clampSessionPaneWidth(width: number, total: number) {
// Preserve the equal split when there is not enough room for both pane minima.
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
}

export function homeFooterHeight(width: number, height: number) {
if (height < 12 || width < 44) return 0
return height < 16 ? 1 : 3
}

export function homeFooterVisibility(width: number) {
return {
mcpCommand: width >= 64,
pluginCommand: width >= 80,
version: width >= 64,
}
}
111 changes: 110 additions & 1 deletion packages/tui/test/app-lifecycle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { EmbeddedTerminalRenderable } from "@opentui/core"
import { EmbeddedTerminalRenderable, TextareaRenderable } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
Expand Down Expand Up @@ -430,6 +430,115 @@ test("session title generated while an untitled session is loading remains visib
}
})

test.each([
{ name: "wide", width: 100, height: 30 },
{ name: "narrow", width: 40, height: 12 },
])("prompt-first Home preserves its production prompt through plugin readiness ($name)", async (size) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<{ directory: string }>()
await using setup = await createAppFixture({
width: size.width,
height: size.height,
state: state.path,
config: { animations: false, tabs: { enabled: false }, plugins: ["fixture"] },
packages: {
prepare: async () => {
requested.resolve()
return plugins.promise
},
},
})

try {
await requested.promise
await setup.ready
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
const prompt = setup.renderer.currentFocusedEditor!
await setup.mockInput.typeText("typed before plugins")
await setup.waitForFrame((frame) => frame.includes("typed before plugins"))
const geometry = { x: prompt.x, y: prompt.y, width: prompt.width, height: prompt.height }
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()

plugins.resolve({ directory: "" })
await setup.waitFor(() => setup.renderer.root.findDescendantById("home-logo") !== undefined)
await setup.renderOnce()

expect(setup.renderer.currentFocusedEditor).toBe(prompt)
expect(prompt.plainText).toBe("typed before plugins")
expect({ x: prompt.x, y: prompt.y, width: prompt.width, height: prompt.height }).toEqual(geometry)
await setup.mockInput.typeText(" and remains usable")
await setup.waitFor(() => prompt.plainText === "typed before plugins and remains usable")
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
} finally {
const prompt = setup.renderer.currentFocusedEditor
if (prompt instanceof TextareaRenderable && prompt.plainText) {
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitFor(() => prompt.plainText === "")
}
plugins.resolve({ directory: "" })
}
})

test.each([
{ name: "session", args: { sessionID: "ses_startup_gate" } },
{ name: "continue", args: { continue: true } },
])("initial $name routing stays gated while plugins load", async ({ args }) => {
await using state = await tmpdir()
const requested = Promise.withResolvers<void>()
const plugins = Promise.withResolvers<{ directory: string }>()
const session = {
id: "ses_startup_gate",
title: "Startup gate fixture",
projectID: "proj_test",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
await using setup = await createAppFixture({
state: state.path,
args,
config: { animations: false, tabs: { enabled: false }, plugins: ["fixture"] },
packages: {
prepare: async () => {
requested.resolve()
return plugins.promise
},
},
fetch: (url) => {
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: [], cursor: {} })
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
return json({ data: [] })
return undefined
},
})

try {
await requested.promise
await setup.ready
await setup.renderOnce()
expect(setup.renderer.currentFocusedEditor).toBeNull()
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()
expect(setup.captureCharFrame()).not.toContain("Ask anything")

plugins.resolve({ directory: "" })
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
expect(setup.renderer.root.findDescendantById("home-logo")).toBeUndefined()
await setup.mockInput.typeText("session prompt ready")
await setup.waitForFrame((frame) => frame.includes("session prompt ready"))
} finally {
const prompt = setup.renderer.currentFocusedEditor
if (prompt instanceof TextareaRenderable && prompt.plainText) {
setup.mockInput.pressKey("c", { ctrl: true })
await setup.waitFor(() => prompt.plainText === "")
}
plugins.resolve({ directory: "" })
}
})

test("vertical session tabs collapse to a compact rail with the terminal", async () => {
await using state = await tmpdir()
await Bun.write(path.join(state.path, "test", "tui", "layout.json"), JSON.stringify({ verticalTabsWidth: 42 }))
Expand Down
Loading
Loading