Compare commits

...

38 Commits

Author SHA1 Message Date
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
Shantur Rathore
e16c5752ed Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-16 09:01:25 +00:00
Shantur Rathore
375f92410e Merge pull request #169 from NeuralNomadsAI/codenomad/issue-136
feat(ui): unify picker Tab/Enter/Shift+Enter and allow directory attachments
2026-02-16 09:00:22 +00:00
Shantur Rathore
53f1dd4150 Merge pull request #171 from VooDisss/codenomad/issue-136
fix(ui): improve picker deletion, ESC cancel, and SHIFT+ENTER path handling
2026-02-16 08:59:17 +00:00
VooDisss
b7f638f07d fix(i18n): add workspace root translation key 2026-02-16 05:21:22 +02:00
VooDisss
32113ea100 fix(ui): resolve root path @. and @./ correctly 2026-02-16 05:03:27 +02:00
VooDisss
b31135f622 fix(ui): fix ./ path prefix for SHIFT+ENTER 2026-02-16 04:29:24 +02:00
Shantur Rathore
eb6701185b Min version 0.11.1 2026-02-15 23:36:32 +00:00
Shantur Rathore
d948ad8e35 Bump version to 0.11.1 2026-02-15 23:34:26 +00:00
VooDisss
f58267dd30 fix(ui): always strip @ for SHIFT+ENTER paths regardless of file attachment 2026-02-16 01:23:24 +02:00
VooDisss
95c747923c fix(ui): improve picker actions, directory navigation, @ handling, and message display 2026-02-16 01:11:53 +02:00
Shantur Rathore
f3b9ee4e04 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-15 22:52:48 +00:00
Shantur Rathore
309a123c1f Merge pull request #176 from NeuralNomadsAI/codenomad/issue-175
fix(ui): prevent close button overlapping theme toggle
2026-02-15 22:45:49 +00:00
Shantur Rathore
761e3d4268 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev
# Conflicts:
#	packages/ui/src/stores/preferences.tsx
2026-02-15 22:43:18 +00:00
Shantur Rathore
265d497ef4 chore(opencode-config): bump @opencode-ai/plugin to 1.2.4 2026-02-15 22:26:17 +00:00
Shantur Rathore
56a052086f fix(ui): ignore unsupported patch parts 2026-02-15 22:26:17 +00:00
Shantur Rathore
9a4d205d97 refactor(ui): rename message time.completed to time.end
Update all references from info.time.completed to info.time.end to align
with SDK schema changes. Affects message status tracking and rendering.
2026-02-15 20:38:57 +00:00
Shantur Rathore
ff71302969 fix(ui): prevent close button overlapping theme toggle 2026-02-15 15:43:54 +00:00
Shantur Rathore
4f6c8523c0 Merge pull request #174 from NeuralNomadsAI/codenomad/issue-173
Docs: link server CLI docs and list flags/env vars
2026-02-15 15:30:33 +00:00
Shantur Rathore
8c24a7daf3 docs: reorganize server and dev release docs 2026-02-15 15:29:06 +00:00
Shantur Rathore
682937e945 docs(server): improve CLI flag/env var docs
Make server usage easier to discover from the root README, add local install/run instructions, and document additional CLI flags/env vars for UI and logging.
2026-02-15 15:21:09 +00:00
Shantur Rathore
35ff359c0f Merge pull request #170 from NeuralNomadsAI/codenomad/issue-153
Fix: hide keyboard shortcut hints in WebUI + add toggle
2026-02-15 09:24:30 +00:00
Shantur Rathore
5067db3dd0 fix(ui): handle message.part.delta streaming
Wire message.part.delta SSE events into the v2 message store and append deltas onto existing part fields.
2026-02-15 00:54:31 +00:00
Shantur Rathore
c7195469bd fix(ui): add keyboard shortcut hints toggle
Hide shortcut hints in WebUI and allow toggling in native desktop apps.
2026-02-14 00:02:56 +00:00
Shantur Rathore
1ef01da019 feat(ui): improve picker actions and directory attach 2026-02-13 22:52:42 +00:00
Shantur Rathore
edd3ded1d8 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-13 14:47:43 +00:00
Shantur Rathore
e30ff6358d feat(settings): move config/state to owner buckets
Add generic /api/storage config/state endpoints with merge-patch, migrate legacy YAML/JSON layout, and update UI/server to read and write owner-scoped settings. Replace config SSE events and drop /api/config routes.
2026-02-13 14:34:33 +00:00
Shantur Rathore
e9f281a69d Merge pull request #168 from NeuralNomadsAI/codenomad/issue-166
fix(ui): hide keyboard hints on phone layout
2026-02-13 10:15:53 +00:00
98 changed files with 2576 additions and 1388 deletions

View File

@@ -44,19 +44,22 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
npx @neuralnomads/codenomad --launch
```
For dev version
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
- [packages/server/README.md](packages/server/README.md)
To see all available options:
```bash
npx @neuralnomads/codenomad --help
```
### 🧪 Dev Releases
Bleeding-edge builds are published as GitHub pre-releases and are generated automatically from the `dev` branch.
```bash
npx @neuralnomads/codenomad-dev --launch
```
Dev builds are published as GitHub pre-releases:
https://github.com/shantur/CodeNomad/releases
Dev releases are bleeding-edge builds, generated automatically every time a new commit is pushed to the `dev` branch.
This command starts the server and opens the web client in your default browser.
## Highlights
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
@@ -120,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.10.3",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.10.3",
"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.10.3",
"version": "0.11.2",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"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.10.3",
"version": "0.11.2",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12070,12 +12070,12 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.10.3",
"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.10.3",
"version": "0.11.2",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",

View File

@@ -1,4 +1,4 @@
{
"minServerVersion": "0.10.3",
"minServerVersion": "0.11.1",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
}

View File

@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
return "local"
}
const mode = parsed?.preferences?.listeningMode
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
if (mode === "local" || mode === "all") {
return mode
}

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.10.3",
"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.1.53"
"@opencode-ai/plugin": "1.2.4"
}
}
}

View File

@@ -31,6 +31,12 @@ You can run CodeNomad directly without installing it:
npx @neuralnomads/codenomad --launch
```
To list all CLI options:
```sh
npx @neuralnomads/codenomad --help
```
On startup, CodeNomad prints two URLs:
- `Local Connection URL : ...` (used by desktop shells)
@@ -44,6 +50,16 @@ npm install -g @neuralnomads/codenomad
codenomad --launch
```
### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary:
```sh
npm install @neuralnomads/codenomad
npx codenomad --launch
```
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags
You can configure the server using flags or environment variables:
@@ -63,10 +79,30 @@ You can configure the server using flags or environment variables:
| `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
| `--log-level <level>` | `CLI_LOG_LEVEL` | Logging level (trace, debug, info, warn, error) |
| `--log-destination <path>` | `CLI_LOG_DESTINATION` | Log destination file (defaults to stdout) |
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
| `--generate-token` | `CODENOMAD_GENERATE_TOKEN` | Emit a one-time local bootstrap token for desktop flows |
| `--dangerously-skip-auth` | `CODENOMAD_SKIP_AUTH` | Disable CodeNomad's internal auth (use only behind a trusted perimeter) |
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh
npx @neuralnomads/codenomad-dev --launch
```
These environment variables control how CodeNomad checks for dev updates:
| Env Variable | Description |
|-------------|-------------|
| `CODENOMAD_UPDATE_CHANNEL` | Update channel (use `dev` to enable dev build update checks) |
| `CODENOMAD_GITHUB_REPO` | GitHub repo used for dev release checks (default `NeuralNomadsAI/CodeNomad`) |
### HTTP vs HTTPS

View File

@@ -1,12 +1,12 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@neuralnomads/codenomad",
"version": "0.10.3",
"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.10.3",
"version": "0.11.2",
"description": "CodeNomad Server",
"license": "MIT",
"author": {

View File

@@ -1,7 +1,6 @@
import type {
AgentModelSelection,
AgentModelSelections,
ConfigFile,
ModelPreference,
OpenCodeBinary,
Preferences,
@@ -183,9 +182,9 @@ export interface BinaryRecord {
validationError?: string
}
export type AppConfig = ConfigFile
export type AppConfigResponse = AppConfig
export type AppConfigUpdateRequest = Partial<AppConfig>
export type SettingsOwner = string
export type SettingsBucket = Record<string, unknown>
export type SettingsDoc = Record<string, unknown>
export interface BinaryListResponse {
binaries: BinaryRecord[]
@@ -214,8 +213,8 @@ export type WorkspaceEventType =
| "workspace.error"
| "workspace.stopped"
| "workspace.log"
| "config.appChanged"
| "config.binariesChanged"
| "storage.configChanged"
| "storage.stateChanged"
| "instance.dataChanged"
| "instance.event"
| "instance.eventStatus"
@@ -226,8 +225,8 @@ export type WorkspaceEventPayload =
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
| { type: "workspace.stopped"; workspaceId: string }
| { type: "workspace.log"; entry: WorkspaceLogEntry }
| { type: "config.appChanged"; config: AppConfig }
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }

View File

@@ -1,192 +0,0 @@
import {
BinaryCreateRequest,
BinaryRecord,
BinaryUpdateRequest,
BinaryValidationResult,
} from "../api-types"
import { spawnSync } from "child_process"
import { ConfigStore } from "./store"
import { EventBus } from "../events/bus"
import type { ConfigFile } from "./schema"
import { Logger } from "../logger"
import { buildSpawnSpec } from "../workspaces/runtime"
export class BinaryRegistry {
constructor(
private readonly configStore: ConfigStore,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
list(): BinaryRecord[] {
return this.mapRecords()
}
resolveDefault(): BinaryRecord {
const binaries = this.mapRecords()
if (binaries.length === 0) {
this.logger.warn("No configured binaries found, falling back to opencode")
return this.buildFallbackRecord("opencode")
}
return binaries.find((binary) => binary.isDefault) ?? binaries[0]
}
create(request: BinaryCreateRequest): BinaryRecord {
this.logger.debug({ path: request.path }, "Registering OpenCode binary")
const entry = {
path: request.path,
version: undefined,
lastUsed: Date.now(),
label: request.label,
}
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path)
nextConfig.opencodeBinaries = [entry, ...deduped]
if (request.makeDefault) {
nextConfig.preferences.lastUsedBinary = request.path
}
this.configStore.replace(nextConfig)
const record = this.getById(request.path)
this.emitChange()
return record
}
update(id: string, updates: BinaryUpdateRequest): BinaryRecord {
this.logger.debug({ id }, "Updating OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) =>
binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary,
)
if (updates.makeDefault) {
nextConfig.preferences.lastUsedBinary = id
}
this.configStore.replace(nextConfig)
const record = this.getById(id)
this.emitChange()
return record
}
remove(id: string) {
this.logger.debug({ id }, "Removing OpenCode binary")
const config = this.configStore.get()
const nextConfig = this.cloneConfig(config)
const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id)
nextConfig.opencodeBinaries = remaining
if (nextConfig.preferences.lastUsedBinary === id) {
nextConfig.preferences.lastUsedBinary = remaining[0]?.path
}
this.configStore.replace(nextConfig)
this.emitChange()
}
validatePath(path: string): BinaryValidationResult {
this.logger.debug({ path }, "Validating OpenCode binary path")
return this.validateRecord({
id: path,
path,
label: this.prettyLabel(path),
isDefault: false,
})
}
private cloneConfig(config: ConfigFile): ConfigFile {
return JSON.parse(JSON.stringify(config)) as ConfigFile
}
private mapRecords(): BinaryRecord[] {
const config = this.configStore.get()
const configuredBinaries = config.opencodeBinaries.map<BinaryRecord>((binary) => ({
id: binary.path,
path: binary.path,
label: binary.label ?? this.prettyLabel(binary.path),
version: binary.version,
isDefault: false,
}))
const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode"
const annotated = configuredBinaries.map((binary) => ({
...binary,
isDefault: binary.path === defaultPath,
}))
if (!annotated.some((binary) => binary.path === defaultPath)) {
annotated.unshift(this.buildFallbackRecord(defaultPath))
}
return annotated
}
private getById(id: string): BinaryRecord {
return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id)
}
private emitChange() {
this.logger.debug("Emitting binaries changed event")
this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() })
}
private validateRecord(record: BinaryRecord): BinaryValidationResult {
const inputPath = record.path
if (!inputPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(inputPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
private buildFallbackRecord(path: string): BinaryRecord {
return {
id: path,
path,
label: this.prettyLabel(path),
isDefault: true,
}
}
private prettyLabel(path: string) {
const parts = path.split(/[\\/]/)
const last = parts[parts.length - 1] || path
return last || path
}
}

View File

@@ -1,244 +0,0 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import { EventBus } from "../events/bus"
import { Logger } from "../logger"
import {
ConfigFile,
ConfigFileSchema,
ConfigYamlSchema,
DEFAULT_CONFIG,
DEFAULT_CONFIG_YAML,
DEFAULT_STATE,
StateFile,
StateFileSchema,
} from "./schema"
import type { ConfigLocation } from "./location"
export class ConfigStore {
private cache: ConfigFile = DEFAULT_CONFIG
private state: StateFile = DEFAULT_STATE
private loaded = false
constructor(
private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {}
load(): ConfigFile {
if (this.loaded) {
return this.cache
}
try {
const configYamlPath = this.location.configYamlPath
const stateYamlPath = this.location.stateYamlPath
const legacyJsonPath = this.location.legacyJsonPath
if (fs.existsSync(configYamlPath)) {
const configDoc = this.readYamlFile(configYamlPath, DEFAULT_CONFIG_YAML, ConfigYamlSchema, "config")
const stateDoc = fs.existsSync(stateYamlPath)
? this.readYamlFile(stateYamlPath, DEFAULT_STATE, StateFileSchema, "state")
: DEFAULT_STATE
this.state = stateDoc
this.cache = this.mergeDocs(configDoc, stateDoc)
this.logger.debug({ configYamlPath, stateYamlPath }, "Loaded existing YAML config/state")
} else if (fs.existsSync(legacyJsonPath)) {
const migrated = this.migrateFromLegacyJson(legacyJsonPath)
this.state = migrated.state
this.cache = migrated.config
} else {
// Fresh install: write defaults.
this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
this.persist()
this.logger.debug(
{ configYamlPath, stateYamlPath },
"No config files found, created default YAML config/state",
)
}
} catch (error) {
this.logger.warn({ err: error }, "Failed to load config/state, using defaults")
this.state = DEFAULT_STATE
this.cache = this.mergeDocs(DEFAULT_CONFIG_YAML, DEFAULT_STATE)
}
this.loaded = true
return this.cache
}
get(): ConfigFile {
return this.load()
}
replace(config: ConfigFile) {
const validated = ConfigFileSchema.parse(config)
this.commit(validated)
}
/**
* Apply a merge-patch update to the current config.
* - Missing keys are preserved.
* - Object values are merged recursively.
* - Explicit `null` deletes keys.
* - Arrays are replaced.
*/
mergePatch(patch: unknown) {
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
throw new Error("Config patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current as any, patch as any)
const validated = ConfigFileSchema.parse(next)
this.commit(validated)
}
private commit(next: ConfigFile) {
this.cache = next
this.loaded = true
this.state = {
...this.state,
recentFolders: next.recentFolders,
}
this.persist()
const published = Boolean(this.eventBus)
this.eventBus?.publish({ type: "config.appChanged", config: this.cache })
this.logger.debug({ broadcast: published }, "Config SSE event emitted")
this.logger.trace({ config: this.cache }, "Config payload")
}
private persist() {
try {
const configYamlPath = this.location.configYamlPath
const stateYamlPath = this.location.stateYamlPath
fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.mkdirSync(path.dirname(configYamlPath), { recursive: true })
const configYaml = stringifyYaml(stripRecentFolders(this.cache) as any)
const stateYaml = stringifyYaml(this.state as any)
fs.writeFileSync(configYamlPath, ensureTrailingNewline(configYaml), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stateYaml), "utf-8")
this.logger.debug({ configYamlPath, stateYamlPath }, "Persisted YAML config/state")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist config")
}
}
private mergeDocs(configDoc: unknown, stateDoc: StateFile): ConfigFile {
const merged = {
...(configDoc as any),
// State wins for recent folders.
recentFolders: stateDoc.recentFolders ?? [],
}
return ConfigFileSchema.parse(merged)
}
private readYamlFile<T>(
filePath: string,
fallback: T,
schema: { parse: (value: unknown) => T },
label: string,
): T {
try {
const content = fs.readFileSync(filePath, "utf-8")
const parsed = parseYaml(content)
return schema.parse(parsed ?? {})
} catch (error) {
this.logger.warn({ err: error, filePath, label }, "Failed to read YAML file, using defaults")
return fallback
}
}
private migrateFromLegacyJson(legacyJsonPath: string): { config: ConfigFile; state: StateFile } {
const configYamlPath = this.location.configYamlPath
const stateYamlPath = this.location.stateYamlPath
const content = fs.readFileSync(legacyJsonPath, "utf-8")
const parsed = JSON.parse(content)
const legacy = ConfigFileSchema.parse(parsed)
const state: StateFile = StateFileSchema.parse({
...DEFAULT_STATE,
recentFolders: legacy.recentFolders ?? [],
})
const merged = this.mergeDocs(stripRecentFolders(legacy), state)
// Persist YAML docs first, then move legacy aside.
try {
fs.mkdirSync(this.location.baseDir, { recursive: true })
fs.writeFileSync(configYamlPath, ensureTrailingNewline(stringifyYaml(stripRecentFolders(merged) as any)), "utf-8")
fs.writeFileSync(stateYamlPath, ensureTrailingNewline(stringifyYaml(state as any)), "utf-8")
this.logger.info({ legacyJsonPath, configYamlPath, stateYamlPath }, "Migrated config.json -> YAML")
} catch (error) {
this.logger.warn({ err: error }, "Failed to persist migrated YAML config/state")
}
try {
const bakPath = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bakPath)
this.logger.info({ legacyJsonPath, bakPath }, "Moved legacy config.json to backup")
} catch (error) {
this.logger.warn({ err: error, legacyJsonPath }, "Failed to rename legacy config.json to backup")
}
return { config: merged, state }
}
}
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function stripRecentFolders(config: ConfigFile): Omit<ConfigFile, "recentFolders"> & Record<string, unknown> {
const clone: Record<string, unknown> = { ...(config as any) }
delete clone.recentFolders
return clone as any
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
function applyMergePatch(current: any, patch: any): any {
// RFC 7396-ish merge patch with explicit null deletes.
if (!isPlainObject(patch)) {
return patch
}
const base = isPlainObject(current) ? { ...current } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
if (isPlainObject(value) && isPlainObject(base[key])) {
base[key] = applyMergePatch(base[key], value)
continue
}
// Arrays and scalars replace.
base[key] = value
}
return base
}
function pickBackupPath(legacyJsonPath: string): string {
const base = legacyJsonPath.endsWith(".json") ? legacyJsonPath.slice(0, -".json".length) : legacyJsonPath
const preferred = `${base}.json.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${base}.json.bak.${Date.now()}`
}

View File

@@ -24,8 +24,8 @@ export class EventBus extends EventEmitter {
this.on("workspace.error", handler)
this.on("workspace.stopped", handler)
this.on("workspace.log", handler)
this.on("config.appChanged", handler)
this.on("config.binariesChanged", handler)
this.on("storage.configChanged", handler)
this.on("storage.stateChanged", handler)
this.on("instance.dataChanged", handler)
this.on("instance.event", handler)
this.on("instance.eventStatus", handler)
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
this.off("workspace.error", handler)
this.off("workspace.stopped", handler)
this.off("workspace.log", handler)
this.off("config.appChanged", handler)
this.off("config.binariesChanged", handler)
this.off("storage.configChanged", handler)
this.off("storage.stateChanged", handler)
this.off("instance.dataChanged", handler)
this.off("instance.event", handler)
this.off("instance.eventStatus", handler)

View File

@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
import { createRequire } from "module"
import { createHttpServer } from "./server/http-server"
import { WorkspaceManager } from "./workspaces/manager"
import { ConfigStore } from "./config/store"
import { resolveConfigLocation } from "./config/location"
import { BinaryRegistry } from "./config/binaries"
import { SettingsService } from "./settings/service"
import { BinaryResolver } from "./settings/binaries"
import { FileSystemBrowser } from "./filesystem/browser"
import { EventBus } from "./events/bus"
import { ServerMeta } from "./api-types"
@@ -291,21 +291,12 @@ async function main() {
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
// Eagerly load config at boot so migrations run immediately
// (instead of waiting for the first /api/config request).
try {
configStore.get()
} catch (error) {
configLogger.warn({ err: error }, "Failed to load config at boot; continuing with defaults")
}
const binaryRegistry = new BinaryRegistry(configStore, eventBus, configLogger)
const settings = new SettingsService(configLocation, eventBus, configLogger)
const binaryResolver = new BinaryResolver(settings)
const workspaceManager = new WorkspaceManager({
rootDir: options.rootDir,
configStore,
binaryRegistry,
settings,
binaryResolver,
eventBus,
logger: workspaceLogger,
getServerBaseUrl: () => serverMeta.localUrl,
@@ -392,8 +383,7 @@ async function main() {
defaultPort: options.httpPort,
protocol: "http",
workspaceManager,
configStore,
binaryRegistry,
settings,
fileSystemBrowser,
eventBus,
serverMeta,
@@ -413,8 +403,7 @@ async function main() {
protocol: "https",
httpsOptions: tlsResolution?.httpsOptions,
workspaceManager,
configStore,
binaryRegistry,
settings,
fileSystemBrowser,
eventBus,
serverMeta,

View File

@@ -9,12 +9,11 @@ import type { Logger } from "../logger"
import { WorkspaceManager } from "../workspaces/manager"
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import type { SettingsService } from "../settings/service"
import { FileSystemBrowser } from "../filesystem/browser"
import { EventBus } from "../events/bus"
import { registerWorkspaceRoutes } from "./routes/workspaces"
import { registerConfigRoutes } from "./routes/config"
import { registerSettingsRoutes } from "./routes/settings"
import { registerFilesystemRoutes } from "./routes/filesystem"
import { registerMetaRoutes } from "./routes/meta"
import { registerEventRoutes } from "./routes/events"
@@ -37,8 +36,7 @@ interface HttpServerDeps {
protocol: "http" | "https"
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
workspaceManager: WorkspaceManager
configStore: ConfigStore
binaryRegistry: BinaryRegistry
settings: SettingsService
fileSystemBrowser: FileSystemBrowser
eventBus: EventBus
serverMeta: ServerMeta
@@ -244,7 +242,7 @@ export function createHttpServer(deps: HttpServerDeps) {
})
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })

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,76 +0,0 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { ConfigStore } from "../../config/store"
import { BinaryRegistry } from "../../config/binaries"
interface RouteDeps {
configStore: ConfigStore
binaryRegistry: BinaryRegistry
}
const BinaryCreateSchema = z.object({
path: z.string(),
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryUpdateSchema = z.object({
label: z.string().optional(),
makeDefault: z.boolean().optional(),
})
const BinaryValidateSchema = z.object({
path: z.string(),
})
export function registerConfigRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/config/app", async () => deps.configStore.get())
app.put("/api/config/app", async (request, reply) => {
// Backwards compatible: treat PUT as a merge-patch update.
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.patch("/api/config/app", async (request, reply) => {
try {
deps.configStore.mergePatch(request.body ?? {})
return deps.configStore.get()
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid config patch" }
}
})
app.get("/api/config/binaries", async () => {
return { binaries: deps.binaryRegistry.list() }
})
app.post("/api/config/binaries", async (request, reply) => {
const body = BinaryCreateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.create(body)
reply.code(201)
return { binary }
})
app.patch<{ Params: { id: string } }>("/api/config/binaries/:id", async (request) => {
const body = BinaryUpdateSchema.parse(request.body ?? {})
const binary = deps.binaryRegistry.update(request.params.id, body)
return { binary }
})
app.delete<{ Params: { id: string } }>("/api/config/binaries/:id", async (request, reply) => {
deps.binaryRegistry.remove(request.params.id)
reply.code(204)
})
app.post("/api/config/binaries/validate", async (request) => {
const body = BinaryValidateSchema.parse(request.body ?? {})
return deps.binaryRegistry.validatePath(body.path)
})
}

View File

@@ -0,0 +1,110 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import { spawnSync } from "child_process"
import { buildSpawnSpec } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
interface RouteDeps {
settings: SettingsService
logger: Logger
}
const ValidateBinarySchema = z.object({
path: z.string(),
})
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get("/api/storage/state", async () => deps.settings.getDoc("state"))
app.patch("/api/storage/state", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("state", request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
app.get<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request) => {
return deps.settings.getOwner("state", request.params.owner)
})
app.patch<{ Params: { owner: string } }>("/api/storage/state/:owner", async (request, reply) => {
try {
return deps.settings.mergePatchOwner("state", request.params.owner, request.body ?? {})
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
}
})
// Binary validation helper (used by UI when adding binaries)
app.post("/api/storage/binaries/validate", async (request, reply) => {
try {
const body = ValidateBinarySchema.parse(request.body ?? {})
return validateBinaryPath(body.path)
} catch (error) {
deps.logger.warn({ err: error }, "Failed to validate binary")
reply.code(400)
return { valid: false, error: error instanceof Error ? error.message : "Invalid request" }
}
})
}

View File

@@ -0,0 +1,55 @@
import type { SettingsService } from "./service"
export interface OpenCodeBinaryEntry {
path: string
version?: string
lastUsed?: number
label?: string
}
export interface ResolvedBinary {
path: string
label: string
version?: string
}
function prettyLabel(p: string): string {
const parts = p.split(/[\\/]/)
const last = parts[parts.length - 1] || p
return last || p
}
function readUiBinaries(settings: SettingsService): OpenCodeBinaryEntry[] {
const ui = settings.getOwner("state", "ui")
const list = (ui as any)?.opencodeBinaries
if (!Array.isArray(list)) return []
return list.filter((item) => item && typeof item === "object" && typeof (item as any).path === "string") as any
}
function readDefaultBinaryPath(settings: SettingsService): string | undefined {
const server = settings.getOwner("config", "server")
const value = (server as any)?.opencodeBinary
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined
}
export class BinaryResolver {
constructor(private readonly settings: SettingsService) {}
list(): OpenCodeBinaryEntry[] {
return readUiBinaries(this.settings)
}
resolveDefault(): ResolvedBinary {
const binaries = this.list()
const configuredDefault = readDefaultBinaryPath(this.settings)
const fallback = binaries[0]?.path
const path = configuredDefault ?? fallback ?? "opencode"
const entry = binaries.find((b) => b.path === path)
return {
path,
label: entry?.label ?? prettyLabel(path),
version: entry?.version,
}
}
}

View File

@@ -0,0 +1,39 @@
type PlainObject = Record<string, unknown>
export function isPlainObject(value: unknown): value is PlainObject {
if (!value || typeof value !== "object") return false
if (Array.isArray(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
/**
* RFC 7396-ish merge patch with explicit null deletes.
* - Objects merge recursively
* - Arrays/scalars replace
* - null deletes keys
*/
export function applyMergePatch(current: unknown, patch: unknown): unknown {
if (!isPlainObject(patch)) {
return patch
}
const base: PlainObject = isPlainObject(current) ? { ...(current as PlainObject) } : {}
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key]
continue
}
const existing = base[key]
if (isPlainObject(value) && isPlainObject(existing)) {
base[key] = applyMergePatch(existing, value)
continue
}
base[key] = value
}
return base
}

View File

@@ -0,0 +1,269 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import type { ConfigLocation } from "../config/location"
import { isPlainObject } from "./merge-patch"
type Doc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function safeReadYaml(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return parseYaml(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read YAML file during migration")
return null
}
}
function safeReadJson(filePath: string, logger: Logger): unknown {
try {
const content = fs.readFileSync(filePath, "utf-8")
return JSON.parse(content)
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to read JSON file during migration")
return null
}
}
function writeYaml(filePath: string, doc: Doc, logger: Logger) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true })
const yaml = stringifyYaml(doc as any)
fs.writeFileSync(filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
logger.warn({ err: error, filePath }, "Failed to write YAML file during migration")
}
}
function pickBackupPath(filePath: string): string {
const preferred = `${filePath}.bak`
if (!fs.existsSync(preferred)) {
return preferred
}
return `${filePath}.bak.${Date.now()}`
}
function normalizeDoc(value: unknown): Doc {
return isPlainObject(value) ? (value as Doc) : {}
}
function looksLikeNewOwnerDoc(value: unknown): boolean {
const doc = normalizeDoc(value)
// Heuristic: owner-bucket docs have at least one of these roots.
return Boolean(doc.ui || doc.server || doc.app || doc.legacy)
}
function looksLikeLegacyConfig(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.preferences || doc.opencodeBinaries || doc.theme || doc.recentFolders)
}
function looksLikeLegacyState(value: unknown): boolean {
const doc = normalizeDoc(value)
return Boolean(doc.recentFolders)
}
function omitKeys(source: Doc, keys: Set<string>): Doc {
const out: Doc = {}
for (const [k, v] of Object.entries(source)) {
if (keys.has(k)) continue
out[k] = v
}
return out
}
function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { config: Doc; state: Doc } {
const cfg = normalizeDoc(legacyConfig)
const st = normalizeDoc(legacyState)
const outConfig: Doc = {}
const outState: Doc = {}
const uiConfig: Doc = {}
const uiSettings: Doc = {}
const serverConfig: Doc = {}
const uiState: Doc = {}
// theme -> config.ui.theme
if (typeof cfg.theme === "string") {
uiConfig.theme = cfg.theme
}
const preferences = normalizeDoc(cfg.preferences)
if (Object.keys(preferences).length > 0) {
// Server-owned stable keys
const envVars = preferences.environmentVariables
if (isPlainObject(envVars)) {
serverConfig.environmentVariables = envVars
}
const listeningMode = preferences.listeningMode
if (typeof listeningMode === "string") {
serverConfig.listeningMode = listeningMode
}
const lastUsedBinary = preferences.lastUsedBinary
if (typeof lastUsedBinary === "string") {
serverConfig.opencodeBinary = lastUsedBinary
}
// UI-owned state keys (drop preferences)
const modelRecents = preferences.modelRecents
const modelFavorites = preferences.modelFavorites
const modelThinkingSelections = preferences.modelThinkingSelections
const models: Doc = {}
if (Array.isArray(modelRecents)) {
models.recents = modelRecents
}
if (Array.isArray(modelFavorites)) {
models.favorites = modelFavorites
}
if (isPlainObject(modelThinkingSelections)) {
models.thinkingSelections = modelThinkingSelections
}
if (Object.keys(models).length > 0) {
uiState.models = models
}
// Remaining preferences are treated as stable UI settings.
const moved = new Set([
"environmentVariables",
"listeningMode",
"lastUsedBinary",
"modelRecents",
"modelFavorites",
"modelThinkingSelections",
])
Object.assign(uiSettings, omitKeys(preferences, moved))
}
// recentFolders lives in legacy state (yaml) or legacy config.json
const recentFolders = (st.recentFolders ?? cfg.recentFolders) as unknown
if (Array.isArray(recentFolders)) {
uiState.recentFolders = recentFolders
}
// opencodeBinaries -> state.ui.opencodeBinaries
if (Array.isArray(cfg.opencodeBinaries)) {
uiState.opencodeBinaries = cfg.opencodeBinaries
}
if (Object.keys(uiSettings).length > 0) {
uiConfig.settings = uiSettings
}
if (Object.keys(uiConfig).length > 0) {
outConfig.ui = uiConfig
}
if (Object.keys(serverConfig).length > 0) {
outConfig.server = serverConfig
}
if (Object.keys(uiState).length > 0) {
outState.ui = uiState
}
// Unknown top-level keys -> legacy.unknown
const knownConfigKeys = new Set(["preferences", "opencodeBinaries", "theme", "recentFolders"])
const unknownConfig = omitKeys(cfg, knownConfigKeys)
if (Object.keys(unknownConfig).length > 0) {
outConfig.legacy = { unknown: unknownConfig }
}
const knownStateKeys = new Set(["recentFolders"])
const unknownState = omitKeys(st, knownStateKeys)
if (Object.keys(unknownState).length > 0) {
outState.legacy = { unknown: unknownState }
}
return { config: outConfig, state: outState }
}
/**
* Migrate older config/state layouts into owner-bucket YAML docs.
*
* Legacy inputs supported:
* - config.yaml with { preferences, opencodeBinaries, theme }
* - state.yaml with { recentFolders }
* - legacy config.json with full ConfigFile schema
*/
export function migrateSettingsLayout(location: ConfigLocation, logger: Logger) {
const configYamlPath = location.configYamlPath
const stateYamlPath = location.stateYamlPath
const legacyJsonPath = location.legacyJsonPath
const configExists = fs.existsSync(configYamlPath)
const stateExists = fs.existsSync(stateYamlPath)
const configDoc = configExists ? safeReadYaml(configYamlPath, logger) : null
const stateDoc = stateExists ? safeReadYaml(stateYamlPath, logger) : null
const configIsNew = configExists && looksLikeNewOwnerDoc(configDoc) && !looksLikeLegacyConfig(configDoc)
const stateIsNew = stateExists && looksLikeNewOwnerDoc(stateDoc) && !looksLikeLegacyState(stateDoc)
if (configIsNew && stateIsNew) {
return
}
const legacyJsonExists = fs.existsSync(legacyJsonPath)
const hasLegacyYaml = (configExists && looksLikeLegacyConfig(configDoc)) || (stateExists && looksLikeLegacyState(stateDoc))
const shouldMigrateFromJson = !configExists && legacyJsonExists
if (!hasLegacyYaml && !shouldMigrateFromJson) {
// Either fresh install or partially written docs; let stores create on first write.
return
}
const sourceConfig = shouldMigrateFromJson ? safeReadJson(legacyJsonPath, logger) : configDoc
const sourceState = shouldMigrateFromJson ? sourceConfig : stateDoc
const { config, state } = mapLegacyToOwnerDocs(sourceConfig, sourceState)
try {
fs.mkdirSync(location.baseDir, { recursive: true })
} catch (error) {
logger.warn({ err: error, baseDir: location.baseDir }, "Failed to create base directory during migration")
}
// Backup legacy files before rewriting.
if (configExists) {
try {
const bak = pickBackupPath(configYamlPath)
fs.renameSync(configYamlPath, bak)
logger.info({ configYamlPath, bak }, "Backed up legacy config.yaml")
} catch (error) {
logger.warn({ err: error, configYamlPath }, "Failed to backup legacy config.yaml")
}
}
if (stateExists) {
try {
const bak = pickBackupPath(stateYamlPath)
fs.renameSync(stateYamlPath, bak)
logger.info({ stateYamlPath, bak }, "Backed up legacy state.yaml")
} catch (error) {
logger.warn({ err: error, stateYamlPath }, "Failed to backup legacy state.yaml")
}
}
if (shouldMigrateFromJson) {
try {
const bak = pickBackupPath(legacyJsonPath)
fs.renameSync(legacyJsonPath, bak)
logger.info({ legacyJsonPath, bak }, "Moved legacy config.json to backup")
} catch (error) {
logger.warn({ err: error, legacyJsonPath }, "Failed to move legacy config.json to backup")
}
}
writeYaml(configYamlPath, config, logger)
writeYaml(stateYamlPath, state, logger)
logger.info({ configYamlPath, stateYamlPath }, "Migrated settings docs to owner-bucket layout")
}

View File

@@ -0,0 +1,55 @@
import type { Logger } from "../logger"
import type { EventBus } from "../events/bus"
import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
export type DocKind = "config" | "state"
export class SettingsService {
private readonly configStore: YamlDocStore
private readonly stateStore: YamlDocStore
constructor(
private readonly location: ConfigLocation,
private readonly eventBus: EventBus | undefined,
private readonly logger: Logger,
) {
migrateSettingsLayout(location, logger)
this.configStore = new YamlDocStore(location.configYamlPath, logger.child({ component: "settings-config" }))
this.stateStore = new YamlDocStore(location.stateYamlPath, logger.child({ component: "settings-state" }))
}
getDoc(kind: DocKind): SettingsDoc {
return kind === "config" ? this.configStore.get() : this.stateStore.get()
}
mergePatchDoc(kind: DocKind, patch: unknown): SettingsDoc {
const updated = kind === "config" ? this.configStore.mergePatch(patch) : this.stateStore.mergePatch(patch)
this.publish(kind, "*")
return updated
}
getOwner(kind: DocKind, owner: string): SettingsDoc {
return kind === "config" ? this.configStore.getOwner(owner) : this.stateStore.getOwner(owner)
}
mergePatchOwner(kind: DocKind, owner: string, patch: unknown): SettingsDoc {
const updated =
kind === "config" ? this.configStore.mergePatchOwner(owner, patch) : this.stateStore.mergePatchOwner(owner, patch)
this.publish(kind, owner, updated)
return updated
}
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
} as any
this.eventBus.publish(payload)
}
}

View File

@@ -0,0 +1,110 @@
import fs from "fs"
import path from "path"
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
import type { Logger } from "../logger"
import { applyMergePatch, isPlainObject } from "./merge-patch"
export type SettingsDoc = Record<string, unknown>
function ensureTrailingNewline(content: string): string {
if (!content) return "\n"
return content.endsWith("\n") ? content : `${content}\n`
}
function normalizeDoc(input: unknown): SettingsDoc {
if (!isPlainObject(input)) {
return {}
}
return input
}
export class YamlDocStore {
private cache: SettingsDoc = {}
private loaded = false
constructor(
private readonly filePath: string,
private readonly logger: Logger,
) {}
load(): SettingsDoc {
if (this.loaded) {
return this.cache
}
try {
if (!fs.existsSync(this.filePath)) {
this.cache = {}
this.loaded = true
return this.cache
}
const content = fs.readFileSync(this.filePath, "utf-8")
const parsed = parseYaml(content)
this.cache = normalizeDoc(parsed)
this.loaded = true
return this.cache
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to read YAML doc; using empty object")
this.cache = {}
this.loaded = true
return this.cache
}
}
get(): SettingsDoc {
return this.load()
}
replace(next: unknown): SettingsDoc {
const normalized = normalizeDoc(next)
this.cache = normalized
this.loaded = true
this.persist()
return this.cache
}
mergePatch(patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const current = this.get()
const next = applyMergePatch(current, patch)
return this.replace(next)
}
getOwner(owner: string): SettingsDoc {
const doc = this.get()
const value = (doc as any)?.[owner]
return normalizeDoc(value)
}
replaceOwner(owner: string, value: unknown): SettingsDoc {
const doc = this.get()
const nextDoc: SettingsDoc = { ...doc, [owner]: normalizeDoc(value) }
this.replace(nextDoc)
return nextDoc[owner] as SettingsDoc
}
mergePatchOwner(owner: string, patch: unknown): SettingsDoc {
if (!isPlainObject(patch)) {
throw new Error("Patch must be a JSON object")
}
const doc = this.get()
const currentOwner = normalizeDoc((doc as any)?.[owner])
const nextOwner = normalizeDoc(applyMergePatch(currentOwner, patch))
const nextDoc: SettingsDoc = { ...doc, [owner]: nextOwner }
this.replace(nextDoc)
return nextOwner
}
private persist() {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true })
const yaml = stringifyYaml(this.cache as any)
fs.writeFileSync(this.filePath, ensureTrailingNewline(yaml), "utf-8")
} catch (error) {
this.logger.warn({ err: error, filePath: this.filePath }, "Failed to persist YAML doc")
}
}
}

View File

@@ -2,8 +2,8 @@ import path from "path"
import { spawnSync } from "child_process"
import { connect } from "net"
import { EventBus } from "../events/bus"
import { ConfigStore } from "../config/store"
import { BinaryRegistry } from "../config/binaries"
import type { SettingsService } from "../settings/service"
import type { BinaryResolver } from "../settings/binaries"
import { FileSystemBrowser } from "../filesystem/browser"
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
interface WorkspaceManagerOptions {
rootDir: string
configStore: ConfigStore
binaryRegistry: BinaryRegistry
settings: SettingsService
binaryResolver: BinaryResolver
eventBus: EventBus
logger: Logger
getServerBaseUrl: () => string
@@ -86,7 +86,7 @@ export class WorkspaceManager {
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`
const binary = this.options.binaryRegistry.resolveDefault()
const binary = this.options.binaryResolver.resolveDefault()
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
clearWorkspaceSearchCache(workspacePath)
@@ -118,8 +118,9 @@ export class WorkspaceManager {
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
const preferences = this.options.configStore.get().preferences ?? {}
const userEnvironment = preferences.environmentVariables ?? {}
const serverConfig = this.options.settings.getOwner("config", "server")
const envVars = (serverConfig as any)?.environmentVariables
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
const opencodePassword = generateOpencodeServerPassword()

View File

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

View File

@@ -140,9 +140,16 @@ struct PreferencesConfig {
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct ServerConfig {
#[serde(rename = "listeningMode")]
listening_mode: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AppConfig {
preferences: Option<PreferencesConfig>,
server: Option<ServerConfig>,
}
fn resolve_config_locations() -> (PathBuf, PathBuf) {
@@ -188,11 +195,18 @@ fn resolve_listening_mode() -> String {
if let Ok(content) = fs::read_to_string(&yaml_path) {
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
let mode = config
.server
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
.and_then(|srv| srv.listening_mode.as_ref())
.or_else(|| {
config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
});
if let Some(mode) = mode {
if mode == "local" {
return "local".to_string();
}
@@ -206,11 +220,17 @@ fn resolve_listening_mode() -> String {
// Legacy fallback.
if let Ok(content) = fs::read_to_string(&json_path) {
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
if let Some(mode) = config
.preferences
let mode = config
.server
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
{
.and_then(|srv| srv.listening_mode.as_ref())
.or_else(|| {
config
.preferences
.as_ref()
.and_then(|prefs| prefs.listening_mode.as_ref())
});
if let Some(mode) = mode {
if mode == "local" {
return "local".to_string();
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.10.3",
"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"
@@ -58,8 +60,10 @@ const App: Component = () => {
const { t } = useI18n()
const {
preferences,
serverSettings,
recordWorkspaceLaunch,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
@@ -80,12 +84,109 @@ 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 =
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
})
const updateInstanceTabBarHeight = () => {
if (typeof document === "undefined") return
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
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))
})
@@ -177,7 +278,7 @@ const App: Component = () => {
return
}
setIsSelectingFolder(true)
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
try {
recordWorkspaceLaunch(folderPath, selectedBinary)
clearLaunchError()
@@ -293,6 +394,7 @@ const App: Component = () => {
preferences,
toggleAutoCleanupBlankSessions,
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
@@ -395,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) => {
@@ -425,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>
@@ -451,25 +571,17 @@ const App: Component = () => {
<Show when={showFolderSelection()}>
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div class="w-full h-full relative">
<button
onClick={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
class="absolute top-4 right-4 z-10 p-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={t("app.launchError.closeTitle")}
>
<svg class="w-5 h-5 text-gray-600 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<FolderSelectionView
onSelectFolder={handleSelectFolder}
isLoading={isSelectingFolder()}
advancedSettingsOpen={isAdvancedSettingsOpen()}
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
onClose={() => {
setShowFolderSelection(false)
setIsAdvancedSettingsOpen(false)
clearLaunchError()
}}
/>
</div>
</div>

View File

@@ -112,6 +112,10 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
const groupedCommandList = () => processedCommands().groups
const orderedCommands = () => processedCommands().ordered
const isCommandDisabled = (command: Command) => {
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
}
const selectedIndex = createMemo(() => {
const ordered = orderedCommands()
if (ordered.length === 0) return -1
@@ -138,10 +142,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
}
return
}
const currentId = selectedCommandId()
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
setSelectedCommandId(ordered[0].id)
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
}
})
@@ -195,12 +200,14 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
if (index < 0 || index >= ordered.length) return
const command = ordered[index]
if (!command) return
if (isCommandDisabled(command)) return
props.onExecute(command)
props.onClose()
}
}
function handleCommandClick(command: Command) {
if (isCommandDisabled(command)) return
props.onExecute(command)
props.onClose()
}
@@ -265,11 +272,13 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
<For each={group.commands}>
{(command, localIndex) => {
const commandIndex = group.startIndex + localIndex()
const disabled = isCommandDisabled(command)
return (
<button
type="button"
data-command-index={commandIndex}
onClick={() => handleCommandClick(command)}
disabled={disabled}
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
onPointerMove={(event) => {
if (event.movementX === 0 && event.movementY === 0) return

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

@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
const { t } = useI18n()
const {
preferences,
serverSettings,
addEnvironmentVariable,
removeEnvironmentVariable,
updateEnvironmentVariables,
} = useConfig()
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
const [newKey, setNewKey] = createSignal("")
const [newValue, setNewValue] = createSignal("")

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

@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
</Show>
</div>
<div class="panel-footer">
<div class="panel-footer keyboard-hints">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">
<kbd class="kbd"></kbd>

View File

@@ -1,6 +1,6 @@
import { Select } from "@kobalte/core/select"
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
import { useConfig } from "../stores/preferences"
import AdvancedSettingsModal from "./advanced-settings-modal"
import DirectoryBrowserDialog from "./directory-browser-dialog"
@@ -23,14 +23,15 @@ interface FolderSelectionViewProps {
onAdvancedSettingsOpen?: () => void
onAdvancedSettingsClose?: () => void
onOpenRemoteAccess?: () => void
onClose?: () => void
}
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
const { t, locale } = useI18n()
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
const nativeDialogsAvailable = supportsNativeDialogs()
let recentListRef: HTMLDivElement | undefined
@@ -53,7 +54,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
// Update selected binary when preferences change
createEffect(() => {
const lastUsed = preferences().lastUsedBinary
const lastUsed = serverSettings().opencodeBinary
if (!lastUsed) return
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
})
@@ -373,7 +374,18 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onOpenRemoteAccess?.()}
>
<MonitorUp class="w-4 h-4" />
<MonitorUp class="w-4 h-4" />
</button>
</Show>
<Show when={props.onClose}>
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
onClick={() => props.onClose?.()}
aria-label={t("app.launchError.close")}
title={t("app.launchError.closeTitle")}
>
<X class="w-4 h-4" />
</button>
</Show>
</div>
@@ -548,7 +560,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
: t("folderSelection.browse.button")}
</span>
</div>
<Kbd shortcut="cmd+n" class="ml-2" />
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
</button>
</div>
@@ -573,7 +585,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
<div class="panel panel-footer shrink-0 hidden sm:block">
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
<div class="panel-footer-hints">
<Show when={folders().length > 0}>
<div class="flex items-center gap-1.5">
@@ -591,7 +603,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</div>
</Show>
<div class="flex items-center gap-1.5">
<Kbd shortcut="cmd+n" />
<Kbd shortcut="cmd+n" class="kbd-hint" />
<span>{t("folderSelection.hints.browse")}</span>
</div>
</div>

View File

@@ -8,7 +8,7 @@ interface HintRowProps {
const HintRow: Component<HintRowProps> = (props) => {
return (
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
{props.children}
</span>
)

View File

@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
)}
<span>{t("instanceWelcome.new.createButton")}</span>
</div>
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
</button>
</div>
</div>
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
</div>
</Show>
<div class="panel-footer hidden sm:block">
<div class="panel-footer hidden sm:block keyboard-hints">
<div class="panel-footer-hints">
<div class="flex items-center gap-1.5">

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}
@@ -633,7 +632,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
>
{t("instanceShell.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
</div>
@@ -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">
@@ -730,7 +731,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</div>
<div class="session-toolbar-right flex-1 flex items-center gap-3">
<span class="connection-status-shortcut-hint">
<span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" />
</span>
@@ -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

@@ -198,6 +198,16 @@ interface MessageContentItemProps {
onContentRendered?: () => void
}
function isSupportedPartType(part: unknown): boolean {
const type = (part as any)?.type
// Ignore part types the UI does not support rendering yet.
return !(typeof type === "string" && type === "patch")
}
function isContentPartType(type: unknown): boolean {
return type === "text" || type === "file"
}
function MessageContentItem(props: MessageContentItemProps) {
const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -222,15 +232,9 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
break
}
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) break
resolved.push(part)
}
@@ -256,15 +260,9 @@ function MessageContentItem(props: MessageContentItemProps) {
const partId = ids[idx]
const part = current.parts[partId]?.data
if (!part) continue
if (
part.type === "tool" ||
part.type === "reasoning" ||
part.type === "compaction" ||
part.type === "step-start" ||
part.type === "step-finish"
) {
continue
}
if (!isSupportedPartType(part)) continue
if (!isContentPartType((part as any).type)) continue
if (partHasRenderableText(part)) {
return false
}
@@ -549,6 +547,9 @@ export default function MessageBlock(props: MessageBlockProps) {
}
orderedParts.forEach((part, partIndex) => {
if (!isSupportedPartType(part)) {
return
}
if (part.type === "tool") {
flushContent()
const partId = part.id

View File

@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js"
import { Copy, Split, Trash2, Undo } from "lucide-solid"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart } from "../types/message"
import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types"
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
interface MessageItemProps {
record: MessageRecord
@@ -45,6 +46,15 @@ export default function MessageItem(props: MessageItemProps) {
const messageParts = () => props.parts
// User messages can temporarily include synthetic helper parts (e.g. tool traces / file reads).
// We only want to display the primary prompt text for the user message; other synthetic text
// parts should be hidden.
const primaryUserTextPartId = () => {
if (!isUser()) return null
const firstText = messageParts().find((part) => part?.type === "text") as { id?: string } | undefined
return typeof firstText?.id === "string" ? firstText.id : null
}
const fileAttachments = () =>
messageParts().filter((part): part is FilePart => part?.type === "file" && typeof (part as FilePart).url === "string")
@@ -96,7 +106,8 @@ export default function MessageItem(props: MessageItemProps) {
}
if (url.startsWith("file://")) {
window.open(url, "_blank", "noopener")
// Local filesystem URLs are not reliably downloadable from the message stream.
// We hide the download action for these chips.
return
}
@@ -151,7 +162,8 @@ export default function MessageItem(props: MessageItemProps) {
}
const info = props.messageInfo
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
const timeInfo = info?.time as { created: number; end?: number } | undefined
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
}
const handleRevert = () => {
@@ -372,6 +384,7 @@ export default function MessageItem(props: MessageItemProps) {
messageType={props.record.role}
instanceId={props.instanceId}
sessionId={props.sessionId}
primaryUserTextPartId={primaryUserTextPartId()}
onRendered={props.onContentRendered}
/>
)}
@@ -398,17 +411,20 @@ export default function MessageItem(props: MessageItemProps) {
<img src={attachment.url} alt={name} class="h-5 w-5 rounded object-cover" />
</Show>
<span class="truncate max-w-[180px]">{name}</span>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
<Show when={!attachment.url?.startsWith("file://")}>
<button
type="button"
onClick={() => void handleAttachmentDownload(attachment)}
class="attachment-download"
aria-label={t("messageItem.attachment.downloadAriaLabel", { name })}
title={t("messageItem.attachment.downloadAriaLabel", { name })}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12l4 4 4-4m-4-8v12" />
</svg>
</button>
</Show>
<button
type="button"

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>
@@ -62,7 +58,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
{t("messageListHeader.commandPalette.button")}
</button>
<span class="connection-status-shortcut-hint">
<Kbd shortcut="cmd+shift+p" />
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
</span>
</div>
</div>

View File

@@ -13,9 +13,12 @@ interface MessagePartProps {
messageType?: "user" | "assistant"
instanceId: string
sessionId: string
// For user messages, keep the primary prompt text visible even when synthetic (optimistic).
// 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()
@@ -28,8 +31,19 @@ interface MessagePartProps {
const shouldHideTextPart = () => {
const part = props.part
if (!part || part.type !== "text") return false
// Keep optimistic user prompts visible; hide synthetic assistant text.
return Boolean((part as any).synthetic) && props.messageType !== "user"
const isSynthetic = Boolean((part as any).synthetic)
if (!isSynthetic) return false
// Keep optimistic user prompts visible; hide other synthetic user helper parts.
if (props.messageType === "user") {
const primaryId = props.primaryUserTextPartId
if (!primaryId) return false
return part.id !== primaryId
}
// Hide synthetic assistant text.
return true
}

View File

@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
<ul>
<li>
<span>{t("messageSection.empty.tips.commandPalette")}</span>
<Kbd shortcut="cmd+shift+p" class="ml-2" />
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
</li>
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
<li>

View File

@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
import type { Model } from "../types/session"
import { useI18n } from "../lib/i18n"
import { getLogger } from "../lib/logger"
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
const log = getLogger("session")
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
const favoriteKeySet = createMemo(() => {
const result = new Set<string>()
for (const item of preferences().modelFavorites ?? []) {
for (const item of uiState().models.favorites ?? []) {
if (item.providerId && item.modelId) {
result.add(`${item.providerId}/${item.modelId}`)
}

View File

@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
opencodeBinaries,
addOpenCodeBinary,
removeOpenCodeBinary,
preferences,
updatePreferences,
serverSettings,
updateLastUsedBinary,
} = useConfig()
const [customPath, setCustomPath] = createSignal("")
const [validating, setValidating] = createSignal(false)
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
const binaries = () => opencodeBinaries()
const lastUsedBinary = () => preferences().lastUsedBinary
const lastUsedBinary = () => serverSettings().opencodeBinary
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (validation.valid) {
addOpenCodeBinary(path, validation.version)
props.onBinaryChange(path)
updatePreferences({ lastUsedBinary: path })
updateLastUsedBinary(path)
setCustomPath("")
setValidationError(null)
} else {
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.disabled) return
if (path === props.selectedBinary) return
props.onBinaryChange(path)
updatePreferences({ lastUsedBinary: path })
updateLastUsedBinary(path)
}
function handleRemoveBinary(path: string, event: Event) {
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
if (props.selectedBinary === path) {
props.onBinaryChange("opencode")
updatePreferences({ lastUsedBinary: "opencode" })
updateLastUsedBinary("opencode")
}
}

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"
@@ -480,7 +500,7 @@ export default function PromptInput(props: PromptInputProps) {
</Show>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
<Show
when={props.escapeInDebounce}
fallback={

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

@@ -183,9 +183,25 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
if (isDeletingFromEnd || isDeletingFromStart || isSelected) {
const currentAttachments = options.getAttachments()
const attachment = currentAttachments.find(
(a) => (a.source.type === "file" || a.source.type === "agent") && a.filename === name,
)
const attachment = currentAttachments.find((a) => {
if (a.source.type === "agent") {
return a.filename === name
}
if (a.source.type === "file") {
// Match either by filename (basename) or by path (for full paths like @docs/file.txt)
return (
a.filename === name ||
a.source.path === name ||
a.source.path.endsWith("/" + name) ||
a.source.path === name.replace(/\/$/, "")
)
}
if (a.source.type === "text") {
// For text attachments (path-only mentions), match by value
return a.source.value === name || a.source.value.endsWith("/" + name)
}
return false
})
if (attachment) {
e.preventDefault()
@@ -205,6 +221,14 @@ export function usePromptKeyDown(options: UsePromptKeyDownOptions) {
textarea.setSelectionRange(mentionStart, mentionStart)
}, 0)
// Check if there are any @ remaining in the text - if not, close the picker
if (!newText.includes("@") && options.isPickerOpen()) {
options.closePicker()
// Clear ignoredAtPositions since we deleted the entire @mention
// This ensures typing @ again will open the picker
options.setIgnoredAtPositions(new Set())
}
return
}
}

View File

@@ -1,9 +1,10 @@
import { createSignal, type Accessor, type Setter } from "solid-js"
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
import type { Agent } from "../../types/session"
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
import { addAttachment, getAttachments } from "../../stores/attachments"
import type { PickerMode } from "./types"
import type { PickerSelectAction } from "../unified-picker"
type PickerItem =
| { type: "agent"; agent: Agent }
@@ -37,7 +38,7 @@ type PromptPickerController = {
setIgnoredAtPositions: Setter<Set<number>>
handleInput: (e: Event) => void
handlePickerSelect: (item: PickerItem) => void
handlePickerSelect: (item: PickerItem, action: PickerSelectAction) => void
handlePickerClose: () => void
}
@@ -103,10 +104,11 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
setAtPosition(null)
}
function handlePickerSelect(item: PickerItem) {
function handlePickerSelect(item: PickerItem, action: PickerSelectAction) {
const textarea = options.getTextarea()
if (item.type === "command") {
// For commands, Tab/Enter/Shift+Enter/click all mean "select".
const name = item.command.name
const currentPrompt = options.prompt()
@@ -128,6 +130,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
}
}, 0)
} else if (item.type === "agent") {
// For agents, Tab/Enter/Shift+Enter/click all mean "select".
const agentName = item.agent.name
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
@@ -163,76 +166,152 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const relativePath = item.file.relativePath ?? displayPath
const isFolder = item.file.isDirectory ?? displayPath.endsWith("/")
if (isFolder) {
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
const folderMention =
relativePath === "." || relativePath === ""
? "/"
: relativePath.replace(/\/+$/, "") + "/"
if (pos !== null) {
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const newPrompt = before + folderMention + after
options.setPrompt(newPrompt)
setSearchQuery(folderMention)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + 1 + folderMention.length
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
}, 0)
}
return
}
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPath,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPath,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
const currentPrompt = options.prompt()
const pos = atPosition()
const cursorPos = textarea?.selectionStart || 0
if (pos !== null) {
const replaceMentionToken = (mentionText: string, opts?: { trailingSpace?: boolean }) => {
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
const attachmentText = `@${normalizedPath}`
const newPrompt = before + attachmentText + " " + after
options.setPrompt(newPrompt)
const suffix = opts?.trailingSpace ? " " : ""
const nextPrompt = before + mentionText + suffix + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
const newCursorPos = pos + attachmentText.length + 1
nextTextarea.setSelectionRange(newCursorPos, newCursorPos)
}
if (!nextTextarea) return
const nextCursorPos = pos + mentionText.length + suffix.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const replaceMentionQueryAfterAt = (value: string) => {
// Replaces only the query after '@' (keeps the '@' itself). Used for directory navigation.
if (pos === null) return
const currentPrompt = options.prompt()
const before = currentPrompt.substring(0, pos + 1)
const after = currentPrompt.substring(cursorPos)
const nextPrompt = before + value + after
options.setPrompt(nextPrompt)
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (!nextTextarea) return
const nextCursorPos = pos + 1 + value.length
nextTextarea.setSelectionRange(nextCursorPos, nextCursorPos)
}, 0)
}
const folderMention =
relativePath === "." || relativePath === "" || relativePath === "./"
? "./"
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
const normalizedFolderPath = (() => {
const trimmed = relativePath.replace(/\/+$/, "")
// If it's root "./", just return "./"
if (trimmed === "" || trimmed === ".") return "./"
// Otherwise remove any leading ./ and add ./ prefix
return "./" + trimmed.replace(/^\.\//, "")
})()
const addPathOnlyAttachment = (value: string) => {
const display = `path: ${value}`
const filename = value
const existing = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existing.some(
(att) => att.source.type === "text" && att.source.value === value && att.display === display,
)
if (!alreadyAttached) {
addAttachment(options.instanceId(), options.sessionId(), createTextAttachment(value, display, filename))
}
}
if (isFolder) {
if (action === "tab") {
// TAB on directory: autocomplete directory name and show its contents.
replaceMentionQueryAfterAt(folderMention)
setSearchQuery(folderMention)
return
}
const mentionText = `@${folderMention}`
if (action === "shiftEnter") {
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
replaceMentionToken(mentionText, { trailingSpace: true })
} else {
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedFolderPath && att.source.mime === "inode/directory",
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedFolderPath,
dirFilename,
"inode/directory",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(mentionText, { trailingSpace: true })
}
} else {
const normalizedPath = relativePath.replace(/\/+$/, "") || relativePath
if (action === "tab") {
// TAB on file: autocomplete the file path but do not attach.
replaceMentionToken(`@${normalizedPath}`)
setSearchQuery(normalizedPath)
return
}
if (action === "shiftEnter") {
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
addPathOnlyAttachment(normalizedPathWithPrefix)
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
} else {
// ENTER/click on file: attach file (existing behavior).
// Always prefix with ./ for consistency
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
const pathSegments = normalizedPath.split("/")
const filename = (() => {
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
return candidate === "." ? "/" : candidate
})()
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
const alreadyAttached = existingAttachments.some(
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
)
if (!alreadyAttached) {
const attachment = createFileAttachment(
normalizedPathWithPrefix,
filename,
"text/plain",
undefined,
options.instanceFolder(),
)
addAttachment(options.instanceId(), options.sessionId(), attachment)
}
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
}
}
}
setShowPicker(false)
@@ -245,6 +324,28 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
const pos = atPosition()
if (pickerMode() === "mention" && pos !== null) {
setIgnoredAtPositions((prev) => new Set(prev).add(pos))
// Remove the partial @mention text from the textarea when ESC is pressed
const textarea = options.getTextarea()
if (textarea) {
const currentPrompt = options.prompt()
const cursorPos = textarea.selectionStart
// Remove text from @ position to cursor position
const before = currentPrompt.substring(0, pos)
const after = currentPrompt.substring(cursorPos)
options.setPrompt(before + after)
// Restore cursor position to where @ was
setTimeout(() => {
const nextTextarea = options.getTextarea()
if (nextTextarea) {
nextTextarea.setSelectionRange(pos, pos)
}
}, 0)
// Clear ignoredAtPositions so typing @ again will work
setIgnoredAtPositions(new Set<number>())
}
}
setShowPicker(false)
setAtPosition(null)

View File

@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
import { serverApi } from "../lib/api-client"
import { restartCli } from "../lib/native/cli"
import { preferences, setListeningMode } from "../stores/preferences"
import { serverSettings, setListeningMode } from "../stores/preferences"
import { showConfirmDialog } from "../stores/alerts"
import { getLogger } from "../lib/logger"
import { useI18n } from "../lib/i18n"
@@ -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)
@@ -33,7 +34,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [savingPassword, setSavingPassword] = createSignal(false)
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
const allowExternalConnections = createMemo(() => currentMode() === "all")
const displayAddresses = createMemo(() => {
const list = addresses()
@@ -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

@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
</span>
</Show>
</div>
<kbd class="kbd ml-2">
<kbd class="kbd ml-2 kbd-hint">
Cmd+Enter
</kbd>
</button>

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

@@ -51,9 +51,7 @@ function normalizeQuery(rawQuery: string) {
if (!trimmed) {
return ""
}
if (trimmed === "." || trimmed === "./") {
return ""
}
// Don't normalize "." - it's used for workspace root
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
}
@@ -74,10 +72,12 @@ type PickerItem =
| { type: "file"; file: FileItem }
| { type: "command"; command: SDKCommand }
export type PickerSelectAction = "click" | "tab" | "enter" | "shiftEnter"
interface UnifiedPickerProps {
open: boolean
mode?: "mention" | "command"
onSelect: (item: PickerItem) => void
onSelect: (item: PickerItem, action: PickerSelectAction) => void
onClose: () => void
agents: Agent[]
commands?: SDKCommand[]
@@ -266,6 +266,13 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
const workspaceChanged = lastWorkspaceId !== props.workspaceId
const queryChanged = lastQuery !== props.searchQuery
if (queryChanged) {
// Reset selectedIndex to 0 when query changes to avoid ghost state
// This ensures proper highlighting when navigating back to root or changing queries
setSelectedIndex(0)
resetScrollPosition()
}
if (!isInitialized() || workspaceChanged || queryChanged) {
setIsInitialized(true)
lastWorkspaceId = props.workspaceId
@@ -341,7 +348,22 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
return items
}
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
const isExactRootQuery = props.searchQuery === "." || props.searchQuery === "./"
if (mode() === "mention" && isExactRootQuery) {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
items.push({ type: "file", file: rootFile })
}
// Don't show agents for exact root path queries
if (!isExactRootQuery) {
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
}
files().forEach((file) => items.push({ type: "file", file }))
return items
}
@@ -356,7 +378,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
}
function handleSelect(item: PickerItem) {
props.onSelect(item)
props.onSelect(item, "click")
}
function handleKeyDown(e: KeyboardEvent) {
@@ -379,7 +401,8 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
e.stopPropagation()
const selected = items[selectedIndex()]
if (selected) {
handleSelect(selected)
const action: PickerSelectAction = e.key === "Tab" ? "tab" : e.shiftKey ? "shiftEnter" : "enter"
props.onSelect(selected, action)
}
} else if (e.key === "Escape") {
e.preventDefault()
@@ -443,7 +466,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
<div
class={`dropdown-item ${isSelected() ? "dropdown-item-highlight" : ""}`}
data-picker-selected={isSelected()}
onClick={() => handleSelect({ type: "command", command })}
onClick={() => props.onSelect({ type: "command", command }, "click")}
>
<div class="flex items-start gap-2">
<svg class="dropdown-icon-accent h-4 w-4 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -464,7 +487,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && agentCount() > 0}>
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header">
{t("unifiedPicker.sections.agents")}
</div>
@@ -479,7 +502,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "agent", agent })}
onClick={() => props.onSelect({ type: "agent", agent }, "click")}
>
<div class="flex items-start gap-2">
<svg
@@ -519,10 +542,39 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
</For>
</Show>
<Show when={mode() === "mention" && fileCount() > 0}>
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
<div class="dropdown-section-header">
{t("unifiedPicker.sections.files")}
</div>
<Show when={props.searchQuery === "." || props.searchQuery === "./"}>
<div
class={`dropdown-item py-1.5 ${
selectedIndex() === 0 ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={selectedIndex() === 0}
onClick={() => {
const rootFile: FileItem = {
path: ".",
relativePath: ".",
isDirectory: true,
isGitFile: false,
}
props.onSelect({ type: "file", file: rootFile }, "click")
}}
>
<div class="flex items-center gap-2 text-sm">
<svg class="dropdown-icon h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
<span class="font-mono">. {t("unifiedPicker.sections.workspaceRoot")}</span>
</div>
</div>
</Show>
<For each={files()}>
{(file) => {
const itemIndex = allItems().findIndex(
@@ -535,7 +587,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
itemIndex === selectedIndex() ? "dropdown-item-highlight" : ""
}`}
data-picker-selected={itemIndex === selectedIndex()}
onClick={() => handleSelect({ type: "file", file })}
onClick={() => props.onSelect({ type: "file", file }, "click")}
>
<div class="flex items-center gap-2 text-sm">
<Show

View File

@@ -1,11 +1,7 @@
import type {
AppConfig,
BackgroundProcess,
BackgroundProcessListResponse,
BackgroundProcessOutputResponse,
BinaryCreateRequest,
BinaryListResponse,
BinaryUpdateRequest,
BinaryValidationResult,
FileSystemEntry,
FileSystemCreateFolderResponse,
@@ -214,37 +210,27 @@ export const serverApi = {
)
},
fetchConfig(): Promise<AppConfig> {
return request<AppConfig>("/api/config/app")
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
},
updateConfig(payload: AppConfig): Promise<AppConfig> {
return request<AppConfig>("/api/config/app", {
method: "PUT",
body: JSON.stringify(payload),
})
},
listBinaries(): Promise<BinaryListResponse> {
return request<BinaryListResponse>("/api/config/binaries")
},
createBinary(payload: BinaryCreateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
method: "POST",
body: JSON.stringify(payload),
})
},
updateBinary(id: string, updates: BinaryUpdateRequest) {
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(updates),
body: JSON.stringify(patch ?? {}),
})
},
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
},
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
method: "PATCH",
body: JSON.stringify(patch ?? {}),
})
},
deleteBinary(id: string): Promise<void> {
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
},
validateBinary(path: string): Promise<BinaryValidationResult> {
return request<BinaryValidationResult>("/api/config/binaries/validate", {
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
method: "POST",
body: JSON.stringify({ path }),
})

View File

@@ -18,6 +18,7 @@ export interface Command {
description: Resolvable<string>
keywords?: Resolvable<string[]>
shortcut?: KeyboardShortcut
disabled?: Resolvable<boolean>
action: () => void | Promise<void>
category?: Resolvable<string>
}

View File

@@ -14,6 +14,7 @@ import { getLogger } from "../logger"
import { requestData } from "../opencode-api"
import { emitSessionSidebarRequest } from "../session-sidebar-events"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
const log = getLogger("actions")
@@ -28,6 +29,7 @@ function splitKeywords(key: string): string[] {
export interface UseCommandsOptions {
preferences: Accessor<Preferences>
toggleShowThinkingBlocks: () => void
toggleKeyboardShortcutHints: () => void
toggleShowTimelineTools: () => void
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
@@ -454,6 +456,26 @@ export function useCommands(options: UseCommandsOptions) {
action: options.toggleShowTimelineTools,
})
commandRegistry.register({
id: "keyboard-shortcut-hints",
label: () =>
tGlobal(
options.preferences().showKeyboardShortcutHints
? "commands.keyboardShortcutHints.label.hide"
: "commands.keyboardShortcutHints.label.show",
),
description: () =>
tGlobal(
runtimeEnv.host === "web"
? "commands.keyboardShortcutHints.description.disabledWeb"
: "commands.keyboardShortcutHints.description",
),
category: "System",
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
disabled: () => runtimeEnv.host === "web",
action: options.toggleKeyboardShortcutHints,
})
commandRegistry.register({
id: "thinking-default-visibility",
label: () => {

View File

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "Show Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.label.hide": "Hide Keyboard Shortcut Hints",
"commands.keyboardShortcutHints.description": "Show or hide keyboard shortcut hints across the UI",
"commands.keyboardShortcutHints.description.disabledWeb": "Disabled in WebUI (shortcut hints are always hidden)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, hints",
"commands.common.expanded": "Expanded",
"commands.common.collapsed": "Collapsed",
"commands.common.visible": "Visible",
@@ -158,6 +164,7 @@ export const commandMessages = {
"unifiedPicker.sections.commands": "COMMANDS",
"unifiedPicker.sections.agents": "AGENTS",
"unifiedPicker.sections.files": "FILES",
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
"unifiedPicker.badge.subagent": "subagent",
"unifiedPicker.footer.navigate": "navigate",
"unifiedPicker.footer.select": "select",

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

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
"commands.timelineToolCalls.keywords": "línea de tiempo, herramienta, alternar",
"commands.keyboardShortcutHints.label.show": "Mostrar atajos de teclado",
"commands.keyboardShortcutHints.label.hide": "Ocultar atajos de teclado",
"commands.keyboardShortcutHints.description": "Mostrar u ocultar sugerencias de atajos de teclado en la interfaz",
"commands.keyboardShortcutHints.description.disabledWeb": "Desactivado en WebUI (los atajos siempre se ocultan)",
"commands.keyboardShortcutHints.keywords": "atajo, atajos, teclado, keybind, pistas",
"commands.common.expanded": "Expandido",
"commands.common.collapsed": "Colapsado",
"commands.common.visible": "Visible",

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

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
"commands.keyboardShortcutHints.label.show": "Afficher les raccourcis clavier",
"commands.keyboardShortcutHints.label.hide": "Masquer les raccourcis clavier",
"commands.keyboardShortcutHints.description": "Afficher ou masquer les indices de raccourcis clavier dans l'interface",
"commands.keyboardShortcutHints.description.disabledWeb": "Désactivé en WebUI (les raccourcis sont toujours masqués)",
"commands.keyboardShortcutHints.keywords": "raccourci, raccourcis, clavier, keybind, indices",
"commands.common.expanded": "Développé",
"commands.common.collapsed": "Réduit",
"commands.common.visible": "Visible",

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

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
"commands.keyboardShortcutHints.label.show": "キーボードショートカットのヒントを表示",
"commands.keyboardShortcutHints.label.hide": "キーボードショートカットのヒントを非表示",
"commands.keyboardShortcutHints.description": "UI 全体のキーボードショートカットヒントを表示/非表示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI では無効(ヒントは常に非表示)",
"commands.keyboardShortcutHints.keywords": "ショートカット, キーボード, ヒント, shortcuts, keyboard, hints",
"commands.common.expanded": "展開",
"commands.common.collapsed": "折りたたみ",
"commands.common.visible": "表示",

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

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
"commands.keyboardShortcutHints.label.show": "Показать подсказки сочетаний",
"commands.keyboardShortcutHints.label.hide": "Скрыть подсказки сочетаний",
"commands.keyboardShortcutHints.description": "Показать или скрыть подсказки сочетаний клавиш в интерфейсе",
"commands.keyboardShortcutHints.description.disabledWeb": "Отключено в WebUI (подсказки всегда скрыты)",
"commands.keyboardShortcutHints.keywords": "shortcut, shortcuts, keyboard, keybind, подсказки",
"commands.common.expanded": "Развернуто",
"commands.common.collapsed": "Свернуто",
"commands.common.visible": "Видимо",

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

@@ -97,6 +97,12 @@ export const commandMessages = {
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
"commands.keyboardShortcutHints.label.show": "显示键盘快捷键提示",
"commands.keyboardShortcutHints.label.hide": "隐藏键盘快捷键提示",
"commands.keyboardShortcutHints.description": "显示或隐藏界面中的键盘快捷键提示",
"commands.keyboardShortcutHints.description.disabledWeb": "WebUI 中已禁用(提示始终隐藏)",
"commands.keyboardShortcutHints.keywords": "shortcuts, keyboard, hints, 快捷键, 键盘, 提示",
"commands.common.expanded": "展开",
"commands.common.collapsed": "折叠",
"commands.common.visible": "可见",

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

@@ -1,12 +1,59 @@
import type { Attachment } from "../types/attachment"
import type { Attachment, FileSource } from "../types/attachment"
export function resolvePastedPlaceholders(prompt: string, attachments: Attachment[] = []): string {
if (!prompt || !prompt.includes("[pasted #")) {
if (!prompt) {
return prompt
}
const fileAttachments = new Set(
attachments
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
.map((a) => a.source.path),
)
const pathAttachments = new Set(
attachments
.filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"))
.map((a) => (a.source as { value: string }).value),
)
let result = prompt
// Step 1: Handle root paths FIRST using unique placeholders
// Replace longer pattern first to avoid partial match issues
result = result.replace(/@(\.\/)/g, "___ROOT___")
result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___")
// Note: The regex @(\.)(?!\.) means @. NOT followed by another .
// Step 2: Build set of non-root paths
const allPaths = new Set<string>()
for (const p of fileAttachments) {
if (p && p !== "." && p !== "./") allPaths.add(p)
}
for (const p of pathAttachments) {
if (p && p !== "." && p !== "./") allPaths.add(p)
}
// Step 3: Replace @path with ./path for non-root paths
for (const path of allPaths) {
if (!path) continue
const withoutPrefix = path.startsWith("./") ? path.slice(2) : path
const withPrefix = path.startsWith("./") ? path : "./" + path
result = result.replace("@" + withoutPrefix, withPrefix)
result = result.replace("@" + withoutPrefix + "/", withPrefix + "/")
}
// Step 4: Convert placeholders back to ./
result = result.replace("___ROOT___", "./")
result = result.replace("___ROOT_NOSLASH___", "./")
// Step 5: Resolve [pasted #N] placeholders
if (!result.includes("[pasted #")) {
return result
}
if (!attachments || attachments.length === 0) {
return prompt
return result
}
const lookup = new Map<string, string>()
@@ -15,7 +62,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
const source = attachment?.source
if (!source || source.type !== "text") continue
const display = attachment?.display
const value = source.value
const value = (source as { value?: string }).value
if (typeof display !== "string" || typeof value !== "string") continue
const match = display.match(/pasted #(\d+)/)
if (!match) continue
@@ -26,10 +73,10 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
}
if (lookup.size === 0) {
return prompt
return result
}
return prompt.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
return result.replace(/\[pasted #(\d+)\]/g, (fullMatch) => {
const replacement = lookup.get(fullMatch)
return typeof replacement === "string" ? replacement : fullMatch
})

View File

@@ -4,6 +4,7 @@ import {
MessageRemovedEvent,
MessagePartUpdatedEvent,
MessagePartRemovedEvent,
MessagePartDeltaEvent,
} from "../types/message"
import type {
EventLspUpdated,
@@ -58,6 +59,7 @@ type SSEEvent =
| MessageRemovedEvent
| MessagePartUpdatedEvent
| MessagePartRemovedEvent
| MessagePartDeltaEvent
| EventSessionUpdated
| EventSessionCompacted
| EventSessionDiff
@@ -118,6 +120,9 @@ class SSEManager {
case "message.part.updated":
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
break
case "message.part.delta":
this.onMessagePartDelta?.(instanceId, event as MessagePartDeltaEvent)
break
case "message.removed":
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
break
@@ -184,6 +189,7 @@ class SSEManager {
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
onMessagePartDelta?: (instanceId: string, event: MessagePartDeltaEvent) => void
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void

View File

@@ -1,11 +1,11 @@
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types"
import { serverApi } from "./api-client"
import { serverEvents } from "./server-events"
import { getLogger } from "./logger"
const log = getLogger("actions")
export type ConfigData = AppConfig
export type OwnerBucket = Record<string, any>
const DEFAULT_INSTANCE_DATA: InstanceData = {
messageHistory: [],
@@ -30,17 +30,25 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
}
export class ServerStorage {
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
private configCache: ConfigData | null = null
private loadPromise: Promise<ConfigData> | null = null
private configOwnerCache = new Map<string, OwnerBucket>()
private stateOwnerCache = new Map<string, OwnerBucket>()
private configOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
private stateOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
private configOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
private stateOwnerListeners = new Map<string, Set<(value: OwnerBucket) => void>>()
private instanceDataCache = new Map<string, InstanceData>()
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
constructor() {
serverEvents.on("config.appChanged", (event) => {
if (event.type !== "config.appChanged") return
this.setConfigCache(event.config)
serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => {
if (event.type !== "storage.configChanged") return
this.setOwnerCache("config", event.owner, event.value)
})
serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => {
if (event.type !== "storage.stateChanged") return
this.setOwnerCache("state", event.owner, event.value)
})
serverEvents.on("instance.dataChanged", (event) => {
@@ -49,30 +57,56 @@ export class ServerStorage {
})
}
async loadConfig(): Promise<ConfigData> {
if (this.configCache) {
return this.configCache
}
async loadConfigOwner(owner: string): Promise<OwnerBucket> {
const cached = this.configOwnerCache.get(owner)
if (cached) return cached
if (!this.loadPromise) {
this.loadPromise = serverApi
.fetchConfig()
.then((config) => {
this.setConfigCache(config)
return config
if (!this.configOwnerLoadPromises.has(owner)) {
const promise = serverApi
.fetchConfigOwner<OwnerBucket>(owner)
.then((value) => {
this.setOwnerCache("config", owner, value)
return value
})
.finally(() => {
this.loadPromise = null
this.configOwnerLoadPromises.delete(owner)
})
this.configOwnerLoadPromises.set(owner, promise)
}
return this.loadPromise
return this.configOwnerLoadPromises.get(owner)!
}
async updateConfig(next: ConfigData): Promise<ConfigData> {
const nextConfig = await serverApi.updateConfig(next)
this.setConfigCache(nextConfig)
return nextConfig
async patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
this.setOwnerCache("config", owner, updated)
return updated
}
async loadStateOwner(owner: string): Promise<OwnerBucket> {
const cached = this.stateOwnerCache.get(owner)
if (cached) return cached
if (!this.stateOwnerLoadPromises.has(owner)) {
const promise = serverApi
.fetchStateOwner<OwnerBucket>(owner)
.then((value) => {
this.setOwnerCache("state", owner, value)
return value
})
.finally(() => {
this.stateOwnerLoadPromises.delete(owner)
})
this.stateOwnerLoadPromises.set(owner, promise)
}
return this.stateOwnerLoadPromises.get(owner)!
}
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
this.setOwnerCache("state", owner, updated)
return updated
}
async loadInstanceData(instanceId: string): Promise<InstanceData> {
@@ -110,12 +144,40 @@ export class ServerStorage {
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
}
onConfigChanged(listener: (config: ConfigData) => void): () => void {
this.configChangeListeners.add(listener)
if (this.configCache) {
listener(this.configCache)
onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
if (!this.configOwnerListeners.has(owner)) {
this.configOwnerListeners.set(owner, new Set())
}
const bucket = this.configOwnerListeners.get(owner)!
bucket.add(listener)
const cached = this.configOwnerCache.get(owner)
if (cached) {
listener(cached)
}
return () => {
bucket.delete(listener)
if (bucket.size === 0) {
this.configOwnerListeners.delete(owner)
}
}
}
onStateOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
if (!this.stateOwnerListeners.has(owner)) {
this.stateOwnerListeners.set(owner, new Set())
}
const bucket = this.stateOwnerListeners.get(owner)!
bucket.add(listener)
const cached = this.stateOwnerCache.get(owner)
if (cached) {
listener(cached)
}
return () => {
bucket.delete(listener)
if (bucket.size === 0) {
this.stateOwnerListeners.delete(owner)
}
}
return () => this.configChangeListeners.delete(listener)
}
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
@@ -136,18 +198,30 @@ export class ServerStorage {
}
}
private setConfigCache(config: ConfigData) {
if (this.configCache && isDeepEqual(this.configCache, config)) {
this.configCache = config
private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) {
if (owner === "*") {
// Full-doc updates are not tracked owner-by-owner; invalidate caches.
if (kind === "config") {
this.configOwnerCache.clear()
} else {
this.stateOwnerCache.clear()
}
return
}
this.configCache = config
this.notifyConfigChanged(config)
}
private notifyConfigChanged(config: ConfigData) {
for (const listener of this.configChangeListeners) {
listener(config)
const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache
const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners
const previous = cache.get(owner)
if (previous && isDeepEqual(previous, value)) {
cache.set(owner, value)
return
}
cache.set(owner, value)
const bucket = listeners.get(owner)
if (!bucket) return
for (const listener of bucket) {
listener(value)
}
}

View File

@@ -30,8 +30,8 @@ async function bootstrap() {
document.documentElement.removeAttribute("data-theme")
try {
const config = await storage.loadConfig()
const theme = config?.theme ?? "system"
const uiConfig = await storage.loadConfigOwner("ui")
const theme = (uiConfig as any)?.theme ?? "system"
if (theme === "system") {
document.documentElement.removeAttribute("data-theme")

View File

@@ -20,7 +20,7 @@ import {
} from "./sessions"
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
import { fetchCommands, clearCommands } from "./commands"
import { preferences } from "./preferences"
import { serverSettings } from "./preferences"
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
import { setHasInstances } from "./ui"
import { messageStoreBus } from "./message-v2/bus"
@@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
binaryLabel: descriptor.binaryLabel,
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {},
}
}

View File

@@ -77,9 +77,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
return
}
const store = messageStoreBus.getOrCreate(instanceId)
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
const timeInfo = (info.time ?? {}) as { created?: number; end?: number }
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined
store.upsertMessage({
id: info.id,
@@ -87,7 +87,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
role: info.role === "user" ? "user" : "assistant",
status: options?.status ?? "complete",
createdAt,
updatedAt: completedAt ?? createdAt,
updatedAt: endAt ?? createdAt,
bumpRevision: Boolean(options?.bumpRevision),
})
store.setMessageInfo(info.id, info)
@@ -104,6 +104,22 @@ export function applyPartUpdateV2(instanceId: string, part: ClientPart | null |
})
}
export function applyPartDeltaV2(
instanceId: string,
input: { messageId: string; partId: string; field: string; delta: string },
): void {
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
return
}
const store = messageStoreBus.getOrCreate(instanceId)
store.applyPartDelta({
messageId: input.messageId,
partId: input.partId,
field: input.field,
delta: input.delta,
})
}
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
if (!oldId || !newId || oldId === newId) return
const store = messageStoreBus.getOrCreate(instanceId)

View File

@@ -189,6 +189,7 @@ export interface InstanceMessageStore {
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
upsertMessage: (input: MessageUpsertInput) => void
applyPartUpdate: (input: PartUpdateInput) => void
applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void
removeMessage: (messageId: string) => void
removeMessagePart: (messageId: string, partId: string) => void
bufferPendingPart: (entry: PendingPartEntry) => void
@@ -597,6 +598,45 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
bumpSessionRevision(message.sessionId)
}
function applyPartDelta(input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) {
if (!input?.messageId || !input.partId || !input.field || typeof input.delta !== "string") {
return
}
const message = state.messages[input.messageId]
if (!message) {
// Best-effort: drop deltas for unknown messages.
return
}
let applied = false
setState(
"messages",
input.messageId,
produce((draft: MessageRecord) => {
const entry = draft.parts[input.partId]
if (!entry?.data) return
const part = entry.data as any
const currentValue = part?.[input.field]
if (typeof currentValue === "string" || currentValue === undefined || currentValue === null) {
part[input.field] = `${currentValue ?? ""}${input.delta}`
applied = true
}
if (!applied) return
entry.revision += 1
draft.updatedAt = Date.now()
if (input.bumpRevision ?? true) {
draft.revision += 1
}
}),
)
if (applied) {
bumpSessionRevision(message.sessionId)
}
}
function removeMessage(messageId: string) {
if (!messageId) return
@@ -1087,19 +1127,20 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState(reconcile(createInitialState(instanceId)))
}
return {
return {
instanceId,
state,
setState,
addOrUpdateSession,
hydrateMessages,
upsertMessage,
hydrateMessages,
upsertMessage,
applyPartUpdate,
applyPartDelta,
removeMessage,
removeMessagePart,
bufferPendingPart,
flushPendingParts,
flushPendingParts,
replaceMessageId,
setMessageInfo,
getMessageInfo,
@@ -1125,4 +1166,3 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
}
}

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)

File diff suppressed because it is too large Load Diff

View File

@@ -140,8 +140,11 @@ async function sendMessage(
const display: string | undefined = att.display
const value: unknown = source.value
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
const isPathPlaceholder = typeof display === "string" && /^path:/.test(display)
if (isPastedPlaceholder || typeof value !== "string") {
// Skip path: attachments from being sent as separate parts (content is already in prompt)
// Skip pasted placeholders too (already resolved in prompt)
if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") {
continue
}

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

@@ -1,6 +1,7 @@
import type {
MessageInfo,
MessagePartRemovedEvent,
MessagePartDeltaEvent,
MessagePartUpdatedEvent,
MessageRemovedEvent,
MessageUpdateEvent,
@@ -48,6 +49,7 @@ import { loadMessages } from "./session-api"
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
import {
applyPartUpdateV2,
applyPartDeltaV2,
replaceMessageIdV2,
reconcilePendingQuestionsV2,
upsertMessageInfoV2,
@@ -298,10 +300,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
const messageId = typeof info.id === "string" ? info.id : undefined
if (!sessionId || !messageId) return
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number }
const nextUpdated =
typeof timeInfo.completed === "number" && timeInfo.completed > 0
? timeInfo.completed
typeof timeInfo.end === "number" && timeInfo.end > 0
? timeInfo.end
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
? timeInfo.updated
: typeof timeInfo.created === "number" && timeInfo.created > 0
@@ -331,14 +333,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
if (!record) {
const createdAt = info.time?.created ?? Date.now()
const completedAt = (info.time as { completed?: number } | undefined)?.completed
const endAt = (info.time as { end?: number } | undefined)?.end
store.upsertMessage({
id: messageId,
sessionId,
role,
status,
createdAt,
updatedAt: completedAt ?? createdAt,
updatedAt: endAt ?? createdAt,
})
}
@@ -348,6 +350,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
}
}
function handleMessagePartDelta(instanceId: string, event: MessagePartDeltaEvent): void {
const props = event.properties
if (!props) return
const { messageID, partID, field, delta } = props
if (!messageID || !partID || !field || typeof delta !== "string") return
applyPartDeltaV2(instanceId, { messageId: messageID, partId: partID, field, delta })
}
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
const info = event.properties?.info
@@ -625,6 +635,7 @@ function handleQuestionAnswered(
export {
handleMessagePartRemoved,
handleMessageRemoved,
handleMessagePartDelta,
handleMessageUpdate,
handlePermissionReplied,
handlePermissionUpdated,

View File

@@ -1,5 +1,5 @@
import { agents, providers } from "./session-state"
import { preferences, getAgentModelPreference } from "./preferences"
import { uiState, getAgentModelPreference } from "./preferences"
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
@@ -17,7 +17,7 @@ function isModelValid(
function getRecentModelPreferenceForInstance(
instanceId: string,
): { providerId: string; modelId: string } | undefined {
const recents = preferences().modelRecents ?? []
const recents = uiState().models.recents ?? []
for (const item of recents) {
if (isModelValid(instanceId, item)) {
return item

View File

@@ -58,6 +58,7 @@ import {
import {
handleMessagePartRemoved,
handleMessageRemoved,
handleMessagePartDelta,
handleMessageUpdate,
handlePermissionReplied,
handlePermissionUpdated,
@@ -74,6 +75,7 @@ import {
sseManager.onMessageUpdate = handleMessageUpdate
sseManager.onMessagePartUpdated = handleMessageUpdate
sseManager.onMessagePartDelta = handleMessagePartDelta
sseManager.onMessageRemoved = handleMessageRemoved
sseManager.onMessagePartRemoved = handleMessagePartRemoved
sseManager.onSessionUpdate = handleSessionUpdate

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

@@ -46,6 +46,11 @@
color: var(--text-primary);
}
.modal-item:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
background-color: var(--surface-hover);
}

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

@@ -153,6 +153,19 @@
@apply opacity-50;
}
/*
Shortcut hints are useful on desktop native apps, but are noisy/irrelevant on
touch-first devices and in WebUI where browser shortcuts often conflict.
*/
html[data-runtime-host="web"] .keyboard-hints,
html[data-runtime-host="web"] .kbd-hint,
html[data-runtime-platform="mobile"] .keyboard-hints,
html[data-runtime-platform="mobile"] .kbd-hint,
html[data-keyboard-hints="hide"] .keyboard-hints,
html[data-keyboard-hints="hide"] .kbd-hint {
display: none !important;
}
/* Truncate from the start (keeps end visible; good for paths) */
.truncate-start {
overflow: hidden;

View File

@@ -20,6 +20,19 @@ export type {
SDKMessage
}
// Server streaming event: append-only delta updates.
// Emitted over SSE by newer OpenCode builds.
export interface MessagePartDeltaEvent {
type: "message.part.delta"
properties: {
sessionID: string
messageID: string
partID: string
field: string
delta: string
}
}
export interface RenderCache {
text: string
html: string

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"
}
}