Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
94e3298
docs: design document for TUI window system
vtemian Dec 29, 2025
f9b14e4
docs: add TUI window system implementation plan
vtemian Dec 29, 2025
5509473
feat(tui): add view type definitions for tree, list, text, and form p…
vtemian Dec 29, 2025
6a2910e
feat(tui): add view registry for managing plugin-provided views
vtemian Dec 29, 2025
26e0571
feat(tui): add layout type definitions for window system
vtemian Dec 29, 2025
f7409fb
feat(tui): add layout tree operations for window management
vtemian Dec 29, 2025
990baaa
feat(config): add window command keybinds with Vim-style defaults
vtemian Dec 29, 2025
04f2b5c
feat(tui): add layout context provider for window state management
vtemian Dec 29, 2025
521c919
feat(config): add component visibility and spacing options to TUI config
vtemian Dec 29, 2025
75a0830
feat(plugin): add window and view API types for plugin system
vtemian Dec 29, 2025
f2d83a1
feat(tui): add window command handler for Vim-style keybinds
vtemian Dec 29, 2025
d33a8ed
feat(tui): add layout renderer component for window system
vtemian Dec 29, 2025
d530087
chore(tui): add index files for layout and view modules
vtemian Dec 29, 2025
75b600f
feat(tui): integrate layout system into app
vtemian Dec 29, 2025
26df5bb
fix(tui): resolve type errors and style issues in window system
vtemian Dec 29, 2025
7e6aa38
feat(tui): complete layout system integration with route bridge
vtemian Dec 30, 2025
6eb975d
fix(config): use leader-based keybinds for window commands
vtemian Dec 30, 2025
27c3cef
fix(config): use hjkl for window navigation, reassign conflicting key…
vtemian Dec 30, 2025
042bff0
feat(tui): add window focus registry for cursor management without re…
vtemian Dec 30, 2025
0b0dd06
fix(tui): only show border on focused window
vtemian Dec 30, 2025
c356bdb
fix(tui): fix window navigation in nested splits by traversing up the…
vtemian Dec 30, 2025
353cc00
feat(tui): enable per-window independent session views
vtemian Dec 30, 2025
ac4fdb5
fix(tui): new windows start with home view instead of blank session
vtemian Dec 30, 2025
45fb300
fix(tui): guard against undefined session in Header component
vtemian Dec 30, 2025
14be39a
fix(tui): use layout navigation for session/view switching
vtemian Dec 30, 2025
4fbcde0
fix(tui): focus prompt after navigating to different view
vtemian Dec 30, 2025
42e1c1e
fix(tui): use createMemo for currentSessionID to ensure reactivity
vtemian Dec 30, 2025
ec91e7e
fix(tui): use keyed Show to remount Session when sessionID changes
vtemian Dec 30, 2025
99de8f5
fix(tui): handle Enter key in Session's useKeyboard for prompt submis…
vtemian Dec 30, 2025
493cadc
fix(tui): force reactive read before prompt submit to fix timing issue
vtemian Dec 30, 2025
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
57 changes: 29 additions & 28 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentu
import { Clipboard } from "@tui/util/clipboard"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { Flag } from "@/flag/flag"
Expand All @@ -20,9 +20,12 @@ import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { KeybindProvider } from "@tui/context/keybind"
import { LayoutProvider, useLayout } from "@tui/context/layout"
import { WindowCommandsProvider } from "@tui/context/window-commands"
import { RouteLayoutBridgeProvider } from "@tui/context/route-layout-bridge"
import { LayoutRenderer } from "@tui/layout/renderer"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"

import { PromptHistoryProvider } from "./component/prompt/history"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
Expand Down Expand Up @@ -121,17 +124,23 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
<LayoutProvider>
<RouteLayoutBridgeProvider>
<WindowCommandsProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</WindowCommandsProvider>
</RouteLayoutBridgeProvider>
</LayoutProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
Expand Down Expand Up @@ -178,6 +187,7 @@ function App() {
const sync = useSync()
const exit = useExit()
const promptRef = usePromptRef()
const layout = useLayout()

// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
Expand Down Expand Up @@ -236,10 +246,7 @@ function App() {
local.model.set({ providerID, modelID }, { recent: true })
}
if (args.sessionID) {
route.navigate({
type: "session",
sessionID: args.sessionID,
})
layout.navigateFocusedWindow(`session:${args.sessionID}`)
}
})
})
Expand All @@ -253,7 +260,7 @@ function App() {
.find((x) => x.parentID === undefined)?.id
if (match) {
continued = true
route.navigate({ type: "session", sessionID: match })
layout.navigateFocusedWindow(`session:${match}`)
}
})

Expand Down Expand Up @@ -290,6 +297,7 @@ function App() {
const current = promptRef.current
// Don't require focus - if there's any text, preserve it
const currentPrompt = current?.current?.input ? current.current : undefined
layout.navigateFocusedWindow("home")
route.navigate({
type: "home",
initialPrompt: currentPrompt,
Expand Down Expand Up @@ -528,7 +536,7 @@ function App() {

sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
route.navigate({ type: "home" })
layout.navigateFocusedWindow("home")
toast.show({
variant: "info",
message: "The current session was deleted",
Expand Down Expand Up @@ -599,14 +607,7 @@ function App() {
}
}}
>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
</Match>
<Match when={route.data.type === "session"}>
<Session />
</Match>
</Switch>
<LayoutRenderer />
</box>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import { useLayout } from "../context/layout"
import "opentui-spinner/solid"

export function DialogSessionList() {
Expand All @@ -18,6 +19,7 @@ export function DialogSessionList() {
const route = useRoute()
const sdk = useSDK()
const kv = useKV()
const layout = useLayout()

const [toDelete, setToDelete] = createSignal<string>()

Expand Down Expand Up @@ -74,10 +76,8 @@ export function DialogSessionList() {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
// Update window view (layout system handles route sync via bridge)
layout.navigateFocusedWindow(`session:${option.value}`)
dialog.clear()
}}
keybind={[
Expand Down
37 changes: 31 additions & 6 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useWindowID } from "../../context/window-id"
import { WindowFocusRegistry } from "../../window-focus-registry"
import { useLayout } from "../../context/layout"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -126,6 +129,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const windowID = useWindowID()
const layout = useLayout()

function promptModelWarning() {
toast.show({
Expand Down Expand Up @@ -350,6 +355,11 @@ export function Prompt(props: PromptProps) {

onMount(() => {
promptPartTypeId = input.extmarks.registerType("prompt-part")
// Register with window focus system if in a window context
if (windowID) {
const unregister = WindowFocusRegistry.register(windowID, { focus: () => input.focus() })
onCleanup(unregister)
}
})

function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
Expand Down Expand Up @@ -522,6 +532,14 @@ export function Prompt(props: PromptProps) {
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (autocomplete?.visible) {
toast.show({ message: "DEBUG: blocked by autocomplete", variant: "error", duration: 3000 })
return
}
if (!store.prompt.input) {
toast.show({ message: "DEBUG: blocked by empty input", variant: "error", duration: 3000 })
return
}
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
exit()
Expand Down Expand Up @@ -623,14 +641,21 @@ export function Prompt(props: PromptProps) {
setStore("extmarkToPartIndex", new Map())
props.onSubmit?.()

// temporary hack to make sure the message is sent
if (!props.sessionID)
// Navigate to the new session after submission
if (!props.sessionID) {
setTimeout(() => {
route.navigate({
type: "session",
sessionID,
})
if (windowID) {
// Multi-window mode: update this window's view
layout.navigateFocusedWindow(`session:${sessionID}`)
} else {
// Single-window mode: use route navigation
route.navigate({
type: "session",
sessionID,
})
}
}, 50)
}
input.clear()
}
const exit = useExit()
Expand Down
Loading