Compare commits
27 Commits
codenomad/
...
v0.11.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e16c5752ed | ||
|
|
375f92410e | ||
|
|
53f1dd4150 | ||
|
|
b7f638f07d | ||
|
|
32113ea100 | ||
|
|
b31135f622 | ||
|
|
eb6701185b | ||
|
|
d948ad8e35 | ||
|
|
f58267dd30 | ||
|
|
95c747923c | ||
|
|
f3b9ee4e04 | ||
|
|
309a123c1f | ||
|
|
761e3d4268 | ||
|
|
265d497ef4 | ||
|
|
56a052086f | ||
|
|
9a4d205d97 | ||
|
|
ff71302969 | ||
|
|
4f6c8523c0 | ||
|
|
8c24a7daf3 | ||
|
|
682937e945 | ||
|
|
35ff359c0f | ||
|
|
5067db3dd0 | ||
|
|
c7195469bd | ||
|
|
1ef01da019 | ||
|
|
edd3ded1d8 | ||
|
|
e30ff6358d | ||
|
|
e9f281a69d |
19
README.md
19
README.md
@@ -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.
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
@@ -11985,7 +11985,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
@@ -12021,7 +12021,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"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.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
@@ -12070,7 +12070,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.10.3",
|
||||
"minServerVersion": "0.11.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.53"
|
||||
"@opencode-ai/plugin": "1.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"description": "CodeNomad Server",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
110
packages/server/src/server/routes/settings.ts
Normal file
110
packages/server/src/server/routes/settings.ts
Normal 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" }
|
||||
}
|
||||
})
|
||||
}
|
||||
55
packages/server/src/settings/binaries.ts
Normal file
55
packages/server/src/settings/binaries.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
39
packages/server/src/settings/merge-patch.ts
Normal file
39
packages/server/src/settings/merge-patch.ts
Normal 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
|
||||
}
|
||||
269
packages/server/src/settings/migrate.ts
Normal file
269
packages/server/src/settings/migrate.ts
Normal 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")
|
||||
}
|
||||
55
packages/server/src/settings/service.ts
Normal file
55
packages/server/src/settings/service.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
110
packages/server/src/settings/yaml-doc-store.ts
Normal file
110
packages/server/src/settings/yaml-doc-store.ts
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.10.3",
|
||||
"version": "0.11.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -58,8 +58,10 @@ const App: Component = () => {
|
||||
const { t } = useI18n()
|
||||
const {
|
||||
preferences,
|
||||
serverSettings,
|
||||
recordWorkspaceLaunch,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleUsageMetrics,
|
||||
@@ -80,6 +82,13 @@ const App: Component = () => {
|
||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||
|
||||
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")
|
||||
@@ -177,7 +186,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 +302,7 @@ const App: Component = () => {
|
||||
preferences,
|
||||
toggleAutoCleanupBlankSessions,
|
||||
toggleShowThinkingBlocks,
|
||||
toggleKeyboardShortcutHints,
|
||||
toggleShowTimelineTools,
|
||||
toggleUsageMetrics,
|
||||
togglePromptSubmitOnEnter,
|
||||
@@ -451,25 +461,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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("")
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -633,7 +633,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>
|
||||
@@ -730,7 +730,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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -62,7 +62,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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -480,7 +480,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={
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -33,7 +33,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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "表示",
|
||||
|
||||
@@ -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": "Видимо",
|
||||
|
||||
@@ -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": "可见",
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user