Compare commits

...

15 Commits

Author SHA1 Message Date
Shantur Rathore
127a1f628d feat(server,ui): allow OpenCode directory override via proxy path 2026-02-18 09:43:30 +00:00
Shantur Rathore
859312ba3b feat(ui): add dispose instance and rehydrate
Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
2026-02-18 01:07:52 +00:00
Shantur Rathore
4eaa711f01 fix(ui): make alert dialog scrollable for long errors 2026-02-18 00:27:26 +00:00
Shantur Rathore
c8ff858565 fix(ui): render user message text as markdown
User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors.
2026-02-17 22:44:30 +00:00
Shantur Rathore
6de6ef5a4a Bump to v0.11.2 2026-02-17 18:47:21 +00:00
Shantur Rathore
4dee154490 docs: add star history chart 2026-02-17 18:43:02 +00:00
Shantur Rathore
ef388adc4f fix(server): avoid back to login after auth
Replace /login history entry on success and redirect authenticated /login to /, with no-store headers to prevent caching.
2026-02-17 18:27:41 +00:00
Shantur Rathore
e8cfad1266 fix(ui): anchor fullscreen exit button to viewport
Render the mobile fullscreen exit button at the App root so fixed positioning stays pinned to the top-right regardless of instance header visibility.
2026-02-17 18:13:44 +00:00
Shantur Rathore
3f82dd21fe fix(ui): reduce prompt expanded height on mobile
Use the existing instance shell layout mode to cap expanded prompt rows to 10 on phone/tablet while keeping 15 on desktop.
2026-02-17 18:04:37 +00:00
Shantur Rathore
dc13d9a7d0 fix(ui): avoid mobile prompt focus on switch
Stops auto-focusing the prompt on phone session switches and scopes type-to-focus to the active visible prompt, disabling it on coarse pointers.
2026-02-17 18:00:48 +00:00
Shantur Rathore
29557fba6d feat(ui): add mobile fullscreen mode
Adds an in-memory mobile fullscreen toggle that hides chrome and uses the Fullscreen API when available.
2026-02-17 17:30:03 +00:00
Shantur Rathore
dea5079713 feat(ui): add diff toolbar toggles and word wrap
Replace split/unified and context controls with icon toggles, add a word-wrap toggle (default on), and move the toolbar into the tab header to free vertical space.
2026-02-17 13:47:07 +00:00
Shantur Rathore
ddc58a2c3c feat(ui): add context meter indicator
Replace duplicated Used/Avail pills with a shared ContextMeter component and add a small filled context usage indicator for quick scanning.
2026-02-17 12:26:03 +00:00
Shantur Rathore
eafd4d83af fix(ui): use model input limit for avail tokens
Upgrade @opencode-ai/sdk to 1.2.6 and prefer v2 model limit.input when present for the session AVAIL chip; otherwise keep the existing context-window-based estimate.
2026-02-17 11:13:17 +00:00
Shantur Rathore
1a0734c6b1 fix(ui): persist listening mode before restart 2026-02-16 21:39:46 +00:00
60 changed files with 1209 additions and 256 deletions

View File

@@ -123,3 +123,6 @@ To build the Desktop App from source:
1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)

20
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.2",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -2809,9 +2809,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz",
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==",
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT"
},
"node_modules/@pinojs/redact": {
@@ -11985,7 +11985,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1",
"version": "0.11.2",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.2",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.11.1",
"version": "0.11.2",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12070,12 +12070,12 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.11.1",
"version": "0.11.2",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11",
"@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.11.1",
"version": "0.11.2",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1",
"version": "0.11.2",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {

View File

@@ -4,6 +4,6 @@
"private": true,
"license": "MIT",
"dependencies": {
"@opencode-ai/plugin": "1.2.4"
"@opencode-ai/plugin": "1.2.6"
}
}
}

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.2",
"dependencies": {
"@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.11.1",
"version": "0.11.2",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: {
request: FastifyRequest
reply: FastifyReply
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
return
}
const directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
try {
extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix)
if (extracted.overrideDirectory) {
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
})
}
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") {
return "/"

View File

@@ -119,7 +119,8 @@
showError(message || `Login failed (${res.status})`)
return
}
window.location.href = "/"
// Replace history entry so Back doesn't return to /login.
window.location.replace("/")
} catch (e) {
showError(e && e.message ? e.message : String(e))
}

View File

@@ -51,7 +51,19 @@ function getTokenHtml(): string {
}
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => {
app.get("/login", async (request, reply) => {
// If already authenticated, don't show the login page.
const session = deps.authManager.getSessionFromRequest(request)
if (session) {
reply.redirect("/")
return
}
// Avoid caching the login page (helps with bfcache/back behavior).
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username))
})
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
return
}
// Avoid caching the token bootstrap page.
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(getTokenHtml())
})

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.11.1",
"version": "0.11.2",
"private": true,
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.11.1",
"version": "0.11.2",
"private": true,
"license": "MIT",
"type": "module",
@@ -13,7 +13,7 @@
"dependencies": {
"@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11",
"@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0",

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts"
@@ -82,6 +84,46 @@ const App: Component = () => {
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
// In-memory only: hides chrome on phone; may also request browser fullscreen.
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
const fullscreenSupported = () => {
if (typeof document === "undefined") return false
const el = document.documentElement as any
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
}
const syncBrowserFullscreenState = () => {
if (typeof document === "undefined") return
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
}
const enterMobileFullscreen = async () => {
if (!isPhoneLayout()) return
setMobileFullscreenMode(true)
if (!fullscreenSupported()) return
try {
await document.documentElement.requestFullscreen()
} catch {
// Ignore: immersive mode still works without browser fullscreen.
}
}
const exitMobileFullscreen = async () => {
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
try {
await document.exitFullscreen()
} catch {
// Ignore
}
}
setMobileFullscreenMode(false)
}
createEffect(() => {
if (typeof document === "undefined") return
const shouldShow =
@@ -95,6 +137,56 @@ const App: Component = () => {
setInstanceTabBarHeight(element?.offsetHeight ?? 0)
}
onMount(() => {
if (typeof document === "undefined") return
syncBrowserFullscreenState()
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
})
onMount(() => {
if (typeof window === "undefined") return
const vv = window.visualViewport
if (!vv) return
const updateKeyboardOffset = () => {
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
}
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
schedule()
vv.addEventListener("resize", schedule)
vv.addEventListener("scroll", schedule)
window.addEventListener("orientationchange", schedule)
onCleanup(() => {
vv.removeEventListener("resize", schedule)
vv.removeEventListener("scroll", schedule)
window.removeEventListener("orientationchange", schedule)
document.documentElement.style.removeProperty("--keyboard-offset")
})
})
// If the user exits browser fullscreen via browser UI, restore chrome.
let lastBrowserFullscreen = false
createEffect(() => {
const active = browserFullscreenActive()
const mode = mobileFullscreenMode()
if (mode && lastBrowserFullscreen && !active) {
setMobileFullscreenMode(false)
}
lastBrowserFullscreen = active
})
// If we leave phone layout (rotation / resize), restore chrome.
createEffect(() => {
if (!isPhoneLayout() && mobileFullscreenMode()) {
void exitMobileFullscreen()
}
})
createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
})
@@ -405,19 +497,34 @@ const App: Component = () => {
</div>
</Dialog.Portal>
</Dialog>
<div class="h-screen w-screen flex flex-col">
<div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
<div class="mobile-fullscreen-exit-wrapper">
<button
type="button"
class="message-scroll-button mobile-fullscreen-exit-button"
onClick={() => void exitMobileFullscreen()}
aria-label={t("instanceShell.fullscreen.exit")}
title={t("instanceShell.fullscreen.exit")}
>
<Minimize2 class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show
when={!hasInstances()}
fallback={
<>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
<Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
<InstanceTabs
instances={instances()}
activeInstanceId={activeInstanceId()}
onSelect={setActiveInstanceId}
onClose={handleCloseInstance}
onNew={handleNewInstanceRequest}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
<For each={Array.from(instances().values())}>
{(instance) => {
@@ -435,7 +542,10 @@ const App: Component = () => {
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()}
tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/>
</InstanceMetadataProvider>

View File

@@ -115,28 +115,36 @@ const AlertDialog: Component = () => {
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description>
</div>
</div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content
class="modal-surface w-full max-w-xl md:max-w-2xl p-6 border border-base shadow-2xl max-h-[85vh] overflow-hidden flex flex-col"
tabIndex={-1}
>
<div class="flex items-start gap-3 min-h-0">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{
"background-color": accent.badgeBg,
"border-color": accent.badgeBorder,
color: accent.badgeText,
}}
aria-hidden
>
{accent.symbol}
</div>
<div class="flex-1 min-w-0 min-h-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1">
<div
class="max-h-[60vh] overflow-auto pr-2 whitespace-pre-wrap break-words"
style={{ "overflow-wrap": "anywhere" }}
>
{payload.message}
{payload.detail && <div class="mt-3">{payload.detail}</div>}
</div>
</Dialog.Description>
</div>
</div>
<Show when={isPrompt}>
<div class="mt-4">
@@ -185,14 +193,14 @@ const AlertDialog: Component = () => {
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
)
}}
</Show>
)
}
export default AlertDialog

View File

@@ -0,0 +1,123 @@
import type { Component } from "solid-js"
interface ContextMeterProps {
usedTokens: number
availableTokens: number | null
formatTokens: (value: number) => string
usedLabel: string
availableLabel: string
class?: string
}
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveFillColor(percent: number): string {
if (percent >= 0.8) return "var(--status-error)"
if (percent >= 0.6) return "var(--status-warning)"
return "var(--status-success)"
}
export const ContextMeter: Component<ContextMeterProps> = (props) => {
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
const percent = () => {
const usedValue = used()
const availableValue = available()
if (availableValue === null || availableValue <= 0) return null
// Heuristic: if available >= used, treat it like a capacity/limit.
// Otherwise treat it like remaining tokens.
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
return clamp(ratio, 0, 1)
}
const fillColor = () => {
const value = percent()
if (value === null) return "var(--border-base)"
return resolveFillColor(value)
}
const percentLabel = () => {
const value = percent()
if (value === null) return "--"
return `${Math.round(value * 100)}%`
}
const containerClass =
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
}
}
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, startAngle)
const end = polarToCartesian(cx, cy, r, endAngle)
const delta = ((endAngle - startAngle) % 360 + 360) % 360
const largeArc = delta > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
const circle = () => {
const value = percent()
const size = 22
const r = 9
const cx = 11
const cy = 11
const progress = value === null ? 0 : value
const startAngle = -90
const endAngle = startAngle + progress * 360
const isFull = progress >= 0.999
const hasFill = progress > 0.001
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
return (
<svg
width={size}
height={size}
viewBox="0 0 22 22"
aria-hidden="true"
style={{ flex: "0 0 auto" }}
>
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
{isFull ? (
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
) : sectorPath ? (
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
) : null}
</svg>
)
}
const tooltipText = () => `Context Used: ${percentLabel()}`
return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
</div>
)
}
export default ContextMeter

View File

@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
after: string
viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
}
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -54,7 +55,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
scrollBeyondLastLine: false,
renderWhitespace: "selection",
fontSize: 13,
wordWrap: "off",
wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: false,
folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
@@ -81,6 +82,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
diffEditor.updateOptions({
renderSideBySide: viewMode === "split",
@@ -89,7 +91,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
contextMode === "collapsed"
? { enabled: true }
: { enabled: false },
wordWrap,
})
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
})
createEffect(() => {

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return (
<div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show>
<div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
</div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,14 +1,21 @@
import { Component, For, Show, createMemo } from "solid-js"
import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps {
instance: Instance
compact?: boolean
showDisposeButton?: boolean
}
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext()
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : []
})
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return (
<div class="panel">
<div class="panel-header">
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div>
</div>
</div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div>
</div>
)

View File

@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client"
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status"
import { ShieldAlert } from "lucide-solid"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types"
import {
@@ -69,6 +70,11 @@ interface InstanceShellProps {
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void
tabBarOffset: number
// In-memory only: mobile immersive/fullscreen mode.
mobileFullscreenMode: boolean
onEnterMobileFullscreen: () => void
onExitMobileFullscreen: () => void
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
@@ -117,6 +123,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
})
const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -349,16 +357,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
measureDrawerHost,
})
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
const formattedAvailableTokens = () => {
const avail = tokenStats().avail
if (typeof avail === "number") {
return formatTokenTotal(avail)
}
return "--"
}
const renderLeftPanel = () => {
if (leftPinned()) {
@@ -594,13 +592,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{renderLeftPanel()}
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={!mobileFullscreen()}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
<Show
when={!isPhoneLayout()}
fallback={
<div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={leftDrawerState() === "floating-closed"}>
<IconButton
ref={setLeftToggleButtonEl}
@@ -647,6 +646,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span>
</div>
<Show when={!props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
<Show when={rightDrawerState() === "floating-closed"}>
<IconButton
ref={setRightToggleButtonEl}
@@ -661,20 +672,15 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div>
}
>
@@ -693,18 +699,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
<Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.usedLabel")}
</span>
<span class="font-semibold text-primary">{formattedUsedTokens()}</span>
</div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
<ContextMeter
usedTokens={tokenStats().used}
availableTokens={tokenStats().avail}
formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</Show>
<div class="ml-auto flex items-center session-header-hints">
@@ -769,9 +770,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show>
</div>
</div>
</Show>
</Toolbar>
</AppBar>
</Show>
</Toolbar>
</AppBar>
</Show>
<Box
component="main"
@@ -808,6 +810,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId={props.instance.id}
instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
)
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -195,6 +199,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
})
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
const clampSplitWidth = (value: number) => {
const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen}
onToggleList={toggleChangesList}
splitWidth={changesSplitWidth}
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
scopeKey={gitScopeKey}
diffViewMode={diffViewMode}
diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen}

View File

@@ -1,50 +1,61 @@
import type { Component } from "solid-js"
import type { DiffContextMode, DiffViewMode } from "../types"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps {
viewMode: DiffViewMode
contextMode: DiffContextMode
wordWrapMode: DiffWordWrapMode
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
return (
<div class="file-viewer-toolbar">
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`}
aria-pressed={props.viewMode === "split"}
onClick={() => props.onViewModeChange("split")}
class="file-viewer-toolbar-icon-button"
onClick={() => props.onViewModeChange(nextViewMode())}
aria-label={viewModeTitle()}
title={viewModeTitle()}
>
Split
{nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`}
aria-pressed={props.viewMode === "unified"}
onClick={() => props.onViewModeChange("unified")}
class="file-viewer-toolbar-icon-button"
onClick={() => props.onContextModeChange(nextContextMode())}
aria-label={contextModeTitle()}
title={contextModeTitle()}
>
Unified
{nextContextMode() === "collapsed" ? (
<FoldVertical class="h-4 w-4" aria-hidden="true" />
) : (
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
)}
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"}
onClick={() => props.onContextModeChange("collapsed")}
title="Hide unchanged regions"
class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
aria-label={wordWrapTitle()}
title={wordWrapTitle()}
>
Collapsed
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
<WrapText class="h-4 w-4" aria-hidden="true" />
</button>
</div>
)

View File

@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -18,8 +18,10 @@ interface ChangesTabProps {
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -77,14 +79,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
@@ -102,6 +96,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
)}
</Show>
@@ -182,6 +177,17 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<span class="files-tab-stat-value">-{totals.deletions}</span>
</span>
</div>
<div style={{ "margin-left": "auto" }}>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</div>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void
onRefresh: () => void
@@ -80,14 +82,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderViewer = () => (
<div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco">
<Show
when={props.selectedLoading()}
@@ -122,6 +116,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
after={String((file() as any).after || "")}
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/>
)}
</Show>
@@ -237,6 +232,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
>
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</>
}
list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"

View File

@@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

@@ -1,10 +1,8 @@
import { Show } from "solid-js"
import Kbd from "./kbd"
import ContextMeter from "./context-meter"
import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps {
usedTokens: number
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info">
<div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span>
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span>
</div>
<div class={METRIC_CHIP_CLASS}>
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span>
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span>
</div>
<ContextMeter
usedTokens={props.usedTokens}
availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
formatTokens={props.formatTokens}
usedLabel={t("messageListHeader.metrics.usedLabel")}
availableLabel={t("messageListHeader.metrics.availableLabel")}
/>
</div>
</div>

View File

@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -17,16 +16,18 @@ interface MessagePartProps {
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null
onRendered?: () => void
}
export default function MessagePart(props: MessagePartProps) {
}
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => {
const part = props.part
@@ -57,6 +58,11 @@ interface MessagePartProps {
return ""
}
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") {
return segment.trim().length > 0
@@ -91,20 +97,28 @@ interface MessagePartProps {
const createTextPartForMarkdown = (): TextPart => {
const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") {
if (part.type === "text" && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.type === "text" ? part.synthetic : false,
version: (part as { version?: number }).version
text: (part as any).text,
synthetic: false,
version: (part as { version?: number }).version,
renderCache: (part as any).renderCache,
}
}
return {
id: part.id,
type: "text",
type: "text",
text: "",
synthetic: false
synthetic: false,
}
}
@@ -117,22 +131,18 @@ interface MessagePartProps {
<Switch>
<Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}>
<Show
when={isAssistantMessage()}
fallback={<span class="text-primary">{plainTextContent()}</span>}
>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
<div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
<Markdown
part={createTextPartForMarkdown()}
instanceId={props.instanceId}
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
onRendered={props.onRendered}
/>
</Show>
</div>
</Show>
</Match>

View File

@@ -176,15 +176,26 @@ export default function PromptInput(props: PromptInputProps) {
),
)
onMount(() => {
const isCoarsePointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
if (isCoarsePointer()) return
if (props.isActive === false) return
if (props.disabled) return
const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement
const activeElement = document.activeElement as HTMLElement | null
const isInputElement =
activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" ||
activeElement?.isContentEditable
Boolean(activeElement?.isContentEditable)
if (isInputElement) return
@@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return
const isSpecialKey =
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete"
e.key === "Tab" ||
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return
if (e.key.length === 1 && textareaRef && !props.disabled) {
textareaRef.focus()
const textarea = textareaRef
if (!textarea || textarea.disabled) return
// In session cache mode inactive panes are display:none; avoid stealing focus.
if (textarea.offsetParent === null) return
if (e.key.length === 1) {
textarea.focus()
}
}
document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown)
})
@@ -435,7 +455,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
disabled={props.disabled}
rows={expandState() === "expanded" ? 15 : 4}
rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
spellcheck={false}
autocorrect="off"
autoCapitalize="off"

View File

@@ -17,6 +17,12 @@ export interface PromptInputProps {
instanceId: string
instanceFolder: string
sessionId: string
// Used to scope global "type-to-focus" behavior.
isActive?: boolean
// Phone/tablet layouts should keep the expanded prompt more compact.
compactLayout?: boolean
onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void>
disabled?: boolean

View File

@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null)
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return
}
if (applyingListeningMode()) {
return
}
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning",
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return
}
setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
setApplyingListeningMode(true)
setError(null)
try {
// Important: await the config patch before restart so Electron reads the updated mode from disk.
await setListeningMode(targetMode)
const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
}
void refreshMeta()
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked)
}}
disabled={loading() || applyingListeningMode()}
>
<Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>

View File

@@ -28,6 +28,8 @@ interface SessionViewProps {
instanceId: string
instanceFolder: string
escapeInDebounce: boolean
isPhoneLayout?: boolean
compactPromptLayout?: boolean
showSidebarToggle?: boolean
onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean
@@ -76,6 +78,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => {
if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
if (props.isPhoneLayout) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null
@@ -314,17 +319,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
</Show>
<PromptInput
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
instanceId={props.instanceId}
instanceFolder={props.instanceFolder}
sessionId={activeSession.id}
isActive={props.isActive}
compactLayout={props.compactPromptLayout}
onSend={handleSendMessage}
onRunShell={handleRunShell}
escapeInDebounce={props.escapeInDebounce}
isSessionBusy={sessionBusy()}
disabled={sessionNeedsInput()}
onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
</div>
)
}}

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Open right drawer",
"instanceShell.rightDrawer.toggle.close": "Close right drawer",
"instanceShell.fullscreen.enter": "Full screen",
"instanceShell.fullscreen.exit": "Exit full screen",
"instanceShell.metrics.usedLabel": "Used",
"instanceShell.metrics.availableLabel": "Avail",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom",
"infoView.dispose.actions.dispose": "Dispose instance",
"infoView.dispose.actions.disposing": "Disposing...",
"infoView.dispose.confirm.title": "Dispose instance?",
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
"infoView.dispose.confirm.confirmLabel": "Dispose",
"infoView.dispose.confirm.cancelLabel": "Cancel",
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
"infoView.dispose.toast.error": "Failed to dispose instance.",
} as const

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
"instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho",
"instanceShell.fullscreen.enter": "Pantalla completa",
"instanceShell.fullscreen.exit": "Salir de pantalla completa",
"instanceShell.metrics.usedLabel": "Usado",
"instanceShell.metrics.availableLabel": "Disp.",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
"infoView.logs.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final",
"infoView.dispose.actions.dispose": "Desechar instancia",
"infoView.dispose.actions.disposing": "Desechando...",
"infoView.dispose.confirm.title": "¿Desechar instancia?",
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
"infoView.dispose.confirm.confirmLabel": "Desechar",
"infoView.dispose.confirm.cancelLabel": "Cancelar",
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
} as const

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
"instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit",
"instanceShell.fullscreen.enter": "Plein écran",
"instanceShell.fullscreen.exit": "Quitter le plein écran",
"instanceShell.metrics.usedLabel": "Utilisé",
"instanceShell.metrics.availableLabel": "Dispo",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
"infoView.logs.scrollToBottom": "Aller en bas",
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
"infoView.dispose.actions.disposing": "Réinitialisation...",
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
"infoView.dispose.confirm.cancelLabel": "Annuler",
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
} as const

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
"instanceShell.fullscreen.enter": "全画面",
"instanceShell.fullscreen.exit": "全画面を終了",
"instanceShell.metrics.usedLabel": "使用",
"instanceShell.metrics.availableLabel": "残り",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
"infoView.logs.empty.waiting": "サーバー出力を待機中...",
"infoView.logs.scrollToBottom": "最下部へスクロール",
"infoView.dispose.actions.dispose": "インスタンスを破棄",
"infoView.dispose.actions.disposing": "破棄しています...",
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
"infoView.dispose.confirm.confirmLabel": "破棄",
"infoView.dispose.confirm.cancelLabel": "キャンセル",
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
} as const

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
"instanceShell.fullscreen.enter": "Полный экран",
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
"instanceShell.metrics.usedLabel": "Использовано",
"instanceShell.metrics.availableLabel": "Доступно",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
"infoView.logs.empty.waiting": "Ожидание вывода сервера…",
"infoView.logs.scrollToBottom": "Прокрутить вниз",
"infoView.dispose.actions.dispose": "Сбросить инстанс",
"infoView.dispose.actions.disposing": "Сброс...",
"infoView.dispose.confirm.title": "Сбросить инстанс?",
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
"infoView.dispose.confirm.confirmLabel": "Сбросить",
"infoView.dispose.confirm.cancelLabel": "Отмена",
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
} as const

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
"instanceShell.fullscreen.enter": "全屏",
"instanceShell.fullscreen.exit": "退出全屏",
"instanceShell.metrics.usedLabel": "已用",
"instanceShell.metrics.availableLabel": "可用",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
"infoView.logs.empty.waiting": "正在等待服务器输出...",
"infoView.logs.scrollToBottom": "滚动到底部",
"infoView.dispose.actions.dispose": "释放实例",
"infoView.dispose.actions.disposing": "正在释放...",
"infoView.dispose.confirm.title": "要释放实例吗?",
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
"infoView.dispose.confirm.confirmLabel": "释放",
"infoView.dispose.confirm.cancelLabel": "取消",
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
"infoView.dispose.toast.error": "释放实例失败。",
} as const

View File

@@ -4,12 +4,12 @@ import { CODENOMAD_API_BASE } from "./api-client"
class SDKManager {
private clients = new Map<string, OpencodeClient>()
private key(instanceId: string, worktreeSlug: string): string {
return `${instanceId}:${worktreeSlug || "root"}`
private key(instanceId: string, proxyPath: string): string {
return `${instanceId}:${normalizeProxyPath(proxyPath)}`
}
createClient(instanceId: string, proxyPath: string, worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, worktreeSlug)
createClient(instanceId: string, proxyPath: string, _worktreeSlug = "root"): OpencodeClient {
const key = this.key(instanceId, proxyPath)
const existing = this.clients.get(key)
if (existing) {
return existing
@@ -23,12 +23,12 @@ class SDKManager {
return client
}
getClient(instanceId: string, worktreeSlug = "root"): OpencodeClient | null {
return this.clients.get(this.key(instanceId, worktreeSlug)) ?? null
getClient(instanceId: string, proxyPath: string): OpencodeClient | null {
return this.clients.get(this.key(instanceId, proxyPath)) ?? null
}
destroyClient(instanceId: string, worktreeSlug = "root"): void {
this.clients.delete(this.key(instanceId, worktreeSlug))
destroyClient(instanceId: string, proxyPath: string): void {
this.clients.delete(this.key(instanceId, proxyPath))
}
destroyClientsForInstance(instanceId: string): void {
@@ -46,7 +46,7 @@ class SDKManager {
export type { OpencodeClient }
function buildInstanceBaseUrl(proxyPath: string): string {
export function buildInstanceBaseUrl(proxyPath: string): string {
const normalized = normalizeProxyPath(proxyPath)
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
return `${base}${normalized}/`

View File

@@ -54,6 +54,13 @@ interface BackgroundProcessRemovedEvent {
}
}
interface ServerInstanceDisposedEvent {
type: "server.instance.disposed"
properties: {
directory: string
}
}
type SSEEvent =
| MessageUpdateEvent
| MessageRemovedEvent
@@ -74,6 +81,7 @@ type SSEEvent =
| TuiToastEvent
| BackgroundProcessUpdatedEvent
| BackgroundProcessRemovedEvent
| ServerInstanceDisposedEvent
| { type: string; properties?: Record<string, unknown> }
type ConnectionStatus = InstanceStreamStatus
@@ -173,6 +181,9 @@ class SSEManager {
case "background.process.removed":
this.onBackgroundProcessRemoved?.(instanceId, event as BackgroundProcessRemovedEvent)
break
case "server.instance.disposed":
this.onInstanceDisposed?.(instanceId, event as ServerInstanceDisposedEvent)
break
default:
log.warn("Unknown SSE event type", { type: event.type })
}
@@ -205,6 +216,7 @@ class SSEManager {
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
onBackgroundProcessUpdated?: (instanceId: string, event: BackgroundProcessUpdatedEvent) => void
onBackgroundProcessRemoved?: (instanceId: string, event: BackgroundProcessRemovedEvent) => void
onInstanceDisposed?: (instanceId: string, event: ServerInstanceDisposedEvent) => void
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
getStatus(instanceId: string): ConnectionStatus | null {

View File

@@ -6,7 +6,7 @@ import { getPermissionCreatedAt, getPermissionSessionId } from "../types/permiss
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { getQuestionSessionId } from "../types/question"
import { requestData } from "../lib/opencode-api"
import { sdkManager } from "../lib/sdk-manager"
import { buildInstanceBaseUrl, sdkManager } from "../lib/sdk-manager"
import { sseManager } from "../lib/sse-manager"
import { serverApi } from "../lib/api-client"
import { serverEvents } from "../lib/server-events"
@@ -18,7 +18,14 @@ import {
fetchProviders,
clearInstanceDraftPrompts,
} from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import {
ensureWorktreesLoaded,
ensureWorktreeMapLoaded,
getOrCreateWorktreeClient,
getWorktreeSlugForSession,
reloadWorktreeMap,
reloadWorktrees,
} from "./worktrees"
import { fetchCommands, clearCommands } from "./commands"
import { serverSettings } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
@@ -76,6 +83,9 @@ const [disconnectedInstance, setDisconnectedInstance] = createSignal<Disconnecte
const MAX_LOG_ENTRIES = 1000
const pendingDisposeRequests = new Map<string, Promise<boolean>>()
const pendingRehydrations = new Map<string, Promise<void>>()
function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instance {
const existing = instances().get(descriptor.id)
return {
@@ -228,10 +238,15 @@ async function syncPendingQuestions(instanceId: string): Promise<void> {
}
}
async function hydrateInstanceData(instanceId: string) {
async function hydrateInstanceData(instanceId: string, options?: { force?: boolean }) {
try {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
if (options?.force) {
await reloadWorktrees(instanceId)
await reloadWorktreeMap(instanceId)
} else {
await ensureWorktreesLoaded(instanceId)
await ensureWorktreeMapLoaded(instanceId)
}
await fetchSessions(instanceId)
await fetchAgents(instanceId)
await fetchProviders(instanceId)
@@ -246,6 +261,91 @@ async function hydrateInstanceData(instanceId: string) {
}
}
async function postInstanceDispose(instanceId: string): Promise<boolean> {
const instance = instances().get(instanceId)
if (!instance?.proxyPath) {
throw new Error("Instance not ready")
}
const baseUrl = buildInstanceBaseUrl(instance.proxyPath)
const url = new URL("instance/dispose", baseUrl)
const response = await fetch(url.toString(), {
method: "POST",
credentials: "include",
headers: {
Accept: "application/json",
},
})
if (!response.ok) {
const message = await response.text().catch(() => "")
throw new Error(message || `Dispose request failed with ${response.status}`)
}
const contentType = response.headers.get("content-type") ?? ""
if (contentType.includes("application/json")) {
const data = await response.json().catch(() => undefined)
if (typeof data === "boolean") return data
if (data && typeof data === "object" && "data" in (data as any)) {
return Boolean((data as any).data)
}
return Boolean(data)
}
const text = await response.text().catch(() => "")
if (text.trim() === "true") return true
if (text.trim() === "false") return false
return Boolean(text)
}
async function rehydrateInstance(instanceId: string, options?: { reason?: string }): Promise<void> {
if (pendingRehydrations.has(instanceId)) {
return pendingRehydrations.get(instanceId)
}
const promise = (async () => {
const instance = instances().get(instanceId)
if (!instance?.client) {
return
}
log.info("Rehydrating instance", { instanceId, reason: options?.reason })
clearCacheForInstance(instanceId)
clearCommands(instanceId)
clearInstanceMetadata(instanceId)
clearInstanceDraftPrompts(instanceId)
clearPermissionQueue(instanceId)
clearQuestionQueue(instanceId)
await hydrateInstanceData(instanceId, { force: true })
})().finally(() => {
pendingRehydrations.delete(instanceId)
})
pendingRehydrations.set(instanceId, promise)
return promise
}
async function disposeInstance(instanceId: string): Promise<boolean> {
if (pendingDisposeRequests.has(instanceId)) {
return pendingDisposeRequests.get(instanceId)!
}
const promise = (async () => {
const ok = await postInstanceDispose(instanceId)
if (ok) {
await rehydrateInstance(instanceId, { reason: "disposed" })
}
return ok
})().finally(() => {
pendingDisposeRequests.delete(instanceId)
})
pendingDisposeRequests.set(instanceId, promise)
return promise
}
void (async function initializeWorkspaces() {
try {
const workspaces = await serverApi.fetchWorkspaces()
@@ -939,6 +1039,30 @@ sseManager.onLspUpdated = async (instanceId) => {
}
}
sseManager.onInstanceDisposed = (sourceInstanceId, event) => {
const directory = event?.properties?.directory
if (!directory) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
const matchingInstanceIds: string[] = []
for (const instance of instances().values()) {
if (instance.folder === directory) {
matchingInstanceIds.push(instance.id)
}
}
if (matchingInstanceIds.length === 0) {
void rehydrateInstance(sourceInstanceId, { reason: "disposed" })
return
}
for (const instanceId of matchingInstanceIds) {
void rehydrateInstance(instanceId, { reason: "disposed" })
}
}
async function acknowledgeDisconnectedInstance(): Promise<void> {
const pending = disconnectedInstance()
if (!pending) {
@@ -995,4 +1119,5 @@ export {
disconnectedInstance,
acknowledgeDisconnectedInstance,
fetchLspStatus,
disposeInstance,
}

View File

@@ -63,9 +63,14 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
resolveSelectedModel(instanceProviders, latestProviderId, latestModelId)
let modelOutputLimit = DEFAULT_MODEL_OUTPUT_LIMIT
let modelInputLimit: number | null = null
if (selectedModel) {
contextWindow = selectedModel.limit?.context ?? 0
const inputLimit = selectedModel.limit?.input
if (typeof inputLimit === "number" && inputLimit > 0) {
modelInputLimit = inputLimit
}
const outputLimit = selectedModel.limit?.output
if (typeof outputLimit === "number" && outputLimit > 0) {
modelOutputLimit = Math.min(outputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
@@ -107,7 +112,13 @@ export function updateSessionInfo(instanceId: string, sessionId: string): void {
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
if (!contextAvailableFromPrevious) {
if (modelInputLimit !== null) {
// Prefer explicit input limits when provided by the API.
// This is used by the UI "Avail" chip.
contextAvailableTokens = modelInputLimit
}
if (!contextAvailableFromPrevious && contextAvailableTokens === null) {
if (contextWindow > 0) {
if (latestHasContextUsage && actualUsageTokens > 0) {
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)

View File

@@ -304,10 +304,10 @@ function setThemePreference(preference: ThemePreference): void {
void patchConfigOwner("ui", { theme: preference }).catch((error) => log.error("Failed to set theme", error))
}
function setListeningMode(mode: ListeningMode): void {
if (serverSettings().listeningMode === mode) return
void patchConfigOwner("server", { listeningMode: mode }).catch((error) => log.error("Failed to set listening mode", error))
}
async function setListeningMode(mode: ListeningMode): Promise<void> {
if (serverSettings().listeningMode === mode) return
await patchConfigOwner("server", { listeningMode: mode })
}
function updateEnvironmentVariables(envVars: Record<string, string>): void {
void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) =>

View File

@@ -291,12 +291,13 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
const initialProvider = instanceProviders.find((p) => p.id === session.model.providerId)
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
const initialContextWindow = initialModel?.limit?.context ?? 0
const initialInputLimit = initialModel?.limit?.input ?? 0
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
const initialOutputLimit =
initialModel?.limit?.output && initialModel.limit.output > 0
? initialModel.limit.output
: DEFAULT_MODEL_OUTPUT_LIMIT
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
const initialContextAvailable = initialInputLimit > 0 ? initialInputLimit : initialContextWindow > 0 ? initialContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)
@@ -398,10 +399,11 @@ async function forkSession(
const forkProvider = instanceProviders.find((p) => p.id === forkedSession.model.providerId)
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
const forkContextWindow = forkModel?.limit?.context ?? 0
const forkInputLimit = forkModel?.limit?.input ?? 0
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
const forkOutputLimit =
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
const forkContextAvailable = forkInputLimit > 0 ? forkInputLimit : forkContextWindow > 0 ? forkContextWindow : null
setSessionInfoByInstance((prev) => {
const next = new Map(prev)

View File

@@ -329,12 +329,38 @@ function buildWorktreeProxyPath(instanceId: string, slug: string): string {
return `/workspaces/${encodeURIComponent(instanceId)}/worktrees/${encodeURIComponent(normalizedSlug)}/instance`
}
function encodeBase64UrlUtf8(input: string): string {
const bytes = new TextEncoder().encode(input)
// Convert bytes -> base64 (btoa expects a binary string)
let binary = ""
const chunkSize = 0x8000
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
const base64 = btoa(binary)
// base64 -> base64url (strip padding)
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
}
function buildWorktreeProxyPathWithDirectoryOverride(instanceId: string, slug: string, directory: string): string {
const base = buildWorktreeProxyPath(instanceId, slug)
const encoded = encodeBase64UrlUtf8(directory)
return `${base}/__dir/${encoded}`
}
function getOrCreateWorktreeClient(instanceId: string, slug: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPath(instanceId, normalized)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getOrCreateWorktreeClientWithDirectoryOverride(instanceId: string, slug: string, directory: string): OpencodeClient {
const normalized = normalizeWorktreeSlug(instanceId, slug || "root")
const proxyPath = buildWorktreeProxyPathWithDirectoryOverride(instanceId, normalized, directory)
return sdkManager.createClient(instanceId, proxyPath, normalized)
}
function getRootClient(instanceId: string): OpencodeClient {
return getOrCreateWorktreeClient(instanceId, "root")
}
@@ -359,7 +385,9 @@ export {
removeParentSessionMapping,
getWorktreeSlugForDirectory,
buildWorktreeProxyPath,
buildWorktreeProxyPathWithDirectoryOverride,
getOrCreateWorktreeClient,
getOrCreateWorktreeClientWithDirectoryOverride,
getRootClient,
createWorktree,
deleteWorktree,

View File

@@ -54,3 +54,28 @@ button.button-tertiary:hover:not(:disabled) {
button.button-tertiary:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
.button-danger,
button.button-danger {
@apply px-6 py-3 text-base rounded-lg;
background-color: var(--button-danger-bg);
color: var(--button-danger-text);
border-color: var(--button-danger-bg);
}
.button-danger:hover:not(:disabled),
button.button-danger:hover:not(:disabled) {
background-color: var(--button-danger-hover-bg);
border-color: var(--button-danger-hover-bg);
}
.button-danger:focus-visible,
button.button-danger:focus-visible {
box-shadow: 0 0 0 2px var(--focus-ring-offset), 0 0 0 4px var(--focus-ring-color);
}
/* Smaller sizing variant for destructive actions in tight spaces. */
.button-danger.button-small,
button.button-danger.button-small {
@apply px-4 py-2 text-sm;
}

View File

@@ -9,6 +9,9 @@
line-height: var(--line-height-normal);
font-weight: var(--font-weight-regular);
color: var(--text-primary);
/* Message containers may use `whitespace-pre-wrap` for plain text.
Markdown should always match assistant rendering (normal whitespace). */
white-space: normal;
}
.markdown-body p,
@@ -28,7 +31,7 @@
.markdown-body h5,
.markdown-body h6 {
font-family: inherit;
color: inherit;
color: var(--markdown-heading-color, inherit);
font-weight: var(--font-weight-semibold);
line-height: 1.3;
margin-top: 0.9em;
@@ -71,7 +74,7 @@
.markdown-body strong {
font-weight: var(--font-weight-regular);
color: var(--message-assistant-border);
color: var(--markdown-accent, var(--message-assistant-border));
}
.markdown-body em {

View File

@@ -1,6 +1,10 @@
/* Message item base styles */
.message-item-base {
@apply flex flex-col gap-2 p-3 w-full;
/* Markdown rendering uses these to theme emphasis + headings per message role. */
--markdown-accent: var(--message-user-border);
--markdown-heading-color: var(--message-user-border);
}
.message-item-header {
@@ -71,6 +75,9 @@
padding: 0.6rem 0.65rem;
margin-top: 0;
margin-bottom: 0;
--markdown-accent: var(--message-assistant-border);
--markdown-heading-color: var(--text-primary);
}
.message-item-base:not(.assistant-message) {

View File

@@ -295,7 +295,33 @@
}
.prompt-input {
padding-bottom: 1.5rem;
/* Prevent iOS Safari input zoom + keep input compact. */
font-size: 16px;
padding-bottom: 0.75rem;
}
}
@media (max-width: 1279px) {
:root {
--prompt-input-compact-height: 104px;
}
.prompt-input-wrapper {
min-height: var(--prompt-input-compact-height);
}
.prompt-input-field-container {
min-height: var(--prompt-input-compact-height);
height: var(--prompt-input-compact-height);
}
.prompt-input-field {
height: var(--prompt-input-compact-height);
}
.prompt-input-field-container.is-expanded,
.prompt-input-field.is-expanded {
height: auto;
}
}
@@ -307,9 +333,9 @@
@media (max-width: 640px) {
.prompt-input {
min-height: 64px;
min-height: 0;
padding: 0.5rem 0.75rem;
padding-bottom: 2.25rem;
padding-bottom: 0.75rem;
}
.prompt-input-wrapper {

View File

@@ -282,7 +282,7 @@
}
.file-viewer-toolbar {
@apply ml-auto flex items-center gap-1;
@apply flex items-center gap-1;
}
.file-viewer-toolbar-button {
@@ -291,6 +291,22 @@
color: var(--text-secondary);
}
.file-viewer-toolbar-icon-button {
@apply inline-flex items-center justify-center shrink-0 w-7 h-7 border border-base transition-colors;
background-color: var(--surface-base);
color: var(--text-secondary);
}
.file-viewer-toolbar-icon-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);
}
.file-viewer-toolbar-icon-button.active {
color: var(--text-primary);
box-shadow: inset 0 0 0 1px var(--accent-primary);
}
.file-viewer-toolbar-button:hover {
background-color: var(--surface-hover);
color: var(--text-primary);

View File

@@ -125,6 +125,18 @@ session-sidebar-controls .selector-trigger-primary {
width: 100%;
}
.mobile-fullscreen-exit-wrapper {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + 12px);
right: calc(env(safe-area-inset-right, 0px) + 12px);
z-index: 1250;
pointer-events: none;
}
.mobile-fullscreen-exit-button {
pointer-events: auto;
}
.session-resize-handle {
@apply absolute top-0 w-1 h-full cursor-col-resize bg-transparent transition-colors;
z-index: 10;

View File

@@ -64,6 +64,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary) {
@apply inline-flex items-center justify-center gap-2 font-medium transition-colors rounded-md;
@@ -74,6 +76,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):focus-visible {
outline: none;
@@ -84,6 +88,8 @@
button.button-primary,
.button-secondary,
button.button-secondary,
.button-danger,
button.button-danger,
.button-tertiary,
button.button-tertiary):disabled {
@apply cursor-not-allowed opacity-50;

View File

@@ -90,6 +90,7 @@ export interface Model {
variantKeys?: string[]
limit?: {
context?: number
input?: number
output?: number
}
cost?: {

Binary file not shown.

51
temp/package/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.6",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "./script/build.ts"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./client": {
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
},
"./server": {
"import": "./dist/server.js",
"types": "./dist/server.d.ts"
},
"./v2": {
"import": "./dist/v2/index.js",
"types": "./dist/v2/index.d.ts"
},
"./v2/client": {
"import": "./dist/v2/client.js",
"types": "./dist/v2/client.d.ts"
},
"./v2/server": {
"import": "./dist/v2/server.js",
"types": "./dist/v2/server.d.ts"
}
},
"files": [
"dist"
],
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "22.0.2",
"@types/node": "22.13.9",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1"
},
"dependencies": {},
"publishConfig": {
"directory": "dist"
}
}