mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-05 00:16:22 +00:00
Compare commits
13
Commits
worktree-ui
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d791dfe67 | ||
|
|
f268956c75 | ||
|
|
3290a39667 | ||
|
|
51d69b26a0 | ||
|
|
8ff1ef1a62 | ||
|
|
90cd910f52 | ||
|
|
c3342ca812 | ||
|
|
7ca047b2b9 | ||
|
|
b78c2ea7b4 | ||
|
|
0e143437c8 | ||
|
|
bff58fc387 | ||
|
|
a26a978051 | ||
|
|
218a0dde97 |
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
|
||||
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
|
||||
@@ -34,8 +34,6 @@ export const ProviderTipSchema = Persistence.struct({
|
||||
dismissedAt: Schema.Finite,
|
||||
})
|
||||
|
||||
export const WorkspaceTipSchema = ProviderTipSchema
|
||||
|
||||
export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
@@ -101,15 +99,7 @@ export function NewSessionView(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewSessionTips
|
||||
workspaceEligible={
|
||||
!!props.project.selected() &&
|
||||
props.workspace.bar.visible() &&
|
||||
props.workspace.selection.value() !== "create" &&
|
||||
props.workspace.project.managed() === 0
|
||||
}
|
||||
onWorkspace={() => select("create")}
|
||||
/>
|
||||
<ProviderTip />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -128,90 +118,56 @@ export function NewSessionStatus(props: { visible: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
function ProviderTip() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [providerState, setProviderState, , providerReady] = persisted(
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
ProviderTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
|
||||
Persist.global("new-session.workspace-tip"),
|
||||
WorkspaceTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const workspaceVisible = createMemo(
|
||||
() =>
|
||||
props.workspaceEligible &&
|
||||
workspaceReady() &&
|
||||
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const providerVisible = createMemo(
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
providers.ready() &&
|
||||
providerReady() &&
|
||||
persistedReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
|
||||
if (providerVisible()) return "provider"
|
||||
if (workspaceVisible()) return "workspace"
|
||||
})
|
||||
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: () => tip() !== undefined,
|
||||
show: visible,
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
const open = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
props.onWorkspace()
|
||||
return
|
||||
}
|
||||
const openProviders = () => {
|
||||
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
})
|
||||
}
|
||||
const dismiss = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
return
|
||||
}
|
||||
setProviderState("dismissedAt", Date.now())
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="new-session-tip"
|
||||
data-visible={tip() !== undefined}
|
||||
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={open}
|
||||
onClick={openProviders}
|
||||
>
|
||||
<span class="truncate">
|
||||
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
|
||||
</span>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
@@ -220,7 +176,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
sameDirectory,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
workspaceSelectionDestination,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
@@ -56,45 +56,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
return current ? normalizeProjectInfo(current) : undefined
|
||||
})
|
||||
const [worktrees, worktreeActions] = createResource(
|
||||
() => currentProject()?.id,
|
||||
async (projectID) => ({
|
||||
projectID,
|
||||
items: await serverSDK.api.worktree
|
||||
.list({ projectID })
|
||||
.catch(() => (currentProject()?.id === projectID ? currentProject()?.worktrees : undefined) ?? []),
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
const worktreeItems = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const loaded = worktrees.latest
|
||||
return loaded?.projectID === project.id ? loaded.items : project.worktrees
|
||||
})
|
||||
const worktreeDirectories = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const directories = [
|
||||
...worktreeItems().map((item) => item.directory),
|
||||
...project.worktrees.map((item) => item.directory),
|
||||
...(project.sandboxes ?? []),
|
||||
]
|
||||
return directories
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
|
||||
})
|
||||
const managedWorktrees = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return 0
|
||||
return worktreeItems().filter(
|
||||
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
|
||||
).length
|
||||
})
|
||||
const visible = createMemo(() =>
|
||||
resolveNewSessionGit({
|
||||
projectVcs: currentProject()?.vcs,
|
||||
@@ -105,9 +66,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = currentProject()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) || worktreeDirectories().some((item) => sameDirectory(item, worktree))
|
||||
? worktree
|
||||
: undefined
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = currentProject()
|
||||
@@ -138,7 +97,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
() => undefined,
|
||||
)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
@@ -163,11 +122,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
workspace: createMemo(() => {
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return (
|
||||
current === "create" ||
|
||||
(!!project &&
|
||||
(isWorkspaceDirectory(project, current) || worktreeDirectories().some((item) => sameDirectory(item, current))))
|
||||
)
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
@@ -187,8 +142,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: worktreeDirectories,
|
||||
managed: managedWorktrees,
|
||||
workspaces: () => {
|
||||
const project = currentProject()
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: visible,
|
||||
branches: () => {
|
||||
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -26,9 +25,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
const branchTruncation = createTruncatedText()
|
||||
const focusWorktreeSearch = () =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
@@ -38,8 +34,8 @@ export function PromptWorkspaceSelector(props: {
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "plus"
|
||||
return "outline-worktree"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = { type: "select", value }
|
||||
@@ -70,14 +66,13 @@ export function PromptWorkspaceSelector(props: {
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
@@ -94,10 +89,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
@@ -117,8 +109,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.local.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
@@ -128,22 +126,24 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => select("create")}>
|
||||
<Icon name="plus" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.new.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</Tooltip>
|
||||
<Icon name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
@@ -156,11 +156,10 @@ export function PromptWorkspaceSelector(props: {
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
focusWorktreeSearch()
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.SubTrigger
|
||||
onClick={focusWorktreeSearch}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
@@ -171,7 +170,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
@@ -206,7 +205,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
@@ -214,11 +213,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
<Show when={search.workspaces.trim() && workspaces().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
@@ -238,7 +232,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
@@ -252,7 +245,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
<span class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
@@ -326,7 +319,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const truncation = createTruncatedText()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
@@ -346,27 +338,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={value()}
|
||||
disabled={!truncation.truncated()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{value()}
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createTruncatedText() {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
return {
|
||||
truncated,
|
||||
observe: (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +659,6 @@ export const dict = {
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
@@ -1239,43 +1238,30 @@ export const dict = {
|
||||
|
||||
"workspace.new": "New worktree",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Uses project’s current checkout",
|
||||
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.createFrom": "Create from branch",
|
||||
"session.new.workspace.branch.search.placeholder": "Search branches",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search worktrees",
|
||||
"session.new.workspace.search.empty": "No matching worktrees",
|
||||
"settings.tab.workspaces": "Worktrees",
|
||||
"settings.workspaces.description": "Review worktrees and manage disk usage",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No worktrees yet",
|
||||
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
|
||||
"settings.workspaces.empty": "No worktrees",
|
||||
"settings.workspaces.count.one": "{{count}} worktree",
|
||||
"settings.workspaces.count.other": "{{count}} worktrees",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.sessions.filtered.one": "{{count}} session",
|
||||
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all worktrees",
|
||||
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
|
||||
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
|
||||
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.one":
|
||||
"This will permanently delete {{count}} worktree without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.other":
|
||||
"This will permanently delete {{count}} worktrees without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.warning":
|
||||
"Worktrees with unmerged changes or active locations will be kept.",
|
||||
"settings.workspaces.delete.button": "Delete worktrees",
|
||||
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
|
||||
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked":
|
||||
"Linked sessions will remain, but their working directory will no longer exist.",
|
||||
"Linked sessions will remain, but their working directory will be permanently removed.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
@@ -1286,10 +1272,9 @@ export const dict = {
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating worktree",
|
||||
"workspace.lifecycle.created": "Worktree created",
|
||||
"workspace.lifecycle.deleting": "Deleting…",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with worktrees",
|
||||
"workspace.onboarding.description": "Each gets its own checkout",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.lifecycle.moving": "Moving to worktree",
|
||||
"workspace.lifecycle.set": "Worktree set",
|
||||
"session.summary.title": "Session details",
|
||||
@@ -1308,10 +1293,9 @@ export const dict = {
|
||||
"workspace.status.checking": "Checking for unmerged changes…",
|
||||
"workspace.status.error": "Unable to verify git status.",
|
||||
"workspace.status.clean": "No unmerged changes detected.",
|
||||
"workspace.status.dirty": "Unmerged changes will be lost.",
|
||||
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
|
||||
"workspace.delete.title": "Delete worktree",
|
||||
"workspace.delete.confirm": "Delete “{{name}}”?",
|
||||
"workspace.delete.location": "Location",
|
||||
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
|
||||
"workspace.delete.button": "Delete worktree",
|
||||
"workspace.reset.title": "Reset worktree",
|
||||
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { FileViewsSchema } from "@/workspaces/files/view-cache"
|
||||
@@ -12,7 +12,6 @@ describe("persisted consumer schemas", () => {
|
||||
test("onboarding and provider tip retain defaults and validate stored values", () => {
|
||||
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
|
||||
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
|
||||
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
|
||||
expect(onboarding({})).toEqual({ used: false })
|
||||
expect(onboarding({ used: "true" })).toEqual({ used: false })
|
||||
expect(onboarding({ used: true })).toEqual({ used: true })
|
||||
@@ -20,7 +19,6 @@ describe("persisted consumer schemas", () => {
|
||||
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
})
|
||||
|
||||
test("collapse records recover malformed entries without losing valid siblings", () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -185,7 +185,7 @@ function WorkspaceMoveAction(props: {
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
@@ -257,10 +257,7 @@ export function SessionSummaryPanel(props: {
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Project } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
@@ -27,6 +28,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const data = useData()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||
@@ -98,13 +100,13 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="plus" />
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
@@ -112,7 +114,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -122,6 +124,10 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Sub>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<Menu.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -1029,92 +1029,25 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion[data-removing="true"] {
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
padding-bottom 150ms ease-out,
|
||||
margin-bottom 150ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
|
||||
.settings-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
|
||||
> .settings-workspaces-row {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion[data-visible="true"] {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion > .settings-workspaces-empty {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-workspaces-row-motion {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row,
|
||||
.settings-workspaces-empty-motion {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1155,7 +1088,7 @@
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1165,11 +1098,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-workspaces-path-name {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.settings-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
@@ -1177,10 +1105,6 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-meta-project {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-active,
|
||||
.settings-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
@@ -1190,16 +1114,11 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-active {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.settings-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1210,8 +1129,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
@@ -1231,28 +1149,22 @@
|
||||
|
||||
.settings-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
@@ -36,7 +35,7 @@ const sections = [
|
||||
[
|
||||
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
|
||||
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
|
||||
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
|
||||
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
|
||||
],
|
||||
[
|
||||
{ value: "providers", icon: "providers", label: "settings.providers.title" },
|
||||
@@ -55,7 +54,6 @@ export const SettingsScreen: Component = () => {
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
@@ -148,14 +146,7 @@ export const SettingsScreen: Component = () => {
|
||||
</Show>
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Menu.RadioItem
|
||||
value={section.value}
|
||||
closeOnSelect
|
||||
onSelect={() => {
|
||||
if (section.value === "workspaces")
|
||||
setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Menu.RadioItem value={section.value} closeOnSelect>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Menu.RadioItem>
|
||||
@@ -181,12 +172,7 @@ export const SettingsScreen: Component = () => {
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger
|
||||
value={section.value}
|
||||
onClick={() => {
|
||||
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger value={section.value}>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Tabs.Trigger>
|
||||
@@ -222,10 +208,7 @@ export const SettingsScreen: Component = () => {
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={directory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
<SettingsWorkspaces activeDirectory={directory()} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={directory()} onBack={showProviders} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -48,7 +46,7 @@ type Workspace = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (props) => {
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -58,12 +56,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
deleting: [] as string[],
|
||||
removing: [] as string[],
|
||||
})
|
||||
createEffect(() => {
|
||||
props.resetProjectFilter()
|
||||
setStore("project", "all")
|
||||
})
|
||||
|
||||
const projectQuery = useQuery(() => ({
|
||||
@@ -119,7 +111,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
] as const,
|
||||
queryFn: () => loadSessions(workspaceDirectories()),
|
||||
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
|
||||
placeholderData: (previous) => previous,
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(() => {
|
||||
@@ -132,29 +123,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
)
|
||||
})
|
||||
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
||||
const workspacesWithoutSessions = createMemo(() => {
|
||||
if (sessionQuery.isPending || sessionQuery.isError) return []
|
||||
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
|
||||
})
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
|
||||
const project = projectName(workspace.project)
|
||||
const label = language.plural("settings.workspaces.sessions", count, {
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
const start = label.lastIndexOf(project)
|
||||
if (start < 0) return label
|
||||
return (
|
||||
<>
|
||||
{label.slice(0, start)}
|
||||
<span class="settings-workspaces-meta-project">{project}</span>
|
||||
{label.slice(start + project.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
@@ -182,64 +158,54 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
}
|
||||
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
|
||||
const messages = [
|
||||
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
|
||||
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
|
||||
result.dirty ? language.t("workspace.status.dirty") : undefined,
|
||||
].filter((message): message is string => message !== undefined)
|
||||
return messages
|
||||
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: result.active
|
||||
? language.t("settings.workspaces.delete.blocked.active")
|
||||
: inspectionMessages(result)[0],
|
||||
description: inspectionMessages(result)[0],
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
|
||||
const key = String(pathKey(workspace.directory))
|
||||
setStore("deleting", (items) => [...items, key])
|
||||
try {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
setStore("removing", (items) => [...items, key])
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
} finally {
|
||||
setStore("deleting", (items) => items.filter((item) => item !== key))
|
||||
setStore("removing", (items) => items.filter((item) => item !== key))
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
@@ -287,30 +253,13 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteAll.warning")}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const confirmDeleteWithoutSessions = () => {
|
||||
if (store.transaction || workspacesWithoutSessions().length === 0) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...workspacesWithoutSessions()]
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
@@ -322,47 +271,44 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
<>
|
||||
<div class="settings-tab-header settings-workspaces-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.workspaces.description")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-workspaces">
|
||||
<Show when={filtered().length > 0}>
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
<Show when={!projectQuery.isPending && !projectQuery.isError}>
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<Menu placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
@@ -375,85 +321,78 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Show when={workspacesWithoutSessions().length > 0}>
|
||||
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
|
||||
{language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
</Menu.Item>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<Menu.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
|
||||
<SettingsList>
|
||||
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
|
||||
<div class="settings-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="settings-workspaces-empty">
|
||||
<Show
|
||||
when={!projectQuery.isPending && !projectQuery.isError}
|
||||
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
|
||||
>
|
||||
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
|
||||
<span>{language.t("settings.workspaces.empty.description")}</span>
|
||||
</Show>
|
||||
{language.t(
|
||||
projectQuery.isPending
|
||||
? "common.loading"
|
||||
: projectQuery.isError
|
||||
? "common.requestFailed"
|
||||
: "settings.workspaces.empty",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace())
|
||||
const key = () => String(pathKey(workspace().directory))
|
||||
const deleting = () => store.deleting.includes(key())
|
||||
return (
|
||||
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-workspaces-row">
|
||||
<div class="settings-workspaces-row-header">
|
||||
<div class="settings-workspaces-copy">
|
||||
<div class="settings-workspaces-main">
|
||||
<WorkspacePath directory={workspace().directory} />
|
||||
<Tooltip
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
dir="ltr"
|
||||
aria-label={workspace.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-workspaces-row-actions">
|
||||
<Show
|
||||
when={deleting()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={lastActive(workspace())}>
|
||||
{(value) => (
|
||||
<Tooltip
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace().directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace())}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span class="settings-workspaces-active">
|
||||
{language.t("workspace.lifecycle.deleting")}
|
||||
</span>
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
@@ -462,7 +401,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
{(session) => (
|
||||
<div class="settings-workspaces-session">
|
||||
<span>{sessionLabel(session)}</span>
|
||||
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -471,48 +410,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Key>
|
||||
</SettingsList>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspacePath(props: { directory: string }) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const name = () => getFilename(props.directory)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={props.directory}
|
||||
placement="top-start"
|
||||
disabled={!truncated()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
|
||||
tabIndex={truncated() ? 0 : undefined}
|
||||
dir="ltr"
|
||||
aria-label={props.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
<span>{props.directory.slice(0, -name().length)}</span>
|
||||
<span class="settings-workspaces-path-name">{name()}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspaces(props: {
|
||||
title: string
|
||||
confirmation: string
|
||||
warning: string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
@@ -525,12 +434,13 @@ function DialogDeleteWorkspaces(props: {
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={props.title}
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>{props.confirmation}</div>
|
||||
<div>{props.warning}</div>
|
||||
</div>
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -539,7 +449,7 @@ function DialogDeleteWorkspaces(props: {
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.delete.button")}
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
@@ -562,7 +472,7 @@ function DialogDeleteWorkspace(props: {
|
||||
staleTime: 0,
|
||||
}))
|
||||
const descriptions = () => {
|
||||
if (status.isPending) return []
|
||||
if (status.isPending) return [language.t("workspace.status.checking")]
|
||||
if (status.isError) return [language.t("workspace.status.error")]
|
||||
if (!status.data) return []
|
||||
return props.inspectionMessages(status.data.result)
|
||||
@@ -577,20 +487,18 @@ function DialogDeleteWorkspace(props: {
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-11-regular text-v2-text-text-faint">
|
||||
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
|
||||
</span>
|
||||
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
</div>
|
||||
<div>{language.t("settings.workspaces.delete.warning")}</div>
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -601,7 +509,7 @@ function DialogDeleteWorkspace(props: {
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={status.isPending || status.isError}
|
||||
disabled={status.isPending || status.isError || status.data?.result.active}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue } from "effect"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
const update = yield* updater.run().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: {
|
||||
monitor: (notify, signal) =>
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify, signal) =>
|
||||
runPromise(
|
||||
updater.monitor((version) => Effect.sync(() => notify(version))),
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
|
||||
export type Mode = "default" | "service" | "stdio"
|
||||
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
yield* Updater.Service.pipe(
|
||||
Effect.flatMap((updater) =>
|
||||
Updater.pollUpdates({
|
||||
check: updater.run().pipe(
|
||||
Effect.flatMap((result) => {
|
||||
if (!result) return Effect.void
|
||||
if (result.type === "available") return server.updateAvailable(result.version)
|
||||
return server.updated(result.version)
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
return yield* options.mode === "service"
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type Policy = "disable" | "notify"
|
||||
export type Action = "none" | "notify"
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Action = "none" | "notify" | "auto"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
return "notify"
|
||||
return policy
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -6,14 +6,14 @@ describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
})
|
||||
|
||||
test("reports every available release", () => {
|
||||
@@ -23,6 +23,11 @@ describe("updater", () => {
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("automatically installs every available release when enabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export interface Interface {
|
||||
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
|
||||
readonly run: () => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export const monitorUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly inspect: () => Effect.Effect<string | undefined, Error>
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
export const pollUpdates = Effect.fnUntraced(function* (input: {
|
||||
readonly check: Effect.Effect<unknown>
|
||||
readonly initialDelay?: Duration.Input
|
||||
readonly interval?: Duration.Input
|
||||
}) {
|
||||
const interval = input.interval ?? "10 minutes"
|
||||
const initialDelay = input.initialDelay ?? "90 seconds"
|
||||
const check = Effect.gen(function* () {
|
||||
const version = yield* input.inspect()
|
||||
if (version !== undefined) yield* input.notify(version)
|
||||
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
|
||||
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
|
||||
return yield* input.check.pipe(
|
||||
Effect.repeat(Schedule.spaced(interval)),
|
||||
Effect.delay(input.initialDelay ?? "1 minute"),
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify") return value
|
||||
if (value === "auto") return "notify"
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
@@ -75,10 +75,10 @@ const make = Effect.gen(function* () {
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "notify"
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
try: (signal) =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: OPENCODE_VERSION,
|
||||
current,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
const next = action(current, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return undefined
|
||||
}
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return version
|
||||
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
|
||||
return { policy, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
|
||||
yield* Effect.logWarning("update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
yield* Ref.set(installedVersion, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -229,9 +235,36 @@ const make = Effect.gen(function* () {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
|
||||
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
|
||||
const check = Effect.fn("cli.updater.check")(function* () {
|
||||
if (OPENCODE_LOCAL)
|
||||
return {
|
||||
type: "unavailable" as const,
|
||||
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
|
||||
}
|
||||
const version = yield* latest()
|
||||
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
|
||||
const current = yield* Ref.get(installedVersion)
|
||||
if (action(current, version, "auto") === "none") {
|
||||
// An earlier check may have installed the update while this client is still running.
|
||||
return action(OPENCODE_VERSION, current, "auto") === "none"
|
||||
? undefined
|
||||
: { type: "installed" as const, version: current }
|
||||
}
|
||||
return { type: "available" as const, version }
|
||||
})
|
||||
|
||||
return Service.of({ monitor, apply, method, latest, upgrade })
|
||||
const run = Effect.fn("cli.updater.run")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (!result) return undefined
|
||||
if (result.policy === "notify") return { type: "available" as const, version: result.version }
|
||||
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
return { type: "installed" as const, version: result.version }
|
||||
},
|
||||
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
return Service.of({ run, check, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
|
||||
@@ -12,7 +12,8 @@ await Effect.runPromise(
|
||||
process.argv.slice(2),
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
|
||||
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed("2.0.0"),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("89 seconds")
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
expect(yield* Queue.take(updates)).toBe("2.0.0")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not notify when no update is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const updates = yield* Queue.unbounded<string>()
|
||||
yield* Updater.monitorUpdates({
|
||||
inspect: () => Effect.succeed(undefined),
|
||||
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(updates)).toBe(0)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.effect("polls after 1 minute and every 10 minutes after that", () =>
|
||||
Effect.gen(function* () {
|
||||
const checks = yield* Queue.unbounded<void>()
|
||||
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
|
||||
|
||||
yield* Effect.yieldNow
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("59 seconds")
|
||||
expect(yield* Queue.size(checks)).toBe(0)
|
||||
yield* TestClock.adjust("1 second")
|
||||
yield* Queue.take(checks)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
yield* Queue.take(checks)
|
||||
}),
|
||||
)
|
||||
@@ -1892,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify"
|
||||
update?: "disable" | "notify" | "auto"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
|
||||
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const nativeUpdate = own(input, "update")
|
||||
? input.update === "auto"
|
||||
? "notify"
|
||||
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
|
||||
@@ -409,19 +409,17 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
|
||||
sessionID: SessionSchema.ID,
|
||||
promotable: Promotable,
|
||||
) {
|
||||
const next = (delivery: Delivery) =>
|
||||
db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const steer = yield* next("steer")
|
||||
const steer = (yield* pendingSteers(db, sessionID))[0]
|
||||
if (steer) return fromRow(steer)
|
||||
if (promotable !== "input") return undefined
|
||||
const queued = yield* next("queue")
|
||||
const queued = yield* db
|
||||
.select()
|
||||
.from(SessionInboxTable)
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return queued ? fromRow(queued) : undefined
|
||||
})
|
||||
|
||||
@@ -490,9 +488,10 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
|
||||
})
|
||||
|
||||
/**
|
||||
* Promotes pending input into visible messages and returns the promoted count.
|
||||
* Steers always go first; only the "input" scope may fall through to one queued
|
||||
* input, and it then collects steers that arrived during promotion.
|
||||
* Promotes pending input into visible messages and returns the promoted count,
|
||||
* or undefined when the runner must first handle a pending control.
|
||||
* Steered compaction takes priority over pending prompts, without crossing a move.
|
||||
* Only the "input" scope may fall through to one queued input.
|
||||
*/
|
||||
export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
db: DatabaseService,
|
||||
@@ -506,6 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
const steers = yield* pendingSteers(db, sessionID)
|
||||
if (steers.length > 0 || scope === "steer") {
|
||||
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control === 0) return undefined
|
||||
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
|
||||
}
|
||||
|
||||
@@ -518,6 +518,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued) return 0
|
||||
if (queued.type === "compaction" || queued.type === "move") return undefined
|
||||
const promoted = yield* publish(db, bus, sessionID, [queued])
|
||||
const arrivedSteers = yield* pendingSteers(db, sessionID)
|
||||
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
@@ -536,4 +537,14 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
|
||||
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
|
||||
.orderBy(asc(SessionInboxTable.enqueued_seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => {
|
||||
// A move changes the context's Location: never pull compaction across it.
|
||||
// Within that boundary, compact before promoting even earlier steers so
|
||||
// their text stays verbatim after the checkpoint, not inside its summary.
|
||||
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
|
||||
return rows
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -147,7 +147,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
return yield* restore(
|
||||
const ready = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
@@ -156,6 +156,8 @@ const layer = Layer.effect(
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
// A control admitted during context preparation owns this boundary.
|
||||
if (promoted === undefined) return undefined
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
@@ -164,6 +166,7 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
|
||||
update:
|
||||
info.autoupdate === false
|
||||
? "disable"
|
||||
: info.autoupdate === "notify" || info.autoupdate === true
|
||||
: info.autoupdate === "notify"
|
||||
? "notify"
|
||||
: undefined,
|
||||
: info.autoupdate === true
|
||||
? "auto"
|
||||
: undefined,
|
||||
share: info.share ?? (info.autoshare ? "auto" : undefined),
|
||||
enterprise: info.enterprise,
|
||||
username: info.username,
|
||||
|
||||
@@ -666,14 +666,14 @@ describe("Config", () => {
|
||||
test("migrates the v1 update policy", () => {
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
|
||||
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
|
||||
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
|
||||
})
|
||||
|
||||
test("normalizes the previous native auto update policy", () => {
|
||||
test("normalizes the native auto update policy", () => {
|
||||
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
|
||||
type: "normalized",
|
||||
encoded: { update: "notify" },
|
||||
encoded: { update: "auto" },
|
||||
diagnostics: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1925,6 +1925,185 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual(["Replacement context"])
|
||||
})
|
||||
|
||||
for (const order of ["before", "between", "after"] as const) {
|
||||
scenario(`prioritizes manual compaction admitted ${order} two steers at the safe boundary`, function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("Active complete", "active"),
|
||||
TestLLM.text("## Objective\n- Active work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const compactID = SessionMessage.ID.create()
|
||||
if (order === "before") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const first = yield* s.admit("STEER_A")
|
||||
if (order === "between") yield* s.session.compact({ sessionID, id: compactID })
|
||||
const second = yield* s.admit("STEER_B")
|
||||
if (order === "after") yield* s.session.compact({ sessionID, id: compactID })
|
||||
expect((yield* s.session.compact({ sessionID })).id).toBe(compactID)
|
||||
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect(yield* s.inbox).toHaveLength(3)
|
||||
expect((yield* s.messages).some((message) => message.type === "compaction")).toBe(false)
|
||||
yield* active.finish
|
||||
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_A")
|
||||
expect(userTexts(s.requests[1])).not.toContain("STEER_B")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect((yield* s.messages).filter((message) => message.id === compactID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed" },
|
||||
])
|
||||
expect((yield* s.context).filter((message) => message.type === "user").map((message) => message.id)).toEqual([
|
||||
first.id,
|
||||
second.id,
|
||||
])
|
||||
// An advisory drain must not redeliver either steer or rerun compaction.
|
||||
const runner = yield* SessionRunner.Service
|
||||
yield* runner.drain({ sessionID, force: false })
|
||||
expect(s.requests).toHaveLength(3)
|
||||
})
|
||||
}
|
||||
|
||||
scenario("waits for active tools before prioritizing compaction over pending steers", function* (s) {
|
||||
yield* s.llm.push(
|
||||
TestLLM.tool("call-active", "echo", { text: "active" }),
|
||||
TestLLM.text("## Objective\n- Tool work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
yield* s.admit("Active work")
|
||||
const tools = yield* s.blockTools()
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* tools.started
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
expect(s.requests).toHaveLength(1)
|
||||
expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
yield* tools.release
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(s.requests[1].messages.some((message) => message.role === "tool")).toBe(true)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("rechecks compaction admitted during boundary context preparation", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.admit("STEER_B")
|
||||
const preparing = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
s.systemLoadHook = Deferred.succeed(preparing, undefined).pipe(Effect.andThen(Deferred.await(release)))
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("## Objective\n- Earlier work checkpoint", "summary"),
|
||||
TestLLM.text("Steers complete", "steers"),
|
||||
)
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(preparing)
|
||||
yield* s.session.compact({ sessionID })
|
||||
yield* Deferred.succeed(release, undefined)
|
||||
yield* Fiber.join(run)
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
for (const outcome of ["cancelled", "failed"] as const) {
|
||||
scenario(`preserves both earlier steers when prioritized compaction is ${outcome}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Active complete", "active"))
|
||||
yield* s.admit("Active work")
|
||||
const active = yield* s.resumePaused
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
if (outcome === "cancelled") yield* s.session.cancelInbox({ sessionID, inboxID: compact.id })
|
||||
if (outcome === "failed") yield* s.llm.push([LLMEvent.providerError({ message: "summary unavailable" })])
|
||||
yield* s.llm.push(TestLLM.text("Steers complete", "steers"))
|
||||
yield* active.finish
|
||||
|
||||
expect(s.requests).toHaveLength(outcome === "cancelled" ? 2 : 3)
|
||||
if (outcome === "failed") {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
})
|
||||
}
|
||||
if (outcome === "cancelled") expect((yield* s.messages).some((message) => message.id === compact.id)).toBe(false)
|
||||
expect(userTexts(s.requests[s.requests.length - 1]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(
|
||||
(yield* s.context)
|
||||
.filter((message) => message.id === first.id || message.id === second.id)
|
||||
.map((message) => message.id),
|
||||
).toEqual([first.id, second.id])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
scenario("keeps steers durable across interrupted priority compaction and replay", function* (s) {
|
||||
yield* s.runPrompt("Earlier work")
|
||||
const first = yield* s.admit("STEER_A")
|
||||
const second = yield* s.admit("STEER_B")
|
||||
yield* s.llm.push(TestLLM.text("## Objective\n- Interrupted checkpoint", "summary"))
|
||||
const summary = yield* s.llm.gate
|
||||
const compact = yield* s.session.compact({ sessionID })
|
||||
yield* summary.started
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
yield* s.session.interrupt(sessionID)
|
||||
yield* s.session.wait(sessionID)
|
||||
yield* summary.release
|
||||
expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject({ status: "failed" })
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect((yield* s.inbox).map((item) => item.id)).toEqual([first.id, second.id])
|
||||
|
||||
yield* s.llm.push(TestLLM.text("Recovered steers", "steers"))
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[2]).slice(-2)).toEqual(["STEER_A", "STEER_B"])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario("does not pull compaction across an earlier move", function* (s) {
|
||||
yield* s.admit("STEER_A")
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: {
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
projectID: Project.ID.global,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
yield* s.admit("STEER_B")
|
||||
yield* s.sessionInbox.admitCompaction({ id: SessionMessage.ID.create(), sessionID, delivery: "steer" })
|
||||
yield* s.llm.push(
|
||||
TestLLM.text("First steer complete", "first"),
|
||||
TestLLM.text("## Objective\n- Source work checkpoint", "summary"),
|
||||
TestLLM.text("Second steer complete", "second"),
|
||||
)
|
||||
yield* s.resume
|
||||
expect(s.requests).toHaveLength(3)
|
||||
expect(userTexts(s.requests[0])).toEqual(["STEER_A"])
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("Summarize only the history shown")
|
||||
expect(userTexts(s.requests[2]).at(-1)).toBe("STEER_B")
|
||||
expect(
|
||||
(yield* recordedEventTypes(sessionID)).filter(
|
||||
(type) => type === "session.moved.1" || type === "session.compaction.started.1",
|
||||
),
|
||||
).toEqual(["session.moved.1", "session.compaction.started.1"])
|
||||
})
|
||||
|
||||
scenario("runs steers before queued compaction and later queued input", function* (s) {
|
||||
s.currentModel = recoveryModel
|
||||
yield* s.llm.push(
|
||||
|
||||
@@ -162,6 +162,21 @@ type PromptFooterInput = {
|
||||
readonly showDetails: boolean
|
||||
}
|
||||
|
||||
export type PanelPresentation = "panel" | "fullscreen"
|
||||
|
||||
/** Client-local state of the selected session panel. The host owns its layout and input scope. */
|
||||
export interface PanelInput {
|
||||
/** Selected content name, set by ui.panel.open. Contributions decide whether to render it. */
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
readonly width: number
|
||||
readonly presentation: PanelPresentation
|
||||
readonly focused: boolean
|
||||
readonly focus: () => void
|
||||
readonly close: () => void
|
||||
readonly toggleFullscreen: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
@@ -180,6 +195,7 @@ export interface SlotMap {
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "session.panel": PanelInput
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": { readonly sessionID: string }
|
||||
}
|
||||
@@ -450,6 +466,14 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly panel: {
|
||||
/** Opens the session.panel slot in the current session. */
|
||||
open(name: string, options?: { readonly presentation?: PanelPresentation }): boolean
|
||||
/** Closes this plugin's active panel. Other plugins' panels are unaffected. */
|
||||
close(): void
|
||||
/** This plugin's active panel, if any. Reactive when read in a Solid computation. */
|
||||
current(): { readonly name: string; readonly sessionID: string } | undefined
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,6 +18221,12 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18448,6 +18454,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -34,8 +34,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
|
||||
default_agent: Schema.String.pipe(optional).annotate({
|
||||
description: "Default primary agent to use when no session agent is selected",
|
||||
}),
|
||||
update: Schema.Literals(["disable", "notify"]).pipe(optional).annotate({
|
||||
description: "Disable updates or notify when one is available",
|
||||
update: Schema.Literals(["disable", "notify", "auto"]).pipe(optional).annotate({
|
||||
description: "Disable updates, notify when one is available, or install updates automatically",
|
||||
}),
|
||||
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
|
||||
description: "Control whether sessions may be shared manually, automatically, or not at all",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export * as ServerProcess from "./process"
|
||||
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { InstallationEvent } from "@opencode-ai/schema/installation-event"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode-ai/protocol/groups/persistent-pty"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
@@ -114,7 +116,14 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: shutdown.await }
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
return {
|
||||
address: bound.http.address,
|
||||
shutdown: shutdown.await,
|
||||
updateAvailable: (version: string) =>
|
||||
bus.publish(InstallationEvent.UpdateAvailable, { version }).pipe(Effect.asVoid),
|
||||
updated: (version: string) => bus.publish(InstallationEvent.Updated, { version }).pipe(Effect.asVoid),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause)
|
||||
|
||||
@@ -101,7 +101,13 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(event.headers.get("content-encoding")).toBeNull()
|
||||
const body = event.body
|
||||
if (!body) return yield* Effect.die(new Error("Event response has no body"))
|
||||
yield* Effect.promise(() => body.cancel())
|
||||
const reader = body.getReader()
|
||||
yield* Effect.promise(() => readUntil(reader, "server.connected"))
|
||||
yield* server.updateAvailable("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.update-available"))
|
||||
yield* server.updated("2.0.0")
|
||||
yield* Effect.promise(() => readUntil(reader, "installation.updated"))
|
||||
yield* Effect.promise(() => reader.cancel())
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
@@ -126,3 +132,11 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
if (next.done) throw new Error(`Event stream ended before ${expected}`)
|
||||
if (new TextDecoder().decode(next.value).includes(expected)) return
|
||||
}
|
||||
}
|
||||
|
||||
+52
-56
@@ -64,7 +64,6 @@ import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogUpdate } from "./component/dialog-update"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -88,6 +87,7 @@ import open from "open"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { newSessionLocation } from "./config/new-session-location"
|
||||
import { UpdateNotificationProvider, useUpdateNotification, type UpdateSource } from "./context/update-notification"
|
||||
import { PluginProvider, usePlugin, type PackageSource } from "./plugin/context"
|
||||
import { localPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
@@ -100,6 +100,7 @@ import { cliErrorMessage, errorFormat } from "./util/error"
|
||||
import { AttentionProvider } from "./context/attention"
|
||||
import { StorageProvider, useStorage } from "./context/storage"
|
||||
import { SessionTerminalsProvider } from "./context/session-terminals"
|
||||
import { PanelProvider, usePanel } from "./context/panel"
|
||||
import { SessionFrame } from "./component/session-frame"
|
||||
import { createTuiClipboard } from "./clipboard"
|
||||
|
||||
@@ -154,6 +155,7 @@ const appBindingCommands = [
|
||||
"provider.connect",
|
||||
"opencode.settings",
|
||||
"opencode.status",
|
||||
"opencode.update",
|
||||
"server.pair",
|
||||
"service.restart",
|
||||
"opencode.debug",
|
||||
@@ -185,10 +187,7 @@ export type TuiInput = {
|
||||
}
|
||||
args: Args
|
||||
config: Config.Interface
|
||||
updater?: {
|
||||
monitor: (notify: (version: string) => void, signal: AbortSignal) => Promise<void>
|
||||
apply: (version: string) => Promise<void>
|
||||
}
|
||||
updater?: UpdateSource
|
||||
packages: PackageSource
|
||||
environment?: Readonly<Record<string, string>>
|
||||
terminalHandoff?: () => Promise<
|
||||
@@ -397,22 +396,27 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
<UpdateNotificationProvider
|
||||
updater={input.updater}
|
||||
>
|
||||
<App
|
||||
updater={input.updater}
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
<PanelProvider>
|
||||
<PluginProvider
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
</UpdateNotificationProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
@@ -462,7 +466,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"] }) {
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -474,10 +478,12 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const panels = usePanel()
|
||||
const keymap = Keymap.use()
|
||||
const event = useEvent()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const updater = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const { mode, supports, setMode, locked, lock, unlock } = useThemes()
|
||||
const data = useData()
|
||||
@@ -501,40 +507,6 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const [layout, updateLayout] = useStorage().store<{ verticalTabsWidth?: number }>("layout", {
|
||||
initial: { verticalTabsWidth: SESSION_SIDEBAR_WIDTH },
|
||||
})
|
||||
const [updateNotifications, markUpdateNotification] = useStorage().store<{ versions: string[] }>(
|
||||
"update-notifications",
|
||||
{ initial: { versions: [] } },
|
||||
)
|
||||
const showUpdate = (version: string) => {
|
||||
const updater = props.updater
|
||||
if (!updater || updateNotifications.versions.includes(version)) return
|
||||
void markUpdateNotification((draft) => {
|
||||
draft.versions = [...draft.versions, version].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
const key = `update:${version}`
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogUpdate
|
||||
dialogKey={key}
|
||||
version={version}
|
||||
install={() => updater.apply(version)}
|
||||
restart={client.restart}
|
||||
/>
|
||||
),
|
||||
undefined,
|
||||
{ key },
|
||||
)
|
||||
dialog.setCentered(true)
|
||||
}
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater.monitor(showUpdate, controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update monitor failed", { error })
|
||||
})
|
||||
})
|
||||
const tabsResize = createPaneResize({
|
||||
value: () => layout.verticalTabsWidth ?? SESSION_SIDEBAR_WIDTH,
|
||||
defaultValue: () => SESSION_SIDEBAR_WIDTH,
|
||||
@@ -608,9 +580,22 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () =>
|
||||
config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width, tabsResize.preferredSize())
|
||||
const tabsVisible = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const tabsAvailable = () => sessionTabs.enabled() && sessionTabs.tabs().length > 0 && route.data.type !== "plugin"
|
||||
const fullscreenPanel = () =>
|
||||
route.data.type === "session" &&
|
||||
panels.current()?.sessionID === route.data.sessionID &&
|
||||
panels.presentation() === "fullscreen"
|
||||
const tabsVisible = () => tabsAvailable() && !fullscreenPanel()
|
||||
const verticalTabsVisible = () => tabsVisible() && tabsVertical()
|
||||
|
||||
// Measure the prospective split layout, even while full-screen hides the tabs.
|
||||
createEffect(() => panels.setWidth(dimensions().width - (tabsAvailable() && tabsVertical() ? tabsResize.size() : 0)))
|
||||
createEffect(() => {
|
||||
const current = panels.current()
|
||||
if (!current || (route.data.type === "session" && route.data.sessionID === current.sessionID)) return
|
||||
panels.close()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
renderer.useMouse = config.data.mouse
|
||||
})
|
||||
@@ -972,6 +957,17 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
...(updater.open
|
||||
? [
|
||||
{
|
||||
name: "opencode.update",
|
||||
title: "Update OpenCode",
|
||||
slash: { name: "update" },
|
||||
run: () => updater.open?.("manual"),
|
||||
category: "System",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: "server.pair",
|
||||
title: "Pair device",
|
||||
|
||||
@@ -1,67 +1,56 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { createSignal, For, Match, Show, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { errorMessage } from "../util/error"
|
||||
import type { UpdateState } from "../context/update-notification"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
type State =
|
||||
| { type: "ready"; active: "update" | "skip" }
|
||||
| { type: "installing" }
|
||||
| { type: "restarting" }
|
||||
| { type: "failed"; message: string }
|
||||
|
||||
export function DialogUpdate(props: {
|
||||
dialogKey: string
|
||||
version: string
|
||||
check?: (signal: AbortSignal) => Promise<string | undefined>
|
||||
state: () => UpdateState | undefined
|
||||
install: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
restart: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const theme = useTheme("elevated")
|
||||
const [state, setState] = createSignal<State>({ type: "ready", active: "update" })
|
||||
const close = () => {
|
||||
if (dialog.key === props.dialogKey) dialog.clear()
|
||||
}
|
||||
const [error, setError] = createSignal<string>()
|
||||
const [active, setActive] = createSignal(0)
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
|
||||
const install = async () => {
|
||||
setState({ type: "installing" })
|
||||
await props.install()
|
||||
if (props.restart) {
|
||||
setState({ type: "restarting" })
|
||||
await props.restart()
|
||||
}
|
||||
close()
|
||||
}
|
||||
dialog.setCentered(true)
|
||||
|
||||
const beginInstall = () => {
|
||||
if (state().type !== "ready") return
|
||||
void install().catch((error) => setState({ type: "failed", message: errorMessage(error) }))
|
||||
}
|
||||
const [check] = createResource(
|
||||
() => props.check,
|
||||
(check) =>
|
||||
check(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted) setError(errorMessage(error))
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
const state = createMemo(() => {
|
||||
if (check.loading) return { type: "checking" as const }
|
||||
const unavailable = check()
|
||||
if (unavailable) return { type: "unavailable" as const, message: unavailable }
|
||||
const message = error()
|
||||
if (message) return { type: "check-failed" as const, message }
|
||||
return props.state() ?? { type: "current" as const }
|
||||
})
|
||||
const buttons = createMemo(() => {
|
||||
const type = state().type
|
||||
if (type === "installing") return []
|
||||
const confirm =
|
||||
type === "available"
|
||||
? { label: "Update", run: props.install }
|
||||
: type === "installed"
|
||||
? { label: "Restart", run: props.restart }
|
||||
: undefined
|
||||
return [{ label: "Skip", run: () => dialog.clear() }, ...(confirm ? [confirm] : [])]
|
||||
})
|
||||
|
||||
const run = () => {
|
||||
const current = state()
|
||||
if (current.type !== "ready") return
|
||||
if (current.active === "skip") return close()
|
||||
beginInstall()
|
||||
}
|
||||
|
||||
const toggle = () =>
|
||||
setState((current) =>
|
||||
current.type === "ready" ? { ...current, active: current.active === "update" ? "skip" : "update" } : current,
|
||||
)
|
||||
|
||||
const selected = (action: "update" | "skip") => {
|
||||
const current = state()
|
||||
return current.type === "ready" && current.active === action
|
||||
}
|
||||
|
||||
const failure = () => {
|
||||
const current = state()
|
||||
return current.type === "failed" ? current.message : ""
|
||||
}
|
||||
createEffect(() => setActive(Math.max(0, buttons().length - 1)))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
@@ -70,20 +59,17 @@ export function DialogUpdate(props: {
|
||||
bind: "return",
|
||||
title: "Confirm update action",
|
||||
group: "Dialog",
|
||||
run: () => (state().type === "failed" ? close() : run()),
|
||||
run: () => void buttons()[active()]?.run(),
|
||||
},
|
||||
{
|
||||
bind: "left",
|
||||
title: "Previous update action",
|
||||
...["left", "right", "tab", "shift+tab"].map((bind) => ({
|
||||
bind,
|
||||
title: bind === "left" || bind === "shift+tab" ? "Previous update action" : "Next update action",
|
||||
group: "Dialog",
|
||||
run: toggle,
|
||||
},
|
||||
{
|
||||
bind: "right",
|
||||
title: "Next update action",
|
||||
group: "Dialog",
|
||||
run: toggle,
|
||||
},
|
||||
run: () => {
|
||||
const count = buttons().length
|
||||
if (count) setActive((value) => (value + 1) % count)
|
||||
},
|
||||
})),
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -91,64 +77,65 @@ export function DialogUpdate(props: {
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
||||
Update available
|
||||
{state().type === "available" || state().type === "installing" || state().type === "failed"
|
||||
? "Update available"
|
||||
: "Update"}
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={close}>
|
||||
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={state().type === "ready"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. Applying will
|
||||
{props.restart
|
||||
? " restart the server and active sessions will be resumed."
|
||||
: " install the update but you will need to manually restart."}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={state().type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>Installing OpenCode {props.version}…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "restarting"}>
|
||||
<Spinner shimmer={theme.text.default}>Restarting the background service…</Spinner>
|
||||
</Match>
|
||||
<Match when={state().type === "failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>{failure()}</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show when={state()} keyed>
|
||||
{(current) => (
|
||||
<Switch>
|
||||
<Match when={current.type === "checking"}>
|
||||
<Spinner shimmer={theme.text.default}>Checking for updates…</Spinner>
|
||||
</Match>
|
||||
<Match when={current.type === "available"}>
|
||||
<text fg={theme.text.subdued}>
|
||||
An update is available. After installing, you'll be prompted to restart OpenCode.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "installing"}>
|
||||
<Spinner shimmer={theme.text.default}>
|
||||
{current.type === "installing" ? `Installing OpenCode ${current.version}…` : ""}
|
||||
</Spinner>
|
||||
</Match>
|
||||
<Match when={current.type === "installed"}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
Update successful! A restart is required. Any active sessions will be resumed automatically.
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "current"}>
|
||||
<text fg={theme.text.subdued}>OpenCode is already up to date.</text>
|
||||
</Match>
|
||||
<Match when={current.type === "unavailable"}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
{current.type === "unavailable" ? current.message : ""}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={current.type === "failed" || current.type === "check-failed"}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
{current.type === "failed" || current.type === "check-failed" ? current.message : ""}
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={state().type === "ready"}
|
||||
fallback={
|
||||
<Show when={state().type === "failed"}>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.background.action.primary.focused}
|
||||
onMouseUp={close}
|
||||
>
|
||||
<text fg={theme.text.action.primary.focused}>close</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={buttons().length > 0}>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["skip", "update"] as const}>
|
||||
{(action) => (
|
||||
<For each={buttons()}>
|
||||
{(button, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={selected(action) ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => {
|
||||
if (action === "skip") return close()
|
||||
beginInstall()
|
||||
}}
|
||||
backgroundColor={active() === index() ? theme.background.action.primary.focused : undefined}
|
||||
onMouseUp={() => void button.run()}
|
||||
>
|
||||
<text fg={selected(action) ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{action === "update" ? "Update" : "Skip"}
|
||||
<text fg={active() === index() ? theme.text.action.primary.focused : theme.text.subdued}>
|
||||
{button.label}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { MaskedTextRenderable } from "./masked-text"
|
||||
import { coast, smootherstep } from "./tab-pulse"
|
||||
|
||||
type FadeInTextOptions = TextOptions & {
|
||||
backdrop?: RGBA
|
||||
enabled?: boolean
|
||||
sweepOffset?: number
|
||||
sweepWidth?: number
|
||||
}
|
||||
|
||||
const DURATION = 200
|
||||
const FEATHER = 8
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
|
||||
class FadeInTextRenderable extends MaskedTextRenderable {
|
||||
private _backdrop = RGBA.defaultBackground()
|
||||
private _enabled = true
|
||||
private _sweepOffset = 0
|
||||
private _sweepWidth: number | undefined
|
||||
private elapsed = 0
|
||||
|
||||
constructor(ctx: RenderContext, options: FadeInTextOptions) {
|
||||
super(ctx, options)
|
||||
this.matrix[15] = 1
|
||||
this.updateBackdrop()
|
||||
if (options.backdrop) this.backdrop = options.backdrop
|
||||
if (options.enabled === false) this.enabled = false
|
||||
this.live = this._enabled
|
||||
}
|
||||
|
||||
set backdrop(value: RGBA) {
|
||||
if (value.equals(this._backdrop)) return
|
||||
this._backdrop = value
|
||||
this.updateBackdrop()
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set enabled(value: boolean) {
|
||||
if (value === this._enabled) return
|
||||
this._enabled = value
|
||||
this.live = value && this.elapsed < DURATION
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set sweepOffset(value: number | undefined) {
|
||||
this._sweepOffset = value ?? 0
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set sweepWidth(value: number | undefined) {
|
||||
this._sweepWidth = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private updateBackdrop() {
|
||||
this.matrix[3] = this._backdrop.r
|
||||
this.matrix[7] = this._backdrop.g
|
||||
this.matrix[11] = this._backdrop.b
|
||||
}
|
||||
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this._enabled || this.elapsed >= DURATION) return super.render(buffer, deltaTime)
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
this.elapsed = Math.min(DURATION, this.elapsed + deltaTime)
|
||||
this.renderMasked(buffer, 1, (end) => {
|
||||
const progress = this.elapsed / DURATION
|
||||
const front = -FEATHER + coast(progress) * ((this._sweepWidth ?? end) + FEATHER * 2)
|
||||
return (column) => 1 - smootherstep(clamp((front - (this._sweepOffset + column)) / FEATHER))
|
||||
})
|
||||
if (this.elapsed >= DURATION) this.live = false
|
||||
}
|
||||
}
|
||||
|
||||
extend({ fade_in_text: FadeInTextRenderable })
|
||||
|
||||
declare module "@opentui/solid" {
|
||||
interface OpenTUIComponents {
|
||||
fade_in_text: typeof FadeInTextRenderable
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["text"], "ref"> & {
|
||||
animate?: boolean
|
||||
backdrop?: RGBA
|
||||
sweepOffset?: number
|
||||
sweepWidth?: number
|
||||
}
|
||||
|
||||
export function FadeInText(props: Props) {
|
||||
const config = useConfig().data
|
||||
const [local, text] = splitProps(props, ["animate", "backdrop", "sweepOffset", "sweepWidth"])
|
||||
return (
|
||||
<fade_in_text
|
||||
{...text}
|
||||
backdrop={local.backdrop}
|
||||
enabled={(local.animate ?? true) && (config.animations ?? true)}
|
||||
sweepOffset={local.sweepOffset}
|
||||
sweepWidth={local.sweepWidth}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { OptimizedBuffer, RGBA, TargetChannel, TextRenderable } from "@opentui/core"
|
||||
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
export class MaskedTextRenderable extends TextRenderable {
|
||||
protected readonly matrix = new Float32Array(16)
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
|
||||
protected renderMasked(
|
||||
buffer: OptimizedBuffer,
|
||||
initialStrength: number,
|
||||
shade: (width: number) => (column: number) => number,
|
||||
) {
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const intensity = shade(end)
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
let strength = initialStrength
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
// Wide glyph continuation cells retain the head cell's intensity.
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensity(column)
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BoxRenderable } from "@opentui/core"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { usePanel, type PanelTarget } from "../context/panel"
|
||||
import { InteractivityProvider } from "../context/interactivity"
|
||||
import { ThemeContextProvider, useTheme } from "../context/theme"
|
||||
import { Slot } from "../plugin/render"
|
||||
|
||||
export function PanelHost(props: {
|
||||
panel: PanelTarget
|
||||
width: number
|
||||
focused: boolean
|
||||
onFocus: () => void
|
||||
onTarget: (node: BoxRenderable | undefined) => void
|
||||
}) {
|
||||
const panels = usePanel()
|
||||
let node: BoxRenderable
|
||||
onMount(() => props.onTarget(node))
|
||||
onCleanup(() => props.onTarget(undefined))
|
||||
|
||||
const Content = () => {
|
||||
const theme = useTheme()
|
||||
return (
|
||||
<box
|
||||
id="session-panel"
|
||||
ref={(value: BoxRenderable) => (node = value)}
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
focusable
|
||||
backgroundColor={theme.background.default}
|
||||
onMouseDown={props.onFocus}
|
||||
>
|
||||
<Slot
|
||||
path="session.panel"
|
||||
input={{
|
||||
name: props.panel.name,
|
||||
sessionID: props.panel.sessionID,
|
||||
get width() {
|
||||
return props.width
|
||||
},
|
||||
get presentation() {
|
||||
return panels.presentation()
|
||||
},
|
||||
get focused() {
|
||||
return props.focused
|
||||
},
|
||||
focus: props.onFocus,
|
||||
close: panels.close,
|
||||
toggleFullscreen: panels.toggleFullscreen,
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<InteractivityProvider enabled={props.focused}>
|
||||
<ThemeContextProvider context={panels.presentation() === "panel" ? "elevated" : undefined}>
|
||||
<Content />
|
||||
</ThemeContextProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { resolvePastedAttachments } from "./local-attachment"
|
||||
import { locationKey, useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { useInteractivity } from "../../context/interactivity"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
|
||||
@@ -186,6 +187,8 @@ export function Prompt(props: PromptProps) {
|
||||
let anchor: BoxRenderable
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable | undefined>()
|
||||
|
||||
const enabled = useInteractivity()
|
||||
const disabled = () => props.disabled || !enabled()
|
||||
const leader = Keymap.useLeaderActive()
|
||||
const muted = () => leader() || props.muted
|
||||
const local = useLocal()
|
||||
@@ -257,6 +260,7 @@ export function Prompt(props: PromptProps) {
|
||||
const [pendingDirectory, setPendingDirectory] = createSignal<string>()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: [
|
||||
{
|
||||
id: "session.cd",
|
||||
@@ -346,8 +350,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.disabled) input.cursorColor = theme.background.surface.offset
|
||||
if (!props.disabled) input.cursorColor = theme.text.default
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
})
|
||||
|
||||
@@ -370,12 +373,13 @@ export function Prompt(props: PromptProps) {
|
||||
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
|
||||
pasteQueue = pasteQueue
|
||||
.then(async () => {
|
||||
if (disposed || input.isDestroyed) return
|
||||
if (disposed || input.isDestroyed || disabled()) return
|
||||
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
|
||||
await run(
|
||||
() =>
|
||||
disposed ||
|
||||
input.isDestroyed ||
|
||||
disabled() ||
|
||||
props.sessionID !== before.sessionID ||
|
||||
store.mode !== before.mode ||
|
||||
input.plainText !== before.text,
|
||||
@@ -634,15 +638,18 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: promptCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
enabled: !disabled(),
|
||||
bindings: ["prompt.queue"],
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: !disabled(),
|
||||
bindings: [
|
||||
"prompt.submit",
|
||||
"prompt.editor",
|
||||
@@ -660,12 +667,13 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
return input.focused
|
||||
return !disabled() && input.focused
|
||||
},
|
||||
get current() {
|
||||
return store.prompt
|
||||
},
|
||||
focus() {
|
||||
if (disabled()) return
|
||||
input.focus()
|
||||
},
|
||||
blur() {
|
||||
@@ -719,11 +727,13 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
if (props.visible === false || props.disabled || dialog.stack.length > 0) {
|
||||
if (props.visible === false || disabled() || dialog.stack.length > 0) {
|
||||
if (input.focused) input.blur()
|
||||
input.focusable = false
|
||||
return
|
||||
}
|
||||
|
||||
input.focusable = true
|
||||
// Slot/plugin updates can remount the background prompt while a dialog is open.
|
||||
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
|
||||
if (!input.focused) input.focus()
|
||||
@@ -919,13 +929,14 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: !disabled(),
|
||||
commands: stashCommands(),
|
||||
}))
|
||||
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled,
|
||||
enabled: inputTarget() !== undefined && !disabled(),
|
||||
bindings: ["prompt.paste"],
|
||||
}
|
||||
})
|
||||
@@ -933,7 +944,7 @@ export function Prompt(props: PromptProps) {
|
||||
Keymap.createLayer(() => {
|
||||
return {
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && !props.disabled && store.prompt.text !== "",
|
||||
enabled: inputTarget() !== undefined && !disabled() && store.prompt.text !== "",
|
||||
bindings: ["prompt.clear"],
|
||||
}
|
||||
})
|
||||
@@ -945,7 +956,7 @@ export function Prompt(props: PromptProps) {
|
||||
cursorVersion()
|
||||
return (
|
||||
inputTarget() !== undefined &&
|
||||
!props.disabled &&
|
||||
!disabled() &&
|
||||
store.mode === "normal" &&
|
||||
!auto()?.visible &&
|
||||
input?.visualCursor.offset === 0
|
||||
@@ -969,7 +980,7 @@ export function Prompt(props: PromptProps) {
|
||||
return {
|
||||
priority: 1,
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined && store.mode === "shell",
|
||||
enabled: inputTarget() !== undefined && !disabled() && store.mode === "shell",
|
||||
commands: [
|
||||
{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
{
|
||||
@@ -988,7 +999,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
return inputTarget() !== undefined && !disabled() && store.mode === "shell" && input?.visualCursor.offset === 0
|
||||
})(),
|
||||
commands: [
|
||||
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
|
||||
@@ -1002,7 +1013,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1038,7 +1049,7 @@ export function Prompt(props: PromptProps) {
|
||||
target: inputTarget,
|
||||
enabled: (() => {
|
||||
cursorVersion()
|
||||
return inputTarget() !== undefined && !props.disabled && !auto()?.visible && input !== undefined
|
||||
return inputTarget() !== undefined && !disabled() && !auto()?.visible && input !== undefined
|
||||
})(),
|
||||
commands: [
|
||||
{
|
||||
@@ -1073,6 +1084,7 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
let submitting = false
|
||||
async function submit(delivery: SessionInbox.Delivery = "steer") {
|
||||
if (disabled()) return false
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
@@ -1096,7 +1108,6 @@ export function Prompt(props: PromptProps) {
|
||||
setStore("prompt", "text", input.plainText)
|
||||
syncExtmarksWithPromptParts()
|
||||
}
|
||||
if (props.disabled) return false
|
||||
if (move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
@@ -1764,18 +1775,19 @@ export function Prompt(props: PromptProps) {
|
||||
}}
|
||||
onCursorChange={() => setCursorVersion((value) => value + 1)}
|
||||
onKeyDown={(e: { preventDefault(): void }) => {
|
||||
if (props.disabled) {
|
||||
if (disabled()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}}
|
||||
onSubmit={() => {
|
||||
if (disabled()) return
|
||||
// IME: double-defer so the last composed character (e.g. Korean
|
||||
// hangul) is flushed to plainText before we read it for submission.
|
||||
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||
}}
|
||||
onPaste={(event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
if (disabled()) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
@@ -1811,12 +1823,16 @@ export function Prompt(props: PromptProps) {
|
||||
setTimeout(() => {
|
||||
// setTimeout is a workaround and needs to be addressed properly
|
||||
if (!input || input.isDestroyed) return
|
||||
input.cursorColor = theme.text.default
|
||||
input.cursorColor = disabled() ? theme.background.surface.offset : theme.text.default
|
||||
if (config.cursor) input.cursorStyle = config.cursor
|
||||
}, 0)
|
||||
}}
|
||||
onMouseDown={(r: MouseEvent) => {
|
||||
if (props.disabled || r.button !== 0) return
|
||||
if (disabled()) {
|
||||
r.preventDefault()
|
||||
return
|
||||
}
|
||||
if (r.button !== 0) return
|
||||
r.target?.focus()
|
||||
const extmark = input.extmarks
|
||||
.getAtOffset(input.cursorOffset)
|
||||
@@ -1826,7 +1842,7 @@ export function Prompt(props: PromptProps) {
|
||||
r.stopPropagation()
|
||||
}}
|
||||
focusedBackgroundColor="transparent"
|
||||
cursorColor={props.disabled ? theme.background.surface.offset : theme.text.default}
|
||||
cursorColor={disabled() ? theme.background.surface.offset : theme.text.default}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { RGBA, MouseEvent, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import {
|
||||
CliRenderEvents,
|
||||
RGBA,
|
||||
MouseEvent,
|
||||
type BoxRenderable,
|
||||
type Renderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, Show } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createResource, createSignal, on, onCleanup, Show } from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { useData } from "../context/data"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { InteractivityProvider } from "../context/interactivity"
|
||||
import { useSessionTerminals } from "../context/session-terminals"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { usePanel } from "../context/panel"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { Session } from "../routes/session"
|
||||
import { Sidebar } from "../routes/session/sidebar"
|
||||
import { clampTerminalPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { clampSessionPaneWidth, SESSION_SIDEBAR_WIDTH } from "../ui/layout"
|
||||
import { createPaneResize } from "../ui/pane-resize"
|
||||
import { PaneResizeHandle } from "../ui/pane-resize-handle"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { TerminalPane } from "./terminal-pane"
|
||||
import { PanelHost } from "./panel-host"
|
||||
|
||||
export function SessionFrame(props: { sessionID: string; verticalTabsWidth: number }) {
|
||||
const sessions = useSessionTerminals()
|
||||
@@ -21,40 +32,49 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const config = useConfig()
|
||||
const data = useData()
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const panels = usePanel()
|
||||
const dialog = useDialog()
|
||||
const availableWidth = () => Math.max(0, dimensions().width - props.verticalTabsWidth)
|
||||
const defaultTerminalWidth = () => Math.max(1, Math.floor(dimensions().width / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ terminalWidth?: number }>("layout", { initial: {} })
|
||||
const terminalResize = createPaneResize({
|
||||
value: () => layout.terminalWidth ?? defaultTerminalWidth(),
|
||||
defaultValue: defaultTerminalWidth,
|
||||
clamp: (width) => clampTerminalPaneWidth(width, availableWidth()),
|
||||
const defaultPaneWidth = () => Math.max(1, Math.floor(panels.width() / 2))
|
||||
const [layout, updateLayout] = useStorage().store<{ paneWidth?: number; terminalWidth?: number }>("layout", {
|
||||
initial: {},
|
||||
})
|
||||
const paneResize = createPaneResize({
|
||||
value: () => layout.paneWidth ?? layout.terminalWidth ?? defaultPaneWidth(),
|
||||
defaultValue: defaultPaneWidth,
|
||||
clamp: (width) => clampSessionPaneWidth(width, panels.width()),
|
||||
fromMouse: (event) => dimensions().width - event.x - 1,
|
||||
contains: (event, width) => event.x >= dimensions().width - width - 1 && event.x <= dimensions().width - width,
|
||||
onCommit: (width) => {
|
||||
void updateLayout((draft) => {
|
||||
draft.terminalWidth = width
|
||||
draft.paneWidth = width
|
||||
}).catch((error) => console.error("Failed to persist TUI layout", error))
|
||||
},
|
||||
})
|
||||
let resizeRelease = false
|
||||
const finishTerminalResize = (event: MouseEvent) => {
|
||||
if (terminalResize.resizing()) {
|
||||
const finishPaneResize = (event: MouseEvent) => {
|
||||
if (paneResize.resizing()) {
|
||||
// A captured drag-end can be followed by mouse-up on the focus overlay.
|
||||
resizeRelease = true
|
||||
queueMicrotask(() => {
|
||||
resizeRelease = false
|
||||
})
|
||||
}
|
||||
terminalResize.onMouseUp(event)
|
||||
paneResize.onMouseUp(event)
|
||||
}
|
||||
const [sidebarOpen, setSidebarOpen] = createSignal(false)
|
||||
const [sessionWidth, setSessionWidth] = createSignal<number>()
|
||||
const [terminalFocused, setTerminalFocused] = createSignal(false)
|
||||
const [activePane, setActivePane] = createSignal<"session" | "right">("session")
|
||||
const [restoreTerminalFocus, setRestoreTerminalFocus] = createSignal(false)
|
||||
let focusTerminal: (() => void) | undefined
|
||||
let showTerminals: (() => void) | undefined
|
||||
let sessionScroll: ScrollBoxRenderable | undefined
|
||||
let sessionNode: BoxRenderable | undefined
|
||||
let rightNode: BoxRenderable | undefined
|
||||
let panelNode: BoxRenderable | undefined
|
||||
createResource(
|
||||
() => (config.data.session.terminal ? props.sessionID : undefined),
|
||||
(sessionID) => sessions.refresh(sessionID).catch(() => undefined),
|
||||
@@ -65,14 +85,23 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
const value = session()
|
||||
return value.terminals.find((terminal) => terminal.id === value.selectedTerminalID)
|
||||
}
|
||||
const activePanel = createMemo(() => {
|
||||
const current = panels.current()
|
||||
if (current?.sessionID === props.sessionID) return current
|
||||
})
|
||||
const fullscreen = () => activePanel() !== undefined && panels.presentation() === "fullscreen"
|
||||
createEffect(
|
||||
on(
|
||||
() => selectedTerminal()?.id,
|
||||
(id) => {
|
||||
if (id) setSidebarOpen(false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
on([activePanel, () => selectedTerminal()?.id], ([panel, terminal], previous) => {
|
||||
if (panel && panel !== previous?.[0]) {
|
||||
setSidebarOpen(false)
|
||||
if (terminal) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
if (terminal && terminal !== previous?.[1]) {
|
||||
setSidebarOpen(false)
|
||||
if (panel) panels.close()
|
||||
}
|
||||
}),
|
||||
)
|
||||
const wide = createMemo(() => dimensions().width - props.verticalTabsWidth > 120)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
@@ -81,6 +110,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
|
||||
})
|
||||
const rightPane = createMemo(() => {
|
||||
if (activePanel()) return "panel"
|
||||
if (sidebarOpen() && sidebarVisible()) return "sidebar"
|
||||
if (selectedTerminal()) return "terminal"
|
||||
if (sidebarVisible()) return "sidebar"
|
||||
@@ -94,34 +124,137 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
})
|
||||
.catch(toast.error)
|
||||
setSidebarOpen(!visible)
|
||||
if (!visible && activePanel()) panels.close()
|
||||
if (!visible && selectedTerminal()) void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
})
|
||||
}
|
||||
const focusSession = () => {
|
||||
if (fullscreen()) return
|
||||
// Permission prompts replace the input, so returning focus must not depend on it.
|
||||
if (terminalFocused()) renderer.currentFocusedRenderable?.blur()
|
||||
if (activePane() === "right") renderer.currentFocusedRenderable?.blur()
|
||||
setActivePane("session")
|
||||
prompt.current?.focus()
|
||||
}
|
||||
const focusRightPane = () => {
|
||||
setActivePane("right")
|
||||
if (activePanel()) {
|
||||
panelNode?.focus()
|
||||
return
|
||||
}
|
||||
focusTerminal?.()
|
||||
}
|
||||
const onFocused = () => {
|
||||
const current = renderer.currentFocusedRenderable
|
||||
if (rightPane() !== "sidebar" && within(current, rightNode)) setActivePane("right")
|
||||
if (!fullscreen() && within(current, sessionNode)) setActivePane("session")
|
||||
}
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
onCleanup(() => renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused))
|
||||
createEffect(() => {
|
||||
if (fullscreen()) focusRightPane()
|
||||
})
|
||||
createEffect(() => {
|
||||
if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
|
||||
})
|
||||
createEffect(() => {
|
||||
if (!restoreTerminalFocus() || selectedTerminal()) return
|
||||
setRestoreTerminalFocus(false)
|
||||
focusSession()
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: () => config.data.session.terminal === true,
|
||||
mode: "global",
|
||||
enabled: () => (rightPane() === "terminal" || activePanel() !== undefined) && dialog.stack.length === 0,
|
||||
commands: [
|
||||
{
|
||||
id: "pane.focus.left",
|
||||
title: "Focus session pane",
|
||||
enabled: () => !fullscreen(),
|
||||
run: focusSession,
|
||||
},
|
||||
{
|
||||
id: "pane.focus.right",
|
||||
title: "Focus terminal pane",
|
||||
title: "Focus right pane",
|
||||
run: focusRightPane,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Pane management stays reachable from either input scope.
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "session.sidebar.toggle",
|
||||
title: rightPane() === "sidebar" ? "Hide sidebar" : "Show sidebar",
|
||||
group: "Session",
|
||||
palette: true,
|
||||
run: () => {
|
||||
focusTerminal?.()
|
||||
toggleSidebar()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(config.data.session.terminal
|
||||
? [
|
||||
{
|
||||
id: "terminal.toggle",
|
||||
title: rightPane() === "terminal" ? "Hide terminal pane" : "Show terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (rightPane() === "terminal") {
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
return
|
||||
}
|
||||
void sessions
|
||||
.refresh(props.sessionID)
|
||||
.then(async () => {
|
||||
const terminal = sessions.get(props.sessionID).terminals.at(-1)
|
||||
if (terminal) return sessions.selectTerminal(props.sessionID, terminal.id)
|
||||
await sessions.newTerminal(props.sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.select",
|
||||
title: "Select terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
if (fullscreen()) panels.close()
|
||||
focusSession()
|
||||
showTerminals?.()
|
||||
void sessions.refresh(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "terminal.close",
|
||||
title: "Close terminal pane",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
enabled: rightPane() === "terminal",
|
||||
run: () => {
|
||||
dialog.clear()
|
||||
focusSession()
|
||||
void sessions.selectTerminal(props.sessionID, null).catch(toast.error)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "session.terminal",
|
||||
title: "New terminal",
|
||||
group: "Session",
|
||||
palette: true as const,
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await sessions.newTerminal(props.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}))
|
||||
|
||||
@@ -132,30 +265,38 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
minHeight={0}
|
||||
flexDirection="row"
|
||||
position="relative"
|
||||
onMouseDrag={terminalResize.onMouseDrag}
|
||||
onMouseDragEnd={finishTerminalResize}
|
||||
onMouseUp={finishTerminalResize}
|
||||
onMouseDrag={paneResize.onMouseDrag}
|
||||
onMouseDragEnd={finishPaneResize}
|
||||
onMouseUp={finishPaneResize}
|
||||
>
|
||||
<box
|
||||
id="session-pane"
|
||||
ref={(value: BoxRenderable) => (sessionNode = value)}
|
||||
flexGrow={1}
|
||||
flexBasis={0}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
position={fullscreen() ? "absolute" : "relative"}
|
||||
visible={!fullscreen()}
|
||||
width={fullscreen() ? Math.max(0, panels.width() - paneResize.size()) : undefined}
|
||||
height="100%"
|
||||
onSizeChange={function () {
|
||||
setSessionWidth(this.width)
|
||||
}}
|
||||
>
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={terminalFocused()}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
<Show when={terminalFocused()}>
|
||||
<InteractivityProvider enabled={activePane() === "session" && !fullscreen()}>
|
||||
<Session
|
||||
scrollRef={(value) => (sessionScroll = value)}
|
||||
verticalTabsWidth={props.verticalTabsWidth}
|
||||
promptMuted={activePane() !== "session"}
|
||||
sidebarVisible={rightPane() === "sidebar"}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
visibleTerminalID={rightPane() === "terminal" ? selectedTerminal()?.id : undefined}
|
||||
onTerminalPicker={(show) => (showTerminals = show)}
|
||||
width={sessionWidth()}
|
||||
/>
|
||||
</InteractivityProvider>
|
||||
<Show when={activePane() === "right"}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
@@ -174,35 +315,60 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
}}
|
||||
// Consume the release before revealing permission buttons underneath.
|
||||
onMouseUp={() => {
|
||||
if (terminalResize.resizing() || resizeRelease) return
|
||||
if (paneResize.resizing() || resizeRelease) return
|
||||
focusSession()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={rightPane() === "terminal" || (rightPane() === "sidebar" && wide())}>
|
||||
<Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
|
||||
<box
|
||||
ref={(value: BoxRenderable) => (rightNode = value)}
|
||||
flexShrink={0}
|
||||
width={rightPane() === "terminal" ? terminalResize.size() : SESSION_SIDEBAR_WIDTH}
|
||||
width={
|
||||
fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
|
||||
}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
>
|
||||
<Show
|
||||
when={rightPane() === "sidebar"}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={terminalResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
<Show
|
||||
keyed
|
||||
when={activePanel()}
|
||||
fallback={
|
||||
<Show keyed when={selectedTerminal()?.id}>
|
||||
{(ptyID) => (
|
||||
<TerminalPane
|
||||
ptyID={ptyID}
|
||||
resizing={paneResize.resizing()}
|
||||
autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
|
||||
onAutoFocus={() => {
|
||||
sessions.clearFocus(ptyID)
|
||||
setRestoreTerminalFocus(false)
|
||||
}}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(item) => (
|
||||
<PanelHost
|
||||
panel={item}
|
||||
width={fullscreen() ? availableWidth() : paneResize.size()}
|
||||
focused={activePane() === "right"}
|
||||
onFocus={focusRightPane}
|
||||
onTarget={(node) => {
|
||||
panelNode = node
|
||||
if (node) {
|
||||
focusRightPane()
|
||||
return
|
||||
}
|
||||
setActivePane("session")
|
||||
}}
|
||||
onFocusChange={setTerminalFocused}
|
||||
onFocusRequest={(value) => (focusTerminal = value)}
|
||||
onDisconnect={() => setRestoreTerminalFocus(true)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
@@ -212,12 +378,8 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={rightPane() === "terminal" && availableWidth() >= 3}>
|
||||
<PaneResizeHandle
|
||||
resize={terminalResize}
|
||||
left={availableWidth() - terminalResize.size() - 1}
|
||||
highlight="right"
|
||||
/>
|
||||
<Show when={!fullscreen() && (rightPane() === "terminal" || rightPane() === "panel") && availableWidth() >= 3}>
|
||||
<PaneResizeHandle resize={paneResize} left={availableWidth() - paneResize.size() - 1} highlight="right" />
|
||||
</Show>
|
||||
<Show when={rightPane() === "sidebar" && !wide()}>
|
||||
<box
|
||||
@@ -235,3 +397,11 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function within(node: Renderable | null | undefined, root: Renderable | undefined) {
|
||||
if (!root) return false
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (current === root) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import {
|
||||
OptimizedBuffer,
|
||||
RGBA,
|
||||
TargetChannel,
|
||||
TextRenderable,
|
||||
type RenderContext,
|
||||
type TextOptions,
|
||||
} from "@opentui/core"
|
||||
import { RGBA, type OptimizedBuffer, type RenderContext, type TextOptions } from "@opentui/core"
|
||||
import { extend, type JSX } from "@opentui/solid"
|
||||
import { splitProps } from "solid-js"
|
||||
import { MaskedTextRenderable } from "./masked-text"
|
||||
import { coast, intensityAt } from "./tab-pulse"
|
||||
|
||||
type ShimmerTextOptions = TextOptions & {
|
||||
@@ -15,15 +9,10 @@ type ShimmerTextOptions = TextOptions & {
|
||||
}
|
||||
|
||||
const DURATION = 1200
|
||||
const TRANSPARENT = RGBA.fromValues(0, 0, 0, 0)
|
||||
const CONTINUATION = 0xc0000000 | 0
|
||||
|
||||
class ShimmerTextRenderable extends TextRenderable {
|
||||
class ShimmerTextRenderable extends MaskedTextRenderable {
|
||||
private _shimmer = RGBA.defaultForeground()
|
||||
private elapsed = 0
|
||||
private scratch: OptimizedBuffer | undefined
|
||||
private mask = new Float32Array(0)
|
||||
private matrix = new Float32Array(16)
|
||||
|
||||
constructor(ctx: RenderContext, options: ShimmerTextOptions) {
|
||||
super(ctx, options)
|
||||
@@ -47,44 +36,10 @@ class ShimmerTextRenderable extends TextRenderable {
|
||||
override render(buffer: OptimizedBuffer, deltaTime: number) {
|
||||
if (!this.visible || this.isDestroyed || !Number.isFinite(this.width) || this.width <= 0 || this.height <= 0) return
|
||||
this.elapsed = (this.elapsed + deltaTime) % DURATION
|
||||
if (!this.scratch)
|
||||
this.scratch = OptimizedBuffer.create(this.width, this.height, this._ctx.widthMethod, { respectAlpha: true })
|
||||
if (this.scratch.width !== this.width || this.scratch.height !== this.height)
|
||||
this.scratch.resize(this.width, this.height)
|
||||
|
||||
this.scratch.clear(TRANSPARENT)
|
||||
this.scratch.drawTextBuffer(this.textBufferView, 0, 0)
|
||||
const characters = this.scratch.buffers.char
|
||||
let end = 0
|
||||
for (let row = 0; row < this.height; row++) {
|
||||
let column = this.width
|
||||
while (
|
||||
column > 0 &&
|
||||
(characters[row * this.width + column - 1] === 32 || characters[row * this.width + column - 1] === 0)
|
||||
)
|
||||
column--
|
||||
end = Math.max(end, column)
|
||||
}
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
if (this.mask.length !== this.width * this.height * 3) this.mask = new Float32Array(this.width * this.height * 3)
|
||||
let strength = 0
|
||||
for (let cell = 0; cell < characters.length; cell++) {
|
||||
const column = cell % this.width
|
||||
if ((characters[cell] & CONTINUATION) !== CONTINUATION) strength = intensityAt(column, front, 4, 18)
|
||||
this.mask[cell * 3] = column
|
||||
this.mask[cell * 3 + 1] = Math.floor(cell / this.width)
|
||||
this.mask[cell * 3 + 2] = strength
|
||||
}
|
||||
this.scratch.colorMatrix(this.matrix, this.mask, 1, TargetChannel.FG)
|
||||
buffer.drawFrameBuffer(this.screenX, this.screenY, this.scratch)
|
||||
this.markClean()
|
||||
this._ctx.addToHitGrid(this.screenX, this.screenY, this.width, this.height, this.num)
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
this.scratch?.destroy()
|
||||
this.scratch = undefined
|
||||
super.destroy()
|
||||
this.renderMasked(buffer, 0, (end) => {
|
||||
const front = -4 + coast(this.elapsed / DURATION) * (end + 22)
|
||||
return (column) => intensityAt(column, front, 4, 18)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CliRenderEvents, EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import { EmbeddedTerminalRenderable, type RGBA } from "@opentui/core"
|
||||
import type { ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
import { extend, useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
@@ -28,7 +28,6 @@ export function TerminalPane(props: {
|
||||
onAutoFocus?: () => void
|
||||
onFocusRequest?: (focus: (() => void) | undefined) => void
|
||||
onDisconnect?: () => void
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
const keymap = Keymap.use()
|
||||
@@ -148,9 +147,6 @@ export function TerminalPane(props: {
|
||||
},
|
||||
{ priority: 100 },
|
||||
)
|
||||
// Blur emits this event before updating the terminal's own focused flag.
|
||||
const onFocused = () => props.onFocusChange?.(renderer.currentFocusedRenderable === terminal)
|
||||
renderer.on(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
createEffect(() => {
|
||||
if (!props.autoFocus || !terminal) return
|
||||
terminal.focus()
|
||||
@@ -172,8 +168,6 @@ export function TerminalPane(props: {
|
||||
waitingSize?.resolve()
|
||||
socket?.close()
|
||||
offKeys()
|
||||
renderer.off(CliRenderEvents.FOCUSED_RENDERABLE, onFocused)
|
||||
props.onFocusChange?.(false)
|
||||
props.onFocusRequest?.(undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export const Definitions = {
|
||||
"theme.mode.lock": keybind("none", "Lock or unlock theme mode"),
|
||||
"session.sidebar.toggle": keybind("<leader>b", "Toggle sidebar"),
|
||||
"pane.focus.left": keybind("<leader>left", "Focus session pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus terminal pane"),
|
||||
"pane.focus.right": keybind("<leader>right", "Focus right pane"),
|
||||
"terminal.select": keybind("<leader>down", "Select terminal"),
|
||||
"terminal.toggle": keybind("<leader>t", "Toggle terminal pane"),
|
||||
"terminal.close": keybind("<leader>up", "Close terminal pane"),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createContext, createMemo, getOwner, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
|
||||
const Context = createContext<Accessor<boolean>>(() => true)
|
||||
|
||||
/** Disabling a subtree also disables every nested interactivity provider. */
|
||||
export function InteractivityProvider(props: ParentProps<{ enabled: boolean }>) {
|
||||
const parent = useInteractivity()
|
||||
const enabled = createMemo(() => parent() && props.enabled)
|
||||
return <Context.Provider value={enabled}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
/** Shared by keymap consumers and native input/focus handlers. Defaults to enabled. */
|
||||
export function useInteractivity() {
|
||||
return useContext(Context)
|
||||
}
|
||||
|
||||
/** Forwarded APIs use the calling component's context, or their captured context outside a Solid owner. */
|
||||
export function resolveInteractivity(fallback: Accessor<boolean>) {
|
||||
return getOwner() ? useInteractivity() : fallback
|
||||
}
|
||||
@@ -13,9 +13,19 @@ import { formatCommandBindings, formatKeySequence } from "@opentui/keymap/extras
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import { KeymapProvider, useBindings, useKeymapSelector } from "@opentui/keymap/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createContext, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||
import {
|
||||
createComputed,
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useConfig } from "../config"
|
||||
import { TuiKeybind } from "../config/keybind"
|
||||
import { resolveInteractivity, useInteractivity } from "./interactivity"
|
||||
|
||||
declare module "@opentui/keymap" {
|
||||
interface Command {
|
||||
@@ -175,13 +185,17 @@ export interface Keymap {
|
||||
|
||||
function use(): Keymap {
|
||||
const value = useValue()
|
||||
const enabled = useInteractivity()
|
||||
const leader = value.config.keybinds.get("leader")?.[0]?.key
|
||||
const isLeader = leader ? value.keymap.createKeyMatcher(leader) : () => false
|
||||
return {
|
||||
dispatch(id, input) {
|
||||
value.dispatch(id, input)
|
||||
},
|
||||
mode: value.mode,
|
||||
mode: {
|
||||
current: value.mode.current,
|
||||
push: (mode) => value.mode.push(mode, resolveInteractivity(enabled)),
|
||||
},
|
||||
intercept: value.keymap.intercept.bind(value.keymap),
|
||||
isLeader,
|
||||
}
|
||||
@@ -189,6 +203,7 @@ function use(): Keymap {
|
||||
|
||||
function createLayer(input: () => KeymapLayer) {
|
||||
const value = useValue()
|
||||
const enabled = useInteractivity()
|
||||
useBindings(() => {
|
||||
const layer = input()
|
||||
const { commands, bindings, mode, ...options } = layer
|
||||
@@ -215,6 +230,7 @@ function createLayer(input: () => KeymapLayer) {
|
||||
)
|
||||
return {
|
||||
...options,
|
||||
enabled: enabled() ? options.enabled : false,
|
||||
...(mode === "global" ? {} : { mode: mode ?? MODE.base }),
|
||||
commands: grouped.named.map((command) => {
|
||||
const { id, description, group, palette, bind, run, ...definition } = command
|
||||
@@ -397,37 +413,34 @@ export const Keymap = {
|
||||
} as const
|
||||
|
||||
function createMode(keymap: OpenTuiKeymap) {
|
||||
keymap.setData(MODE.key, MODE.base)
|
||||
const [stack, setStack] = createSignal<
|
||||
{ readonly id: symbol; readonly mode: string; readonly enabled: Accessor<boolean> }[]
|
||||
>([])
|
||||
const current = createMemo(() => stack().findLast((item) => item.enabled())?.mode ?? MODE.base)
|
||||
// Publish mode changes before another command can be dispatched in the same callback.
|
||||
createComputed(() => keymap.setData(MODE.key, current()))
|
||||
const unregister = keymap.registerLayerFields({
|
||||
mode(value, context) {
|
||||
context.require(MODE.key, value)
|
||||
},
|
||||
})
|
||||
const stack: { readonly id: symbol; readonly mode: string }[] = []
|
||||
let disposed = false
|
||||
|
||||
const update = () => keymap.setData(MODE.key, stack.at(-1)?.mode ?? MODE.base)
|
||||
|
||||
return {
|
||||
current() {
|
||||
return stack.at(-1)?.mode ?? MODE.base
|
||||
},
|
||||
push(mode: string) {
|
||||
current,
|
||||
push(mode: string, enabled: Accessor<boolean>) {
|
||||
if (disposed) return () => {}
|
||||
const id = Symbol(mode)
|
||||
stack.push({ id, mode })
|
||||
update()
|
||||
// Inactive scopes retain their stack position beneath any newer modes.
|
||||
setStack((items) => [...items, { id, mode, enabled }])
|
||||
return () => {
|
||||
const index = stack.findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
stack.splice(index, 1)
|
||||
update()
|
||||
setStack((items) => items.filter((item) => item.id !== id))
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
stack.length = 0
|
||||
setStack([])
|
||||
unregister()
|
||||
keymap.setData(MODE.key, undefined)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PanelPresentation } from "@opencode-ai/plugin/tui/context"
|
||||
import { batch, createContext, createMemo, createSignal, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export type PanelTarget = {
|
||||
readonly plugin: string
|
||||
readonly name: string
|
||||
readonly sessionID: string
|
||||
}
|
||||
|
||||
export function createPanelState() {
|
||||
const [current, setCurrent] = createSignal<PanelTarget>()
|
||||
const [requested, setRequested] = createSignal<PanelPresentation>("panel")
|
||||
const [width, setWidth] = createSignal(0)
|
||||
const canSplit = () => width() > 80
|
||||
const presentation = createMemo(() => (canSplit() ? requested() : "fullscreen"))
|
||||
return {
|
||||
current,
|
||||
width,
|
||||
canSplit,
|
||||
presentation,
|
||||
setWidth,
|
||||
open(target: PanelTarget, presentation: PanelPresentation = "panel") {
|
||||
batch(() => {
|
||||
setRequested(presentation)
|
||||
setCurrent((current) =>
|
||||
current?.plugin === target.plugin && current.name === target.name && current.sessionID === target.sessionID
|
||||
? current
|
||||
: target,
|
||||
)
|
||||
})
|
||||
},
|
||||
close: () => setCurrent(),
|
||||
release(plugin: string) {
|
||||
if (current()?.plugin !== plugin) return
|
||||
setCurrent()
|
||||
},
|
||||
toggleFullscreen() {
|
||||
if (!canSplit()) return
|
||||
setRequested((current) => (current === "panel" ? "fullscreen" : "panel"))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<ReturnType<typeof createPanelState>>()
|
||||
|
||||
export function PanelProvider(props: ParentProps) {
|
||||
return <Context.Provider value={createPanelState()}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function usePanel() {
|
||||
const value = useContext(Context)
|
||||
if (!value) throw new Error("usePanel must be used within a PanelProvider")
|
||||
return value
|
||||
}
|
||||
|
||||
export function useOptionalPanel() {
|
||||
return useContext(Context)
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "../theme"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/component"
|
||||
import { createComponentTheme, createComponentThemeView, type ComponentTheme } from "../theme/component"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -379,12 +379,15 @@ export function useTheme(context?: ContextName) {
|
||||
}
|
||||
export const ThemeProvider = themeContext.provider
|
||||
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName }>) {
|
||||
/** Switches context without remounting children; undefined inherits the enclosing view. */
|
||||
export function ThemeContextProvider(props: ParentProps<{ context: ContextName | undefined }>) {
|
||||
const value = themeContext.use()
|
||||
const current = createComponentThemeView(() => {
|
||||
const name = props.context
|
||||
return name ? value.themes.currentTokens().contextual[name] : value.current
|
||||
}, value.themes.mode)
|
||||
return (
|
||||
<themeContext.context.Provider
|
||||
value={{ current: value.themes.current.contextual[props.context], themes: value.themes, ready: value.ready }}
|
||||
>
|
||||
<themeContext.context.Provider value={{ current, themes: value.themes, ready: value.ready }}>
|
||||
{props.children}
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
import { useStorage } from "./storage"
|
||||
import { useEvent } from "./event"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { useExit } from "./exit"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogUpdate } from "../component/dialog-update"
|
||||
|
||||
type ClientNotice = { readonly type: "available" | "installed"; readonly version: string }
|
||||
type Notice = ClientNotice & ({ readonly source: "client" } | { readonly source: "server"; readonly remote: boolean })
|
||||
export type UpdateState =
|
||||
| ClientNotice
|
||||
| { readonly type: "installing"; readonly version: string }
|
||||
| { readonly type: "failed"; readonly message: string }
|
||||
|
||||
export type UpdateSource = {
|
||||
readonly remote: boolean
|
||||
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
|
||||
readonly check: (
|
||||
signal: AbortSignal,
|
||||
) => Promise<ClientNotice | { readonly type: "unavailable"; readonly message: string } | undefined>
|
||||
readonly apply: (version: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const { use: useUpdateNotification, provider: UpdateNotificationProvider } = createSimpleContext({
|
||||
name: "UpdateNotification",
|
||||
init: (props: { updater?: UpdateSource }) => {
|
||||
const event = useEvent()
|
||||
const exit = useExit()
|
||||
const dialog = useDialog()
|
||||
const log = useLog({ component: "update-notification" })
|
||||
const [state, setState] = createSignal<UpdateState>()
|
||||
const [notification, setNotification] = createSignal<Notice>()
|
||||
const [notifications, markNotification] = useStorage().store<{ versions: string[] }>("update-notifications", {
|
||||
initial: { versions: [] },
|
||||
})
|
||||
|
||||
const notify = (notice: Notice) => {
|
||||
if (!props.updater) return
|
||||
if (
|
||||
notifications.versions.includes(`${notice.source}:${notice.version}`) ||
|
||||
(notice.source === "client" && notifications.versions.includes(notice.version))
|
||||
)
|
||||
return
|
||||
setNotification((current) => {
|
||||
if (notice.source === "server" && current?.source === "client") return current
|
||||
return notice
|
||||
})
|
||||
}
|
||||
|
||||
const dismiss = () => {
|
||||
const current = notification()
|
||||
if (!current) return
|
||||
setNotification(undefined)
|
||||
// Only interactions with the automatic notification update its history.
|
||||
void markNotification((draft) => {
|
||||
draft.versions = [...draft.versions, `${current.source}:${current.version}`].slice(-100)
|
||||
}).catch((error) => log.error("failed to persist update notification", { error }))
|
||||
}
|
||||
|
||||
const install = async () => {
|
||||
const updater = props.updater
|
||||
const current = state()
|
||||
if (!updater || !current || current.type !== "available") return
|
||||
setState({ type: "installing", version: current.version })
|
||||
await updater.apply(current.version).then(
|
||||
() => setState({ type: "installed", version: current.version }),
|
||||
(error) => setState({ type: "failed", message: errorMessage(error) }),
|
||||
)
|
||||
}
|
||||
|
||||
const check = async (signal: AbortSignal) => {
|
||||
const updater = props.updater
|
||||
if (!updater || state()?.type === "installing") return
|
||||
const result = await updater.check(signal)
|
||||
if (signal.aborted) return
|
||||
if (result?.type === "unavailable") return result.message
|
||||
setState(result)
|
||||
}
|
||||
|
||||
const restart = () => {
|
||||
const current = state()
|
||||
if (current?.type !== "installed") return
|
||||
exit()
|
||||
}
|
||||
|
||||
const open = (origin: "manual" | "notification") => {
|
||||
if (!props.updater) return
|
||||
const current = notification()
|
||||
const known = current && (current.source === "client" || !current.remote) ? current : undefined
|
||||
if (origin === "notification" && !known) return
|
||||
const active = state()
|
||||
// The notification can predate an installation through /update.
|
||||
if (known && active?.type !== "installing" && !(active?.type === "installed" && active.version === known.version))
|
||||
setState({ type: known.type, version: known.version })
|
||||
// Manual checks hide the current notice without marking the version as seen.
|
||||
if (origin === "manual") setNotification(undefined)
|
||||
if (origin === "notification") dismiss()
|
||||
const status = state()?.type
|
||||
dialog.replace(() => (
|
||||
<DialogUpdate
|
||||
check={status === undefined || status === "failed" ? check : undefined}
|
||||
state={state}
|
||||
install={install}
|
||||
restart={restart}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const updater = props.updater
|
||||
if (!updater) return
|
||||
const controller = new AbortController()
|
||||
onCleanup(() => controller.abort())
|
||||
void updater
|
||||
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
|
||||
.catch((error) => {
|
||||
if (!controller.signal.aborted) log.error("update check failed", { error })
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
event.on("installation.update-available", (event) =>
|
||||
notify({
|
||||
source: "server",
|
||||
remote: props.updater?.remote ?? false,
|
||||
type: "available",
|
||||
version: event.data.version,
|
||||
}),
|
||||
),
|
||||
)
|
||||
onCleanup(
|
||||
event.on("installation.updated", (event) =>
|
||||
notify({
|
||||
source: "server",
|
||||
remote: props.updater?.remote ?? false,
|
||||
type: "installed",
|
||||
version: event.data.version,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
notification,
|
||||
dismiss,
|
||||
open: props.updater ? open : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { BoxRenderable, MouseButton } from "@opentui/core"
|
||||
import { Portal, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export function DiffFileMenu(props: {
|
||||
context: Plugin.Context
|
||||
state: { fileIndex: number; x: number; y: number }
|
||||
reviewed: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const width = () => Math.min(19, dimensions().width)
|
||||
const run = () => {
|
||||
props.onClose()
|
||||
props.onToggle()
|
||||
}
|
||||
onCleanup(props.context.keymap.mode.push("menu"))
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "menu",
|
||||
commands: [
|
||||
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
|
||||
{ bind: "return", title: label(), group: "Diff", run },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Portal
|
||||
ref={(container) => {
|
||||
if (!(container instanceof BoxRenderable)) return
|
||||
// Portal's wrapper must also escape root flow, not follow the full-height app.
|
||||
container.position = "absolute"
|
||||
container.left = 0
|
||||
container.top = 0
|
||||
container.zIndex = 2600
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu-overlay"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
zIndex={2600}
|
||||
onMouseDown={(event) => {
|
||||
props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu"
|
||||
position="absolute"
|
||||
left={Math.max(0, Math.min(props.state.x, dimensions().width - width()))}
|
||||
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
|
||||
width={width()}
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === MouseButton.RIGHT) props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.button === MouseButton.LEFT) run()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false} wrapMode="none" truncate>
|
||||
{label()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { filetype } from "../../util/filetype"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { DiffFileMenu } from "./diff-viewer-file-menu"
|
||||
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { EmptyBorder } from "../../ui/border"
|
||||
@@ -1076,76 +1077,6 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffFileMenu(props: {
|
||||
context: Plugin.Context
|
||||
state: FileMenuState
|
||||
reviewed: boolean
|
||||
onToggle: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.overlay
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete")
|
||||
const run = () => {
|
||||
props.onClose()
|
||||
props.onToggle()
|
||||
}
|
||||
onCleanup(props.context.keymap.mode.push("menu"))
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "menu",
|
||||
commands: [
|
||||
{ bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose },
|
||||
{ bind: "return", title: label(), group: "Diff", run },
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
id="diff-file-menu-overlay"
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
zIndex={2600}
|
||||
onMouseDown={(event) => {
|
||||
props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<box
|
||||
id="diff-file-menu"
|
||||
position="absolute"
|
||||
left={Math.max(0, Math.min(props.state.x, dimensions().width - 19))}
|
||||
top={Math.max(0, Math.min(props.state.y + 1, dimensions().height - 1))}
|
||||
width={19}
|
||||
height={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() ? theme.background.action.primary.hovered : theme.background.default}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
onMouseDown={(event) => {
|
||||
if (event.button === MouseButton.RIGHT) props.onClose()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onMouseUp={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (event.button === MouseButton.LEFT) run()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{label()}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useOptionalPanel } from "../context/panel"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
@@ -68,6 +69,7 @@ export function usePluginHost() {
|
||||
attention: useAttention(),
|
||||
storage: useStorage(),
|
||||
sessionTabs: useSessionTabs(),
|
||||
panel: useOptionalPanel(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +84,7 @@ export function createPluginContext(input: {
|
||||
registry: Registry
|
||||
}): Context {
|
||||
const host = input.host
|
||||
input.owned.push(async () => host.panel?.release(input.id))
|
||||
let context: Context
|
||||
let claims = 0
|
||||
// Every dialog and registered render is wrapped so plugin components can
|
||||
@@ -174,6 +177,21 @@ export function createPluginContext(input: {
|
||||
return host.route.data
|
||||
},
|
||||
},
|
||||
panel: {
|
||||
open(name, options) {
|
||||
if (!host.panel || !input.registry.active()) return false
|
||||
const route = host.route.data
|
||||
if (route.type !== "session") return false
|
||||
host.panel.open({ plugin: input.id, name, sessionID: route.sessionID }, options?.presentation)
|
||||
return true
|
||||
},
|
||||
close: () => host.panel?.release(input.id),
|
||||
current() {
|
||||
const current = host.panel?.current()
|
||||
if (current?.plugin !== input.id) return
|
||||
return { name: current.name, sessionID: current.sessionID }
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
enabled: host.sessionTabs.enabled,
|
||||
list: () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Prompt, type PromptRef } from "../component/prompt"
|
||||
import { createEffect, createMemo, createSignal, onMount, Show, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { Logo } from "../component/logo"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useRouteData } from "../context/route"
|
||||
@@ -11,6 +11,11 @@ import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { Slot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { TextAttributes, type RGBA } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useUpdateNotification } from "../context/update-notification"
|
||||
import { FadeInText } from "../component/fade-in-text"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
let once = false
|
||||
const placeholder = {
|
||||
@@ -81,13 +86,16 @@ export function Home() {
|
||||
paddingRight={dimensions().width < 44 ? 1 : 2}
|
||||
>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
<box height={4} minHeight={0} flexShrink={1} />
|
||||
<box height={3} minHeight={0} flexShrink={1} />
|
||||
<box flexShrink={0}>
|
||||
<Logo />
|
||||
</box>
|
||||
<box height={1} minHeight={0} flexShrink={1} />
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0} position="relative">
|
||||
<Prompt ref={bind} placeholders={placeholder} disabled={forms().length > 0} />
|
||||
<box position="absolute" top="100%" left={0} right={0} alignItems="center">
|
||||
<UpdateNotification />
|
||||
</box>
|
||||
</box>
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
@@ -109,3 +117,88 @@ export function Home() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateNotification() {
|
||||
const update = useUpdateNotification()
|
||||
const theme = useTheme()
|
||||
const remoteMessage = "A remote server cannot be updated from here. Updating it is recommended."
|
||||
const [hovered, setHovered] = createSignal<"primary" | "close">()
|
||||
createEffect(() => {
|
||||
update.notification()
|
||||
setHovered(undefined)
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={update.notification()} keyed>
|
||||
{(state) => (
|
||||
<box flexShrink={0} marginTop={4} alignItems="center">
|
||||
<Switch>
|
||||
<Match when={state.source === "client" || !state.remote}>
|
||||
<box
|
||||
alignItems="center"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={hovered() === "primary" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("primary")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={() => update.open?.("notification")}
|
||||
>
|
||||
<UpdateMessage
|
||||
title="Update available"
|
||||
description={`Version ${state.version} is available. Click for more details`}
|
||||
backdrop={
|
||||
hovered() === "primary" ? theme.background.action.primary.hovered : theme.background.default
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={state.type === "available" && state.source === "server" && state.remote}>
|
||||
<box alignItems="center">
|
||||
<UpdateMessage
|
||||
title="Server update available"
|
||||
description={remoteMessage}
|
||||
backdrop={theme.background.default}
|
||||
/>
|
||||
<FadeInText
|
||||
fg={theme.text.subdued}
|
||||
backdrop={hovered() === "close" ? theme.background.action.primary.hovered : theme.background.default}
|
||||
sweepWidth={stringWidth(remoteMessage)}
|
||||
sweepOffset={Math.floor((stringWidth(remoteMessage) - stringWidth("Close")) / 2)}
|
||||
marginTop={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
bg={hovered() === "close" ? theme.background.action.primary.hovered : undefined}
|
||||
onMouseOver={() => setHovered("close")}
|
||||
onMouseOut={() => setHovered(undefined)}
|
||||
onMouseUp={update.dismiss}
|
||||
>
|
||||
Close
|
||||
</FadeInText>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function UpdateMessage(props: { title: string; description: string; backdrop: RGBA }) {
|
||||
const theme = useTheme()
|
||||
const titleWidth = stringWidth(props.title)
|
||||
const descriptionWidth = stringWidth(props.description)
|
||||
const width = Math.max(titleWidth, descriptionWidth)
|
||||
return (
|
||||
<FadeInText width={width} height={2} wrapMode="none" fg={theme.text.default} backdrop={props.backdrop}>
|
||||
<span style={{ fg: theme.text.action.primary.selected, attributes: TextAttributes.BOLD }}>
|
||||
{" ".repeat(Math.floor((width - titleWidth) / 2))}
|
||||
{props.title}
|
||||
</span>
|
||||
{"\n"}
|
||||
<span style={{ fg: theme.text.subdued }}>
|
||||
{" ".repeat(Math.floor((width - descriptionWidth) / 2))}
|
||||
{props.description}
|
||||
</span>
|
||||
</FadeInText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useClipboard } from "../../context/clipboard"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useInteractivity } from "../../context/interactivity"
|
||||
import { useConfig } from "../../config"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import {
|
||||
@@ -48,6 +49,8 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const keymap = Keymap.use()
|
||||
const enabled = useInteractivity()
|
||||
const active = () => enabled() && keymap.mode.current() === FORM_MODE
|
||||
const config = useConfig().data
|
||||
const clipboard = useClipboard()
|
||||
const toast = useToast()
|
||||
@@ -68,6 +71,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
})
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
const [inputTarget, setInputTarget] = createSignal<TextareaRenderable>()
|
||||
let review: ScrollBoxRenderable | undefined
|
||||
let measureReview: (() => void) | undefined
|
||||
|
||||
@@ -216,9 +220,22 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview)
|
||||
})
|
||||
|
||||
// Refs publish after initialization so burst typing stays with the interceptor until the editor is ready.
|
||||
createEffect(() => {
|
||||
const target = inputTarget()
|
||||
if (!target || target.isDestroyed) return
|
||||
if (!active()) {
|
||||
target.blur()
|
||||
target.focusable = false
|
||||
return
|
||||
}
|
||||
target.focusable = true
|
||||
target.focus()
|
||||
})
|
||||
|
||||
onCleanup(
|
||||
keymap.intercept("key", ({ event, consume }) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
if (!active()) return
|
||||
if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return
|
||||
if (event.ctrl || event.meta || event.option || event.super || event.hyper) return
|
||||
if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return
|
||||
@@ -328,7 +345,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
}
|
||||
|
||||
usePaste((event) => {
|
||||
if (keymap.mode.current() !== FORM_MODE) return
|
||||
if (!active()) return
|
||||
const value = stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")
|
||||
if (store.editing && renderer.currentFocusedEditor === textarea) {
|
||||
textarea.insertText(value)
|
||||
@@ -343,7 +360,7 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
return clipboard
|
||||
.read()
|
||||
.then((content) => {
|
||||
if (content?.mime !== "text/plain") return
|
||||
if (!active() || content?.mime !== "text/plain") return
|
||||
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
|
||||
if (store.editing || textual()) {
|
||||
textarea?.insertText(value)
|
||||
@@ -878,8 +895,9 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
val.focus()
|
||||
if (val.isDestroyed) return
|
||||
val.gotoLineEnd()
|
||||
setInputTarget(val)
|
||||
})
|
||||
}}
|
||||
initialValue={
|
||||
@@ -1017,9 +1035,10 @@ export function FormPrompt(props: { form: FormWithLocation }) {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
if (val.isDestroyed) return
|
||||
val.setText(input())
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
setInputTarget(val)
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
|
||||
@@ -113,7 +113,6 @@ import { createDelayedPresence } from "../../util/delayed-presence"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { useSessionTerminals } from "../../context/session-terminals"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -161,6 +160,7 @@ export function Session(props: {
|
||||
sidebarVisible: boolean
|
||||
onToggleSidebar: () => void
|
||||
visibleTerminalID?: string
|
||||
onTerminalPicker?: (show: (() => void) | undefined) => void
|
||||
width?: number
|
||||
}) {
|
||||
const setEpilogue = useEpilogue()
|
||||
@@ -234,6 +234,8 @@ export function Session(props: {
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
})
|
||||
props.onTerminalPicker?.(() => setComposer({ open: true, tab: "terminals" }))
|
||||
onCleanup(() => props.onTerminalPicker?.(undefined))
|
||||
createEffect(() => {
|
||||
if (props.promptMuted && composer.open) setComposer("open", false)
|
||||
})
|
||||
@@ -260,7 +262,6 @@ export function Session(props: {
|
||||
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
|
||||
const toast = useToast()
|
||||
const terminalError = () => toast.show({ variant: "error", message: "Unable to load terminal" })
|
||||
const client = useClient()
|
||||
const autoApproved = new Set<string>()
|
||||
createEffect(() => {
|
||||
@@ -295,7 +296,6 @@ export function Session(props: {
|
||||
const [firstJump, setFirstJump] = createSignal<() => void>()
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
const sessionTabs = useSessionTabs()
|
||||
const terminals = useSessionTerminals()
|
||||
const [awayFromBottom, setAwayFromBottom] = createSignal(false)
|
||||
const [latestHovered, setLatestHovered] = createSignal(false)
|
||||
let ensureAllRowsPending: (() => void)[] | undefined
|
||||
@@ -949,13 +949,21 @@ export function Session(props: {
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
void client.api.session.revert
|
||||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt()?.set({
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
})
|
||||
const sessionID = route.sessionID
|
||||
const target = prompt()
|
||||
void (async () => {
|
||||
if (pendingDeliveries().has(message.id)) {
|
||||
if (!(await mutatePending("cancel", message.id))) return
|
||||
} else {
|
||||
await client.api.session.interrupt({ sessionID })
|
||||
await client.api.session.wait({ sessionID })
|
||||
await client.api.session.revert.stage({ sessionID, messageID: message.id })
|
||||
}
|
||||
target?.set({
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
})
|
||||
})().catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
@@ -976,73 +984,6 @@ export function Session(props: {
|
||||
})()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: props.sidebarVisible ? "Hide sidebar" : "Show sidebar",
|
||||
id: "session.sidebar.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
props.onToggleSidebar()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
...(config.session.terminal
|
||||
? [
|
||||
{
|
||||
title: props.visibleTerminalID ? "Hide terminal pane" : "Show terminal pane",
|
||||
id: "terminal.toggle",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
const sessionID = route.sessionID
|
||||
if (props.visibleTerminalID) {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(sessionID, null).catch(toast.error)
|
||||
} else {
|
||||
void terminals
|
||||
.refresh(sessionID)
|
||||
.then(async () => {
|
||||
const terminal = terminals.get(sessionID).terminals.at(-1)
|
||||
if (terminal) return terminals.selectTerminal(sessionID, terminal.id)
|
||||
await terminals.newTerminal(sessionID)
|
||||
})
|
||||
.catch(terminalError)
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Select terminal",
|
||||
id: "terminal.select",
|
||||
group: "Session",
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
setComposer({ open: true, tab: "terminals" })
|
||||
void terminals.refresh(route.sessionID).catch(terminalError)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Close terminal pane",
|
||||
id: "terminal.close",
|
||||
group: "Session",
|
||||
enabled: props.visibleTerminalID !== undefined,
|
||||
run: () => {
|
||||
promptRef.current?.focus()
|
||||
void terminals.selectTerminal(route.sessionID, null).catch(toast.error)
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New terminal",
|
||||
id: "session.terminal",
|
||||
group: "Session",
|
||||
slash: { name: "terminal" },
|
||||
run: async () => {
|
||||
dialog.clear()
|
||||
await terminals.newTerminal(route.sessionID).catch(terminalError)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: (() => {
|
||||
const next = nextThinkingMode(thinkingMode())
|
||||
@@ -1507,7 +1448,6 @@ export function Session(props: {
|
||||
<Prompt
|
||||
visible={true}
|
||||
ref={bind}
|
||||
disabled={false}
|
||||
muted={props.promptMuted}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
@@ -2522,12 +2462,24 @@ function QueuedPromptDock(props: { prompts: { id: string; text: string }[]; onOp
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const theme = useTheme()
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
createEffect(() => {
|
||||
const at = props.retry?.at
|
||||
if (at === undefined) return
|
||||
const update = () => setSeconds(Math.max(0, Math.ceil((at - Date.now()) / 1_000)))
|
||||
if (update() === 0) return
|
||||
const timer = setInterval(() => {
|
||||
if (update() === 0) clearInterval(timer)
|
||||
}, 1_000)
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
return (
|
||||
<Show when={props.retry}>
|
||||
{(retry) => (
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.text.feedback.warning.default}>
|
||||
⚠ Retry attempt {retry().attempt} scheduled: {retry().error.message}
|
||||
⚠ {seconds() > 0 ? `Retrying in ${seconds()}s` : "Retry due"} · attempt {retry().attempt} ·{" "}
|
||||
{retry().error.message}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
@@ -2926,6 +2878,7 @@ function InlineTool(props: {
|
||||
pending: string
|
||||
failure?: string
|
||||
spinner?: boolean
|
||||
running?: boolean
|
||||
status?: JSX.Element
|
||||
children: JSX.Element
|
||||
part: SessionMessageAssistantTool
|
||||
@@ -2937,7 +2890,9 @@ function InlineTool(props: {
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
const permission = useToolPermission(() => props.part)
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const error = createMemo(() =>
|
||||
!props.running && props.part.state.status === "error" ? props.part.state.error.message : undefined,
|
||||
)
|
||||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
@@ -3482,6 +3437,7 @@ function Subagent(props: ToolProps) {
|
||||
<InlineTool
|
||||
icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
|
||||
spinner={!continuation() && isRunning()}
|
||||
running={isRunning()}
|
||||
complete={description()}
|
||||
pending="Delegating…"
|
||||
part={props.part}
|
||||
|
||||
@@ -11,12 +11,13 @@ import { permissionAlwaysLines, permissionOptionLabel, permissionPresentation }
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { useConfig } from "../../config"
|
||||
import { Keymap } from "../../context/keymap"
|
||||
import { useInteractivity } from "../../context/interactivity"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { SimulationSemantics } from "../../simulation/semantics"
|
||||
import { PatchDiff } from "../../component/patch-diff"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
type PermissionStage = "permission" | "always" | "reject"
|
||||
type PermissionStage = "permission" | "reject"
|
||||
|
||||
function EditBody(props: { file?: string; diff?: string; patch?: string }) {
|
||||
const theme = useTheme()
|
||||
@@ -140,27 +141,6 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={store.stage === "always"}>
|
||||
<SessionQuestion
|
||||
title="Always allow"
|
||||
semanticLabel={`Always allow ${props.request.action}`}
|
||||
instance={props.request.id}
|
||||
body={
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
options={{ confirm: permissionOptionLabel("confirm"), cancel: permissionOptionLabel("cancel") }}
|
||||
escapeKey="cancel"
|
||||
onSelect={(option) => {
|
||||
setStore("stage", "permission")
|
||||
if (option === "cancel") return
|
||||
reply("always")
|
||||
}}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={store.stage === "reject"}>
|
||||
<RejectPrompt
|
||||
action={props.request.action}
|
||||
@@ -185,7 +165,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
},
|
||||
pathFormatter.format,
|
||||
)
|
||||
const presentationBody =
|
||||
const presentationBody = () =>
|
||||
props.request.action === "edit" ? (
|
||||
<EditBody file={current.file} diff={current.diff} patch={current.patch} />
|
||||
) : props.request.action === "external_directory" ? (
|
||||
@@ -240,7 +220,15 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
semanticLabel={permissionSemanticLabel(props.request.action, current.title)}
|
||||
instance={props.request.id}
|
||||
header={header()}
|
||||
body={presentationBody}
|
||||
body={(option) => (
|
||||
<Show when={option === "always"} fallback={presentationBody()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<For each={permissionAlwaysLines(props.request)}>
|
||||
{(line, index) => <text fg={index() === 0 ? theme.text.subdued : theme.text.default}>{line}</text>}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)}
|
||||
options={
|
||||
props.request.save?.length
|
||||
? {
|
||||
@@ -254,7 +242,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
|
||||
fullscreen
|
||||
onSelect={(option) => {
|
||||
if (option === "always") {
|
||||
setStore("stage", "always")
|
||||
reply("always")
|
||||
return
|
||||
}
|
||||
if (option === "reject") {
|
||||
@@ -288,6 +276,7 @@ function RejectPrompt(props: {
|
||||
onCancel: () => void
|
||||
}) {
|
||||
let input: TextareaRenderable
|
||||
const enabled = useInteractivity()
|
||||
const theme = useTheme("elevated")
|
||||
const config = useConfig().data
|
||||
const dimensions = useTerminalDimensions()
|
||||
@@ -364,7 +353,7 @@ function RejectPrompt(props: {
|
||||
}))(val)
|
||||
val.traits = { status: "REJECT" }
|
||||
}}
|
||||
focused
|
||||
focused={enabled()}
|
||||
textColor={theme.text.default}
|
||||
focusedTextColor={theme.text.default}
|
||||
cursorColor={theme.text.default}
|
||||
@@ -423,7 +412,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
group?: string
|
||||
choicesLabel?: string
|
||||
header?: JSX.Element
|
||||
body: JSX.Element
|
||||
body: JSX.Element | ((option: keyof T) => JSX.Element)
|
||||
options: T
|
||||
escapeKey?: keyof T
|
||||
fullscreen?: boolean
|
||||
@@ -547,7 +536,7 @@ export function SessionQuestion<const T extends Record<string, string>>(props: {
|
||||
{props.header}
|
||||
</box>
|
||||
</Show>
|
||||
{props.body}
|
||||
{typeof props.body === "function" ? props.body(store.selected) : props.body}
|
||||
</box>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
|
||||
@@ -3,7 +3,16 @@ import type { Accessor } from "solid-js"
|
||||
import type { Mode, ResolvedTheme, ResolvedThemeTokens } from "@opencode-ai/theme/tui"
|
||||
|
||||
export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Accessor<Mode>) {
|
||||
const create = (view: Accessor<ResolvedThemeTokens>) => ({
|
||||
return Object.assign(createComponentThemeView(current, mode), {
|
||||
contextual: {
|
||||
elevated: createComponentThemeView(() => current().contextual.elevated, mode),
|
||||
overlay: createComponentThemeView(() => current().contextual.overlay, mode),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createComponentThemeView(view: Accessor<ResolvedThemeTokens>, mode: Accessor<Mode>) {
|
||||
return {
|
||||
get hue() {
|
||||
return view().hue
|
||||
},
|
||||
@@ -35,14 +44,7 @@ export function createComponentTheme(current: Accessor<ResolvedTheme>, mode: Acc
|
||||
increase: (color: RGBA, amount = 1) => view().increase(color, amount),
|
||||
decrease: (color: RGBA, amount = 1) => view().decrease(color, amount),
|
||||
raise: (color: RGBA) => (mode() === "light" ? view().increase(color) : view().decrease(color)),
|
||||
})
|
||||
|
||||
return Object.assign(create(current), {
|
||||
contextual: {
|
||||
elevated: create(() => current().contextual.elevated),
|
||||
overlay: create(() => current().contextual.overlay),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type ComponentTheme = ReturnType<typeof createComponentTheme>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function clampSessionTabsWidth(width: number, total: number) {
|
||||
)
|
||||
}
|
||||
|
||||
export function clampTerminalPaneWidth(width: number, total: number) {
|
||||
export function clampSessionPaneWidth(width: number, total: number) {
|
||||
const half = Math.max(1, Math.floor(total / 2))
|
||||
// Preserve the equal split when there is not enough room for both pane minima.
|
||||
return Math.max(Math.min(24, half), Math.min(width, Math.max(half, total - SESSION_CONTENT_MIN_WIDTH)))
|
||||
|
||||
@@ -1228,7 +1228,7 @@ test("ctrl+c dismisses autocomplete and shell mode before exiting", async () =>
|
||||
})
|
||||
|
||||
test.each(["manual", "select"] as const)(
|
||||
"selection copy and dismissal respect %s mode in the prompt and terminal pane",
|
||||
"selection copy and pane management respect %s mode in the prompt and terminal pane",
|
||||
async (copy) => {
|
||||
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
@@ -1359,6 +1359,19 @@ test.each(["manual", "select"] as const)(
|
||||
expect(setup.renderer.hasSelection).toBeFalse()
|
||||
expect(setup.renderer.isDestroyed).toBeFalse()
|
||||
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("up")
|
||||
await setup.waitFor(() => terminal.isDestroyed)
|
||||
expect(setup.renderer.currentFocusedEditor?.plainText).toBe("")
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressKey("t")
|
||||
await setup.waitForFrame((frame) => frame.includes("alpha beta gamma"))
|
||||
expect(setup.renderer.currentFocusedRenderable).toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow("down")
|
||||
await setup.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Terminals"))
|
||||
expect(setup.renderer.currentFocusedRenderable).not.toBeInstanceOf(EmbeddedTerminalRenderable)
|
||||
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
} finally {
|
||||
@@ -1529,3 +1542,95 @@ test("server plugin failures share one notice and use source names before an ID
|
||||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
test.each([44, 100])(
|
||||
"retry countdown updates and clears with the retry lifecycle at width %s",
|
||||
async (width) => {
|
||||
await using state = await tmpdir()
|
||||
const session = {
|
||||
id: "ses_countdown",
|
||||
projectID: "proj_test",
|
||||
location: { directory },
|
||||
title: "Retry countdown",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
const model = { id: "model", providerID: "provider" }
|
||||
const error = { type: "provider.transport" as const, message: "Provider unavailable" }
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
args: { sessionID: session.id },
|
||||
config: { animations: false, tabs: { enabled: false } },
|
||||
fetch: (url) => {
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`)
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "msg_countdown",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [],
|
||||
error,
|
||||
retry: { attempt: 2, at: Date.now() + 2_500, error },
|
||||
time: { created: 1 },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if ([`/api/session/${session.id}/inbox`, `/api/session/${session.id}/permission`].includes(url.pathname))
|
||||
return json({ data: [] })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Retrying in 3s"))
|
||||
expect(setup.captureCharFrame()).toContain("attempt 2")
|
||||
expect(setup.captureCharFrame()).toContain("Provider unavailable")
|
||||
expect(setup.captureCharFrame()).not.toContain("Error:")
|
||||
await setup.waitForFrame((frame) => frame.includes("Retrying in 2s"), { maxPasses: 200 })
|
||||
await setup.waitForFrame((frame) => frame.includes("Retrying in 1s"), { maxPasses: 200 })
|
||||
await setup.waitForFrame((frame) => frame.includes("Retry due"), { maxPasses: 200 })
|
||||
expect(setup.captureCharFrame()).not.toContain("in 0s")
|
||||
|
||||
setup.events.emit({
|
||||
id: "evt_countdown_rescheduled",
|
||||
created: 2,
|
||||
type: "session.retry.scheduled",
|
||||
durable: { aggregateID: session.id, seq: 1, version: 1 },
|
||||
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 3, at: Date.now() + 10_500, error },
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes("Retrying in 11s") && frame.includes("attempt 3"))
|
||||
setup.events.emit({
|
||||
id: "evt_countdown_started",
|
||||
created: 3,
|
||||
type: "session.step.started",
|
||||
durable: { aggregateID: session.id, seq: 2, version: 1 },
|
||||
data: { sessionID: session.id, assistantMessageID: "msg_countdown", agent: "build", model },
|
||||
})
|
||||
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
|
||||
|
||||
setup.events.emit({
|
||||
id: "evt_countdown_expired",
|
||||
created: 4,
|
||||
type: "session.retry.scheduled",
|
||||
durable: { aggregateID: session.id, seq: 3, version: 1 },
|
||||
data: { sessionID: session.id, assistantMessageID: "msg_countdown", attempt: 4, at: Date.now() - 1_000, error },
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes("Retry due") && frame.includes("attempt 4"))
|
||||
expect(setup.captureCharFrame()).not.toContain("in -")
|
||||
setup.events.emit({
|
||||
id: "evt_countdown_interrupted",
|
||||
created: 5,
|
||||
type: "session.execution.interrupted",
|
||||
durable: { aggregateID: session.id, seq: 4, version: 1 },
|
||||
data: { sessionID: session.id, reason: "shutdown" },
|
||||
})
|
||||
await setup.waitForFrame((frame) => !frame.includes("Retrying") && !frame.includes("Retry due"))
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
@@ -1579,6 +1579,89 @@ test("tracks session status from active sessions and execution events", async ()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["before", "between", "after"])("shows compaction admitted %s steers in execution order", async (order) => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-compaction-priority"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
return undefined
|
||||
}, events)
|
||||
let rows: SessionRow[] = []
|
||||
let client: ReturnType<typeof useClient> | undefined
|
||||
function Probe() {
|
||||
client = useClient()
|
||||
rows = createSessionRows(() => sessionID)
|
||||
return <box />
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
const admissions =
|
||||
order === "before" ? ["compact", "a", "b"] : order === "between" ? ["a", "compact", "b"] : ["a", "b", "compact"]
|
||||
try {
|
||||
await wait(() => client?.connection.status() === "connected")
|
||||
admissions.forEach((id, index) =>
|
||||
emitEvent(events, {
|
||||
id: `evt_admit_${id}`,
|
||||
created: index + 1,
|
||||
type: "session.inbox.enqueued",
|
||||
durable: durable(sessionID, index + 1),
|
||||
data: {
|
||||
sessionID,
|
||||
inboxID: id,
|
||||
item:
|
||||
id === "compact"
|
||||
? { type: "compaction", payload: {}, delivery: "steer" }
|
||||
: { type: "user", payload: { text: `STEER_${id.toUpperCase()}` }, delivery: "steer" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await wait(() => rows.length === 3)
|
||||
expect(rows).toEqual([
|
||||
{ type: "compaction-queued", inboxID: "compact" },
|
||||
{ type: "message", messageID: "a" },
|
||||
{ type: "message", messageID: "b" },
|
||||
])
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_started",
|
||||
created: 4,
|
||||
type: "session.compaction.started",
|
||||
durable: durable(sessionID, 4),
|
||||
data: { sessionID, reason: "manual", recent: "", inputID: "compact" },
|
||||
})
|
||||
await wait(() => rows[0]?.type === "message")
|
||||
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
created: 5,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable(sessionID, 5),
|
||||
data: { sessionID, reason: "manual", text: "## Objective\n- Checkpoint", recent: "" },
|
||||
})
|
||||
for (const [index, id] of ["a", "b"].entries()) {
|
||||
emitEvent(events, {
|
||||
id: `evt_deliver_${id}`,
|
||||
created: index + 6,
|
||||
type: "session.inbox.delivered",
|
||||
durable: durable(sessionID, index + 6),
|
||||
data: { sessionID, inboxID: id },
|
||||
})
|
||||
}
|
||||
await app.renderOnce()
|
||||
expect(rows).toEqual(["compact", "a", "b"].map((messageID) => ({ type: "message", messageID })))
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("restores queued compaction from durable pending input", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-compaction-queued"
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { PermissionRequest } from "@opencode-ai/client"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { testRender, type JSX } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
import { DataProvider, useData, type FormWithLocation } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { InteractivityProvider } from "../../../src/context/interactivity"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { FormPrompt, FORM_MODE } from "../../../src/routes/session/form"
|
||||
import { PermissionPrompt } from "../../../src/routes/session/permission"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
async function mountPanes(root: string, render: () => JSX.Element, parentID?: string) {
|
||||
const [active, setActive] = createSignal(false)
|
||||
const replies: unknown[] = []
|
||||
const cancellations: string[] = []
|
||||
const submissions: string[] = []
|
||||
const ready = Promise.withResolvers<void>()
|
||||
let peer!: TextareaRenderable
|
||||
let keymap!: Keymap
|
||||
const transport = createFetch((url, request) => {
|
||||
if (url.pathname === "/api/session/ses_scoped")
|
||||
return json({
|
||||
data: {
|
||||
id: "ses_scoped",
|
||||
parentID,
|
||||
title: "Scoped session",
|
||||
projectID: "proj_test",
|
||||
location: { directory: root },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
},
|
||||
})
|
||||
if (url.pathname.endsWith("/reply"))
|
||||
return request.json().then((body) => {
|
||||
replies.push(body)
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
if (url.pathname.endsWith("/cancel")) {
|
||||
cancellations.push(url.pathname)
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
}, createEventStream())
|
||||
|
||||
function Panes() {
|
||||
const data = useData()
|
||||
keymap = Keymap.use()
|
||||
onMount(() => void data.session.sync("ses_scoped").then(ready.resolve, ready.reject))
|
||||
return (
|
||||
<box>
|
||||
<InteractivityProvider enabled={!active()}>
|
||||
<textarea
|
||||
ref={(value) => (peer = value)}
|
||||
focused={!active()}
|
||||
initialValue="peer"
|
||||
onSubmit={() => submissions.push(peer.plainText)}
|
||||
/>
|
||||
</InteractivityProvider>
|
||||
<InteractivityProvider enabled={active()}>{render()}</InteractivityProvider>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={root} paths={{ home: root, state: root, worktree: root }}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ animations: false })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(transport.fetch)}>
|
||||
<DataProvider directory={root}>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<ToastProvider>
|
||||
<Panes />
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 90, height: 24, kittyKeyboard: true },
|
||||
)
|
||||
app.renderer.start()
|
||||
await ready.promise
|
||||
await app.renderOnce()
|
||||
return { app, setActive, replies, cancellations, submissions, peer, keymap }
|
||||
}
|
||||
|
||||
function form(fields: FormWithLocation["fields"]): FormWithLocation {
|
||||
return { id: "frm_scoped", sessionID: "ses_scoped", title: "Scoped form", fields }
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: "per_scoped",
|
||||
sessionID: "ses_scoped",
|
||||
action: "shell",
|
||||
resources: ["echo scoped"],
|
||||
} satisfies PermissionRequest
|
||||
|
||||
test("an inactive form leaves Enter, navigation, and paste with the focused peer", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => (
|
||||
<FormPrompt
|
||||
form={form([
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "staging", label: "Staging" },
|
||||
{ value: "production", label: "Production" },
|
||||
],
|
||||
},
|
||||
])}
|
||||
/>
|
||||
))
|
||||
try {
|
||||
expect(panes.keymap.mode.current()).toBe("base")
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressArrow("down")
|
||||
panes.app.mockInput.pressKey("2")
|
||||
panes.app.mockInput.pressEscape()
|
||||
await panes.app.mockInput.pasteBracketedText(" pasted")
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.peer.plainText).toContain("pasted")
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(panes.cancellations).toEqual([])
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.keymap.mode.current()).toBe(FORM_MODE)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { target: "staging" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("a form textarea mounts inactive and restores its draft focus after scope and modal changes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <FormPrompt form={form([{ key: "notes", type: "string" }])} />)
|
||||
try {
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.setActive(true)
|
||||
const input = panes.app.renderer.currentFocusedEditor
|
||||
expect(input).not.toBeNull()
|
||||
expect(input?.id).not.toBe(panes.peer.id)
|
||||
await panes.app.mockInput.typeText("draft answer")
|
||||
|
||||
const pop = panes.keymap.mode.push("modal")
|
||||
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
|
||||
panes.setActive(false)
|
||||
panes.setActive(true)
|
||||
expect(panes.keymap.mode.current()).toBe("modal")
|
||||
expect(panes.app.renderer.currentFocusedEditor).toBeNull()
|
||||
pop()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
|
||||
panes.setActive(false)
|
||||
input?.focus()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
await panes.app.mockInput.typeText(" other")
|
||||
await panes.app.mockInput.pasteBracketedText(" pane")
|
||||
panes.app.mockInput.pressEnter()
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
expect(input?.plainText).toBe("draft answer")
|
||||
expect(panes.replies).toEqual([])
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { notes: "draft answer" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("inactive custom forms cannot intercept a peer using the same form mode", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => (
|
||||
<FormPrompt
|
||||
form={form([{ key: "target", type: "string", options: [{ value: "staging", label: "Staging" }], custom: true }])}
|
||||
/>
|
||||
))
|
||||
try {
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressArrow("down")
|
||||
panes.setActive(false)
|
||||
const pop = panes.keymap.mode.push(FORM_MODE)
|
||||
await panes.app.mockInput.typeText(" typed")
|
||||
await panes.app.mockInput.pasteBracketedText(" pasted")
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.renderOnce()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
expect(panes.peer.plainText).toContain("typed")
|
||||
expect(panes.peer.plainText).toContain("pasted")
|
||||
expect(panes.app.captureCharFrame()).toContain("Type your own answer")
|
||||
expect(panes.replies).toEqual([])
|
||||
pop()
|
||||
|
||||
panes.setActive(true)
|
||||
await panes.app.mockInput.typeText("production target")
|
||||
await panes.app.waitFor(() => panes.app.renderer.currentFocusedEditor?.plainText === "production target")
|
||||
panes.setActive(false)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.plainText).toBe("production target")
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ answer: { target: "production target" } }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("permission layers leave the focused peer's Enter and navigation alone until activated", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />)
|
||||
try {
|
||||
panes.app.mockInput.pressEnter()
|
||||
panes.app.mockInput.pressArrow("right")
|
||||
panes.app.mockInput.pressEscape()
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ reply: "once" }])
|
||||
expect(panes.submissions).toHaveLength(1)
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("permission rejection text keeps its draft and regains focus when its scope resumes", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const panes = await mountPanes(tmp.path, () => <PermissionPrompt request={request} />, "ses_parent")
|
||||
try {
|
||||
panes.setActive(true)
|
||||
panes.app.mockInput.pressEscape()
|
||||
await panes.app.waitForFrame((frame) => frame.includes("Reject permission"))
|
||||
const input = panes.app.renderer.currentFocusedEditor
|
||||
expect(input).not.toBeNull()
|
||||
await panes.app.mockInput.typeText("choose another command")
|
||||
|
||||
panes.setActive(false)
|
||||
panes.app.mockInput.pressEnter()
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(panes.peer.id)
|
||||
expect(panes.submissions).toEqual(["peer"])
|
||||
expect(panes.replies).toEqual([])
|
||||
expect(input?.plainText).toBe("choose another command")
|
||||
|
||||
panes.setActive(true)
|
||||
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
|
||||
panes.app.mockInput.pressEnter()
|
||||
await panes.app.waitFor(() => panes.replies.length === 1)
|
||||
expect(panes.replies).toEqual([{ reply: "reject", message: "choose another command" }])
|
||||
} finally {
|
||||
panes.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createSignal } from "solid-js"
|
||||
import { DEFAULT_THEME, selectTheme } from "@opencode-ai/theme/tui"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
@@ -166,10 +167,58 @@ test("contextual hooks resolve overrides and fall back to a standalone theme's b
|
||||
if (!theme) throw new Error("Contextual theme is not mounted")
|
||||
if (!explicit) throw new Error("Explicit contextual theme is not mounted")
|
||||
expect(theme.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
expect(theme).toBe(explicit)
|
||||
expect(theme.text.default).toBe(explicit.text.default)
|
||||
expect(theme.text.default).toBe(themes.current.contextual.elevated.text.default)
|
||||
expect(themes.current.contextual.overlay.background.default).toBe(themes.current.background.default)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["dark", "light"] as const)(
|
||||
"reactive %s theme contexts change without remounting their contents",
|
||||
async (mode) => {
|
||||
const [context, setContext] = createSignal<"elevated" | undefined>("elevated")
|
||||
const [parent, setParent] = createSignal<"overlay" | undefined>()
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
let themes: ReturnType<typeof useThemes> | undefined
|
||||
let mounts = 0
|
||||
function Probe() {
|
||||
mounts++
|
||||
theme = useTheme()
|
||||
themes = useThemes()
|
||||
return <text fg={theme.text.default}>probe</text>
|
||||
}
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "opencode", mode } })}>
|
||||
<ThemeProvider mode={mode} source={{ discover: async () => ({}) }}>
|
||||
<ThemeContextProvider context={parent()}>
|
||||
<ThemeContextProvider context={context()}>
|
||||
<Probe />
|
||||
</ThemeContextProvider>
|
||||
</ThemeContextProvider>
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
))
|
||||
app.renderer.start()
|
||||
try {
|
||||
await wait(() => themes?.ready === true)
|
||||
if (!theme || !themes) throw new Error("Theme provider is not mounted")
|
||||
const view = theme
|
||||
expect(view.background.default).toBe(themes.current.contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
await app.flush()
|
||||
expect(view.background.default).toBe(themes.current.background.default)
|
||||
setParent("overlay")
|
||||
await app.flush()
|
||||
expect(view.background.default).toBe(themes.current.contextual.overlay.background.default)
|
||||
setContext("elevated")
|
||||
await app.flush()
|
||||
expect(view.text.default).toBe(themes.current.contextual.elevated.text.default)
|
||||
expect(theme).toBe(view)
|
||||
expect(mounts).toBe(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
|
||||
|
||||
test("interactivity is independent of the keymap and cannot re-enable a disabled ancestor", async () => {
|
||||
const [parent, setParent] = createSignal(true)
|
||||
const [child, setChild] = createSignal(true)
|
||||
let defaults!: () => boolean
|
||||
let enabled!: () => boolean
|
||||
|
||||
function Probe() {
|
||||
enabled = useInteractivity()
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
defaults = useInteractivity()
|
||||
return (
|
||||
<InteractivityProvider enabled={parent()}>
|
||||
<InteractivityProvider enabled={child()}>
|
||||
<Probe />
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(defaults()).toBe(true)
|
||||
expect(enabled()).toBe(true)
|
||||
setParent(false)
|
||||
expect(enabled()).toBe(false)
|
||||
setChild(false)
|
||||
setChild(true)
|
||||
expect(enabled()).toBe(false)
|
||||
setParent(true)
|
||||
expect(enabled()).toBe(true)
|
||||
setChild(false)
|
||||
expect(enabled()).toBe(false)
|
||||
expect(defaults()).toBe(true)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { Keymap } from "../src/context/keymap"
|
||||
import { InteractivityProvider, useInteractivity } from "../src/context/interactivity"
|
||||
|
||||
const config = { keybinds: { get: () => [] } }
|
||||
|
||||
test("disabled scopes isolate named, inline, and global layers without disabling application commands", async () => {
|
||||
const calls: string[] = []
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
let keymap!: Keymap
|
||||
|
||||
function Scoped() {
|
||||
Keymap.createLayer(() => ({
|
||||
commands: [
|
||||
{ id: "scoped.submit", bind: "return", run: () => void calls.push("submit") },
|
||||
{ bind: "j", run: () => void calls.push("inline") },
|
||||
],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "scoped.global", bind: "g", run: () => void calls.push("scoped global") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
keymap = Keymap.use()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
commands: [{ id: "app.global", bind: "x", run: () => void calls.push("app global") }],
|
||||
}))
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("j")
|
||||
app.mockInput.pressKey("g")
|
||||
keymap.dispatch("scoped.submit")
|
||||
keymap.dispatch("scoped.global")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls).toEqual(["app global"])
|
||||
|
||||
setEnabled(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("j")
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["app global", "submit", "inline", "scoped global"])
|
||||
|
||||
const pop = keymap.mode.push("modal")
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls.slice(4)).toEqual(["scoped global", "app global"])
|
||||
|
||||
setEnabled(false)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
app.mockInput.pressKey("x")
|
||||
expect(calls.slice(6)).toEqual(["app global"])
|
||||
pop()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("nested scopes conjoin ancestors and retain dispatch-time layer predicates", async () => {
|
||||
const calls: string[] = []
|
||||
const [parent, setParent] = createSignal(false)
|
||||
const [child, setChild] = createSignal(true)
|
||||
const [layer, setLayer] = createSignal(true)
|
||||
let allowed = true
|
||||
let read!: () => boolean
|
||||
let unscoped!: () => boolean
|
||||
|
||||
function Scoped() {
|
||||
read = useInteractivity()
|
||||
Keymap.createLayer(() => ({
|
||||
enabled: layer(),
|
||||
commands: [{ bind: "return", run: () => void calls.push("boolean") }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: () => allowed,
|
||||
commands: [{ bind: "g", run: () => void calls.push("predicate") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
unscoped = useInteractivity()
|
||||
return (
|
||||
<InteractivityProvider enabled={parent()}>
|
||||
<InteractivityProvider enabled={child()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
expect(unscoped()).toBe(true)
|
||||
expect(read()).toBe(false)
|
||||
app.mockInput.pressEnter()
|
||||
setParent(true)
|
||||
expect(read()).toBe(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
|
||||
allowed = false
|
||||
app.mockInput.pressKey("g")
|
||||
setLayer(false)
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["boolean", "predicate"])
|
||||
|
||||
setChild(false)
|
||||
setLayer(true)
|
||||
allowed = true
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(read()).toBe(false)
|
||||
setParent(false)
|
||||
setChild(true)
|
||||
expect(read()).toBe(false)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["boolean", "predicate"])
|
||||
|
||||
setParent(true)
|
||||
expect(read()).toBe(true)
|
||||
app.mockInput.pressEnter()
|
||||
app.mockInput.pressKey("g")
|
||||
expect(calls).toEqual(["boolean", "predicate", "boolean", "predicate"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ownerless mode pushes suspend and resume in their captured scope without changing stack order", async () => {
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
const calls: string[] = []
|
||||
let scoped!: Keymap
|
||||
let global!: Keymap
|
||||
|
||||
function Scoped() {
|
||||
scoped = Keymap.use()
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "form",
|
||||
commands: [{ bind: "return", run: () => void calls.push("form") }],
|
||||
}))
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "menu",
|
||||
commands: [{ bind: "return", run: () => void calls.push("menu") }],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
global = Keymap.use()
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<Scoped />
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
const form = scoped.mode.push("form")
|
||||
expect(global.mode.current()).toBe("base")
|
||||
app.mockInput.pressEnter()
|
||||
const modal = global.mode.push("modal")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("modal")
|
||||
app.mockInput.pressEnter()
|
||||
modal()
|
||||
expect(global.mode.current()).toBe("form")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form"])
|
||||
|
||||
setEnabled(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
const menu = scoped.mode.push("menu")
|
||||
form()
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form", "menu"])
|
||||
menu()
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(false)
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
app.mockInput.pressEnter()
|
||||
expect(calls).toEqual(["form", "menu"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("forwarded keymaps push modes in the calling component's nested scope and clean up while inactive", async () => {
|
||||
const [enabled, setEnabled] = createSignal(false)
|
||||
const [nested, setNested] = createSignal(true)
|
||||
const [mounted, setMounted] = createSignal(true)
|
||||
let global!: Keymap
|
||||
|
||||
function Scoped(props: { keymap: Keymap }) {
|
||||
onMount(() => onCleanup(props.keymap.mode.push("menu")))
|
||||
return null
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
global = Keymap.use()
|
||||
return (
|
||||
<InteractivityProvider enabled={enabled()}>
|
||||
<InteractivityProvider enabled={nested()}>
|
||||
<Show when={mounted()}>
|
||||
<Scoped keymap={global} />
|
||||
</Show>
|
||||
</InteractivityProvider>
|
||||
</InteractivityProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<Keymap.Provider config={config}>
|
||||
<Harness />
|
||||
</Keymap.Provider>
|
||||
))
|
||||
try {
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setNested(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setNested(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setEnabled(false)
|
||||
setMounted(false)
|
||||
setEnabled(true)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
setMounted(true)
|
||||
expect(global.mode.current()).toBe("menu")
|
||||
setMounted(false)
|
||||
expect(global.mode.current()).toBe("base")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createPanelState } from "../src/context/panel"
|
||||
|
||||
test("presentation changes preserve the selected panel identity", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.setWidth(160)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.toggleFullscreen()
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
expect(panels.current()).toBe(current)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("narrow geometry overrides presentation without discarding the user's choice", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
panels.setWidth(80)
|
||||
expect(panels.canSplit()).toBe(false)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(81)
|
||||
expect(panels.canSplit()).toBe(true)
|
||||
expect(panels.presentation()).toBe("panel")
|
||||
panels.toggleFullscreen()
|
||||
panels.setWidth(60)
|
||||
panels.setWidth(160)
|
||||
expect(panels.presentation()).toBe("fullscreen")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("opening a different name changes the panel selection", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "review.diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
panels.open({ plugin: "review", name: "review.history", sessionID: "session" })
|
||||
expect(panels.current()).not.toBe(current)
|
||||
expect(panels.current()?.name).toBe("review.history")
|
||||
panels.open({ plugin: "tasks", name: "tasks.list", sessionID: "session" })
|
||||
expect(panels.current()).toEqual({ plugin: "tasks", name: "tasks.list", sessionID: "session" })
|
||||
panels.release("review")
|
||||
expect(panels.current()?.name).toBe("tasks.list")
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("releasing a plugin contribution only closes its own selected panel", () => {
|
||||
createRoot((dispose) => {
|
||||
const panels = createPanelState()
|
||||
panels.open({ plugin: "review", name: "diff", sessionID: "session" })
|
||||
const current = panels.current()
|
||||
panels.release("other")
|
||||
expect(panels.current()).toBe(current)
|
||||
panels.release("review")
|
||||
expect(panels.current()).toBeUndefined()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
|
||||
import { createSignal } from "solid-js"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { DEFAULT_THEME, resolveTheme, selectTheme, type ContextName } from "@opencode-ai/theme/tui"
|
||||
import { createComponentTheme } from "../../../src/theme/component"
|
||||
import { createComponentTheme, createComponentThemeView } from "../../../src/theme/component"
|
||||
|
||||
test("provides reactive properties, states, contexts, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
@@ -67,3 +67,19 @@ test("provides reactive properties, states, contexts, and color operations", ()
|
||||
expect(current().decrease(current().background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(current().raise(current().background.surface.offset)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
||||
test("a stable component theme view follows presentation context changes", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
const [context, setContext] = createSignal<ContextName>()
|
||||
const theme = createComponentThemeView(
|
||||
() => (context() ? resolved().contextual[context()!] : resolved()),
|
||||
() => "dark",
|
||||
)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setContext("elevated")
|
||||
expect(theme.background.default).toBe(resolved().contextual.elevated.background.default)
|
||||
setContext(undefined)
|
||||
expect(theme.background.default).toBe(resolved().background.default)
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
})
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] {
|
||||
background-color: transparent;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] [data-slot="icon-svg"] {
|
||||
|
||||
@@ -100,14 +100,6 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
|
||||
},
|
||||
"outline-worktree": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M12 3.11133L14.3885 5.49977L12 7.88822M14.5 5.49972H10.058L5.13456 11.8139M14.5 11.6606L8.5 11.6606M5.50012 11.6597H1.5" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-trash": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2.44434 4.22224H13.5554M5.99994 4.22224V2.44446C5.99994 1.95557 5.99992 1.55557 5.99992 1.55557H9.99989C9.99989 1.55557 9.99994 1.95557 9.99994 2.44446V4.22224M6.55545 7.77779L6.7485 11.7778M9.44437 7.77779L9.2513 11.7778M12.1756 6.88891L11.8666 12.7556C11.8168 13.7068 11.7748 14.4445 11.7748 14.4445H4.22511C4.22511 14.4445 4.18392 13.7067 4.13414 12.7556L3.82509 6.88891" stroke="currentColor"/>`,
|
||||
},
|
||||
close: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
|
||||
@@ -65,13 +65,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tooltip-v2"][data-appearance="large"] {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
@keyframes tooltipV2In {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ Floating tooltip built on Kobalte's tooltip primitive.
|
||||
- \`value\`: Content rendered inside the floating tooltip.
|
||||
- \`children\`: The trigger element that activates the tooltip on hover/focus.
|
||||
- \`placement\`: Kobalte placement string (e.g. "top", "bottom", "left", "right").
|
||||
- \`appearance\`: \`compact\` (default), \`standard\`, or \`large\`.
|
||||
- \`appearance\`: \`compact\` (default) or \`standard\`.
|
||||
- \`inactive\`: When true, renders only the trigger without tooltip behavior.
|
||||
- \`forceOpen\`: Forces the tooltip to stay open.
|
||||
- Inherits Kobalte Tooltip root props.
|
||||
@@ -49,9 +49,6 @@ export const Appearances = {
|
||||
<Tooltip appearance="compact" value="Compact tooltip">
|
||||
<span>Compact</span>
|
||||
</Tooltip>
|
||||
<Tooltip appearance="large" value="Large tooltip">
|
||||
<span>Large</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./tooltip.css"
|
||||
|
||||
export interface TooltipProps extends ComponentProps<typeof Root> {
|
||||
value: JSX.Element
|
||||
appearance?: "standard" | "compact" | "large"
|
||||
appearance?: "standard" | "compact"
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,6 +18221,12 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18448,6 +18454,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -13904,7 +13904,7 @@
|
||||
},
|
||||
"update": {
|
||||
"type": "string",
|
||||
"enum": ["disable", "notify"]
|
||||
"enum": ["disable", "notify", "auto"]
|
||||
},
|
||||
"share": {
|
||||
"type": "string",
|
||||
@@ -18221,6 +18221,12 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -18448,6 +18454,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -236,10 +236,7 @@ function Status() {
|
||||
Register a fenced-code renderer by language; the returned function unregisters it.
|
||||
|
||||
```ts
|
||||
const unregister = context.markdown.registerCodeBlockRenderer(
|
||||
"acme",
|
||||
(_token, render) => render.defaultRender(),
|
||||
)
|
||||
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
|
||||
return unregister
|
||||
```
|
||||
|
||||
@@ -340,7 +337,14 @@ Custom JSX dialogs can set their size and close themselves.
|
||||
|
||||
```tsx
|
||||
context.ui.dialog.set({ size: "large", centered: true })
|
||||
context.ui.dialog.show(() => <box><text>Acme</text></box>, () => console.log("closed"))
|
||||
context.ui.dialog.show(
|
||||
() => (
|
||||
<box>
|
||||
<text>Acme</text>
|
||||
</box>
|
||||
),
|
||||
() => console.log("closed"),
|
||||
)
|
||||
context.ui.dialog.clear()
|
||||
```
|
||||
|
||||
@@ -405,6 +409,84 @@ context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</t
|
||||
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })
|
||||
```
|
||||
|
||||
### Session panels
|
||||
|
||||
Register a contribution to `session.panel`, then open the panel from a command. The host owns sizing, focus, and
|
||||
full-screen presentation; the plugin owns its contents. The selected name is passed to every contribution as
|
||||
`panel.name`, and each contribution decides whether to render.
|
||||
|
||||
```tsx
|
||||
import { Show } from "solid-js"
|
||||
|
||||
context.ui.slot({
|
||||
append: "session.panel",
|
||||
render: (panel) => (
|
||||
<Show when={panel.name === "acme.review"}>
|
||||
<ReviewPanel panel={panel} />
|
||||
</Show>
|
||||
),
|
||||
})
|
||||
|
||||
context.ui.slot({
|
||||
append: "app",
|
||||
render: () => {
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "acme.review",
|
||||
title: "Open review",
|
||||
slash: { name: "review" },
|
||||
run: () => {
|
||||
context.ui.panel.open("acme.review")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Opening outside a session returns `false`.
|
||||
- This is an ordinary slot: all five placements and the existing replacement ordering rules apply.
|
||||
- Use `append` for independently selectable contributions so they can coexist. `replace` still takes over the slot.
|
||||
- Names are shared selection values, not registered claims. Use a plugin-prefixed name such as `acme.review` to avoid collisions. Opening a name with no matching renderer leaves the slot empty.
|
||||
- Changing presentation preserves the mounted contributions. Closing the panel disposes them; disabling a plugin removes its contributions through normal slot cleanup.
|
||||
- Its keyboard layers and input modes are active only while the panel owns input.
|
||||
|
||||
The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and `focused` properties, plus `focus`,
|
||||
`close`, and `toggleFullscreen` actions. The host keeps narrow terminals full-screen; `toggleFullscreen` has no effect
|
||||
until there is enough room for a side panel.
|
||||
|
||||
```tsx
|
||||
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
|
||||
import { usePlugin } from "@opencode-ai/plugin/tui"
|
||||
|
||||
function ReviewPanel(props: { panel: PanelInput }) {
|
||||
const context = usePlugin()
|
||||
context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
id: "acme.review.fullscreen",
|
||||
bind: "f",
|
||||
run: props.panel.toggleFullscreen,
|
||||
},
|
||||
],
|
||||
}))
|
||||
return <text>Reviewing {props.panel.sessionID}</text>
|
||||
}
|
||||
```
|
||||
|
||||
You can request full-screen presentation initially, inspect your active panel, or close it without affecting another
|
||||
plugin's panel.
|
||||
|
||||
```ts
|
||||
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
|
||||
const current = context.ui.panel.current()
|
||||
context.ui.panel.close()
|
||||
```
|
||||
|
||||
## Formatting
|
||||
|
||||
Format filesystem paths for display, including home-directory abbreviation.
|
||||
|
||||
@@ -37,11 +37,11 @@ Manual compaction is available through session interfaces. See the generated [AP
|
||||
operation.
|
||||
|
||||
A manual request is durably admitted and wakes the session runner. It can
|
||||
compact short histories that would not trigger automatic compaction. If the
|
||||
session is busy, compaction runs at the next safe drain boundary before later
|
||||
steered or queued prompts are promoted. Repeated requests while one is pending
|
||||
compact short histories that would not trigger automatic compaction. By default,
|
||||
compaction runs at the next safe step boundary before pending steered or queued
|
||||
prompts, even if they were submitted first. Repeated requests while one is pending
|
||||
coalesce into that pending request. Whether compaction completes or fails, the
|
||||
barrier is then settled so later prompts can proceed.
|
||||
barrier is then settled so pending prompts can proceed.
|
||||
|
||||
The server operation returns the admitted compaction input; it does not wait
|
||||
for summary generation. Clients can then wait for the session or follow the
|
||||
|
||||
@@ -130,7 +130,10 @@ agents.
|
||||
### Updates
|
||||
|
||||
Control update checks from the global config. Set `update` to `"disable"` to
|
||||
skip them or `"notify"` to show available updates before installing them.
|
||||
skip them, `"notify"` to show available updates before installing them, or
|
||||
`"auto"` to install updates automatically. When omitted, `update` defaults to `"auto"`.
|
||||
|
||||
Automatic installation does not restart a running server. Restart it manually to activate the installed update.
|
||||
Project-level values are ignored.
|
||||
|
||||
```jsonc
|
||||
|
||||
@@ -409,8 +409,7 @@ The V1 provider filters do not have one-to-one native V2 config fields, but thei
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, while `"notify"` and `true` map to `"notify"`.
|
||||
- The previous V2 value `update: "auto"` is treated as `update: "notify"`.
|
||||
- `autoupdate` becomes `update`: `false` maps to `"disable"`, `"notify"` maps to `"notify"`, and `true` maps to `"auto"`.
|
||||
- `small_model` becomes the `model` selection for the built-in `title` agent. Native V2 configuration should use
|
||||
`agents.title.model` instead.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user