Compare commits
5 Commits
v0.10.3-de
...
codenomad/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d1f702597 | ||
|
|
2d93d82611 | ||
|
|
4e0f064c3a | ||
|
|
e4e10cc630 | ||
|
|
8f6d4c8b09 |
19
README.md
19
README.md
@@ -44,22 +44,19 @@ Run CodeNomad as a local server and access it via your web browser. Perfect for
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
Full server/CLI documentation (flags + env vars, TLS, auth, remote access):
|
For dev version
|
||||||
- [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
|
```bash
|
||||||
npx @neuralnomads/codenomad-dev --launch
|
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
|
## Highlights
|
||||||
|
|
||||||
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
- **Multi-Instance**: Juggle several OpenCode sessions side-by-side with tabs.
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function readListeningModeFromConfig(): ListeningMode {
|
|||||||
return "local"
|
return "local"
|
||||||
}
|
}
|
||||||
|
|
||||||
const mode = parsed?.server?.listeningMode ?? parsed?.preferences?.listeningMode
|
const mode = parsed?.preferences?.listeningMode
|
||||||
if (mode === "local" || mode === "all") {
|
if (mode === "local" || mode === "all") {
|
||||||
return mode
|
return mode
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,6 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/plugin": "1.2.4"
|
"@opencode-ai/plugin": "1.1.53"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,12 +31,6 @@ You can run CodeNomad directly without installing it:
|
|||||||
npx @neuralnomads/codenomad --launch
|
npx @neuralnomads/codenomad --launch
|
||||||
```
|
```
|
||||||
|
|
||||||
To list all CLI options:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npx @neuralnomads/codenomad --help
|
|
||||||
```
|
|
||||||
|
|
||||||
On startup, CodeNomad prints two URLs:
|
On startup, CodeNomad prints two URLs:
|
||||||
|
|
||||||
- `Local Connection URL : ...` (used by desktop shells)
|
- `Local Connection URL : ...` (used by desktop shells)
|
||||||
@@ -50,16 +44,6 @@ npm install -g @neuralnomads/codenomad
|
|||||||
codenomad --launch
|
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
|
### Common Flags
|
||||||
You can configure the server using flags or environment variables:
|
You can configure the server using flags or environment variables:
|
||||||
|
|
||||||
@@ -79,30 +63,10 @@ You can configure the server using flags or environment variables:
|
|||||||
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
| `--config <path>` | `CLI_CONFIG` | Config file location |
|
||||||
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
|
| `--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-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`) |
|
| `--username <username>` | `CODENOMAD_SERVER_USERNAME` | Username for CodeNomad's internal auth (default `codenomad`) |
|
||||||
| `--password <password>` | `CODENOMAD_SERVER_PASSWORD` | Password for CodeNomad's internal auth |
|
| `--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 |
|
| `--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) |
|
| `--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
|
### HTTP vs HTTPS
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentModelSelection,
|
AgentModelSelection,
|
||||||
AgentModelSelections,
|
AgentModelSelections,
|
||||||
|
ConfigFile,
|
||||||
ModelPreference,
|
ModelPreference,
|
||||||
OpenCodeBinary,
|
OpenCodeBinary,
|
||||||
Preferences,
|
Preferences,
|
||||||
@@ -182,9 +183,9 @@ export interface BinaryRecord {
|
|||||||
validationError?: string
|
validationError?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SettingsOwner = string
|
export type AppConfig = ConfigFile
|
||||||
export type SettingsBucket = Record<string, unknown>
|
export type AppConfigResponse = AppConfig
|
||||||
export type SettingsDoc = Record<string, unknown>
|
export type AppConfigUpdateRequest = Partial<AppConfig>
|
||||||
|
|
||||||
export interface BinaryListResponse {
|
export interface BinaryListResponse {
|
||||||
binaries: BinaryRecord[]
|
binaries: BinaryRecord[]
|
||||||
@@ -213,8 +214,8 @@ export type WorkspaceEventType =
|
|||||||
| "workspace.error"
|
| "workspace.error"
|
||||||
| "workspace.stopped"
|
| "workspace.stopped"
|
||||||
| "workspace.log"
|
| "workspace.log"
|
||||||
| "storage.configChanged"
|
| "config.appChanged"
|
||||||
| "storage.stateChanged"
|
| "config.binariesChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
| "instance.event"
|
| "instance.event"
|
||||||
| "instance.eventStatus"
|
| "instance.eventStatus"
|
||||||
@@ -225,8 +226,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.error"; workspace: WorkspaceDescriptor }
|
||||||
| { type: "workspace.stopped"; workspaceId: string }
|
| { type: "workspace.stopped"; workspaceId: string }
|
||||||
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
| { type: "workspace.log"; entry: WorkspaceLogEntry }
|
||||||
| { type: "storage.configChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "config.appChanged"; config: AppConfig }
|
||||||
| { type: "storage.stateChanged"; owner: SettingsOwner; value: SettingsBucket }
|
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
|
|||||||
192
packages/server/src/config/binaries.ts
Normal file
192
packages/server/src/config/binaries.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
244
packages/server/src/config/store.ts
Normal file
244
packages/server/src/config/store.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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.error", handler)
|
||||||
this.on("workspace.stopped", handler)
|
this.on("workspace.stopped", handler)
|
||||||
this.on("workspace.log", handler)
|
this.on("workspace.log", handler)
|
||||||
this.on("storage.configChanged", handler)
|
this.on("config.appChanged", handler)
|
||||||
this.on("storage.stateChanged", handler)
|
this.on("config.binariesChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
this.on("instance.event", handler)
|
this.on("instance.event", handler)
|
||||||
this.on("instance.eventStatus", handler)
|
this.on("instance.eventStatus", handler)
|
||||||
@@ -35,8 +35,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("workspace.error", handler)
|
this.off("workspace.error", handler)
|
||||||
this.off("workspace.stopped", handler)
|
this.off("workspace.stopped", handler)
|
||||||
this.off("workspace.log", handler)
|
this.off("workspace.log", handler)
|
||||||
this.off("storage.configChanged", handler)
|
this.off("config.appChanged", handler)
|
||||||
this.off("storage.stateChanged", handler)
|
this.off("config.binariesChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
this.off("instance.event", handler)
|
this.off("instance.event", handler)
|
||||||
this.off("instance.eventStatus", handler)
|
this.off("instance.eventStatus", handler)
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import { fileURLToPath } from "url"
|
|||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { createHttpServer } from "./server/http-server"
|
import { createHttpServer } from "./server/http-server"
|
||||||
import { WorkspaceManager } from "./workspaces/manager"
|
import { WorkspaceManager } from "./workspaces/manager"
|
||||||
|
import { ConfigStore } from "./config/store"
|
||||||
import { resolveConfigLocation } from "./config/location"
|
import { resolveConfigLocation } from "./config/location"
|
||||||
import { SettingsService } from "./settings/service"
|
import { BinaryRegistry } from "./config/binaries"
|
||||||
import { BinaryResolver } from "./settings/binaries"
|
|
||||||
import { FileSystemBrowser } from "./filesystem/browser"
|
import { FileSystemBrowser } from "./filesystem/browser"
|
||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
@@ -291,12 +291,21 @@ async function main() {
|
|||||||
|
|
||||||
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
const nodeExtraCaCertsPath = !options.http ? tlsResolution?.caCertPath : undefined
|
||||||
|
|
||||||
const settings = new SettingsService(configLocation, eventBus, configLogger)
|
const configStore = new ConfigStore(configLocation, eventBus, configLogger)
|
||||||
const binaryResolver = new BinaryResolver(settings)
|
|
||||||
|
// 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 workspaceManager = new WorkspaceManager({
|
const workspaceManager = new WorkspaceManager({
|
||||||
rootDir: options.rootDir,
|
rootDir: options.rootDir,
|
||||||
settings,
|
configStore,
|
||||||
binaryResolver,
|
binaryRegistry,
|
||||||
eventBus,
|
eventBus,
|
||||||
logger: workspaceLogger,
|
logger: workspaceLogger,
|
||||||
getServerBaseUrl: () => serverMeta.localUrl,
|
getServerBaseUrl: () => serverMeta.localUrl,
|
||||||
@@ -383,7 +392,8 @@ async function main() {
|
|||||||
defaultPort: options.httpPort,
|
defaultPort: options.httpPort,
|
||||||
protocol: "http",
|
protocol: "http",
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
settings,
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
@@ -403,7 +413,8 @@ async function main() {
|
|||||||
protocol: "https",
|
protocol: "https",
|
||||||
httpsOptions: tlsResolution?.httpsOptions,
|
httpsOptions: tlsResolution?.httpsOptions,
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
settings,
|
configStore,
|
||||||
|
binaryRegistry,
|
||||||
fileSystemBrowser,
|
fileSystemBrowser,
|
||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import type { Logger } from "../logger"
|
|||||||
import { WorkspaceManager } from "../workspaces/manager"
|
import { WorkspaceManager } from "../workspaces/manager"
|
||||||
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
import { isValidWorktreeSlug, listWorktrees, resolveRepoRoot } from "../workspaces/git-worktrees"
|
||||||
|
|
||||||
import type { SettingsService } from "../settings/service"
|
import { ConfigStore } from "../config/store"
|
||||||
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
import { registerWorkspaceRoutes } from "./routes/workspaces"
|
||||||
import { registerSettingsRoutes } from "./routes/settings"
|
import { registerConfigRoutes } from "./routes/config"
|
||||||
import { registerFilesystemRoutes } from "./routes/filesystem"
|
import { registerFilesystemRoutes } from "./routes/filesystem"
|
||||||
import { registerMetaRoutes } from "./routes/meta"
|
import { registerMetaRoutes } from "./routes/meta"
|
||||||
import { registerEventRoutes } from "./routes/events"
|
import { registerEventRoutes } from "./routes/events"
|
||||||
@@ -36,7 +37,8 @@ interface HttpServerDeps {
|
|||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
workspaceManager: WorkspaceManager
|
workspaceManager: WorkspaceManager
|
||||||
settings: SettingsService
|
configStore: ConfigStore
|
||||||
|
binaryRegistry: BinaryRegistry
|
||||||
fileSystemBrowser: FileSystemBrowser
|
fileSystemBrowser: FileSystemBrowser
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -242,7 +244,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
registerSettingsRoutes(app, { settings: deps.settings, logger: apiLogger })
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry })
|
||||||
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser })
|
||||||
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
registerMetaRoutes(app, { serverMeta: deps.serverMeta })
|
||||||
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
registerEventRoutes(app, { eventBus: deps.eventBus, registerClient: registerSseClient, logger: sseLogger })
|
||||||
|
|||||||
76
packages/server/src/server/routes/config.ts
Normal file
76
packages/server/src/server/routes/config.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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" }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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 { spawnSync } from "child_process"
|
||||||
import { connect } from "net"
|
import { connect } from "net"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import type { SettingsService } from "../settings/service"
|
import { ConfigStore } from "../config/store"
|
||||||
import type { BinaryResolver } from "../settings/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
import { FileSystemBrowser } from "../filesystem/browser"
|
import { FileSystemBrowser } from "../filesystem/browser"
|
||||||
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
import { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
||||||
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
||||||
@@ -23,8 +23,8 @@ const STARTUP_STABILITY_DELAY_MS = 1500
|
|||||||
|
|
||||||
interface WorkspaceManagerOptions {
|
interface WorkspaceManagerOptions {
|
||||||
rootDir: string
|
rootDir: string
|
||||||
settings: SettingsService
|
configStore: ConfigStore
|
||||||
binaryResolver: BinaryResolver
|
binaryRegistry: BinaryRegistry
|
||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
logger: Logger
|
logger: Logger
|
||||||
getServerBaseUrl: () => string
|
getServerBaseUrl: () => string
|
||||||
@@ -86,7 +86,7 @@ export class WorkspaceManager {
|
|||||||
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
|
||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryResolver.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
@@ -118,9 +118,8 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const serverConfig = this.options.settings.getOwner("config", "server")
|
const preferences = this.options.configStore.get().preferences ?? {}
|
||||||
const envVars = (serverConfig as any)?.environmentVariables
|
const userEnvironment = preferences.environmentVariables ?? {}
|
||||||
const userEnvironment = envVars && typeof envVars === "object" && !Array.isArray(envVars) ? (envVars as any) : {}
|
|
||||||
|
|
||||||
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
const opencodeUsername = DEFAULT_OPENCODE_USERNAME
|
||||||
const opencodePassword = generateOpencodeServerPassword()
|
const opencodePassword = generateOpencodeServerPassword()
|
||||||
|
|||||||
@@ -140,16 +140,9 @@ struct PreferencesConfig {
|
|||||||
listening_mode: Option<String>,
|
listening_mode: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct ServerConfig {
|
|
||||||
#[serde(rename = "listeningMode")]
|
|
||||||
listening_mode: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct AppConfig {
|
struct AppConfig {
|
||||||
preferences: Option<PreferencesConfig>,
|
preferences: Option<PreferencesConfig>,
|
||||||
server: Option<ServerConfig>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
fn resolve_config_locations() -> (PathBuf, PathBuf) {
|
||||||
@@ -195,18 +188,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
|
|
||||||
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
if let Ok(content) = fs::read_to_string(&yaml_path) {
|
||||||
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_yaml::from_str::<AppConfig>(&content) {
|
||||||
let mode = config
|
if let Some(mode) = config
|
||||||
.server
|
.preferences
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|srv| srv.listening_mode.as_ref())
|
.and_then(|prefs| prefs.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" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
@@ -220,17 +206,11 @@ fn resolve_listening_mode() -> String {
|
|||||||
// Legacy fallback.
|
// Legacy fallback.
|
||||||
if let Ok(content) = fs::read_to_string(&json_path) {
|
if let Ok(content) = fs::read_to_string(&json_path) {
|
||||||
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
if let Ok(config) = serde_json::from_str::<AppConfig>(&content) {
|
||||||
let mode = config
|
if let Some(mode) = config
|
||||||
.server
|
.preferences
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|srv| srv.listening_mode.as_ref())
|
.and_then(|prefs| prefs.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" {
|
if mode == "local" {
|
||||||
return "local".to_string();
|
return "local".to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,10 +58,8 @@ const App: Component = () => {
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
preferences,
|
preferences,
|
||||||
serverSettings,
|
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
@@ -82,13 +80,6 @@ const App: Component = () => {
|
|||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
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 = () => {
|
const updateInstanceTabBarHeight = () => {
|
||||||
if (typeof document === "undefined") return
|
if (typeof document === "undefined") return
|
||||||
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
const element = document.querySelector<HTMLElement>(".tab-bar-instance")
|
||||||
@@ -186,7 +177,7 @@ const App: Component = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSelectingFolder(true)
|
setIsSelectingFolder(true)
|
||||||
const selectedBinary = binaryPath || serverSettings().opencodeBinary || "opencode"
|
const selectedBinary = binaryPath || preferences().lastUsedBinary || "opencode"
|
||||||
try {
|
try {
|
||||||
recordWorkspaceLaunch(folderPath, selectedBinary)
|
recordWorkspaceLaunch(folderPath, selectedBinary)
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
@@ -302,7 +293,6 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
toggleKeyboardShortcutHints,
|
|
||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
@@ -461,17 +451,25 @@ const App: Component = () => {
|
|||||||
<Show when={showFolderSelection()}>
|
<Show when={showFolderSelection()}>
|
||||||
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
|
||||||
<div class="w-full h-full relative">
|
<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
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
||||||
onClose={() => {
|
|
||||||
setShowFolderSelection(false)
|
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,10 +112,6 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const groupedCommandList = () => processedCommands().groups
|
const groupedCommandList = () => processedCommands().groups
|
||||||
const orderedCommands = () => processedCommands().ordered
|
const orderedCommands = () => processedCommands().ordered
|
||||||
|
|
||||||
const isCommandDisabled = (command: Command) => {
|
|
||||||
return command.disabled ? Boolean(resolveResolvable(command.disabled)) : false
|
|
||||||
}
|
|
||||||
const selectedIndex = createMemo(() => {
|
const selectedIndex = createMemo(() => {
|
||||||
const ordered = orderedCommands()
|
const ordered = orderedCommands()
|
||||||
if (ordered.length === 0) return -1
|
if (ordered.length === 0) return -1
|
||||||
@@ -145,8 +141,7 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
|
|
||||||
const currentId = selectedCommandId()
|
const currentId = selectedCommandId()
|
||||||
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
if (!currentId || !ordered.some((cmd) => cmd.id === currentId)) {
|
||||||
const firstEnabled = ordered.find((cmd) => !isCommandDisabled(cmd))
|
setSelectedCommandId(ordered[0].id)
|
||||||
setSelectedCommandId((firstEnabled || ordered[0])?.id ?? null)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -200,14 +195,12 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
if (index < 0 || index >= ordered.length) return
|
if (index < 0 || index >= ordered.length) return
|
||||||
const command = ordered[index]
|
const command = ordered[index]
|
||||||
if (!command) return
|
if (!command) return
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCommandClick(command: Command) {
|
function handleCommandClick(command: Command) {
|
||||||
if (isCommandDisabled(command)) return
|
|
||||||
props.onExecute(command)
|
props.onExecute(command)
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
@@ -272,13 +265,11 @@ const CommandPalette: Component<CommandPaletteProps> = (props) => {
|
|||||||
<For each={group.commands}>
|
<For each={group.commands}>
|
||||||
{(command, localIndex) => {
|
{(command, localIndex) => {
|
||||||
const commandIndex = group.startIndex + localIndex()
|
const commandIndex = group.startIndex + localIndex()
|
||||||
const disabled = isCommandDisabled(command)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-command-index={commandIndex}
|
data-command-index={commandIndex}
|
||||||
onClick={() => handleCommandClick(command)}
|
onClick={() => handleCommandClick(command)}
|
||||||
disabled={disabled}
|
|
||||||
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
class={`modal-item ${selectedCommandId() === command.id ? "modal-item-highlight" : ""}`}
|
||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (event.movementX === 0 && event.movementY === 0) return
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ interface EnvironmentVariablesEditorProps {
|
|||||||
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
const EnvironmentVariablesEditor: Component<EnvironmentVariablesEditorProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const {
|
const {
|
||||||
serverSettings,
|
preferences,
|
||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateEnvironmentVariables,
|
updateEnvironmentVariables,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [envVars, setEnvVars] = createSignal<Record<string, string>>(serverSettings().environmentVariables || {})
|
const [envVars, setEnvVars] = createSignal<Record<string, string>>(preferences().environmentVariables || {})
|
||||||
const [newKey, setNewKey] = createSignal("")
|
const [newKey, setNewKey] = createSignal("")
|
||||||
const [newValue, setNewValue] = createSignal("")
|
const [newValue, setNewValue] = createSignal("")
|
||||||
|
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer keyboard-hints">
|
<div class="panel-footer">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Select } from "@kobalte/core/select"
|
import { Select } from "@kobalte/core/select"
|
||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
@@ -23,15 +23,14 @@ interface FolderSelectionViewProps {
|
|||||||
onAdvancedSettingsOpen?: () => void
|
onAdvancedSettingsOpen?: () => void
|
||||||
onAdvancedSettingsClose?: () => void
|
onAdvancedSettingsClose?: () => void
|
||||||
onOpenRemoteAccess?: () => void
|
onOpenRemoteAccess?: () => void
|
||||||
onClose?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences } = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
@@ -54,7 +53,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = serverSettings().opencodeBinary
|
const lastUsed = preferences().lastUsedBinary
|
||||||
if (!lastUsed) return
|
if (!lastUsed) return
|
||||||
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
})
|
})
|
||||||
@@ -374,18 +373,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
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>
|
</button>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +548,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
: t("folderSelection.browse.button")}
|
: t("folderSelection.browse.button")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut="cmd+n" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -585,7 +573,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel panel-footer shrink-0 hidden sm:block keyboard-hints">
|
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -603,7 +591,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
<Kbd shortcut="cmd+n" />
|
||||||
<span>{t("folderSelection.hints.browse")}</span>
|
<span>{t("folderSelection.hints.browse")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface HintRowProps {
|
|||||||
|
|
||||||
const HintRow: Component<HintRowProps> = (props) => {
|
const HintRow: Component<HintRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<span aria-hidden={props.ariaHidden} class={`keyboard-hints text-xs text-muted ${props.class || ""}`}>
|
<span aria-hidden={props.ariaHidden} class={`text-xs text-muted ${props.class || ""}`}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -502,7 +502,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
<span>{t("instanceWelcome.new.createButton")}</span>
|
<span>{t("instanceWelcome.new.createButton")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Kbd shortcut={newSessionShortcutString()} class="ml-2 kbd-hint" />
|
<Kbd shortcut={newSessionShortcutString()} class="ml-2" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +539,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel-footer hidden sm:block keyboard-hints">
|
<div class="panel-footer hidden sm:block">
|
||||||
|
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -633,7 +633,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
>
|
>
|
||||||
{t("instanceShell.commandPalette.button")}
|
{t("instanceShell.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,7 +730,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
<div class="session-toolbar-right flex-1 flex items-center gap-3">
|
||||||
<span class="connection-status-shortcut-hint kbd-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -198,16 +198,6 @@ interface MessageContentItemProps {
|
|||||||
onContentRendered?: () => void
|
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) {
|
function MessageContentItem(props: MessageContentItemProps) {
|
||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
@@ -232,9 +222,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (!isSupportedPartType(part)) continue
|
if (
|
||||||
|
part.type === "tool" ||
|
||||||
if (!isContentPartType((part as any).type)) break
|
part.type === "reasoning" ||
|
||||||
|
part.type === "compaction" ||
|
||||||
|
part.type === "step-start" ||
|
||||||
|
part.type === "step-finish"
|
||||||
|
) {
|
||||||
|
break
|
||||||
|
}
|
||||||
resolved.push(part)
|
resolved.push(part)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,9 +256,15 @@ function MessageContentItem(props: MessageContentItemProps) {
|
|||||||
const partId = ids[idx]
|
const partId = ids[idx]
|
||||||
const part = current.parts[partId]?.data
|
const part = current.parts[partId]?.data
|
||||||
if (!part) continue
|
if (!part) continue
|
||||||
if (!isSupportedPartType(part)) continue
|
if (
|
||||||
|
part.type === "tool" ||
|
||||||
if (!isContentPartType((part as any).type)) continue
|
part.type === "reasoning" ||
|
||||||
|
part.type === "compaction" ||
|
||||||
|
part.type === "step-start" ||
|
||||||
|
part.type === "step-finish"
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (partHasRenderableText(part)) {
|
if (partHasRenderableText(part)) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -547,9 +549,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
orderedParts.forEach((part, partIndex) => {
|
orderedParts.forEach((part, partIndex) => {
|
||||||
if (!isSupportedPartType(part)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
flushContent()
|
flushContent()
|
||||||
const partId = part.id
|
const partId = part.id
|
||||||
|
|||||||
@@ -162,8 +162,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
const timeInfo = info?.time as { created: number; end?: number } | undefined
|
return Boolean(info && info.role === "assistant" && info.time.completed !== undefined && info.time.completed === 0)
|
||||||
return Boolean(info && info.role === "assistant" && (timeInfo?.end === undefined || timeInfo?.end === 0))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRevert = () => {
|
const handleRevert = () => {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
|
|||||||
{t("messageListHeader.commandPalette.button")}
|
{t("messageListHeader.commandPalette.button")}
|
||||||
</button>
|
</button>
|
||||||
<span class="connection-status-shortcut-hint">
|
<span class="connection-status-shortcut-hint">
|
||||||
<Kbd shortcut="cmd+shift+p" class="kbd-hint" />
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -867,7 +867,7 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
<span>{t("messageSection.empty.tips.commandPalette")}</span>
|
||||||
<Kbd shortcut="cmd+shift+p" class="ml-2 kbd-hint" />
|
<Kbd shortcut="cmd+shift+p" class="ml-2" />
|
||||||
</li>
|
</li>
|
||||||
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
<li>{t("messageSection.empty.tips.askAboutCodebase")}</li>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ChevronDown, Star } from "lucide-solid"
|
|||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { uiState, toggleFavoriteModelPreference } from "../stores/preferences"
|
import { preferences, toggleFavoriteModelPreference } from "../stores/preferences"
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
|
|
||||||
const favoriteKeySet = createMemo(() => {
|
const favoriteKeySet = createMemo(() => {
|
||||||
const result = new Set<string>()
|
const result = new Set<string>()
|
||||||
for (const item of uiState().models.favorites ?? []) {
|
for (const item of preferences().modelFavorites ?? []) {
|
||||||
if (item.providerId && item.modelId) {
|
if (item.providerId && item.modelId) {
|
||||||
result.add(`${item.providerId}/${item.modelId}`)
|
result.add(`${item.providerId}/${item.modelId}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
removeOpenCodeBinary,
|
removeOpenCodeBinary,
|
||||||
serverSettings,
|
preferences,
|
||||||
updateLastUsedBinary,
|
updatePreferences,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [customPath, setCustomPath] = createSignal("")
|
const [customPath, setCustomPath] = createSignal("")
|
||||||
const [validating, setValidating] = createSignal(false)
|
const [validating, setValidating] = createSignal(false)
|
||||||
@@ -42,7 +42,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
const lastUsedBinary = () => serverSettings().opencodeBinary
|
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||||
|
|
||||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (validation.valid) {
|
if (validation.valid) {
|
||||||
addOpenCodeBinary(path, validation.version)
|
addOpenCodeBinary(path, validation.version)
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updateLastUsedBinary(path)
|
updatePreferences({ lastUsedBinary: path })
|
||||||
setCustomPath("")
|
setCustomPath("")
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
} else {
|
} else {
|
||||||
@@ -183,7 +183,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (path === props.selectedBinary) return
|
if (path === props.selectedBinary) return
|
||||||
props.onBinaryChange(path)
|
props.onBinaryChange(path)
|
||||||
updateLastUsedBinary(path)
|
updatePreferences({ lastUsedBinary: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveBinary(path: string, event: Event) {
|
function handleRemoveBinary(path: string, event: Event) {
|
||||||
@@ -193,7 +193,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
if (props.selectedBinary === path) {
|
if (props.selectedBinary === path) {
|
||||||
props.onBinaryChange("opencode")
|
props.onBinaryChange("opencode")
|
||||||
updateLastUsedBinary("opencode")
|
updatePreferences({ lastUsedBinary: "opencode" })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -480,7 +480,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={shouldShowOverlay()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
when={props.escapeInDebounce}
|
when={props.escapeInDebounce}
|
||||||
fallback={
|
fallback={
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createSignal, type Accessor, type Setter } from "solid-js"
|
import { createSignal, type Accessor, type Setter } from "solid-js"
|
||||||
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
import type { Command as SDKCommand } from "@opencode-ai/sdk/v2"
|
||||||
import type { Agent } from "../../types/session"
|
import type { Agent } from "../../types/session"
|
||||||
import { createAgentAttachment, createFileAttachment, createTextAttachment } from "../../types/attachment"
|
import { createAgentAttachment, createFileAttachment } from "../../types/attachment"
|
||||||
import { addAttachment, getAttachments } from "../../stores/attachments"
|
import { addAttachment, getAttachments, removeAttachment } from "../../stores/attachments"
|
||||||
import type { PickerMode } from "./types"
|
import type { PickerMode } from "./types"
|
||||||
import type { PickerSelectAction } from "../unified-picker"
|
import type { PickerSelectAction } from "../unified-picker"
|
||||||
|
|
||||||
@@ -204,30 +204,15 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
const folderMention =
|
const folderMention =
|
||||||
relativePath === "." || relativePath === "" || relativePath === "./"
|
relativePath === "." || relativePath === ""
|
||||||
? "./"
|
? "/"
|
||||||
: (relativePath.startsWith("./") ? relativePath.replace(/\/+$/, "") + "/" : relativePath.replace(/^\.\//, "").replace(/\/+$/, "") + "/")
|
: relativePath.replace(/\/+$/, "") + "/"
|
||||||
|
|
||||||
const normalizedFolderPath = (() => {
|
const normalizedFolderPath = (() => {
|
||||||
const trimmed = relativePath.replace(/\/+$/, "")
|
const trimmed = relativePath.replace(/\/+$/, "")
|
||||||
// If it's root "./", just return "./"
|
return trimmed.length > 0 ? trimmed : "."
|
||||||
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 (isFolder) {
|
||||||
if (action === "tab") {
|
if (action === "tab") {
|
||||||
// TAB on directory: autocomplete directory name and show its contents.
|
// TAB on directory: autocomplete directory name and show its contents.
|
||||||
@@ -239,14 +224,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
const mentionText = `@${folderMention}`
|
const mentionText = `@${folderMention}`
|
||||||
|
|
||||||
if (action === "shiftEnter") {
|
if (action === "shiftEnter") {
|
||||||
// SHIFT+ENTER on directory: keep @path in prompt, add text attachment, remove @ when sending
|
// SHIFT+ENTER on directory: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||||
// Always prefix with ./ for consistency
|
|
||||||
const normalizedFolderPathWithPrefix = normalizedFolderPath.startsWith("./") ? normalizedFolderPath : "./" + normalizedFolderPath
|
|
||||||
addPathOnlyAttachment(normalizedFolderPathWithPrefix)
|
|
||||||
replaceMentionToken(mentionText, { trailingSpace: true })
|
replaceMentionToken(mentionText, { trailingSpace: true })
|
||||||
} else {
|
} else {
|
||||||
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
// ENTER/click on directory: attach as a file part pointing at a file:// directory URL.
|
||||||
const dirLabel = normalizedFolderPath === "./" ? "./" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
const dirLabel =
|
||||||
|
normalizedFolderPath === "." ? "/" : normalizedFolderPath.split("/").pop() || normalizedFolderPath
|
||||||
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
const dirFilename = dirLabel.endsWith("/") ? dirLabel : `${dirLabel}/`
|
||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
@@ -255,6 +238,20 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
if (!alreadyAttached) {
|
||||||
|
// Remove any parent/child directory attachments that overlap with this one
|
||||||
|
// (e.g., if "docs/" is attached and user selects "docs/screenshots/", replace parent with child)
|
||||||
|
for (const att of existingAttachments) {
|
||||||
|
if (
|
||||||
|
att.source.type === "file" &&
|
||||||
|
att.source.mime === "inode/directory" &&
|
||||||
|
(normalizedFolderPath.startsWith(att.source.path + "/") || // new is child of existing
|
||||||
|
att.source.path.startsWith(normalizedFolderPath + "/")) // new is parent of existing
|
||||||
|
) {
|
||||||
|
// Remove the overlapping directory attachment
|
||||||
|
removeAttachment(options.instanceId(), options.sessionId(), att.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
normalizedFolderPath,
|
normalizedFolderPath,
|
||||||
dirFilename,
|
dirFilename,
|
||||||
@@ -278,15 +275,10 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "shiftEnter") {
|
if (action === "shiftEnter") {
|
||||||
// SHIFT+ENTER on file: keep @path in prompt, add text attachment, remove @ when sending
|
// SHIFT+ENTER on file: keep @path in prompt (BACKSPACE works), remove @ when sending
|
||||||
// Always prefix with ./ for consistency
|
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
|
||||||
addPathOnlyAttachment(normalizedPathWithPrefix)
|
|
||||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
|
||||||
} else {
|
} else {
|
||||||
// ENTER/click on file: attach file (existing behavior).
|
// ENTER/click on file: attach file (existing behavior).
|
||||||
// Always prefix with ./ for consistency
|
|
||||||
const normalizedPathWithPrefix = normalizedPath.startsWith("./") ? normalizedPath : "./" + normalizedPath
|
|
||||||
const pathSegments = normalizedPath.split("/")
|
const pathSegments = normalizedPath.split("/")
|
||||||
const filename = (() => {
|
const filename = (() => {
|
||||||
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
const candidate = pathSegments[pathSegments.length - 1] || normalizedPath
|
||||||
@@ -295,12 +287,12 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
|
|
||||||
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
const existingAttachments = getAttachments(options.instanceId(), options.sessionId())
|
||||||
const alreadyAttached = existingAttachments.some(
|
const alreadyAttached = existingAttachments.some(
|
||||||
(att) => att.source.type === "file" && att.source.path === normalizedPathWithPrefix,
|
(att) => att.source.type === "file" && att.source.path === normalizedPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!alreadyAttached) {
|
if (!alreadyAttached) {
|
||||||
const attachment = createFileAttachment(
|
const attachment = createFileAttachment(
|
||||||
normalizedPathWithPrefix,
|
normalizedPath,
|
||||||
filename,
|
filename,
|
||||||
"text/plain",
|
"text/plain",
|
||||||
undefined,
|
undefined,
|
||||||
@@ -309,7 +301,7 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
addAttachment(options.instanceId(), options.sessionId(), attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
replaceMentionToken(`@${normalizedPathWithPrefix}`, { trailingSpace: true })
|
replaceMentionToken(`@${normalizedPath}`, { trailingSpace: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -342,9 +334,6 @@ export function usePromptPicker(options: PromptPickerOptions): PromptPickerContr
|
|||||||
nextTextarea.setSelectionRange(pos, pos)
|
nextTextarea.setSelectionRange(pos, pos)
|
||||||
}
|
}
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
||||||
// Clear ignoredAtPositions so typing @ again will work
|
|
||||||
setIgnoredAtPositions(new Set<number>())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-so
|
|||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
import { serverSettings, setListeningMode } from "../stores/preferences"
|
import { preferences, setListeningMode } from "../stores/preferences"
|
||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
@@ -33,7 +33,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const SessionPicker: Component<SessionPickerProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<kbd class="kbd ml-2 kbd-hint">
|
<kbd class="kbd ml-2">
|
||||||
Cmd+Enter
|
Cmd+Enter
|
||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ function normalizeQuery(rawQuery: string) {
|
|||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// Don't normalize "." - it's used for workspace root
|
if (trimmed === "." || trimmed === "./") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
return trimmed.replace(/^(\.\/)+/, "").replace(/^\/+/, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,22 +350,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add root directory as first item only when query is EXACTLY "." or "./" (not "./docs/")
|
filteredAgents().forEach((agent) => items.push({ type: "agent", agent }))
|
||||||
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 }))
|
files().forEach((file) => items.push({ type: "file", file }))
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
@@ -487,7 +474,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && agentCount() > 0 && !(props.searchQuery === "." || props.searchQuery === "./")}>
|
<Show when={mode() === "mention" && agentCount() > 0}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.agents")}
|
{t("unifiedPicker.sections.agents")}
|
||||||
</div>
|
</div>
|
||||||
@@ -542,39 +529,10 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
|||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={mode() === "mention" && (fileCount() > 0 || props.searchQuery === "." || props.searchQuery === "./")}>
|
<Show when={mode() === "mention" && fileCount() > 0}>
|
||||||
<div class="dropdown-section-header">
|
<div class="dropdown-section-header">
|
||||||
{t("unifiedPicker.sections.files")}
|
{t("unifiedPicker.sections.files")}
|
||||||
</div>
|
</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()}>
|
<For each={files()}>
|
||||||
{(file) => {
|
{(file) => {
|
||||||
const itemIndex = allItems().findIndex(
|
const itemIndex = allItems().findIndex(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AppConfig,
|
||||||
BackgroundProcess,
|
BackgroundProcess,
|
||||||
BackgroundProcessListResponse,
|
BackgroundProcessListResponse,
|
||||||
BackgroundProcessOutputResponse,
|
BackgroundProcessOutputResponse,
|
||||||
|
BinaryCreateRequest,
|
||||||
|
BinaryListResponse,
|
||||||
|
BinaryUpdateRequest,
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
@@ -210,27 +214,37 @@ export const serverApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
fetchConfig(): Promise<AppConfig> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
|
return request<AppConfig>("/api/config/app")
|
||||||
},
|
},
|
||||||
patchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
updateConfig(payload: AppConfig): Promise<AppConfig> {
|
||||||
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`, {
|
return request<AppConfig>("/api/config/app", {
|
||||||
method: "PATCH",
|
method: "PUT",
|
||||||
body: JSON.stringify(patch ?? {}),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
|
listBinaries(): Promise<BinaryListResponse> {
|
||||||
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`)
|
return request<BinaryListResponse>("/api/config/binaries")
|
||||||
},
|
},
|
||||||
patchStateOwner<T extends Record<string, any> = Record<string, any>>(owner: string, patch: unknown): Promise<T> {
|
createBinary(payload: BinaryCreateRequest) {
|
||||||
return request<T>(`/api/storage/state/${encodeURIComponent(owner)}`, {
|
return request<{ binary: BinaryListResponse["binaries"][number] }>("/api/config/binaries", {
|
||||||
method: "PATCH",
|
method: "POST",
|
||||||
body: JSON.stringify(patch ?? {}),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateBinary(id: string, updates: BinaryUpdateRequest) {
|
||||||
|
return request<{ binary: BinaryListResponse["binaries"][number] }>(`/api/config/binaries/${encodeURIComponent(id)}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteBinary(id: string): Promise<void> {
|
||||||
|
return request(`/api/config/binaries/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
validateBinary(path: string): Promise<BinaryValidationResult> {
|
validateBinary(path: string): Promise<BinaryValidationResult> {
|
||||||
return request<BinaryValidationResult>("/api/storage/binaries/validate", {
|
return request<BinaryValidationResult>("/api/config/binaries/validate", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export interface Command {
|
|||||||
description: Resolvable<string>
|
description: Resolvable<string>
|
||||||
keywords?: Resolvable<string[]>
|
keywords?: Resolvable<string[]>
|
||||||
shortcut?: KeyboardShortcut
|
shortcut?: KeyboardShortcut
|
||||||
disabled?: Resolvable<boolean>
|
|
||||||
action: () => void | Promise<void>
|
action: () => void | Promise<void>
|
||||||
category?: Resolvable<string>
|
category?: Resolvable<string>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getLogger } from "../logger"
|
|||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -29,7 +28,6 @@ function splitKeywords(key: string): string[] {
|
|||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
toggleKeyboardShortcutHints: () => void
|
|
||||||
toggleShowTimelineTools: () => void
|
toggleShowTimelineTools: () => void
|
||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
@@ -456,26 +454,6 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
action: options.toggleShowTimelineTools,
|
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({
|
commandRegistry.register({
|
||||||
id: "thinking-default-visibility",
|
id: "thinking-default-visibility",
|
||||||
label: () => {
|
label: () => {
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
"commands.timelineToolCalls.description": "Toggle tool call entries in the message timeline",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle",
|
"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.expanded": "Expanded",
|
||||||
"commands.common.collapsed": "Collapsed",
|
"commands.common.collapsed": "Collapsed",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
@@ -164,7 +158,6 @@ export const commandMessages = {
|
|||||||
"unifiedPicker.sections.commands": "COMMANDS",
|
"unifiedPicker.sections.commands": "COMMANDS",
|
||||||
"unifiedPicker.sections.agents": "AGENTS",
|
"unifiedPicker.sections.agents": "AGENTS",
|
||||||
"unifiedPicker.sections.files": "FILES",
|
"unifiedPicker.sections.files": "FILES",
|
||||||
"unifiedPicker.sections.workspaceRoot": "WORKSPACE ROOT",
|
|
||||||
"unifiedPicker.badge.subagent": "subagent",
|
"unifiedPicker.badge.subagent": "subagent",
|
||||||
"unifiedPicker.footer.navigate": "navigate",
|
"unifiedPicker.footer.navigate": "navigate",
|
||||||
"unifiedPicker.footer.select": "select",
|
"unifiedPicker.footer.select": "select",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Alternar entradas de llamadas de herramienta en la línea de tiempo de mensajes",
|
"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.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.expanded": "Expandido",
|
||||||
"commands.common.collapsed": "Colapsado",
|
"commands.common.collapsed": "Colapsado",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
"commands.timelineToolCalls.description": "Afficher/masquer les entrées d'appel d'outil dans la timeline des messages",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, outil, basculer",
|
"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.expanded": "Développé",
|
||||||
"commands.common.collapsed": "Réduit",
|
"commands.common.collapsed": "Réduit",
|
||||||
"commands.common.visible": "Visible",
|
"commands.common.visible": "Visible",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
"commands.timelineToolCalls.description": "メッセージタイムラインのツールコール表示を切り替え",
|
||||||
"commands.timelineToolCalls.keywords": "タイムライン, ツール, 切り替え, timeline, tool, toggle",
|
"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.expanded": "展開",
|
||||||
"commands.common.collapsed": "折りたたみ",
|
"commands.common.collapsed": "折りたたみ",
|
||||||
"commands.common.visible": "表示",
|
"commands.common.visible": "表示",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
"commands.timelineToolCalls.description": "Переключить отображение вызовов инструментов в таймлайне сообщений",
|
||||||
"commands.timelineToolCalls.keywords": "таймлайн, tool, переключить",
|
"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.expanded": "Развернуто",
|
||||||
"commands.common.collapsed": "Свернуто",
|
"commands.common.collapsed": "Свернуто",
|
||||||
"commands.common.visible": "Видимо",
|
"commands.common.visible": "Видимо",
|
||||||
|
|||||||
@@ -97,12 +97,6 @@ export const commandMessages = {
|
|||||||
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
"commands.timelineToolCalls.description": "切换消息时间轴中的工具调用条目",
|
||||||
"commands.timelineToolCalls.keywords": "timeline, tool, toggle, 时间轴, 工具, 切换",
|
"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.expanded": "展开",
|
||||||
"commands.common.collapsed": "折叠",
|
"commands.common.collapsed": "折叠",
|
||||||
"commands.common.visible": "可见",
|
"commands.common.visible": "可见",
|
||||||
|
|||||||
@@ -5,49 +5,90 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
return prompt
|
return prompt
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileAttachments = new Set(
|
// First, strip `@` from path-like mentions that do NOT have a backing file attachment.
|
||||||
|
// This is intended for SHIFT+ENTER selection where we keep `@path` in the textarea for
|
||||||
|
// easy deletion, but send `path` to the API.
|
||||||
|
//
|
||||||
|
// IMPORTANT: avoid rewriting plain `@mentions` or email addresses.
|
||||||
|
const fileAttachmentPaths = new Set(
|
||||||
attachments
|
attachments
|
||||||
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
|
.filter((a): a is Attachment & { source: FileSource } => a.source.type === "file")
|
||||||
.map((a) => a.source.path),
|
.map((a) => a.source.path),
|
||||||
)
|
)
|
||||||
|
|
||||||
const pathAttachments = new Set(
|
const isPathLike = (value: string) => {
|
||||||
attachments
|
if (!value) return false
|
||||||
.filter((a) => a.source.type === "text" && typeof a.display === "string" && a.display.startsWith("path:"))
|
if (value.includes("/") || value.includes("\\")) return true
|
||||||
.map((a) => (a.source as { value: string }).value),
|
if (value.startsWith("./") || value.startsWith("../")) return true
|
||||||
)
|
if (value.startsWith("~")) return true
|
||||||
|
if (value.endsWith("/")) return true
|
||||||
|
|
||||||
let result = prompt
|
// Root-level files (no `/`) still commonly have an extension.
|
||||||
|
const ext = value.split(".").pop()?.toLowerCase()
|
||||||
|
if (!ext || ext === value.toLowerCase()) return false
|
||||||
|
|
||||||
// Step 1: Handle root paths FIRST using unique placeholders
|
// Keep this list intentionally small and code-focused to avoid matching domains like `example.com`.
|
||||||
// Replace longer pattern first to avoid partial match issues
|
const allowedExts = new Set([
|
||||||
result = result.replace(/@(\.\/)/g, "___ROOT___")
|
"ts",
|
||||||
result = result.replace(/@(\.)(?!\.)/g, "___ROOT_NOSLASH___")
|
"tsx",
|
||||||
// Note: The regex @(\.)(?!\.) means @. NOT followed by another .
|
"js",
|
||||||
|
"jsx",
|
||||||
// Step 2: Build set of non-root paths
|
"mjs",
|
||||||
const allPaths = new Set<string>()
|
"cjs",
|
||||||
for (const p of fileAttachments) {
|
"json",
|
||||||
if (p && p !== "." && p !== "./") allPaths.add(p)
|
"md",
|
||||||
}
|
"txt",
|
||||||
for (const p of pathAttachments) {
|
"yml",
|
||||||
if (p && p !== "." && p !== "./") allPaths.add(p)
|
"yaml",
|
||||||
|
"toml",
|
||||||
|
"css",
|
||||||
|
"html",
|
||||||
|
"htm",
|
||||||
|
"svg",
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"pdf",
|
||||||
|
"rs",
|
||||||
|
"go",
|
||||||
|
"py",
|
||||||
|
"java",
|
||||||
|
"kt",
|
||||||
|
"swift",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"sql",
|
||||||
|
"lock",
|
||||||
|
])
|
||||||
|
return allowedExts.has(ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Replace @path with ./path for non-root paths
|
const stripTrailingPunctuation = (value: string) => {
|
||||||
for (const path of allPaths) {
|
const match = value.match(/^(.*?)([)\]}.,!?:;]+)?$/)
|
||||||
if (!path) continue
|
if (!match) return { core: value, trailing: "" }
|
||||||
const withoutPrefix = path.startsWith("./") ? path.slice(2) : path
|
return { core: match[1] ?? value, trailing: match[2] ?? "" }
|
||||||
const withPrefix = path.startsWith("./") ? path : "./" + path
|
|
||||||
result = result.replace("@" + withoutPrefix, withPrefix)
|
|
||||||
result = result.replace("@" + withoutPrefix + "/", withPrefix + "/")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Convert placeholders back to ./
|
let result = prompt.replace(/(^|[\s([{"'`])@([^\s@]+)/g, (full, prefix, rawToken) => {
|
||||||
result = result.replace("___ROOT___", "./")
|
const { core, trailing } = stripTrailingPunctuation(String(rawToken))
|
||||||
result = result.replace("___ROOT_NOSLASH___", "./")
|
if (!core) return full
|
||||||
|
|
||||||
// Step 5: Resolve [pasted #N] placeholders
|
// If this path has a file attachment, keep the `@` (attachment is sent separately).
|
||||||
|
if (fileAttachmentPaths.has(core) || fileAttachmentPaths.has(core.replace(/\/$/, ""))) {
|
||||||
|
return `${prefix}@${core}${trailing}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only strip for path-like tokens; leave plain `@mentions` intact.
|
||||||
|
if (!isPathLike(core)) {
|
||||||
|
return `${prefix}@${core}${trailing}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix}${core}${trailing}`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Then, resolve [pasted #N] placeholders
|
||||||
if (!result.includes("[pasted #")) {
|
if (!result.includes("[pasted #")) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -62,7 +103,7 @@ export function resolvePastedPlaceholders(prompt: string, attachments: Attachmen
|
|||||||
const source = attachment?.source
|
const source = attachment?.source
|
||||||
if (!source || source.type !== "text") continue
|
if (!source || source.type !== "text") continue
|
||||||
const display = attachment?.display
|
const display = attachment?.display
|
||||||
const value = (source as { value?: string }).value
|
const value = source.value
|
||||||
if (typeof display !== "string" || typeof value !== "string") continue
|
if (typeof display !== "string" || typeof value !== "string") continue
|
||||||
const match = display.match(/pasted #(\d+)/)
|
const match = display.match(/pasted #(\d+)/)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
MessageRemovedEvent,
|
MessageRemovedEvent,
|
||||||
MessagePartUpdatedEvent,
|
MessagePartUpdatedEvent,
|
||||||
MessagePartRemovedEvent,
|
MessagePartRemovedEvent,
|
||||||
MessagePartDeltaEvent,
|
|
||||||
} from "../types/message"
|
} from "../types/message"
|
||||||
import type {
|
import type {
|
||||||
EventLspUpdated,
|
EventLspUpdated,
|
||||||
@@ -59,7 +58,6 @@ type SSEEvent =
|
|||||||
| MessageRemovedEvent
|
| MessageRemovedEvent
|
||||||
| MessagePartUpdatedEvent
|
| MessagePartUpdatedEvent
|
||||||
| MessagePartRemovedEvent
|
| MessagePartRemovedEvent
|
||||||
| MessagePartDeltaEvent
|
|
||||||
| EventSessionUpdated
|
| EventSessionUpdated
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventSessionDiff
|
| EventSessionDiff
|
||||||
@@ -120,9 +118,6 @@ class SSEManager {
|
|||||||
case "message.part.updated":
|
case "message.part.updated":
|
||||||
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
|
this.onMessagePartUpdated?.(instanceId, event as MessagePartUpdatedEvent)
|
||||||
break
|
break
|
||||||
case "message.part.delta":
|
|
||||||
this.onMessagePartDelta?.(instanceId, event as MessagePartDeltaEvent)
|
|
||||||
break
|
|
||||||
case "message.removed":
|
case "message.removed":
|
||||||
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
|
this.onMessageRemoved?.(instanceId, event as MessageRemovedEvent)
|
||||||
break
|
break
|
||||||
@@ -189,7 +184,6 @@ class SSEManager {
|
|||||||
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
onMessageUpdate?: (instanceId: string, event: MessageUpdateEvent) => void
|
||||||
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
|
onMessageRemoved?: (instanceId: string, event: MessageRemovedEvent) => void
|
||||||
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
|
onMessagePartUpdated?: (instanceId: string, event: MessagePartUpdatedEvent) => void
|
||||||
onMessagePartDelta?: (instanceId: string, event: MessagePartDeltaEvent) => void
|
|
||||||
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
|
onMessagePartRemoved?: (instanceId: string, event: MessagePartRemovedEvent) => void
|
||||||
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
|
onSessionUpdate?: (instanceId: string, event: EventSessionUpdated) => void
|
||||||
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
|
onSessionCompacted?: (instanceId: string, event: EventSessionCompacted) => void
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { InstanceData, WorkspaceEventPayload } from "../../../server/src/api-types"
|
import type { AppConfig, InstanceData } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "./api-client"
|
import { serverApi } from "./api-client"
|
||||||
import { serverEvents } from "./server-events"
|
import { serverEvents } from "./server-events"
|
||||||
import { getLogger } from "./logger"
|
import { getLogger } from "./logger"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
export type OwnerBucket = Record<string, any>
|
export type ConfigData = AppConfig
|
||||||
|
|
||||||
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
const DEFAULT_INSTANCE_DATA: InstanceData = {
|
||||||
messageHistory: [],
|
messageHistory: [],
|
||||||
@@ -30,25 +30,17 @@ function isDeepEqual(a: unknown, b: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ServerStorage {
|
export class ServerStorage {
|
||||||
private configOwnerCache = new Map<string, OwnerBucket>()
|
private configChangeListeners: Set<(config: ConfigData) => void> = new Set()
|
||||||
private stateOwnerCache = new Map<string, OwnerBucket>()
|
private configCache: ConfigData | null = null
|
||||||
private configOwnerLoadPromises = new Map<string, Promise<OwnerBucket>>()
|
private loadPromise: Promise<ConfigData> | null = null
|
||||||
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 instanceDataCache = new Map<string, InstanceData>()
|
||||||
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
private instanceDataListeners = new Map<string, Set<(data: InstanceData) => void>>()
|
||||||
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
private instanceLoadPromises = new Map<string, Promise<InstanceData>>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
serverEvents.on("storage.configChanged", (event: WorkspaceEventPayload) => {
|
serverEvents.on("config.appChanged", (event) => {
|
||||||
if (event.type !== "storage.configChanged") return
|
if (event.type !== "config.appChanged") return
|
||||||
this.setOwnerCache("config", event.owner, event.value)
|
this.setConfigCache(event.config)
|
||||||
})
|
|
||||||
|
|
||||||
serverEvents.on("storage.stateChanged", (event: WorkspaceEventPayload) => {
|
|
||||||
if (event.type !== "storage.stateChanged") return
|
|
||||||
this.setOwnerCache("state", event.owner, event.value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
serverEvents.on("instance.dataChanged", (event) => {
|
serverEvents.on("instance.dataChanged", (event) => {
|
||||||
@@ -57,56 +49,30 @@ export class ServerStorage {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadConfigOwner(owner: string): Promise<OwnerBucket> {
|
async loadConfig(): Promise<ConfigData> {
|
||||||
const cached = this.configOwnerCache.get(owner)
|
if (this.configCache) {
|
||||||
if (cached) return cached
|
return this.configCache
|
||||||
|
|
||||||
if (!this.configOwnerLoadPromises.has(owner)) {
|
|
||||||
const promise = serverApi
|
|
||||||
.fetchConfigOwner<OwnerBucket>(owner)
|
|
||||||
.then((value) => {
|
|
||||||
this.setOwnerCache("config", owner, value)
|
|
||||||
return value
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.configOwnerLoadPromises.delete(owner)
|
|
||||||
})
|
|
||||||
this.configOwnerLoadPromises.set(owner, promise)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.configOwnerLoadPromises.get(owner)!
|
if (!this.loadPromise) {
|
||||||
}
|
this.loadPromise = serverApi
|
||||||
|
.fetchConfig()
|
||||||
async patchConfigOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
.then((config) => {
|
||||||
const updated = await serverApi.patchConfigOwner<OwnerBucket>(owner, patch)
|
this.setConfigCache(config)
|
||||||
this.setOwnerCache("config", owner, updated)
|
return config
|
||||||
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(() => {
|
.finally(() => {
|
||||||
this.stateOwnerLoadPromises.delete(owner)
|
this.loadPromise = null
|
||||||
})
|
})
|
||||||
this.stateOwnerLoadPromises.set(owner, promise)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.stateOwnerLoadPromises.get(owner)!
|
return this.loadPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
async patchStateOwner(owner: string, patch: unknown): Promise<OwnerBucket> {
|
async updateConfig(next: ConfigData): Promise<ConfigData> {
|
||||||
const updated = await serverApi.patchStateOwner<OwnerBucket>(owner, patch)
|
const nextConfig = await serverApi.updateConfig(next)
|
||||||
this.setOwnerCache("state", owner, updated)
|
this.setConfigCache(nextConfig)
|
||||||
return updated
|
return nextConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
async loadInstanceData(instanceId: string): Promise<InstanceData> {
|
||||||
@@ -144,40 +110,12 @@ export class ServerStorage {
|
|||||||
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
this.setInstanceDataCache(instanceId, DEFAULT_INSTANCE_DATA)
|
||||||
}
|
}
|
||||||
|
|
||||||
onConfigOwnerChanged(owner: string, listener: (value: OwnerBucket) => void): () => void {
|
onConfigChanged(listener: (config: ConfigData) => void): () => void {
|
||||||
if (!this.configOwnerListeners.has(owner)) {
|
this.configChangeListeners.add(listener)
|
||||||
this.configOwnerListeners.set(owner, new Set())
|
if (this.configCache) {
|
||||||
}
|
listener(this.configCache)
|
||||||
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 {
|
onInstanceDataChanged(instanceId: string, listener: (data: InstanceData) => void): () => void {
|
||||||
@@ -198,30 +136,18 @@ export class ServerStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setOwnerCache(kind: "config" | "state", owner: string, value: OwnerBucket) {
|
private setConfigCache(config: ConfigData) {
|
||||||
if (owner === "*") {
|
if (this.configCache && isDeepEqual(this.configCache, config)) {
|
||||||
// Full-doc updates are not tracked owner-by-owner; invalidate caches.
|
this.configCache = config
|
||||||
if (kind === "config") {
|
|
||||||
this.configOwnerCache.clear()
|
|
||||||
} else {
|
|
||||||
this.stateOwnerCache.clear()
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
this.configCache = config
|
||||||
|
this.notifyConfigChanged(config)
|
||||||
|
}
|
||||||
|
|
||||||
const cache = kind === "config" ? this.configOwnerCache : this.stateOwnerCache
|
private notifyConfigChanged(config: ConfigData) {
|
||||||
const listeners = kind === "config" ? this.configOwnerListeners : this.stateOwnerListeners
|
for (const listener of this.configChangeListeners) {
|
||||||
|
listener(config)
|
||||||
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")
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uiConfig = await storage.loadConfigOwner("ui")
|
const config = await storage.loadConfig()
|
||||||
const theme = (uiConfig as any)?.theme ?? "system"
|
const theme = config?.theme ?? "system"
|
||||||
|
|
||||||
if (theme === "system") {
|
if (theme === "system") {
|
||||||
document.documentElement.removeAttribute("data-theme")
|
document.documentElement.removeAttribute("data-theme")
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
} from "./sessions"
|
} from "./sessions"
|
||||||
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
import { ensureWorktreesLoaded, ensureWorktreeMapLoaded, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import { fetchCommands, clearCommands } from "./commands"
|
import { fetchCommands, clearCommands } from "./commands"
|
||||||
import { serverSettings } from "./preferences"
|
import { preferences } from "./preferences"
|
||||||
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
import { setSessionPendingPermission, setSessionPendingQuestion } from "./session-state"
|
||||||
import { setHasInstances } from "./ui"
|
import { setHasInstances } from "./ui"
|
||||||
import { messageStoreBus } from "./message-v2/bus"
|
import { messageStoreBus } from "./message-v2/bus"
|
||||||
@@ -91,7 +91,7 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
|||||||
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
||||||
binaryLabel: descriptor.binaryLabel,
|
binaryLabel: descriptor.binaryLabel,
|
||||||
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
||||||
environmentVariables: existing?.environmentVariables ?? serverSettings().environmentVariables ?? {},
|
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
const timeInfo = (info.time ?? {}) as { created?: number; end?: number }
|
const timeInfo = (info.time ?? {}) as { created?: number; completed?: number }
|
||||||
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
const createdAt = typeof timeInfo.created === "number" ? timeInfo.created : Date.now()
|
||||||
const endAt = typeof timeInfo.end === "number" ? timeInfo.end : undefined
|
const completedAt = typeof timeInfo.completed === "number" ? timeInfo.completed : undefined
|
||||||
|
|
||||||
store.upsertMessage({
|
store.upsertMessage({
|
||||||
id: info.id,
|
id: info.id,
|
||||||
@@ -87,7 +87,7 @@ export function upsertMessageInfoV2(instanceId: string, info: MessageInfo | null
|
|||||||
role: info.role === "user" ? "user" : "assistant",
|
role: info.role === "user" ? "user" : "assistant",
|
||||||
status: options?.status ?? "complete",
|
status: options?.status ?? "complete",
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: endAt ?? createdAt,
|
updatedAt: completedAt ?? createdAt,
|
||||||
bumpRevision: Boolean(options?.bumpRevision),
|
bumpRevision: Boolean(options?.bumpRevision),
|
||||||
})
|
})
|
||||||
store.setMessageInfo(info.id, info)
|
store.setMessageInfo(info.id, info)
|
||||||
@@ -104,22 +104,6 @@ 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 {
|
export function replaceMessageIdV2(instanceId: string, oldId: string, newId: string): void {
|
||||||
if (!oldId || !newId || oldId === newId) return
|
if (!oldId || !newId || oldId === newId) return
|
||||||
const store = messageStoreBus.getOrCreate(instanceId)
|
const store = messageStoreBus.getOrCreate(instanceId)
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ export interface InstanceMessageStore {
|
|||||||
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
hydrateMessages: (sessionId: string, inputs: MessageUpsertInput[], infos?: Iterable<MessageInfo>) => void
|
||||||
upsertMessage: (input: MessageUpsertInput) => void
|
upsertMessage: (input: MessageUpsertInput) => void
|
||||||
applyPartUpdate: (input: PartUpdateInput) => void
|
applyPartUpdate: (input: PartUpdateInput) => void
|
||||||
applyPartDelta: (input: { messageId: string; partId: string; field: string; delta: string; bumpRevision?: boolean }) => void
|
|
||||||
removeMessage: (messageId: string) => void
|
removeMessage: (messageId: string) => void
|
||||||
removeMessagePart: (messageId: string, partId: string) => void
|
removeMessagePart: (messageId: string, partId: string) => void
|
||||||
bufferPendingPart: (entry: PendingPartEntry) => void
|
bufferPendingPart: (entry: PendingPartEntry) => void
|
||||||
@@ -598,45 +597,6 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
bumpSessionRevision(message.sessionId)
|
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) {
|
function removeMessage(messageId: string) {
|
||||||
if (!messageId) return
|
if (!messageId) return
|
||||||
|
|
||||||
@@ -1127,20 +1087,19 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
setState(reconcile(createInitialState(instanceId)))
|
setState(reconcile(createInitialState(instanceId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
instanceId,
|
instanceId,
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
addOrUpdateSession,
|
addOrUpdateSession,
|
||||||
hydrateMessages,
|
hydrateMessages,
|
||||||
upsertMessage,
|
upsertMessage,
|
||||||
applyPartUpdate,
|
applyPartUpdate,
|
||||||
applyPartDelta,
|
|
||||||
removeMessage,
|
removeMessage,
|
||||||
removeMessagePart,
|
removeMessagePart,
|
||||||
bufferPendingPart,
|
bufferPendingPart,
|
||||||
flushPendingParts,
|
flushPendingParts,
|
||||||
replaceMessageId,
|
replaceMessageId,
|
||||||
setMessageInfo,
|
setMessageInfo,
|
||||||
getMessageInfo,
|
getMessageInfo,
|
||||||
@@ -1166,3 +1125,4 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -140,11 +140,8 @@ async function sendMessage(
|
|||||||
const display: string | undefined = att.display
|
const display: string | undefined = att.display
|
||||||
const value: unknown = source.value
|
const value: unknown = source.value
|
||||||
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
const isPastedPlaceholder = typeof display === "string" && /^pasted #\d+/.test(display)
|
||||||
const isPathPlaceholder = typeof display === "string" && /^path:/.test(display)
|
|
||||||
|
|
||||||
// Skip path: attachments from being sent as separate parts (content is already in prompt)
|
if (isPastedPlaceholder || typeof value !== "string") {
|
||||||
// Skip pasted placeholders too (already resolved in prompt)
|
|
||||||
if (isPastedPlaceholder || isPathPlaceholder || typeof value !== "string") {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
MessageInfo,
|
MessageInfo,
|
||||||
MessagePartRemovedEvent,
|
MessagePartRemovedEvent,
|
||||||
MessagePartDeltaEvent,
|
|
||||||
MessagePartUpdatedEvent,
|
MessagePartUpdatedEvent,
|
||||||
MessageRemovedEvent,
|
MessageRemovedEvent,
|
||||||
MessageUpdateEvent,
|
MessageUpdateEvent,
|
||||||
@@ -49,7 +48,6 @@ import { loadMessages } from "./session-api"
|
|||||||
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
import { getOrCreateWorktreeClient, getRootClient, getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees"
|
||||||
import {
|
import {
|
||||||
applyPartUpdateV2,
|
applyPartUpdateV2,
|
||||||
applyPartDeltaV2,
|
|
||||||
replaceMessageIdV2,
|
replaceMessageIdV2,
|
||||||
reconcilePendingQuestionsV2,
|
reconcilePendingQuestionsV2,
|
||||||
upsertMessageInfoV2,
|
upsertMessageInfoV2,
|
||||||
@@ -300,10 +298,10 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
const messageId = typeof info.id === "string" ? info.id : undefined
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
if (!sessionId || !messageId) return
|
if (!sessionId || !messageId) return
|
||||||
|
|
||||||
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; end?: number }
|
const timeInfo = (info.time ?? {}) as { created?: number; updated?: number; completed?: number }
|
||||||
const nextUpdated =
|
const nextUpdated =
|
||||||
typeof timeInfo.end === "number" && timeInfo.end > 0
|
typeof timeInfo.completed === "number" && timeInfo.completed > 0
|
||||||
? timeInfo.end
|
? timeInfo.completed
|
||||||
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
|
: typeof timeInfo.updated === "number" && timeInfo.updated > 0
|
||||||
? timeInfo.updated
|
? timeInfo.updated
|
||||||
: typeof timeInfo.created === "number" && timeInfo.created > 0
|
: typeof timeInfo.created === "number" && timeInfo.created > 0
|
||||||
@@ -333,14 +331,14 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const createdAt = info.time?.created ?? Date.now()
|
const createdAt = info.time?.created ?? Date.now()
|
||||||
const endAt = (info.time as { end?: number } | undefined)?.end
|
const completedAt = (info.time as { completed?: number } | undefined)?.completed
|
||||||
store.upsertMessage({
|
store.upsertMessage({
|
||||||
id: messageId,
|
id: messageId,
|
||||||
sessionId,
|
sessionId,
|
||||||
role,
|
role,
|
||||||
status,
|
status,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt: endAt ?? createdAt,
|
updatedAt: completedAt ?? createdAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,14 +348,6 @@ 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 {
|
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
|
|
||||||
@@ -635,7 +625,6 @@ function handleQuestionAnswered(
|
|||||||
export {
|
export {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
handleMessagePartDelta,
|
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { agents, providers } from "./session-state"
|
import { agents, providers } from "./session-state"
|
||||||
import { uiState, getAgentModelPreference } from "./preferences"
|
import { preferences, getAgentModelPreference } from "./preferences"
|
||||||
|
|
||||||
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
const DEFAULT_MODEL_OUTPUT_LIMIT = 32_000
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ function isModelValid(
|
|||||||
function getRecentModelPreferenceForInstance(
|
function getRecentModelPreferenceForInstance(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
): { providerId: string; modelId: string } | undefined {
|
): { providerId: string; modelId: string } | undefined {
|
||||||
const recents = uiState().models.recents ?? []
|
const recents = preferences().modelRecents ?? []
|
||||||
for (const item of recents) {
|
for (const item of recents) {
|
||||||
if (isModelValid(instanceId, item)) {
|
if (isModelValid(instanceId, item)) {
|
||||||
return item
|
return item
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
handleMessagePartRemoved,
|
handleMessagePartRemoved,
|
||||||
handleMessageRemoved,
|
handleMessageRemoved,
|
||||||
handleMessagePartDelta,
|
|
||||||
handleMessageUpdate,
|
handleMessageUpdate,
|
||||||
handlePermissionReplied,
|
handlePermissionReplied,
|
||||||
handlePermissionUpdated,
|
handlePermissionUpdated,
|
||||||
@@ -75,7 +74,6 @@ import {
|
|||||||
|
|
||||||
sseManager.onMessageUpdate = handleMessageUpdate
|
sseManager.onMessageUpdate = handleMessageUpdate
|
||||||
sseManager.onMessagePartUpdated = handleMessageUpdate
|
sseManager.onMessagePartUpdated = handleMessageUpdate
|
||||||
sseManager.onMessagePartDelta = handleMessagePartDelta
|
|
||||||
sseManager.onMessageRemoved = handleMessageRemoved
|
sseManager.onMessageRemoved = handleMessageRemoved
|
||||||
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
sseManager.onMessagePartRemoved = handleMessagePartRemoved
|
||||||
sseManager.onSessionUpdate = handleSessionUpdate
|
sseManager.onSessionUpdate = handleSessionUpdate
|
||||||
|
|||||||
@@ -46,11 +46,6 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-item:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
.modal-list-container[data-pointer-mode="pointer"] .modal-item:hover {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,19 +153,6 @@
|
|||||||
@apply opacity-50;
|
@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 from the start (keeps end visible; good for paths) */
|
||||||
.truncate-start {
|
.truncate-start {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -20,19 +20,6 @@ export type {
|
|||||||
SDKMessage
|
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 {
|
export interface RenderCache {
|
||||||
text: string
|
text: string
|
||||||
html: string
|
html: string
|
||||||
|
|||||||
Reference in New Issue
Block a user