tui: refactor routing structure and add path mapping

- Add @tui/* path mapping to tsconfig.json for cleaner imports
- Restructure TUI routes from flat files to proper routes/ directory
- Move home.tsx and session.tsx to routes/home.tsx and routes/session/index.tsx
- Update all TUI component imports to use @tui/* alias
- Add locale time formatting to session list dialog
- Improve keybind handling with preventDefault for command dialog
This commit is contained in:
Dax Raad 2025-09-26 04:47:49 -04:00
parent eb4c5f4eac
commit 1bd8b62344
20 changed files with 187 additions and 115 deletions

View file

@ -1,4 +1,4 @@
import { Theme } from "../context/theme"
import { Theme } from "@tui/context/theme"
export const SplitBorder = {
border: ["left" as const, "right" as const],

View file

@ -1,7 +1,7 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useLocal } from "@tui/context/local"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogAgent() {
const local = useLocal()

View file

@ -1,5 +1,5 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import {
createContext,
createMemo,
@ -10,7 +10,7 @@ import {
type ParentProps,
} from "solid-js"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "../context/keybind"
import { useKeybind } from "@tui/context/keybind"
type Context = ReturnType<typeof init>
const ctx = createContext<Context>()
@ -26,6 +26,7 @@ function init() {
useKeyboard((evt) => {
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
option.onSelect?.(dialog)
return
}

View file

@ -1,9 +1,9 @@
import { createMemo } from "solid-js"
import { useLocal } from "../context/local"
import { useSync } from "../context/sync"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, isDeepEqual } from "remeda"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogModel() {
const local = useLocal()

View file

@ -1,8 +1,9 @@
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, onMount } from "solid-js"
import { Locale } from "@/util/locale"
export function DialogSessionList() {
const dialog = useDialog()
@ -12,7 +13,8 @@ export function DialogSessionList() {
const options = createMemo(() => {
const today = new Date().toDateString()
return sync.data.session.map((x) => {
let category = new Date(x.time.created).toDateString()
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
@ -20,6 +22,7 @@ export function DialogSessionList() {
title: x.title,
value: x.id,
category,
footer: Locale.time(x.time.updated),
}
})
})
@ -32,6 +35,7 @@ export function DialogSessionList() {
<DialogSelect
title="Sessions"
options={options()}
limit={50}
onSelect={(option) => {
route.navigate({
type: "session",

View file

@ -1,7 +1,7 @@
import { createMemo, createResource } from "solid-js"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "@tui/context/sdk"
import { createStore } from "solid-js/store"
export function DialogTag(props: { onSelect?: (value: string) => void }) {

View file

@ -1,20 +1,20 @@
import { InputRenderable, TextAttributes, BoxRenderable, type ParsedKey } from "@opentui/core"
import { createEffect, createMemo, createResource, For, Match, onMount, Show, Switch } from "solid-js"
import { firstBy } from "remeda"
import { useLocal } from "../context/local"
import { Theme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { SplitBorder } from "./border"
import { useSDK } from "../context/sdk"
import { useRoute } from "../context/route"
import { useSync } from "../context/sync"
import { Identifier } from "../../../../id/id"
import { useLocal } from "@tui/context/local"
import { Theme } from "@tui/context/theme"
import { useDialog } from "@tui/ui/dialog"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { Identifier } from "@/id/id"
import { createStore, produce } from "solid-js/store"
import type { FilePart } from "@opencode-ai/sdk"
import fuzzysort from "fuzzysort"
import { useCommandDialog } from "./dialog-command"
import { useKeybind } from "../context/keybind"
import { Clipboard } from "../../../../util/clipboard"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "@tui/context/keybind"
import { Clipboard } from "@/util/clipboard"
export type PromptProps = {
sessionID?: string

View file

@ -1,13 +1,13 @@
import { createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync"
import { Keybind } from "../../../../util/keybind"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { createContext } from "solid-js"
import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { Instance } from "../../../../project/instance"
import { Instance } from "@/project/instance"
export function init() {
const sync = useSync()

View file

@ -1,10 +1,10 @@
import { createStore } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { useSync } from "./sync"
import { Theme } from "./theme"
import { useSync } from "@tui/context/sync"
import { Theme } from "@tui/context/theme"
import { uniqueBy } from "remeda"
import path from "path"
import { Global } from "../../../../global"
import { Global } from "@/global"
function init() {
const sync = useSync()

View file

@ -1,6 +1,6 @@
import { createContext, useContext, type ParentProps } from "solid-js"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { Server } from "../../../../server/server"
import { Server } from "@/server/server"
function init() {
const client = createOpencodeClient({

View file

@ -1,8 +1,8 @@
import type { Message, Agent, Provider, Session, Part, Config, Todo, Command } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "./sdk"
import { useSDK } from "@tui/context/sdk"
import { createContext, Show, useContext, type ParentProps } from "solid-js"
import { Binary } from "../../../../util/binary"
import { Binary } from "@/util/binary"
function init() {
const [store, setStore] = createStore<{

View file

@ -255,7 +255,7 @@ type Theme = {
}
import { createContext, useContext, createSignal, createEffect, onMount } from "solid-js"
import { Storage } from "../../../../storage/storage"
import { Storage } from "@/storage/storage"
export const Theme = Object.entries(OPENCODE_THEME).reduce((acc, [key, value]) => {
acc[key as keyof Theme] = value.dark

View file

@ -1,7 +1,7 @@
import { Installation } from "../../../installation"
import { useTheme } from "./context/theme"
import { Installation } from "@/installation"
import { useTheme } from "@tui/context/theme"
import { TextAttributes } from "@opentui/core"
import { Prompt } from "./component/prompt"
import { Prompt } from "@tui/component/prompt"
import { For } from "solid-js"
export function Home() {
@ -41,7 +41,7 @@ function HelpRow(props: { children: string; slash: string; theme: any }) {
return (
<text>
<span style={{ bold: true, fg: props.theme.primary }}>/{props.slash.padEnd(10, " ")}</span>
<span>{props.children.padEnd(15, " ")} </span>
<span>{props.children.padEnd(19, " ")} </span>
<span style={{ fg: props.theme.textMuted }}>ctrl+x n</span>
</text>
)

View file

@ -0,0 +1,63 @@
import { createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { Theme } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border"
import { Locale } from "@/util/locale"
import type { AssistantMessage } from "@opencode-ai/sdk"
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
total: Locale.number(total),
percentage: (model ? Locale.number(Math.round((total / model.limit.context) * 100)) : "0") + "%",
}
})
return (
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement} flexShrink={0}>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span> <span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text wrap={false}>
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
<Show when={context()}>
<text fg={Theme.textMuted} wrap={false}>
{context()!.total}/{context()!.percentage} ({cost()})
</text>
</Show>
</box>
</box>
)
}

View file

@ -1,32 +1,33 @@
import { createEffect, createMemo, For, Match, Show, Switch, type Component } from "solid-js"
import { Dynamic } from "solid-js/web"
import path from "path"
import { useRouteData } from "./context/route"
import { useSync } from "./context/sync"
import { SplitBorder } from "./component/border"
import { Theme } from "./context/theme"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Theme } from "@tui/context/theme"
import { BoxRenderable, ScrollBoxRenderable } from "@opentui/core"
import { Prompt } from "./component/prompt"
import { Prompt } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart } from "@opencode-ai/sdk"
import { useLocal } from "./context/local"
import { Locale } from "../../../util/locale"
import type { Tool } from "../../../tool/tool"
import type { ReadTool } from "../../../tool/read"
import type { WriteTool } from "../../../tool/write"
import { BashTool } from "../../../tool/bash"
import type { GlobTool } from "../../../tool/glob"
import { TodoWriteTool } from "../../../tool/todo"
import type { GrepTool } from "../../../tool/grep"
import type { ListTool } from "../../../tool/ls"
import type { EditTool } from "../../../tool/edit"
import type { PatchTool } from "../../../tool/patch"
import type { WebFetchTool } from "../../../tool/webfetch"
import type { TaskTool } from "../../../tool/task"
import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
import type { ReadTool } from "@/tool/read"
import type { WriteTool } from "@/tool/write"
import { BashTool } from "@/tool/bash"
import type { GlobTool } from "@/tool/glob"
import { TodoWriteTool } from "@/tool/todo"
import type { GrepTool } from "@/tool/grep"
import type { ListTool } from "@/tool/ls"
import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import { useKeyboard, type BoxProps, type JSX } from "@opentui/solid"
import { useSDK } from "./context/sdk"
import { useCommandDialog } from "./component/dialog-command"
import { Shimmer } from "./ui/shimmer"
import { useKeybind } from "./context/keybind"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
import { Shimmer } from "@tui/ui/shimmer"
import { useKeybind } from "@tui/context/keybind"
import { Header } from "./header"
export function Session() {
const route = useRouteData("session")
@ -102,24 +103,7 @@ export function Session() {
return (
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexGrow={1}>
<Show when={session()}>
<box paddingLeft={1} paddingRight={1} {...SplitBorder} borderColor={Theme.backgroundElement} flexShrink={0}>
<text>
<span style={{ bold: true, fg: Theme.accent }}>#</span>{" "}
<span style={{ bold: true }}>{session().title}</span>
</text>
<box flexDirection="row">
<Switch>
<Match when={session().share?.url}>
<text fg={Theme.textMuted}>{session().share!.url}</text>
</Match>
<Match when={true}>
<text wrap={false}>
/share <span style={{ fg: Theme.textMuted }}>to create a shareable link</span>
</text>
</Match>
</Switch>
</box>
</box>
<Header />
<scrollbox
ref={(r) => (scroll = r)}
scrollbarOptions={{ visible: false }}

View file

@ -1,24 +1,25 @@
import { cmd } from "../cmd"
import { cmd } from "@/cli/cmd/cmd"
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "./context/route"
import { Home } from "./home"
import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect } from "solid-js"
import { ThemeProvider, useTheme } from "./context/theme"
import { Installation } from "../../../installation"
import { Global } from "../../../global"
import { DialogProvider, useDialog } from "./ui/dialog"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider, useLocal } from "./context/local"
import { DialogModel } from "./component/dialog-model"
import { Session } from "./session"
import { CommandProvider, useCommandDialog } from "./component/dialog-command"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { KeybindProvider, useKeybind } from "./context/keybind"
import { Config } from "../../../config/config"
import { Instance } from "../../../project/instance"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Installation } from "@/installation"
import { Global } from "@/global"
import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { SDKProvider } from "@tui/context/sdk"
import { SyncProvider } from "@tui/context/sync"
import { LocalProvider, useLocal } from "@tui/context/local"
import { DialogModel } from "@tui/component/dialog-model"
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, useKeybind } from "@tui/context/keybind"
import { Config } from "@/config/config"
import { Instance } from "@/project/instance"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
export const TuiCommand = cmd({
command: "$0 [project]",

View file

@ -1,20 +1,21 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { Theme } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
import { Theme } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
import { isDeepEqual } from "remeda"
import { useDialog, type DialogContext } from "./dialog"
import { useDialog, type DialogContext } from "@tui/ui/dialog"
import type { KeybindsConfig } from "@opencode-ai/sdk"
import { useKeybind } from "../context/keybind"
import { useKeybind } from "@tui/context/keybind"
export interface DialogSelectProps<T> {
title: string
options: DialogSelectOption<T>[]
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
limit?: number
current?: T
}
@ -23,6 +24,7 @@ export interface DialogSelectOption<T = any> {
value: T
keybind?: keyof KeybindsConfig
description?: string
footer?: string
category?: string
disabled?: boolean
onSelect?: (ctx: DialogContext) => void
@ -42,6 +44,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const result = pipe(
props.options,
filter((x) => x.disabled !== false),
take(props.limit ?? Infinity),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
groupBy((x) => x.category ?? ""),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
@ -64,7 +67,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
scroll.scrollTo(0)
})
function move(direction: -1 | 1) {
function move(direction: number) {
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
@ -89,6 +92,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
useKeyboard((evt) => {
if (evt.name === "up") move(-1)
if (evt.name === "down") move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "return") {
const option = selected()
if (option.onSelect) option.onSelect(dialog)
@ -97,6 +102,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
})
let scroll: ScrollBoxRenderable
const keybind = useKeybind()
return (
<box gap={1}>
@ -147,7 +153,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Option
id={JSON.stringify(option.value)}
title={option.title}
keybind={option.keybind}
footer={option.footer ?? (option.keybind ? keybind.print(option.keybind as any) : undefined)}
description={option.description !== category ? option.description : undefined}
active={isDeepEqual(option.value, selected()?.value)}
current={isDeepEqual(option.value, props.current)}
@ -181,9 +187,8 @@ function Option(props: {
description?: string
active?: boolean
current?: boolean
keybind?: string
footer?: string
}) {
const keybind = useKeybind()
return (
<box
id={props.id}
@ -201,8 +206,8 @@ function Option(props: {
</text>
<text fg={props.active ? Theme.background : Theme.textMuted}> {props.description}</text>
</box>
<Show when={props.keybind}>
<text fg={props.active ? Theme.background : Theme.textMuted}>{keybind.print(props.keybind as any)}</text>
<Show when={props.footer}>
<text fg={props.active ? Theme.background : Theme.textMuted}>{props.footer}</text>
</Show>
</box>
)

View file

@ -1,6 +1,6 @@
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { createContext, For, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { Theme } from "../context/theme"
import { Theme } from "@tui/context/theme"
import { RGBA } from "@opentui/core"
import { createStore, produce } from "solid-js/store"

View file

@ -7,4 +7,13 @@ export namespace Locale {
const date = new Date(input)
return date.toLocaleTimeString()
}
export function number(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + "M"
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + "K"
}
return num.toString()
}
}

View file

@ -5,6 +5,11 @@
"jsx": "preserve",
"jsxImportSource": "@opentui/solid",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"customConditions": ["development", "browser"]
"customConditions": ["development", "browser"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
}
}
}