Compare commits

...

20 Commits

Author SHA1 Message Date
Shantur
197dee2aea Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-31 00:22:32 +01:00
Shantur
045d8da8b2 feat(voice): add spoken summary mode for conversation replies 2026-03-31 00:20:26 +01:00
Pascal André
c9bd4b7395 fix(tauri): stop stale UI assets from shadowing desktop builds (#258)
## Summary
- prefer the bundled desktop UI over the downloaded cache when both
report the same version, so rebuilt installers do not keep serving stale
frontend assets
- rebuild the server workspace during the Tauri prebuild step on every
desktop package build, matching Electron's correctness boundary for
fresh UI/server assets
- add a regression test covering the equal-version bundled-vs-downloaded
UI selection path

## Why
- local desktop rebuilds should reflect the latest server and UI code
without requiring users to manually clear cached assets
- packaged updates should keep favoring the freshly bundled frontend
when the cached copy is not actually newer

## Testing
- node --import tsx --test
packages/server/src/ui/__tests__/remote-ui.test.ts
- npm run build:tauri
2026-03-30 20:54:29 +01:00
Pascal André
41a5026331 fix(tauri): sync native app version with package releases (#257)
## Summary
- sync the Tauri native version metadata from
`packages/tauri-app/package.json` so release builds pick up workspace
version bumps like `0.13.1`
- update the checked-in Tauri `Cargo.toml` and `tauri.conf.json`
versions from `0.12.3` to `0.13.1`
- document the prebuild sync behavior in `BUILD.md`

## Testing
- `node packages/tauri-app/scripts/sync-tauri-version.js`
2026-03-30 20:52:37 +01:00
codenomadbot[bot]
d1a27ac31b fix(ui): escape raw HTML in user prompt messages (#260)
## Summary
- escape raw HTML when rendering user message markdown so prompt input
is shown as text instead of injected HTML
- keep assistant and tool markdown behavior unchanged by scoping the
escape behavior to user messages
- update markdown cache keys so escaped and non-escaped render output do
not collide

## Verification
- `npm run typecheck --workspace @codenomad/ui` *(fails in this
workspace because frontend dependencies are not installed)*
- `npm run build --workspace @codenomad/ui` *(fails in this workspace
because `vite` is not installed)*

--
Yours,
[CodeNomadBot](https://github.com/NeuralNomadsAI/CodeNomad)

Co-authored-by: Shantur <shantur@Mac.home>
2026-03-30 08:48:52 +01:00
Jess Chadwick
37b3f85e61 feat: Enable file editing and saving (#252)
## Summary
- Adds file writing capability to Monaco editor in the file viewer
- Implements writeFile API on the server for workspace files
- Integrates save functionality into the file viewer UI with proper
state management

## Bug Fixes (Review Feedback)
- Fixed failed save discarding edits when switching files - now checks
save result and only proceeds if successful
- Fixed refresh overwriting dirty editor state - now prompts for
confirmation before discarding edits
- Fixed save button unable to save empty files - changed check from `if
(content)` to `if (content !== undefined && content !== null)`
- Added agent edit conflict detection - when agent edits file while user
has unsaved changes, shows conflict dialog with Overwrite/Cancel options
- Fixed dialog appearing behind unpinned sidebar - increased alert
dialog z-index to z-100

## Related Issues
- Closes #251

---------

Co-authored-by: Jess Chadwick <jchadwick@gmail.com>
2026-03-29 22:41:11 +01:00
Shantur
55a6479c0e fix(electron): use safe npm invocation on windows 2026-03-29 09:47:58 +01:00
Shantur Rathore
f88064af06 fix(desktop): bundle CLI resources and request mic access 2026-03-28 15:30:14 +00:00
Shantur Rathore
1b4eff9419 Min version 0.13.1 2026-03-27 19:46:54 +00:00
Shantur Rathore
6c1febf50e Bump to v0.13.1 2026-03-27 19:46:12 +00:00
Shantur Rathore
75622ef366 refactor(ui): simplify prompt recording indicator 2026-03-27 19:45:56 +00:00
Shantur Rathore
864f913e3e feat(ui): add assistant conversation playback mode 2026-03-27 19:17:25 +00:00
Shantur Rathore
b7d4f8f869 feat(ui): add clear action to prompt input 2026-03-26 23:10:02 +00:00
Shantur Rathore
0dc5867fb3 fix(speech): surface streaming playback compatibility 2026-03-26 22:59:30 +00:00
Shantur Rathore
d13ecba322 feat(speech): add configurable TTS playback modes 2026-03-26 20:46:49 +00:00
Shantur Rathore
740f37db86 refactor(ui): use stop-square icon for speech playback 2026-03-26 19:39:37 +00:00
Shantur Rathore
d447b05821 feat(ui): add message text-to-speech controls 2026-03-26 18:29:45 +00:00
Shantur Rathore
1233121a13 feat(speech): add prompt voice input (#249)
## Summary
- add server-backed speech capabilities and transcription endpoints plus
UI settings for speech configuration
- add push-to-talk prompt voice input with microphone controls,
transcription insertion, and browser capability gating
- keep prompt controls aligned by restoring right-side nav placement and
moving the mic beside the expand control
2026-03-25 14:08:11 +00:00
Pascal André
a950d47df0 fix(tauri): force Windows process tree shutdown (#240)
## Summary
- force the Windows CLI process tree shutdown path during normal app
close
- avoid leaving child server processes alive when the direct wrapper
process exits first
- keep the change limited to the Windows shutdown path in cli_manager

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-24 21:12:43 +00:00
MusiCode1
1c68f5d288 feat(i18n): Hebrew locale + full RTL support (#243)
# feat(i18n): Hebrew locale + full RTL support

## Summary

This PR adds full Hebrew (he) locale support to the UI, including a
complete translation of all user-facing strings and comprehensive RTL
layout support across all components.

## What was done

### Hebrew translation
- Full translation of all i18n message files for the `he` locale (17
translation files)
- Registered the language in the i18n system and the language picker

### RTL support
- Automatic direction detection (`dir="rtl"`) when Hebrew is selected
- Replaced physical CSS properties (`left`/`right`) with logical
equivalents (`inline-start`/`inline-end`) across the project
- Fixed resize direction, file path alignment, and textarea padding
- Fixed navigation button positioning in textarea for RTL
- Fixed scrollbar direction in RTL
- Fixed code block direction and selector alignment
- Fixed Monaco editor direction in the file viewer
- Auto-detect text direction in reasoning block (`dir="auto"` +
`unicode-bidi: plaintext`)

### Adapted components
- `session-layout` — sidebar and resize handle
- `prompt-input` — text direction and buttons
- `message-base` — message blocks and reasoning
- `message-timeline` — timeline bar
- `right-panel` — right side panel
- `tool-call` — tool call display
- `settings-screen` — settings page
- `selector` — selection component
- `instance-shell` — main shell

## New files

```
packages/ui/src/lib/i18n/messages/he/
  advancedSettings.ts
  app.ts
  commands.ts
  dialogs.ts
  filesystem.ts
  folderSelection.ts
  index.ts
  instance.ts
  loadingScreen.ts
  logs.ts
  markdown.ts
  messaging.ts
  remoteAccess.ts
  session.ts
  settings.ts
  time.ts
  toolCall.ts
```

## Suggested testing
- Switch language to Hebrew and verify all strings are translated
- Verify RTL layout is correct across all screens (session, settings,
file viewer)
- Verify that English text inside a reasoning block is displayed LTR
- Switch back to English and verify everything returns to LTR

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Shantur Rathore <i@shantur.com>
2026-03-24 21:09:52 +00:00
140 changed files with 5824 additions and 278 deletions

View File

@@ -1,6 +0,0 @@
{
"minServerVersion": "0.12.3",
"latestUIVersion": "0.12.3-rtl",
"uiPackageURL": "https://github.com/MusiCode1/CodeNomad/releases/download/v0.12.3-rtl/codenomad-ui-rtl.zip",
"sha256": "a2ce1aaa04345a2f9ca9d3c3149567867f3a5e477cf6eb269381e6dc1bec7ca2"
}

35
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "codenomad-workspace",
"version": "0.12.3",
"version": "0.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codenomad-workspace",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -8240,6 +8240,27 @@
"regex-recursion": "^6.0.2"
}
},
"node_modules/openai": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.27.0.tgz",
"integrity": "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -12019,6 +12040,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -12033,7 +12055,7 @@
},
"packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"@codenomad/ui": "file:../ui",
@@ -12070,7 +12092,7 @@
},
"packages/server": {
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"@fastify/cors": "^8.5.0",
@@ -12080,6 +12102,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -12111,7 +12134,7 @@
},
"packages/tauri-app": {
"name": "@codenomad/tauri-app",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"devDependencies": {
"@tauri-apps/cli": "^2.9.4"
@@ -12119,7 +12142,7 @@
},
"packages/ui": {
"name": "@codenomad/ui",
"version": "0.12.3",
"version": "0.13.1",
"license": "MIT",
"dependencies": {
"@git-diff-view/solid": "^0.0.8",

View File

@@ -1,6 +1,6 @@
{
"name": "codenomad-workspace",
"version": "0.12.3",
"version": "0.13.1",
"private": true,
"description": "CodeNomad monorepo workspace",
"license": "MIT",
@@ -22,7 +22,7 @@
"build:mac-x64": "npm run build:mac-x64 --workspace @neuralnomads/codenomad-electron-app",
"build:binaries": "npm run build:binaries --workspace @neuralnomads/codenomad-electron-app",
"typecheck": "npm run typecheck --workspace @codenomad/ui && npm run typecheck --workspace @neuralnomads/codenomad-electron-app",
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version"
"bumpVersion": "npm version --workspaces --include-workspace-root --no-git-tag-version && npm run sync:version --workspace @codenomad/tauri-app"
},
"dependencies": {
"7zip-bin": "^5.2.0",
@@ -31,4 +31,4 @@
"devDependencies": {
"baseline-browser-mapping": "^2.9.11"
}
}
}

View File

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

View File

@@ -2,3 +2,4 @@ node_modules/
dist/
release/
.vite/
electron/resources/server/

View File

@@ -1,5 +1,6 @@
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
import fs from "fs"
import { requestMicrophoneAccess } from "./permissions"
import type { CliProcessManager, CliStatus } from "./process-manager"
let wakeLockId: number | null = null
@@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
return { enabled: false }
})
ipcMain.handle(
"media:requestMicrophoneAccess",
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
)
ipcMain.handle(
"notifications:show",
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {

View File

@@ -6,6 +6,7 @@ import { dirname, join } from "path"
import { fileURLToPath } from "url"
import { createApplicationMenu } from "./menu"
import { setupCliIPC } from "./ipc"
import { configureMediaPermissionHandlers } from "./permissions"
import { CliProcessManager } from "./process-manager"
const mainFilename = fileURLToPath(import.meta.url)
@@ -489,6 +490,7 @@ app.whenReady().then(() => {
if (isMac) {
session.defaultSession.setSpellCheckerEnabled(false)
configureMediaPermissionHandlers(getAllowedRendererOrigins)
app.on("browser-window-created", (_, window) => {
window.webContents.session.setSpellCheckerEnabled(false)
})

View File

@@ -0,0 +1,58 @@
import { session, systemPreferences } from "electron"
const isMac = process.platform === "darwin"
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
if (!origin) {
return false
}
try {
const normalized = new URL(origin).origin
return allowedOrigins.includes(normalized)
} catch {
return false
}
}
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
const isAudioMediaRequest = (permission: string, details?: unknown) => {
if (permission !== "media") {
return false
}
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
return mediaTypes.length === 0 || mediaTypes.includes("audio")
}
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
if (!isAudioMediaRequest(permission, details)) {
return false
}
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
})
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
if (!isAudioMediaRequest(permission, details)) {
callback(false)
return
}
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
})
}
export async function requestMicrophoneAccess(): Promise<boolean> {
if (!isMac) {
return true
}
const status = systemPreferences.getMediaAccessStatus("microphone")
if (status === "granted") {
return true
}
return systemPreferences.askForMediaAccess("microphone")
}

View File

@@ -1,14 +1,17 @@
import { spawn, spawnSync, type ChildProcess } from "child_process"
import { app } from "electron"
import { app, utilityProcess, type UtilityProcess } from "electron"
import { createRequire } from "module"
import { EventEmitter } from "events"
import { existsSync, readFileSync } from "fs"
import os from "os"
import path from "path"
import { fileURLToPath } from "url"
import { parse as parseYaml } from "yaml"
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
const nodeRequire = createRequire(import.meta.url)
const mainFilename = fileURLToPath(import.meta.url)
const mainDirname = path.dirname(mainFilename)
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
@@ -38,6 +41,9 @@ interface CliEntryResolution {
runnerPath?: string
}
type ManagedChild = ChildProcess | UtilityProcess
type ChildLaunchMode = "spawn" | "utility"
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
function isYamlPath(filePath: string): boolean {
@@ -117,7 +123,8 @@ export declare interface CliProcessManager {
}
export class CliProcessManager extends EventEmitter {
private child?: ChildProcess
private child?: ManagedChild
private childLaunchMode: ChildLaunchMode = "spawn"
private status: CliStatus = { state: "stopped" }
private stdoutBuffer = ""
private stderrBuffer = ""
@@ -135,33 +142,63 @@ export class CliProcessManager extends EventEmitter {
this.requestedStop = false
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
const cliEntry = this.resolveCliEntry(options)
const listeningMode = this.resolveListeningMode()
const host = resolveHostForMode(listeningMode)
const args = this.buildCliArgs(options, host)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
let child: ManagedChild
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
if (this.shouldUsePackagedShellSupervisor(options)) {
const runtimePath = this.resolveShellNodeCommand()
const entryPath = this.resolveBundledProdEntry()
const supervisorPath = this.resolveCliSupervisorPath()
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
const supervisorPayload = JSON.stringify({
command: shellCommand.command,
args: shellCommand.args,
cwd: process.cwd(),
})
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
)
console.info(`[cli] utility supervisor: ${supervisorPath}`)
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
const detached = process.platform !== "win32"
const child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
env: shellEnv,
stdio: "pipe",
serviceName: "CodeNomad CLI Supervisor",
})
this.childLaunchMode = "utility"
} else {
const cliEntry = this.resolveCliEntry(options)
console.info(
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
)
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
if (!child.pid) {
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
env.ELECTRON_RUN_AS_NODE = "1"
const spawnDetails = supportsUserShell()
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
: this.buildDirectSpawn(cliEntry, args)
const detached = process.platform !== "win32"
child = spawn(spawnDetails.command, spawnDetails.args, {
cwd: process.cwd(),
stdio: ["ignore", "pipe", "pipe"],
env,
shell: false,
detached,
})
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
this.childLaunchMode = "spawn"
}
if (this.childLaunchMode === "spawn" && !child.pid) {
console.error("[cli] spawn failed: no pid")
}
@@ -176,23 +213,48 @@ export class CliProcessManager extends EventEmitter {
this.handleStream(data.toString(), "stderr")
})
child.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
if (this.childLaunchMode === "utility") {
const utilityChild = child as UtilityProcess
child.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
utilityChild.on("error", (error) => {
const message = this.describeUtilityProcessError(error)
console.error("[cli] utility supervisor failed:", error)
this.updateStatus({ state: "error", error: message })
this.emit("error", new Error(message))
})
utilityChild.on("exit", (code) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
} else {
const spawnedChild = child as ChildProcess
spawnedChild.on("error", (error) => {
console.error("[cli] failed to start CLI:", error)
this.updateStatus({ state: "error", error: error.message })
this.emit("error", error)
})
spawnedChild.on("exit", (code, signal) => {
const failed = this.status.state !== "ready"
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
this.updateStatus({ state: failed ? "error" : "stopped", error })
if (failed && error) {
this.emit("error", new Error(error))
}
this.emit("exit", this.status)
this.child = undefined
})
}
return new Promise<CliStatus>((resolve, reject) => {
const timeout = setTimeout(() => {
@@ -219,16 +281,22 @@ export class CliProcessManager extends EventEmitter {
return
}
if (this.childLaunchMode === "utility") {
return this.stopUtilityChild(child as UtilityProcess)
}
const spawnedChild = child as ChildProcess
this.requestedStop = true
const pid = child.pid
const pid = spawnedChild.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return
}
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
try {
@@ -304,7 +372,7 @@ export class CliProcessManager extends EventEmitter {
sendStopSignal("SIGKILL")
}, 30000)
child.on("exit", () => {
spawnedChild.on("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
@@ -324,6 +392,46 @@ export class CliProcessManager extends EventEmitter {
})
}
private stopUtilityChild(child: UtilityProcess): Promise<void> {
this.requestedStop = true
const pid = child.pid
if (!pid) {
this.child = undefined
this.updateStatus({ state: "stopped" })
return Promise.resolve()
}
return new Promise((resolve) => {
const killTimeout = setTimeout(() => {
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}, 30000)
child.once("exit", () => {
clearTimeout(killTimeout)
this.child = undefined
console.info("[cli] CLI process exited")
this.updateStatus({ state: "stopped" })
resolve()
})
if (child.pid === undefined) {
clearTimeout(killTimeout)
this.child = undefined
this.updateStatus({ state: "stopped" })
resolve()
return
}
child.kill()
})
}
getStatus(): CliStatus {
return { ...this.status }
}
@@ -335,14 +443,22 @@ export class CliProcessManager extends EventEmitter {
private handleTimeout() {
if (this.child) {
const pid = this.child.pid
if (pid && process.platform !== "win32") {
if (this.childLaunchMode === "utility") {
if (pid) {
try {
process.kill(pid, "SIGKILL")
} catch {
// no-op
}
}
} else if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGKILL")
} catch {
this.child.kill("SIGKILL")
;(this.child as ChildProcess).kill("SIGKILL")
}
} else {
this.child.kill("SIGKILL")
;(this.child as ChildProcess).kill("SIGKILL")
}
this.child = undefined
}
@@ -449,6 +565,10 @@ export class CliProcessManager extends EventEmitter {
return parts.join(" ")
}
private buildExecutableCommand(command: string, args: string[]): string {
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
}
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
if (cliEntry.runner === "tsx") {
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
@@ -519,4 +639,58 @@ export class CliProcessManager extends EventEmitter {
}
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
}
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
return !options.dev && app.isPackaged && process.platform === "darwin"
}
private resolveCliSupervisorPath(): string {
const candidates = [
path.join(process.resourcesPath, "cli-supervisor.cjs"),
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
}
private resolveShellNodeCommand(): string {
const configured = process.env.NODE_BINARY?.trim()
return configured && configured.length > 0 ? configured : "node"
}
private resolveBundledProdEntry(): string {
const candidates = [
path.join(process.resourcesPath, "server", "dist", "bin.js"),
path.join(mainDirname, "../resources/server/dist/bin.js"),
]
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate
}
}
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
}
private describeUtilityProcessError(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message
}
if (error && typeof error === "object") {
const typed = error as { type?: unknown; location?: unknown }
if (typeof typed.type === "string") {
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
}
}
return String(error)
}
}

View File

@@ -20,6 +20,7 @@ const electronAPI = {
return null
}
},
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
}

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env node
const { spawn } = require("child_process")
const SHUTDOWN_GRACE_MS = 30_000
let child = null
let shutdownTimer = null
function log(message, error) {
if (error) {
console.error(`[cli-supervisor] ${message}`, error)
return
}
console.log(`[cli-supervisor] ${message}`)
}
function clearShutdownTimer() {
if (shutdownTimer) {
clearTimeout(shutdownTimer)
shutdownTimer = null
}
}
function forwardStream(stream, target) {
if (!stream) return
stream.on("data", (chunk) => {
target.write(chunk)
})
}
function terminateChild(force) {
if (!child || child.exitCode !== null || child.signalCode !== null) {
return
}
try {
child.kill(force ? "SIGKILL" : "SIGTERM")
} catch {
// no-op
}
}
function requestShutdown(force = false) {
if (!child) {
process.exit(force ? 1 : 0)
return
}
terminateChild(force)
if (force) {
process.exit(1)
return
}
clearShutdownTimer()
shutdownTimer = setTimeout(() => {
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
terminateChild(true)
}, SHUTDOWN_GRACE_MS)
shutdownTimer.unref()
}
function installShutdownHandlers() {
process.on("SIGTERM", () => requestShutdown(false))
process.on("SIGINT", () => requestShutdown(false))
process.on("disconnect", () => requestShutdown(false))
process.on("uncaughtException", (error) => {
log("uncaught exception", error)
requestShutdown(true)
})
process.on("unhandledRejection", (error) => {
log("unhandled rejection", error)
requestShutdown(true)
})
}
function parsePayload() {
const raw = process.argv[2]
if (!raw) {
throw new Error("Supervisor payload is required")
}
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object") {
throw new Error("Supervisor payload must be an object")
}
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
throw new Error("Supervisor payload command is required")
}
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
throw new Error("Supervisor payload args must be a string array")
}
return {
command: parsed.command,
args: parsed.args,
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
}
}
function main() {
installShutdownHandlers()
const payload = parsePayload()
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
env: process.env,
shell: false,
stdio: ["ignore", "pipe", "pipe"],
})
forwardStream(child.stdout, process.stdout)
forwardStream(child.stderr, process.stderr)
child.on("error", (error) => {
log("failed to spawn shell command", error)
process.exit(1)
})
child.on("exit", (code, signal) => {
clearShutdownTimer()
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
process.exit()
})
}
main()

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad-electron-app",
"version": "0.12.3",
"version": "0.13.1",
"description": "CodeNomad - AI coding assistant",
"license": "MIT",
"author": {
@@ -20,6 +20,8 @@
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"prepare:resources": "node scripts/prepare-resources.js",
"prebuild": "npm run prepare:resources",
"build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json",
"preview": "electron-vite preview",
@@ -33,8 +35,11 @@
"build:linux-arm64": "node scripts/build.js linux-arm64",
"build:linux-rpm": "node scripts/build.js linux-rpm",
"build:all": "node scripts/build.js all",
"prepackage:mac": "npm run prepare:resources",
"package:mac": "electron-builder --mac",
"prepackage:win": "npm run prepare:resources",
"package:win": "electron-builder --win",
"prepackage:linux": "npm run prepare:resources",
"package:linux": "electron-builder --linux"
},
"dependencies": {
@@ -82,6 +87,12 @@
}
],
"mac": {
"entitlements": "electron/resources/entitlements.mac.plist",
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
"extendInfo": {
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
},
"category": "public.app-category.developer-tools",
"target": [
{

View File

@@ -111,6 +111,12 @@ async function build(platform) {
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
cwd: workspaceRoot,
env: { NODE_PATH: workspaceNodeModulesPath },
})
console.log("\n📦 Step 2/3: Building Electron app...\n")
await run(npmCmd, ["run", "build"])

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env node
import fs from "fs"
import path, { join } from "path"
import { spawnSync } from "child_process"
import { fileURLToPath } from "url"
const __dirname = fileURLToPath(new URL(".", import.meta.url))
const appDir = join(__dirname, "..")
const workspaceRoot = join(appDir, "..", "..")
const serverRoot = join(appDir, "..", "server")
const resourcesRoot = join(appDir, "electron", "resources")
const serverDest = join(resourcesRoot, "server")
const npmExecPath = process.env.npm_execpath
const npmNodeExecPath = process.env.npm_node_execpath
const serverSources = ["dist", "public", "node_modules", "package.json"]
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
function log(message) {
console.log(`[prepare-resources] ${message}`)
}
function ensureServerBuild() {
const distPath = join(serverRoot, "dist")
const publicPath = join(serverRoot, "public")
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
}
}
function ensureServerDependencies() {
if (fs.existsSync(serverDepsMarker)) {
return
}
log("installing production server dependencies")
const npmArgs = [
"install",
"--omit=dev",
"--ignore-scripts",
"--workspaces=false",
"--package-lock=false",
"--install-strategy=shallow",
"--fund=false",
"--audit=false",
]
const env = {
...process.env,
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
npm_config_workspaces: "false",
}
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
const result = npmCli
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
if (result.status !== 0) {
if (result.error) {
throw result.error
}
throw new Error(`npm install exited with code ${result.status ?? 1}`)
}
}
function copyServerArtifacts() {
fs.rmSync(serverDest, { recursive: true, force: true })
fs.mkdirSync(serverDest, { recursive: true })
for (const name of serverSources) {
const from = join(serverRoot, name)
const to = join(serverDest, name)
if (!fs.existsSync(from)) {
throw new Error(`Missing required server artifact: ${from}`)
}
fs.cpSync(from, to, { recursive: true, dereference: true })
log(`copied ${name} to Electron resources`)
}
}
function stripNodeModuleBins() {
const root = join(serverDest, "node_modules")
if (!fs.existsSync(root)) {
return
}
const stack = [root]
let removed = 0
while (stack.length > 0) {
const current = stack.pop()
if (!current) break
let entries
try {
entries = fs.readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
const full = join(current, entry.name)
if (entry.name === ".bin") {
fs.rmSync(full, { recursive: true, force: true })
removed += 1
continue
}
if (entry.isDirectory()) {
stack.push(full)
}
}
}
if (removed > 0) {
log(`removed ${removed} node_modules/.bin directories`)
}
}
async function main() {
ensureServerBuild()
ensureServerDependencies()
copyServerArtifacts()
stripNodeModuleBins()
}
main().catch((error) => {
console.error("[prepare-resources] failed:", error)
process.exit(1)
})

View File

@@ -14,5 +14,5 @@
"noEmit": true
},
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "electron/resources/server"]
}

View File

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

View File

@@ -2,6 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { createCodeNomadClient, getCodeNomadConfig } from "./lib/client"
import { createBackgroundProcessTools } from "./lib/background-process"
let voiceModeEnabled = false
export async function CodeNomadPlugin(input: PluginInput) {
const config = getCodeNomadConfig()
const client = createCodeNomadClient(config)
@@ -16,6 +18,11 @@ export async function CodeNomadPlugin(input: PluginInput) {
pingTs: (event.properties as any)?.ts,
},
}).catch(() => {})
return
}
if (event.type === "codenomad.voiceMode") {
voiceModeEnabled = Boolean((event.properties as { enabled?: unknown } | undefined)?.enabled)
}
})
@@ -23,6 +30,13 @@ export async function CodeNomadPlugin(input: PluginInput) {
tool: {
...backgroundProcessTools,
},
async "chat.message"(_input: { sessionID: string }, output: { message: { system?: string } }) {
if (!voiceModeEnabled) {
return
}
output.message.system = [output.message.system, buildVoiceModePrompt()].filter(Boolean).join("\n\n")
},
async event(input: { event: any }) {
const opencodeEvent = input?.event
if (!opencodeEvent || typeof opencodeEvent !== "object") return
@@ -30,3 +44,19 @@ export async function CodeNomadPlugin(input: PluginInput) {
},
}
}
function buildVoiceModePrompt(): string {
return [
"Voice conversation mode is enabled.",
"Prepend your reply with a fenced code block using language `spoken`.",
"The `spoken` block should be the natural conversational reply you would say out loud to the user. It should be a concise spoken gist of the full response in 2 to 4 natural sentences.",
"In the spoken block, summarize the main outcome, recommendation, or next step. Sound conversational and natural, not like a document summary.",
"Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.",
"Do not add generic phrases about whether the user should read more.",
"Only mention additional written detail when there is something specific that may matter for the user's next response, such as a tradeoff, caveat, risk, open question, exact diff, or test result.",
"When referring to that written detail, say `below` or `in the message` rather than `detailed section`.",
"After the `spoken` block, continue with your normal detailed response.",
"Example:",
"```spoken\nI implemented the relay-based voice-mode flow and it works with the current plugin bridge. The reconnect caveat is explained below.\n```",
].join("\n\n")
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@neuralnomads/codenomad",
"version": "0.12.3",
"version": "0.13.1",
"description": "CodeNomad Server",
"license": "MIT",
"author": {
@@ -32,6 +32,7 @@
"fastify": "^4.28.1",
"fuzzysort": "^2.0.4",
"node-forge": "^1.3.3",
"openai": "^6.27.0",
"pino": "^9.4.0",
"undici": "^6.19.8",
"yaml": "^2.4.2",
@@ -46,4 +47,4 @@
"tsx": "^4.20.6",
"typescript": "^5.6.3"
}
}
}

View File

@@ -207,6 +207,43 @@ export interface BinaryValidationResult {
error?: string
}
export interface SpeechSegment {
startMs: number
endMs: number
text: string
}
export interface SpeechCapabilitiesResponse {
available: boolean
configured: boolean
provider: string
supportsStt: boolean
supportsTts: boolean
supportsStreamingTts: boolean
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormats: string[]
streamingTtsFormats: string[]
}
export interface SpeechTranscriptionResponse {
text: string
language?: string
durationMs?: number
segments?: SpeechSegment[]
}
export interface SpeechSynthesisResponse {
audioBase64: string
mimeType: string
}
export interface VoiceModeStateResponse {
enabled: boolean
}
export type WorkspaceEventType =
| "workspace.created"
| "workspace.started"

View File

@@ -81,6 +81,14 @@ export class FileSystemBrowser {
return { path: relativePath, absolutePath }
}
writeFile(relativePath: string, contents: string): void {
if (this.unrestricted) {
throw new Error("writeFile is not available in unrestricted mode")
}
const resolved = this.toRestrictedAbsolute(relativePath)
fs.writeFileSync(resolved, contents, "utf-8")
}
readFile(relativePath: string): string {
if (this.unrestricted) {
throw new Error("readFile is not available in unrestricted mode")

View File

@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
import { SpeechService } from "./speech/service"
const require = createRequire(import.meta.url)
@@ -304,6 +305,7 @@ async function main() {
})
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore(configLocation.instancesDir)
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
const instanceEventBridge = new InstanceEventBridge({
workspaceManager,
eventBus,
@@ -388,6 +390,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: uiResolution.uiDevServerUrl,
@@ -408,6 +411,7 @@ async function main() {
eventBus,
serverMeta,
instanceStore,
speechService,
authManager,
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
uiDevServerUrl: undefined,

View File

@@ -21,12 +21,15 @@ import { registerStorageRoutes } from "./routes/storage"
import { registerPluginRoutes } from "./routes/plugin"
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
import { registerWorktreeRoutes } from "./routes/worktrees"
import { registerSpeechRoutes } from "./routes/speech"
import { ServerMeta } from "../api-types"
import { InstanceStore } from "../storage/instance-store"
import { BackgroundProcessManager } from "../background-processes/manager"
import type { AuthManager } from "../auth/manager"
import { registerAuthRoutes } from "./routes/auth"
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
import type { SpeechService } from "../speech/service"
import { PluginChannelManager } from "../plugins/channel"
interface HttpServerDeps {
bindHost: string
@@ -41,6 +44,7 @@ interface HttpServerDeps {
eventBus: EventBus
serverMeta: ServerMeta
instanceStore: InstanceStore
speechService: SpeechService
authManager: AuthManager
uiStaticDir: string
uiDevServerUrl?: string
@@ -170,6 +174,7 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
logger: deps.logger.child({ component: "background-processes" }),
})
const pluginChannel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
registerAuthRoutes(app, { authManager: deps.authManager })
@@ -252,7 +257,13 @@ export function createHttpServer(deps: HttpServerDeps) {
eventBus: deps.eventBus,
workspaceManager: deps.workspaceManager,
})
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
registerSpeechRoutes(app, { speechService: deps.speechService })
registerPluginRoutes(app, {
workspaceManager: deps.workspaceManager,
eventBus: deps.eventBus,
logger: proxyLogger,
channel: pluginChannel,
})
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })

View File

@@ -1,5 +1,6 @@
import { FastifyInstance } from "fastify"
import { z } from "zod"
import type { VoiceModeStateResponse } from "../../api-types"
import type { WorkspaceManager } from "../../workspaces/manager"
import type { EventBus } from "../../events/bus"
import type { Logger } from "../../logger"
@@ -10,6 +11,7 @@ interface RouteDeps {
workspaceManager: WorkspaceManager
eventBus: EventBus
logger: Logger
channel: PluginChannelManager
}
const PluginEventSchema = z.object({
@@ -17,9 +19,11 @@ const PluginEventSchema = z.object({
properties: z.record(z.unknown()).optional(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
const channel = new PluginChannelManager(deps.logger.child({ component: "plugin-channel" }))
const VoiceModeStateSchema = z.object({
enabled: z.boolean(),
})
export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get<{ Params: { id: string } }>("/workspaces/:id/plugin/events", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
@@ -33,10 +37,10 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
reply.raw.flushHeaders?.()
reply.hijack()
const registration = channel.register(request.params.id, reply)
const registration = deps.channel.register(request.params.id, reply)
const heartbeat = setInterval(() => {
channel.send(request.params.id, buildPingEvent())
deps.channel.send(request.params.id, buildPingEvent())
}, 15000)
const close = () => {
@@ -49,6 +53,24 @@ export function registerPluginRoutes(app: FastifyInstance, deps: RouteDeps) {
request.raw.on("error", close)
})
app.post<{ Params: { id: string }; Body: VoiceModeStateResponse }>("/workspaces/:id/plugin/voice-mode", (request, reply) => {
const workspace = deps.workspaceManager.get(request.params.id)
if (!workspace) {
reply.code(404).send({ error: "Workspace not found" })
return
}
const payload = VoiceModeStateSchema.parse(request.body ?? {})
deps.channel.send(request.params.id, {
type: "codenomad.voiceMode",
properties: {
enabled: payload.enabled,
formatVersion: "v1",
},
})
return { enabled: payload.enabled }
})
const handleWildcard = async (request: any, reply: any) => {
const workspaceId = request.params.id as string
const workspace = deps.workspaceManager.get(workspaceId)

View File

@@ -3,6 +3,7 @@ import { z } from "zod"
import { probeBinaryVersion } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger"
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
interface RouteDeps {
settings: SettingsService
@@ -20,10 +21,10 @@ function validateBinaryPath(binaryPath: string): { valid: boolean; version?: str
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
// Full-document access
app.get("/api/storage/config", async () => deps.settings.getDoc("config"))
app.get("/api/storage/config", async () => sanitizeConfigDoc(deps.settings.getDoc("config")))
app.patch("/api/storage/config", async (request, reply) => {
try {
return deps.settings.mergePatchDoc("config", request.body ?? {})
return sanitizeConfigDoc(deps.settings.mergePatchDoc("config", request.body ?? {}))
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }
@@ -31,12 +32,15 @@ export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {
})
app.get<{ Params: { owner: string } }>("/api/storage/config/:owner", async (request) => {
return deps.settings.getOwner("config", request.params.owner)
return sanitizeConfigOwner(request.params.owner, 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 ?? {})
return sanitizeConfigOwner(
request.params.owner,
deps.settings.mergePatchOwner("config", request.params.owner, request.body ?? {}),
)
} catch (error) {
reply.code(400)
return { error: error instanceof Error ? error.message : "Invalid patch" }

View File

@@ -0,0 +1,74 @@
import type { FastifyInstance } from "fastify"
import { z } from "zod"
import type { SpeechService } from "../../speech/service"
interface RouteDeps {
speechService: SpeechService
}
const TranscribeBodySchema = z.object({
audioBase64: z.string().min(1, "Audio payload is required"),
mimeType: z.string().min(1, "Audio MIME type is required"),
filename: z.string().optional(),
language: z.string().optional(),
prompt: z.string().optional(),
})
const SynthesizeBodySchema = z.object({
text: z.string().trim().min(1, "Text is required"),
format: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
function getSpeechErrorStatus(error: unknown): number {
if (error instanceof z.ZodError) {
return 400
}
if (error instanceof Error && /not configured/i.test(error.message)) {
return 503
}
return 502
}
function getSpeechErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback
}
export function registerSpeechRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/speech/capabilities", async () => deps.speechService.getCapabilities())
app.post("/api/speech/transcribe", async (request, reply) => {
try {
const body = TranscribeBodySchema.parse(request.body ?? {})
return await deps.speechService.transcribe(body)
} catch (error) {
request.log.error({ err: error }, "Failed to transcribe audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to transcribe audio") }
}
})
app.post("/api/speech/synthesize", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
return await deps.speechService.synthesize(body)
} catch (error) {
request.log.error({ err: error }, "Failed to synthesize audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to synthesize audio") }
}
})
app.post("/api/speech/synthesize/stream", async (request, reply) => {
try {
const body = SynthesizeBodySchema.parse(request.body ?? {})
const result = await deps.speechService.synthesizeStream(body)
reply.header("Content-Type", result.mimeType)
reply.header("Cache-Control", "no-store")
return reply.send(result.stream)
} catch (error) {
request.log.error({ err: error }, "Failed to stream synthesized audio")
reply.code(getSpeechErrorStatus(error))
return { error: getSpeechErrorMessage(error, "Failed to stream synthesized audio") }
}
})
}

View File

@@ -19,6 +19,10 @@ const WorkspaceFileContentQuerySchema = z.object({
path: z.string(),
})
const WorkspaceFileContentBodySchema = z.object({
contents: z.string(),
})
const WorkspaceFileSearchQuerySchema = z.object({
q: z.string().trim().min(1, "Query is required"),
limit: z.coerce.number().int().positive().max(200).optional(),
@@ -100,6 +104,20 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) {
return handleWorkspaceError(error, reply)
}
})
app.put<{
Params: { id: string }
Querystring: { path?: string }
}>("/api/workspaces/:id/files/content", async (request, reply) => {
try {
const query = WorkspaceFileContentQuerySchema.parse(request.query ?? {})
const body = WorkspaceFileContentBodySchema.parse(request.body ?? {})
deps.workspaceManager.writeFile(request.params.id, query.path, body.contents)
reply.code(204)
} catch (error) {
return handleWorkspaceError(error, reply)
}
})
}

View File

@@ -0,0 +1,40 @@
import type { SettingsDoc } from "./yaml-doc-store"
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function sanitizeServerOwner(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
const speech = isPlainObject(next.speech) ? { ...next.speech } : null
if (!speech) {
return next
}
const rawApiKey = typeof speech.apiKey === "string" ? speech.apiKey.trim() : ""
if (rawApiKey) {
delete speech.apiKey
speech.hasApiKey = true
} else if (!("hasApiKey" in speech)) {
speech.hasApiKey = false
}
next.speech = speech
return next
}
export function sanitizeConfigOwner(owner: string, value: SettingsDoc): SettingsDoc {
if (owner !== "server") {
return value
}
return sanitizeServerOwner(value)
}
export function sanitizeConfigDoc(value: SettingsDoc): SettingsDoc {
const next: SettingsDoc = { ...value }
if (isPlainObject(next.server)) {
next.server = sanitizeServerOwner(next.server)
}
return next
}

View File

@@ -4,6 +4,7 @@ import type { ConfigLocation } from "../config/location"
import { YamlDocStore, type SettingsDoc } from "./yaml-doc-store"
import { migrateSettingsLayout } from "./migrate"
import type { WorkspaceEventPayload } from "../api-types"
import { sanitizeConfigOwner } from "./public-config"
export type DocKind = "config" | "state"
@@ -45,10 +46,11 @@ export class SettingsService {
private publish(kind: DocKind, owner: string, value?: SettingsDoc) {
if (!this.eventBus) return
const type = kind === "config" ? "storage.configChanged" : "storage.stateChanged"
const nextValue = value ?? this.getOwner(kind, owner)
const payload: WorkspaceEventPayload = {
type,
owner,
value: value ?? this.getOwner(kind, owner),
value: kind === "config" ? sanitizeConfigOwner(owner, nextValue) : nextValue,
} as any
this.eventBus.publish(payload)
}

View File

@@ -0,0 +1,234 @@
import { Readable } from "node:stream"
import OpenAI from "openai"
import { toFile } from "openai/uploads"
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
import type { Logger } from "../../logger"
import type { NormalizedSpeechSettings, SpeechSynthesisStreamResponse, SynthesizeSpeechInput, TranscribeAudioInput } from "../service"
interface OpenAICompatibleSpeechProviderOptions {
settings: NormalizedSpeechSettings
logger: Logger
}
export class OpenAICompatibleSpeechProvider {
constructor(private readonly options: OpenAICompatibleSpeechProviderOptions) {}
getCapabilities() {
const { settings } = this.options
return {
available: true,
configured: Boolean(settings.apiKey),
provider: settings.provider,
supportsStt: true,
supportsTts: true,
supportsStreamingTts: true,
baseUrl: settings.baseUrl,
sttModel: settings.sttModel,
ttsModel: settings.ttsModel,
ttsVoice: settings.ttsVoice,
ttsFormats: ["mp3", "wav", "opus", "aac"],
streamingTtsFormats: ["mp3", "wav", "opus", "aac"],
}
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
const client = this.createClient()
const startedAt = Date.now()
const extension = extensionForMime(input.mimeType)
const buffer = Buffer.from(input.audioBase64, "base64")
const filename = input.filename?.trim() || `prompt-input.${extension}`
this.options.logger.info(
{
mimeType: input.mimeType,
bytes: buffer.byteLength,
language: input.language,
model: this.options.settings.sttModel,
},
"speech.transcribe",
)
const response = await this.requestTranscription(client, buffer, filename, input)
return {
text: typeof response?.text === "string" ? response.text : "",
language: typeof response?.language === "string" ? response.language : input.language,
durationMs: Number.isFinite(response?.duration) ? Math.round(Number(response.duration) * 1000) : Date.now() - startedAt,
segments: Array.isArray(response?.segments)
? response.segments
.filter((segment: any) => typeof segment?.text === "string")
.map((segment: any) => ({
startMs: Math.max(0, Math.round(Number(segment.start ?? 0) * 1000)),
endMs: Math.max(0, Math.round(Number(segment.end ?? 0) * 1000)),
text: String(segment.text),
}))
: undefined,
}
}
private async requestTranscription(
client: OpenAI,
buffer: Buffer,
filename: string,
input: TranscribeAudioInput,
): Promise<any> {
const baseRequest = {
model: this.options.settings.sttModel,
...(input.language ? { language: input.language } : {}),
...(input.prompt ? { prompt: input.prompt } : {}),
}
try {
const file = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file,
response_format: "verbose_json" as any,
} as any)) as any
} catch (error) {
this.options.logger.warn({ err: error }, "speech.transcribe verbose_json failed; retrying default format")
const retryFile = await toFile(buffer, filename, { type: input.mimeType })
return (await client.audio.transcriptions.create({
...baseRequest,
file: retryFile,
} as any)) as any
}
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize",
)
const response = await this.requestSpeechAudio(input.text, format)
const mimeType = response.headers.get("content-type") || mimeTypeForFormat(format)
const audioBuffer = Buffer.from(await response.arrayBuffer())
return {
audioBase64: audioBuffer.toString("base64"),
mimeType,
}
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
const format = input.format ?? this.options.settings.ttsFormat
this.options.logger.info(
{
model: this.options.settings.ttsModel,
voice: this.options.settings.ttsVoice,
format,
},
"speech.synthesize.stream",
)
const response = await this.requestSpeechAudio(input.text, format)
if (!response.body) {
throw new Error("Speech provider did not return a stream.")
}
return {
stream: Readable.fromWeb(response.body as any),
mimeType: response.headers.get("content-type") || mimeTypeForFormat(format),
}
}
private async requestSpeechAudio(text: string, format: "mp3" | "wav" | "opus" | "aac"): Promise<Response> {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
let response: Response
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${settings.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: settings.ttsModel,
voice: settings.ttsVoice,
input: text,
response_format: format,
}),
})
} catch (error) {
const detailedError = error as Error & {
cause?: unknown
code?: string
errno?: number | string
syscall?: string
address?: string
port?: number
}
this.options.logger.error(
{
err: error,
endpoint: endpoint.toString(),
baseUrl: settings.baseUrl,
model: settings.ttsModel,
voice: settings.ttsVoice,
format,
cause: detailedError.cause,
code: detailedError.code,
errno: detailedError.errno,
syscall: detailedError.syscall,
address: detailedError.address,
port: detailedError.port,
},
"speech.synthesize fetch failed",
)
throw error
}
if (!response.ok) {
const detail = await response.text()
throw new Error(detail || `Speech synthesis failed with ${response.status}`)
}
return response
}
private createClient(): OpenAI {
const { settings } = this.options
if (!settings.apiKey) {
throw new Error("Speech provider is not configured. Add an API key in Speech settings.")
}
return new OpenAI({
apiKey: settings.apiKey,
baseURL: settings.baseUrl,
})
}
}
function extensionForMime(mimeType: string): string {
const normalized = mimeType.toLowerCase()
if (normalized.includes("webm")) return "webm"
if (normalized.includes("ogg")) return "ogg"
if (normalized.includes("wav")) return "wav"
if (normalized.includes("mpeg") || normalized.includes("mp3")) return "mp3"
if (normalized.includes("mp4") || normalized.includes("aac")) return "m4a"
return "webm"
}
function mimeTypeForFormat(format: "mp3" | "wav" | "opus" | "aac"): string {
if (format === "wav") return "audio/wav"
if (format === "opus") return 'audio/ogg; codecs="opus"'
if (format === "aac") return "audio/aac"
return "audio/mpeg"
}
function ensureTrailingSlash(value: string): string {
return value.endsWith("/") ? value : `${value}/`
}

View File

@@ -0,0 +1,106 @@
import { z } from "zod"
import type { Readable } from "node:stream"
import type { Logger } from "../logger"
import type { SettingsService } from "../settings/service"
import type { SpeechCapabilitiesResponse, SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../api-types"
import { OpenAICompatibleSpeechProvider } from "./providers/openai-compatible"
const ServerSpeechSettingsSchema = z.object({
speech: z
.object({
provider: z.string().optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
sttModel: z.string().optional(),
ttsModel: z.string().optional(),
ttsVoice: z.string().optional(),
ttsFormat: z.enum(["mp3", "wav", "opus", "aac"]).optional(),
})
.optional(),
})
export interface TranscribeAudioInput {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}
export interface SynthesizeSpeechInput {
text: string
format?: "mp3" | "wav" | "opus" | "aac"
}
export interface SpeechSynthesisStreamResponse {
stream: Readable
mimeType: string
}
export interface SpeechProvider {
getCapabilities(): SpeechCapabilitiesResponse
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse>
}
export interface NormalizedSpeechSettings {
provider: string
apiKey?: string
baseUrl?: string
sttModel: string
ttsModel: string
ttsVoice: string
ttsFormat: "mp3" | "wav" | "opus" | "aac"
}
const DEFAULT_PROVIDER = "openai-compatible"
const DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"
const DEFAULT_TTS_MODEL = "gpt-4o-mini-tts"
const DEFAULT_TTS_VOICE = "alloy"
const DEFAULT_TTS_FORMAT = "mp3"
export class SpeechService {
constructor(
private readonly settings: SettingsService,
private readonly logger: Logger,
) {}
getCapabilities(): SpeechCapabilitiesResponse {
return this.createProvider().getCapabilities()
}
async transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse> {
return this.createProvider().transcribe(input)
}
async synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse> {
return this.createProvider().synthesize(input)
}
async synthesizeStream(input: SynthesizeSpeechInput): Promise<SpeechSynthesisStreamResponse> {
return this.createProvider().synthesizeStream(input)
}
private createProvider(): SpeechProvider {
const settings = this.resolveSettings()
return new OpenAICompatibleSpeechProvider({
settings,
logger: this.logger.child({ provider: settings.provider }),
})
}
private resolveSettings(): NormalizedSpeechSettings {
const parsed = ServerSpeechSettingsSchema.parse(this.settings.getOwner("config", "server") ?? {})
const speech = parsed.speech ?? {}
return {
provider: speech.provider?.trim() || DEFAULT_PROVIDER,
apiKey: speech.apiKey?.trim() || process.env.OPENAI_API_KEY,
baseUrl: speech.baseUrl?.trim() || process.env.OPENAI_BASE_URL || undefined,
sttModel: speech.sttModel?.trim() || DEFAULT_STT_MODEL,
ttsModel: speech.ttsModel?.trim() || DEFAULT_TTS_MODEL,
ttsVoice: speech.ttsVoice?.trim() || DEFAULT_TTS_VOICE,
ttsFormat: speech.ttsFormat ?? DEFAULT_TTS_FORMAT,
}
}
}

View File

@@ -55,4 +55,31 @@ describe("resolveUi local version preference", () => {
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
it("prefers bundled when bundled and downloaded versions are equal", async () => {
const bundledDir = path.join(tempRoot, "bundled")
const configDir = path.join(tempRoot, "config")
const currentDir = path.join(configDir, "ui", "current")
await mkdir(bundledDir, { recursive: true })
await mkdir(currentDir, { recursive: true })
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
const result = await resolveUi({
serverVersion: "0.8.1",
bundledUiDir: bundledDir,
autoUpdate: false,
configDir,
logger: noopLogger,
})
assert.equal(result.source, "bundled")
assert.equal(result.uiStaticDir, bundledDir)
assert.equal(result.uiVersion, "0.8.1")
})
})

View File

@@ -250,7 +250,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: currentResolved,
source: "downloaded",
uiVersion: await readUiVersion(currentResolved),
priority: 2,
priority: 1,
})
}
@@ -260,7 +260,7 @@ async function pickBestLocalUi(args: {
uiStaticDir: bundledResolved,
source: "bundled",
uiVersion: await readUiVersion(bundledResolved),
priority: 1,
priority: 2,
})
}

View File

@@ -83,6 +83,12 @@ export class WorkspaceManager {
}
}
writeFile(workspaceId: string, relativePath: string, contents: string): void {
const workspace = this.requireWorkspace(workspaceId)
const browser = new FileSystemBrowser({ rootDir: workspace.path })
browser.writeFile(relativePath, contents)
}
async create(folder: string, name?: string): Promise<WorkspaceDescriptor> {
const id = `${Date.now().toString(36)}`

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/tauri-app",
"version": "0.12.3",
"version": "0.13.1",
"private": true,
"license": "MIT",
"scripts": {
@@ -8,6 +8,7 @@
"dev:ui": "npm run dev --workspace @codenomad/ui",
"dev:prep": "node ./scripts/dev-prep.js",
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
"sync:version": "node ./scripts/sync-tauri-version.js",
"prebuild": "node ./scripts/prebuild.js",
"bundle:server": "npm run prebuild",
"build": "tauri build"

View File

@@ -56,11 +56,7 @@ async function ensureMonacoAssets() {
function ensureServerBuild() {
const distPath = path.join(serverRoot, "dist")
const publicPath = path.join(serverRoot, "public")
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
return
}
console.log("[prebuild] server build missing; running workspace build...")
console.log("[prebuild] rebuilding server workspace for desktop packaging...")
execSync("npm --workspace @neuralnomads/codenomad run build", {
cwd: workspaceRoot,
stdio: "inherit",

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require("fs")
const path = require("path")
const root = path.resolve(__dirname, "..")
const packageJsonPath = path.join(root, "package.json")
const cargoTomlPath = path.join(root, "src-tauri", "Cargo.toml")
const cargoLockPath = path.join(root, "Cargo.lock")
const tauriConfigPath = path.join(root, "src-tauri", "tauri.conf.json")
function readPackageVersion() {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
throw new Error("Missing version in packages/tauri-app/package.json")
}
return packageJson.version
}
function syncCargoToml(version) {
const current = fs.readFileSync(cargoTomlPath, "utf8")
const packageVersionPattern = /(\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/m
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find [package] version in packages/tauri-app/src-tauri/Cargo.toml")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoTomlPath, updated)
return true
}
function syncCargoLock(version) {
if (!fs.existsSync(cargoLockPath)) {
return false
}
const current = fs.readFileSync(cargoLockPath, "utf8")
const packageVersionPattern = /(\[\[package\]\]\r?\nname = "codenomad-tauri"\r?\nversion = ")([^"]+)(")/
const match = current.match(packageVersionPattern)
if (!match) {
throw new Error("Unable to find codenomad-tauri version in packages/tauri-app/Cargo.lock")
}
if (match[2] === version) {
return false
}
const updated = current.replace(packageVersionPattern, (_, prefix, __, suffix) => `${prefix}${version}${suffix}`)
fs.writeFileSync(cargoLockPath, updated)
return true
}
function syncTauriConfig(version) {
const current = fs.readFileSync(tauriConfigPath, "utf8")
const config = JSON.parse(current)
if (config.version === version) {
return false
}
config.version = version
fs.writeFileSync(tauriConfigPath, `${JSON.stringify(config, null, 2)}\n`)
return true
}
function main() {
const version = readPackageVersion()
const changed = []
if (syncCargoToml(version)) {
changed.push(path.relative(root, cargoTomlPath))
}
if (syncCargoLock(version)) {
changed.push(path.relative(root, cargoLockPath))
}
if (syncTauriConfig(version)) {
changed.push(path.relative(root, tauriConfigPath))
}
if (changed.length === 0) {
console.log(`[sync-tauri-version] already aligned to ${version}`)
return
}
console.log(`[sync-tauri-version] synced ${version} -> ${changed.join(", ")}`)
}
try {
main()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[sync-tauri-version] failed: ${message}`)
process.exit(1)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
</dict>
</plist>

View File

@@ -51,6 +51,8 @@ fn workspace_root() -> Option<PathBuf> {
const SESSION_COOKIE_NAME: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30;
#[cfg(windows)]
const CLI_WINDOWS_FORCE_GRACE_MS: u64 = 2_000;
#[cfg(unix)]
fn configure_posix_process_group(command: &mut Command) {
@@ -402,6 +404,8 @@ impl CliProcessManager {
let mut child_opt = self.child.lock();
if let Some(mut child) = child_opt.take() {
log_line(&format!("stopping CLI pid={}", child.id()));
#[cfg(windows)]
let mut forced_tree_shutdown = false;
#[cfg(unix)]
unsafe {
let pid = child.id() as i32;
@@ -414,9 +418,7 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), false) {
let _ = child.kill();
}
let _ = kill_process_tree_windows(child.id(), false);
}
let start = Instant::now();
@@ -424,6 +426,21 @@ impl CliProcessManager {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
#[cfg(windows)]
if !forced_tree_shutdown
&& start.elapsed() > Duration::from_millis(CLI_WINDOWS_FORCE_GRACE_MS)
{
log_line(&format!(
"regular Windows shutdown still running after {}ms; escalating pid={}",
CLI_WINDOWS_FORCE_GRACE_MS,
child.id()
));
forced_tree_shutdown = true;
if !kill_process_tree_windows(child.id(), true) {
let _ = child.kill();
}
}
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
log_line(&format!(
"stop timed out after {}s; sending SIGKILL pid={}",
@@ -440,7 +457,11 @@ impl CliProcessManager {
}
#[cfg(windows)]
{
if !kill_process_tree_windows(child.id(), true) {
if !forced_tree_shutdown
&& !kill_process_tree_windows(child.id(), true)
{
let _ = child.kill();
} else if forced_tree_shutdown {
let _ = child.kill();
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@codenomad/ui",
"version": "0.12.3",
"version": "0.13.1",
"private": true,
"license": "MIT",
"type": "module",
@@ -45,4 +45,4 @@
"vite-plugin-pwa": "^1.2.0",
"vite-plugin-solid": "^2.10.0"
}
}
}

View File

@@ -68,6 +68,7 @@ const App: Component = () => {
toggleAutoCleanupBlankSessions,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -353,6 +354,7 @@ const App: Component = () => {
toggleShowTimelineTools,
toggleUsageMetrics,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,

View File

@@ -108,15 +108,15 @@ const AlertDialog: Component = () => {
open
modal
onOpenChange={(open) => {
if (!open) {
// Only handle dismiss if dialog is dismissible (default: true)
if (!open && payload.dismissible !== false) {
dismiss(false, payload)
}
}}
>
<Dialog.Portal>
<Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<Dialog.Overlay class="modal-overlay z-[60]" />
<Dialog.Content class="modal-surface fixed left-1/2 top-1/2 z-[1310] w-full max-w-sm -translate-x-1/2 -translate-y-1/2 p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
@@ -140,10 +140,11 @@ const AlertDialog: Component = () => {
<Show when={isPrompt}>
<div class="mt-4">
<label class="text-sm font-medium text-secondary">
<label for="prompt-input" class="text-sm font-medium text-secondary">
{payload.inputLabel || t("alertDialog.prompt.inputLabel")}
</label>
<input
id="prompt-input"
ref={(el) => {
promptInputRef = el
}}
@@ -184,11 +185,10 @@ const AlertDialog: Component = () => {
>
{confirmLabel}
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}}
</Show>

View File

@@ -9,6 +9,8 @@ interface MonacoFileViewerProps {
scopeKey: string
path: string
content: string
onSave?: (content: string) => void
onContentChange?: (content: string) => void
}
export function MonacoFileViewer(props: MonacoFileViewerProps) {
@@ -33,6 +35,11 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = null
}
const saveContent = () => {
if (!editor || !props.onSave) return
props.onSave(editor.getValue())
}
onMount(() => {
let cancelled = false
void (async () => {
@@ -44,7 +51,7 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
editor = monaco.editor.create(host, {
value: "",
language: "plaintext",
readOnly: true,
readOnly: false,
automaticLayout: true,
lineNumbers: "on",
minimap: { enabled: false },
@@ -54,6 +61,14 @@ export function MonacoFileViewer(props: MonacoFileViewerProps) {
fontSize: 13,
})
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, saveContent)
editor.onDidChangeModelContent(() => {
if (props.onContentChange) {
props.onContentChange(editor.getValue())
}
})
setReady(true)
})()

View File

@@ -45,6 +45,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
{ value: "ru", label: "Русский" },
{ value: "ja", label: "日本語" },
{ value: "zh-Hans", label: "简体中文" },
{ value: "he", label: "עברית" },
]
const selectedLanguageOption = () => languageOptions.find((opt) => opt.value === locale()) ?? languageOptions[0]
@@ -341,7 +342,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
aria-busy={isLoading() ? "true" : "false"}
>
<div class="absolute top-4 left-6">
<div class="absolute top-4" style="inset-inline-start: 1.5rem;">
<Select<LanguageOption>
value={selectedLanguageOption()}
onChange={(value) => {
@@ -385,7 +386,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
</Select.Portal>
</Select>
</div>
<div class="absolute top-4 right-6 flex items-center gap-2">
<div class="absolute top-4 flex items-center gap-2" style="inset-inline-end: 1.5rem;">
<button
type="button"
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"

View File

@@ -44,6 +44,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) return
@@ -82,7 +83,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="panel-body space-y-3">
<div>
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">{t("instanceInfo.labels.folder")}</div>
<div class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div dir="ltr" class="text-xs text-primary font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base">
{currentInstance().folder}
</div>
</div>
@@ -94,7 +95,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.project")}
</div>
<div class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
<div dir="ltr" class="text-xs font-mono px-2 py-1.5 rounded border truncate bg-surface-secondary border-base text-primary">
{project().id}
</div>
</div>
@@ -137,7 +138,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
{t("instanceInfo.labels.binaryPath")}
</div>
<div class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
<div dir="ltr" class="text-xs font-mono break-all px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
{currentInstance().binaryPath}
</div>
</div>
@@ -151,7 +152,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
<div class="space-y-1">
<For each={environmentEntries()}>
{([key, value]) => (
<div class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<div dir="ltr" class="flex items-center gap-2 px-2 py-1.5 rounded border bg-surface-secondary border-base">
<span class="text-xs font-mono font-medium flex-1 text-primary" title={key}>
{key}
</span>

View File

@@ -81,7 +81,8 @@ interface InstanceShellProps {
}
const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t } = useI18n()
const { t, locale } = useI18n()
const isRTL = () => locale() === "he"
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
const [rightDrawerWidth, setRightDrawerWidth] = createSignal(
@@ -371,7 +372,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{
width: `${sessionSidebarWidth()}px`,
flexShrink: 0,
borderRight: "1px solid var(--border-base)",
borderInlineEnd: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
height: "100%",
minHeight: 0,
@@ -413,16 +414,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined
return (
<Drawer
anchor="left"
anchor={isRTL() ? "right" : "left"}
variant="temporary"
open={leftOpen()}
onClose={closeLeftDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${sessionSidebarWidth()}px`,
boxSizing: "border-box",
borderRight: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
borderInlineEnd: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
backgroundImage: "none",
color: "var(--text-primary)",
@@ -480,7 +482,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
sx={{
width: `${rightDrawerWidth()}px`,
flexShrink: 0,
borderLeft: "1px solid var(--border-base)",
borderInlineStart: "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
height: "100%",
minHeight: 0,
@@ -523,16 +525,17 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const modalProps = container ? { container: container as Element } : undefined
return (
<Drawer
anchor="right"
anchor={isRTL() ? "left" : "right"}
variant="temporary"
open={rightOpen()}
onClose={closeRightDrawer}
ModalProps={modalProps}
sx={{
zIndex: 60,
"& .MuiDrawer-paper": {
width: isPhoneLayout() ? "100vw" : `${rightDrawerWidth()}px`,
boxSizing: "border-box",
borderLeft: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
borderInlineStart: isPhoneLayout() ? "none" : "1px solid var(--border-base)",
backgroundColor: "var(--surface-secondary)",
backgroundImage: "none",
color: "var(--text-primary)",
@@ -742,7 +745,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<Kbd shortcut="cmd+shift+p" />
</span>
<div class="ml-auto flex items-center gap-3">
<div class="ms-auto flex items-center gap-3">
<div class="connection-status-meta flex items-center gap-3">
<Show when={connectionStatus() === "connected"}>
<span class="status-indicator connected">

View File

@@ -24,6 +24,9 @@ import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } f
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api"
import { serverApi } from "../../../../lib/api-client"
import { showConfirmDialog } from "../../../../stores/alerts"
import { showToastNotification } from "../../../../lib/notifications"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import {
@@ -102,6 +105,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [browserSelectedContent, setBrowserSelectedContent] = createSignal<string | null>(null)
const [browserSelectedLoading, setBrowserSelectedLoading] = createSignal(false)
const [browserSelectedError, setBrowserSelectedError] = createSignal<string | null>(null)
const [browserSelectedDirty, setBrowserSelectedDirty] = createSignal(false)
const [browserSelectedSaving, setBrowserSelectedSaving] = createSignal(false)
const [browserSelectedOriginalContent, setBrowserSelectedOriginalContent] = createSignal<string | null>(null)
const [diffViewMode, setDiffViewMode] = createSignal<DiffViewMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, ["split", "unified"] as const) ?? "unified",
@@ -249,7 +255,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const mode = activeSplitResize()
if (!mode) return
event.preventDefault()
const delta = event.clientX - splitResizeStartX()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (event.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -272,7 +279,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const touch = event.touches[0]
if (!touch) return
event.preventDefault()
const delta = touch.clientX - splitResizeStartX()
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const delta = (touch.clientX - splitResizeStartX()) * (isRtl ? -1 : 1)
const next = clampSplitWidth(splitResizeStartWidth() + delta)
if (mode === "changes") setChangesSplitWidth(next)
else if (mode === "git-changes") setGitChangesSplitWidth(next)
@@ -537,6 +545,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedLoading(true)
setBrowserSelectedError(null)
setBrowserSelectedContent(null)
setBrowserSelectedDirty(false)
setBrowserSelectedOriginalContent(null)
// Phone: treat file selection as a commit action and close the overlay.
if (props.isPhoneLayout()) {
@@ -557,6 +567,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Track original content for conflict detection
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
@@ -564,6 +575,95 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}
}
const saveBrowserFile = async (content: string): Promise<boolean> => {
const path = browserSelectedPath()
if (!path) return false
// Check for conflict: agent edited file while user was editing
const originalContent = browserSelectedOriginalContent()
if (originalContent !== null) {
try {
const currentDiskContent = await requestData<FileContent>(
browserClient().file.read({ path }),
"file.read",
)
const diskContent = (currentDiskContent as any)?.content
// If disk content differs from what we originally loaded (agent edit)
// AND differs from user's current edits, we have a conflict
if (diskContent !== originalContent && diskContent !== content) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.conflict.message", { path }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.conflict.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.conflict.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return false
}
// User chose to overwrite, proceed with save
}
} catch {
// If we can't check for conflict, proceed with save
}
}
setBrowserSelectedSaving(true)
try {
await serverApi.writeWorkspaceFile(props.instanceId, path, content)
setBrowserSelectedContent(content)
setBrowserSelectedOriginalContent(content) // Update original to match saved
setBrowserSelectedDirty(false)
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveSuccess"),
variant: "success",
})
return true
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to save file")
showToastNotification({
message: props.t("instanceShell.rightPanel.toast.saveError"),
variant: "error",
})
return false
} finally {
setBrowserSelectedSaving(false)
}
}
const handleBrowserFileChange = (content: string) => {
setBrowserSelectedContent(content)
setBrowserSelectedDirty(true)
}
const handleOpenBrowserFileRequest = async (path: string) => {
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.saveConfirm.message", { path: browserSelectedPath() || "" }),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.saveConfirm.cancelLabel"),
dismissible: false,
},
)
if (confirmed) {
const saveSuccess = await saveBrowserFile(browserSelectedContent() || "")
if (!saveSuccess) {
// Save failed - stay on current file, error toast already shown
return
}
} else {
// User chose not to save - clear dirty state and discard edits
setBrowserSelectedDirty(false)
}
}
await openBrowserFile(path)
}
createEffect(() => {
if (rightPanelTab() !== "files") return
if (browserLoading()) return
@@ -576,6 +676,7 @@ const RightPanel: Component<RightPanelProps> = (props) => {
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
setBrowserSelectedDirty(false)
})
createEffect(() => {
@@ -628,6 +729,22 @@ const RightPanel: Component<RightPanelProps> = (props) => {
}
const refreshFilesTab = async () => {
// Prompt for confirmation if file has unsaved changes
if (browserSelectedDirty()) {
const confirmed = await showConfirmDialog(
props.t("instanceShell.rightPanel.actions.refreshDirty.message"),
{
variant: "warning",
confirmLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.confirmLabel"),
cancelLabel: props.t("instanceShell.rightPanel.actions.refreshDirty.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) {
return
}
}
void loadBrowserEntries(browserPath())
const selected = browserSelectedPath()
if (selected) {
@@ -649,6 +766,8 @@ const RightPanel: Component<RightPanelProps> = (props) => {
throw new Error("Unsupported file type")
}
setBrowserSelectedContent(text)
setBrowserSelectedOriginalContent(text) // Update original content after refresh
setBrowserSelectedDirty(false) // Clear dirty after refresh
} catch (error) {
setBrowserSelectedError(error instanceof Error ? error.message : "Failed to read file")
} finally {
@@ -828,11 +947,15 @@ const RightPanel: Component<RightPanelProps> = (props) => {
browserSelectedContent={browserSelectedContent}
browserSelectedLoading={browserSelectedLoading}
browserSelectedError={browserSelectedError}
browserSelectedDirty={browserSelectedDirty}
browserSelectedSaving={browserSelectedSaving}
parentPath={browserParentPath}
scopeKey={browserScopeKey}
onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onOpenFile={(path: string) => void openBrowserFile(path)}
onRequestOpenFile={(path: string) => void handleOpenBrowserFileRequest(path)}
onRefresh={() => void refreshFilesTab()}
onSave={(content: string) => void saveBrowserFile(content)}
onContentChange={(content: string) => handleBrowserFileChange(content)}
listOpen={filesListOpen}
onToggleList={toggleFilesList}
splitWidth={filesSplitWidth}

View File

@@ -2,6 +2,7 @@ import type { Component } from "solid-js"
import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import { useI18n } from "../../../../../lib/i18n"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps {
@@ -14,14 +15,15 @@ interface DiffToolbarProps {
}
const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const { t } = useI18n()
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const viewModeTitle = () => (nextViewMode() === "split" ? t("instanceShell.diff.switchToSplit") : t("instanceShell.diff.switchToUnified"))
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
nextContextMode() === "collapsed" ? t("instanceShell.diff.hideUnchanged") : t("instanceShell.diff.showFull")
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? t("instanceShell.diff.enableWordWrap") : t("instanceShell.diff.disableWordWrap"))
return (
<div class="file-viewer-toolbar">

View File

@@ -1,5 +1,6 @@
import { Show, type Component, type JSX } from "solid-js"
import { useI18n } from "../../../../../lib/i18n"
import OverlayList from "./OverlayList"
type SplitFilePanelList = {
@@ -24,12 +25,13 @@ interface SplitFilePanelProps {
}
const SplitFilePanel: Component<SplitFilePanelProps> = (props) => {
const { t } = useI18n()
return (
<div class="files-tab-container">
<div class="files-tab-header">
<div class="files-tab-header-row">
<button type="button" class="files-toggle-button" onClick={props.onToggleList}>
{props.listOpen ? "Hide files" : "Show files"}
{props.listOpen ? t("instanceShell.filesShell.hideFiles") : t("instanceShell.filesShell.showFiles")}
</button>
{props.header}

View File

@@ -1,7 +1,7 @@
import { For, Show, Suspense, lazy, type Accessor, type Component, type JSX } from "solid-js"
import type { FileNode } from "@opencode-ai/sdk/v2/client"
import { RefreshCw } from "lucide-solid"
import { RefreshCw, Save } from "lucide-solid"
import SplitFilePanel from "../components/SplitFilePanel"
@@ -21,13 +21,17 @@ interface FilesTabProps {
browserSelectedContent: Accessor<string | null>
browserSelectedLoading: Accessor<boolean>
browserSelectedError: Accessor<string | null>
browserSelectedDirty: Accessor<boolean>
browserSelectedSaving: Accessor<boolean>
parentPath: Accessor<string | null>
scopeKey: Accessor<string>
onLoadEntries: (path: string) => void
onOpenFile: (path: string) => void
onRequestOpenFile: (path: string) => void
onRefresh: () => void
onSave: (content: string) => void
onContentChange: (content: string) => void
listOpen: Accessor<boolean>
onToggleList: () => void
@@ -38,6 +42,13 @@ interface FilesTabProps {
}
const FilesTab: Component<FilesTabProps> = (props) => {
const handleSave = () => {
const content = props.browserSelectedContent()
if (content !== undefined && content !== null) {
props.onSave(content)
}
}
const renderContent = (): JSX.Element => {
const entriesValue = props.browserEntries()
const entries = entriesValue || []
@@ -86,7 +97,13 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</div>
}
>
<LazyMonacoFileViewer scopeKey={props.scopeKey()} path={payload().path} content={payload().content} />
<LazyMonacoFileViewer
scopeKey={props.scopeKey()}
path={payload().path}
content={payload().content}
onSave={props.onSave}
onContentChange={props.onContentChange}
/>
</Suspense>
)}
</Show>
@@ -135,7 +152,7 @@ const FilesTab: Component<FilesTabProps> = (props) => {
props.onLoadEntries(item.path)
return
}
props.onOpenFile(item.path)
props.onRequestOpenFile(item.path)
}}
title={item.path}
>
@@ -168,14 +185,25 @@ const FilesTab: Component<FilesTabProps> = (props) => {
</Show>
<Show when={props.browserError()}>{(err) => <span class="text-error">{err()}</span>}</Show>
</div>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.save") || "Save (Ctrl+S)"}
aria-label={props.t("instanceShell.rightPanel.actions.save") || "Save"}
disabled={props.browserSelectedSaving() || !props.browserSelectedDirty()}
style={{ "margin-inline-start": "auto" }}
onClick={handleSave}
>
<Show when={props.browserSelectedSaving()} fallback={<Save class="h-4 w-4" />}>
<RefreshCw class="h-4 w-4 animate-spin" />
</Show>
</button>
<button
type="button"
class="files-header-icon-button"
title={props.t("instanceShell.rightPanel.actions.refresh")}
aria-label={props.t("instanceShell.rightPanel.actions.refresh")}
disabled={props.browserLoading()}
style={{ "margin-left": "auto" }}
onClick={() => props.onRefresh()}
>
<RefreshCw class={`h-4 w-4${props.browserLoading() ? " animate-spin" : ""}`} />
@@ -198,4 +226,4 @@ const FilesTab: Component<FilesTabProps> = (props) => {
return <>{renderContent()}</>
}
export default FilesTab
export default FilesTab

View File

@@ -82,7 +82,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
})
const emptyViewerMessage = createMemo(() => {
if (!hasSession()) return props.t("instanceShell.sessionChanges.noSessionSelected")
if (!hasSession()) return props.t("instanceShell.gitChanges.noSessionSelected")
const currentEntries = entries()
if (currentEntries === null) return props.t("instanceShell.gitChanges.loading")
if (nonDeleted().length === 0) return props.t("instanceShell.gitChanges.empty")

View File

@@ -46,7 +46,9 @@ export function useDrawerResize(options: DrawerResizeOptions): DrawerResizeApi {
if (!side) return
const startWidth = resizeStartWidth()
const clamp = side === "left" ? options.clampLeft : options.clampRight
const delta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const isRtl = typeof document !== "undefined" && document.documentElement.dir === "rtl"
const rawDelta = side === "left" ? clientX - resizeStartX() : resizeStartX() - clientX
const delta = isRtl ? -rawDelta : rawDelta
const nextWidth = clamp(startWidth + delta)
applyDrawerWidth(side, nextWidth)
}

View File

@@ -83,6 +83,7 @@ interface MarkdownProps {
isDark?: boolean
size?: "base" | "sm" | "tight"
disableHighlight?: boolean
escapeRawHtml?: boolean
onRendered?: () => void
}
@@ -103,11 +104,12 @@ export function Markdown(props: MarkdownProps) {
const text = decodeHtmlEntitiesLocally(rawText)
const themeKey = Boolean(props.isDark) ? "dark" : "light"
const highlightEnabled = !props.disableHighlight
const escapeRawHtml = Boolean(props.escapeRawHtml)
const partId = typeof part.id === "string" && part.id.length > 0 ? part.id : undefined
const cacheId = resolvePartCacheId(part, text)
const version = resolvePartVersion(part, text)
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, partId, cacheId, version, requestKey }
const requestKey = `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${escapeRawHtml ? 1 : 0}:${version}`
return { part, text, themeKey, highlightEnabled, escapeRawHtml, partId, cacheId, version, requestKey }
})
const cacheHandle = useGlobalCache({
@@ -116,7 +118,7 @@ export function Markdown(props: MarkdownProps) {
scope: "markdown",
cacheId: () => {
const { cacheId, themeKey, highlightEnabled } = resolved()
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}`
return `${cacheId}:${themeKey}:${highlightEnabled ? 1 : 0}:${resolved().escapeRawHtml ? 1 : 0}`
},
version: () => resolved().version,
})
@@ -126,7 +128,7 @@ export function Markdown(props: MarkdownProps) {
text: snapshot.text,
html: renderedHtml,
theme: snapshot.themeKey,
mode: snapshot.version,
mode: `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`,
}
setHtml(renderedHtml)
cacheHandle.set(cacheEntry)
@@ -138,6 +140,7 @@ export function Markdown(props: MarkdownProps) {
markdown.setMarkdownTheme(snapshot.themeKey === "dark")
const rendered = await markdown.renderMarkdown(snapshot.text, {
suppressHighlight: !snapshot.highlightEnabled,
escapeRawHtml: snapshot.escapeRawHtml,
})
if (latestRequestKey === snapshot.requestKey) {
@@ -148,10 +151,11 @@ export function Markdown(props: MarkdownProps) {
createEffect(() => {
const snapshot = resolved()
latestRequestKey = snapshot.requestKey
const cacheMode = `${snapshot.version}:${snapshot.escapeRawHtml ? "escaped" : "raw"}`
const cacheMatches = (cache: RenderCache | undefined) => {
if (!cache) return false
return cache.theme === snapshot.themeKey && cache.mode === snapshot.version
return cache.theme === snapshot.themeKey && cache.mode === cacheMode
}
const localCache = snapshot.part.renderCache

View File

@@ -14,6 +14,8 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@@ -1384,6 +1386,13 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId}:${(props.part as any)?.id ?? "reasoning"}`,
text: reasoningText,
})
const canSpeakReasoning = () => reasoningText().trim().length > 0 && speech.canUseSpeech()
createEffect(() => {
if (!expanded()) return
reasoningText()
@@ -1462,6 +1471,20 @@ function ReasoningCard(props: ReasoningCardProps) {
</button>
<div class="message-reasoning-actions">
<Show when={canSpeakReasoning()}>
<SpeechActionButton
class="message-action-button"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void speech.toggle()
}}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<button
type="button"
class="message-action-button"
@@ -1531,7 +1554,7 @@ function ReasoningCard(props: ReasoningCardProps) {
<div class="message-reasoning-expanded">
<div class="message-reasoning-body">
<div class="message-reasoning-output" role="region" aria-label={t("messageBlock.reasoning.detailsAriaLabel")}>
<pre class="message-reasoning-text">{reasoningText() || ""}</pre>
<pre class="message-reasoning-text" dir="auto">{reasoningText() || ""}</pre>
</div>
</div>
</div>

View File

@@ -11,6 +11,8 @@ import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
function DeleteUpToIcon() {
return (
@@ -294,6 +296,13 @@ export default function MessageItem(props: MessageItemProps) {
.join("\n\n")
}
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.record.id}`,
text: getRawContent,
})
const canSpeakMessage = () => getRawContent().trim().length > 0 && speech.canUseSpeech()
const handleCopy = async () => {
const content = getRawContent()
if (!content) return
@@ -443,6 +452,16 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.onFork}>
<button
class="message-action-button"
@@ -503,6 +522,16 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button>
<Show when={canSpeakMessage()}>
<SpeechActionButton
class="message-action-button"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"

View File

@@ -146,6 +146,7 @@ export default function MessagePart(props: MessagePartProps) {
sessionId={props.sessionId}
isDark={isDark()}
size={isAssistantMessage() ? "tight" : "base"}
escapeRawHtml={props.messageType === "user"}
onRendered={props.onRendered}
/>
</Show>

View File

@@ -295,7 +295,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
{t("modelSelector.trigger.primary", { model: currentModelValue()?.name ?? t("modelSelector.none") })}
</span>
{currentModelValue() && (
<span class="selector-trigger-secondary">
<span class="selector-trigger-secondary" dir="ltr">
{currentModelValue()!.providerId}/{currentModelValue()!.id}
</span>
)}

View File

@@ -1,5 +1,5 @@
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import { Suspense, createEffect, createSignal, lazy, on, onCleanup, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown, Loader2, Mic, Volume2, X } from "lucide-solid"
import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -18,6 +18,8 @@ import { usePromptState } from "./prompt-input/usePromptState"
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
import { canUseConversationMode, isConversationModeEnabled, toggleConversationMode } from "../stores/conversation-speech"
const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
@@ -350,6 +352,19 @@ export default function PromptInput(props: PromptInputProps) {
textareaRef?.focus()
}
function handleClearPrompt() {
clearPrompt()
clearHistoryDraft()
resetHistoryNavigation()
setShowPicker(false)
setPickerMode("mention")
setAtPosition(null)
setSearchQuery("")
setIgnoredAtPositions(new Set<number>())
syncAttachmentCounters("")
textareaRef?.focus()
}
function insertBlockContent(block: string) {
const textarea = textareaRef
const current = prompt()
@@ -421,6 +436,8 @@ export default function PromptInput(props: PromptInputProps) {
return hasText || attachments().length > 0
}
const canClearPrompt = () => prompt().length > 0
const shellHint = () =>
mode() === "shell"
? { key: "Esc", text: t("promptInput.hints.shell.exit") }
@@ -450,9 +467,52 @@ export default function PromptInput(props: PromptInputProps) {
})
const shouldShowOverlay = () => prompt().length === 0
const voiceInput = usePromptVoiceInput({
prompt,
setPrompt,
getTextarea: () => textareaRef ?? null,
enabled: () => preferences().showPromptVoiceInput,
disabled: () => Boolean(props.disabled),
})
const showVoiceInput = () =>
preferences().showPromptVoiceInput &&
(voiceInput.canUseVoiceInput() || voiceInput.isRecording() || voiceInput.isTranscribing())
const conversationModeEnabled = () => isConversationModeEnabled(props.instanceId)
const showConversationToggle = () => showVoiceInput() || conversationModeEnabled()
const canToggleConversationMode = () => canUseConversationMode()
const conversationModeButtonTitle = () =>
conversationModeEnabled()
? t("promptInput.conversationMode.disable.title")
: t("promptInput.conversationMode.enable.title")
const instance = () => getActiveInstance()
let voiceButtonPressed = false
const beginVoicePress = (event?: PointerEvent | KeyboardEvent) => {
if (voiceButtonPressed || props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput()) return
voiceButtonPressed = true
if (event instanceof PointerEvent) {
const target = event.currentTarget
if (target instanceof HTMLElement) {
try {
target.setPointerCapture(event.pointerId)
} catch {
// no-op
}
}
}
void voiceInput.startRecording()
}
const endVoicePress = () => {
if (!voiceButtonPressed) return
voiceButtonPressed = false
voiceInput.stopRecording()
}
return (
<div class="prompt-input-container">
<div
@@ -506,42 +566,111 @@ export default function PromptInput(props: PromptInputProps) {
autocomplete="off"
/>
<div class="prompt-nav-buttons">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<div class="prompt-nav-column prompt-nav-column-left">
<Show when={showVoiceInput()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
onPointerDown={(event) => {
event.preventDefault()
beginVoicePress(event)
}}
onPointerUp={(event) => {
event.preventDefault()
endVoicePress()
}}
onPointerCancel={() => endVoicePress()}
onLostPointerCapture={() => endVoicePress()}
onKeyDown={(event) => {
if (event.repeat) return
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
beginVoicePress(event)
}}
onKeyUp={(event) => {
if (event.key !== " " && event.key !== "Enter") return
event.preventDefault()
endVoicePress()
}}
onBlur={() => endVoicePress()}
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
aria-label={voiceInput.buttonTitle()}
title={voiceInput.buttonTitle()}
>
<Show
when={voiceInput.isRecording()}
fallback={
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
</Show>
}
>
<Mic class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
<Show when={showConversationToggle()}>
<button
type="button"
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
onClick={() => toggleConversationMode(props.instanceId)}
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
aria-pressed={conversationModeEnabled()}
aria-label={conversationModeButtonTitle()}
title={conversationModeButtonTitle()}
>
<Volume2 class="h-4 w-4" aria-hidden="true" />
</button>
</Show>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
class="prompt-clear-button"
onClick={handleClearPrompt}
disabled={!canClearPrompt()}
aria-label={t("promptInput.clear.ariaLabel")}
title={t("promptInput.clear.title")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
<X class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="prompt-nav-column prompt-nav-column-right">
<ExpandButton
expandState={expandState}
onToggleExpand={handleExpandToggle}
/>
<Show when={hasHistory()}>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectPreviousHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoPrevious()}
aria-label={t("promptInput.history.previousAriaLabel")}
>
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
class="prompt-history-button"
onClick={() =>
selectNextHistory({
force: true,
isPickerOpen: showPicker(),
getTextarea: () => textareaRef,
})
}
disabled={!canHistoryGoNext()}
aria-label={t("promptInput.history.nextAriaLabel")}
>
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
</button>
</Show>
</div>
</div>
<Show when={shouldShowOverlay()}>
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>

View File

@@ -0,0 +1,253 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { serverApi } from "../../lib/api-client"
import { useI18n } from "../../lib/i18n"
import { isElectronHost } from "../../lib/runtime-env"
interface UsePromptVoiceInputOptions {
prompt: Accessor<string>
setPrompt: (value: string) => void
getTextarea: () => HTMLTextAreaElement | null
enabled: Accessor<boolean>
disabled: Accessor<boolean>
}
type VoiceInputState = "idle" | "recording" | "transcribing"
export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
const { t } = useI18n()
const [state, setState] = createSignal<VoiceInputState>("idle")
const [elapsedMs, setElapsedMs] = createSignal(0)
let mediaRecorder: MediaRecorder | null = null
let mediaStream: MediaStream | null = null
let timerId: number | undefined
let shouldTranscribe = true
let recordedChunks: Blob[] = []
let recordingStartedAt = 0
createEffect(() => {
void loadSpeechCapabilities()
})
onCleanup(() => {
cleanupMedia(false)
})
const isSupported = () => {
if (typeof window === "undefined") return false
return typeof window.MediaRecorder !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia)
}
const canUseVoiceInput = () => {
const capabilities = speechCapabilities()
return Boolean(
options.enabled() &&
isSupported() &&
capabilities?.available &&
capabilities?.configured &&
capabilities?.supportsStt,
)
}
async function toggleRecording(): Promise<void> {
if (state() === "recording") {
stopRecording()
return
}
await startRecording()
}
function stopRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = true
mediaRecorder.stop()
setState("transcribing")
stopTimer()
}
function cancelRecording() {
if (!mediaRecorder || state() !== "recording") return
shouldTranscribe = false
mediaRecorder.stop()
cleanupMedia(false)
}
async function startRecording() {
if (!canUseVoiceInput() || options.disabled() || state() === "transcribing" || state() === "recording") return
if (!isSupported()) {
showAlertDialog(t("promptInput.voiceInput.error.unsupported"), {
title: t("promptInput.voiceInput.error.title"),
variant: "error",
})
return
}
try {
recordedChunks = []
shouldTranscribe = true
if (isElectronHost()) {
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
if (granted && !granted.granted) {
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
}
}
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = createRecorder(mediaStream)
mediaRecorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
recordedChunks.push(event.data)
}
})
mediaRecorder.addEventListener("stop", () => {
void finalizeRecording()
})
recordingStartedAt = Date.now()
setElapsedMs(0)
setState("recording")
startTimer()
mediaRecorder.start()
} catch (error) {
cleanupMedia(false)
showAlertDialog(t("promptInput.voiceInput.error.permission"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function finalizeRecording() {
const recorder = mediaRecorder
const stream = mediaStream
mediaRecorder = null
mediaStream = null
if (!shouldTranscribe || recordedChunks.length === 0) {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
return
}
const mimeType = recorder?.mimeType || recordedChunks[0]?.type || "audio/webm"
try {
const audioBlob = new Blob(recordedChunks, { type: mimeType })
const transcription = await serverApi.transcribeAudio({
audioBase64: await blobToBase64(audioBlob),
mimeType,
})
if (transcription.text.trim()) {
insertTranscript(transcription.text.trim())
}
} catch (error) {
showAlertDialog(t("promptInput.voiceInput.error.transcribe"), {
title: t("promptInput.voiceInput.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
recordedChunks = []
stopTracks(stream)
setState("idle")
setElapsedMs(0)
}
}
function insertTranscript(text: string) {
const current = options.prompt()
const textarea = options.getTextarea()
const start = textarea ? textarea.selectionStart : current.length
const end = textarea ? textarea.selectionEnd : current.length
const before = current.slice(0, start)
const after = current.slice(end)
const prefix = before.length > 0 && !/\s$/.test(before) ? " " : ""
const suffix = after.length > 0 && !/^\s/.test(after) ? " " : ""
const nextValue = `${before}${prefix}${text}${suffix}${after}`
const cursor = before.length + prefix.length + text.length
options.setPrompt(nextValue)
if (textarea) {
setTimeout(() => {
textarea.focus()
textarea.setSelectionRange(cursor, cursor)
}, 0)
}
}
function cleanupMedia(resetState = true) {
stopTimer()
if (mediaRecorder && mediaRecorder.state !== "inactive") {
mediaRecorder.stop()
}
mediaRecorder = null
stopTracks(mediaStream)
mediaStream = null
recordedChunks = []
if (resetState) {
setState("idle")
setElapsedMs(0)
}
}
function startTimer() {
stopTimer()
timerId = window.setInterval(() => {
setElapsedMs(Date.now() - recordingStartedAt)
}, 250)
}
function stopTimer() {
if (timerId !== undefined) {
window.clearInterval(timerId)
timerId = undefined
}
}
return {
state,
elapsedMs,
canUseVoiceInput,
startRecording,
stopRecording,
toggleRecording,
cancelRecording,
isRecording: () => state() === "recording",
isTranscribing: () => state() === "transcribing",
buttonTitle: () => {
if (state() === "recording") return t("promptInput.voiceInput.stop.title")
if (state() === "transcribing") return t("promptInput.voiceInput.transcribing.title")
return t("promptInput.voiceInput.start.title")
},
}
}
function createRecorder(stream: MediaStream): MediaRecorder {
const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
const supported = candidates.find((candidate) => typeof MediaRecorder.isTypeSupported !== "function" || MediaRecorder.isTypeSupported(candidate))
return supported ? new MediaRecorder(stream, { mimeType: supported }) : new MediaRecorder(stream)
}
function stopTracks(stream: MediaStream | null) {
stream?.getTracks().forEach((track) => track.stop())
}
async function blobToBase64(blob: Blob): Promise<string> {
const buffer = await blob.arrayBuffer()
const bytes = new Uint8Array(buffer)
let binary = ""
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary)
}

View File

@@ -98,6 +98,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) {

View File

@@ -157,6 +157,7 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning",
confirmLabel: t("sessionList.delete.confirmLabel"),
cancelLabel: t("sessionList.delete.cancelLabel"),
dismissible: false,
},
)
if (!confirmed) return
@@ -285,6 +286,7 @@ const SessionList: Component<SessionListProps> = (props) => {
variant: "warning",
confirmLabel: t("sessionList.bulkDelete.confirmLabel"),
cancelLabel: t("sessionList.bulkDelete.cancelLabel"),
dismissible: false,
},
)

View File

@@ -16,6 +16,7 @@ import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
const log = getLogger("session")
@@ -88,6 +89,10 @@ export const SessionView: Component<SessionViewProps> = (props) => {
on(
() => props.isActive,
(isActive) => {
if (!isActive) {
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
return
}
if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).

View File

@@ -1,5 +1,5 @@
import { Dialog } from "@kobalte/core/dialog"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, X } from "lucide-solid"
import { Settings, Bell, MonitorUp, Paintbrush, Terminal, Volume2, X } from "lucide-solid"
import { createMemo, For, type Component } from "solid-js"
import { useI18n } from "../lib/i18n"
import {
@@ -13,6 +13,7 @@ import { AppearanceSettingsSection } from "./settings/appearance-settings-sectio
import { NotificationsSettingsSection } from "./settings/notifications-settings-section"
import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
import { SpeechSettingsSection } from "./settings/speech-settings-section"
export const SettingsScreen: Component = () => {
const { t } = useI18n()
@@ -21,6 +22,7 @@ export const SettingsScreen: Component = () => {
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
])
@@ -30,6 +32,8 @@ export const SettingsScreen: Component = () => {
return <NotificationsSettingsSection />
case "remote":
return <RemoteAccessSettingsSection />
case "speech":
return <SpeechSettingsSection />
case "opencode":
return <OpenCodeSettingsSection />
case "appearance":

View File

@@ -24,6 +24,7 @@ export const AppearanceSettingsSection: Component = () => {
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
@@ -38,10 +39,11 @@ export const AppearanceSettingsSection: Component = () => {
toggleShowThinkingBlocks,
toggleKeyboardShortcutHints,
toggleShowTimelineTools,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
setDiffViewMode,
toggleUsageMetrics,
toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput,
setDiffViewMode,
setToolOutputExpansion,
setDiagnosticsExpansion,
setThinkingBlocksExpansion,

View File

@@ -86,6 +86,7 @@ export const RemoteAccessSettingsSection: Component = () => {
variant: "warning",
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
dismissible: false,
})
if (!confirmed) return

View File

@@ -0,0 +1,373 @@
import { For, Show, createEffect, createMemo, createSignal, type Component } from "solid-js"
import { Loader2, Mic, Square, Volume2 } from "lucide-solid"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { useI18n } from "../../lib/i18n"
import { loadSpeechCapabilities, speechCapabilities, speechCapabilitiesError, speechCapabilitiesLoading } from "../../stores/speech"
import { getLogger } from "../../lib/logger"
import { useSpeech } from "../../lib/hooks/use-speech"
import { getSpeechPlaybackSupport } from "../../lib/speech-playback-support"
const log = getLogger("actions")
type DraftFields = {
apiKey: string
baseUrl: string
sttModel: string
ttsModel: string
ttsVoice: string
playbackMode: SpeechSettings["playbackMode"]
ttsFormat: SpeechSettings["ttsFormat"]
}
function createDraftFields(speech: SpeechSettings): DraftFields {
return {
apiKey: "",
baseUrl: speech.baseUrl ?? "",
sttModel: speech.sttModel,
ttsModel: speech.ttsModel,
ttsVoice: speech.ttsVoice,
playbackMode: speech.playbackMode,
ttsFormat: speech.ttsFormat,
}
}
function isDraftEqual(a: DraftFields, b: DraftFields): boolean {
return (
a.apiKey === b.apiKey &&
a.baseUrl === b.baseUrl &&
a.sttModel === b.sttModel &&
a.ttsModel === b.ttsModel &&
a.ttsVoice === b.ttsVoice &&
a.playbackMode === b.playbackMode &&
a.ttsFormat === b.ttsFormat
)
}
export const SpeechSettingsCard: Component = () => {
const { t } = useI18n()
const { serverSettings, updateSpeechSettings } = useConfig()
const initialDrafts = createDraftFields(serverSettings().speech)
const [isSaving, setIsSaving] = createSignal(false)
const [saveStatus, setSaveStatus] = createSignal<"idle" | "saved" | "error">("saved")
const [drafts, setDrafts] = createSignal<DraftFields>(initialDrafts)
const [apiKeyTouched, setApiKeyTouched] = createSignal(false)
const [clearStoredApiKey, setClearStoredApiKey] = createSignal(false)
const testSpeech = useSpeech({
id: () => "settings-speech-test",
text: () => t("settings.speech.testPlayback.sample"),
settingsOverride: () => ({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
}),
})
createEffect(() => {
const speech = serverSettings().speech
const nextDrafts = createDraftFields(speech)
if (!isSaving() && !isDirty()) {
if (!isDraftEqual(drafts(), nextDrafts)) {
setDrafts(nextDrafts)
}
if (apiKeyTouched()) {
setApiKeyTouched(false)
}
if (clearStoredApiKey()) {
setClearStoredApiKey(false)
}
}
})
createEffect(() => {
void loadSpeechCapabilities()
})
const capabilityLabel = () => {
if (speechCapabilitiesLoading()) return t("settings.speech.status.loading")
if (speechCapabilitiesError()) return t("settings.speech.status.error")
return speechCapabilities()?.configured ? t("settings.speech.status.configured") : t("settings.speech.status.missing")
}
const updateDraft = (key: keyof DraftFields, value: string) => {
setSaveStatus("idle")
if (key === "apiKey") {
setApiKeyTouched(true)
setClearStoredApiKey(false)
}
setDrafts((current) => ({ ...current, [key]: value }))
}
const apiKeyDirty = createMemo(() => clearStoredApiKey() || drafts().apiKey.trim().length > 0)
const playbackSupport = createMemo(() =>
getSpeechPlaybackSupport({
playbackMode: drafts().playbackMode,
ttsFormat: drafts().ttsFormat,
capabilities: speechCapabilities(),
}),
)
const compatibilityMessage = createMemo(() => {
const capabilities = speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return null
}
if (drafts().playbackMode === "streaming" && !capabilities.supportsStreamingTts) {
return t("settings.speech.compatibility.streamingUnavailable")
}
if (drafts().playbackMode === "streaming" && !playbackSupport().available) {
return t("settings.speech.compatibility.browserStreamingUnavailable")
}
return t("settings.speech.compatibility.runtimeNote")
})
const isDirty = createMemo(() => {
const speech = serverSettings().speech
const current = drafts()
return (
apiKeyDirty() ||
(current.baseUrl || "") !== (speech.baseUrl || "") ||
current.sttModel !== speech.sttModel ||
current.ttsModel !== speech.ttsModel ||
current.ttsVoice !== speech.ttsVoice ||
current.playbackMode !== speech.playbackMode ||
current.ttsFormat !== speech.ttsFormat
)
})
const saveStatusLabel = () => {
if (isSaving()) return t("settings.speech.save.saving")
if (saveStatus() === "saved") return t("settings.speech.save.saved")
if (saveStatus() === "error") return t("settings.speech.save.error")
return t("settings.speech.save.unsaved")
}
async function handleSave() {
if (!isDirty() || isSaving()) return
const current = drafts()
setIsSaving(true)
setSaveStatus("idle")
try {
const trimmedApiKey = current.apiKey.trim()
await updateSpeechSettings({
...(clearStoredApiKey() ? { apiKey: null } : trimmedApiKey ? { apiKey: trimmedApiKey } : {}),
baseUrl: current.baseUrl.trim() || undefined,
sttModel: current.sttModel.trim() || undefined,
ttsModel: current.ttsModel.trim() || undefined,
ttsVoice: current.ttsVoice.trim() || undefined,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
await loadSpeechCapabilities(true)
setDrafts({
apiKey: "",
baseUrl: current.baseUrl.trim(),
sttModel: current.sttModel.trim() || serverSettings().speech.sttModel,
ttsModel: current.ttsModel.trim() || serverSettings().speech.ttsModel,
ttsVoice: current.ttsVoice.trim() || serverSettings().speech.ttsVoice,
playbackMode: current.playbackMode,
ttsFormat: current.ttsFormat,
})
setApiKeyTouched(false)
setClearStoredApiKey(false)
setSaveStatus("saved")
} catch (error) {
log.error("Failed to save speech settings", error)
setSaveStatus("error")
} finally {
setIsSaving(false)
}
}
return (
<div class="settings-card">
<div class="settings-card-header">
<div class="settings-card-heading-with-icon">
<Volume2 class="settings-card-heading-icon" />
<div>
<h3 class="settings-card-title">{t("settings.speech.title")}</h3>
<p class="settings-card-subtitle">{t("settings.speech.subtitle")}</p>
</div>
</div>
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
</div>
<div class="settings-stack">
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{t("settings.speech.provider.title")}</div>
<div class="settings-toggle-caption">{t("settings.speech.provider.subtitle")}</div>
</div>
<div class="settings-toolbar-inline">
<span class="settings-inline-note">{t("settings.speech.provider.openaiCompatible")}</span>
<span class="settings-inline-note">{capabilityLabel()}</span>
<span class="settings-inline-note">{saveStatusLabel()}</span>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap inline-flex items-center gap-2"
onClick={() => void testSpeech.toggle()}
disabled={isSaving()}
title={testSpeech.buttonTitle()}
aria-label={testSpeech.buttonTitle()}
>
<Show
when={testSpeech.isLoading()}
fallback={
<Show when={testSpeech.isPlaying()} fallback={<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />}>
<Square class="w-3.5 h-3.5" aria-hidden="true" />
</Show>
}
>
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
</Show>
<span>
{testSpeech.isPlaying()
? t("settings.speech.testPlayback.stop")
: testSpeech.isLoading()
? t("settings.speech.testPlayback.generating")
: t("settings.speech.testPlayback.action")}
</span>
</button>
<button
type="button"
class="selector-button selector-button-primary w-auto whitespace-nowrap"
onClick={() => void handleSave()}
disabled={!isDirty() || isSaving()}
>
{isSaving() ? t("settings.speech.save.saving") : t("settings.speech.save.action")}
</button>
</div>
</div>
<Field
label={t("settings.speech.apiKey.title")}
caption={t("settings.speech.apiKey.subtitle")}
value={drafts().apiKey}
onInput={(value) => updateDraft("apiKey", value)}
type="password"
placeholder={serverSettings().speech.hasApiKey ? t("settings.speech.apiKey.placeholder") : undefined}
/>
<Show when={serverSettings().speech.hasApiKey && !apiKeyTouched() && drafts().apiKey.length === 0}>
<div class="settings-inline-note">
{clearStoredApiKey() ? t("settings.speech.apiKey.clearPending") : t("settings.speech.apiKey.storedNote")}{" "}
<Show when={!clearStoredApiKey()}>
<button
type="button"
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
onClick={() => {
setClearStoredApiKey(true)
setSaveStatus("idle")
}}
>
{t("settings.speech.apiKey.clearAction")}
</button>
</Show>
</div>
</Show>
<Field
label={t("settings.speech.baseUrl.title")}
caption={t("settings.speech.baseUrl.subtitle")}
value={drafts().baseUrl}
onInput={(value) => updateDraft("baseUrl", value)}
placeholder={t("settings.speech.baseUrl.placeholder")}
/>
<Field
label={t("settings.speech.sttModel.title")}
caption={t("settings.speech.sttModel.subtitle")}
value={drafts().sttModel}
onInput={(value) => updateDraft("sttModel", value)}
/>
<Field
label={t("settings.speech.ttsModel.title")}
caption={t("settings.speech.ttsModel.subtitle")}
value={drafts().ttsModel}
onInput={(value) => updateDraft("ttsModel", value)}
/>
<Field
label={t("settings.speech.ttsVoice.title")}
caption={t("settings.speech.ttsVoice.subtitle")}
value={drafts().ttsVoice}
onInput={(value) => updateDraft("ttsVoice", value)}
icon={<Mic class="w-3.5 h-3.5 icon-muted flex-shrink-0" />}
/>
<SelectField
label={t("settings.speech.playbackMode.title")}
caption={t("settings.speech.playbackMode.subtitle")}
value={drafts().playbackMode}
onInput={(value) => updateDraft("playbackMode", value as DraftFields["playbackMode"])}
options={[
{ value: "streaming", label: t("settings.speech.playbackMode.streaming") },
{ value: "buffered", label: t("settings.speech.playbackMode.buffered") },
]}
/>
<SelectField
label={t("settings.speech.ttsFormat.title")}
caption={t("settings.speech.ttsFormat.subtitle")}
value={drafts().ttsFormat}
onInput={(value) => updateDraft("ttsFormat", value as DraftFields["ttsFormat"])}
options={[
{ value: "mp3", label: "MP3" },
{ value: "wav", label: "WAV" },
{ value: "opus", label: "Opus" },
{ value: "aac", label: "AAC" },
]}
/>
<div class="settings-inline-note">{t("settings.speech.help")}</div>
<Show when={compatibilityMessage()}>{(message) => <div class="settings-inline-note">{message()}</div>}</Show>
<div class="settings-inline-note">{t("settings.speech.testPlayback.note")}</div>
</div>
</div>
)
}
const Field: Component<{
label: string
caption: string
value: string
type?: string
placeholder?: string
onInput: (value: string) => void
icon?: any
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="flex items-center gap-2 min-w-[18rem] max-w-[24rem] w-full">
{props.icon}
<input
type={props.type ?? "text"}
value={props.value}
onInput={(event) => props.onInput(event.currentTarget.value)}
class="selector-input w-full"
placeholder={props.placeholder}
/>
</div>
</div>
)
}
const SelectField: Component<{
label: string
caption: string
value: string
onInput: (value: string) => void
options: Array<{ value: string; label: string }>
}> = (props) => {
return (
<div class="settings-toggle-row settings-toggle-row-compact">
<div>
<div class="settings-toggle-title">{props.label}</div>
<div class="settings-toggle-caption">{props.caption}</div>
</div>
<div class="min-w-[18rem] max-w-[24rem] w-full">
<select value={props.value} onInput={(event) => props.onInput(event.currentTarget.value)} class="selector-input w-full">
<For each={props.options}>{(option) => <option value={option.value}>{option.label}</option>}</For>
</select>
</div>
</div>
)
}
export default SpeechSettingsCard

View File

@@ -0,0 +1,10 @@
import type { Component } from "solid-js"
import SpeechSettingsCard from "./speech-settings-card"
export const SpeechSettingsSection: Component = () => {
return (
<div class="settings-section-stack">
<SpeechSettingsCard />
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { Loader2, Volume2 } from "lucide-solid"
import type { JSX } from "solid-js"
interface SpeechActionButtonProps {
class?: string
title: string
isLoading: boolean
isPlaying: boolean
onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent>
type?: "button" | "submit" | "reset"
}
export default function SpeechActionButton(props: SpeechActionButtonProps) {
return (
<button
type={props.type ?? "button"}
class={props.class}
onClick={props.onClick}
aria-label={props.title}
title={props.title}
>
{props.isLoading ? (
<Loader2 class="w-3.5 h-3.5 animate-spin" aria-hidden="true" />
) : props.isPlaying ? (
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="9" y="9" width="6" height="6" rx="1" fill="currentColor" stroke="none" />
</svg>
) : (
<Volume2 class="w-3.5 h-3.5" aria-hidden="true" />
)}
</button>
)
}

View File

@@ -29,6 +29,7 @@ import type {
ToolScrollHelpers,
} from "./tool-call/types"
import {
buildToolSpeechText,
ensureMarkdownContent,
getRelativePath,
getToolIcon,
@@ -41,6 +42,8 @@ import {
} from "./tool-call/utils"
import { resolveTitleForTool } from "./tool-call/tool-title"
import { getLogger } from "../lib/logger"
import { useSpeech } from "../lib/hooks/use-speech"
import SpeechActionButton from "./speech-action-button"
const log = getLogger("session")
@@ -960,6 +963,21 @@ export default function ToolCall(props: ToolCallProps) {
return renderToolTitle()
})
const speechText = createMemo(() =>
buildToolSpeechText({
title: headerText(),
state: toolState(),
t,
}),
)
const speech = useSpeech({
id: () => `${props.instanceId}:${props.sessionId}:${props.messageId ?? "message"}:${toolCallIdentifier()}`,
text: speechText,
})
const canSpeakToolCall = () => speechText().trim().length > 0 && speech.canUseSpeech()
const handleCopyHeader = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -1023,6 +1041,16 @@ export default function ToolCall(props: ToolCallProps) {
<Copy class="w-3.5 h-3.5" />
</button>
<Show when={canSpeakToolCall()}>
<SpeechActionButton
class="tool-call-header-copy"
onClick={() => void speech.toggle()}
title={speech.buttonTitle()}
isLoading={speech.isLoading()}
isPlaying={speech.isPlaying()}
/>
</Show>
<span class="tool-call-header-status" aria-hidden="true">
{statusIcon()}
</span>

View File

@@ -231,3 +231,37 @@ export function getDefaultToolAction(toolName: string) {
return tGlobal("toolCall.renderer.action.working")
}
}
export function buildToolSpeechText(options: {
title: string
state?: ToolState
t: (key: string, params?: Record<string, unknown>) => string
}): string {
const sections: string[] = []
if (options.title.trim()) {
sections.push(options.title.trim())
}
const { input, output } = readToolStatePayload(options.state)
const formattedInput = formatUnknown(input)
const formattedOutput = formatUnknown(output)
if (formattedInput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.input")}:\n${formattedInput.text.trim()}`)
}
if (formattedOutput?.text?.trim()) {
sections.push(`${options.t("toolCall.io.output")}:\n${formattedOutput.text.trim()}`)
}
if (options.state?.status === "error" && options.state.error?.trim()) {
sections.push(`${options.t("toolCall.error.label")} ${options.state.error.trim()}`)
}
if (sections.length === 1 && options.state?.status === "pending") {
sections.push(options.t("toolCall.pending.waitingToRun"))
}
return sections.join("\n\n").trim()
}

View File

@@ -18,6 +18,7 @@ import {
setWorktreeSlugForParentSession,
} from "../stores/worktrees"
import { sessions } from "../stores/sessions"
import { useI18n } from "../lib/i18n"
const log = getLogger("session")
@@ -25,8 +26,6 @@ type WorktreeOption =
| { kind: "action"; key: "__create__"; label: string }
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
const CREATE_OPTION: WorktreeOption = { kind: "action", key: "__create__", label: "+ Create worktree" }
function preventSelectPress(event: PointerEvent | MouseEvent) {
// Prevent Select.Item from treating this as a selection.
// We intentionally prevent default to stop Kobalte's internal press handling.
@@ -71,6 +70,7 @@ interface WorktreeSelectorProps {
}
export default function WorktreeSelector(props: WorktreeSelectorProps) {
const { t } = useI18n()
const [isOpen, setIsOpen] = createSignal(false)
const [createOpen, setCreateOpen] = createSignal(false)
const [createSlug, setCreateSlug] = createSignal("")
@@ -99,7 +99,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
directory: wt.directory,
raw: wt,
}))
return [CREATE_OPTION, ...mapped]
const createOption: WorktreeOption = { kind: "action", key: "__create__", label: t("instanceShell.worktree.create") }
return [createOption, ...mapped]
})
const selectedOption = createMemo<WorktreeOption | undefined>(() => {

View File

@@ -7,7 +7,11 @@ import type {
FileSystemCreateFolderResponse,
FileSystemListResponse,
InstanceData,
SpeechCapabilitiesResponse,
SpeechSynthesisResponse,
SpeechTranscriptionResponse,
ServerMeta,
VoiceModeStateResponse,
WorkspaceCreateRequest,
WorkspaceDescriptor,
WorkspaceFileResponse,
@@ -120,6 +124,28 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
}
async function requestRaw(path: string, init?: RequestInit): Promise<Response> {
const url = API_BASE ? new URL(path, API_BASE).toString() : path
const headers = normalizeHeaders(init?.headers)
if (init?.body !== undefined && !headers["Content-Type"]) {
headers["Content-Type"] = "application/json"
}
const method = (init?.method ?? "GET").toUpperCase()
const startedAt = Date.now()
logHttp(`${method} ${path}`)
const response = await fetch(url, { ...init, headers, credentials: init?.credentials ?? "include" })
if (!response.ok) {
const message = await response.text()
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt, error: message })
throw new Error(message || `Request failed with ${response.status}`)
}
logHttp(`${method} ${path} -> ${response.status}`, { durationMs: Date.now() - startedAt })
return response
}
export const serverApi = {
fetchWorkspaces(): Promise<WorkspaceDescriptor[]> {
@@ -209,6 +235,16 @@ export const serverApi = {
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
)
},
writeWorkspaceFile(id: string, relativePath: string, contents: string): Promise<void> {
const params = new URLSearchParams({ path: relativePath })
return request(
`/api/workspaces/${encodeURIComponent(id)}/files/content?${params.toString()}`,
{
method: "PUT",
body: JSON.stringify({ contents }),
},
)
},
fetchConfigOwner<T extends Record<string, any> = Record<string, any>>(owner: string): Promise<T> {
return request<T>(`/api/storage/config/${encodeURIComponent(owner)}`)
@@ -235,6 +271,37 @@ export const serverApi = {
body: JSON.stringify({ path }),
})
},
fetchSpeechCapabilities(): Promise<SpeechCapabilitiesResponse> {
return request<SpeechCapabilitiesResponse>("/api/speech/capabilities")
},
transcribeAudio(payload: {
audioBase64: string
mimeType: string
filename?: string
language?: string
prompt?: string
}): Promise<SpeechTranscriptionResponse> {
return request<SpeechTranscriptionResponse>("/api/speech/transcribe", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeech(payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" }): Promise<SpeechSynthesisResponse> {
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
method: "POST",
body: JSON.stringify(payload),
})
},
synthesizeSpeechStream(
payload: { text: string; format?: "mp3" | "wav" | "opus" | "aac" },
signal?: AbortSignal,
): Promise<Response> {
return requestRaw("/api/speech/synthesize/stream", {
method: "POST",
body: JSON.stringify(payload),
signal,
})
},
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams()
if (path && path !== ".") {
@@ -282,6 +349,12 @@ export const serverApi = {
{ method: "POST" },
)
},
updateVoiceMode(instanceId: string, enabled: boolean): Promise<VoiceModeStateResponse> {
return request<VoiceModeStateResponse>(`/workspaces/${encodeURIComponent(instanceId)}/plugin/voice-mode`, {
method: "POST",
body: JSON.stringify({ enabled }),
})
},
fetchBackgroundProcessOutput(
instanceId: string,
processId: string,

View File

@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
toggleUsageMetrics: () => void
toggleAutoCleanupBlankSessions: () => void
togglePromptSubmitOnEnter: () => void
toggleShowPromptVoiceInput: () => void
setDiffViewMode: (mode: "split" | "unified") => void
setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
@@ -435,6 +436,7 @@ export function useCommands(options: UseCommandsOptions) {
toggleUsageMetrics: options.toggleUsageMetrics,
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
setDiffViewMode: options.setDiffViewMode,
setToolOutputExpansion: options.setToolOutputExpansion,
setDiagnosticsExpansion: options.setDiagnosticsExpansion,

View File

@@ -0,0 +1,416 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import { showAlertDialog } from "../../stores/alerts"
import { serverApi } from "../api-client"
import { useI18n } from "../i18n"
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
import { useConfig, type SpeechSettings } from "../../stores/preferences"
import { formatToMimeType, getSpeechPlaybackSupport } from "../speech-playback-support"
type SpeechPlaybackState = "idle" | "loading" | "playing"
interface UseSpeechOptions {
id: Accessor<string>
text: Accessor<string>
settingsOverride?: Accessor<Partial<Pick<SpeechSettings, "playbackMode" | "ttsFormat">>>
}
interface ActivePlaybackEntry {
ownerId: string
stop: () => void
}
const stateResetters = new Map<string, () => void>()
let activePlayback: ActivePlaybackEntry | null = null
function resetOwnerState(ownerId: string) {
stateResetters.get(ownerId)?.()
}
function stopActivePlayback(ownerId?: string) {
if (!activePlayback) return
if (ownerId && activePlayback.ownerId !== ownerId) return
const current = activePlayback
activePlayback = null
current.stop()
}
function setActivePlayback(ownerId: string, stop: () => void) {
if (activePlayback?.ownerId === ownerId) {
activePlayback = { ownerId, stop }
return
}
stopActivePlayback()
activePlayback = { ownerId, stop }
}
export function useSpeech(options: UseSpeechOptions) {
const { t } = useI18n()
const { serverSettings } = useConfig()
const [state, setState] = createSignal<SpeechPlaybackState>("idle")
let requestVersion = 0
let audio: HTMLAudioElement | null = null
let objectUrl: string | null = null
let mediaSource: MediaSource | null = null
let abortController: AbortController | null = null
createEffect(() => {
void loadSpeechCapabilities()
})
const cleanupAudio = () => {
if (abortController) {
abortController.abort()
abortController = null
}
if (audio) {
audio.pause()
audio.currentTime = 0
audio.src = ""
audio.load()
audio = null
}
mediaSource = null
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = null
}
}
const resetState = () => {
requestVersion += 1
cleanupAudio()
setState("idle")
}
stateResetters.set(options.id(), resetState)
onCleanup(() => {
stateResetters.delete(options.id())
stopActivePlayback(options.id())
resetState()
})
const isSupported = () => typeof window !== "undefined" && typeof window.Audio !== "undefined"
const resolvedSettings = () => ({
...serverSettings().speech,
...(options.settingsOverride?.() ?? {}),
})
const canUseSpeech = () => {
const capabilities = speechCapabilities()
if (!isSupported() || !capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
return false
}
return getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
}).available
}
const stop = () => {
if (activePlayback?.ownerId === options.id()) {
activePlayback = null
}
resetState()
}
const start = async () => {
const ownerId = options.id()
const text = options.text().trim()
if (!text || state() === "loading" || state() === "playing") return
if (!isSupported()) {
showAlertDialog(t("messageItem.actions.speak.error.unsupported"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const capabilities = (await loadSpeechCapabilities()) ?? speechCapabilities()
if (!capabilities?.available || !capabilities?.configured || !capabilities?.supportsTts) {
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
variant: "error",
})
return
}
const support = getSpeechPlaybackSupport({
playbackMode: resolvedSettings().playbackMode,
ttsFormat: resolvedSettings().ttsFormat,
capabilities,
})
if (!support.available) {
const detailKey =
support.reason === "provider-streaming-unavailable"
? "settings.speech.compatibility.streamingUnavailable"
: support.reason === "browser-streaming-unavailable"
? "settings.speech.compatibility.browserStreamingUnavailable"
: "messageItem.actions.speak.error.unsupported"
showAlertDialog(t("messageItem.actions.speak.error.unavailable"), {
title: t("messageItem.actions.speak.error.title"),
detail: t(detailKey),
variant: "error",
})
return
}
requestVersion += 1
const currentRequest = requestVersion
stopActivePlayback()
cleanupAudio()
setState("loading")
const settings = resolvedSettings()
const format = settings.ttsFormat
try {
if (settings.playbackMode === "streaming") {
await startStreamingPlayback(ownerId, currentRequest, text, format)
} else {
await startBufferedPlayback(ownerId, currentRequest, text, format)
}
} catch (error) {
if (currentRequest !== requestVersion) {
return
}
resetState()
showAlertDialog(t("messageItem.actions.speak.error.generate"), {
title: t("messageItem.actions.speak.error.title"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
async function startBufferedPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
const response = await serverApi.synthesizeSpeech({ text, format })
if (currentRequest !== requestVersion) {
return
}
const nextUrl = createObjectUrlFromBase64(response.audioBase64, response.mimeType)
const nextAudio = new Audio(nextUrl)
objectUrl = nextUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
setState("playing")
await nextAudio.play()
}
async function startStreamingPlayback(
ownerId: string,
currentRequest: number,
text: string,
format: "mp3" | "wav" | "opus" | "aac",
) {
if (typeof MediaSource === "undefined") {
throw new Error("MediaSource is not available in this browser.")
}
const controller = new AbortController()
abortController = controller
const response = await serverApi.synthesizeSpeechStream({ text, format }, controller.signal)
const mimeType = response.headers.get("content-type") || formatToMimeType(format)
if (!MediaSource.isTypeSupported(mimeType)) {
throw new Error(`Streaming playback is not supported for ${mimeType}.`)
}
const stream = response.body
if (!stream) {
throw new Error("Speech stream did not include a response body.")
}
const nextMediaSource = new MediaSource()
const nextObjectUrl = URL.createObjectURL(nextMediaSource)
const nextAudio = new Audio(nextObjectUrl)
mediaSource = nextMediaSource
objectUrl = nextObjectUrl
audio = nextAudio
attachPlaybackLifecycle(ownerId, nextAudio)
setActivePlayback(ownerId, () => {
cleanupAudio()
setState("idle")
})
await new Promise<void>((resolve, reject) => {
const handleSourceOpen = () => {
nextMediaSource.removeEventListener("sourceopen", handleSourceOpen)
void streamToMediaSource({
mediaSource: nextMediaSource,
stream,
mimeType,
audioElement: nextAudio,
onPlayable: async () => {
if (currentRequest !== requestVersion) return
if (state() !== "playing") {
setState("playing")
}
try {
await nextAudio.play()
} catch (error) {
reject(error)
}
},
onComplete: resolve,
onError: reject,
})
}
nextMediaSource.addEventListener("sourceopen", handleSourceOpen, { once: true })
nextAudio.addEventListener(
"error",
() => reject(new Error("Unable to play streamed speech.")),
{ once: true },
)
})
}
const toggle = async () => {
if (state() === "idle") {
await start()
return
}
stop()
}
return {
state,
canUseSpeech,
isLoading: () => state() === "loading",
isPlaying: () => state() === "playing",
toggle,
stop,
buttonTitle: () => {
if (state() === "loading") return t("messageItem.actions.generatingSpeech")
if (state() === "playing") return t("messageItem.actions.stopSpeech")
return t("messageItem.actions.speak")
},
}
}
function attachPlaybackLifecycle(ownerId: string, audio: HTMLAudioElement) {
const finish = () => {
if (activePlayback?.ownerId === ownerId) {
activePlayback = null
}
resetOwnerState(ownerId)
}
audio.addEventListener("ended", finish, { once: true })
audio.addEventListener("error", finish, { once: true })
}
async function streamToMediaSource(options: {
mediaSource: MediaSource
stream: ReadableStream<Uint8Array>
mimeType: string
audioElement: HTMLAudioElement
onPlayable: () => Promise<void>
onComplete: () => void
onError: (error: unknown) => void
}) {
try {
const sourceBuffer = options.mediaSource.addSourceBuffer(options.mimeType)
const reader = options.stream.getReader()
let startedPlayback = false
let queue: Uint8Array[] = []
let processing = false
const flushQueue = async () => {
if (processing || sourceBuffer.updating || queue.length === 0) return
processing = true
const chunk = queue.shift()!
await appendChunk(sourceBuffer, chunk)
if (!startedPlayback) {
startedPlayback = true
await options.onPlayable()
}
processing = false
await flushQueue()
}
while (true) {
const { done, value } = await reader.read()
if (done) break
if (value && value.byteLength > 0) {
queue.push(value)
await flushQueue()
}
}
while (queue.length > 0 || sourceBuffer.updating) {
if (queue.length > 0) {
await flushQueue()
} else {
await waitForUpdateEnd(sourceBuffer)
}
}
if (options.mediaSource.readyState === "open") {
options.mediaSource.endOfStream()
}
options.onComplete()
} catch (error) {
options.onError(error)
}
}
function appendChunk(sourceBuffer: SourceBuffer, chunk: Uint8Array): Promise<void> {
return new Promise((resolve, reject) => {
const handleUpdateEnd = () => {
cleanup()
resolve()
}
const handleError = () => {
cleanup()
reject(new Error("Failed to append audio stream chunk."))
}
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", handleUpdateEnd)
sourceBuffer.removeEventListener("error", handleError)
}
sourceBuffer.addEventListener("updateend", handleUpdateEnd, { once: true })
sourceBuffer.addEventListener("error", handleError, { once: true })
sourceBuffer.appendBuffer(new Uint8Array(chunk).buffer)
})
}
function waitForUpdateEnd(sourceBuffer: SourceBuffer): Promise<void> {
return new Promise((resolve) => {
sourceBuffer.addEventListener("updateend", () => resolve(), { once: true })
})
}
function createObjectUrlFromBase64(audioBase64: string, mimeType: string): string {
const binary = atob(audioBase64)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return URL.createObjectURL(new Blob([bytes], { type: mimeType || "audio/mpeg" }))
}

View File

@@ -7,10 +7,11 @@ type Messages = Record<string, string>
export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" | "he"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans", "he"] as const
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const RTL_LOCALES = new Set<Locale>(["he"])
const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
@@ -22,6 +23,11 @@ const localeLoaders: Record<Locale, () => Promise<Messages>> = {
ru: async () => (await import("./messages/ru")).ruMessages,
ja: async () => (await import("./messages/ja")).jaMessages,
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
he: async () => (await import("./messages/he")).heMessages,
}
function getLocaleDirection(locale: Locale): "ltr" | "rtl" {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
}
function normalizeLocaleTag(value: string): string {
@@ -149,6 +155,8 @@ export const I18nProvider: ParentComponent = (props) => {
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
const previousGlobalMessages = globalMessages
const previousGlobalLocale = globalLocale
const previousDocumentLanguage = typeof document !== "undefined" ? document.documentElement.lang : ""
const previousDocumentDirection = typeof document !== "undefined" ? document.documentElement.dir : ""
onMount(() => {
const detected = detectNavigatorLocale()
@@ -195,10 +203,21 @@ export const I18nProvider: ParentComponent = (props) => {
})
})
createEffect(() => {
if (typeof document === "undefined") return
const activeLocale = locale()
document.documentElement.dir = getLocaleDirection(activeLocale)
document.documentElement.lang = activeLocale
})
onCleanup(() => {
globalMessages = previousGlobalMessages
globalLocale = previousGlobalLocale
setGlobalRevision((value) => value + 1)
if (typeof document !== "undefined") {
document.documentElement.lang = previousDocumentLanguage
document.documentElement.dir = previousDocumentDirection
}
})
const value: I18nContextValue = {

View File

@@ -95,6 +95,18 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Status",
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.actions.save": "Save (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "Do you want to save changes to \"{path}\" before switching?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Save",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Discard Changes",
"instanceShell.rightPanel.actions.conflict.message": "File was modified by the agent. Overwrite agent's changes?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Overwrite",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancel",
"instanceShell.rightPanel.actions.refreshDirty.message": "File has unsaved changes. Refresh will discard your edits. Continue?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Refresh",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancel",
"instanceShell.rightPanel.toast.saveSuccess": "File saved successfully",
"instanceShell.rightPanel.toast.saveError": "Failed to save file",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan",
@@ -114,6 +126,7 @@ export const instanceMessages = {
"instanceShell.sessionChanges.filesChanged": "{count} files changed",
"instanceShell.sessionChanges.actions.show": "Show changes",
"instanceShell.gitChanges.noSessionSelected": "Select a session to view git changes.",
"instanceShell.gitChanges.loading": "Loading git changes...",
"instanceShell.gitChanges.empty": "No git changes yet.",
"instanceShell.gitChanges.deleted": "Deleted",
@@ -124,6 +137,15 @@ export const instanceMessages = {
"instanceShell.filesShell.viewerTitle": "Change viewer",
"instanceShell.filesShell.viewerPlaceholder": "Detailed change rendering will be added in the next step.",
"instanceShell.filesShell.viewerEmpty": "No file selected.",
"instanceShell.filesShell.hideFiles": "Hide files",
"instanceShell.filesShell.showFiles": "Show files",
"instanceShell.diff.hideUnchanged": "Hide unchanged regions",
"instanceShell.diff.showFull": "Show full file",
"instanceShell.diff.switchToSplit": "Switch to split view",
"instanceShell.diff.switchToUnified": "Switch to unified view",
"instanceShell.diff.enableWordWrap": "Enable word wrap",
"instanceShell.diff.disableWordWrap": "Disable word wrap",
"instanceShell.worktree.create": "+ Create worktree",
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
"instanceShell.plan.empty": "Nothing planned yet.",

View File

@@ -75,6 +75,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!",
"messageItem.actions.speak": "Speak message",
"messageItem.actions.generatingSpeech": "Generating speech",
"messageItem.actions.stopSpeech": "Stop playback",
"messageItem.actions.speak.error.title": "Speech playback failed",
"messageItem.actions.speak.error.unsupported": "Speech playback is not supported in this browser.",
"messageItem.actions.speak.error.unavailable": "Speech playback is unavailable until speech settings are configured.",
"messageItem.actions.speak.error.generate": "Unable to generate speech for this message.",
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
"messageItem.actions.deletingMessage": "Deleting...",
@@ -135,7 +142,21 @@ export const messagingMessages = {
"promptInput.overlay.againToAbort": "again to abort session",
"promptInput.stopSession.ariaLabel": "Stop session",
"promptInput.stopSession.title": "Stop session",
"promptInput.clear.ariaLabel": "Clear prompt text",
"promptInput.clear.title": "Clear prompt text",
"promptInput.send.ariaLabel": "Send message",
"promptInput.send.errorFallback": "Failed to send message",
"promptInput.send.errorTitle": "Send failed",
"promptInput.conversationMode.enable.title": "Enable conversation mode",
"promptInput.conversationMode.disable.title": "Disable conversation mode",
"promptInput.conversationMode.error.title": "Conversation playback failed",
"promptInput.conversationMode.error.message": "Unable to continue speaking assistant replies.",
"promptInput.voiceInput.start.title": "Start voice input",
"promptInput.voiceInput.stop.title": "Stop recording and transcribe",
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
"promptInput.voiceInput.error.title": "Voice input failed",
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Show or hide token and cost stats for assistant messages.",
"settings.behavior.autoCleanup.title": "Auto-cleanup blank sessions",
"settings.behavior.autoCleanup.subtitle": "Automatically clean up blank sessions when creating new ones.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter to submit",
"settings.behavior.promptSubmit.subtitle": "Use Enter to submit prompts; Cmd/Ctrl+Enter inserts a new line.",
"settings.speech.title": "Speech",
"settings.speech.subtitle": "Configure speech-to-text now and text-to-speech groundwork for later features.",
"settings.speech.provider.title": "Provider",
"settings.speech.provider.subtitle": "Speech requests use the server-side speech adapter.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Checking configuration...",
"settings.speech.status.configured": "Configured",
"settings.speech.status.missing": "Missing API key",
"settings.speech.status.error": "Speech service unavailable",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Used for CodeNomad-managed speech requests.",
"settings.speech.apiKey.placeholder": "Enter a new API key",
"settings.speech.apiKey.storedNote": "A saved API key is hidden. Enter a new value to replace it, or leave the field blank to keep it.",
"settings.speech.apiKey.clearAction": "Clear saved key",
"settings.speech.apiKey.clearPending": "The saved API key will be removed when you save.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Optional override for OpenAI-compatible speech endpoints.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Transcription model",
"settings.speech.sttModel.subtitle": "Model used for prompt speech-to-text requests.",
"settings.speech.ttsModel.title": "Speech model",
"settings.speech.ttsModel.subtitle": "Default text-to-speech model reserved for future playback features.",
"settings.speech.ttsVoice.title": "Default voice",
"settings.speech.ttsVoice.subtitle": "Default text-to-speech voice reserved for future playback features.",
"settings.speech.playbackMode.title": "Playback mode",
"settings.speech.playbackMode.subtitle": "Choose whether TTS starts playing as audio streams in or after the full file is generated.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Output format",
"settings.speech.ttsFormat.subtitle": "Choose the audio format for synthesized speech. Streaming support depends on your provider and browser.",
"settings.speech.help": "Prompt voice input appears when speech transcription is configured and supported. Message playback uses the TTS mode and format selected here.",
"settings.speech.compatibility.streamingUnavailable": "Your current speech provider configuration does not advertise streaming TTS. Switch playback mode to buffered if you want playback to work now.",
"settings.speech.compatibility.browserStreamingUnavailable": "Your current browser cannot stream the selected TTS format. Choose buffered playback or switch to a different format.",
"settings.speech.compatibility.runtimeNote": "All formats stay selectable in streaming mode. Some browser and provider combinations may still fail at playback time.",
"settings.speech.testPlayback.action": "Test playback",
"settings.speech.testPlayback.generating": "Generating sample",
"settings.speech.testPlayback.stop": "Stop sample",
"settings.speech.testPlayback.sample": "Thank you for using CodeNomad, your speech settings are working fine.",
"settings.speech.testPlayback.note": "The test uses your current playback mode and format immediately. Save API key, base URL, model, or voice changes first if you want those reflected too.",
"settings.speech.save.action": "Save",
"settings.speech.save.saving": "Saving...",
"settings.speech.save.saved": "Saved",
"settings.speech.save.unsaved": "Unsaved changes",
"settings.speech.save.error": "Save failed",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.actions.refresh": "Actualizar",
"instanceShell.rightPanel.actions.save": "Guardar (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "¿Deseas guardar los cambios en \"{path}\" antes de cambiar?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Guardar",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Descartar cambios",
"instanceShell.rightPanel.actions.conflict.message": "El archivo fue modificado por el agente. ¿Sobrescribir los cambios del agente?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Sobrescribir",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Cancelar",
"instanceShell.rightPanel.actions.refreshDirty.message": "El archivo tiene cambios sin guardar. Actualizar discardará tus ediciones. ¿Continuar?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualizar",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Cancelar",
"instanceShell.rightPanel.toast.saveSuccess": "Archivo guardado exitosamente",
"instanceShell.rightPanel.toast.saveError": "Error al guardar el archivo",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!",
"messageItem.actions.speak": "Reproducir mensaje",
"messageItem.actions.generatingSpeech": "Generando audio",
"messageItem.actions.stopSpeech": "Detener reproduccion",
"messageItem.actions.speak.error.title": "La reproduccion de voz fallo",
"messageItem.actions.speak.error.unsupported": "La reproduccion de voz no es compatible con este navegador.",
"messageItem.actions.speak.error.unavailable": "La reproduccion de voz no estara disponible hasta que la configuracion de voz este lista.",
"messageItem.actions.speak.error.generate": "No se pudo generar audio para este mensaje.",
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
"messageItem.actions.deletingMessage": "Eliminando...",
@@ -137,7 +144,21 @@ export const messagingMessages = {
"promptInput.overlay.againToAbort": "otra vez para abortar la sesión",
"promptInput.stopSession.ariaLabel": "Detener sesión",
"promptInput.stopSession.title": "Detener sesión",
"promptInput.clear.ariaLabel": "Borrar el texto del prompt",
"promptInput.clear.title": "Borrar el texto del prompt",
"promptInput.send.ariaLabel": "Enviar mensaje",
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
"promptInput.send.errorTitle": "Error al enviar",
"promptInput.conversationMode.enable.title": "Activar modo conversacion",
"promptInput.conversationMode.disable.title": "Desactivar modo conversacion",
"promptInput.conversationMode.error.title": "Fallo la reproduccion de la conversacion",
"promptInput.conversationMode.error.message": "No se pudieron seguir reproduciendo las respuestas del asistente.",
"promptInput.voiceInput.start.title": "Iniciar entrada de voz",
"promptInput.voiceInput.stop.title": "Detener grabación y transcribir",
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
"promptInput.voiceInput.error.title": "La entrada de voz falló",
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
"promptInput.voiceInput.error.permissionDenied": "macOS denegó el acceso al micrófono.",
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Muestra u oculta estadisticas de tokens y costo en mensajes del asistente.",
"settings.behavior.autoCleanup.title": "Limpieza automatica de sesiones en blanco",
"settings.behavior.autoCleanup.subtitle": "Limpia automaticamente las sesiones en blanco al crear nuevas.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Enter para enviar",
"settings.behavior.promptSubmit.subtitle": "Usa Enter para enviar; Cmd/Ctrl+Enter inserta una nueva linea.",
"settings.speech.title": "Voz",
"settings.speech.subtitle": "Configura ahora el reconocimiento de voz y prepara la base de texto a voz para funciones futuras.",
"settings.speech.provider.title": "Proveedor",
"settings.speech.provider.subtitle": "Las solicitudes de voz usan el adaptador de voz del servidor.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Comprobando configuración...",
"settings.speech.status.configured": "Configurado",
"settings.speech.status.missing": "Falta la clave API",
"settings.speech.status.error": "Servicio de voz no disponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Se usa para las solicitudes de voz gestionadas por CodeNomad.",
"settings.speech.apiKey.placeholder": "Introduce una nueva clave API",
"settings.speech.apiKey.storedNote": "Hay una clave API guardada y oculta. Introduce un nuevo valor para reemplazarla o deja el campo vacío para conservarla.",
"settings.speech.apiKey.clearAction": "Borrar clave guardada",
"settings.speech.apiKey.clearPending": "La clave API guardada se eliminará al guardar.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Anulación opcional para endpoints de voz compatibles con OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modelo de transcripción",
"settings.speech.sttModel.subtitle": "Modelo usado para las solicitudes de voz a texto en el prompt.",
"settings.speech.ttsModel.title": "Modelo de voz",
"settings.speech.ttsModel.subtitle": "Modelo predeterminado de texto a voz reservado para futuras funciones de reproducción.",
"settings.speech.ttsVoice.title": "Voz predeterminada",
"settings.speech.ttsVoice.subtitle": "Voz predeterminada de texto a voz reservada para futuras funciones de reproducción.",
"settings.speech.playbackMode.title": "Modo de reproduccion",
"settings.speech.playbackMode.subtitle": "Elige si TTS empieza a reproducirse mientras llega el audio o despues de generar el archivo completo.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Formato de salida",
"settings.speech.ttsFormat.subtitle": "Elige el formato de audio para la voz sintetizada. La compatibilidad de streaming depende de tu proveedor y navegador.",
"settings.speech.help": "La entrada de voz del prompt aparece cuando la transcripcion de voz esta configurada y es compatible. La reproduccion de mensajes usa el modo y formato TTS seleccionados aqui.",
"settings.speech.compatibility.streamingUnavailable": "Tu configuracion actual del proveedor de voz no anuncia TTS por streaming. Cambia el modo de reproduccion a buffered si quieres que la reproduccion funcione ahora.",
"settings.speech.compatibility.browserStreamingUnavailable": "Tu navegador actual no puede reproducir por streaming el formato TTS seleccionado. Elige reproduccion buffered o cambia a otro formato.",
"settings.speech.compatibility.runtimeNote": "Todos los formatos siguen disponibles en modo streaming. Algunas combinaciones de navegador y proveedor aun pueden fallar al reproducir.",
"settings.speech.testPlayback.action": "Probar reproduccion",
"settings.speech.testPlayback.generating": "Generando muestra",
"settings.speech.testPlayback.stop": "Detener muestra",
"settings.speech.testPlayback.sample": "Gracias por usar CodeNomad, tu configuracion de voz funciona correctamente.",
"settings.speech.testPlayback.note": "La prueba usa de inmediato el modo y formato actuales. Guarda primero los cambios de API key, base URL, modelo o voz si tambien quieres probarlos.",
"settings.speech.save.action": "Guardar",
"settings.speech.save.saving": "Guardando...",
"settings.speech.save.saved": "Guardado",
"settings.speech.save.unsaved": "Cambios sin guardar",
"settings.speech.save.error": "Error al guardar",
} as const

View File

@@ -94,6 +94,19 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Fichiers",
"instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.actions.refresh": "Actualiser",
"instanceShell.rightPanel.actions.save": "Enregistrer (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "Voulez-vous enregistrer les modifications de \"{path}\" avant de changer ?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "Enregistrer",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "Annuler les modifications",
"instanceShell.rightPanel.actions.conflict.message": "Le fichier a été modifié par l'agent. Écraser les modifications de l'agent ?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "Écraser",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "Annuler",
"instanceShell.rightPanel.actions.refreshDirty.message": "Le fichier a des modifications non enregistrées. Actualiser supprimera vos modifications. Continuer ?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "Actualiser",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "Annuler",
"instanceShell.rightPanel.toast.saveSuccess": "Fichier enregistré avec succès",
"instanceShell.rightPanel.toast.saveError": "Échec de l'enregistrement du fichier",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan",

View File

@@ -77,6 +77,13 @@ export const messagingMessages = {
"messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !",
"messageItem.actions.speak": "Lire le message",
"messageItem.actions.generatingSpeech": "Generation de l'audio",
"messageItem.actions.stopSpeech": "Arreter la lecture",
"messageItem.actions.speak.error.title": "La lecture vocale a echoue",
"messageItem.actions.speak.error.unsupported": "La lecture vocale n'est pas prise en charge dans ce navigateur.",
"messageItem.actions.speak.error.unavailable": "La lecture vocale n'est pas disponible tant que les parametres vocaux ne sont pas configures.",
"messageItem.actions.speak.error.generate": "Impossible de generer l'audio pour ce message.",
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
"messageItem.actions.deletingMessage": "Suppression...",
@@ -137,7 +144,21 @@ export const messagingMessages = {
"promptInput.overlay.againToAbort": "à nouveau pour interrompre la session",
"promptInput.stopSession.ariaLabel": "Arrêter la session",
"promptInput.stopSession.title": "Arrêter la session",
"promptInput.clear.ariaLabel": "Effacer le texte du prompt",
"promptInput.clear.title": "Effacer le texte du prompt",
"promptInput.send.ariaLabel": "Envoyer le message",
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
"promptInput.send.errorTitle": "Échec de l'envoi",
"promptInput.conversationMode.enable.title": "Activer le mode conversation",
"promptInput.conversationMode.disable.title": "Desactiver le mode conversation",
"promptInput.conversationMode.error.title": "La lecture de la conversation a echoue",
"promptInput.conversationMode.error.message": "Impossible de continuer a lire les reponses de l'assistant.",
"promptInput.voiceInput.start.title": "Démarrer la saisie vocale",
"promptInput.voiceInput.stop.title": "Arrêter l'enregistrement et transcrire",
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
"promptInput.voiceInput.error.permissionDenied": "macOS a refusé l'accès au microphone.",
"promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.",
"promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.",
} as const

View File

@@ -65,6 +65,7 @@ export const settingsMessages = {
"settings.nav.appearance": "Appearance",
"settings.nav.notifications": "Notifications",
"settings.nav.remote": "Remote Access",
"settings.nav.speech": "Speech",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "This device",
"settings.scope.server": "Server setting",
@@ -137,6 +138,52 @@ export const settingsMessages = {
"settings.behavior.usageMetrics.subtitle": "Afficher ou masquer les stats de tokens et de cout pour les messages de l'assistant.",
"settings.behavior.autoCleanup.title": "Nettoyage auto des sessions vides",
"settings.behavior.autoCleanup.subtitle": "Nettoyer automatiquement les sessions vides lors de la creation de nouvelles.",
"settings.behavior.promptVoiceInput.title": "Prompt voice input",
"settings.behavior.promptVoiceInput.subtitle": "Show the microphone control for speech-to-text prompt input when speech is configured.",
"settings.behavior.promptSubmit.title": "Entrer pour envoyer",
"settings.behavior.promptSubmit.subtitle": "Utiliser Entrer pour envoyer; Cmd/Ctrl+Entrer insere une nouvelle ligne.",
"settings.speech.title": "Voix",
"settings.speech.subtitle": "Configurez dès maintenant la reconnaissance vocale et préparez la synthèse vocale pour de futures fonctionnalités.",
"settings.speech.provider.title": "Fournisseur",
"settings.speech.provider.subtitle": "Les requêtes vocales utilisent l'adaptateur vocal côté serveur.",
"settings.speech.provider.openaiCompatible": "OpenAI-compatible",
"settings.speech.status.loading": "Vérification de la configuration...",
"settings.speech.status.configured": "Configuré",
"settings.speech.status.missing": "Clé API manquante",
"settings.speech.status.error": "Service vocal indisponible",
"settings.speech.apiKey.title": "API key",
"settings.speech.apiKey.subtitle": "Utilisée pour les requêtes vocales gérées par CodeNomad.",
"settings.speech.apiKey.placeholder": "Saisissez une nouvelle clé API",
"settings.speech.apiKey.storedNote": "Une clé API enregistrée est masquée. Saisissez une nouvelle valeur pour la remplacer ou laissez le champ vide pour la conserver.",
"settings.speech.apiKey.clearAction": "Effacer la clé enregistrée",
"settings.speech.apiKey.clearPending": "La clé API enregistrée sera supprimée lors de l'enregistrement.",
"settings.speech.baseUrl.title": "Base URL",
"settings.speech.baseUrl.subtitle": "Remplacement facultatif des points d'accès vocaux compatibles OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "Modèle de transcription",
"settings.speech.sttModel.subtitle": "Modèle utilisé pour les requêtes vocales vers texte du prompt.",
"settings.speech.ttsModel.title": "Modèle vocal",
"settings.speech.ttsModel.subtitle": "Modèle de synthèse vocale par défaut réservé aux futures fonctions de lecture.",
"settings.speech.ttsVoice.title": "Voix par défaut",
"settings.speech.ttsVoice.subtitle": "Voix de synthèse vocale par défaut réservée aux futures fonctions de lecture.",
"settings.speech.playbackMode.title": "Mode de lecture",
"settings.speech.playbackMode.subtitle": "Choisissez si le TTS commence a jouer pendant le flux audio ou apres la generation complete du fichier.",
"settings.speech.playbackMode.streaming": "Streaming",
"settings.speech.playbackMode.buffered": "Buffered",
"settings.speech.ttsFormat.title": "Format de sortie",
"settings.speech.ttsFormat.subtitle": "Choisissez le format audio pour la voix synthetisee. La prise en charge du streaming depend du fournisseur et du navigateur.",
"settings.speech.help": "La saisie vocale du prompt apparait lorsque la transcription vocale est configuree et prise en charge. La lecture des messages utilise le mode et le format TTS selectionnes ici.",
"settings.speech.compatibility.streamingUnavailable": "Votre configuration actuelle du fournisseur vocal n'annonce pas le TTS en streaming. Passez le mode de lecture sur buffered si vous voulez que la lecture fonctionne maintenant.",
"settings.speech.compatibility.browserStreamingUnavailable": "Votre navigateur actuel ne peut pas lire en streaming le format TTS selectionne. Choisissez la lecture buffered ou passez a un autre format.",
"settings.speech.compatibility.runtimeNote": "Tous les formats restent selectionnables en mode streaming. Certaines combinaisons navigateur/fournisseur peuvent quand meme echouer au moment de la lecture.",
"settings.speech.testPlayback.action": "Tester la lecture",
"settings.speech.testPlayback.generating": "Generation de l'extrait",
"settings.speech.testPlayback.stop": "Arreter l'extrait",
"settings.speech.testPlayback.sample": "Merci d'utiliser CodeNomad, vos parametres vocaux fonctionnent correctement.",
"settings.speech.testPlayback.note": "Le test utilise immediatement le mode et le format actuels. Enregistrez d'abord les changements d'API key, d'URL de base, de modele ou de voix si vous voulez aussi les tester.",
"settings.speech.save.action": "Enregistrer",
"settings.speech.save.saving": "Enregistrement...",
"settings.speech.save.saved": "Enregistré",
"settings.speech.save.unsaved": "Modifications non enregistrées",
"settings.speech.save.error": "Échec de l'enregistrement",
} as const

View File

@@ -0,0 +1,6 @@
export const advancedSettingsMessages = {
"advancedSettings.title": "הגדרות מתקדמות",
"advancedSettings.environmentVariables.title": "משתני סביבה",
"advancedSettings.environmentVariables.subtitle": "מוחלים בכל פעם שמופע OpenCode חדש מופעל",
"advancedSettings.actions.close": "סגור",
} as const

View File

@@ -0,0 +1,42 @@
export const appMessages = {
"app.launchError.title": "לא ניתן להפעיל את OpenCode",
"app.launchError.description": "לא הצלחנו להפעיל את קובץ ה-OpenCode שנבחר. בדוק את פלט השגיאה למטה או בחר קובץ בינארי אחר מהגדרות OpenCode.",
"app.launchError.binaryPathLabel": "נתיב הקובץ הבינארי",
"app.launchError.errorOutputLabel": "פלט שגיאה",
"app.launchError.openAdvancedSettings": "פתח הגדרות OpenCode",
"app.launchError.close": "סגור",
"app.launchError.closeTitle": "סגור (Esc)",
"app.launchError.fallbackMessage": "הפעלת סביבת העבודה נכשלה",
"app.stopInstance.confirmMessage": "לעצור את מופע OpenCode? פעולה זו תעצור את השרת.",
"app.stopInstance.title": "עצור מופע",
"app.stopInstance.confirmLabel": "עצור",
"app.stopInstance.cancelLabel": "המשך להריץ",
"emptyState.logoAlt": "לוגו CodeNomad",
"emptyState.brandTitle": "CodeNomad",
"emptyState.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
"emptyState.actions.selectFolder": "בחר תיקייה",
"emptyState.actions.selecting": "בוחר...",
"emptyState.keyboardShortcut": "קיצור מקלדת: {shortcut}",
"emptyState.examples": "דוגמאות: {example}",
"emptyState.multipleInstances": "ניתן לפתוח מספר מופעים של אותה תיקייה",
"releases.upgradeRequired.title": "נדרש שדרוג",
"releases.upgradeRequired.message.withVersion": "שדרג ל-CodeNomad {version} כדי להשתמש בממשק המעודכן.",
"releases.upgradeRequired.message.noVersion": "שדרג את CodeNomad כדי להשתמש בממשק המעודכן.",
"releases.upgradeRequired.action.getUpdate": "קבל עדכון",
"releases.uiUpdated.title": "הממשק עודכן",
"releases.uiUpdated.message": "הממשק עודכן לגרסה {version}.",
"releases.devUpdateAvailable.title": "גרסת פיתוח זמינה",
"releases.devUpdateAvailable.message": "גרסת פיתוח חדשה זמינה: {version}.",
"releases.devUpdateAvailable.action": "צפה בגרסה",
"theme.mode.system": "מערכת",
"theme.mode.light": "בהיר",
"theme.mode.dark": "כהה",
"theme.toggle.title": "ערכת נושא: {mode}",
"theme.toggle.ariaLabel": "ערכת נושא: {mode}",
} as const

View File

@@ -0,0 +1,176 @@
export const commandMessages = {
"commandPalette.title": "לוח פקודות",
"commandPalette.description": "חיפוש והפעלה של פקודות",
"commandPalette.searchPlaceholder": "הקלד פקודה או חיפוש...",
"commandPalette.empty": "לא נמצאו פקודות עבור \"{query}\"",
"commandPalette.category.customCommands": "פקודות מותאמות אישית",
"commandPalette.category.instance": "מופע",
"commandPalette.category.session": "סשן",
"commandPalette.category.agentModel": "סוכן ומודל",
"commandPalette.category.inputFocus": "קלט ופוקוס",
"commandPalette.category.system": "מערכת",
"commandPalette.category.other": "אחר",
"commands.newInstance.label": "מופע חדש",
"commands.newInstance.description": "פתח בורר תיקיות ליצירת מופע חדש",
"commands.newInstance.keywords": "תיקייה, פרויקט, סביבת עבודה",
"commands.closeInstance.label": "סגור מופע",
"commands.closeInstance.description": "עצור את השרת של המופע הנוכחי",
"commands.closeInstance.keywords": "עצור, סגור",
"commands.nextInstance.label": "מופע הבא",
"commands.nextInstance.description": "עבור למופע הבא",
"commands.nextInstance.keywords": "החלף, נווט",
"commands.previousInstance.label": "מופע קודם",
"commands.previousInstance.description": "עבור למופע הקודם",
"commands.previousInstance.keywords": "החלף, נווט",
"commands.newSession.label": "סשן חדש",
"commands.newSession.description": "צור סשן הורה חדש",
"commands.newSession.keywords": "צור, התחל",
"commands.closeSession.label": "סגור סשן",
"commands.closeSession.description": "סגור את סשן ההורה הנוכחי",
"commands.closeSession.keywords": "סגור, עצור",
"commands.scrubSessions.label": "נקה סשנים",
"commands.scrubSessions.description": "הסר סשנים ריקים, סשני תת-סוכן שסיימו את משימתם הראשית, וסשני פיצול מיותרים.",
"commands.scrubSessions.keywords": "ניקוי, ריק, סשנים, הסר, מחק",
"commands.instanceInfo.label": "מידע על מופע",
"commands.instanceInfo.description": "פתח את סקירת המופע ללוגים וסטטוס",
"commands.instanceInfo.keywords": "מידע, לוגים, קונסולה, פלט",
"commands.nextSession.label": "סשן הבא",
"commands.nextSession.description": "עבור לסשן הבא",
"commands.nextSession.keywords": "החלף, נווט",
"commands.previousSession.label": "סשן קודם",
"commands.previousSession.description": "עבור לסשן הקודם",
"commands.previousSession.keywords": "החלף, נווט",
"commands.compactSession.label": "סכם סשן",
"commands.compactSession.description": "סכם ודחוס את הסשן הנוכחי",
"commands.compactSession.keywords": "סיכום, דחיסה",
"commands.compactSession.errorFallback": "סיכום הסשן נכשל",
"commands.compactSession.alert.title": "הסיכום נכשל",
"commands.compactSession.alert.message": "הסיכום נכשל: {message}",
"commands.undoLastMessage.label": "בטל הודעה אחרונה",
"commands.undoLastMessage.description": "בטל את ההודעה האחרונה",
"commands.undoLastMessage.keywords": "חזרה, ביטול",
"commands.undoLastMessage.none.title": "אין פעולות לביטול",
"commands.undoLastMessage.none.message": "אין מה לבטל",
"commands.undoLastMessage.failed.title": "הביטול נכשל",
"commands.undoLastMessage.failed.message": "ביטול ההודעה נכשל",
"commands.openModelSelector.label": "פתח בורר מודלים",
"commands.openModelSelector.description": "בחר מודל אחר",
"commands.openModelSelector.keywords": "מודל, llm, ai",
"commands.selectModelVariant.label": "בחר גרסת מודל",
"commands.selectModelVariant.description": "בחר רמת מאמץ חשיבה למודל הנוכחי",
"commands.selectModelVariant.keywords": "גרסה, חשיבה, מאמץ",
"commands.openAgentSelector.label": "פתח בורר סוכנים",
"commands.openAgentSelector.description": "בחר סוכן אחר",
"commands.openAgentSelector.keywords": "סוכן, מצב",
"commands.clearInput.label": "נקה קלט",
"commands.clearInput.description": "נקה את תיבת הטקסט של הפקודה",
"commands.clearInput.keywords": "נקה, אפס",
"commands.promptSubmitShortcut.label.default": "Enter: שורה חדשה, Cmd/Ctrl+Enter: שלח פקודה",
"commands.promptSubmitShortcut.label.swapped": "Enter: שלח פקודה, Cmd/Ctrl+Enter: שורה חדשה",
"commands.promptSubmitShortcut.description": "החלף את התנהגות Enter ו-Cmd/Ctrl+Enter בקלט הפקודה",
"commands.promptSubmitShortcut.keywords": "enter, cmd, ctrl, שלח, שורה חדשה, קיצור",
"commands.thinkingBlocks.label.show": "הצג חשיבה",
"commands.thinkingBlocks.label.hide": "הסתר חשיבה",
"commands.thinkingBlocks.description": "הצג או הסתר קטעי חשיבה של ה-AI",
"commands.thinkingBlocks.keywords": "חשיבה, הצג, הסתר",
"commands.timelineToolCalls.label.show": "הצג קריאות כלי בציר הזמן",
"commands.timelineToolCalls.label.hide": "הסתר קריאות כלי בציר הזמן",
"commands.timelineToolCalls.description": "הצג/הסתר קריאות כלי בציר הודעות",
"commands.timelineToolCalls.keywords": "ציר זמן, כלי, הצג, הסתר",
"commands.keyboardShortcutHints.label.show": "הצג רמזי קיצורי מקלדת",
"commands.keyboardShortcutHints.label.hide": "הסתר רמזי קיצורי מקלדת",
"commands.keyboardShortcutHints.description": "הצג או הסתר רמזי קיצורי מקלדת בכל הממשק",
"commands.keyboardShortcutHints.description.disabledWeb": "מושבת בממשק Web (רמזי קיצורים תמיד מוסתרים)",
"commands.keyboardShortcutHints.keywords": "קיצור, מקלדת, רמזים",
"commands.common.expanded": "פרוס",
"commands.common.collapsed": "מכווץ",
"commands.common.visible": "גלוי",
"commands.common.hidden": "מוסתר",
"commands.common.enabled": "מופעל",
"commands.common.disabled": "מושבת",
"commands.thinkingBlocksDefault.label": "תצוגת חשיבה: {state}",
"commands.thinkingBlocksDefault.description": "כווץ / פרוס קטעי חשיבה של ה-AI",
"commands.thinkingBlocksDefault.keywords": "חשיבה, פרוס, כווץ, ברירת מחדל",
"commands.diffViewSplit.label": "השתמש בתצוגת diff מפוצלת",
"commands.diffViewSplit.description": "הצג diff של קריאות כלי זה לצד זה",
"commands.diffViewSplit.keywords": "diff, מפוצל, תצוגה",
"commands.diffViewUnified.label": "השתמש בתצוגת diff מאוחדת",
"commands.diffViewUnified.description": "הצג diff של קריאות כלי בשורה אחת",
"commands.diffViewUnified.keywords": "diff, מאוחד, תצוגה",
"commands.toolOutputsDefault.label": "ברירת מחדל לפלטי כלים · {state}",
"commands.toolOutputsDefault.description": "החלף ברירת מחדל לפריסת פלטי כלים",
"commands.toolOutputsDefault.keywords": "כלי, פלט, פרוס, כווץ",
"commands.diagnosticsDefault.label": "ברירת מחדל לאבחון · {state}",
"commands.diagnosticsDefault.description": "החלף ברירת מחדל לפריסת פלט אבחון",
"commands.diagnosticsDefault.keywords": "אבחון, פרוס, כווץ",
"commands.toolInputsVisibility.label": "נראות קלטי כלים · {state}",
"commands.toolInputsVisibility.description": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי",
"commands.toolInputsVisibility.keywords": "כלי, קלטים, ארגומנטים, נראות, הסתר, הצג",
"commands.tokenUsageDisplay.label": "תצוגת שימוש בטוקנים · {state}",
"commands.tokenUsageDisplay.description": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן",
"commands.tokenUsageDisplay.keywords": "טוקן, שימוש, עלות, נתונים",
"commands.autoCleanupBlankSessions.label": "ניקוי אוטומטי של סשנים ריקים · {state}",
"commands.autoCleanupBlankSessions.description": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים",
"commands.autoCleanupBlankSessions.keywords": "אוטומטי, ניקוי, ריק, סשנים",
"commands.showHelp.label": "הצג עזרה",
"commands.showHelp.description": "הצג קיצורי מקלדת ועזרה",
"commands.showHelp.keywords": "קיצורים, עזרה",
"commands.custom.argumentsPrompt.message": "ארגומנטים עבור /{name}",
"commands.custom.argumentsPrompt.title": "פקודה מותאמת אישית",
"commands.custom.argumentsPrompt.inputLabel": "ארגומנטים",
"commands.custom.argumentsPrompt.inputPlaceholder": "למשל: foo bar",
"commands.custom.argumentsPrompt.confirmLabel": "הפעל",
"commands.custom.argumentsPrompt.cancelLabel": "ביטול",
"commands.custom.argumentsPrompt.openFailed.message": "פתיחת תיבת ארגומנטים נכשלה.",
"commands.custom.argumentsPrompt.openFailed.title": "ארגומנטים לפקודה",
"commands.custom.entries.descriptionFallback": "פקודה מותאמת אישית",
"commands.custom.sessionRequired.message": "בחר סשן לפני הפעלת פקודה מותאמת אישית.",
"commands.custom.sessionRequired.title": "נדרש סשן",
"commands.custom.runFailed.message": "הפעלת הפקודה המותאמת אישית נכשלה. בדוק את הקונסולה לפרטים.",
"commands.custom.runFailed.title": "הפקודה נכשלה",
"unifiedPicker.loading.searching": "מחפש...",
"unifiedPicker.loading.loadingWorkspace": "טוען סביבת עבודה...",
"unifiedPicker.title.command": "בחר פקודה",
"unifiedPicker.title.mention": "בחר סוכן או קובץ",
"unifiedPicker.empty": "לא נמצאו תוצאות",
"unifiedPicker.sections.commands": "פקודות",
"unifiedPicker.sections.agents": "סוכנים",
"unifiedPicker.sections.files": "קבצים",
"unifiedPicker.sections.workspaceRoot": "שורש סביבת העבודה",
"unifiedPicker.badge.subagent": "תת-סוכן",
"unifiedPicker.footer.navigate": "ניווט",
"unifiedPicker.footer.select": "בחירה",
"unifiedPicker.footer.close": "סגירה",
} as const

View File

@@ -0,0 +1,16 @@
export const dialogMessages = {
"alertDialog.fallbackTitle.info": "לתשומת לבך",
"alertDialog.fallbackTitle.warning": "נא לבדוק",
"alertDialog.fallbackTitle.error": "משהו השתבש",
"alertDialog.actions.confirm": "אישור",
"alertDialog.actions.run": "הפעל",
"alertDialog.actions.ok": "אישור",
"alertDialog.actions.cancel": "ביטול",
"alertDialog.prompt.inputLabel": "קלט",
"backgroundProcessOutputDialog.title": "פלט תהליך רקע",
"backgroundProcessOutputDialog.actions.close": "סגור",
"backgroundProcessOutputDialog.loading": "טוען פלט...",
"backgroundProcessOutputDialog.truncatedNotice": "הפלט קוצר לצורך התצוגה.",
"backgroundProcessOutputDialog.loadErrorFallback": "טעינת הפלט נכשלה.",
} as const

View File

@@ -0,0 +1,43 @@
export const filesystemMessages = {
"directoryBrowser.defaultDescription": "עיון בתיקיות תחת שורש סביבת העבודה המוגדר.",
"directoryBrowser.close": "סגור",
"directoryBrowser.currentFolder": "תיקייה נוכחית",
"directoryBrowser.selectCurrent": "בחר נוכחית",
"directoryBrowser.newFolder": "תיקייה חדשה",
"directoryBrowser.creating": "יוצר…",
"directoryBrowser.loadingFolders": "טוען תיקיות…",
"directoryBrowser.noFolders": "אין תיקיות זמינות.",
"directoryBrowser.upOneLevel": "עלה רמה אחת",
"directoryBrowser.select": "בחר",
"directoryBrowser.load.errorFallback": "לא ניתן לטעון את מערכת הקבצים",
"directoryBrowser.createFolder.promptMessage": "צור תיקייה חדשה בספרייה הנוכחית.",
"directoryBrowser.createFolder.title": "תיקייה חדשה",
"directoryBrowser.createFolder.inputLabel": "שם תיקייה",
"directoryBrowser.createFolder.inputPlaceholder": "למשל: my-new-project",
"directoryBrowser.createFolder.confirmLabel": "צור",
"directoryBrowser.createFolder.cancelLabel": "ביטול",
"directoryBrowser.createFolder.invalidNameMessage": "נא להזין שם תיקייה יחיד.",
"directoryBrowser.createFolder.invalidNameDetail": "שמות תיקיות אינם יכולים לכלול נטויות, '..', או '~'.",
"directoryBrowser.createFolder.errorFallback": "יצירת התיקייה נכשלה",
"filesystemBrowser.descriptionFallback": "חפש נתיב תחת שורש סביבת העבודה המוגדר.",
"filesystemBrowser.rootLabel": "שורש: {root}",
"filesystemBrowser.actions.close": "סגור",
"filesystemBrowser.actions.retry": "נסה שוב",
"filesystemBrowser.actions.select": "בחר",
"filesystemBrowser.filterLabel": "סינון",
"filesystemBrowser.search.placeholder.directories": "חפש תיקיות",
"filesystemBrowser.search.placeholder.files": "חפש קבצים",
"filesystemBrowser.currentFolder.label": "תיקייה נוכחית",
"filesystemBrowser.currentFolder.selectCurrent": "בחר נוכחית",
"filesystemBrowser.loading.filesystem": "מערכת קבצים",
"filesystemBrowser.loading.workspaceRoot": "שורש סביבת עבודה",
"filesystemBrowser.loading.loadingWithPath": "טוען {path}…",
"filesystemBrowser.empty.noEntries": "לא נמצאו רשומות.",
"filesystemBrowser.navigation.upOneLevel": "עלה רמה אחת",
"filesystemBrowser.hints.navigate": "ניווט",
"filesystemBrowser.hints.select": "בחירה",
"filesystemBrowser.hints.close": "סגירה",
"filesystemBrowser.errors.loadFilesystemFallback": "לא ניתן לטעון את מערכת הקבצים",
"filesystemBrowser.errors.openDirectoryFallback": "לא ניתן לפתוח את הספרייה",
} as const

View File

@@ -0,0 +1,42 @@
export const folderSelectionMessages = {
"folderSelection.language.ariaLabel": "שפה",
"folderSelection.logoAlt": "לוגו CodeNomad",
"folderSelection.tagline": "בחר תיקייה כדי להתחיל לתכנת עם AI",
"folderSelection.links.github": "CodeNomad GitHub",
"folderSelection.links.githubStars": "כוכבי CodeNomad ב-GitHub",
"folderSelection.links.discord": "CodeNomad Discord",
"folderSelection.empty.title": "אין תיקיות אחרונות",
"folderSelection.empty.description": "עיין בתיקייה כדי להתחיל",
"folderSelection.recent.title": "תיקיות אחרונות",
"folderSelection.recent.subtitle.one": "תיקייה אחת זמינה",
"folderSelection.recent.subtitle.other": "{count} תיקיות זמינות",
"folderSelection.recent.remove": "הסר מהרשימה האחרונה",
"folderSelection.browse.title": "עיון בתיקייה",
"folderSelection.browse.subtitle": "בחר כל תיקייה במחשב שלך",
"folderSelection.browse.button": "עיון בתיקיות",
"folderSelection.browse.buttonOpening": "פותח...",
"folderSelection.advancedSettings": "הגדרות מתקדמות",
"folderSelection.opencode": "OpenCode",
"folderSelection.hints.navigate": "ניווט",
"folderSelection.hints.select": "בחירה",
"folderSelection.hints.remove": "הסרה",
"folderSelection.hints.browse": "עיון",
"folderSelection.loading.title": "מפעיל מופע...",
"folderSelection.loading.subtitle": "המתן בזמן שאנו מכינים את סביבת העבודה שלך.",
"folderSelection.drop.title": "שחרר תיקייה כדי לפתוח אותה",
"folderSelection.drop.subtitle": "התחל מופע חדש בתיקייה שנשחררה.",
"folderSelection.drop.invalidTitle": "לא ניתן לפתוח את הפריט שנשחרר",
"folderSelection.drop.invalidMessage": "שחרר תיקייה כדי להתחיל מופע חדש.",
"folderSelection.dialog.title": "בחר סביבת עבודה",
"folderSelection.dialog.description": "בחר סביבת עבודה כדי להתחיל לתכנת.",
} as const

View File

@@ -0,0 +1,36 @@
import { advancedSettingsMessages } from "./advancedSettings"
import { appMessages } from "./app"
import { commandMessages } from "./commands"
import { dialogMessages } from "./dialogs"
import { filesystemMessages } from "./filesystem"
import { folderSelectionMessages } from "./folderSelection"
import { instanceMessages } from "./instance"
import { loadingScreenMessages } from "./loadingScreen"
import { logMessages } from "./logs"
import { markdownMessages } from "./markdown"
import { messagingMessages } from "./messaging"
import { remoteAccessMessages } from "./remoteAccess"
import { sessionMessages } from "./session"
import { settingsMessages } from "./settings"
import { timeMessages } from "./time"
import { toolCallMessages } from "./toolCall"
import { mergeMessageParts } from "../merge"
export const heMessages = mergeMessageParts(
folderSelectionMessages,
advancedSettingsMessages,
loadingScreenMessages,
timeMessages,
appMessages,
dialogMessages,
filesystemMessages,
instanceMessages,
logMessages,
sessionMessages,
messagingMessages,
toolCallMessages,
markdownMessages,
settingsMessages,
remoteAccessMessages,
commandMessages,
)

View File

@@ -0,0 +1,178 @@
export const instanceMessages = {
"instanceTabs.new.title": "מופע חדש (Cmd/Ctrl+N)",
"instanceTabs.new.ariaLabel": "מופע חדש",
"instanceTabs.remote.title": "חיבור מרוחק",
"instanceTabs.remote.ariaLabel": "חיבור מרוחק",
"instanceInfo.title": "מידע על המופע",
"instanceInfo.labels.folder": "תיקייה",
"instanceInfo.labels.project": "פרויקט",
"instanceInfo.labels.versionControl": "בקרת גרסאות",
"instanceInfo.labels.opencodeVersion": "גרסת OpenCode",
"instanceInfo.labels.binaryPath": "נתיב קובץ בינארי",
"instanceInfo.labels.environmentVariables": "משתני סביבה ({count})",
"instanceInfo.loading": "טוען...",
"instanceInfo.server.title": "שרת",
"instanceInfo.server.port": "פורט:",
"instanceInfo.server.pid": "PID:",
"instanceInfo.server.status": "סטטוס:",
"instanceTab.status.permission": "ממתין לאישור",
"instanceTab.status.compacting": "מסכם",
"instanceTab.status.working": "עובד",
"instanceTab.status.idle": "מוכן",
"instanceTab.status.ariaLabel": "סטטוס מופע: {status}",
"instanceTab.actions.close.ariaLabel": "סגור מופע",
"instanceShell.leftPanel.sessionsTitle": "סשנים",
"instanceShell.leftPanel.instanceInfo": "מידע על המופע",
"instanceShell.leftDrawer.pin": "נעץ מגירה שמאלית",
"instanceShell.leftDrawer.unpin": "שחרר נעיצת מגירה שמאלית",
"instanceShell.leftDrawer.toggle.pinned": "המגירה השמאלית נעוצה",
"instanceShell.leftDrawer.toggle.open": "פתח מגירה שמאלית",
"instanceShell.leftDrawer.toggle.close": "סגור מגירה שמאלית",
"instanceShell.rightDrawer.pin": "נעץ מגירה ימנית",
"instanceShell.rightDrawer.unpin": "שחרר נעיצת מגירה ימנית",
"instanceShell.rightDrawer.toggle.pinned": "המגירה הימנית נעוצה",
"instanceShell.rightDrawer.toggle.open": "פתח מגירה ימנית",
"instanceShell.rightDrawer.toggle.close": "סגור מגירה ימנית",
"instanceShell.fullscreen.enter": "מסך מלא",
"instanceShell.fullscreen.exit": "יציאה ממסך מלא",
"instanceShell.metrics.usedLabel": "בשימוש",
"instanceShell.metrics.availableLabel": "זמין",
"instanceShell.commandPalette.openAriaLabel": "פתח לוח פקודות",
"instanceShell.commandPalette.button": "לוח פקודות",
"instanceShell.connection.ariaLabel": "חיבור {status}",
"instanceShell.connection.connected": "מחובר",
"instanceShell.connection.connecting": "מתחבר...",
"instanceShell.connection.disconnected": "מנותק",
"instanceShell.connection.unknown": "לא ידוע",
"instanceWelcome.shortcuts.newSession": "סשן חדש",
"instanceWelcome.empty.title": "אין סשנים קודמים",
"instanceWelcome.empty.description": "צור סשן חדש למטה כדי להתחיל",
"instanceWelcome.loading.title": "טוען סשנים",
"instanceWelcome.loading.description": "מאחזר את הסשנים הקודמים שלך...",
"instanceWelcome.resume.title": "המשך סשן",
"instanceWelcome.resume.subtitle.one": "סשן אחד זמין",
"instanceWelcome.resume.subtitle.other": "{count} סשנים זמינים",
"instanceWelcome.session.untitled": "סשן ללא שם",
"instanceWelcome.new.title": "התחל סשן חדש",
"instanceWelcome.new.subtitle": "ישתמש אוטומטית בסוכן/מודל האחרון שלך",
"instanceWelcome.new.createButton": "צור סשן",
"instanceWelcome.overlay.close": "סגור",
"instanceWelcome.actions.viewInstanceInfo": "צפה במידע על המופע",
"instanceWelcome.actions.renameTitle": "שנה שם סשן",
"instanceWelcome.actions.deleteTitle": "מחק סשן",
"instanceWelcome.hints.navigate": "ניווט",
"instanceWelcome.hints.jump": "קפיצה",
"instanceWelcome.hints.firstLast": "ראשון/אחרון",
"instanceWelcome.hints.resume": "המשך",
"instanceWelcome.hints.delete": "מחיקה",
"instanceWelcome.toasts.renameError": "לא ניתן לשנות שם הסשן",
"instanceDisconnected.title": "המופע התנתק",
"instanceDisconnected.folderFallback": "סביבת עבודה זו",
"instanceDisconnected.reasonFallback": "השרת הפסיק להגיב",
"instanceDisconnected.description": "לא ניתן עוד להגיע ל-{folder}. סגור את הלשונית כדי להמשיך לעבוד.",
"instanceDisconnected.details.title": "פרטים",
"instanceDisconnected.details.folderLabel": "תיקייה:",
"instanceDisconnected.actions.closeInstance": "סגור מופע",
"instanceShell.empty.title": "לא נבחר סשן",
"instanceShell.empty.description": "בחר סשן לצפייה בהודעות",
"instanceShell.rightPanel.title": "לוח סטטוס",
"instanceShell.rightPanel.tabs.changes": "שינויי סשן",
"instanceShell.rightPanel.tabs.gitChanges": "שינויי Git",
"instanceShell.rightPanel.tabs.files": "קבצים",
"instanceShell.rightPanel.tabs.status": "סטטוס",
"instanceShell.rightPanel.tabs.ariaLabel": "לשוניות לוח ימני",
"instanceShell.rightPanel.actions.refresh": "רענן",
"instanceShell.rightPanel.actions.save": "שמור (Ctrl+S)",
"instanceShell.rightPanel.actions.saveConfirm.message": "האם ברצונך לשמור את השינויים לפני המעבר?",
"instanceShell.rightPanel.actions.saveConfirm.confirmLabel": "שמור",
"instanceShell.rightPanel.actions.saveConfirm.cancelLabel": "בטל שינויים",
"instanceShell.rightPanel.actions.conflict.message": "הקובץ שונה על ידי הסוכן. לדרוס את שינויי הסוכן?",
"instanceShell.rightPanel.actions.conflict.confirmLabel": "דרוס",
"instanceShell.rightPanel.actions.conflict.cancelLabel": "בטל",
"instanceShell.rightPanel.actions.refreshDirty.message": "לקובץ יש שינויים שלא נשמרו. רענון יבטל את העריכות שלך. להמשיך?",
"instanceShell.rightPanel.actions.refreshDirty.confirmLabel": "רענן",
"instanceShell.rightPanel.actions.refreshDirty.cancelLabel": "בטל",
"instanceShell.rightPanel.toast.saveSuccess": "הקובץ נשמר בהצלחה",
"instanceShell.rightPanel.toast.saveError": "כשלון בשמירת הקובץ",
"instanceShell.rightPanel.sections.sessionChanges": "שינויי סשן",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "קבצים שהשתנו בסשן הנוכחי. מציג הוספות ומחיקות לכל קובץ.",
"instanceShell.rightPanel.sections.plan": "תוכנית",
"instanceShell.rightPanel.sections.plan.tooltip": "מפת הדרכים של הסוכן לסשן זה. עוקב אחר משימות, תת-משימות וסטטוס השלמתן.",
"instanceShell.rightPanel.sections.backgroundProcesses": "מעטפות רקע",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "תהליכים ממושכים שהופעלו על ידי הסוכן. ניתן לעקוב אחר פלטם, לעצור אותם או לסיים אותם.",
"instanceShell.rightPanel.sections.mcp": "שרתי MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "שרתי Model Context Protocol המרחיבים את יכולות הסוכן עם כלים ושירותים חיצוניים.",
"instanceShell.rightPanel.sections.lsp": "שרתי LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "שרתי Language Server Protocol המספקים בינת קוד, אבחון ותכונות ספציפיות לשפה.",
"instanceShell.rightPanel.sections.plugins": "תוספים",
"instanceShell.rightPanel.sections.plugins.tooltip": "תוספים המתאימים אישית את הממשק ואת התנהגות השרת, ומוסיפים תכונות מעבר ל-MCP ו-LSP.",
"instanceShell.sessionChanges.noSessionSelected": "בחר סשן לצפייה בשינויים.",
"instanceShell.sessionChanges.loading": "מאחזר שינויי סשן...",
"instanceShell.sessionChanges.empty": "אין שינויי סשן עדיין.",
"instanceShell.sessionChanges.filesChanged": "{count} קבצים שונו",
"instanceShell.sessionChanges.actions.show": "הצג שינויים",
"instanceShell.filesShell.fileListTitle": "רשימת קבצים",
"instanceShell.filesShell.mobileSelectorLabel": "בחר קובץ",
"instanceShell.filesShell.mobileSelectorEmpty": "בחר קובץ",
"instanceShell.filesShell.viewerTitle": "מציג שינויים",
"instanceShell.filesShell.viewerPlaceholder": "תצוגת שינויים מפורטת תתווסף בשלב הבא.",
"instanceShell.filesShell.viewerEmpty": "לא נבחר קובץ.",
"instanceShell.filesShell.hideFiles": "הסתר קבצים",
"instanceShell.filesShell.showFiles": "הצג קבצים",
"instanceShell.gitChanges.noSessionSelected": "בחר סשן לצפייה בשינויי Git.",
"instanceShell.gitChanges.loading": "טוען שינויי Git…",
"instanceShell.gitChanges.empty": "אין שינויי Git עדיין.",
"instanceShell.diff.hideUnchanged": "הסתר אזורים ללא שינוי",
"instanceShell.diff.showFull": "הצג קובץ מלא",
"instanceShell.diff.switchToSplit": "עבור לתצוגה מפוצלת",
"instanceShell.diff.switchToUnified": "עבור לתצוגה מאוחדת",
"instanceShell.diff.enableWordWrap": "הפעל גלישת מילים",
"instanceShell.diff.disableWordWrap": "כבה גלישת מילים",
"instanceShell.worktree.create": "+ צור worktree",
"instanceShell.plan.noSessionSelected": "בחר סשן לצפייה בתוכנית.",
"instanceShell.plan.empty": "עדיין לא תוכנן דבר.",
"instanceShell.backgroundProcesses.empty": "אין תהליכי רקע.",
"instanceShell.backgroundProcesses.status": "סטטוס: {status}",
"instanceShell.backgroundProcesses.output": "פלט: {sizeKb}KB",
"instanceShell.backgroundProcesses.actions.output": "פלט",
"instanceShell.backgroundProcesses.actions.stop": "עצור",
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
"versionPill.appWithVersion": "אפליקציה {version}",
"versionPill.ui": "ממשק",
"versionPill.uiWithVersion": "ממשק {version}",
"versionPill.source": " ({source})",
"opencodeBinarySelector.title": "קובץ בינארי של OpenCode",
"opencodeBinarySelector.subtitle": "בחר איזה קובץ הרצה OpenCode ישתמש",
"opencodeBinarySelector.customPath.placeholder": "הזן נתיב לקובץ בינארי של opencode…",
"opencodeBinarySelector.actions.add": "הוסף",
"opencodeBinarySelector.actions.browse": "עיין אחר קובץ בינארי…",
"opencodeBinarySelector.actions.removeTitle": "הסר קובץ בינארי",
"opencodeBinarySelector.badge.systemPath": "השתמש בקובץ בינארי מנתיב המערכת",
"opencodeBinarySelector.status.checkingVersions": "בודק גרסאות…",
"opencodeBinarySelector.status.checking": "בודק…",
"opencodeBinarySelector.dialog.title": "בחר קובץ בינארי של OpenCode",
"opencodeBinarySelector.dialog.description": "עיין בקבצים החשופים על ידי שרת ה-CLI.",
"opencodeBinarySelector.validation.invalidBinary": "קובץ בינארי לא תקין של OpenCode",
"opencodeBinarySelector.validation.alreadyValidating": "כבר מאמת",
"opencodeBinarySelector.display.systemPath": "{name} (נתיב מערכת)",
"opencodeBinarySelector.versionLabel": "v{version}",
} as const

View File

@@ -0,0 +1,17 @@
export const loadingScreenMessages = {
"loadingScreen.logoAlt": "לוגו CodeNomad",
"loadingScreen.status.issue": "נתקלנו בבעיה",
"loadingScreen.actions.showAnother": "הצג עוד",
"loadingScreen.errors.missingRoot": "אלמנט השורש לטעינה לא נמצא",
"loadingScreen.phrases.neurons": "מחמם את הנוירונים של ה-AI…",
"loadingScreen.phrases.daydreaming": "משכנע את ה-AI להפסיק לחלום בהקיץ…",
"loadingScreen.phrases.goggles": "מצחצח את משקפי הקוד של ה-AI…",
"loadingScreen.phrases.reorganizingFiles": "מבקש מה-AI להפסיק לארגן מחדש את הקבצים שלך…",
"loadingScreen.phrases.coffee": "מאכיל את ה-AI עוד קפה…",
"loadingScreen.phrases.nodeModules": "מלמד את ה-AI לא למחוק node_modules (שוב)…",
"loadingScreen.phrases.actNatural": "אומר ל-AI להיראות טבעי לפני שתגיע…",
"loadingScreen.phrases.rewritingHistory": "מבקש מה-AI בבקשה להפסיק לשכתב היסטוריה…",
"loadingScreen.phrases.stretch": "מאפשר ל-AI להתמתח לפני ספרינט הקוד שלו…",
"loadingScreen.phrases.keyboardControl": "משכנע את ה-AI לתת לך שליטה על המקלדת…",
} as const

View File

@@ -0,0 +1,27 @@
export const logMessages = {
"logsView.title": "לוגי שרת",
"logsView.actions.show": "הצג לוגי שרת",
"logsView.actions.hide": "הסתר לוגי שרת",
"logsView.envVars.title": "משתני סביבה ({count})",
"logsView.paused.title": "לוגי השרת מושהים",
"logsView.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
"logsView.empty.waiting": "ממתין לפלט שרת...",
"logsView.scrollToBottom": "גלול למטה",
"infoView.logs.title": "לוגי שרת",
"infoView.logs.actions.show": "הצג לוגי שרת",
"infoView.logs.actions.hide": "הסתר לוגי שרת",
"infoView.logs.paused.title": "לוגי השרת מושהים",
"infoView.logs.paused.description": "הפעל זרימה לצפייה בפעילות שרת OpenCode שלך.",
"infoView.logs.empty.waiting": "ממתין לפלט שרת...",
"infoView.logs.scrollToBottom": "גלול למטה",
"infoView.dispose.actions.dispose": "בטל מופע",
"infoView.dispose.actions.disposing": "מבטל...",
"infoView.dispose.confirm.title": "לבטל את המופע?",
"infoView.dispose.confirm.message": "פעולה זו מנקה את המצב השמור לפי פרויקט עבור ספרייה זו ומטעינה מחדש את המופע.",
"infoView.dispose.confirm.confirmLabel": "בטל",
"infoView.dispose.confirm.cancelLabel": "ביטול",
"infoView.dispose.toast.success": "המופע בוטל. מטעין מחדש...",
"infoView.dispose.toast.error": "ביטול המופע נכשל.",
} as const

View File

@@ -0,0 +1,7 @@
export const markdownMessages = {
"markdown.codeBlock.copy.label": "העתק",
"markdown.codeBlock.copy.copied": "הועתק!",
"markdown.codeBlock.copy.failed": "נכשל",
"markdown.copy": "העתק",
} as const

View File

@@ -0,0 +1,162 @@
export const messagingMessages = {
"messageListHeader.sidebar.openSessionListAriaLabel": "פתח רשימת סשנים",
"messageListHeader.metrics.usedLabel": "בשימוש",
"messageListHeader.metrics.availableLabel": "זמין",
"messageListHeader.commandPalette.ariaLabel": "פתח לוח פקודות",
"messageListHeader.commandPalette.button": "לוח פקודות",
"messageListHeader.connection.connected": "מחובר",
"messageListHeader.connection.connecting": "מתחבר...",
"messageListHeader.connection.disconnected": "מנותק",
"messageSection.empty.logoAlt": "לוגו CodeNomad",
"messageSection.empty.brandTitle": "CodeNomad",
"messageSection.empty.title": "התחל שיחה",
"messageSection.empty.description": "הקלד הודעה למטה או פתח את לוח הפקודות:",
"messageSection.empty.tips.commandPalette": "לוח פקודות",
"messageSection.empty.tips.askAboutCodebase": "שאל על בסיס הקוד שלך",
"messageSection.empty.tips.attachFilesPrefix": "צרף קבצים עם",
"messageSection.loading.messages": "טוען הודעות...",
"messageSection.scroll.toFirstAriaLabel": "גלול להודעה הראשונה",
"messageSection.scroll.toLatestAriaLabel": "גלול להודעה האחרונה",
"messageSection.quote.addAsQuote": "הוסף כציטוט",
"messageSection.quote.addAsCode": "הוסף כקוד",
"messageSection.quote.copy": "העתק",
"messageSection.quote.copied": "הועתק!",
"messageSection.quote.copyFailed": "ההעתקה נכשלה",
"messageTimeline.ariaLabel": "ציר זמן הודעות",
"messageTimeline.segment.user.label": "אתה",
"messageTimeline.segment.assistant.label": "סוכן",
"messageTimeline.segment.compaction.label": "סיכום",
"messageTimeline.tool.fallbackLabel": "קריאת כלי",
"messageTimeline.tooltip.userFallback": "הודעת משתמש",
"messageTimeline.tooltip.assistantFallback": "תגובת הסוכן",
"messageTimeline.tooltip.compaction.auto": "סיכום אוטומטי",
"messageTimeline.tooltip.compaction.manual": "סיכום ידני",
"messageTimeline.text.filePrefix": "[קובץ] {filename}",
"messageTimeline.text.attachment": "קובץ מצורף",
"messageBlock.tool.header": "קריאת כלי",
"messageBlock.tool.unknown": "לא ידוע",
"messageBlock.tool.goToSession.label": "עבור לסשן",
"messageBlock.tool.goToSession.title": "עבור לסשן",
"messageBlock.tool.goToSession.unavailableTitle": "הסשן עדיין אינו זמין",
"messageBlock.tool.deletePart.label": "מחק חלק",
"messageBlock.tool.deletePart.deleting": "מוחק...",
"messageBlock.tool.deletePart.title": "מחק את פלט קריאת הכלי הזו",
"messageBlock.tool.deletePart.failed.title": "המחיקה נכשלה",
"messageBlock.tool.deletePart.failed.message": "מחיקת פלט קריאת הכלי נכשלה",
"messageBlock.compaction.ariaLabel": "סיכום סשן",
"messageBlock.compaction.autoLabel": "הסשן סוכם אוטומטית",
"messageBlock.compaction.manualLabel": "הסשן סוכם על ידך",
"messageBlock.usage.input": "קלט",
"messageBlock.usage.output": "פלט",
"messageBlock.usage.reasoning": "חשיבה",
"messageBlock.usage.cacheRead": "קריאת מטמון",
"messageBlock.usage.cacheWrite": "כתיבת מטמון",
"messageBlock.usage.cost": "עלות",
"messageBlock.step.agentLabel": "סוכן: {agent}",
"messageBlock.step.modelLabel": "מודל: {model}",
"messageBlock.reasoning.thinkingLabel": "חשיבה",
"messageBlock.reasoning.expandAriaLabel": "פרוס חשיבה",
"messageBlock.reasoning.collapseAriaLabel": "כווץ חשיבה",
"messageBlock.reasoning.indicator.hide": "הסתר",
"messageBlock.reasoning.indicator.view": "צפה",
"messageBlock.reasoning.detailsAriaLabel": "פרטי חשיבה",
"codeBlockInline.actions.copy": "העתק",
"codeBlockInline.actions.copied": "הועתק!",
"messageItem.speaker.you": "אתה",
"messageItem.speaker.assistant": "סוכן",
"messageItem.actions.revert": "בטל שינויים",
"messageItem.actions.revertTitle": "בטל שינויים עד כאן (מוחק הודעות)",
"messageItem.actions.fork": "פצל",
"messageItem.actions.forkTitle": "פצל מהודעה זו",
"messageItem.actions.copy": "העתק",
"messageItem.actions.copyTitle": "העתק הודעה",
"messageItem.actions.copied": "הועתק!",
"messageItem.actions.speak": "השמע הודעה",
"messageItem.actions.generatingSpeech": "יוצר אודיו",
"messageItem.actions.stopSpeech": "עצור ניגון",
"messageItem.actions.speak.error.title": "ניגון הקול נכשל",
"messageItem.actions.speak.error.unsupported": "ניגון קול אינו נתמך בדפדפן הזה.",
"messageItem.actions.speak.error.unavailable": "ניגון קול לא זמין עד שהגדרות הקול יוגדרו.",
"messageItem.actions.speak.error.generate": "לא ניתן היה ליצור אודיו עבור ההודעה הזו.",
"messageItem.actions.deleteMessage": "מחק הודעה (לא מבטל שינויים)",
"messageItem.actions.deleteMessagesUpTo": "מחק הודעות עד כאן (לא מבטל שינויים)",
"messageItem.actions.deletingMessage": "מוחק...",
"messageItem.actions.deleteMessageFailedTitle": "המחיקה נכשלה",
"messageItem.actions.deleteMessageFailedMessage": "מחיקת ההודעה נכשלה",
"messageItem.selection.checkboxAriaLabel": "בחר הודעה למחיקה",
"messageSection.bulkDelete.toolbarAriaLabel": "פריטים נבחרים ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "מחק פריטים נבחרים",
"messageSection.bulkDelete.selectAllTitle": "בחר את כל ההודעות",
"messageSection.bulkDelete.moreOptionsTitle": "אפשרויות נוספות",
"messageSection.bulkDelete.selectionModeLabel": "בחירה",
"messageSection.bulkDelete.selectionModeAll": "הכל",
"messageSection.bulkDelete.selectionModeTools": "כלים בלבד",
"messageSection.bulkDelete.selectionHint.toggle": "בחר פריט",
"messageSection.bulkDelete.selectionHint.range": "בחר טווח",
"messageSection.bulkDelete.selectionHint.clear": "נקה בחירה",
"messageSection.bulkDelete.cancelTitle": "בטל בחירה",
"messageSection.bulkDelete.failedTitle": "המחיקה נכשלה",
"messageSection.bulkDelete.failedMessage": "מחיקת הפריטים הנבחרים נכשלה",
"messageItem.status.queued": "בתור",
"messageItem.status.generating": "מייצר...",
"messageItem.status.sending": "שולח...",
"messageItem.status.failedToSend": "שליחת ההודעה נכשלה",
"messagePart.actions.delete": "מחק חלק",
"messagePart.actions.deleting": "מוחק...",
"messagePart.actions.deleteTitle": "מחק פריט זה",
"messagePart.actions.deleteFailedTitle": "המחיקה נכשלה",
"messagePart.actions.deleteFailedMessage": "מחיקת הפריט נכשלה",
"messageItem.attachment.defaultName": "קובץ מצורף",
"messageItem.attachment.downloadAriaLabel": "הורד {name}",
"messageItem.agentMeta.agentLabel": "סוכן: {agent}",
"messageItem.agentMeta.modelLabel": "מודל: {model}",
"messageItem.errors.authenticationFallback": "שגיאת אימות",
"messageItem.errors.outputLengthExceeded": "אורך פלט ההודעה חרג מהמגבלה",
"messageItem.errors.requestAborted": "הבקשה בוטלה",
"messageItem.errors.unknownFallback": "אירעה שגיאה לא ידועה",
"attachmentChip.removeAriaLabel": "הסר קובץ מצורף",
"expandButton.toggleAriaLabel": "שנה גובה תיבת הקלט",
"promptInput.placeholder.shell": "הפעל פקודת מעטפת (Esc ליציאה)...",
"promptInput.placeholder.default": "הקלד הודעה, @file, @agent, או הדבק תמונות וטקסט...",
"promptInput.hints.shell.exit": "לצאת ממצב מעטפת",
"promptInput.hints.shell.enable": "מצב מעטפת",
"promptInput.hints.commands": "פקודות",
"promptInput.history.previousAriaLabel": "פקודה קודמת",
"promptInput.history.nextAriaLabel": "פקודה הבאה",
"promptInput.overlay.newLine": "שורה חדשה",
"promptInput.overlay.send": "שלח",
"promptInput.overlay.filesAgents": "קבצים/סוכנים",
"promptInput.overlay.history": "היסטוריה",
"promptInput.overlay.attachments": "• {count} קובץ/ים מצורף/ים",
"promptInput.overlay.shellModeActive": "מצב מעטפת פעיל",
"promptInput.overlay.press": "לחץ",
"promptInput.overlay.againToAbort": "שוב כדי לבטל את הסשן",
"promptInput.stopSession.ariaLabel": "עצור סשן",
"promptInput.stopSession.title": "עצור סשן",
"promptInput.clear.ariaLabel": "נקה את טקסט הפרומפט",
"promptInput.clear.title": "נקה את טקסט הפרומפט",
"promptInput.send.ariaLabel": "שלח הודעה",
"promptInput.send.errorFallback": "שליחת ההודעה נכשלה",
"promptInput.send.errorTitle": "השליחה נכשלה",
"promptInput.conversationMode.enable.title": "הפעל מצב שיחה",
"promptInput.conversationMode.disable.title": "כבה מצב שיחה",
"promptInput.conversationMode.error.title": "ניגון השיחה נכשל",
"promptInput.conversationMode.error.message": "לא ניתן היה להמשיך להקריא את תגובות העוזר.",
"promptInput.voiceInput.start.title": "התחל קלט קולי",
"promptInput.voiceInput.stop.title": "עצור הקלטה ותמלל",
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
"promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.",
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
} as const

View File

@@ -0,0 +1,51 @@
export const remoteAccessMessages = {
"remoteAccess.eyebrow": "גישה מרוחקת",
"remoteAccess.title": "התחבר ל-CodeNomad מרחוק",
"remoteAccess.subtitle": "השתמש בכתובות למטה כדי לפתוח את CodeNomad ממכשיר אחר.",
"remoteAccess.close": "סגור גישה מרוחקת",
"remoteAccess.refresh": "רענן",
"remoteAccess.sections.listeningMode.label": "מצב האזנה",
"remoteAccess.sections.listeningMode.help": "אפשר או הגבל גישה מרוחקת על ידי קישור לכל הממשקים או רק ל-localhost.",
"remoteAccess.toggle.on": "פועל",
"remoteAccess.toggle.off": "כבוי",
"remoteAccess.toggle.title": "אפשר חיבורים מכתובות IP אחרות",
"remoteAccess.toggle.caption.all": "מקושר ל-0.0.0.0",
"remoteAccess.toggle.caption.local": "מקושר ל-127.0.0.1",
"remoteAccess.toggle.note": "שינוי זה דורש הפעלה מחדש ועוצר זמנית את כל המופעים הפעילים. שתף את הכתובות למטה לאחר שהשרת יופעל מחדש.",
"remoteAccess.listeningMode.restartConfirm.message": "להפעיל מחדש כדי להחיל מצב האזנה? פעולה זו תעצור את כל המופעים הפעילים.",
"remoteAccess.listeningMode.restartConfirm.title.all": "פתוח למכשירים אחרים",
"remoteAccess.listeningMode.restartConfirm.title.local": "מוגבל למכשיר זה",
"remoteAccess.listeningMode.restartConfirm.confirmLabel": "הפעל מחדש עכשיו",
"remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול",
"remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.",
"remoteAccess.sections.serverPassword.label": "סיסמת שרת",
"remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.",
"remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.",
"remoteAccess.username": "שם משתמש: {username}",
"remoteAccess.password.status.set": "סיסמה מוגדרת לגישה מרוחקת.",
"remoteAccess.password.status.unset": "לא הוגדרה סיסמה קלה לזכירה. הגדר סיסמה כדי לאפשר כניסות גישה מרוחקת.",
"remoteAccess.password.actions.cancel": "ביטול",
"remoteAccess.password.actions.change": "שנה סיסמה",
"remoteAccess.password.actions.set": "הגדר סיסמה",
"remoteAccess.password.form.newPassword": "סיסמה חדשה",
"remoteAccess.password.form.confirmPassword": "אשר סיסמה",
"remoteAccess.password.form.placeholder": "לפחות 8 תווים",
"remoteAccess.password.error.tooShort": "הסיסמה חייבת להכיל לפחות 8 תווים.",
"remoteAccess.password.error.mismatch": "הסיסמאות אינן תואמות.",
"remoteAccess.password.save.saving": "שומר…",
"remoteAccess.password.save.label": "שמור סיסמה",
"remoteAccess.sections.addresses.label": "כתובות נגישות",
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
"remoteAccess.addresses.loading": "טוען כתובות…",
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
"remoteAccess.address.scope.network": "רשת",
"remoteAccess.address.scope.loopback": "לולאה מקומית",
"remoteAccess.address.scope.internal": "פנימי",
"remoteAccess.address.open": "פתח",
"remoteAccess.address.showQr": "הצג QR",
"remoteAccess.address.hideQr": "הסתר QR",
"remoteAccess.address.qrAlt": "QR עבור {url}",
} as const

View File

@@ -0,0 +1,90 @@
export const sessionMessages = {
"sessionPicker.title": "OpenCode • {folder}",
"sessionPicker.empty.noPrevious": "אין סשנים קודמים",
"sessionPicker.resume.title": "המשך סשן ({count}):",
"sessionPicker.session.untitled": "ללא שם",
"sessionPicker.divider.or": "או",
"sessionPicker.new.title": "התחל סשן חדש:",
"sessionPicker.agents.loading": "טוען סוכנים...",
"sessionPicker.actions.creating": "יוצר...",
"sessionPicker.actions.createSession": "צור סשן",
"sessionPicker.actions.cancel": "ביטול",
"sessionList.header.title": "סשנים",
"sessionList.session.untitled": "ללא שם",
"sessionList.status.working": "עובד",
"sessionList.status.compacting": "מסכם",
"sessionList.status.idle": "מוכן",
"sessionList.status.needsPermission": "נדרש אישור",
"sessionList.status.needsInput": "נדרש קלט",
"sessionList.expand.collapseAriaLabel": "כווץ סשן",
"sessionList.expand.expandAriaLabel": "פרוס סשן",
"sessionList.expand.collapseTitle": "כווץ",
"sessionList.expand.expandTitle": "פרוס",
"sessionList.actions.newSession.ariaLabel": "סשן חדש",
"sessionList.actions.newSession.title": "סשן חדש",
"sessionList.actions.copyId.ariaLabel": "העתק מזהה סשן",
"sessionList.actions.copyId.title": "העתק מזהה סשן",
"sessionList.actions.rename.ariaLabel": "שנה שם סשן",
"sessionList.actions.rename.title": "שנה שם סשן",
"sessionList.actions.delete.ariaLabel": "מחק סשן",
"sessionList.actions.delete.title": "מחק סשן",
"sessionList.copyId.success": "מזהה סשן הועתק",
"sessionList.copyId.error": "לא ניתן להעתיק מזהה סשן",
"sessionList.delete.error": "לא ניתן למחוק סשן",
"sessionList.delete.title": "מחק סשן",
"sessionList.delete.confirmMessage": "למחוק את \"{label}\"? לא ניתן לבטל פעולה זו.",
"sessionList.delete.confirmLabel": "מחק",
"sessionList.delete.cancelLabel": "ביטול",
"sessionList.rename.error": "לא ניתן לשנות שם הסשן",
"sessionList.filter.placeholder": "חפש סשנים…",
"sessionList.filter.ariaLabel": "חפש סשנים",
"sessionList.selection.selectAllLabel": "בחר הכל",
"sessionList.selection.selectAllAriaLabel": "בחר את כל הסשנים",
"sessionList.selection.clearLabel": "נקה",
"sessionList.selection.clearAriaLabel": "נקה בחירה",
"sessionList.selection.checkboxAriaLabel": "בחר סשן",
"sessionList.bulkDelete.button": "מחק {count}",
"sessionList.bulkDelete.ariaLabel": "מחק {count} סשנים נבחרים",
"sessionList.bulkDelete.title": "מחק סשנים",
"sessionList.bulkDelete.confirmMessage": "למחוק {count} סשנים נבחרים? לא ניתן לבטל פעולה זו.",
"sessionList.bulkDelete.confirmLabel": "מחק",
"sessionList.bulkDelete.cancelLabel": "ביטול",
"sessionList.bulkDelete.error": "לא ניתן למחוק {count} סשנים",
"sessionRenameDialog.title": "שנה שם סשן",
"sessionRenameDialog.description.withLabel": "עדכן את הכותרת עבור \"{label}\".",
"sessionRenameDialog.description.default": "הגדר כותרת חדשה לסשן זה.",
"sessionRenameDialog.input.label": "שם סשן",
"sessionRenameDialog.input.placeholder": "הזן שם סשן",
"sessionRenameDialog.actions.cancel": "ביטול",
"sessionRenameDialog.actions.rename": "שנה שם",
"sessionRenameDialog.actions.renaming": "משנה שם…",
"sessionView.fallback.sessionNotFound": "הסשן לא נמצא",
"sessionView.alerts.abortFailed.message": "עצירת הסשן נכשלה",
"sessionView.alerts.abortFailed.title": "העצירה נכשלה",
"sessionView.alerts.revertFailed.message": "החזרה להודעה נכשלה",
"sessionView.alerts.revertFailed.title": "החזרה נכשלה",
"sessionView.alerts.deleteUpToFailed.message": "מחיקת הודעות נכשלה",
"sessionView.alerts.deleteUpToFailed.title": "המחיקה נכשלה",
"sessionView.alerts.forkFailed.message": "פיצול הסשן נכשל",
"sessionView.alerts.forkFailed.title": "הפיצול נכשל",
"sessionView.attachments.expandPastedTextAriaLabel": "פרוס טקסט שהודבק",
"sessionView.attachments.insertPastedTextTitle": "הכנס טקסט שהודבק",
"sessionView.attachments.removeAriaLabel": "הסר קובץ מצורף",
"sessionEvents.sessionCompactedToast": "הסשן {label} סוכם",
"sessionEvents.sessionError.unknown": "שגיאה לא ידועה",
"sessionEvents.sessionError.title": "שגיאת סשן",
"sessionEvents.sessionError.message": "שגיאה: {message}",
"sessionState.cleanup.deepConfirm.message": "ניקוי עמוק זה עשוי להיות איטי, ועלול למחוק סשנים שלא התכוונת למחוק. האם אתה בטוח?",
"sessionState.cleanup.deepConfirm.title": "ניקוי עמוק של סשנים",
"sessionState.cleanup.deepConfirm.detail": "ניקוי עמוק של סשנים ימחק את כל הסשנים ללא הודעות, יסיר סשני תת-סוכן שסיימו, וינקה פיצולים לא בשימוש של סשן.",
"sessionState.cleanup.deepConfirm.confirmLabel": "המשך",
"sessionState.cleanup.deepConfirm.cancelLabel": "ביטול",
"sessionState.cleanup.toast.one": "נוקה {count} סשן ריק",
"sessionState.cleanup.toast.other": "נוקו {count} סשנים ריקים",
} as const

View File

@@ -0,0 +1,188 @@
export const settingsMessages = {
"instanceServiceStatus.sections.lsp": "שרתי LSP",
"instanceServiceStatus.sections.mcp": "שרתי MCP",
"instanceServiceStatus.sections.plugins": "תוספים",
"instanceServiceStatus.lsp.loading": "טוען שרתי LSP...",
"instanceServiceStatus.lsp.empty": "לא זוהו שרתי LSP.",
"instanceServiceStatus.lsp.status.connected": "מחובר",
"instanceServiceStatus.lsp.status.error": "שגיאה",
"instanceServiceStatus.mcp.loading": "טוען שרתי MCP...",
"instanceServiceStatus.mcp.empty": "לא זוהו שרתי MCP.",
"instanceServiceStatus.mcp.toggleAriaLabel": "הפעל/כבה שרת MCP {name}",
"instanceServiceStatus.plugins.loading": "טוען תוספים...",
"instanceServiceStatus.plugins.empty": "לא הוגדרו תוספים.",
"permissionBanner.pendingRequests.one": "בקשה אחת ממתינה",
"permissionBanner.pendingRequests.other": "{count} בקשות ממתינות",
"permissionBanner.detail.permission.one": "אישור אחד",
"permissionBanner.detail.permission.other": "{count} אישורים",
"permissionBanner.detail.question.one": "שאלה אחת",
"permissionBanner.detail.question.other": "{count} שאלות",
"permissionBanner.detail.wrapper": " ({detail})",
"agentSelector.placeholder": "בחר סוכן...",
"agentSelector.badge.subagent": "תת-סוכן",
"agentSelector.none": "ללא",
"agentSelector.trigger.primary": "סוכן: {agent}",
"modelSelector.placeholder.search": "חפש מודלים...",
"modelSelector.none": "ללא",
"modelSelector.trigger.primary": "מודל: {model}",
"modelSelector.favoritesOnly.toggle.ariaLabel": "הצג מועדפים בלבד",
"modelSelector.favoritesOnly.showAll": "הצג את כל המודלים",
"modelSelector.favorite.add": "הוסף למועדפים",
"modelSelector.favorite.remove": "הסר ממועדפים",
"thinkingSelector.variant.default": "ברירת מחדל",
"thinkingSelector.label": "חשיבה: {variant}",
"envEditor.title": "משתני סביבה",
"envEditor.count.one": "(משתנה אחד)",
"envEditor.count.other": "({count} משתנים)",
"envEditor.fields.name.placeholder": "שם משתנה",
"envEditor.fields.name.readOnlyTitle": "שם משתנה (לקריאה בלבד)",
"envEditor.fields.value.placeholder": "ערך משתנה",
"envEditor.actions.remove.title": "הסר משתנה",
"envEditor.actions.add.title": "הוסף משתנה",
"envEditor.empty": "לא הוגדרו משתני סביבה. הוסף משתנים למעלה להתאמת סביבת OpenCode.",
"envEditor.help": "משתנים אלו יהיו זמינים בסביבת OpenCode בעת הפעלת מופעים.",
"contextUsagePanel.headings.tokens": "טוקנים",
"contextUsagePanel.headings.context": "הקשר",
"contextUsagePanel.labels.input": "קלט",
"contextUsagePanel.labels.output": "פלט",
"contextUsagePanel.labels.cost": "עלות",
"contextUsagePanel.labels.used": "בשימוש",
"contextUsagePanel.labels.available": "זמין",
"contextUsagePanel.unavailable": "--",
"settings.title": "הגדרות",
"settings.navigationAriaLabel": "קטגוריות הגדרות",
"settings.close": "סגור הגדרות",
"settings.content.eyebrow": "העדפות סביבת עבודה",
"settings.open.title": "פתח הגדרות",
"settings.open.ariaLabel": "פתח הגדרות",
"settings.nav.appearance": "מראה",
"settings.nav.notifications": "התראות",
"settings.nav.remote": "גישה מרוחקת",
"settings.nav.opencode": "OpenCode",
"settings.scope.device": "מכשיר זה",
"settings.scope.server": "הגדרת שרת",
"settings.common.enabled": "מופעל",
"settings.common.disabled": "מושבת",
"settings.section.appearance.title": "מראה",
"settings.section.appearance.subtitle": "שנה כיצד האפליקציה נראית במכשיר זה.",
"settings.appearance.theme.title": "ערכת נושא",
"settings.appearance.theme.subtitle": "בחר את מצב הצבע שישמש בכל האפליקציה.",
"settings.appearance.theme.option.system": "התאם להגדרת מערכת ההפעלה",
"settings.appearance.theme.option.light": "השתמש במראה בהיר",
"settings.appearance.theme.option.dark": "השתמש במראה כהה",
"settings.section.notifications.title": "התראות",
"settings.section.notifications.subtitle": "שלוט בהתראות ברמת מערכת ההפעלה עבור פעילות סשן.",
"settings.notifications.permission.granted": "ניתן",
"settings.notifications.permission.denied": "נדחה",
"settings.notifications.permission.default": "לא ניתן",
"settings.notifications.permission.unsupported": "לא נתמך",
"settings.notifications.messages.unsupportedEnvironment": "התראות מערכת ההפעלה אינן נתמכות בסביבה זו.",
"settings.notifications.messages.permissionDenied": "הרשאת התראות נדחתה. הפעל התראות בהגדרות המערכת או הדפדפן.",
"settings.notifications.messages.permissionNotGranted": "הרשאת התראות לא ניתנה.",
"settings.notifications.messages.unsupportedGeneral": "התראות אינן נתמכות בסביבה זו.",
"settings.notifications.messages.permissionGranted": "ההרשאה ניתנה. כעת ניתן להפעיל התראות.",
"settings.notifications.messages.permissionRequestDenied": "ההרשאה נדחתה. ייתכן שתצטרך להפעיל התראות בהגדרות המערכת או הדפדפן.",
"settings.notifications.sessionStatus.title": "התראות סטטוס סשן",
"settings.notifications.sessionStatus.subtitle": "קבל התראות כאשר סשנים דורשים את תשומת לבך.",
"settings.notifications.enable.title": "הפעל התראות",
"settings.notifications.enable.permission": "הרשאה: {permission}",
"settings.notifications.requestPermission.title": "בקש הרשאה",
"settings.notifications.requestPermission.subtitle": "אפשר לאפליקציה לשלוח התראות במכשיר זה.",
"settings.notifications.requestPermission.action": "בקש",
"settings.notifications.allowVisible.title": "התרע כאשר האפליקציה ממוקדת",
"settings.notifications.allowVisible.subtitle": "שמור על התראות פעילות גם כאשר חלון זה גלוי.",
"settings.notifications.unsupportedNote": "התראות אינן נתמכות בסביבה זו. פקד ההתראות נשאר מושבת.",
"settings.notifications.events.title": "התרע אותי כאשר",
"settings.notifications.events.subtitle": "בחר אילו אירועי סשן ישלחו התראות.",
"settings.notifications.events.needsInput": "הסשן דורש קלט",
"settings.notifications.events.idle": "הסשן עובר למצב סרלה",
"settings.notifications.status.enabled": "התראות מופעלות",
"settings.notifications.status.disabled": "התראות מושבתות",
"settings.notifications.status.unsupported": "התראות לא נתמכות",
"settings.section.remote.title": "גישה מרוחקת",
"settings.section.remote.subtitle": "בדוק כיצד שרת זה חשוף ברשת שלך ואבטח אישורי גישה.",
"settings.section.opencode.title": "OpenCode",
"settings.section.opencode.subtitle": "בחר את הקובץ הבינארי של OpenCode והסביבה לשימוש במופעים חדשים.",
"settings.opencode.runtime.title": "סביבת ריצה",
"settings.opencode.runtime.subtitle": "הגדר עם איזה קובץ בינארי של OpenCode מופעים חדשים יופעלו.",
"settings.appearance.behavior.title": "אינטראקציה",
"settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.",
"settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת",
"settings.behavior.keyboardHints.subtitle": "הצג רמזי קיצורי מקלדת בכל הממשק.",
"settings.behavior.thinking.title": "קטעי חשיבה",
"settings.behavior.thinking.subtitle": "הצג או הסתר קטעי חשיבה של ה-AI בהודעות.",
"settings.behavior.thinkingDefault.title": "ברירת מחדל לחשיבה",
"settings.behavior.thinkingDefault.subtitle": "בחר האם קטעי חשיבה מתחילים פרוסים או מכווצים.",
"settings.behavior.timelineTools.title": "קריאות כלי בציר הזמן",
"settings.behavior.timelineTools.subtitle": "הצג או הסתר קריאות כלי בציר הודעות.",
"settings.behavior.diffView.title": "תצוגת diff",
"settings.behavior.diffView.subtitle": "בחר כיצד מוצגים diff של קריאות כלי.",
"settings.behavior.diffView.option.split": "מפוצל",
"settings.behavior.diffView.option.unified": "מאוחד",
"settings.behavior.toolOutputsDefault.title": "ברירת מחדל לפלטי כלים",
"settings.behavior.toolOutputsDefault.subtitle": "בחר האם פלטי כלים מתחילים פרוסים או מכווצים.",
"settings.behavior.diagnosticsDefault.title": "ברירת מחדל לאבחון",
"settings.behavior.diagnosticsDefault.subtitle": "בחר האם פלט אבחון מתחיל פרוס או מכווץ.",
"settings.behavior.toolInputsVisibility.title": "נראות קלטי כלים",
"settings.behavior.toolInputsVisibility.subtitle": "הגדר נראות ברירת מחדל לארגומנטים של קריאות כלי.",
"settings.behavior.usageMetrics.title": "מדדי שימוש בטוקנים",
"settings.behavior.usageMetrics.subtitle": "הצג או הסתר נתוני טוקנים ועלות להודעות הסוכן.",
"settings.behavior.autoCleanup.title": "ניקוי אוטומטי של סשנים ריקים",
"settings.behavior.autoCleanup.subtitle": "נקה אוטומטית סשנים ריקים בעת יצירת סשנים חדשים.",
"settings.behavior.promptVoiceInput.title": "קלט קולי לפרומפט",
"settings.behavior.promptVoiceInput.subtitle": "הצג את כפתור המיקרופון לקלט דיבור-לטקסט כאשר תכונת הקול מוגדרת.",
"settings.behavior.promptSubmit.title": "Enter לשליחה",
"settings.behavior.promptSubmit.subtitle": "השתמש ב-Enter לשליחת פקודות; Cmd/Ctrl+Enter מוסיף שורה חדשה.",
"settings.speech.title": "קול",
"settings.speech.subtitle": "הגדר כעת דיבור-לטקסט והכן תשתית לטקסט-לדיבור עבור יכולות עתידיות.",
"settings.speech.provider.title": "ספק",
"settings.speech.provider.subtitle": "בקשות קול משתמשות במתאם הקול שבצד השרת.",
"settings.speech.provider.openaiCompatible": "תואם OpenAI",
"settings.speech.status.loading": "בודק את ההגדרות...",
"settings.speech.status.configured": "מוגדר",
"settings.speech.status.missing": "חסר מפתח API",
"settings.speech.status.error": "שירות הקול אינו זמין",
"settings.speech.apiKey.title": "מפתח API",
"settings.speech.apiKey.subtitle": "משמש עבור בקשות קול המנוהלות על ידי CodeNomad.",
"settings.speech.apiKey.placeholder": "הזן מפתח API חדש",
"settings.speech.apiKey.storedNote": "מפתח API שמור מוסתר. הזן ערך חדש כדי להחליף אותו, או השאר את השדה ריק כדי לשמור עליו.",
"settings.speech.apiKey.clearAction": "נקה מפתח שמור",
"settings.speech.apiKey.clearPending": "מפתח ה-API השמור יוסר בעת השמירה.",
"settings.speech.baseUrl.title": "כתובת בסיס",
"settings.speech.baseUrl.subtitle": "עקיפה אופציונלית עבור נקודות קצה קוליות התואמות ל-OpenAI.",
"settings.speech.baseUrl.placeholder": "https://api.openai.com/v1",
"settings.speech.sttModel.title": "מודל תמלול",
"settings.speech.sttModel.subtitle": "המודל המשמש לבקשות דיבור-לטקסט בפרומפט.",
"settings.speech.ttsModel.title": "מודל קול",
"settings.speech.ttsModel.subtitle": "מודל ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
"settings.speech.ttsVoice.title": "קול ברירת מחדל",
"settings.speech.ttsVoice.subtitle": "קול ברירת מחדל לטקסט-לדיבור השמור ליכולות ניגון עתידיות.",
"settings.speech.playbackMode.title": "מצב ניגון",
"settings.speech.playbackMode.subtitle": "בחר אם TTS יתחיל לנגן בזמן שהאודיו מוזרם או רק אחרי שהקובץ כולו נוצר.",
"settings.speech.playbackMode.streaming": "סטרימינג",
"settings.speech.playbackMode.buffered": "באפר מלא",
"settings.speech.ttsFormat.title": "פורמט פלט",
"settings.speech.ttsFormat.subtitle": "בחר את פורמט האודיו לדיבור מסונתז. תמיכת סטרימינג תלויה בספק ובדפדפן.",
"settings.speech.help": "קלט קולי לפרומפט מופיע כאשר תמלול קול מוגדר ונתמך. השמעת הודעות משתמשת במצב ובפורמט ה-TTS שנבחרו כאן.",
"settings.speech.compatibility.streamingUnavailable": "תצורת ספק הקול הנוכחית שלך לא מצהירה על TTS בסטרימינג. עבור למצב buffered אם אתה רוצה שהניגון יעבוד כבר עכשיו.",
"settings.speech.compatibility.browserStreamingUnavailable": "הדפדפן הנוכחי שלך לא יכול לנגן בסטרימינג את פורמט ה-TTS שנבחר. בחר בניגון buffered או עבור לפורמט אחר.",
"settings.speech.compatibility.runtimeNote": "כל הפורמטים נשארים זמינים במצב סטרימינג. חלק מהשילובים של דפדפן וספק עדיין עלולים להיכשל בזמן הניגון.",
"settings.speech.testPlayback.action": "בדוק ניגון",
"settings.speech.testPlayback.generating": "יוצר דוגמה",
"settings.speech.testPlayback.stop": "עצור דוגמה",
"settings.speech.testPlayback.sample": "תודה שאתה משתמש ב-CodeNomad, הגדרות הקול שלך פועלות כראוי.",
"settings.speech.testPlayback.note": "המבחן משתמש מיד במצב ובפורמט הנוכחיים. שמור תחילה שינויים ב-API key, ב-Base URL, במודל או בקול אם גם אותם תרצה לבדוק.",
"settings.speech.save.action": "שמור",
"settings.speech.save.saving": "שומר...",
"settings.speech.save.saved": "נשמר",
"settings.speech.save.unsaved": "יש שינויים שלא נשמרו",
"settings.speech.save.error": "השמירה נכשלה",
} as const

Some files were not shown because too many files have changed in this diff Show More