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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions docs/shared-service.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ not create caller-specific Shuvcode configuration domains.
- The canonical configuration root is `~/.config/opencode`; neither the unit nor
an interactive shell sets `OPENCODE_CONFIG_DIR`.
- Shuvcode's normal XDG data and state roots remain canonical for every caller.
- `~/.config/opencode/service.json` is private mode `0600` and contains the
administrator credential used by trusted loopback clients.
- The channel-specific service files under `~/.config/opencode` and
`~/.local/state/opencode` are private mode `0600`. They contain the
administrator credential and managed-service registration used by trusted
loopback clients. Use `shuvcode service` commands instead of selecting a file
by name.
- Mobile clients receive independently revocable device credentials through
pairing. A bridge may keep its own client-facing credential domain while
using the administrator credential on loopback.
Expand All @@ -39,10 +42,17 @@ to make a reverse-proxy URL reachable.

Stop dependent callers, back up both configuration roots, and merge the desired
server configuration into `~/.config/opencode`. Preserve the active service
password by moving it into the canonical `service.json`, then update dependent
loopback clients to the same value without printing it. Remove any systemd
drop-in that sets `OPENCODE_CONFIG_DIR`, reload the user manager, and restart
Shuvcode before its dependants.
password through `shuvcode service`, then update dependent loopback clients to
the same value without printing it. Remove any systemd drop-in that sets
`OPENCODE_CONFIG_DIR`, reload the user manager, and restart Shuvcode before its
dependants.

Migrate plugins as part of the configuration merge. A V1-shaped document keeps
the singular `plugin` field because the V2 loader migrates the whole document;
a native V2 document uses `plugins`. Do not rename only that field in an
otherwise V1 document: it will be ignored during migration. Replace legacy
plugin entrypoints with their V2 server adapters and verify every required
integration method after restart.

Provider authentication stored only in a legacy credential file is not a V2
integration connection. Reconnect those providers through the V2 TUI after the
Expand All @@ -62,5 +72,8 @@ shuvcode pair

Verify that interactive TUI sessions and paired mobile sessions appear in the
same session list, and that every configured V2 provider appears through the
model endpoint. Logs and verification output must not contain administrator
passwords, invitation tokens, or device credentials.
model endpoint. For providers supplied by plugins, verify the required login
methods too; an Anthropic subscription deployment must advertise an OAuth
method, not only API-key or environment methods. Logs and verification output
must not contain administrator passwords, invitation tokens, or device
credentials.
8 changes: 5 additions & 3 deletions packages/tui/src/component/logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { RGBA, TextAttributes } from "@opentui/core"
import { For, type JSX } from "solid-js"
import { useTheme } from "../context/theme"
import { tint } from "../theme/color"
import { logo } from "../logo"
import { logo } from "../shuv-logo"

export const defaultLogo = logo

export function Logo() {
const { theme } = useTheme()
Expand Down Expand Up @@ -49,11 +51,11 @@ export function Logo() {

return (
<box>
<For each={logo.left}>
<For each={defaultLogo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
<box flexDirection="row">{renderLine(defaultLogo.right[index()], theme.text, true)}</box>
</box>
)}
</For>
Expand Down
18 changes: 10 additions & 8 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { contextUsage } from "../../util/session"
import { abbreviateHome } from "../../runtime"
import { promptMetadata } from "../../util/responsive"

registerOpencodeSpinner()

Expand Down Expand Up @@ -257,9 +258,10 @@ export function Prompt(props: PromptProps) {
],
}))
const [cursorVersion, setCursorVersion] = createSignal(0)
const metadata = createMemo(() => promptMetadata(dimensions().width, local.model.parsed().model))
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const connected = useConnected()
const hasRightContent = createMemo(() => Boolean(props.right))
const hasRightContent = createMemo(() => Boolean(props.right) && !metadata().compact)

function promptModelWarning() {
toast.show({
Expand Down Expand Up @@ -1480,11 +1482,14 @@ export function Prompt(props: PromptProps) {
<text
flexShrink={0}
fg={fadeColor(leader() ? theme.textMuted : theme.text, modelMetaAlpha())}
wrapMode="none"
>
{local.model.parsed().model}
{metadata().model}
</text>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
<Show when={showVariant()}>
<Show when={!metadata().compact}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>{currentProviderLabel()}</text>
</Show>
<Show when={showVariant() && !metadata().compact}>
<text fg={fadeColor(theme.textMuted, variantMetaAlpha())}>·</text>
<text>
<span style={{ fg: fadeColor(theme.warning, variantMetaAlpha()), bold: true }}>
Expand Down Expand Up @@ -1565,10 +1570,7 @@ export function Prompt(props: PromptProps) {
</box>
</Match>
<Match when={true}>
<Show
when={!props.hint && locationLabel()}
fallback={props.hint ?? <text />}
>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text fg={theme.textMuted} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
{location()}
Expand Down
3 changes: 3 additions & 0 deletions packages/tui/src/config/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export const Info = Schema.Struct({
).annotate({ description: "Terminal integration settings" }),
prompt: Schema.optional(
Schema.Struct({
max_width: Schema.optional(
Schema.Union([Schema.Int.check(Schema.isGreaterThan(0)), Schema.Literal("auto")]),
).annotate({ description: "Prompt width in columns; 'auto' follows the terminal width" }),
editor: Schema.optional(Schema.Boolean).annotate({
description: "Include the active editor file or selection as prompt context",
}),
Expand Down
15 changes: 9 additions & 6 deletions packages/tui/src/feature-plugins/home/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
import { useTheme } from "../../context/theme"
import { abbreviateHome } from "../../runtime"
import { FilePath } from "../../ui/file-path"
import { homeFooterLayout } from "../../util/responsive"

function Directory(props: { context: Plugin.Context; maxWidth: number }) {
const { theme } = useTheme()
Expand Down Expand Up @@ -56,6 +57,9 @@ function View(props: { context: Plugin.Context }) {
const count = list.filter((item) => item.status.status === "connected").length
return Bun.stringWidth(`⊙ ${count} MCP /status`) + 2
})
const layout = createMemo(() =>
homeFooterLayout(dimensions().width, Bun.stringWidth(InstallationVersion), mcpWidth()),
)

return (
<box
Expand All @@ -66,13 +70,12 @@ function View(props: { context: Plugin.Context }) {
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
gap={layout().gap}
>
<Directory
context={props.context}
maxWidth={Math.max(2, dimensions().width - 8 - Bun.stringWidth(InstallationVersion) - mcpWidth())}
/>
<Mcp context={props.context} />
<Directory context={props.context} maxWidth={layout().directoryWidth} />
<Show when={layout().showMcp}>
<Mcp context={props.context} />
</Show>
<box flexGrow={1} />
<box flexShrink={0}>
<text fg={theme.textMuted}>{InstallationVersion}</text>
Expand Down
23 changes: 16 additions & 7 deletions packages/tui/src/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { FormPrompt } from "./session/form"
import { PluginSlot } from "../plugin/context"
import { useTerminalDimensions } from "@opentui/solid"
import { useConfig } from "../config"
import { homePromptMaxWidth, showHomeLogo } from "../util/responsive"

let once = false
const placeholder = {
Expand All @@ -28,6 +31,10 @@ export function Home() {
const editor = useEditorContext()
const data = useData()
const location = useLocation()
const dimensions = useTerminalDimensions()
const config = useConfig().data
const promptMaxWidth = createMemo(() => homePromptMaxWidth(dimensions().width, config.prompt?.max_width))
const logoVisible = createMemo(() => showHomeLogo(dimensions().width, dimensions().height))
// Global MCP elicitations can arrive without a session route, so keep them reachable from Home.
const forms = createMemo(() => data.session.form.list("global", data.location.default()) ?? [])
let sent = false
Expand Down Expand Up @@ -69,13 +76,15 @@ export function Home() {
<box flexGrow={1} alignItems="center" paddingLeft={2} paddingRight={2}>
<box flexGrow={1} minHeight={0} />
<box height={4} minHeight={0} flexShrink={1} />
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<Show when={logoVisible()}>
<box flexShrink={0}>
<pluginRuntime.Slot name="home_logo" mode="replace">
<Logo />
</pluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
</Show>
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<pluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt
ref={bind}
Expand Down
28 changes: 28 additions & 0 deletions packages/tui/src/util/responsive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Locale } from "./locale"

export function homePromptMaxWidth(width: number, configured: number | "auto" | undefined) {
const available = Math.max(1, width - 4)
if (configured === "auto") return Math.min(available, Math.max(40, Math.floor(width * 0.7)))
return Math.min(available, configured ?? 75)
}

export function showHomeLogo(width: number, height: number) {
return width >= 80 && height >= 24
}

export function homeFooterLayout(width: number, versionWidth: number, mcpWidth: number) {
const compact = width < 70
return {
directoryWidth: Math.max(2, width - 8 - versionWidth - (compact ? 0 : mcpWidth)),
gap: compact ? 1 : 2,
showMcp: !compact,
}
}

export function promptMetadata(width: number, model: string) {
const compact = width < 70
return {
compact,
model: compact ? Locale.truncate(model, Math.max(8, width - 28)) : model,
}
}
7 changes: 7 additions & 0 deletions packages/tui/test/logo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { expect, test } from "bun:test"
import { defaultLogo } from "../src/component/logo"
import { logo } from "../src/shuv-logo"

test("the default home logo uses the shuvcode wordmark", () => {
expect(defaultLogo).toEqual(logo)
})
33 changes: 33 additions & 0 deletions packages/tui/test/responsive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { homeFooterLayout, homePromptMaxWidth, promptMetadata, showHomeLogo } from "../src/util/responsive"

describe("responsive TUI layout", () => {
test("clamps the home prompt to the terminal content width", () => {
expect(homePromptMaxWidth(60, undefined)).toBe(56)
expect(homePromptMaxWidth(120, undefined)).toBe(75)
expect(homePromptMaxWidth(100, "auto")).toBe(70)
expect(homePromptMaxWidth(30, "auto")).toBe(26)
})

test("hides the home logo when either terminal dimension is constrained", () => {
expect(showHomeLogo(80, 24)).toBe(true)
expect(showHomeLogo(79, 24)).toBe(false)
expect(showHomeLogo(80, 23)).toBe(false)
})

test("hides MCP status and budgets the footer directory on narrow terminals", () => {
expect(homeFooterLayout(60, 12, 18)).toEqual({ directoryWidth: 40, gap: 1, showMcp: false })
expect(homeFooterLayout(100, 12, 18)).toEqual({ directoryWidth: 62, gap: 2, showMcp: true })
})

test("compacts long model metadata below 70 columns", () => {
expect(promptMetadata(69, "anthropic/claude-opus-4-very-long-model-name")).toEqual({
compact: true,
model: "anthropic/claude-opus-4-very-long-model-\u2026",
})
expect(promptMetadata(70, "anthropic/claude-opus-4-very-long-model-name")).toEqual({
compact: false,
model: "anthropic/claude-opus-4-very-long-model-name",
})
})
})
Loading