mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-01 16:03:19 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa9fc9135a | |||
| c391983ae0 | |||
| f158abd694 | |||
| 0e3da4b4f1 |
@@ -4,6 +4,13 @@
|
||||
- The default branch in this repo is `dev`.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"packageManager": "bun@1.3.14",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
|
||||
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -45,7 +45,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
const runPromise = Effect.runPromiseWith(context)
|
||||
const service = server.service
|
||||
yield* run({
|
||||
app: { name: process.env.OPENCODE_CLIENT ?? "cli", version: OPENCODE_VERSION, channel: OPENCODE_CHANNEL },
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? "cli",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: process.env.OPENCODE_TUI_CHANNEL ?? OPENCODE_CHANNEL,
|
||||
},
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
service: service
|
||||
|
||||
+20
-11
@@ -68,6 +68,7 @@ import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { DialogOpen } from "./component/dialog-open"
|
||||
import { SessionTabs } from "./component/session-tabs"
|
||||
import { sessionTabsFitVertically } from "./ui/layout"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
@@ -83,6 +84,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
@@ -208,9 +210,13 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
const options = { baseUrl: input.server.endpoint.url, headers: Service.headers(input.server.endpoint) }
|
||||
const api = OpenCode.make(options)
|
||||
const directory = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location.directory),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get()).pipe(Effect.map((response) => response.directory))),
|
||||
const location = yield* Effect.tryPromise(() => api.file.list({ location: { directory: process.cwd() } })).pipe(
|
||||
Effect.map((response) => response.location),
|
||||
Effect.catch(() => Effect.tryPromise(() => api.location.get())),
|
||||
)
|
||||
const directory = location.directory
|
||||
const pluginDirectories = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config),
|
||||
)
|
||||
const handoff = input.terminalHandoff ? yield* Effect.promise(input.terminalHandoff) : undefined
|
||||
const managed = input.server.service
|
||||
@@ -378,7 +384,10 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
@@ -519,6 +528,9 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
||||
const tabsVisible = () =>
|
||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
@@ -1214,16 +1226,13 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="row">
|
||||
<Show when={tabsVisible() && tabsVertical()}>
|
||||
<SessionTabs orientation="vertical" />
|
||||
</Show>
|
||||
<box flexGrow={1} minWidth={0} flexDirection="column">
|
||||
<Show when={plugins.ready()}>
|
||||
<box flexGrow={1} minHeight={0} flexDirection="column">
|
||||
<Show
|
||||
when={
|
||||
sessionTabs.enabled() &&
|
||||
(sessionTabs.tabs().length > 0 || sessionTabs.newTab()) &&
|
||||
route.data.type !== "plugin"
|
||||
}
|
||||
>
|
||||
<Show when={tabsVisible() && !tabsVertical()}>
|
||||
<SessionTabs />
|
||||
</Show>
|
||||
<Switch>
|
||||
|
||||
@@ -100,6 +100,15 @@ export const settings: Setting[] = [
|
||||
values: ["cwd", "global"],
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
{
|
||||
title: "Vertical",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "vertical"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "path"
|
||||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
@@ -18,6 +17,7 @@ import { truncateFilePath } from "../ui/file-path"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
const RECENT_LIMIT = 8
|
||||
|
||||
@@ -81,7 +81,7 @@ export function DialogOpen() {
|
||||
.slice(0, RECENT_LIMIT)
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = project?.canonical === "/" ? undefined : project?.name || path.basename(project?.canonical ?? "")
|
||||
const name = projectName(project)
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
@@ -109,7 +109,7 @@ export function DialogOpen() {
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = project.name ?? path.basename(project.canonical)
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useConfig } from "../config"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
@@ -123,8 +124,7 @@ export function DialogSessionList() {
|
||||
const current = data.location.info()
|
||||
if (!current) return ""
|
||||
const project = data.project.get(current.project.id)
|
||||
if (!project) return ""
|
||||
return project.name || path.basename(project.canonical)
|
||||
return projectName(project) ?? ""
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
@@ -141,9 +141,7 @@ export function DialogSessionList() {
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const project = data.project.get(session.projectID)
|
||||
const footer = allProjects()
|
||||
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
|
||||
: undefined
|
||||
const footer = allProjects() ? Locale.truncate(projectName(project, directory) ?? "", 20) : undefined
|
||||
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
|
||||
const deleting = toDelete() === session.id
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { For, Show, createComputed, createEffect, createMemo, createSignal, untrack } from "solid-js"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useConfig } from "../config"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useData } from "../context/data"
|
||||
import { useTheme, useThemes } from "../context/theme"
|
||||
import {
|
||||
adaptiveSessionTabLayout,
|
||||
@@ -19,6 +20,8 @@ import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { projectName } from "../util/project"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -39,8 +42,349 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
|
||||
}
|
||||
|
||||
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
|
||||
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number) =>
|
||||
tint(base, glow, 0.12 * unreadGlowIntensity(index, width))
|
||||
|
||||
export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
export function SessionTabs(
|
||||
props: { controller?: SessionTabsController; animations?: boolean; orientation?: "horizontal" | "vertical" } = {},
|
||||
) {
|
||||
if (props.orientation === "vertical")
|
||||
return <VerticalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
return <HorizontalSessionTabs controller={props.controller} animations={props.animations} />
|
||||
}
|
||||
|
||||
function VerticalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean }) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const data = useData()
|
||||
const theme = useTheme("elevated")
|
||||
const { mode } = useThemes()
|
||||
const config = useConfig().data
|
||||
const animations = () => props.animations ?? config.animations ?? true
|
||||
const width = () => SESSION_SIDEBAR_WIDTH
|
||||
const hueStep = () => (mode() === "light" ? 800 : 200)
|
||||
const accent = () => theme.hue.accent[hueStep()]
|
||||
const activeNumber = () => theme.hue.interactive[hueStep()]
|
||||
const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35)
|
||||
const [hovered, setHovered] = createSignal<string>()
|
||||
const [dragging, setDragging] = createSignal<string>()
|
||||
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
|
||||
const newTab = () => tabs.newTab?.() ?? false
|
||||
const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current()))
|
||||
const ordered = createMemo(() => {
|
||||
const pending = preview()
|
||||
if (!pending) return tabs.tabs()
|
||||
return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index)
|
||||
})
|
||||
const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered()))
|
||||
const statuses = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
items().map((tab) => {
|
||||
const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID)
|
||||
return [
|
||||
tab.sessionID,
|
||||
{
|
||||
...status,
|
||||
complete: sessionTabComplete(status.unread, status.busy),
|
||||
runs: status.busy && !status.attention,
|
||||
glows:
|
||||
tab.sessionID !== activeID() && (status.attention || (!status.busy && status.unread !== undefined)),
|
||||
},
|
||||
] as const
|
||||
}),
|
||||
),
|
||||
)
|
||||
const itemStatus = (tab: SessionTab) => statuses().get(tab.sessionID)!
|
||||
let rail: { screenY: number } | undefined
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const pending = preview()
|
||||
if (!pending || dragging()) return
|
||||
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
|
||||
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!scroll) return
|
||||
const index = items().findIndex((tab) => tab.sessionID === activeID())
|
||||
if (index === -1) return
|
||||
const top = index * 3
|
||||
if (top < scroll.scrollTop) return scroll.scrollTo(top)
|
||||
if (top + 2 > scroll.scrollTop + scroll.viewport.height) {
|
||||
scroll.scrollTo(top + 2 - scroll.viewport.height)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
ref={(element) => (rail = element)}
|
||||
width={width()}
|
||||
height="100%"
|
||||
flexShrink={0}
|
||||
flexDirection="column"
|
||||
paddingTop={1}
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<scrollbox ref={(element) => (scroll = element)} flexGrow={1} scrollbarOptions={{ visible: false }}>
|
||||
<box flexShrink={0} flexDirection="column" gap={1}>
|
||||
<For each={items()}>
|
||||
{(tab, index) => {
|
||||
const selected = () => activeID() === tab.sessionID
|
||||
const status = createMemo(() => itemStatus(tab))
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID)))
|
||||
const project = createMemo(() => {
|
||||
const value = session()
|
||||
return value ? data.project.get(value.projectID) : undefined
|
||||
})
|
||||
const numberWidth = () => String(index() + 1).length + 1
|
||||
const titleWidth = () => Math.max(1, width() - numberWidth() - 2 - (hovered() === tab.sessionID ? 1 : 0))
|
||||
const title = () => tab.title ?? "Untitled session"
|
||||
const visibleTitle = createMemo(() => Locale.takeWidth(title(), titleWidth()))
|
||||
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
|
||||
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
|
||||
const detail = createMemo(() => {
|
||||
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
|
||||
const value = session()
|
||||
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
|
||||
})
|
||||
const background = createMemo(() => {
|
||||
if (selected()) return theme.background.action.primary.selected
|
||||
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
|
||||
return theme.background.action.primary.hovered
|
||||
return theme.background.default
|
||||
})
|
||||
const pulseBackground = createMemo(() => tint(theme.background.default, background(), background().a))
|
||||
const numberColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
const base =
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), Number(selected()))
|
||||
const color = tint(base, accent(), Number(complete()))
|
||||
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 complete = () => status().complete
|
||||
const glowHue = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const pulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.25))
|
||||
const glowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.45))
|
||||
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
|
||||
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
|
||||
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
|
||||
const glows = () => status().glows
|
||||
const previous = createMemo(() => items()[index() - 1])
|
||||
const previousStatus = createMemo(() => {
|
||||
const tab = previous()
|
||||
return tab
|
||||
? itemStatus(tab)
|
||||
: { ...EMPTY_SESSION_TAB_STATUS, complete: false, runs: false, glows: false }
|
||||
})
|
||||
const previousGlows = () => previousStatus().glows
|
||||
const runs = () => status().runs
|
||||
const previousRuns = () => previousStatus().runs
|
||||
const previousGlowHue = () => {
|
||||
if (previousStatus().attention) return theme.text.feedback.warning.default
|
||||
if (previousStatus().unread === "error") return theme.text.feedback.error.default
|
||||
return accent()
|
||||
}
|
||||
const separatorUpperColor = createMemo(() => tint(theme.background.default, previousGlowHue(), 0.1))
|
||||
const separatorLowerColor = createMemo(() => tint(theme.background.default, glowHue(), 0.12))
|
||||
const separatorUpperPulseColor = createMemo(() =>
|
||||
tint(theme.background.default, theme.text.default, 0.04),
|
||||
)
|
||||
const separatorLowerPulseColor = createMemo(() =>
|
||||
tint(theme.background.default, theme.text.default, 0.05),
|
||||
)
|
||||
const titleColor = (index: number) => {
|
||||
const color = glows()
|
||||
? glowTextColor(foreground(), glowColor(), 1 + numberWidth() + index, width())
|
||||
: foreground()
|
||||
if (!titleFades() || index < visibleTitleParts().length - FADE_WIDTH) return color
|
||||
const position = index - (visibleTitleParts().length - FADE_WIDTH)
|
||||
return tint(color, pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
const release = () => {
|
||||
setDragging(undefined)
|
||||
const pending = preview()
|
||||
if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index)
|
||||
if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID)
|
||||
}
|
||||
return (
|
||||
<box
|
||||
height={2}
|
||||
width="100%"
|
||||
position="relative"
|
||||
flexDirection="column"
|
||||
backgroundColor={background()}
|
||||
onMouseOver={() => setHovered(tab.sessionID)}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseDown={() => setDragging(tab.sessionID)}
|
||||
onMouseUp={release}
|
||||
onMouseDrag={(event) => {
|
||||
if (!rail || tab === NEW_SESSION_TAB) return
|
||||
const target = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
tabs.tabs().length - 1,
|
||||
Math.floor((event.y - rail.screenY - 1 + (scroll?.scrollTop ?? 0)) / 3),
|
||||
),
|
||||
)
|
||||
if (target !== index() && preview()?.index !== target)
|
||||
setPreview({ sessionID: tab.sessionID, index: target })
|
||||
}}
|
||||
onMouseDragEnd={release}
|
||||
>
|
||||
<TabPulse
|
||||
top={-1}
|
||||
edge="above"
|
||||
enabled={animations()}
|
||||
layer={{
|
||||
active: runs(),
|
||||
promptPulse: status().promptPulse,
|
||||
complete: complete() && !status().attention,
|
||||
glow: glows(),
|
||||
breathe: status().attention,
|
||||
color: separatorLowerPulseColor(),
|
||||
glowColor: separatorLowerColor(),
|
||||
glowTail: 8,
|
||||
completionColor: separatorLowerColor(),
|
||||
}}
|
||||
edgeLayer={{
|
||||
active: previousRuns(),
|
||||
promptPulse: previousStatus().promptPulse,
|
||||
complete: previousStatus().complete && !previousStatus().attention,
|
||||
glow: previousGlows(),
|
||||
breathe: previousStatus().attention,
|
||||
color: separatorUpperPulseColor(),
|
||||
glowColor: separatorUpperColor(),
|
||||
glowTail: 5,
|
||||
completionColor: separatorUpperColor(),
|
||||
}}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
<Show when={index() === items().length - 1}>
|
||||
<TabPulse
|
||||
top={2}
|
||||
edge="below"
|
||||
enabled={animations()}
|
||||
layer={{
|
||||
active: runs(),
|
||||
promptPulse: status().promptPulse,
|
||||
complete: complete() && !status().attention,
|
||||
glow: glows(),
|
||||
breathe: status().attention,
|
||||
color: tint(theme.background.default, theme.text.default, 0.04),
|
||||
glowColor: tint(theme.background.default, glowHue(), 0.1),
|
||||
glowTail: 8,
|
||||
completionColor: tint(theme.background.default, glowHue(), 0.1),
|
||||
}}
|
||||
edgeLayer={{
|
||||
color: tint(theme.background.default, theme.text.default, 0.006),
|
||||
glowColor: theme.background.default,
|
||||
glowTail: 5,
|
||||
completionColor: theme.background.default,
|
||||
}}
|
||||
backgroundColor={theme.background.default}
|
||||
/>
|
||||
</Show>
|
||||
<box height={1} width="100%" flexDirection="row" position="relative">
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
layer={{
|
||||
active: status().busy && !status().attention,
|
||||
promptPulse: status().promptPulse,
|
||||
complete: complete() && !status().attention,
|
||||
glow: glows(),
|
||||
breathe: status().attention,
|
||||
color: pulseColor(),
|
||||
glowColor: glowColor(),
|
||||
completionColor: glowColor(),
|
||||
}}
|
||||
backgroundColor={pulseBackground()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{index() + 1}
|
||||
</text>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
wrapMode="none"
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
<Show when={glows() || titleFades()} fallback={visibleTitle()}>
|
||||
<For each={visibleTitleParts()}>
|
||||
{(character, index) => <span style={{ fg: titleColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
<text
|
||||
position="absolute"
|
||||
right={1}
|
||||
zIndex={2}
|
||||
width={1}
|
||||
fg={theme.text.subdued}
|
||||
selectable={false}
|
||||
onMouseUp={(event) => {
|
||||
if (hovered() !== tab.sessionID) return
|
||||
event.stopPropagation()
|
||||
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
|
||||
}}
|
||||
>
|
||||
{hovered() === tab.sessionID ? "×" : ""}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box height={1} width="100%" position="relative" flexDirection="row">
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
layer={{
|
||||
active: status().busy && !status().attention,
|
||||
promptPulse: status().promptPulse,
|
||||
complete: complete() && !status().attention,
|
||||
glow: glows(),
|
||||
breathe: status().attention,
|
||||
color: detailPulseColor(),
|
||||
glowColor: detailGlowColor(),
|
||||
glowTail: 10,
|
||||
completionColor: detailGlowColor(),
|
||||
}}
|
||||
backgroundColor={pulseBackground()}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
|
||||
<text fg={detailColor()} wrapMode="none" selectable={false}>
|
||||
{detail()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HorizontalSessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) {
|
||||
const tabs = props.controller ?? useSessionTabs()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
@@ -269,9 +613,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows()
|
||||
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
|
||||
: base
|
||||
const color = glows() ? glowTextColor(base, glowColor(), 1 + numberWidth() + index, width()) : base
|
||||
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
|
||||
const position = index - (displayedParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
@@ -320,15 +662,17 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
>
|
||||
<TabPulse
|
||||
enabled={animations()}
|
||||
active={status().busy && !status().attention}
|
||||
promptPulse={status().promptPulse}
|
||||
complete={status().complete && !status().attention}
|
||||
glow={glows()}
|
||||
breathe={status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={glowColor()}
|
||||
flashColor={flashColor()}
|
||||
completionColor={accent()}
|
||||
layer={{
|
||||
active: status().busy && !status().attention,
|
||||
promptPulse: status().promptPulse,
|
||||
complete: status().complete && !status().attention,
|
||||
glow: glows(),
|
||||
breathe: status().attention,
|
||||
color: pulseColor(),
|
||||
glowColor: glowColor(),
|
||||
flashColor: flashColor(),
|
||||
completionColor: accent(),
|
||||
}}
|
||||
backgroundColor={background()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { OptimizedBuffer, Renderable, RGBA, type RenderableOptions, type RenderContext } from "@opentui/core"
|
||||
import { extend } from "@opentui/solid"
|
||||
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
enabled?: boolean
|
||||
export type TabPulseLayer = {
|
||||
active?: boolean
|
||||
promptPulse?: number
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
breathe?: boolean
|
||||
color?: RGBA
|
||||
color: RGBA
|
||||
glowColor?: RGBA
|
||||
glowTail?: number
|
||||
flashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
}
|
||||
|
||||
type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
edge?: "above" | "below"
|
||||
enabled?: boolean
|
||||
layer?: TabPulseLayer
|
||||
edgeLayer?: TabPulseLayer
|
||||
backgroundColor?: RGBA
|
||||
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
|
||||
onLevel?: (level: number) => void
|
||||
@@ -20,6 +27,7 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
const smootherstep = (value: number) => value * value * value * (value * (value * 6 - 15) + 10)
|
||||
const RUN_DURATION = 2_800
|
||||
const RUN_ATTACK = 450
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
@@ -58,9 +66,10 @@ const attackDecay = (progress: number, attack: number, peak: number, rest: numbe
|
||||
export const completionPulseOpacity = (progress: number) => attackDecay(progress, COMPLETION_ATTACK, 1, 0)
|
||||
export const glowIgnitionLevel = (progress: number) =>
|
||||
attackDecay(progress, GLOW_IGNITION_ATTACK, GLOW_IGNITION_PEAK, 1)
|
||||
export const unreadGlowIntensity = (index: number, width: number) => {
|
||||
const tail = Math.min(GLOW_TAIL, Math.max(1, width - 2))
|
||||
return smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
const glowIntensityAt = (index: number, tail: number) => smootherstep(clamp(1 - Math.max(0, index - 1) / tail))
|
||||
export const unreadGlowIntensity = (index: number, width: number, maximumTail = GLOW_TAIL) => {
|
||||
const tail = Math.min(maximumTail, Math.max(1, width - 2))
|
||||
return glowIntensityAt(index, tail)
|
||||
}
|
||||
export function blendTabPulseColor(
|
||||
output: RGBA,
|
||||
@@ -131,45 +140,259 @@ class Envelope {
|
||||
// Hoisted so the per-frame liveness check allocates no closure.
|
||||
const envelopeActive = (envelope: Envelope) => envelope.active
|
||||
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private _active: boolean
|
||||
private _promptPulse: number
|
||||
private _complete: boolean
|
||||
private _glow: boolean
|
||||
private _breathe: boolean
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _flashColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
type PulseStateOptions = {
|
||||
enabled: boolean
|
||||
active: boolean
|
||||
promptPulse: number
|
||||
complete: boolean
|
||||
glow: boolean
|
||||
breathe: boolean
|
||||
}
|
||||
|
||||
class PulseState {
|
||||
private enabled: boolean
|
||||
private active: boolean
|
||||
private promptPulse: number
|
||||
private complete: boolean
|
||||
private glow: boolean
|
||||
private breathe: boolean
|
||||
private clock = 0
|
||||
private breatheClock = 0
|
||||
private completionPending = false
|
||||
private runAttack = new Envelope(RUN_ATTACK, smootherstep)
|
||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
private renderColor = RGBA.fromInts(0, 0, 0)
|
||||
private envelopes = [this.runAttack, this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
|
||||
constructor(options: PulseStateOptions) {
|
||||
this.enabled = options.enabled
|
||||
this.active = options.active
|
||||
this.promptPulse = options.promptPulse
|
||||
this.complete = options.complete
|
||||
this.glow = options.glow
|
||||
this.breathe = options.breathe
|
||||
if (this.enabled && this.active) this.runAttack.start()
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this.enabled && this.glow && this.breathe
|
||||
}
|
||||
|
||||
get live() {
|
||||
return this.active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
}
|
||||
|
||||
get running() {
|
||||
if (!this.enabled) return 0
|
||||
return this.active ? (this.runAttack.active ? this.runAttack.level() : 1) : this.runFade.level()
|
||||
}
|
||||
|
||||
get completion() {
|
||||
return this.completionPulse.level() * COMPLETION_OPACITY
|
||||
}
|
||||
|
||||
get flash() {
|
||||
return this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
}
|
||||
|
||||
get glowLevel() {
|
||||
if (!this.glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
}
|
||||
|
||||
setEnabled(value: boolean) {
|
||||
if (value === this.enabled) return false
|
||||
this.enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
} else if (this.active) {
|
||||
this.runAttack.restart()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setActive(value: boolean) {
|
||||
if (value === this.active) return false
|
||||
this.active = value
|
||||
if (!this.enabled) return true
|
||||
if (value) {
|
||||
this.clock = 0
|
||||
this.runAttack.restart()
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
const level = this.runAttack.active ? this.runAttack.level() : 1
|
||||
this.runAttack.stop()
|
||||
this.runFade.start(level)
|
||||
this.completionPending = true
|
||||
}
|
||||
this.edgeFlash.start()
|
||||
return true
|
||||
}
|
||||
|
||||
setPromptPulse(value: number) {
|
||||
if (value === this.promptPulse) return false
|
||||
this.promptPulse = value
|
||||
if (this.enabled) this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
return true
|
||||
}
|
||||
|
||||
setComplete(value: boolean) {
|
||||
if (value === this.complete) return false
|
||||
this.complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this.enabled) this.completionPulse.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setGlow(value: boolean) {
|
||||
if (value === this.glow) return false
|
||||
if (this.enabled && !value) this.glowOff.start(this.glowLevel)
|
||||
this.glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this.enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
setBreathe(value: boolean) {
|
||||
if (value === this.breathe) return false
|
||||
this.breathe = value
|
||||
this.breatheClock = 0
|
||||
return true
|
||||
}
|
||||
|
||||
advance(deltaTime: number) {
|
||||
if (!this.enabled) return
|
||||
if (this.active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (!this.completionPending) return
|
||||
if (this.complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
return
|
||||
}
|
||||
if (!this.runFade.active) this.completionPending = false
|
||||
}
|
||||
|
||||
fronts(width: number) {
|
||||
const cycles = this.clock / RUN_DURATION
|
||||
const progress = cycles % 1
|
||||
const start = -RUN_HEAD
|
||||
const end = width - 1 + RUN_TAIL
|
||||
const secondProgress = cycles < 0.5 ? 0 : (cycles + 0.5) % 1
|
||||
return [start + coast(progress) * (end - start), start + coast(secondProgress) * (end - start)] as const
|
||||
}
|
||||
}
|
||||
|
||||
class PulseLayer {
|
||||
readonly state: PulseState
|
||||
color: RGBA
|
||||
glowColor: RGBA
|
||||
glowTail: number
|
||||
flashColor: RGBA
|
||||
completionColor: RGBA
|
||||
|
||||
constructor(options: TabPulseLayer, enabled: boolean) {
|
||||
this.state = new PulseState({
|
||||
enabled,
|
||||
active: options.active ?? false,
|
||||
promptPulse: options.promptPulse ?? 0,
|
||||
complete: options.complete ?? false,
|
||||
glow: options.glow ?? false,
|
||||
breathe: options.breathe ?? false,
|
||||
})
|
||||
this.color = options.color
|
||||
this.glowColor = options.glowColor ?? options.color
|
||||
this.glowTail = options.glowTail ?? GLOW_TAIL
|
||||
this.flashColor = options.flashColor ?? options.color
|
||||
this.completionColor = options.completionColor ?? options.color
|
||||
}
|
||||
|
||||
set(options: TabPulseLayer) {
|
||||
const color = options.color
|
||||
const glowColor = options.glowColor ?? color
|
||||
const glowTail = options.glowTail ?? GLOW_TAIL
|
||||
const flashColor = options.flashColor ?? color
|
||||
const completionColor = options.completionColor ?? color
|
||||
const changed = [
|
||||
// Stopping a run arms completion; apply complete afterward so an atomic idle update can consume it.
|
||||
this.state.setActive(options.active ?? false),
|
||||
this.state.setPromptPulse(options.promptPulse ?? 0),
|
||||
this.state.setComplete(options.complete ?? false),
|
||||
this.state.setGlow(options.glow ?? false),
|
||||
this.state.setBreathe(options.breathe ?? false),
|
||||
!color.equals(this.color),
|
||||
!glowColor.equals(this.glowColor),
|
||||
glowTail !== this.glowTail,
|
||||
!flashColor.equals(this.flashColor),
|
||||
!completionColor.equals(this.completionColor),
|
||||
].some(Boolean)
|
||||
this.color = color
|
||||
this.glowColor = glowColor
|
||||
this.glowTail = glowTail
|
||||
this.flashColor = flashColor
|
||||
this.completionColor = completionColor
|
||||
return changed
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LAYER: TabPulseLayer = { color: RGBA.defaultForeground() }
|
||||
|
||||
class TabPulseRenderable extends Renderable {
|
||||
private _enabled: boolean
|
||||
private primary: PulseLayer
|
||||
private adjacent: PulseLayer
|
||||
private primaryAssigned: boolean
|
||||
private adjacentAssigned: boolean
|
||||
private _edge: "above" | "below" | undefined
|
||||
private _backgroundColor: RGBA
|
||||
private primaryRenderColor = RGBA.fromInts(0, 0, 0)
|
||||
private adjacentRenderColor = RGBA.fromInts(0, 0, 0)
|
||||
private _onLevel: ((level: number) => void) | undefined
|
||||
private lastLevel = 0
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
const active = options.active ?? false
|
||||
super(ctx, { ...options, height: 1, live: enabled && active })
|
||||
const primary = options.layer ?? DEFAULT_LAYER
|
||||
const adjacent = options.edgeLayer ?? primary
|
||||
const edge = options.edge
|
||||
super(ctx, {
|
||||
...options,
|
||||
height: 1,
|
||||
live:
|
||||
enabled &&
|
||||
((primary.active ?? false) ||
|
||||
((primary.glow ?? false) && (primary.breathe ?? false)) ||
|
||||
(edge !== undefined &&
|
||||
((adjacent.active ?? false) || ((adjacent.glow ?? false) && (adjacent.breathe ?? false))))),
|
||||
})
|
||||
this._enabled = enabled
|
||||
this._active = active
|
||||
this._promptPulse = options.promptPulse ?? 0
|
||||
this._complete = options.complete ?? false
|
||||
this._glow = options.glow ?? false
|
||||
this._breathe = options.breathe ?? false
|
||||
this._color = options.color ?? RGBA.defaultForeground()
|
||||
this._glowColor = options.glowColor ?? this._color
|
||||
this._flashColor = options.flashColor ?? this._color
|
||||
this._completionColor = options.completionColor ?? this._color
|
||||
this.primary = new PulseLayer(primary, enabled)
|
||||
this.adjacent = new PulseLayer(adjacent, enabled && edge !== undefined)
|
||||
this.primaryAssigned = options.layer !== undefined
|
||||
this.adjacentAssigned = options.edgeLayer !== undefined
|
||||
this._edge = edge
|
||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||
this._onLevel = options.onLevel
|
||||
}
|
||||
@@ -179,128 +402,46 @@ class TabPulseRenderable extends Renderable {
|
||||
}
|
||||
|
||||
private emitLevel(value: number) {
|
||||
if (!this._onLevel) return
|
||||
const quantized = Math.round(value * 32) / 32
|
||||
if (quantized === this.lastLevel) return
|
||||
this.lastLevel = quantized
|
||||
this._onLevel?.(quantized)
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
return this._enabled && this._glow && this._breathe
|
||||
}
|
||||
|
||||
/** Resting glow is 1; ignition overshoots on arrival, breathing swells while pending, glowOff decays after. */
|
||||
private glowLevel() {
|
||||
if (!this._glow) return this.glowOff.level()
|
||||
const base = this.ignition.active ? this.ignition.level() : 1
|
||||
if (!this.breathing) return base
|
||||
return (
|
||||
base * (1 + GLOW_BREATHE_RISE * 0.5 * (1 - Math.cos((2 * Math.PI * this.breatheClock) / GLOW_BREATHE_PERIOD)))
|
||||
)
|
||||
this._onLevel(quantized)
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
if (!value) {
|
||||
for (const envelope of this.envelopes) envelope.stop()
|
||||
this.completionPending = false
|
||||
this.breatheClock = 0
|
||||
this.live = false
|
||||
} else if (this._active || this.breathing) {
|
||||
this.live = true
|
||||
this.primary.state.setEnabled(value)
|
||||
this.adjacent.state.setEnabled(value && this._edge !== undefined)
|
||||
this.changed()
|
||||
}
|
||||
|
||||
set layer(value: TabPulseLayer) {
|
||||
if (!this.primaryAssigned) {
|
||||
this.primaryAssigned = true
|
||||
this.primary = new PulseLayer(value, this._enabled)
|
||||
this.changed()
|
||||
return
|
||||
}
|
||||
this.requestRender()
|
||||
if (this.primary.set(value)) this.changed()
|
||||
}
|
||||
|
||||
set active(value: boolean) {
|
||||
if (value === this._active) return
|
||||
this._active = value
|
||||
if (!this._enabled) return
|
||||
if (value) {
|
||||
this.runFade.stop()
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
} else {
|
||||
this.runFade.start()
|
||||
this.completionPending = true
|
||||
set edgeLayer(value: TabPulseLayer) {
|
||||
if (!this.adjacentAssigned) {
|
||||
this.adjacentAssigned = true
|
||||
this.adjacent = new PulseLayer(value, this._enabled && this._edge !== undefined)
|
||||
this.changed()
|
||||
return
|
||||
}
|
||||
// The same neutral edge flash marks both the start and the finish of a run.
|
||||
this.edgeFlash.start()
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
if (this.adjacent.set(value)) this.changed()
|
||||
}
|
||||
|
||||
set promptPulse(value: number) {
|
||||
if (value === this._promptPulse) return
|
||||
this._promptPulse = value
|
||||
if (!this._enabled) return
|
||||
this.edgeFlash.restart(PROMPT_FLASH_SCALE)
|
||||
this.live = true
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set complete(value: boolean) {
|
||||
if (value === this._complete) return
|
||||
this._complete = value
|
||||
if (!value) {
|
||||
this.completionPulse.stop()
|
||||
this.completionPending = false
|
||||
}
|
||||
if (value && this.completionPending) {
|
||||
this.completionPending = false
|
||||
if (this._enabled) {
|
||||
this.completionPulse.start()
|
||||
this.live = true
|
||||
}
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glow(value: boolean) {
|
||||
if (value === this._glow) return
|
||||
if (this._enabled && !value) this.glowOff.start(this.glowLevel())
|
||||
this._glow = value
|
||||
this.ignition.stop()
|
||||
this.breatheClock = 0
|
||||
if (this._enabled && value) {
|
||||
this.glowOff.stop()
|
||||
this.ignition.start()
|
||||
this.live = true
|
||||
}
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set breathe(value: boolean) {
|
||||
if (value === this._breathe) return
|
||||
this._breathe = value
|
||||
this.breatheClock = 0
|
||||
if (this.breathing) this.live = true
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set color(value: RGBA) {
|
||||
if (value.equals(this._color)) return
|
||||
this._color = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set glowColor(value: RGBA) {
|
||||
if (value.equals(this._glowColor)) return
|
||||
this._glowColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set flashColor(value: RGBA) {
|
||||
if (value.equals(this._flashColor)) return
|
||||
this._flashColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set completionColor(value: RGBA) {
|
||||
if (value.equals(this._completionColor)) return
|
||||
this._completionColor = value
|
||||
this.requestRender()
|
||||
set edge(value: "above" | "below" | undefined) {
|
||||
if (value === this._edge) return
|
||||
this._edge = value
|
||||
this.adjacent.state.setEnabled(this._enabled && value !== undefined)
|
||||
this.changed()
|
||||
}
|
||||
|
||||
set backgroundColor(value: RGBA) {
|
||||
@@ -309,44 +450,52 @@ class TabPulseRenderable extends Renderable {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private changed() {
|
||||
this.live = this.primary.state.live || this.adjacent.state.live
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
protected override onUpdate(deltaTime: number): void {
|
||||
if (!this._enabled) return
|
||||
if (this._active || this.runFade.active) this.clock += deltaTime
|
||||
if (this.breathing) this.breatheClock += deltaTime
|
||||
for (const envelope of this.envelopes) envelope.advance(deltaTime)
|
||||
if (this.completionPending) {
|
||||
if (this._complete) {
|
||||
this.completionPending = false
|
||||
this.completionPulse.start()
|
||||
} else if (!this.runFade.active) {
|
||||
this.completionPending = false
|
||||
}
|
||||
}
|
||||
this.live = this._active || this.breathing || this.envelopes.some(envelopeActive)
|
||||
this.primary.state.advance(deltaTime)
|
||||
this.adjacent.state.advance(deltaTime)
|
||||
this.live = this.primary.state.live || this.adjacent.state.live
|
||||
}
|
||||
|
||||
protected override renderSelf(buffer: OptimizedBuffer): void {
|
||||
if (!this.visible || this.isDestroyed || this.width <= 0) return
|
||||
const running = !this._enabled ? 0 : this._active ? 1 : this.runFade.level()
|
||||
const completion = this.completionPulse.level() * COMPLETION_OPACITY
|
||||
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
|
||||
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
const glowLevel = this.glowLevel()
|
||||
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
|
||||
const running = this.primary.state.running
|
||||
const completion = this.primary.state.completion
|
||||
const flash = this.primary.state.flash
|
||||
const glowLevel = this.primary.state.glowLevel
|
||||
const adjacentRunning = this.adjacent.state.running
|
||||
const adjacentCompletion = this.adjacent.state.completion
|
||||
const adjacentFlash = this.adjacent.state.flash
|
||||
const adjacentGlowLevel = this.adjacent.state.glowLevel
|
||||
if (
|
||||
glowLevel === 0 &&
|
||||
running === 0 &&
|
||||
completion === 0 &&
|
||||
flash === 0 &&
|
||||
adjacentGlowLevel === 0 &&
|
||||
adjacentRunning === 0 &&
|
||||
adjacentCompletion === 0 &&
|
||||
adjacentFlash === 0
|
||||
) {
|
||||
this.emitLevel(0)
|
||||
return
|
||||
}
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
const [front, secondFront] = this.primary.state.fronts(this.width)
|
||||
const [adjacentFront, adjacentSecondFront] = this.adjacent.state.fronts(this.width)
|
||||
if (this._onLevel)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
const glowTail = Math.min(this.primary.glowTail, Math.max(1, this.width - 2))
|
||||
const adjacentGlowTail = Math.min(this.adjacent.glowTail, Math.max(1, this.width - 2))
|
||||
for (let index = 0; index < this.width; index++) {
|
||||
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
|
||||
const sweep =
|
||||
@@ -358,19 +507,50 @@ class TabPulseRenderable extends Renderable {
|
||||
) *
|
||||
0.14 *
|
||||
running
|
||||
const adjacentSweep =
|
||||
adjacentRunning === 0
|
||||
? 0
|
||||
: Math.max(
|
||||
intensityAt(index, adjacentFront, RUN_HEAD, RUN_TAIL),
|
||||
intensityAt(index, adjacentSecondFront, RUN_HEAD, RUN_TAIL),
|
||||
) *
|
||||
0.14 *
|
||||
adjacentRunning
|
||||
blendTabPulseColor(
|
||||
this.renderColor,
|
||||
this.primaryRenderColor,
|
||||
this._backgroundColor,
|
||||
this._glowColor,
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
|
||||
this.primary.glowColor,
|
||||
this.primary.color,
|
||||
this.primary.flashColor,
|
||||
this.primary.completionColor,
|
||||
glowLevel === 0 ? 0 : glowIntensityAt(index, glowTail) * GLOW_OPACITY * glowLevel,
|
||||
sweep,
|
||||
flash,
|
||||
completion,
|
||||
)
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
if (!this._edge) {
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.primaryRenderColor)
|
||||
continue
|
||||
}
|
||||
blendTabPulseColor(
|
||||
this.adjacentRenderColor,
|
||||
this._backgroundColor,
|
||||
this.adjacent.glowColor,
|
||||
this.adjacent.color,
|
||||
this.adjacent.flashColor,
|
||||
this.adjacent.completionColor,
|
||||
adjacentGlowLevel === 0 ? 0 : glowIntensityAt(index, adjacentGlowTail) * GLOW_OPACITY * adjacentGlowLevel,
|
||||
adjacentSweep,
|
||||
adjacentFlash,
|
||||
adjacentCompletion,
|
||||
)
|
||||
buffer.setCell(
|
||||
this.screenX + index,
|
||||
this.screenY,
|
||||
this._edge === "above" ? "▄" : "▀",
|
||||
this.primaryRenderColor,
|
||||
this.adjacentRenderColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,34 +564,25 @@ declare module "@opentui/solid" {
|
||||
extend({ tab_pulse: TabPulseRenderable })
|
||||
|
||||
export function TabPulse(props: {
|
||||
top?: number
|
||||
width?: number
|
||||
edge?: "above" | "below"
|
||||
enabled?: boolean
|
||||
active: boolean
|
||||
promptPulse?: number
|
||||
complete?: boolean
|
||||
glow?: boolean
|
||||
breathe?: boolean
|
||||
color: RGBA
|
||||
glowColor?: RGBA
|
||||
flashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
layer: TabPulseLayer
|
||||
edgeLayer?: TabPulseLayer
|
||||
backgroundColor: RGBA
|
||||
onLevel?: (level: number) => void
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
position="absolute"
|
||||
top={props.top}
|
||||
edge={props.edge}
|
||||
zIndex={0}
|
||||
width="100%"
|
||||
width={props.width ?? "100%"}
|
||||
enabled={props.enabled ?? true}
|
||||
active={props.active}
|
||||
promptPulse={props.promptPulse ?? 0}
|
||||
complete={props.complete ?? false}
|
||||
glow={props.glow ?? false}
|
||||
breathe={props.breathe ?? false}
|
||||
color={props.color}
|
||||
glowColor={props.glowColor ?? props.color}
|
||||
flashColor={props.flashColor ?? props.color}
|
||||
completionColor={props.completionColor ?? props.color}
|
||||
layer={props.layer}
|
||||
edgeLayer={props.edgeLayer ?? props.layer}
|
||||
backgroundColor={props.backgroundColor}
|
||||
onLevel={props.onLevel}
|
||||
/>
|
||||
|
||||
@@ -132,6 +132,9 @@ export const Info = Schema.Struct({
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
vertical: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
@@ -30,6 +30,7 @@ import { createSimpleContext } from "./helper"
|
||||
import { useConfig } from "../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
import { configDirectories } from "../util/config-directories"
|
||||
|
||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||
export type ThemeError = { name: string; error: Error }
|
||||
@@ -70,7 +71,7 @@ export type ThemeSource = Readonly<{
|
||||
|
||||
const themeSource: ThemeSource = {
|
||||
async discover() {
|
||||
return discoverThemes(themeDirectories(Global.Path.config, process.cwd()))
|
||||
return discoverThemes(configDirectories(Global.Path.config, process.cwd()))
|
||||
},
|
||||
subscribeRefresh(refresh) {
|
||||
process.on("SIGUSR2", refresh)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource, tuiPluginDirectory } from "./discovery"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
@@ -57,7 +57,7 @@ type Desired = Pick<Registration, "plugin" | "source" | "target" | "version" | "
|
||||
|
||||
const PluginContext = createContext<Value>()
|
||||
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
|
||||
export function PluginProvider(props: ParentProps<{ packages: PackageResolver; directories: string[] }>) {
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
@@ -171,10 +171,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
void enqueue(reconcile).catch(() => undefined)
|
||||
}, 100)
|
||||
})
|
||||
onCleanup(() => {
|
||||
const stopWatching = () => {
|
||||
clearTimeout(pending)
|
||||
watcher.dispose()
|
||||
})
|
||||
}
|
||||
onCleanup(stopWatching)
|
||||
|
||||
// Rebuild the plugin generation as resolve → compare → swap, mirroring the
|
||||
// core plugin registry: fold the ordered entries into a desired end state
|
||||
@@ -186,8 +187,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
// every watch event; remember them until the configuration changes.
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
const entries = [...(await discoverTuiPlugins(host.paths.cwd)), ...(config.data.plugins ?? [])]
|
||||
watcher.add(tuiPluginDirectory(host.paths.cwd))
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -210,7 +211,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const options = typeof entry === "string" ? undefined : entry.options
|
||||
// Watch even when the resolve below fails so fixing a broken plugin reloads it.
|
||||
const local = localSource(target, directory)
|
||||
if (local) watcher.add(fileURLToPath(local))
|
||||
if (local) await watcher.add(fileURLToPath(local))
|
||||
const previous = Object.values(store.registrations).find((registration) => registration.target === target)
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
@@ -363,6 +364,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
if (disposing) return disposing
|
||||
stopWatching()
|
||||
disposing = loading
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
|
||||
@@ -1,23 +1,47 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import { readdir, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import {
|
||||
isMissingPath,
|
||||
localProjectDirectory,
|
||||
projectConfigDirectories,
|
||||
} from "../util/config-directories"
|
||||
|
||||
const extensions = new Set([".cjs", ".cts", ".js", ".jsx", ".mjs", ".mts", ".ts", ".tsx"])
|
||||
|
||||
export function tuiPluginDirectory(cwd: string) {
|
||||
return path.join(cwd, ".opencode", "plugins", "tui")
|
||||
export async function tuiPluginDirectories(cwd: string, configDirectory: string) {
|
||||
const projectDirectory = await localProjectDirectory(cwd)
|
||||
const projectConfig = path.join(projectDirectory, ".opencode")
|
||||
const directories = [configDirectory, ...projectConfigDirectories(projectDirectory, cwd)]
|
||||
const exists = await Promise.all(
|
||||
directories.map((directory) => {
|
||||
if (directory === configDirectory || directory === projectConfig) return true
|
||||
return stat(directory).then(
|
||||
(info) => info.isDirectory(),
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return directories
|
||||
.filter((_, index) => exists[index])
|
||||
.map((directory) => path.join(directory, "plugins", "tui"))
|
||||
}
|
||||
|
||||
export async function discoverTuiPlugins(cwd: string) {
|
||||
const directory = tuiPluginDirectory(cwd)
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (error && typeof error === "object" && Reflect.get(error, "code") === "ENOENT") return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
export async function discoverTuiPlugins(directories: string[]) {
|
||||
return (
|
||||
await Promise.all(
|
||||
directories.map(async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: unknown) => {
|
||||
if (isMissingPath(error)) return []
|
||||
return Promise.reject(error)
|
||||
})
|
||||
return entries
|
||||
.filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && extensions.has(path.extname(entry.name)))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort()
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export function localSource(spec: string, directory: string) {
|
||||
|
||||
@@ -10,21 +10,30 @@ import { lstat, realpath, stat } from "fs/promises"
|
||||
// Directory targets are watched at their root only: edits to nested helper
|
||||
// files do not change the entrypoint mtime and are not detected. Watches are
|
||||
// never torn down individually (a stale watch costs one fs handle and a
|
||||
// spurious onChange); all die with dispose(). Failed or vanished watches are
|
||||
// forgotten so a later add() can re-arm once the path exists.
|
||||
// spurious onChange); all die with dispose(). Missing retryable targets are
|
||||
// polled until they can be armed without relying on a racy chain of ancestor
|
||||
// watches.
|
||||
export function createSourceWatcher(onChange: () => void) {
|
||||
const watchers = new Map<string, ReturnType<typeof watch>>()
|
||||
const watched = new Map<string, Set<string> | null>()
|
||||
const missing = new Set<string>()
|
||||
const arming = new Map<string, Promise<void>>()
|
||||
let disposed = false
|
||||
const notify = () => {
|
||||
if (!disposed) onChange()
|
||||
}
|
||||
const forget = (dir: string) => {
|
||||
watchers.get(dir)?.close()
|
||||
watchers.delete(dir)
|
||||
watched.delete(dir)
|
||||
}
|
||||
const arm = (target: string) => {
|
||||
stat(target)
|
||||
const arm = (target: string, retry: boolean) => {
|
||||
const active = arming.get(target)
|
||||
if (active) return active
|
||||
const result = stat(target)
|
||||
.then((info) => {
|
||||
if (disposed) return
|
||||
const appeared = missing.delete(target)
|
||||
const dir = info.isDirectory() ? target : path.dirname(target)
|
||||
// Directories accept every filename (null); files accept their basename.
|
||||
const name = info.isDirectory() ? null : path.basename(target)
|
||||
@@ -32,44 +41,69 @@ export function createSourceWatcher(onChange: () => void) {
|
||||
if (existing !== undefined) {
|
||||
if (name === null) watched.set(dir, null)
|
||||
else existing?.add(name)
|
||||
if (appeared) notify()
|
||||
return
|
||||
}
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
const watcher = watch(dir, (_event, filename) => {
|
||||
// A replaced directory keeps this watcher on the dead inode (Linux
|
||||
// emits rename, not error); forget it so a later add() re-arms on
|
||||
// the recreated path, and still schedule so reconcile runs now.
|
||||
if (!existsSync(dir)) {
|
||||
forget(dir)
|
||||
onChange()
|
||||
notify()
|
||||
return
|
||||
}
|
||||
// A null filename (platform-dependent) always schedules.
|
||||
const accept = watched.get(dir)
|
||||
if (filename && accept && !accept.has(filename.toString())) return
|
||||
onChange()
|
||||
notify()
|
||||
})
|
||||
watched.set(dir, name === null ? null : new Set([name]))
|
||||
// Reconcile after watcher errors so every source is re-added and any
|
||||
// temporarily unavailable target moves into the polling set.
|
||||
watcher.on("error", () => {
|
||||
forget(dir)
|
||||
notify()
|
||||
})
|
||||
// A watched directory can disappear out from under us; without a
|
||||
// listener the error event would crash the process. Forget the path
|
||||
// so a later add can re-arm once it exists again.
|
||||
watcher.on("error", () => forget(dir))
|
||||
watchers.set(dir, watcher)
|
||||
if (appeared) notify()
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.catch((error: unknown) => {
|
||||
if (!disposed && retry && isMissing(error)) missing.add(target)
|
||||
})
|
||||
.finally(() => arming.delete(target))
|
||||
arming.set(target, result)
|
||||
return result
|
||||
}
|
||||
const add = (target: string) => {
|
||||
arm(target)
|
||||
const add = async (target: string, retry: boolean) => {
|
||||
await arm(target, retry)
|
||||
// A symlinked source receives edits at its resolved target.
|
||||
lstat(target)
|
||||
await lstat(target)
|
||||
.then((info) => {
|
||||
if (!info.isSymbolicLink()) return
|
||||
return realpath(target).then(arm)
|
||||
return realpath(target).then((target) => arm(target, retry))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
clearInterval(poll)
|
||||
for (const watcher of watchers.values()) watcher.close()
|
||||
watchers.clear()
|
||||
watched.clear()
|
||||
missing.clear()
|
||||
}
|
||||
const poll = setInterval(() => missing.forEach((target) => arm(target, true)), 500)
|
||||
poll.unref()
|
||||
return {
|
||||
add: (target: string) => add(target, false),
|
||||
wait: (target: string) => add(target, true),
|
||||
dispose,
|
||||
}
|
||||
return { add, dispose }
|
||||
}
|
||||
|
||||
function isMissing(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const code = Reflect.get(error, "code")
|
||||
return code === "ENOENT" || code === "ENOTDIR"
|
||||
}
|
||||
|
||||
@@ -64,6 +64,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 { projectedPromptInput } from "../../prompt/codec"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
@@ -199,14 +200,19 @@ export function Session() {
|
||||
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
|
||||
const groupExploration = createMemo(() => config.session?.grouping !== "none")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const tabRailWidth = createMemo(() =>
|
||||
config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
||||
? SESSION_SIDEBAR_WIDTH
|
||||
: 0,
|
||||
)
|
||||
const wide = createMemo(() => dimensions().width - tabRailWidth() > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebarOpen()) return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const contentWidth = createMemo(() => dimensions().width - tabRailWidth() - (sidebarVisible() ? 42 : 0) - 4)
|
||||
const models = createMemo(() => data.location.model.list(location()) ?? [])
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PluginSlot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { SESSION_SIDEBAR_WIDTH } from "../../ui/layout"
|
||||
|
||||
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
const data = useData()
|
||||
@@ -18,7 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<Show when={session()}>
|
||||
<box
|
||||
backgroundColor={theme.background.default}
|
||||
width={42}
|
||||
width={SESSION_SIDEBAR_WIDTH}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { readdir, readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export function themeDirectories(config: string, cwd: string) {
|
||||
const directories: string[] = []
|
||||
for (let current = cwd; ; current = path.dirname(current)) {
|
||||
directories.push(path.join(current, ".opencode"))
|
||||
if (path.dirname(current) === current) break
|
||||
}
|
||||
return [config, ...directories.reverse()]
|
||||
}
|
||||
|
||||
export async function discoverThemes(directories: string[]) {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const directory of directories) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const SESSION_SIDEBAR_WIDTH = 42
|
||||
const SESSION_CONTENT_MIN_WIDTH = 44
|
||||
|
||||
export function sessionTabsFitVertically(total: number) {
|
||||
return total >= SESSION_SIDEBAR_WIDTH + SESSION_CONTENT_MIN_WIDTH
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import path from "node:path"
|
||||
import { stat } from "node:fs/promises"
|
||||
|
||||
export function configDirectories(config: string, cwd: string) {
|
||||
return [...new Set([config, ...ancestors(cwd).map((directory) => path.join(directory, ".opencode"))])]
|
||||
}
|
||||
|
||||
export function projectConfigDirectories(project: string, cwd: string) {
|
||||
const directories = ancestors(cwd)
|
||||
return directories
|
||||
.slice(directories.indexOf(path.resolve(project)))
|
||||
.map((directory) => path.join(directory, ".opencode"))
|
||||
}
|
||||
|
||||
export async function localProjectDirectory(cwd: string) {
|
||||
const directories = ancestors(cwd)
|
||||
const repositories = await Promise.all(
|
||||
directories.map((directory) =>
|
||||
Promise.all(
|
||||
[".git", ".hg"].map((name) =>
|
||||
stat(path.join(directory, name)).then(
|
||||
() => true,
|
||||
(error) => (isMissingPath(error) ? false : Promise.reject(error)),
|
||||
),
|
||||
),
|
||||
).then((matches) => matches.some(Boolean)),
|
||||
),
|
||||
)
|
||||
return directories.findLast((_, index) => repositories[index]) ?? path.resolve(cwd)
|
||||
}
|
||||
|
||||
export function isMissingPath(error: unknown) {
|
||||
if (!error || typeof error !== "object") return false
|
||||
const code = Reflect.get(error, "code")
|
||||
return code === "ENOENT" || code === "ENOTDIR"
|
||||
}
|
||||
|
||||
function ancestors(cwd: string) {
|
||||
const directories: string[] = []
|
||||
for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
|
||||
directories.push(current)
|
||||
if (path.dirname(current) === current) break
|
||||
}
|
||||
return directories.reverse()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import path from "path"
|
||||
|
||||
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
|
||||
const canonical = project?.canonical ?? fallback
|
||||
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
|
||||
return project?.name || path.basename(canonical)
|
||||
}
|
||||
@@ -20,10 +20,12 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
active={true}
|
||||
promptPulse={promptPulse()}
|
||||
color={background}
|
||||
flashColor={flash}
|
||||
layer={{
|
||||
active: true,
|
||||
promptPulse: promptPulse(),
|
||||
color: background,
|
||||
flashColor: flash,
|
||||
}}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
@@ -56,6 +58,78 @@ test("a prompt pulse restarts the neutral edge flash while the tab remains busy"
|
||||
}
|
||||
})
|
||||
|
||||
test("edge layers animate independently", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const lower = RGBA.fromHex("#ff0000")
|
||||
const upper = RGBA.fromHex("#0000ff")
|
||||
const [edgeGlow, setEdgeGlow] = createSignal(false)
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
edge="above"
|
||||
layer={{ color: background, glow: true, glowColor: lower }}
|
||||
edgeLayer={{ color: background, glow: edgeGlow(), glowColor: upper }}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const initial = app.captureSpans().lines[0]?.spans[0]
|
||||
expect(initial?.text.startsWith("▄")).toBeTrue()
|
||||
expect(initial?.fg.r ?? 0).toBeGreaterThan(initial?.fg.b ?? 0)
|
||||
expect(initial?.bg.equals(background)).toBeTrue()
|
||||
|
||||
setEdgeGlow(true)
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
const updated = app.captureSpans().lines[0]?.spans[0]
|
||||
expect(updated?.fg.r ?? 0).toBeGreaterThan(updated?.fg.b ?? 0)
|
||||
expect(updated?.bg.b ?? 0).toBeGreaterThan(updated?.bg.r ?? 0)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("an atomic active-to-complete layer update starts the completion pulse", async () => {
|
||||
const background = RGBA.fromHex("#101010")
|
||||
const completion = RGBA.fromHex("#f0f0f0")
|
||||
const [state, setState] = createSignal({ active: true, complete: false })
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<box width={8} height={1} backgroundColor={background}>
|
||||
<TabPulse
|
||||
layer={{
|
||||
...state(),
|
||||
color: background,
|
||||
completionColor: completion,
|
||||
}}
|
||||
backgroundColor={background}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
{ width: 8, height: 1 },
|
||||
)
|
||||
|
||||
const firstBackground = () => app.captureSpans().lines[0]?.spans[0]?.bg
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeTrue()
|
||||
|
||||
setState({ active: false, complete: true })
|
||||
await Bun.sleep(80)
|
||||
await app.renderOnce()
|
||||
expect(firstBackground()?.equals(background)).toBeFalse()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
|
||||
@@ -17,7 +17,7 @@ test("validates mini replay settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } })
|
||||
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { expect, test } from "bun:test"
|
||||
import { discoverTuiPlugins } from "../src/plugin/discovery"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "../src/plugin/discovery"
|
||||
import { localProjectDirectory } from "../src/util/config-directories"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("discovers project TUI plugin files in stable order", async () => {
|
||||
@@ -15,13 +16,57 @@ test("discovers project TUI plugin files in stable order", async () => {
|
||||
writeFile(path.join(directory, "nested", "ignored.ts"), "export default {}"),
|
||||
])
|
||||
|
||||
expect(await discoverTuiPlugins(tmp.path)).toEqual([
|
||||
path.join(directory, "first.js"),
|
||||
path.join(directory, "second.tsx"),
|
||||
])
|
||||
expect(
|
||||
await discoverTuiPlugins(await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))),
|
||||
).toEqual([path.join(directory, "first.js"), path.join(directory, "second.tsx")])
|
||||
})
|
||||
|
||||
test("returns no project TUI plugins when the directory is absent", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
expect(await discoverTuiPlugins(tmp.path)).toEqual([])
|
||||
const roots = await tuiPluginDirectories(tmp.path, path.join(tmp.path, "config"))
|
||||
expect(await discoverTuiPlugins(roots)).toEqual([])
|
||||
expect(roots).toContain(path.join(tmp.path, ".opencode", "plugins", "tui"))
|
||||
})
|
||||
|
||||
test("discovers global and ancestor plugin roots in precedence order", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const config = path.join(tmp.path, "config")
|
||||
const directories = [
|
||||
path.join(config, "plugins", "tui"),
|
||||
path.join(tmp.path, "repo", ".opencode", "plugins", "tui"),
|
||||
path.join(tmp.path, "repo", "packages", ".opencode", "plugins", "tui"),
|
||||
]
|
||||
const outside = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
await mkdir(path.join(project, ".git"), { recursive: true })
|
||||
await Promise.all([...directories, outside].map((directory) => mkdir(directory, { recursive: true })))
|
||||
await Promise.all(
|
||||
directories.map((directory, index) => writeFile(path.join(directory, `${index}.ts`), "export default {}")),
|
||||
)
|
||||
await writeFile(path.join(outside, "outside.ts"), "export default {}")
|
||||
|
||||
const roots = await tuiPluginDirectories(cwd, config)
|
||||
expect(await discoverTuiPlugins(roots)).toEqual(
|
||||
directories.map((directory, index) => path.join(directory, `${index}.ts`)),
|
||||
)
|
||||
expect(roots).not.toContain(path.join(cwd, ".opencode", "plugins", "tui"))
|
||||
expect(roots).not.toContain(outside)
|
||||
})
|
||||
|
||||
test("uses an Hg root for a missing project plugin directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const cwd = path.join(project, "package")
|
||||
await mkdir(path.join(project, ".hg"), { recursive: true })
|
||||
await mkdir(cwd, { recursive: true })
|
||||
|
||||
expect(await tuiPluginDirectories(cwd, path.join(tmp.path, "config"))).toContain(
|
||||
path.join(project, ".opencode", "plugins", "tui"),
|
||||
)
|
||||
})
|
||||
|
||||
test("propagates non-missing filesystem errors", async () => {
|
||||
await expect(localProjectDirectory("\0")).rejects.toBeInstanceOf(Error)
|
||||
await expect(discoverTuiPlugins(["\0"])).rejects.toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { expect, mock, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createEventStream, createFetch } from "./fixture/tui-client"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
function lifecycleSource(marker: string, id: string, version: string) {
|
||||
@@ -36,7 +35,16 @@ async function bootApp(directory: string) {
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch(undefined, events)
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/fs/list") return
|
||||
return json({
|
||||
location: {
|
||||
directory,
|
||||
project: { id: "proj_test", directory, canonical: directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const cwd = process.cwd()
|
||||
process.chdir(directory)
|
||||
@@ -49,7 +57,10 @@ async function bootApp(directory: string) {
|
||||
packages: { resolve: async () => undefined },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
}).pipe(
|
||||
Effect.provide(Global.layerWith({ config: path.join(directory, ".global") })),
|
||||
Effect.provide(FileSystem.layerNoop({})),
|
||||
),
|
||||
)
|
||||
return {
|
||||
task,
|
||||
@@ -62,6 +73,34 @@ async function bootApp(directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("discovers an ancestor TUI plugin directory created after startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await mkdir(path.join(tmp.path, "repo", ".git"))
|
||||
const ready = path.join(tmp.path, "ready.txt")
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const initial = path.join(cwd, ".opencode", "plugins", "tui")
|
||||
await mkdir(initial, { recursive: true })
|
||||
await writeFile(path.join(initial, "ready.ts"), lifecycleSource(ready, "test.ready", "ready"))
|
||||
|
||||
await using app = await bootApp(cwd)
|
||||
expect(await until(() => readFile(ready, "utf8"), (value) => value === "ready:setup\n")).toBe("ready:setup\n")
|
||||
const directory = path.join(tmp.path, "repo", ".opencode", "plugins", "tui")
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(path.join(directory, "hot.ts"), lifecycleSource(marker, "test.hot", "v1"))
|
||||
|
||||
expect(
|
||||
await until(
|
||||
() => readFile(marker, "utf8"),
|
||||
(value) => value === "v1:setup\n",
|
||||
),
|
||||
).toBe("v1:setup\n")
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("editing a discovered TUI plugin hot-reloads its fresh module", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
setCustomThemes,
|
||||
upsertTheme,
|
||||
} from "../src/theme"
|
||||
import { discoverThemes, themeDirectories } from "../src/theme/discovery"
|
||||
import { discoverThemes } from "../src/theme/discovery"
|
||||
import { configDirectories } from "../src/util/config-directories"
|
||||
import { terminalMode } from "../src/theme/system"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -187,7 +188,7 @@ test("theme directories include global config before project directories", async
|
||||
await writeFile(path.join(global, "themes", "global.json"), JSON.stringify({ source: "global" }))
|
||||
await writeFile(path.join(project, ".opencode", "themes", "project.json"), JSON.stringify({ source: "project" }))
|
||||
|
||||
await expect(discoverThemes(themeDirectories(global, project))).resolves.toEqual({
|
||||
await expect(discoverThemes(configDirectories(global, project))).resolves.toEqual({
|
||||
global: { source: "global" },
|
||||
project: { source: "project" },
|
||||
})
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { 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)
|
||||
})
|
||||
Reference in New Issue
Block a user