Compare commits
17 Commits
v0.12.2-de
...
speech-inp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf07904789 | ||
|
|
4e576829b7 | ||
|
|
f9b5e2b529 | ||
|
|
cc2f6976f6 | ||
|
|
0ed19aeefb | ||
|
|
d9068ac8c6 | ||
|
|
51f8eff3f7 | ||
|
|
627ff2d42b | ||
|
|
0d9da40102 | ||
|
|
ff94c9714e | ||
|
|
429825f434 | ||
|
|
d836d2e62d | ||
|
|
f77fb1562e | ||
|
|
b33421a375 | ||
|
|
c64a9a03f9 | ||
|
|
0d215342e3 | ||
|
|
beb14ea0a2 |
40
package-lock.json
generated
40
package-lock.json
generated
@@ -3305,6 +3305,23 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||||
|
"version": "2.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.4.tgz",
|
||||||
|
"integrity": "sha512-EdYd4c9wGvtPB95kqtEyY+bUR+k4kRw3IA30mAQ1jPH6z57AftT8q84qwv0RDp6kkEqOBKxeInKfqi4BESYuqg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0 OR MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@tauri-apps/plugin-notification": {
|
"node_modules/@tauri-apps/plugin-notification": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
|
||||||
@@ -8214,6 +8231,27 @@
|
|||||||
"regex-recursion": "^6.0.2"
|
"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": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
@@ -11971,6 +12009,7 @@
|
|||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -12032,6 +12071,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
|
import fs from "fs"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -65,6 +66,24 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { canceled: result.canceled, paths: result.filePaths }
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle("filesystem:getDirectoryPaths", async (_event, paths: unknown): Promise<string[]> => {
|
||||||
|
if (!Array.isArray(paths)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = paths.filter((value): value is string => {
|
||||||
|
if (typeof value !== "string" || value.trim().length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return fs.statSync(value).isDirectory()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return directories
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => {
|
||||||
const next = Boolean(enabled)
|
const next = Boolean(enabled)
|
||||||
if (next) {
|
if (next) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron")
|
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
onCliStatus: (callback) => {
|
onCliStatus: (callback) => {
|
||||||
@@ -12,6 +12,14 @@ const electronAPI = {
|
|||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
restartCli: () => ipcRenderer.invoke("cli:restart"),
|
||||||
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
|
getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths),
|
||||||
|
getPathForFile: (file) => {
|
||||||
|
try {
|
||||||
|
return webUtils.getPathForFile(file)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"fuzzysort": "^2.0.4",
|
"fuzzysort": "^2.0.4",
|
||||||
"node-forge": "^1.3.3",
|
"node-forge": "^1.3.3",
|
||||||
|
"openai": "^6.27.0",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
"undici": "^6.19.8",
|
"undici": "^6.19.8",
|
||||||
"yaml": "^2.4.2",
|
"yaml": "^2.4.2",
|
||||||
|
|||||||
@@ -207,6 +207,36 @@ export interface BinaryValidationResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpeechSegment {
|
||||||
|
startMs: number
|
||||||
|
endMs: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechCapabilitiesResponse {
|
||||||
|
available: boolean
|
||||||
|
configured: boolean
|
||||||
|
provider: string
|
||||||
|
supportsStt: boolean
|
||||||
|
supportsTts: boolean
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechTranscriptionResponse {
|
||||||
|
text: string
|
||||||
|
language?: string
|
||||||
|
durationMs?: number
|
||||||
|
segments?: SpeechSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechSynthesisResponse {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WorkspaceEventType =
|
export type WorkspaceEventType =
|
||||||
| "workspace.created"
|
| "workspace.created"
|
||||||
| "workspace.started"
|
| "workspace.started"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } fro
|
|||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
|
import { SpeechService } from "./speech/service"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
@@ -304,6 +305,7 @@ async function main() {
|
|||||||
})
|
})
|
||||||
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
|
||||||
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
const instanceStore = new InstanceStore(configLocation.instancesDir)
|
||||||
|
const speechService = new SpeechService(settings, logger.child({ component: "speech" }))
|
||||||
const instanceEventBridge = new InstanceEventBridge({
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
workspaceManager,
|
workspaceManager,
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -388,6 +390,7 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||||
@@ -408,6 +411,7 @@ async function main() {
|
|||||||
eventBus,
|
eventBus,
|
||||||
serverMeta,
|
serverMeta,
|
||||||
instanceStore,
|
instanceStore,
|
||||||
|
speechService,
|
||||||
authManager,
|
authManager,
|
||||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||||
uiDevServerUrl: undefined,
|
uiDevServerUrl: undefined,
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { registerStorageRoutes } from "./routes/storage"
|
|||||||
import { registerPluginRoutes } from "./routes/plugin"
|
import { registerPluginRoutes } from "./routes/plugin"
|
||||||
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||||
|
import { registerSpeechRoutes } from "./routes/speech"
|
||||||
import { ServerMeta } from "../api-types"
|
import { ServerMeta } from "../api-types"
|
||||||
import { InstanceStore } from "../storage/instance-store"
|
import { InstanceStore } from "../storage/instance-store"
|
||||||
import { BackgroundProcessManager } from "../background-processes/manager"
|
import { BackgroundProcessManager } from "../background-processes/manager"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import { registerAuthRoutes } from "./routes/auth"
|
import { registerAuthRoutes } from "./routes/auth"
|
||||||
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
import { sendUnauthorized, wantsHtml } from "../auth/http-auth"
|
||||||
|
import type { SpeechService } from "../speech/service"
|
||||||
|
|
||||||
interface HttpServerDeps {
|
interface HttpServerDeps {
|
||||||
bindHost: string
|
bindHost: string
|
||||||
@@ -41,6 +43,7 @@ interface HttpServerDeps {
|
|||||||
eventBus: EventBus
|
eventBus: EventBus
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
instanceStore: InstanceStore
|
instanceStore: InstanceStore
|
||||||
|
speechService: SpeechService
|
||||||
authManager: AuthManager
|
authManager: AuthManager
|
||||||
uiStaticDir: string
|
uiStaticDir: string
|
||||||
uiDevServerUrl?: string
|
uiDevServerUrl?: string
|
||||||
@@ -252,6 +255,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
eventBus: deps.eventBus,
|
eventBus: deps.eventBus,
|
||||||
workspaceManager: deps.workspaceManager,
|
workspaceManager: deps.workspaceManager,
|
||||||
})
|
})
|
||||||
|
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||||
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
registerPluginRoutes(app, { workspaceManager: deps.workspaceManager, eventBus: deps.eventBus, logger: proxyLogger })
|
||||||
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager })
|
||||||
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger })
|
||||||
|
|||||||
46
packages/server/src/server/routes/speech.ts
Normal file
46
packages/server/src/server/routes/speech.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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"]).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
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(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "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(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to synthesize audio" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
148
packages/server/src/speech/providers/openai-compatible.ts
Normal file
148
packages/server/src/speech/providers/openai-compatible.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import OpenAI from "openai"
|
||||||
|
import { toFile } from "openai/uploads"
|
||||||
|
import type { SpeechSynthesisResponse, SpeechTranscriptionResponse } from "../../api-types"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import type { NormalizedSpeechSettings, 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,
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
sttModel: settings.sttModel,
|
||||||
|
ttsModel: settings.ttsModel,
|
||||||
|
ttsVoice: settings.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 client = this.createClient()
|
||||||
|
const format = input.format ?? "mp3"
|
||||||
|
|
||||||
|
this.options.logger.info(
|
||||||
|
{
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
},
|
||||||
|
"speech.synthesize",
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await client.audio.speech.create({
|
||||||
|
model: this.options.settings.ttsModel,
|
||||||
|
voice: this.options.settings.ttsVoice as any,
|
||||||
|
input: input.text,
|
||||||
|
response_format: format as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioBuffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return {
|
||||||
|
audioBase64: audioBuffer.toString("base64"),
|
||||||
|
mimeType: mimeTypeForFormat(format),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"): string {
|
||||||
|
if (format === "wav") return "audio/wav"
|
||||||
|
if (format === "opus") return "audio/opus"
|
||||||
|
return "audio/mpeg"
|
||||||
|
}
|
||||||
91
packages/server/src/speech/service.ts
Normal file
91
packages/server/src/speech/service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface TranscribeAudioInput {
|
||||||
|
audioBase64: string
|
||||||
|
mimeType: string
|
||||||
|
filename?: string
|
||||||
|
language?: string
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SynthesizeSpeechInput {
|
||||||
|
text: string
|
||||||
|
format?: "mp3" | "wav" | "opus"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechProvider {
|
||||||
|
getCapabilities(): SpeechCapabilitiesResponse
|
||||||
|
transcribe(input: TranscribeAudioInput): Promise<SpeechTranscriptionResponse>
|
||||||
|
synthesize(input: SynthesizeSpeechInput): Promise<SpeechSynthesisResponse>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedSpeechSettings {
|
||||||
|
provider: string
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -828,14 +828,31 @@ impl CliEntry {
|
|||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: plain HTTP + Vite dev server proxy.
|
||||||
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
.or_else(|| {
|
||||||
|
std::env::var("ELECTRON_RENDERER_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "http://localhost:3000".to_string());
|
||||||
|
let log_level = std::env::var("CLI_LOG_LEVEL")
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_lowercase())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "info".to_string());
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("false".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
|
args.push("--http-port".to_string());
|
||||||
|
args.push("0".to_string());
|
||||||
args.push("--ui-dev-server".to_string());
|
args.push("--ui-dev-server".to_string());
|
||||||
args.push("http://localhost:3000".to_string());
|
args.push(ui_dev_server);
|
||||||
args.push("--log-level".to_string());
|
args.push("--log-level".to_string());
|
||||||
args.push("debug".to_string());
|
args.push(log_level);
|
||||||
} else {
|
} else {
|
||||||
// Prod desktop: always keep loopback HTTP enabled.
|
// Prod desktop: always keep loopback HTTP enabled.
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
@@ -900,6 +917,11 @@ fn resolve_dist_entry(_app: &AppHandle) -> Option<String> {
|
|||||||
|
|
||||||
if let Ok(exe) = std::env::current_exe() {
|
if let Ok(exe) = std::env::current_exe() {
|
||||||
if let Some(dir) = exe.parent() {
|
if let Some(dir) = exe.parent() {
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/index.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/bin.js")));
|
||||||
|
candidates.push(Some(dir.join("resources/server/dist/server/index.js")));
|
||||||
|
|
||||||
let resources = dir.join("../Resources");
|
let resources = dir.join("../Resources");
|
||||||
candidates.push(Some(resources.join("server/dist/bin.js")));
|
candidates.push(Some(resources.join("server/dist/bin.js")));
|
||||||
candidates.push(Some(resources.join("server/dist/index.js")));
|
candidates.push(Some(resources.join("server/dist/index.js")));
|
||||||
@@ -995,9 +1017,18 @@ fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(path: PathBuf) -> String {
|
fn normalize_path(path: PathBuf) -> String {
|
||||||
if let Ok(clean) = path.canonicalize() {
|
let resolved = if let Ok(clean) = path.canonicalize() {
|
||||||
clean.to_string_lossy().to_string()
|
clean
|
||||||
} else {
|
} else {
|
||||||
path.to_string_lossy().to_string()
|
path
|
||||||
|
};
|
||||||
|
|
||||||
|
let rendered = resolved.to_string_lossy().to_string();
|
||||||
|
if let Some(stripped) = rendered.strip_prefix("\\\\?\\UNC\\") {
|
||||||
|
format!("\\\\{}", stripped)
|
||||||
|
} else if let Some(stripped) = rendered.strip_prefix("\\\\?\\") {
|
||||||
|
stripped.to_string()
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ fn cli_restart(app: AppHandle, state: tauri::State<AppState>) -> Result<CliStatu
|
|||||||
Ok(state.manager.status())
|
Ok(state.manager.status())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fn is_dev_mode() -> bool {
|
fn is_dev_mode() -> bool {
|
||||||
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok()
|
||||||
}
|
}
|
||||||
@@ -46,7 +45,10 @@ fn should_allow_internal(url: &Url) -> bool {
|
|||||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||||
// This must be treated as an internal origin or the navigation guard will
|
// This must be treated as an internal origin or the navigation guard will
|
||||||
// redirect it to the system browser and the app will appear blank.
|
// redirect it to the system browser and the app will appear blank.
|
||||||
"http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "tauri.localhost")),
|
"http" | "https" => matches!(
|
||||||
|
url.host_str(),
|
||||||
|
Some("127.0.0.1" | "localhost" | "tauri.localhost")
|
||||||
|
),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,39 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||||
|
paths
|
||||||
|
.iter()
|
||||||
|
.filter_map(|path| match std::fs::metadata(path) {
|
||||||
|
Ok(metadata) if metadata.is_dir() => Some(path.to_string_lossy().to_string()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_window_event(app_handle: &AppHandle, window_label: &str, event_name: &str) {
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, ());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_folder_drop_event(
|
||||||
|
app_handle: &AppHandle,
|
||||||
|
window_label: &str,
|
||||||
|
event_name: &str,
|
||||||
|
paths: &[std::path::PathBuf],
|
||||||
|
) {
|
||||||
|
let directories = collect_directory_paths(paths);
|
||||||
|
|
||||||
|
if directories.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(window) = app_handle.get_webview_window(window_label) {
|
||||||
|
let _ = window.emit(event_name, json!({ "paths": directories }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||||
@@ -187,6 +222,27 @@ fn main() {
|
|||||||
app.exit(0);
|
app.exit(0);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Enter { paths, .. }),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drag-enter", &paths);
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_folder_drop_event(&app_handle, &label, "desktop:folder-drop", &paths);
|
||||||
|
}
|
||||||
|
tauri::RunEvent::WindowEvent {
|
||||||
|
label,
|
||||||
|
event: tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Leave),
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
emit_window_event(&app_handle, &label, "desktop:folder-drag-leave");
|
||||||
|
}
|
||||||
tauri::RunEvent::WindowEvent {
|
tauri::RunEvent::WindowEvent {
|
||||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||||
..
|
..
|
||||||
@@ -234,13 +290,16 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
"new_instance",
|
"new_instance",
|
||||||
"New Instance",
|
"New Instance",
|
||||||
true,
|
true,
|
||||||
Some("CmdOrCtrl+N")
|
Some("CmdOrCtrl+N"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let file_menu = SubmenuBuilder::new(app, "File")
|
let file_menu = SubmenuBuilder::new(app, "File")
|
||||||
.item(&new_instance_item)
|
.item(&new_instance_item)
|
||||||
.separator()
|
.separator()
|
||||||
.text(if is_mac { "close" } else { "quit" }, if is_mac { "Close" } else { "Quit" })
|
.text(
|
||||||
|
if is_mac { "close" } else { "quit" },
|
||||||
|
if is_mac { "Close" } else { "Quit" },
|
||||||
|
)
|
||||||
.build()?;
|
.build()?;
|
||||||
submenus.push(file_menu);
|
submenus.push(file_menu);
|
||||||
|
|
||||||
@@ -263,7 +322,6 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
.text("force_reload", "Force Reload")
|
.text("force_reload", "Force Reload")
|
||||||
.text("toggle_devtools", "Toggle Developer Tools")
|
.text("toggle_devtools", "Toggle Developer Tools")
|
||||||
.separator()
|
.separator()
|
||||||
|
|
||||||
.separator()
|
.separator()
|
||||||
.text("toggle_fullscreen", "Toggle Full Screen")
|
.text("toggle_fullscreen", "Toggle Full Screen")
|
||||||
.build()?;
|
.build()?;
|
||||||
@@ -277,9 +335,12 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
|
|||||||
submenus.push(window_menu);
|
submenus.push(window_menu);
|
||||||
|
|
||||||
// Build the main menu with all submenus
|
// Build the main menu with all submenus
|
||||||
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus.iter().map(|s| s as &dyn tauri::menu::IsMenuItem<_>).collect();
|
let submenu_refs: Vec<&dyn tauri::menu::IsMenuItem<_>> = submenus
|
||||||
|
.iter()
|
||||||
|
.map(|s| s as &dyn tauri::menu::IsMenuItem<_>)
|
||||||
|
.collect();
|
||||||
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
let menu = MenuBuilder::new(app).items(&submenu_refs).build()?;
|
||||||
|
|
||||||
app.set_menu(menu)?;
|
app.set_menu(menu)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { showConfirmDialog } from "./stores/alerts"
|
|||||||
import InstanceTabs from "./components/instance-tabs"
|
import InstanceTabs from "./components/instance-tabs"
|
||||||
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
import InstanceDisconnectedModal from "./components/instance-disconnected-modal"
|
||||||
import InstanceShell from "./components/instance/instance-shell2"
|
import InstanceShell from "./components/instance/instance-shell2"
|
||||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
import { SettingsScreen } from "./components/settings-screen"
|
||||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||||
import { initMarkdown } from "./lib/markdown"
|
import { initMarkdown } from "./lib/markdown"
|
||||||
import { initGithubStars } from "./stores/github-stars"
|
import { initGithubStars } from "./stores/github-stars"
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
} from "./stores/sessions"
|
} from "./stores/sessions"
|
||||||
|
|
||||||
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
import { getInstanceSessionIndicatorStatus } from "./stores/session-status"
|
||||||
|
import { openSettings } from "./stores/settings-screen"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const App: Component = () => {
|
|||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -77,8 +79,6 @@ const App: Component = () => {
|
|||||||
setToolInputsVisibility,
|
setToolInputsVisibility,
|
||||||
} = useConfig()
|
} = useConfig()
|
||||||
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
|
||||||
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
|
|
||||||
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
|
|
||||||
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
|
||||||
|
|
||||||
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
const phoneQuery = useMediaQuery("(max-width: 767px)")
|
||||||
@@ -252,7 +252,6 @@ const App: Component = () => {
|
|||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
const instanceId = await createInstance(folderPath, selectedBinary)
|
const instanceId = await createInstance(folderPath, selectedBinary)
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
|
|
||||||
log.info("Created instance", {
|
log.info("Created instance", {
|
||||||
instanceId,
|
instanceId,
|
||||||
@@ -274,7 +273,7 @@ const App: Component = () => {
|
|||||||
|
|
||||||
function handleLaunchErrorAdvanced() {
|
function handleLaunchErrorAdvanced() {
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
setIsAdvancedSettingsOpen(true)
|
openSettings("opencode")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewInstanceRequest() {
|
function handleNewInstanceRequest() {
|
||||||
@@ -362,6 +361,7 @@ const App: Component = () => {
|
|||||||
toggleShowTimelineTools,
|
toggleShowTimelineTools,
|
||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -487,7 +487,6 @@ const App: Component = () => {
|
|||||||
onSelect={setActiveInstanceId}
|
onSelect={setActiveInstanceId}
|
||||||
onClose={handleCloseInstance}
|
onClose={handleCloseInstance}
|
||||||
onNew={handleNewInstanceRequest}
|
onNew={handleNewInstanceRequest}
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -533,10 +532,6 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
||||||
onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
@@ -546,12 +541,8 @@ const App: Component = () => {
|
|||||||
<FolderSelectionView
|
<FolderSelectionView
|
||||||
onSelectFolder={handleSelectFolder}
|
onSelectFolder={handleSelectFolder}
|
||||||
isLoading={isSelectingFolder()}
|
isLoading={isSelectingFolder()}
|
||||||
advancedSettingsOpen={isAdvancedSettingsOpen()}
|
|
||||||
onAdvancedSettingsOpen={() => setIsAdvancedSettingsOpen(true)}
|
|
||||||
onAdvancedSettingsClose={() => setIsAdvancedSettingsOpen(false)}
|
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowFolderSelection(false)
|
setShowFolderSelection(false)
|
||||||
setIsAdvancedSettingsOpen(false)
|
|
||||||
clearLaunchError()
|
clearLaunchError()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -559,7 +550,7 @@ const App: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<RemoteAccessOverlay open={remoteAccessOpen()} onClose={() => setRemoteAccessOpen(false)} />
|
<SettingsScreen />
|
||||||
|
|
||||||
<AlertDialog />
|
<AlertDialog />
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import { Select } from "@kobalte/core/select"
|
|||||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star, Languages, ChevronDown, X } from "lucide-solid"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
|
||||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||||
import VersionPill from "./version-pill"
|
import VersionPill from "./version-pill"
|
||||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||||
import { githubStars } from "../stores/github-stars"
|
import { githubStars } from "../stores/github-stars"
|
||||||
import { formatCompactCount } from "../lib/formatters"
|
import { formatCompactCount } from "../lib/formatters"
|
||||||
import { useI18n, type Locale } from "../lib/i18n"
|
import { useI18n, type Locale } from "../lib/i18n"
|
||||||
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
|
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -19,15 +20,11 @@ const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).h
|
|||||||
interface FolderSelectionViewProps {
|
interface FolderSelectionViewProps {
|
||||||
onSelectFolder: (folder: string, binaryPath?: string) => void
|
onSelectFolder: (folder: string, binaryPath?: string) => void
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
advancedSettingsOpen?: boolean
|
|
||||||
onAdvancedSettingsOpen?: () => void
|
|
||||||
onAdvancedSettingsClose?: () => void
|
|
||||||
onOpenRemoteAccess?: () => void
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||||
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings, updateLastUsedBinary } = useConfig()
|
const { recentFolders, removeRecentFolder, preferences, updatePreferences, serverSettings } = useConfig()
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
const [selectedIndex, setSelectedIndex] = createSignal(0)
|
||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
@@ -193,6 +190,31 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function dropTargetBlocked() {
|
||||||
|
return isLoading() || isFolderBrowserOpen() || settingsOpen()
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInvalidFolderDropAlert() {
|
||||||
|
showAlertDialog(t("folderSelection.drop.invalidMessage"), {
|
||||||
|
title: t("folderSelection.drop.invalidTitle"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const folderDrop = useFolderDrop({
|
||||||
|
enabled: () => !dropTargetBlocked(),
|
||||||
|
onInvalidDrop: showInvalidFolderDropAlert,
|
||||||
|
onDrop: async (paths) => {
|
||||||
|
const firstPath = paths[0]
|
||||||
|
if (!firstPath) {
|
||||||
|
showInvalidFolderDropAlert()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleFolderSelect(firstPath)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
function formatRelativeTime(timestamp: number): string {
|
function formatRelativeTime(timestamp: number): string {
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
@@ -237,11 +259,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
handleFolderSelect(path)
|
handleFolderSelect(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBinaryChange(binary: string) {
|
|
||||||
|
|
||||||
setSelectedBinary(binary)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(path: string, e?: Event) {
|
function handleRemove(path: string, e?: Event) {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -317,6 +334,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
class="flex h-screen w-full items-start justify-center overflow-hidden py-6 px-4 sm:px-6 relative"
|
||||||
style="background-color: var(--surface-secondary)"
|
style="background-color: var(--surface-secondary)"
|
||||||
|
onDragEnter={folderDrop.bind.onDragEnter}
|
||||||
|
onDragOver={folderDrop.bind.onDragOver}
|
||||||
|
onDragLeave={folderDrop.bind.onDragLeave}
|
||||||
|
onDrop={folderDrop.bind.onDrop}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||||
@@ -367,16 +388,24 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute top-4 right-6 flex items-center gap-2">
|
<div class="absolute top-4 right-6 flex items-center gap-2">
|
||||||
<ThemeModeToggle class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center" />
|
<button
|
||||||
<Show when={props.onOpenRemoteAccess}>
|
type="button"
|
||||||
<button
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
type="button"
|
onClick={() => openSettings("appearance")}
|
||||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
aria-label={t("settings.open.title")}
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
title={t("settings.open.title")}
|
||||||
>
|
>
|
||||||
<MonitorUp class="w-4 h-4" />
|
<Settings class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Show>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||||
|
onClick={() => openSettings("remote")}
|
||||||
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
|
title={t("instanceTabs.remote.title")}
|
||||||
|
>
|
||||||
|
<MonitorUp class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
<Show when={props.onClose}>
|
<Show when={props.onClose}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -564,12 +593,12 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Advanced settings section */}
|
{/* OpenCode settings section */}
|
||||||
<div class="panel-section w-full">
|
<div class="panel-section w-full">
|
||||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
<button onClick={() => openSettings("opencode")} class="panel-section-header w-full justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Settings class="w-4 h-4 icon-muted" />
|
<Settings class="w-4 h-4 icon-muted" />
|
||||||
<span class="text-sm font-medium text-secondary">{t("folderSelection.advancedSettings")}</span>
|
<span class="text-sm font-medium text-secondary">{t("folderSelection.opencode")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||||
</button>
|
</button>
|
||||||
@@ -619,16 +648,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={folderDrop.isSupported && folderDrop.isActive() && !dropTargetBlocked()}>
|
||||||
|
<div class="folder-drop-overlay" aria-hidden="true">
|
||||||
|
<div class="folder-drop-card">
|
||||||
|
<FolderPlus class="w-8 h-8 icon-muted" />
|
||||||
|
<p class="folder-drop-title">{t("folderSelection.drop.title")}</p>
|
||||||
|
<p class="folder-drop-subtext">{t("folderSelection.drop.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdvancedSettingsModal
|
|
||||||
open={Boolean(props.advancedSettingsOpen)}
|
|
||||||
onClose={() => props.onAdvancedSettingsClose?.()}
|
|
||||||
selectedBinary={selectedBinary()}
|
|
||||||
onBinaryChange={handleBinaryChange}
|
|
||||||
isLoading={props.isLoading}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DirectoryBrowserDialog
|
<DirectoryBrowserDialog
|
||||||
open={isFolderBrowserOpen()}
|
open={isFolderBrowserOpen()}
|
||||||
title={t("folderSelection.dialog.title")}
|
title={t("folderSelection.dialog.title")}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Component, For, Show, createMemo, createSignal } from "solid-js"
|
import { Component, For, Show, createMemo } from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
import type { Instance } from "../types/instance"
|
import type { Instance } from "../types/instance"
|
||||||
import InstanceTab from "./instance-tab"
|
import InstanceTab from "./instance-tab"
|
||||||
import KeyboardHint from "./keyboard-hint"
|
import KeyboardHint from "./keyboard-hint"
|
||||||
import { Plus, MonitorUp, Bell, BellOff } from "lucide-solid"
|
import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { ThemeModeToggle } from "./theme-mode-toggle"
|
|
||||||
import NotificationsSettingsModal from "./notifications-settings-modal"
|
|
||||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
|
import { openSettings } from "../stores/settings-screen"
|
||||||
|
|
||||||
interface InstanceTabsProps {
|
interface InstanceTabsProps {
|
||||||
instances: Map<string, Instance>
|
instances: Map<string, Instance>
|
||||||
@@ -17,13 +16,11 @@ interface InstanceTabsProps {
|
|||||||
onSelect: (instanceId: string) => void
|
onSelect: (instanceId: string) => void
|
||||||
onClose: (instanceId: string) => void
|
onClose: (instanceId: string) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
onOpenRemoteAccess?: () => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { preferences } = useConfig()
|
const { preferences } = useConfig()
|
||||||
const [notificationsOpen, setNotificationsOpen] = createSignal(false)
|
|
||||||
|
|
||||||
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
const notificationsSupported = createMemo(() => isOsNotificationSupportedSync())
|
||||||
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
const notificationsEnabled = createMemo(() => Boolean(preferences().osNotificationsEnabled))
|
||||||
@@ -33,8 +30,10 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const notificationTitle = createMemo(() => {
|
const notificationTitle = createMemo(() => {
|
||||||
if (!notificationsSupported()) return "Notifications unsupported"
|
if (!notificationsSupported()) return t("settings.notifications.status.unsupported")
|
||||||
return notificationsEnabled() ? "Notifications enabled" : "Notifications disabled"
|
return notificationsEnabled()
|
||||||
|
? t("settings.notifications.status.enabled")
|
||||||
|
: t("settings.notifications.status.disabled")
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,32 +71,35 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<ThemeModeToggle class="new-tab-button" />
|
<button
|
||||||
|
class="new-tab-button"
|
||||||
|
onClick={() => openSettings("appearance")}
|
||||||
|
title={t("settings.open.title")}
|
||||||
|
aria-label={t("settings.open.ariaLabel")}
|
||||||
|
>
|
||||||
|
<Settings class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
class={`new-tab-button ${!notificationsSupported() ? "opacity-50" : ""}`}
|
||||||
onClick={() => setNotificationsOpen(true)}
|
onClick={() => openSettings("notifications")}
|
||||||
title={notificationTitle()}
|
title={notificationTitle()}
|
||||||
aria-label={notificationTitle()}
|
aria-label={notificationTitle()}
|
||||||
>
|
>
|
||||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Show when={Boolean(props.onOpenRemoteAccess)}>
|
<button
|
||||||
<button
|
class="new-tab-button tab-remote-button"
|
||||||
class="new-tab-button tab-remote-button"
|
onClick={() => openSettings("remote")}
|
||||||
onClick={() => props.onOpenRemoteAccess?.()}
|
title={t("instanceTabs.remote.title")}
|
||||||
title={t("instanceTabs.remote.title")}
|
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
>
|
||||||
>
|
<MonitorUp class="w-4 h-4" />
|
||||||
<MonitorUp class="w-4 h-4" />
|
</button>
|
||||||
</button>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NotificationsSettingsModal open={notificationsOpen()} onClose={() => setNotificationsOpen(false)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -578,7 +578,6 @@ export default function MessageBlock(props: MessageBlockProps) {
|
|||||||
const record = createMemo(() => props.store().getMessage(props.messageId))
|
const record = createMemo(() => props.store().getMessage(props.messageId))
|
||||||
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
|
||||||
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
|
||||||
|
|
||||||
const isDeleteMessageHovered = () => {
|
const isDeleteMessageHovered = () => {
|
||||||
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
|
||||||
|
|
||||||
@@ -1290,12 +1289,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
|
||||||
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
|
||||||
|
|
||||||
let headerEl: HTMLDivElement | undefined
|
|
||||||
let actionsEl: HTMLDivElement | undefined
|
|
||||||
let primaryEl: HTMLSpanElement | undefined
|
|
||||||
let metaMeasureEl: HTMLSpanElement | undefined
|
|
||||||
const [showMetaInline, setShowMetaInline] = createSignal(true)
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setExpanded(Boolean(props.defaultExpanded))
|
setExpanded(Boolean(props.defaultExpanded))
|
||||||
})
|
})
|
||||||
@@ -1323,33 +1316,6 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
|
||||||
|
|
||||||
const updateMetaLayout = () => {
|
|
||||||
if (!hasMeta()) return
|
|
||||||
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
|
|
||||||
|
|
||||||
const headerWidth = headerEl.getBoundingClientRect().width
|
|
||||||
const actionsWidth = actionsEl.getBoundingClientRect().width
|
|
||||||
const primaryWidth = primaryEl.getBoundingClientRect().width
|
|
||||||
const metaWidth = metaMeasureEl.getBoundingClientRect().width
|
|
||||||
|
|
||||||
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
|
|
||||||
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (!hasMeta() || typeof ResizeObserver === "undefined") {
|
|
||||||
setShowMetaInline(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetaLayout()
|
|
||||||
const observer = new ResizeObserver(() => updateMetaLayout())
|
|
||||||
if (headerEl) observer.observe(headerEl)
|
|
||||||
if (actionsEl) observer.observe(actionsEl)
|
|
||||||
if (primaryEl) observer.observe(primaryEl)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
const reasoningText = () => {
|
const reasoningText = () => {
|
||||||
const part = props.part as any
|
const part = props.part as any
|
||||||
if (!part) return ""
|
if (!part) return ""
|
||||||
@@ -1428,7 +1394,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="delete-hover-scope message-reasoning-card">
|
<div class="delete-hover-scope message-reasoning-card">
|
||||||
<div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
|
<div class="message-reasoning-header">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-reasoning-toggle"
|
class="message-reasoning-toggle"
|
||||||
@@ -1437,7 +1403,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
|
||||||
>
|
>
|
||||||
<span class="message-reasoning-label">
|
<span class="message-reasoning-label">
|
||||||
<span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
|
<span class="message-reasoning-label-primary">
|
||||||
<Show when={props.showDeleteMessage}>
|
<Show when={props.showDeleteMessage}>
|
||||||
<input
|
<input
|
||||||
class="message-select-checkbox"
|
class="message-select-checkbox"
|
||||||
@@ -1458,43 +1424,10 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
|
|
||||||
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Show when={hasMeta() && showMetaInline()}>
|
|
||||||
<span class="message-step-meta-inline">
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<Show when={hasMeta()}>
|
|
||||||
<span
|
|
||||||
ref={(el) => (metaMeasureEl = el)}
|
|
||||||
class="message-step-meta-inline message-step-meta-inline--measure"
|
|
||||||
>
|
|
||||||
<Show when={agentIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
<Show when={modelIdentifier()}>
|
|
||||||
{(value) => (
|
|
||||||
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
|
<div class="message-reasoning-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="message-action-button"
|
class="message-action-button"
|
||||||
@@ -1543,7 +1476,7 @@ function ReasoningCard(props: ReasoningCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={hasMeta() && !showMetaInline()}>
|
<Show when={hasMeta()}>
|
||||||
<div class="message-reasoning-meta-row">
|
<div class="message-reasoning-meta-row">
|
||||||
<span class="message-step-meta-inline">
|
<span class="message-step-meta-inline">
|
||||||
<Show when={agentIdentifier()}>
|
<Show when={agentIdentifier()}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js"
|
||||||
import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
|
import { ArrowBigUp, ArrowBigDown, Loader2, Mic } from "lucide-solid"
|
||||||
import UnifiedPicker from "./unified-picker"
|
import UnifiedPicker from "./unified-picker"
|
||||||
import ExpandButton from "./expand-button"
|
import ExpandButton from "./expand-button"
|
||||||
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
import { clearAttachments, removeAttachment } from "../stores/attachments"
|
||||||
@@ -17,6 +17,7 @@ import { usePromptState } from "./prompt-input/usePromptState"
|
|||||||
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
|
||||||
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
import { usePromptPicker } from "./prompt-input/usePromptPicker"
|
||||||
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
|
||||||
|
import { usePromptVoiceInput } from "./prompt-input/usePromptVoiceInput"
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
@@ -411,9 +412,45 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shouldShowOverlay = () => prompt().length === 0
|
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 instance = () => getActiveInstance()
|
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 (
|
return (
|
||||||
<div class="prompt-input-container">
|
<div class="prompt-input-container">
|
||||||
<div
|
<div
|
||||||
@@ -555,6 +592,48 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<div class="prompt-input-actions">
|
||||||
|
<Show when={showVoiceInput()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={`prompt-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>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="prompt-voice-timer">{formatVoiceTimer(voiceInput.elapsedMs())}</span>
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="stop-button"
|
class="stop-button"
|
||||||
@@ -589,3 +668,10 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatVoiceTimer(elapsedMs: number): string {
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000))
|
||||||
|
const minutes = Math.floor(totalSeconds / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|||||||
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
244
packages/ui/src/components/prompt-input/usePromptVoiceInput.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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
|
||||||
|
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)
|
||||||
|
}
|
||||||
111
packages/ui/src/components/settings-screen.tsx
Normal file
111
packages/ui/src/components/settings-screen.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { Dialog } from "@kobalte/core/dialog"
|
||||||
|
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 {
|
||||||
|
activeSettingsSection,
|
||||||
|
closeSettings,
|
||||||
|
settingsOpen,
|
||||||
|
setActiveSettingsSection,
|
||||||
|
type SettingsSectionId,
|
||||||
|
} from "../stores/settings-screen"
|
||||||
|
import { AppearanceSettingsSection } from "./settings/appearance-settings-section"
|
||||||
|
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()
|
||||||
|
|
||||||
|
const sections = createMemo(() => [
|
||||||
|
{ 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") },
|
||||||
|
])
|
||||||
|
|
||||||
|
const renderSection = () => {
|
||||||
|
switch (activeSettingsSection()) {
|
||||||
|
case "notifications":
|
||||||
|
return <NotificationsSettingsSection />
|
||||||
|
case "remote":
|
||||||
|
return <RemoteAccessSettingsSection />
|
||||||
|
case "speech":
|
||||||
|
return <SpeechSettingsSection />
|
||||||
|
case "opencode":
|
||||||
|
return <OpenCodeSettingsSection />
|
||||||
|
case "appearance":
|
||||||
|
default:
|
||||||
|
return <AppearanceSettingsSection />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={settingsOpen()} onOpenChange={(open) => !open && closeSettings()}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
|
<div class="settings-screen-frame">
|
||||||
|
<Dialog.Content class="modal-surface settings-screen-shell">
|
||||||
|
<Dialog.Title class="sr-only">{t("settings.title")}</Dialog.Title>
|
||||||
|
|
||||||
|
<aside class="settings-screen-nav">
|
||||||
|
<div class="settings-screen-nav-header">
|
||||||
|
<div class="settings-screen-nav-title-row">
|
||||||
|
<span class="settings-screen-nav-icon-wrap">
|
||||||
|
<Settings class="settings-screen-nav-icon" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h2 class="settings-screen-title">{t("settings.title")}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="settings-screen-nav-list" aria-label={t("settings.navigationAriaLabel")}>
|
||||||
|
<For each={sections()}>
|
||||||
|
{(section) => {
|
||||||
|
const Icon = section.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-nav-button"
|
||||||
|
data-selected={activeSettingsSection() === section.id ? "true" : "false"}
|
||||||
|
onClick={() => setActiveSettingsSection(section.id)}
|
||||||
|
>
|
||||||
|
<Icon class="settings-nav-button-icon" />
|
||||||
|
<span>{section.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="settings-screen-content">
|
||||||
|
<header class="settings-screen-content-header">
|
||||||
|
<div class="settings-screen-content-header-title-group">
|
||||||
|
<p class="settings-screen-content-eyebrow">{t("settings.content.eyebrow")}</p>
|
||||||
|
<h1 class="settings-screen-content-title">
|
||||||
|
{sections().find((section) => section.id === activeSettingsSection())?.label}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary settings-screen-close"
|
||||||
|
onClick={closeSettings}
|
||||||
|
aria-label={t("settings.close")}
|
||||||
|
title={t("settings.close")}
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="settings-screen-scroll">{renderSection()}</div>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</div>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { Select } from "@kobalte/core/select"
|
||||||
|
import { createEffect, createMemo, createSignal, For, type Component } from "solid-js"
|
||||||
|
import { Check, ChevronDown, Laptop, Moon, Sun } from "lucide-solid"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { useTheme, type ThemeMode } from "../../lib/theme"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { getBehaviorSettings, type BehaviorSetting } from "../../lib/settings/behavior-registry"
|
||||||
|
|
||||||
|
const themeModeOptions: Array<{ value: ThemeMode; icon: typeof Laptop }> = [
|
||||||
|
{ value: "system", icon: Laptop },
|
||||||
|
{ value: "light", icon: Sun },
|
||||||
|
{ value: "dark", icon: Moon },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AppearanceSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { themeMode, setThemeMode } = useTheme()
|
||||||
|
const {
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
|
toggleShowTimelineTools,
|
||||||
|
toggleUsageMetrics,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
|
setDiffViewMode,
|
||||||
|
setToolOutputExpansion,
|
||||||
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
|
} = useConfig()
|
||||||
|
|
||||||
|
const behaviorSettings = createMemo(() =>
|
||||||
|
getBehaviorSettings({
|
||||||
|
preferences,
|
||||||
|
updatePreferences,
|
||||||
|
toggleShowThinkingBlocks,
|
||||||
|
toggleKeyboardShortcutHints,
|
||||||
|
toggleShowTimelineTools,
|
||||||
|
toggleUsageMetrics,
|
||||||
|
toggleAutoCleanupBlankSessions,
|
||||||
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
|
setDiffViewMode,
|
||||||
|
setToolOutputExpansion,
|
||||||
|
setDiagnosticsExpansion,
|
||||||
|
setThinkingBlocksExpansion,
|
||||||
|
setToolInputsVisibility,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const [overrides, setOverrides] = createSignal<Map<string, unknown>>(new Map())
|
||||||
|
|
||||||
|
const setOverride = (id: string, value: unknown) => {
|
||||||
|
setOverrides((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, value)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const current = overrides()
|
||||||
|
if (current.size === 0) return
|
||||||
|
|
||||||
|
const prefs = preferences()
|
||||||
|
const settings = behaviorSettings()
|
||||||
|
|
||||||
|
let changed = false
|
||||||
|
const next = new Map(current)
|
||||||
|
for (const setting of settings) {
|
||||||
|
if (!next.has(setting.id)) continue
|
||||||
|
const overrideValue = next.get(setting.id)
|
||||||
|
const actualValue = setting.get(prefs)
|
||||||
|
if (Object.is(actualValue, overrideValue)) {
|
||||||
|
next.delete(setting.id)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
setOverrides(next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const readSettingValue = (setting: BehaviorSetting) => {
|
||||||
|
const current = overrides()
|
||||||
|
if (current.has(setting.id)) return current.get(setting.id)
|
||||||
|
return setting.get(preferences())
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectOption = { value: string; label: string }
|
||||||
|
|
||||||
|
const BehaviorRow: Component<{ setting: BehaviorSetting }> = (props) => {
|
||||||
|
const setting = props.setting
|
||||||
|
const disabled = createMemo(() => (setting.disabled ? Boolean(setting.disabled()) : false))
|
||||||
|
|
||||||
|
if (setting.kind === "toggle") {
|
||||||
|
const options = createMemo<SelectOption[]>(() => [
|
||||||
|
{ value: "true", label: t("settings.common.enabled") },
|
||||||
|
{ value: "false", label: t("settings.common.disabled") },
|
||||||
|
])
|
||||||
|
const currentValue = createMemo(() => String(Boolean(readSettingValue(setting))))
|
||||||
|
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
||||||
|
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<Select<SelectOption>
|
||||||
|
value={selectedOption()}
|
||||||
|
onChange={(opt) => {
|
||||||
|
if (!opt) return
|
||||||
|
const next = opt.value === "true"
|
||||||
|
setOverride(setting.id, next)
|
||||||
|
setting.set(next)
|
||||||
|
}}
|
||||||
|
options={options()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
disabled={disabled()}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<SelectOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumSetting = setting as Extract<BehaviorSetting, { kind: "enum" }>
|
||||||
|
const options = createMemo<SelectOption[]>(() =>
|
||||||
|
enumSetting.options.map((opt: { value: string; labelKey: string }) => ({
|
||||||
|
value: String(opt.value),
|
||||||
|
label: t(opt.labelKey),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
const currentValue = createMemo(() => String(readSettingValue(setting) ?? ""))
|
||||||
|
const selectedOption = createMemo(() => options().find((opt) => opt.value === currentValue()))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`settings-toggle-row ${disabled() ? "opacity-60" : ""}`}>
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t(setting.titleKey)}</div>
|
||||||
|
<div class="settings-toggle-caption">{t(setting.subtitleKey)}</div>
|
||||||
|
</div>
|
||||||
|
<Select<SelectOption>
|
||||||
|
value={selectedOption()}
|
||||||
|
onChange={(opt) => {
|
||||||
|
if (!opt) return
|
||||||
|
setOverride(setting.id, opt.value)
|
||||||
|
enumSetting.set(opt.value as any)
|
||||||
|
}}
|
||||||
|
options={options()}
|
||||||
|
optionValue="value"
|
||||||
|
optionTextValue="label"
|
||||||
|
disabled={disabled()}
|
||||||
|
itemComponent={(itemProps) => (
|
||||||
|
<Select.Item item={itemProps.item} class="selector-option">
|
||||||
|
<Select.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Select.ItemLabel>
|
||||||
|
</Select.Item>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="selector-trigger" aria-label={t(setting.titleKey)}>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<Select.Value<SelectOption>>
|
||||||
|
{(state) => (
|
||||||
|
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||||
|
{state.selectedOption()?.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Select.Value>
|
||||||
|
</div>
|
||||||
|
<Select.Icon class="selector-trigger-icon">
|
||||||
|
<ChevronDown class="w-3 h-3" />
|
||||||
|
</Select.Icon>
|
||||||
|
</Select.Trigger>
|
||||||
|
|
||||||
|
<Select.Portal>
|
||||||
|
<Select.Content class="selector-popover">
|
||||||
|
<Select.Listbox class="selector-listbox" />
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Portal>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modeLabel = (mode: ThemeMode) => {
|
||||||
|
if (mode === "system") return t("theme.mode.system")
|
||||||
|
if (mode === "light") return t("theme.mode.light")
|
||||||
|
return t("theme.mode.dark")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.appearance.theme.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.appearance.theme.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-choice-grid">
|
||||||
|
{themeModeOptions.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="settings-choice"
|
||||||
|
data-selected={themeMode() === option.value ? "true" : "false"}
|
||||||
|
onClick={() => setThemeMode(option.value)}
|
||||||
|
>
|
||||||
|
<span class="settings-choice-icon-wrap">
|
||||||
|
<Icon class="settings-choice-icon" />
|
||||||
|
</span>
|
||||||
|
<span class="settings-choice-copy">
|
||||||
|
<span class="settings-choice-label">{modeLabel(option.value)}</span>
|
||||||
|
<span class="settings-choice-description">{t(`settings.appearance.theme.option.${option.value}`)}</span>
|
||||||
|
</span>
|
||||||
|
<span class="settings-choice-check" aria-hidden="true">
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.appearance.behavior.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.appearance.behavior.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<For each={behaviorSettings()}>{(setting) => <BehaviorRow setting={setting} />}</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { Show, createEffect, createResource, type Component } from "solid-js"
|
||||||
|
import { Bell } from "lucide-solid"
|
||||||
|
import { showToastNotification } from "../../lib/notifications"
|
||||||
|
import {
|
||||||
|
getOsNotificationCapability,
|
||||||
|
requestOsNotificationPermission,
|
||||||
|
type OsNotificationPermission,
|
||||||
|
} from "../../lib/os-notifications"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
function formatPermissionLabel(permission: OsNotificationPermission, t: ReturnType<typeof useI18n>["t"]): string {
|
||||||
|
switch (permission) {
|
||||||
|
case "granted":
|
||||||
|
return t("settings.notifications.permission.granted")
|
||||||
|
case "denied":
|
||||||
|
return t("settings.notifications.permission.denied")
|
||||||
|
case "default":
|
||||||
|
return t("settings.notifications.permission.default")
|
||||||
|
case "unsupported":
|
||||||
|
return t("settings.notifications.permission.unsupported")
|
||||||
|
default:
|
||||||
|
return String(permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NotificationsSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { preferences, updatePreferences } = useConfig()
|
||||||
|
const [capability, { refetch }] = createResource(() => getOsNotificationCapability())
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
void refetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleEnableToggle = async (enabled: boolean) => {
|
||||||
|
if (!enabled) {
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: cap.info ?? t("settings.notifications.messages.unsupportedEnvironment"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission !== "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? t("settings.notifications.messages.permissionDenied")
|
||||||
|
: t("settings.notifications.messages.permissionNotGranted"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
updatePreferences({ osNotificationsEnabled: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreferences({ osNotificationsEnabled: true })
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestPermission = async () => {
|
||||||
|
const cap = capability()
|
||||||
|
if (cap && !cap.supported) {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: cap.info ?? t("settings.notifications.messages.unsupportedGeneral"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await requestOsNotificationPermission()
|
||||||
|
if (permission === "granted") {
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message: t("settings.notifications.messages.permissionGranted"),
|
||||||
|
variant: "success",
|
||||||
|
duration: 6000,
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showToastNotification({
|
||||||
|
title: t("settings.section.notifications.title"),
|
||||||
|
message:
|
||||||
|
permission === "denied"
|
||||||
|
? t("settings.notifications.messages.permissionRequestDenied")
|
||||||
|
: t("settings.notifications.messages.permissionNotGranted"),
|
||||||
|
variant: "warning",
|
||||||
|
})
|
||||||
|
void refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = () => capability()?.supported ?? false
|
||||||
|
const permissionLabel = () => formatPermissionLabel(capability()?.permission ?? "unsupported", t)
|
||||||
|
const infoMessage = () => capability()?.info
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Bell class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.notifications.sessionStatus.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.notifications.sessionStatus.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.enable.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">
|
||||||
|
{t("settings.notifications.enable.permission", { permission: permissionLabel() })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsEnabled)}
|
||||||
|
disabled={!supported() && capability.state === "ready"}
|
||||||
|
onChange={(event) => void handleEnableToggle(event.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={supported() && (capability()?.permission ?? "unsupported") !== "granted"}>
|
||||||
|
<div class="settings-toggle-row settings-toggle-row-compact">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.requestPermission.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.notifications.requestPermission.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="selector-button selector-button-secondary w-auto whitespace-nowrap"
|
||||||
|
onClick={() => void handleRequestPermission()}
|
||||||
|
>
|
||||||
|
{t("settings.notifications.requestPermission.action")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.allowVisible.title")}</div>
|
||||||
|
<div class="settings-toggle-caption">{t("settings.notifications.allowVisible.subtitle")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().osNotificationsAllowWhenVisible)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ osNotificationsAllowWhenVisible: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={Boolean(infoMessage())}>
|
||||||
|
<div class="settings-inline-note">{infoMessage()}</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={!supported() && capability.state === "ready"}>
|
||||||
|
<div class="settings-inline-note">{t("settings.notifications.unsupportedNote")}</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.notifications.events.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.notifications.events.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge">{t("settings.scope.device")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-stack">
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.events.needsInput")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnNeedsInput)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ notifyOnNeedsInput: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div>
|
||||||
|
<div class="settings-toggle-title">{t("settings.notifications.events.idle")}</div>
|
||||||
|
</div>
|
||||||
|
<label class="settings-checkbox-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(preferences().notifyOnIdle)}
|
||||||
|
disabled={!preferences().osNotificationsEnabled}
|
||||||
|
onChange={(event) => updatePreferences({ notifyOnIdle: event.currentTarget.checked })}
|
||||||
|
/>
|
||||||
|
<span>{t("settings.common.enabled")}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { createEffect, createSignal, type Component } from "solid-js"
|
||||||
|
import { Terminal } from "lucide-solid"
|
||||||
|
import OpenCodeBinarySelector from "../opencode-binary-selector"
|
||||||
|
import EnvironmentVariablesEditor from "../environment-variables-editor"
|
||||||
|
import { useConfig } from "../../stores/preferences"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
export const OpenCodeSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { serverSettings, updateLastUsedBinary } = useConfig()
|
||||||
|
const [selectedBinary, setSelectedBinary] = createSignal(serverSettings().opencodeBinary || "opencode")
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const binary = serverSettings().opencodeBinary || "opencode"
|
||||||
|
setSelectedBinary((current) => (current === binary ? current : binary))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBinaryChange = (binary: string) => {
|
||||||
|
setSelectedBinary(binary)
|
||||||
|
updateLastUsedBinary(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Terminal class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("settings.opencode.runtime.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("settings.opencode.runtime.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OpenCodeBinarySelector selectedBinary={selectedBinary()} onBinaryChange={handleBinaryChange} isVisible />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("advancedSettings.environmentVariables.title")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("advancedSettings.environmentVariables.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
<EnvironmentVariablesEditor />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import { Switch } from "@kobalte/core/switch"
|
||||||
|
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||||
|
import { toDataURL } from "qrcode"
|
||||||
|
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
|
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||||
|
import { serverApi } from "../../lib/api-client"
|
||||||
|
import { restartCli } from "../../lib/native/cli"
|
||||||
|
import { serverSettings, setListeningMode } from "../../stores/preferences"
|
||||||
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
|
import { getLogger } from "../../lib/logger"
|
||||||
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
export const RemoteAccessSettingsSection: Component = () => {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [meta, setMeta] = createSignal<ServerMeta | null>(null)
|
||||||
|
const [authStatus, setAuthStatus] = createSignal<{
|
||||||
|
authenticated: boolean
|
||||||
|
username?: string
|
||||||
|
passwordUserProvided?: boolean
|
||||||
|
} | null>(null)
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
|
||||||
|
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
|
||||||
|
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [passwordFormOpen, setPasswordFormOpen] = createSignal(false)
|
||||||
|
const [passwordValue, setPasswordValue] = createSignal("")
|
||||||
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
|
|
||||||
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
|
const displayAddresses = createMemo(() => {
|
||||||
|
const list = addresses()
|
||||||
|
if (!allowExternalConnections()) return []
|
||||||
|
return list.filter((address) => address.scope !== "loopback")
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshMeta = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setPasswordError(null)
|
||||||
|
try {
|
||||||
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
|
setMeta(metaResult)
|
||||||
|
setAuthStatus(authResult)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void refreshMeta()
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleExpanded = async (url: string) => {
|
||||||
|
if (expandedUrl() === url) {
|
||||||
|
setExpandedUrl(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setExpandedUrl(url)
|
||||||
|
if (!qrCodes()[url]) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await toDataURL(url, { margin: 1, scale: 4 })
|
||||||
|
setQrCodes((prev) => ({ ...prev, [url]: dataUrl }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to generate QR code", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAllowConnectionsChange = async (checked: boolean) => {
|
||||||
|
const targetMode: "local" | "all" = checked ? "all" : "local"
|
||||||
|
if (targetMode === currentMode() || applyingListeningMode()) return
|
||||||
|
|
||||||
|
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
|
||||||
|
title: checked
|
||||||
|
? t("remoteAccess.listeningMode.restartConfirm.title.all")
|
||||||
|
: t("remoteAccess.listeningMode.restartConfirm.title.local"),
|
||||||
|
variant: "warning",
|
||||||
|
confirmLabel: t("remoteAccess.listeningMode.restartConfirm.confirmLabel"),
|
||||||
|
cancelLabel: t("remoteAccess.listeningMode.restartConfirm.cancelLabel"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setApplyingListeningMode(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await setListeningMode(targetMode)
|
||||||
|
const restarted = await restartCli()
|
||||||
|
if (!restarted) {
|
||||||
|
setError(t("remoteAccess.restart.errorManual"))
|
||||||
|
} else {
|
||||||
|
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setApplyingListeningMode(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
void refreshMeta()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpenUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer")
|
||||||
|
} catch (err) {
|
||||||
|
log.error("Failed to open URL", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitPassword = async () => {
|
||||||
|
setPasswordError(null)
|
||||||
|
|
||||||
|
const next = passwordValue()
|
||||||
|
const confirm = passwordConfirm()
|
||||||
|
if (next.trim().length < 8) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.tooShort"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next !== confirm) {
|
||||||
|
setPasswordError(t("remoteAccess.password.error.mismatch"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingPassword(true)
|
||||||
|
try {
|
||||||
|
const result = await serverApi.setServerPassword(next)
|
||||||
|
setAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
username: result.username,
|
||||||
|
passwordUserProvided: result.passwordUserProvided,
|
||||||
|
})
|
||||||
|
setPasswordValue("")
|
||||||
|
setPasswordConfirm("")
|
||||||
|
setPasswordFormOpen(false)
|
||||||
|
} catch (err) {
|
||||||
|
setPasswordError(err instanceof Error ? err.message : String(err))
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="settings-section-stack">
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Shield class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.listeningMode.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.listeningMode.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toolbar-inline">
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
<button
|
||||||
|
class="selector-button selector-button-secondary w-auto"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshMeta()}
|
||||||
|
disabled={loading()}
|
||||||
|
>
|
||||||
|
<RefreshCw class={`w-4 h-4 ${loading() ? "remote-spin" : ""}`} />
|
||||||
|
<span>{t("remoteAccess.refresh")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
class="remote-toggle"
|
||||||
|
checked={allowExternalConnections()}
|
||||||
|
onChange={(nextChecked) => void handleAllowConnectionsChange(nextChecked)}
|
||||||
|
disabled={loading() || applyingListeningMode()}
|
||||||
|
>
|
||||||
|
<Switch.Input />
|
||||||
|
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>
|
||||||
|
<span class="remote-toggle-state">
|
||||||
|
{allowExternalConnections() ? t("remoteAccess.toggle.on") : t("remoteAccess.toggle.off")}
|
||||||
|
</span>
|
||||||
|
<Switch.Thumb class="remote-toggle-thumb" />
|
||||||
|
</Switch.Control>
|
||||||
|
<div class="remote-toggle-copy">
|
||||||
|
<span class="remote-toggle-title">{t("remoteAccess.toggle.title")}</span>
|
||||||
|
<span class="remote-toggle-caption">
|
||||||
|
{allowExternalConnections()
|
||||||
|
? t("remoteAccess.toggle.caption.all")
|
||||||
|
: t("remoteAccess.toggle.caption.local")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Switch>
|
||||||
|
|
||||||
|
<p class="remote-toggle-note">{t("remoteAccess.toggle.note")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Shield class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.serverPassword.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.serverPassword.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show
|
||||||
|
when={authStatus() && authStatus()!.authenticated}
|
||||||
|
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
|
>
|
||||||
|
<div class="settings-card-content">
|
||||||
|
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||||
|
<p class="settings-help-text">
|
||||||
|
{authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.status.set")
|
||||||
|
: t("remoteAccess.password.status.unset")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="settings-password-actions">
|
||||||
|
<button
|
||||||
|
class="settings-pill-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
|
setPasswordError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{passwordFormOpen()
|
||||||
|
? t("remoteAccess.password.actions.cancel")
|
||||||
|
: authStatus()!.passwordUserProvided
|
||||||
|
? t("remoteAccess.password.actions.change")
|
||||||
|
: t("remoteAccess.password.actions.set")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordFormOpen()}>
|
||||||
|
<div class="settings-form-group">
|
||||||
|
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordValue()}
|
||||||
|
onInput={(event) => setPasswordValue(event.currentTarget.value)}
|
||||||
|
placeholder={t("remoteAccess.password.form.placeholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="settings-form-group">
|
||||||
|
<label class="settings-form-label">{t("remoteAccess.password.form.confirmPassword")}</label>
|
||||||
|
<input
|
||||||
|
class="selector-input w-full"
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirm()}
|
||||||
|
onInput={(event) => setPasswordConfirm(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={passwordError()}>
|
||||||
|
{(message) => <div class="settings-error-message">{message()}</div>}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div class="settings-password-actions">
|
||||||
|
<button class="settings-pill-button" type="button" disabled={savingPassword()} onClick={() => void handleSubmitPassword()}>
|
||||||
|
{savingPassword() ? t("remoteAccess.password.save.saving") : t("remoteAccess.password.save.label")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-card">
|
||||||
|
<div class="settings-card-header">
|
||||||
|
<div class="settings-card-heading-with-icon">
|
||||||
|
<Wifi class="settings-card-heading-icon" />
|
||||||
|
<div>
|
||||||
|
<h3 class="settings-card-title">{t("remoteAccess.sections.addresses.label")}</h3>
|
||||||
|
<p class="settings-card-subtitle">{t("remoteAccess.sections.addresses.help")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="settings-scope-badge settings-scope-badge-server">{t("settings.scope.server")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
|
<Show
|
||||||
|
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||||
|
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||||
|
>
|
||||||
|
<div class="remote-address-list">
|
||||||
|
<Show when={meta()?.localUrl}>
|
||||||
|
{(url) => {
|
||||||
|
const value = () => url()
|
||||||
|
const expandedState = () => expandedUrl() === value()
|
||||||
|
const qr = () => qrCodes()[value()]
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{value()}</p>
|
||||||
|
<p class="remote-address-meta">{t("remoteAccess.address.scope.loopback")}</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(value())}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(value())}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img
|
||||||
|
src={dataUrl()}
|
||||||
|
alt={t("remoteAccess.address.qrAlt", { url: value() })}
|
||||||
|
class="remote-qr-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<For each={displayAddresses()}>
|
||||||
|
{(address) => {
|
||||||
|
const url = address.remoteUrl
|
||||||
|
const expandedState = () => expandedUrl() === url
|
||||||
|
const qr = () => qrCodes()[url]
|
||||||
|
const scopeLabel = () =>
|
||||||
|
address.scope === "external"
|
||||||
|
? t("remoteAccess.address.scope.network")
|
||||||
|
: address.scope === "loopback"
|
||||||
|
? t("remoteAccess.address.scope.loopback")
|
||||||
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="remote-address">
|
||||||
|
<div class="remote-address-main">
|
||||||
|
<div>
|
||||||
|
<p class="remote-address-url">{url}</p>
|
||||||
|
<p class="remote-address-meta">
|
||||||
|
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="remote-actions">
|
||||||
|
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
||||||
|
<ExternalLink class="remote-icon" />
|
||||||
|
{t("remoteAccess.address.open")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="remote-pill"
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleExpanded(url)}
|
||||||
|
aria-expanded={expandedState()}
|
||||||
|
>
|
||||||
|
<Link2 class="remote-icon" />
|
||||||
|
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={expandedState()}>
|
||||||
|
<div class="remote-qr">
|
||||||
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
|
{(dataUrl) => (
|
||||||
|
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
217
packages/ui/src/components/settings/speech-settings-card.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { createEffect, createMemo, createSignal, type Component } from "solid-js"
|
||||||
|
import { Mic, 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"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
type DraftFields = {
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDraftFields(speech: SpeechSettings): DraftFields {
|
||||||
|
return {
|
||||||
|
apiKey: speech.apiKey ?? "",
|
||||||
|
baseUrl: speech.baseUrl ?? "",
|
||||||
|
sttModel: speech.sttModel,
|
||||||
|
ttsModel: speech.ttsModel,
|
||||||
|
ttsVoice: speech.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const nextDrafts = createDraftFields(speech)
|
||||||
|
if (!isSaving() && !isDirty()) {
|
||||||
|
if (!isDraftEqual(drafts(), nextDrafts)) {
|
||||||
|
setDrafts(nextDrafts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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")
|
||||||
|
setDrafts((current) => ({ ...current, [key]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = createMemo(() => {
|
||||||
|
const speech = serverSettings().speech
|
||||||
|
const current = drafts()
|
||||||
|
return (
|
||||||
|
(current.apiKey || "") !== (speech.apiKey || "") ||
|
||||||
|
(current.baseUrl || "") !== (speech.baseUrl || "") ||
|
||||||
|
current.sttModel !== speech.sttModel ||
|
||||||
|
current.ttsModel !== speech.ttsModel ||
|
||||||
|
current.ttsVoice !== speech.ttsVoice
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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 {
|
||||||
|
await updateSpeechSettings({
|
||||||
|
apiKey: current.apiKey.trim() || undefined,
|
||||||
|
baseUrl: current.baseUrl.trim() || undefined,
|
||||||
|
sttModel: current.sttModel.trim() || undefined,
|
||||||
|
ttsModel: current.ttsModel.trim() || undefined,
|
||||||
|
ttsVoice: current.ttsVoice.trim() || undefined,
|
||||||
|
})
|
||||||
|
await loadSpeechCapabilities(true)
|
||||||
|
setDrafts({
|
||||||
|
apiKey: current.apiKey.trim(),
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
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-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"
|
||||||
|
/>
|
||||||
|
<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" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="settings-inline-note">{t("settings.speech.help")}</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SpeechSettingsCard
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor, type JSX } from "solid-js"
|
||||||
import VirtualItem from "./virtual-item"
|
import VirtualItem, { type VirtualItemHeightChangeMeta } from "./virtual-item"
|
||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
@@ -374,7 +374,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleContentRendered() {
|
function handleContentRendered() {
|
||||||
scheduleAnchorScroll()
|
if (autoScroll() && !anchorLock()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
@@ -470,9 +477,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const bottomAfter = rect.bottom
|
const bottomAfter = rect.bottom
|
||||||
const bottomBefore = bottomAfter - delta
|
const bottomBefore = bottomAfter - delta
|
||||||
const wasAboveViewport = bottomBefore < containerRect.top
|
const wasAboveViewport = bottomBefore < containerRect.top
|
||||||
if (!wasAboveViewport) {
|
if (!wasAboveViewport) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
const next = (pendingScrollCompensations.get(key) ?? 0) + delta
|
||||||
pendingScrollCompensations.set(key, next)
|
pendingScrollCompensations.set(key, next)
|
||||||
@@ -516,25 +521,51 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let pendingAutoPin = false
|
let pendingAutoPin = false
|
||||||
|
let pendingAutoPinFrame: number | null = null
|
||||||
|
|
||||||
|
function clearPendingAutoPinFrame() {
|
||||||
|
if (pendingAutoPinFrame !== null) {
|
||||||
|
cancelAnimationFrame(pendingAutoPinFrame)
|
||||||
|
pendingAutoPinFrame = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAutoPinToBottom() {
|
||||||
|
if (!containerRef) return false
|
||||||
|
if (!autoScroll()) return false
|
||||||
|
if (anchorLock()) return false
|
||||||
|
|
||||||
|
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
||||||
|
if (containerRef.scrollTop !== maxScrollTop) {
|
||||||
|
containerRef.scrollTop = maxScrollTop
|
||||||
|
lastKnownScrollTop = maxScrollTop
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleAutoPinToBottom() {
|
function scheduleAutoPinToBottom() {
|
||||||
if (!containerRef) return
|
if (!containerRef) return
|
||||||
if (pendingAutoPin) return
|
if (pendingAutoPin) return
|
||||||
pendingAutoPin = true
|
pendingAutoPin = true
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
const gen = scrollCompensationGen
|
const gen = scrollCompensationGen
|
||||||
|
|
||||||
// Flush in a microtask so adjustments land before the next paint.
|
// Flush in a microtask so adjustments land before the next paint,
|
||||||
|
// then re-apply on the next two frames to catch deferred layout.
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (gen !== scrollCompensationGen) return
|
if (gen !== scrollCompensationGen) return
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
if (!containerRef) return
|
if (!applyAutoPinToBottom()) return
|
||||||
if (!autoScroll()) return
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
if (anchorLock()) return
|
pendingAutoPinFrame = null
|
||||||
|
if (gen !== scrollCompensationGen) return
|
||||||
const maxScrollTop = Math.max(containerRef.scrollHeight - containerRef.clientHeight, 0)
|
if (!applyAutoPinToBottom()) return
|
||||||
if (containerRef.scrollTop !== maxScrollTop) {
|
pendingAutoPinFrame = requestAnimationFrame(() => {
|
||||||
containerRef.scrollTop = maxScrollTop
|
pendingAutoPinFrame = null
|
||||||
lastKnownScrollTop = maxScrollTop
|
if (gen !== scrollCompensationGen) return
|
||||||
}
|
applyAutoPinToBottom()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,6 +654,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
pendingAutoPin = false
|
pendingAutoPin = false
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
|
|
||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
pendingActiveScroll = false
|
pendingActiveScroll = false
|
||||||
@@ -713,7 +745,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
suppressAutoScrollOnce = false
|
suppressAutoScrollOnce = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (autoScroll()) scheduleAnchorScroll(true)
|
if (autoScroll()) {
|
||||||
|
scheduleAutoPinToBottom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (anchorLock() && !autoScroll()) {
|
||||||
|
scheduleAnchorCorrection()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop anchor lock if the anchored key is removed.
|
// Drop anchor lock if the anchored key is removed.
|
||||||
@@ -820,6 +858,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollCompensationGen += 1
|
scrollCompensationGen += 1
|
||||||
pendingScrollCompensationScheduled = false
|
pendingScrollCompensationScheduled = false
|
||||||
pendingScrollCompensations = new Map()
|
pendingScrollCompensations = new Map()
|
||||||
|
clearPendingAutoPinFrame()
|
||||||
clearScrollToBottomFrames()
|
clearScrollToBottomFrames()
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
@@ -883,6 +922,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const anchorId = () => getAnchorId(key())
|
const anchorId = () => getAnchorId(key())
|
||||||
const overscanPx = props.overscanPx ?? 800
|
const overscanPx = props.overscanPx ?? 800
|
||||||
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
const suspendMeasurements = () => measurementsSuspended() || !isActive()
|
||||||
|
const itemVirtualizationEnabled = () => virtualizationEnabled() && !autoScroll()
|
||||||
return (
|
return (
|
||||||
<VirtualItem
|
<VirtualItem
|
||||||
id={anchorId()}
|
id={anchorId()}
|
||||||
@@ -890,9 +930,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
scrollContainer={scrollElement}
|
scrollContainer={scrollElement}
|
||||||
threshold={overscanPx}
|
threshold={overscanPx}
|
||||||
placeholderClass="message-stream-placeholder"
|
placeholderClass="message-stream-placeholder"
|
||||||
virtualizationEnabled={virtualizationEnabled}
|
virtualizationEnabled={itemVirtualizationEnabled}
|
||||||
suspendMeasurements={suspendMeasurements}
|
suspendMeasurements={suspendMeasurements}
|
||||||
onHeightChange={(nextHeight, previousHeight) => {
|
onHeightChange={(nextHeight, previousHeight, meta: VirtualItemHeightChangeMeta) => {
|
||||||
const delta = nextHeight - previousHeight
|
const delta = nextHeight - previousHeight
|
||||||
|
|
||||||
// Follow mode: keep the viewport pinned to the bottom as
|
// Follow mode: keep the viewport pinned to the bottom as
|
||||||
@@ -913,12 +953,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
// while scrolling upward, compensate scrollTop so visible
|
// while scrolling upward, compensate scrollTop so visible
|
||||||
// content stays stable.
|
// content stays stable.
|
||||||
if (delta) {
|
if (delta) {
|
||||||
|
if (meta.isStaleCacheCorrection) return
|
||||||
scheduleScrollCompensation(key(), delta)
|
scheduleScrollCompensation(key(), delta)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>{() => props.renderItem(item(), index)}</VirtualItem>
|
||||||
{() => props.renderItem(item(), index)}
|
|
||||||
</VirtualItem>
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Index>
|
</Index>
|
||||||
|
|||||||
@@ -167,10 +167,17 @@ interface VirtualItemProps {
|
|||||||
forceVisible?: Accessor<boolean>
|
forceVisible?: Accessor<boolean>
|
||||||
suspendMeasurements?: Accessor<boolean>
|
suspendMeasurements?: Accessor<boolean>
|
||||||
onMeasured?: () => void
|
onMeasured?: () => void
|
||||||
onHeightChange?: (nextHeight: number, previousHeight: number) => void
|
onHeightChange?: (nextHeight: number, previousHeight: number, meta: VirtualItemHeightChangeMeta) => void
|
||||||
id?: string
|
id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VirtualItemHeightChangeMeta {
|
||||||
|
source: "initial-visible-measure" | "resize"
|
||||||
|
previousCachedHeight: number | null
|
||||||
|
isStaleCacheCorrection: boolean
|
||||||
|
wasHidden: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export default function VirtualItem(props: VirtualItemProps) {
|
export default function VirtualItem(props: VirtualItemProps) {
|
||||||
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
const resolveContent = () => (typeof props.children === "function" ? (props.children as () => JSX.Element)() : props.children)
|
||||||
const cachedHeight = sizeCache.get(props.cacheKey)
|
const cachedHeight = sizeCache.get(props.cacheKey)
|
||||||
@@ -183,10 +190,11 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
// When content first mounts, onHeightChange deltas should reflect the DOM's
|
||||||
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
// placeholder height (not 0), otherwise scroll compensation can overshoot.
|
||||||
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
const [measuredHeight, setMeasuredHeight] = createSignal(cachedHeight ?? fallbackPlaceholderHeight())
|
||||||
const [hasMeasured, setHasMeasured] = createSignal(cachedHeight !== undefined)
|
|
||||||
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
let hasReportedMeasurement = Boolean(cachedHeight && cachedHeight > 0)
|
||||||
let pendingVisibility: boolean | null = null
|
let pendingVisibility: boolean | null = null
|
||||||
let visibilityFrame: number | null = null
|
let visibilityFrame: number | null = null
|
||||||
|
let awaitingVisibleMeasurement = true
|
||||||
|
let lastMeasurementWhileHidden = true
|
||||||
const flushVisibility = () => {
|
const flushVisibility = () => {
|
||||||
if (visibilityFrame !== null) {
|
if (visibilityFrame !== null) {
|
||||||
cancelAnimationFrame(visibilityFrame)
|
cancelAnimationFrame(visibilityFrame)
|
||||||
@@ -210,14 +218,14 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
const virtualizationEnabled = () => (props.virtualizationEnabled ? props.virtualizationEnabled() : true)
|
||||||
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
const measurementsSuspended = () => Boolean(props.suspendMeasurements?.())
|
||||||
|
const forceVisible = () => Boolean(props.forceVisible?.())
|
||||||
const shouldHideContent = createMemo(() => {
|
const shouldHideContent = createMemo(() => {
|
||||||
if (props.forceVisible?.()) return false
|
if (forceVisible()) return false
|
||||||
if (!virtualizationEnabled()) return false
|
if (!virtualizationEnabled()) return false
|
||||||
return !isIntersecting()
|
return !isIntersecting()
|
||||||
})
|
})
|
||||||
|
|
||||||
let wrapperRef: HTMLDivElement | undefined
|
let wrapperRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let contentRef: HTMLDivElement | undefined
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
let resizeObserver: ResizeObserver | undefined
|
let resizeObserver: ResizeObserver | undefined
|
||||||
@@ -230,6 +238,17 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleVisibleMeasurements() {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
|
if (!contentRef) return
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (shouldHideContent() || measurementsSuspended()) return
|
||||||
|
if (!contentRef) return
|
||||||
|
updateMeasuredHeight()
|
||||||
|
setupResizeObserver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function cleanupIntersectionObserver() {
|
function cleanupIntersectionObserver() {
|
||||||
if (intersectionCleanup) {
|
if (intersectionCleanup) {
|
||||||
intersectionCleanup()
|
intersectionCleanup()
|
||||||
@@ -237,13 +256,24 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistMeasurement(nextHeight: number) {
|
function persistMeasurement(nextHeight: number, meta?: { source: "initial-visible-measure" | "resize"; wasHidden: boolean }) {
|
||||||
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
if (!Number.isFinite(nextHeight) || nextHeight < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const before = measuredHeight()
|
const before = measuredHeight()
|
||||||
const normalized = nextHeight
|
const normalized = nextHeight
|
||||||
const previous = sizeCache.get(props.cacheKey) ?? measuredHeight()
|
const previousCachedHeight = sizeCache.get(props.cacheKey) ?? null
|
||||||
|
const previous = previousCachedHeight ?? measuredHeight()
|
||||||
|
const measurementMeta: VirtualItemHeightChangeMeta = {
|
||||||
|
source: meta?.source ?? "resize",
|
||||||
|
previousCachedHeight,
|
||||||
|
isStaleCacheCorrection:
|
||||||
|
(meta?.source ?? "resize") === "initial-visible-measure" &&
|
||||||
|
previousCachedHeight !== null &&
|
||||||
|
normalized > 0 &&
|
||||||
|
Math.abs(normalized - previousCachedHeight) > 1,
|
||||||
|
wasHidden: meta?.wasHidden ?? shouldHideContent(),
|
||||||
|
}
|
||||||
// Only keep the previous measurement when the element reports 0 height.
|
// Only keep the previous measurement when the element reports 0 height.
|
||||||
// Allow shrinkage so placeholder height matches real content height;
|
// Allow shrinkage so placeholder height matches real content height;
|
||||||
// keeping the max height can cause mount/unmount jitter near the
|
// keeping the max height can cause mount/unmount jitter near the
|
||||||
@@ -254,34 +284,40 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
setHasMeasured(true)
|
|
||||||
sizeCache.set(props.cacheKey, previous)
|
sizeCache.set(props.cacheKey, previous)
|
||||||
setMeasuredHeight(previous)
|
setMeasuredHeight(previous)
|
||||||
if (previous !== before) props.onHeightChange?.(previous, before)
|
if (previous !== before) props.onHeightChange?.(previous, before, measurementMeta)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (normalized > 0) {
|
if (normalized > 0) {
|
||||||
sizeCache.set(props.cacheKey, normalized)
|
sizeCache.set(props.cacheKey, normalized)
|
||||||
setHasMeasured(true)
|
|
||||||
if (!hasReportedMeasurement) {
|
if (!hasReportedMeasurement) {
|
||||||
hasReportedMeasurement = true
|
hasReportedMeasurement = true
|
||||||
props.onMeasured?.()
|
props.onMeasured?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setMeasuredHeight(normalized)
|
setMeasuredHeight(normalized)
|
||||||
if (normalized !== before) props.onHeightChange?.(normalized, before)
|
if (normalized !== before) props.onHeightChange?.(normalized, before, measurementMeta)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMeasuredHeight() {
|
function updateMeasuredHeight() {
|
||||||
if (!contentRef || measurementsSuspended()) return
|
if (!contentRef) return
|
||||||
|
if (measurementsSuspended()) return
|
||||||
// Prefer subpixel-accurate height for scroll compensation.
|
// Prefer subpixel-accurate height for scroll compensation.
|
||||||
// offsetHeight rounds to integers which can accumulate error.
|
// offsetHeight rounds to integers which can accumulate error.
|
||||||
const rect = contentRef.getBoundingClientRect()
|
const rect = contentRef.getBoundingClientRect()
|
||||||
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
const next = Math.max(0, Math.round(rect.height * 2) / 2)
|
||||||
if (next === measuredHeight()) return
|
const currentMeasured = measuredHeight()
|
||||||
persistMeasurement(next)
|
const measurementSource: "initial-visible-measure" | "resize" = awaitingVisibleMeasurement ? "initial-visible-measure" : "resize"
|
||||||
|
const wasHidden = lastMeasurementWhileHidden
|
||||||
|
if (measurementSource === "initial-visible-measure") {
|
||||||
|
awaitingVisibleMeasurement = false
|
||||||
|
lastMeasurementWhileHidden = false
|
||||||
|
}
|
||||||
|
if (next === currentMeasured) return
|
||||||
|
persistMeasurement(next, { source: measurementSource, wasHidden })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupResizeObserver() {
|
function setupResizeObserver() {
|
||||||
if (!contentRef || measurementsSuspended()) return
|
if (!contentRef || measurementsSuspended()) return
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
@@ -377,30 +413,29 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (shouldHideContent() || measurementsSuspended()) {
|
const hidden = shouldHideContent()
|
||||||
|
if (hidden) {
|
||||||
|
awaitingVisibleMeasurement = true
|
||||||
|
lastMeasurementWhileHidden = true
|
||||||
|
}
|
||||||
|
if (hidden || measurementsSuspended()) {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
} else if (contentRef) {
|
}
|
||||||
queueMicrotask(() => {
|
if (!hidden && !measurementsSuspended() && contentRef) {
|
||||||
updateMeasuredHeight()
|
scheduleVisibleMeasurements()
|
||||||
setupResizeObserver()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const key = props.cacheKey
|
const key = props.cacheKey
|
||||||
|
|
||||||
const cached = sizeCache.get(key)
|
const cached = sizeCache.get(key)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
setMeasuredHeight(cached)
|
setMeasuredHeight(cached)
|
||||||
setHasMeasured(true)
|
|
||||||
} else {
|
} else {
|
||||||
setMeasuredHeight(fallbackPlaceholderHeight())
|
setMeasuredHeight(fallbackPlaceholderHeight())
|
||||||
setHasMeasured(false)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -418,7 +453,7 @@ export default function VirtualItem(props: VirtualItemProps) {
|
|||||||
}
|
}
|
||||||
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
return props.minPlaceholderHeight ?? MIN_PLACEHOLDER_HEIGHT
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cleanupResizeObserver()
|
cleanupResizeObserver()
|
||||||
cleanupIntersectionObserver()
|
cleanupIntersectionObserver()
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import type {
|
|||||||
FileSystemCreateFolderResponse,
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
|
SpeechCapabilitiesResponse,
|
||||||
|
SpeechSynthesisResponse,
|
||||||
|
SpeechTranscriptionResponse,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
WorkspaceCreateRequest,
|
WorkspaceCreateRequest,
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
@@ -235,6 +238,27 @@ export const serverApi = {
|
|||||||
body: JSON.stringify({ path }),
|
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" }): Promise<SpeechSynthesisResponse> {
|
||||||
|
return request<SpeechSynthesisResponse>("/api/speech/synthesize", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
},
|
||||||
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
if (path && path !== ".") {
|
if (path && path !== ".") {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getLogger } from "../logger"
|
|||||||
import { requestData } from "../opencode-api"
|
import { requestData } from "../opencode-api"
|
||||||
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
import { emitSessionSidebarRequest } from "../session-sidebar-events"
|
||||||
import { tGlobal } from "../i18n"
|
import { tGlobal } from "../i18n"
|
||||||
import { runtimeEnv } from "../runtime-env"
|
import { registerBehaviorCommands } from "../settings/behavior-registry"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -34,6 +34,7 @@ export interface UseCommandsOptions {
|
|||||||
toggleUsageMetrics: () => void
|
toggleUsageMetrics: () => void
|
||||||
toggleAutoCleanupBlankSessions: () => void
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
togglePromptSubmitOnEnter: () => void
|
togglePromptSubmitOnEnter: () => void
|
||||||
|
toggleShowPromptVoiceInput: () => void
|
||||||
setDiffViewMode: (mode: "split" | "unified") => void
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
@@ -427,178 +428,20 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
registerBehaviorCommands((command) => commandRegistry.register(command), {
|
||||||
id: "prompt-submit-shortcut",
|
preferences: options.preferences,
|
||||||
label: () =>
|
toggleShowThinkingBlocks: options.toggleShowThinkingBlocks,
|
||||||
options.preferences().promptSubmitOnEnter
|
toggleKeyboardShortcutHints: options.toggleKeyboardShortcutHints,
|
||||||
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
toggleShowTimelineTools: options.toggleShowTimelineTools,
|
||||||
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
toggleUsageMetrics: options.toggleUsageMetrics,
|
||||||
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
toggleAutoCleanupBlankSessions: options.toggleAutoCleanupBlankSessions,
|
||||||
category: "Input & Focus",
|
togglePromptSubmitOnEnter: options.togglePromptSubmitOnEnter,
|
||||||
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
toggleShowPromptVoiceInput: options.toggleShowPromptVoiceInput,
|
||||||
action: options.togglePromptSubmitOnEnter,
|
setDiffViewMode: options.setDiffViewMode,
|
||||||
})
|
setToolOutputExpansion: options.setToolOutputExpansion,
|
||||||
|
setDiagnosticsExpansion: options.setDiagnosticsExpansion,
|
||||||
commandRegistry.register({
|
setThinkingBlocksExpansion: options.setThinkingBlocksExpansion,
|
||||||
id: "thinking",
|
setToolInputsVisibility: options.setToolInputsVisibility,
|
||||||
label: () => tGlobal(options.preferences().showThinkingBlocks ? "commands.thinkingBlocks.label.hide" : "commands.thinkingBlocks.label.show"),
|
|
||||||
description: () => tGlobal("commands.thinkingBlocks.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
|
||||||
action: options.toggleShowThinkingBlocks,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "timeline-tools",
|
|
||||||
label: () => tGlobal(options.preferences().showTimelineTools ? "commands.timelineToolCalls.label.hide" : "commands.timelineToolCalls.label.show"),
|
|
||||||
description: () => tGlobal("commands.timelineToolCalls.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
|
||||||
action: options.toggleShowTimelineTools,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "keyboard-shortcut-hints",
|
|
||||||
label: () =>
|
|
||||||
tGlobal(
|
|
||||||
options.preferences().showKeyboardShortcutHints
|
|
||||||
? "commands.keyboardShortcutHints.label.hide"
|
|
||||||
: "commands.keyboardShortcutHints.label.show",
|
|
||||||
),
|
|
||||||
description: () =>
|
|
||||||
tGlobal(
|
|
||||||
runtimeEnv.host === "web"
|
|
||||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
|
||||||
: "commands.keyboardShortcutHints.description",
|
|
||||||
),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
|
||||||
disabled: () => runtimeEnv.host === "web",
|
|
||||||
action: options.toggleKeyboardShortcutHints,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "thinking-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.thinkingBlocksDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().thinkingBlocksExpansion ?? "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setThinkingBlocksExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diff-view-split",
|
|
||||||
label: () => {
|
|
||||||
const prefix = (options.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
|
||||||
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diffViewSplit.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
|
||||||
action: () => options.setDiffViewMode("split"),
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diff-view-unified",
|
|
||||||
label: () => {
|
|
||||||
const prefix = (options.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
|
||||||
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diffViewUnified.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
|
||||||
action: () => options.setDiffViewMode("unified"),
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "tool-output-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.toolOutputsDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.toolOutputsDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().toolOutputExpansion || "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setToolOutputExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "diagnostics-default-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
|
||||||
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
|
||||||
return tGlobal("commands.diagnosticsDefault.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.diagnosticsDefault.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().diagnosticsExpansion || "expanded"
|
|
||||||
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
|
||||||
options.setDiagnosticsExpansion(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "tool-inputs-visibility",
|
|
||||||
label: () => {
|
|
||||||
const mode = options.preferences().toolInputsVisibility || "hidden"
|
|
||||||
const state =
|
|
||||||
mode === "expanded"
|
|
||||||
? tGlobal("commands.common.expanded")
|
|
||||||
: mode === "collapsed"
|
|
||||||
? tGlobal("commands.common.collapsed")
|
|
||||||
: tGlobal("commands.common.hidden")
|
|
||||||
return tGlobal("commands.toolInputsVisibility.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.toolInputsVisibility.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
|
|
||||||
action: () => {
|
|
||||||
const mode = options.preferences().toolInputsVisibility || "hidden"
|
|
||||||
const next: ToolInputsVisibilityPreference =
|
|
||||||
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
|
|
||||||
options.setToolInputsVisibility(next)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "token-usage-visibility",
|
|
||||||
label: () => {
|
|
||||||
const visible = options.preferences().showUsageMetrics ?? true
|
|
||||||
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
|
|
||||||
return tGlobal("commands.tokenUsageDisplay.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.tokenUsageDisplay.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
|
|
||||||
action: options.toggleUsageMetrics,
|
|
||||||
})
|
|
||||||
|
|
||||||
commandRegistry.register({
|
|
||||||
id: "auto-cleanup-blank-sessions",
|
|
||||||
label: () => {
|
|
||||||
const enabled = options.preferences().autoCleanupBlankSessions
|
|
||||||
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
|
|
||||||
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
|
|
||||||
},
|
|
||||||
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
|
|
||||||
category: "System",
|
|
||||||
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
|
|
||||||
action: options.toggleAutoCleanupBlankSessions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
|
|||||||
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
158
packages/ui/src/lib/hooks/use-folder-drop.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Accessor, createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import {
|
||||||
|
containsFileDrop,
|
||||||
|
extractDroppedDirectoryPaths,
|
||||||
|
listenForNativeFolderDrops,
|
||||||
|
listenForNativeFolderDropState,
|
||||||
|
normalizeDroppedDirectoryPaths,
|
||||||
|
supportsDesktopFolderDrop,
|
||||||
|
} from "../native/desktop-file-drop"
|
||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
|
interface UseFolderDropOptions {
|
||||||
|
enabled: Accessor<boolean>
|
||||||
|
onDrop: (paths: string[]) => void | Promise<void>
|
||||||
|
onInvalidDrop?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderDropBindings {
|
||||||
|
onDragEnter: (event: DragEvent) => void
|
||||||
|
onDragOver: (event: DragEvent) => void
|
||||||
|
onDragLeave: (event: DragEvent) => void
|
||||||
|
onDrop: (event: DragEvent) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFolderDrop(options: UseFolderDropOptions): {
|
||||||
|
isActive: Accessor<boolean>
|
||||||
|
isSupported: boolean
|
||||||
|
bind: FolderDropBindings
|
||||||
|
} {
|
||||||
|
const [isActive, setIsActive] = createSignal(false)
|
||||||
|
const [dragDepth, setDragDepth] = createSignal(0)
|
||||||
|
const isSupported = supportsDesktopFolderDrop()
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDragDepth(0)
|
||||||
|
setIsActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResolvedPaths(paths: string[]) {
|
||||||
|
reset()
|
||||||
|
if (!options.enabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const directoryPaths = await normalizeDroppedDirectoryPaths(paths)
|
||||||
|
if (directoryPaths.length === 0) {
|
||||||
|
options.onInvalidDrop?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await options.onDrop(directoryPaths)
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!isSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposeNativeDrop = () => {}
|
||||||
|
let disposeNativeState = () => {}
|
||||||
|
|
||||||
|
void listenForNativeFolderDrops((paths) => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void handleResolvedPaths(paths)
|
||||||
|
}).then((dispose) => {
|
||||||
|
disposeNativeDrop = dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
void listenForNativeFolderDropState((state) => {
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state === "enter") {
|
||||||
|
setIsActive(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
}).then((dispose) => {
|
||||||
|
disposeNativeState = dispose
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
disposeNativeDrop()
|
||||||
|
disposeNativeState()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const bind: FolderDropBindings = {
|
||||||
|
onDragEnter(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
setDragDepth((prev) => prev + 1)
|
||||||
|
setIsActive(true)
|
||||||
|
},
|
||||||
|
onDragOver(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "copy"
|
||||||
|
}
|
||||||
|
setIsActive(true)
|
||||||
|
},
|
||||||
|
onDragLeave(event) {
|
||||||
|
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
const nextDepth = Math.max(0, dragDepth() - 1)
|
||||||
|
setDragDepth(nextDepth)
|
||||||
|
if (nextDepth === 0) {
|
||||||
|
setIsActive(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop(event) {
|
||||||
|
if (!isSupported) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (!options.enabled()) {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeEnv.host === "tauri") {
|
||||||
|
reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = extractDroppedDirectoryPaths(event)
|
||||||
|
if (paths.length === 0) {
|
||||||
|
reset()
|
||||||
|
options.onInvalidDrop?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleResolvedPaths(paths)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isActive,
|
||||||
|
isSupported,
|
||||||
|
bind,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Unable to launch OpenCode",
|
"app.launchError.title": "Unable to launch OpenCode",
|
||||||
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from Advanced Settings.",
|
"app.launchError.description": "We couldn't start the selected OpenCode binary. Review the error output below or choose a different binary from OpenCode settings.",
|
||||||
"app.launchError.binaryPathLabel": "Binary path",
|
"app.launchError.binaryPathLabel": "Binary path",
|
||||||
"app.launchError.errorOutputLabel": "Error output",
|
"app.launchError.errorOutputLabel": "Error output",
|
||||||
"app.launchError.openAdvancedSettings": "Open Advanced Settings",
|
"app.launchError.openAdvancedSettings": "Open OpenCode Settings",
|
||||||
"app.launchError.close": "Close",
|
"app.launchError.close": "Close",
|
||||||
"app.launchError.closeTitle": "Close (Esc)",
|
"app.launchError.closeTitle": "Close (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
"app.launchError.fallbackMessage": "Failed to launch workspace",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Opening...",
|
"folderSelection.browse.buttonOpening": "Opening...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Advanced Settings",
|
"folderSelection.advancedSettings": "Advanced Settings",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navigate",
|
"folderSelection.hints.navigate": "Navigate",
|
||||||
"folderSelection.hints.select": "Select",
|
"folderSelection.hints.select": "Select",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Starting instance...",
|
"folderSelection.loading.title": "Starting instance...",
|
||||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Drop a folder to open it",
|
||||||
|
"folderSelection.drop.subtitle": "Start a new instance in the dropped folder.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Couldn't open dropped item",
|
||||||
|
"folderSelection.drop.invalidMessage": "Drop a folder to start a new instance.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Select Workspace",
|
"folderSelection.dialog.title": "Select Workspace",
|
||||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -138,4 +138,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Send message",
|
"promptInput.send.ariaLabel": "Send message",
|
||||||
"promptInput.send.errorFallback": "Failed to send message",
|
"promptInput.send.errorFallback": "Failed to send message",
|
||||||
"promptInput.send.errorTitle": "Send failed",
|
"promptInput.send.errorTitle": "Send failed",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Used",
|
"contextUsagePanel.labels.used": "Used",
|
||||||
"contextUsagePanel.labels.available": "Avail",
|
"contextUsagePanel.labels.available": "Avail",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Disabled",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
|
"settings.appearance.behavior.subtitle": "Message, diff, and input defaults.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Keyboard shortcut hints",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Show keyboard shortcut hints across the UI.",
|
||||||
|
"settings.behavior.thinking.title": "Thinking sections",
|
||||||
|
"settings.behavior.thinking.subtitle": "Show or hide AI thinking sections in messages.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Thinking default",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Choose whether thinking sections start expanded or collapsed.",
|
||||||
|
"settings.behavior.timelineTools.title": "Timeline tool calls",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Show or hide tool call entries in the message timeline.",
|
||||||
|
"settings.behavior.diffView.title": "Diff view",
|
||||||
|
"settings.behavior.diffView.subtitle": "Choose how tool-call diffs are displayed.",
|
||||||
|
"settings.behavior.diffView.option.split": "Split",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unified",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Tool outputs default",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Choose whether tool outputs start expanded or collapsed.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Diagnostics default",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Choose whether diagnostics output starts expanded or collapsed.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Tool inputs visibility",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Set default visibility for tool call input arguments.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Token usage metrics",
|
||||||
|
"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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "No se pudo iniciar OpenCode",
|
"app.launchError.title": "No se pudo iniciar OpenCode",
|
||||||
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en Configuración avanzada.",
|
"app.launchError.description": "No pudimos iniciar el binario de OpenCode seleccionado. Revisa la salida de error abajo o elige un binario distinto en la configuración de OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Ruta del binario",
|
"app.launchError.binaryPathLabel": "Ruta del binario",
|
||||||
"app.launchError.errorOutputLabel": "Salida de error",
|
"app.launchError.errorOutputLabel": "Salida de error",
|
||||||
"app.launchError.openAdvancedSettings": "Abrir Configuración avanzada",
|
"app.launchError.openAdvancedSettings": "Abrir Configuración de OpenCode",
|
||||||
"app.launchError.close": "Cerrar",
|
"app.launchError.close": "Cerrar",
|
||||||
"app.launchError.closeTitle": "Cerrar (Esc)",
|
"app.launchError.closeTitle": "Cerrar (Esc)",
|
||||||
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
"app.launchError.fallbackMessage": "No se pudo iniciar el workspace",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Abriendo...",
|
"folderSelection.browse.buttonOpening": "Abriendo...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Configuración avanzada",
|
"folderSelection.advancedSettings": "Configuración avanzada",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Navegar",
|
"folderSelection.hints.navigate": "Navegar",
|
||||||
"folderSelection.hints.select": "Seleccionar",
|
"folderSelection.hints.select": "Seleccionar",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Iniciando instancia...",
|
"folderSelection.loading.title": "Iniciando instancia...",
|
||||||
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
"folderSelection.loading.subtitle": "Espera un momento mientras preparamos tu workspace.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Suelta una carpeta para abrirla",
|
||||||
|
"folderSelection.drop.subtitle": "Inicia una nueva instancia en la carpeta soltada.",
|
||||||
|
"folderSelection.drop.invalidTitle": "No se pudo abrir el elemento soltado",
|
||||||
|
"folderSelection.drop.invalidMessage": "Suelta una carpeta para iniciar una nueva instancia.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Seleccionar workspace",
|
"folderSelection.dialog.title": "Seleccionar workspace",
|
||||||
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
"folderSelection.dialog.description": "Selecciona un workspace para empezar a programar.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Enviar mensaje",
|
"promptInput.send.ariaLabel": "Enviar mensaje",
|
||||||
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
"promptInput.send.errorFallback": "No se pudo enviar el mensaje",
|
||||||
"promptInput.send.errorTitle": "Error al enviar",
|
"promptInput.send.errorTitle": "Error al enviar",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Usado",
|
"contextUsagePanel.labels.used": "Usado",
|
||||||
"contextUsagePanel.labels.available": "Disp.",
|
"contextUsagePanel.labels.available": "Disp.",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Desactivado",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaccion",
|
||||||
|
"settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Muestra sugerencias de atajos de teclado en toda la interfaz.",
|
||||||
|
"settings.behavior.thinking.title": "Secciones de pensamiento",
|
||||||
|
"settings.behavior.thinking.subtitle": "Muestra u oculta las secciones de pensamiento de la IA en los mensajes.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Pensamiento por defecto",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Elige si las secciones de pensamiento comienzan expandidas o contraidas.",
|
||||||
|
"settings.behavior.timelineTools.title": "Llamadas de herramientas en la linea de tiempo",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Muestra u oculta entradas de llamadas de herramientas en la linea de tiempo de mensajes.",
|
||||||
|
"settings.behavior.diffView.title": "Vista de diferencias",
|
||||||
|
"settings.behavior.diffView.subtitle": "Elige como se muestran los diffs de llamadas de herramientas.",
|
||||||
|
"settings.behavior.diffView.option.split": "Dividida",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unificada",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Salidas de herramientas por defecto",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Elige si las salidas de herramientas comienzan expandidas o contraidas.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Diagnosticos por defecto",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Elige si la salida de diagnosticos comienza expandida o contraida.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Visibilidad de entradas de herramientas",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Establece la visibilidad por defecto de los argumentos de entrada de las llamadas de herramientas.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Metricas de uso de tokens",
|
||||||
|
"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": "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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Impossible de lancer OpenCode",
|
"app.launchError.title": "Impossible de lancer OpenCode",
|
||||||
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les Paramètres avancés.",
|
"app.launchError.description": "Nous n'avons pas pu démarrer le binaire OpenCode sélectionné. Consultez la sortie d'erreur ci-dessous ou choisissez un autre binaire dans les paramètres OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Chemin du binaire",
|
"app.launchError.binaryPathLabel": "Chemin du binaire",
|
||||||
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
"app.launchError.errorOutputLabel": "Sortie d'erreur",
|
||||||
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres avancés",
|
"app.launchError.openAdvancedSettings": "Ouvrir les paramètres OpenCode",
|
||||||
"app.launchError.close": "Fermer",
|
"app.launchError.close": "Fermer",
|
||||||
"app.launchError.closeTitle": "Fermer (Esc)",
|
"app.launchError.closeTitle": "Fermer (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
"app.launchError.fallbackMessage": "Échec du lancement de l'espace de travail",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Ouverture...",
|
"folderSelection.browse.buttonOpening": "Ouverture...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Paramètres avancés",
|
"folderSelection.advancedSettings": "Paramètres avancés",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Naviguer",
|
"folderSelection.hints.navigate": "Naviguer",
|
||||||
"folderSelection.hints.select": "Sélectionner",
|
"folderSelection.hints.select": "Sélectionner",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Démarrage de l'instance...",
|
"folderSelection.loading.title": "Démarrage de l'instance...",
|
||||||
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
"folderSelection.loading.subtitle": "Patientez pendant que nous préparons votre espace de travail.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Déposez un dossier pour l'ouvrir",
|
||||||
|
"folderSelection.drop.subtitle": "Démarrez une nouvelle instance dans le dossier déposé.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Impossible d'ouvrir l'élément déposé",
|
||||||
|
"folderSelection.drop.invalidMessage": "Déposez un dossier pour démarrer une nouvelle instance.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
"folderSelection.dialog.title": "Sélectionner l'espace de travail",
|
||||||
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
"folderSelection.dialog.description": "Sélectionnez un espace de travail pour commencer à coder.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Envoyer le message",
|
"promptInput.send.ariaLabel": "Envoyer le message",
|
||||||
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
"promptInput.send.errorFallback": "Impossible d'envoyer le message",
|
||||||
"promptInput.send.errorTitle": "Échec de l'envoi",
|
"promptInput.send.errorTitle": "Échec de l'envoi",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Utilisé",
|
"contextUsagePanel.labels.used": "Utilisé",
|
||||||
"contextUsagePanel.labels.available": "Dispo",
|
"contextUsagePanel.labels.available": "Dispo",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Desactive",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Interaction",
|
||||||
|
"settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Indications de raccourcis clavier",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Afficher des indications de raccourcis clavier dans toute l'interface.",
|
||||||
|
"settings.behavior.thinking.title": "Sections de reflexion",
|
||||||
|
"settings.behavior.thinking.subtitle": "Afficher ou masquer les sections de reflexion de l'IA dans les messages.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Etat initial de la reflexion",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Choisir si les sections de reflexion commencent developpees ou reduites.",
|
||||||
|
"settings.behavior.timelineTools.title": "Appels d'outils dans la chronologie",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Afficher ou masquer les entrees d'appels d'outils dans la chronologie des messages.",
|
||||||
|
"settings.behavior.diffView.title": "Vue du diff",
|
||||||
|
"settings.behavior.diffView.subtitle": "Choisir comment les diffs des appels d'outils sont affiches.",
|
||||||
|
"settings.behavior.diffView.option.split": "Scinde",
|
||||||
|
"settings.behavior.diffView.option.unified": "Unifie",
|
||||||
|
"settings.behavior.toolOutputsDefault.title": "Etat initial des sorties d'outils",
|
||||||
|
"settings.behavior.toolOutputsDefault.subtitle": "Choisir si les sorties d'outils commencent developpees ou reduites.",
|
||||||
|
"settings.behavior.diagnosticsDefault.title": "Etat initial des diagnostics",
|
||||||
|
"settings.behavior.diagnosticsDefault.subtitle": "Choisir si la sortie des diagnostics commence developpee ou reduite.",
|
||||||
|
"settings.behavior.toolInputsVisibility.title": "Visibilite des entrees d'outils",
|
||||||
|
"settings.behavior.toolInputsVisibility.subtitle": "Definir la visibilite par defaut des arguments d'entree des appels d'outils.",
|
||||||
|
"settings.behavior.usageMetrics.title": "Metriques d'utilisation des tokens",
|
||||||
|
"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": "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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "OpenCode を起動できません",
|
"app.launchError.title": "OpenCode を起動できません",
|
||||||
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、詳細設定から別のバイナリを選択してください。",
|
"app.launchError.description": "選択された OpenCode バイナリを起動できませんでした。下のエラー出力を確認するか、OpenCode 設定から別のバイナリを選択してください。",
|
||||||
"app.launchError.binaryPathLabel": "バイナリのパス",
|
"app.launchError.binaryPathLabel": "バイナリのパス",
|
||||||
"app.launchError.errorOutputLabel": "エラー出力",
|
"app.launchError.errorOutputLabel": "エラー出力",
|
||||||
"app.launchError.openAdvancedSettings": "詳細設定を開く",
|
"app.launchError.openAdvancedSettings": "OpenCode 設定を開く",
|
||||||
"app.launchError.close": "閉じる",
|
"app.launchError.close": "閉じる",
|
||||||
"app.launchError.closeTitle": "閉じる (Esc)",
|
"app.launchError.closeTitle": "閉じる (Esc)",
|
||||||
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
"app.launchError.fallbackMessage": "ワークスペースの起動に失敗しました",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "開いています...",
|
"folderSelection.browse.buttonOpening": "開いています...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "詳細設定",
|
"folderSelection.advancedSettings": "詳細設定",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "移動",
|
"folderSelection.hints.navigate": "移動",
|
||||||
"folderSelection.hints.select": "選択",
|
"folderSelection.hints.select": "選択",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "インスタンスを起動中...",
|
"folderSelection.loading.title": "インスタンスを起動中...",
|
||||||
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
"folderSelection.loading.subtitle": "ワークスペースを準備しています。しばらくお待ちください。",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "フォルダをドロップして開く",
|
||||||
|
"folderSelection.drop.subtitle": "ドロップしたフォルダで新しいインスタンスを開始します。",
|
||||||
|
"folderSelection.drop.invalidTitle": "ドロップした項目を開けませんでした",
|
||||||
|
"folderSelection.drop.invalidMessage": "新しいインスタンスを開始するにはフォルダをドロップしてください。",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "ワークスペースを選択",
|
"folderSelection.dialog.title": "ワークスペースを選択",
|
||||||
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
"folderSelection.dialog.description": "コーディングを開始するワークスペースを選択してください。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "メッセージを送信",
|
"promptInput.send.ariaLabel": "メッセージを送信",
|
||||||
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
"promptInput.send.errorFallback": "メッセージの送信に失敗しました",
|
||||||
"promptInput.send.errorTitle": "送信に失敗",
|
"promptInput.send.errorTitle": "送信に失敗",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "使用",
|
"contextUsagePanel.labels.used": "使用",
|
||||||
"contextUsagePanel.labels.available": "残り",
|
"contextUsagePanel.labels.available": "残り",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "無効",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "操作",
|
||||||
|
"settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。",
|
||||||
|
"settings.behavior.keyboardHints.title": "キーボードショートカットのヒント",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "UI全体でキーボードショートカットのヒントを表示します。",
|
||||||
|
"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": "差分表示",
|
||||||
|
"settings.behavior.diffView.subtitle": "ツール呼び出しの差分の表示方法を選びます。",
|
||||||
|
"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": "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で送信",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Enterで送信し、Cmd/Ctrl+Enterで改行します。",
|
||||||
|
"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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "Не удалось запустить OpenCode",
|
"app.launchError.title": "Не удалось запустить OpenCode",
|
||||||
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в расширенных настройках.",
|
"app.launchError.description": "Не удалось запустить выбранный бинарник OpenCode. Просмотрите вывод ошибки ниже или выберите другой бинарник в настройках OpenCode.",
|
||||||
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
"app.launchError.binaryPathLabel": "Путь к бинарнику",
|
||||||
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
"app.launchError.errorOutputLabel": "Вывод ошибки",
|
||||||
"app.launchError.openAdvancedSettings": "Открыть расширенные настройки",
|
"app.launchError.openAdvancedSettings": "Открыть настройки OpenCode",
|
||||||
"app.launchError.close": "Закрыть",
|
"app.launchError.close": "Закрыть",
|
||||||
"app.launchError.closeTitle": "Закрыть (Esc)",
|
"app.launchError.closeTitle": "Закрыть (Esc)",
|
||||||
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
"app.launchError.fallbackMessage": "Не удалось запустить рабочее пространство",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "Открытие…",
|
"folderSelection.browse.buttonOpening": "Открытие…",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "Расширенные настройки",
|
"folderSelection.advancedSettings": "Расширенные настройки",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "Навигация",
|
"folderSelection.hints.navigate": "Навигация",
|
||||||
"folderSelection.hints.select": "Выбрать",
|
"folderSelection.hints.select": "Выбрать",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "Запуск экземпляра…",
|
"folderSelection.loading.title": "Запуск экземпляра…",
|
||||||
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
"folderSelection.loading.subtitle": "Подождите, пока мы подготовим рабочее пространство.",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "Перетащите папку, чтобы открыть ее",
|
||||||
|
"folderSelection.drop.subtitle": "Запустите новый экземпляр в перетащенной папке.",
|
||||||
|
"folderSelection.drop.invalidTitle": "Не удалось открыть перетащенный элемент",
|
||||||
|
"folderSelection.drop.invalidMessage": "Перетащите папку, чтобы запустить новый экземпляр.",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
"folderSelection.dialog.title": "Выберите рабочее пространство",
|
||||||
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
"folderSelection.dialog.description": "Выберите рабочее пространство, чтобы начать писать код.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "Отправить сообщение",
|
"promptInput.send.ariaLabel": "Отправить сообщение",
|
||||||
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
"promptInput.send.errorFallback": "Не удалось отправить сообщение",
|
||||||
"promptInput.send.errorTitle": "Не удалось отправить",
|
"promptInput.send.errorTitle": "Не удалось отправить",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "Использовано",
|
"contextUsagePanel.labels.used": "Использовано",
|
||||||
"contextUsagePanel.labels.available": "Доступно",
|
"contextUsagePanel.labels.available": "Доступно",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "Отключено",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "Взаимодействие",
|
||||||
|
"settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.",
|
||||||
|
"settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш",
|
||||||
|
"settings.behavior.keyboardHints.subtitle": "Показывать подсказки сочетаний клавиш по всему интерфейсу.",
|
||||||
|
"settings.behavior.thinking.title": "Разделы размышлений",
|
||||||
|
"settings.behavior.thinking.subtitle": "Показывать или скрывать разделы размышлений ИИ в сообщениях.",
|
||||||
|
"settings.behavior.thinkingDefault.title": "Размышления по умолчанию",
|
||||||
|
"settings.behavior.thinkingDefault.subtitle": "Выберите, начинать ли разделы размышлений развернутыми или свернутыми.",
|
||||||
|
"settings.behavior.timelineTools.title": "Вызовы инструментов в таймлайне",
|
||||||
|
"settings.behavior.timelineTools.subtitle": "Показывать или скрывать записи вызовов инструментов в таймлайне сообщений.",
|
||||||
|
"settings.behavior.diffView.title": "Вид диффа",
|
||||||
|
"settings.behavior.diffView.subtitle": "Выберите, как отображаются диффы вызовов инструментов.",
|
||||||
|
"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": "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 для отправки",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "Enter отправляет; Cmd/Ctrl+Enter вставляет новую строку.",
|
||||||
|
"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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export const appMessages = {
|
export const appMessages = {
|
||||||
"app.launchError.title": "无法启动 OpenCode",
|
"app.launchError.title": "无法启动 OpenCode",
|
||||||
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在“高级设置”中选择其他可执行文件。",
|
"app.launchError.description": "我们无法启动所选的 OpenCode 可执行文件。请查看下面的错误输出,或在 OpenCode 设置中选择其他可执行文件。",
|
||||||
"app.launchError.binaryPathLabel": "可执行文件路径",
|
"app.launchError.binaryPathLabel": "可执行文件路径",
|
||||||
"app.launchError.errorOutputLabel": "错误输出",
|
"app.launchError.errorOutputLabel": "错误输出",
|
||||||
"app.launchError.openAdvancedSettings": "打开高级设置",
|
"app.launchError.openAdvancedSettings": "打开 OpenCode 设置",
|
||||||
"app.launchError.close": "关闭",
|
"app.launchError.close": "关闭",
|
||||||
"app.launchError.closeTitle": "关闭 (Esc)",
|
"app.launchError.closeTitle": "关闭 (Esc)",
|
||||||
"app.launchError.fallbackMessage": "启动工作区失败",
|
"app.launchError.fallbackMessage": "启动工作区失败",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.browse.buttonOpening": "正在打开...",
|
"folderSelection.browse.buttonOpening": "正在打开...",
|
||||||
|
|
||||||
"folderSelection.advancedSettings": "高级设置",
|
"folderSelection.advancedSettings": "高级设置",
|
||||||
|
"folderSelection.opencode": "OpenCode",
|
||||||
|
|
||||||
"folderSelection.hints.navigate": "导航",
|
"folderSelection.hints.navigate": "导航",
|
||||||
"folderSelection.hints.select": "选择",
|
"folderSelection.hints.select": "选择",
|
||||||
@@ -31,6 +32,11 @@ export const folderSelectionMessages = {
|
|||||||
"folderSelection.loading.title": "正在启动实例...",
|
"folderSelection.loading.title": "正在启动实例...",
|
||||||
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
"folderSelection.loading.subtitle": "正在准备你的工作区,请稍候。",
|
||||||
|
|
||||||
|
"folderSelection.drop.title": "拖放文件夹以打开",
|
||||||
|
"folderSelection.drop.subtitle": "在拖放的文件夹中启动一个新实例。",
|
||||||
|
"folderSelection.drop.invalidTitle": "无法打开拖放的项目",
|
||||||
|
"folderSelection.drop.invalidMessage": "请拖放一个文件夹来启动新实例。",
|
||||||
|
|
||||||
"folderSelection.dialog.title": "选择工作区",
|
"folderSelection.dialog.title": "选择工作区",
|
||||||
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
"folderSelection.dialog.description": "选择工作区以开始编码。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -140,4 +140,11 @@ export const messagingMessages = {
|
|||||||
"promptInput.send.ariaLabel": "发送消息",
|
"promptInput.send.ariaLabel": "发送消息",
|
||||||
"promptInput.send.errorFallback": "发送消息失败",
|
"promptInput.send.errorFallback": "发送消息失败",
|
||||||
"promptInput.send.errorTitle": "发送失败",
|
"promptInput.send.errorTitle": "发送失败",
|
||||||
|
"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.unsupported": "Voice input is not supported in this browser.",
|
||||||
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -55,4 +55,117 @@ export const settingsMessages = {
|
|||||||
"contextUsagePanel.labels.used": "已用",
|
"contextUsagePanel.labels.used": "已用",
|
||||||
"contextUsagePanel.labels.available": "可用",
|
"contextUsagePanel.labels.available": "可用",
|
||||||
"contextUsagePanel.unavailable": "--",
|
"contextUsagePanel.unavailable": "--",
|
||||||
|
|
||||||
|
"settings.title": "Settings",
|
||||||
|
"settings.navigationAriaLabel": "Settings sections",
|
||||||
|
"settings.close": "Close settings",
|
||||||
|
"settings.content.eyebrow": "Workspace preferences",
|
||||||
|
"settings.open.title": "Open settings",
|
||||||
|
"settings.open.ariaLabel": "Open settings",
|
||||||
|
"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",
|
||||||
|
"settings.common.enabled": "Enabled",
|
||||||
|
"settings.common.disabled": "已禁用",
|
||||||
|
"settings.section.appearance.title": "Appearance",
|
||||||
|
"settings.section.appearance.subtitle": "Adjust how the app looks on this device.",
|
||||||
|
"settings.appearance.theme.title": "Theme",
|
||||||
|
"settings.appearance.theme.subtitle": "Choose the color mode used throughout the app.",
|
||||||
|
"settings.appearance.theme.option.system": "Match your operating system setting",
|
||||||
|
"settings.appearance.theme.option.light": "Use the light appearance",
|
||||||
|
"settings.appearance.theme.option.dark": "Use the dark appearance",
|
||||||
|
"settings.section.notifications.title": "Notifications",
|
||||||
|
"settings.section.notifications.subtitle": "Control OS-level notifications for session activity.",
|
||||||
|
"settings.notifications.permission.granted": "Granted",
|
||||||
|
"settings.notifications.permission.denied": "Denied",
|
||||||
|
"settings.notifications.permission.default": "Not granted",
|
||||||
|
"settings.notifications.permission.unsupported": "Unsupported",
|
||||||
|
"settings.notifications.messages.unsupportedEnvironment": "OS notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionDenied": "Notification permission denied. Enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.messages.permissionNotGranted": "Notification permission not granted.",
|
||||||
|
"settings.notifications.messages.unsupportedGeneral": "Notifications are not supported in this environment.",
|
||||||
|
"settings.notifications.messages.permissionGranted": "Permission granted. You can now enable notifications.",
|
||||||
|
"settings.notifications.messages.permissionRequestDenied": "Permission denied. You may need to enable notifications in your system or browser settings.",
|
||||||
|
"settings.notifications.sessionStatus.title": "Session status notifications",
|
||||||
|
"settings.notifications.sessionStatus.subtitle": "Receive alerts when sessions need your attention.",
|
||||||
|
"settings.notifications.enable.title": "Enable notifications",
|
||||||
|
"settings.notifications.enable.permission": "Permission: {permission}",
|
||||||
|
"settings.notifications.requestPermission.title": "Request permission",
|
||||||
|
"settings.notifications.requestPermission.subtitle": "Allow the app to send notifications on this device.",
|
||||||
|
"settings.notifications.requestPermission.action": "Request",
|
||||||
|
"settings.notifications.allowVisible.title": "Notify when the app is focused",
|
||||||
|
"settings.notifications.allowVisible.subtitle": "Keep alerts enabled even while this window is visible.",
|
||||||
|
"settings.notifications.unsupportedNote": "Notifications are not supported in this environment. The notifications control stays disabled.",
|
||||||
|
"settings.notifications.events.title": "Notify me when",
|
||||||
|
"settings.notifications.events.subtitle": "Choose which session events should send alerts.",
|
||||||
|
"settings.notifications.events.needsInput": "Session needs input",
|
||||||
|
"settings.notifications.events.idle": "Session becomes idle",
|
||||||
|
"settings.notifications.status.enabled": "Notifications enabled",
|
||||||
|
"settings.notifications.status.disabled": "Notifications disabled",
|
||||||
|
"settings.notifications.status.unsupported": "Notifications unsupported",
|
||||||
|
"settings.section.remote.title": "Remote Access",
|
||||||
|
"settings.section.remote.subtitle": "Review how this server is exposed on your network and secure access credentials.",
|
||||||
|
"settings.section.opencode.title": "OpenCode",
|
||||||
|
"settings.section.opencode.subtitle": "Choose the OpenCode binary and environment used for new instances.",
|
||||||
|
"settings.opencode.runtime.title": "Runtime",
|
||||||
|
"settings.opencode.runtime.subtitle": "Configure which OpenCode binary new instances launch with.",
|
||||||
|
|
||||||
|
"settings.appearance.behavior.title": "交互",
|
||||||
|
"settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。",
|
||||||
|
"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": "差异视图",
|
||||||
|
"settings.behavior.diffView.subtitle": "选择工具调用差异的显示方式。",
|
||||||
|
"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": "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": "回车发送",
|
||||||
|
"settings.behavior.promptSubmit.subtitle": "使用回车发送;Cmd/Ctrl+回车插入新行。",
|
||||||
|
"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.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.help": "Prompt voice input only appears when speech transcription is configured and supported by this browser.",
|
||||||
|
"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
|
} as const
|
||||||
|
|||||||
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
155
packages/ui/src/lib/native/desktop-file-drop.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { getLogger } from "../logger"
|
||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
type NativeFolderDropState = "enter" | "leave"
|
||||||
|
|
||||||
|
interface TauriFolderDropPayload {
|
||||||
|
paths?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePathList(input: unknown): string[] {
|
||||||
|
if (!Array.isArray(input)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return input.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilePath(file: File): string | null {
|
||||||
|
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||||
|
return file.path
|
||||||
|
}
|
||||||
|
if (runtimeEnv.host === "electron") {
|
||||||
|
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||||
|
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||||
|
return electronPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||||
|
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||||
|
if (!api?.getDirectoryPaths || paths.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await api.getDirectoryPaths(paths)
|
||||||
|
} catch (error) {
|
||||||
|
log.error("[native] failed to validate dropped directory paths", error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsDesktopFolderDrop(): boolean {
|
||||||
|
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function containsFileDrop(event: DragEvent): boolean {
|
||||||
|
const types = event.dataTransfer?.types
|
||||||
|
if (!types) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Array.from(types).includes("Files")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDroppedDirectoryPaths(event: DragEvent): string[] {
|
||||||
|
const dataTransfer = event.dataTransfer
|
||||||
|
if (!dataTransfer) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryHints = new Set<string>()
|
||||||
|
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||||
|
if (item.kind !== "file") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entry = item.webkitGetAsEntry?.()
|
||||||
|
if (!entry?.isDirectory) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const file = item.getAsFile()
|
||||||
|
const filePath = file ? getFilePath(file) : null
|
||||||
|
if (filePath) {
|
||||||
|
directoryHints.add(filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths = new Set<string>()
|
||||||
|
for (const file of Array.from(dataTransfer.files ?? [])) {
|
||||||
|
const filePath = getFilePath(file)
|
||||||
|
if (!filePath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (directoryHints.size > 0 && !directoryHints.has(filePath)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
paths.add(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<string[]> {
|
||||||
|
const uniquePaths = Array.from(new Set(paths.filter((path) => typeof path === "string" && path.trim().length > 0)))
|
||||||
|
if (uniquePaths.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
if (runtimeEnv.host === "electron") {
|
||||||
|
return resolveElectronDirectoryPaths(uniquePaths)
|
||||||
|
}
|
||||||
|
return uniquePaths
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||||
|
if (runtimeEnv.host !== "tauri") {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventApi = window.__TAURI__?.event
|
||||||
|
if (!eventApi?.listen) {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unlisten = await eventApi.listen("desktop:folder-drop", (event) => {
|
||||||
|
const payload = (event.payload ?? {}) as TauriFolderDropPayload
|
||||||
|
const paths = normalizePathList(payload.paths)
|
||||||
|
if (paths.length > 0) {
|
||||||
|
onDrop(paths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unlisten()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("[native] failed to listen for folder-drop event", error)
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||||
|
if (runtimeEnv.host !== "tauri") {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventApi = window.__TAURI__?.event
|
||||||
|
if (!eventApi?.listen) {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [unlistenEnter, unlistenLeave] = await Promise.all([
|
||||||
|
eventApi.listen("desktop:folder-drag-enter", () => onState("enter")),
|
||||||
|
eventApi.listen("desktop:folder-drag-leave", () => onState("leave")),
|
||||||
|
])
|
||||||
|
return () => {
|
||||||
|
unlistenEnter()
|
||||||
|
unlistenLeave()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("[native] failed to listen for folder-drop state", error)
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
471
packages/ui/src/lib/settings/behavior-registry.ts
Normal file
471
packages/ui/src/lib/settings/behavior-registry.ts
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import type { Accessor } from "solid-js"
|
||||||
|
import type {
|
||||||
|
Preferences,
|
||||||
|
ExpansionPreference,
|
||||||
|
ToolInputsVisibilityPreference,
|
||||||
|
} from "../../stores/preferences"
|
||||||
|
import type { Command } from "../commands"
|
||||||
|
import { tGlobal } from "../i18n"
|
||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
|
||||||
|
export type BehaviorSettingKind = "toggle" | "enum"
|
||||||
|
|
||||||
|
export type BehaviorToggleSetting = {
|
||||||
|
kind: "toggle"
|
||||||
|
id: string
|
||||||
|
titleKey: string
|
||||||
|
subtitleKey: string
|
||||||
|
get: (preferences: Preferences) => boolean
|
||||||
|
set: (next: boolean) => void
|
||||||
|
disabled?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BehaviorEnumSetting<T extends string = string> = {
|
||||||
|
kind: "enum"
|
||||||
|
id: string
|
||||||
|
titleKey: string
|
||||||
|
subtitleKey: string
|
||||||
|
get: (preferences: Preferences) => T
|
||||||
|
set: (next: T) => void
|
||||||
|
options: Array<{ value: T; labelKey: string }>
|
||||||
|
disabled?: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BehaviorSetting = BehaviorToggleSetting | BehaviorEnumSetting
|
||||||
|
|
||||||
|
export type BehaviorRegistryActions = {
|
||||||
|
preferences: Accessor<Preferences>
|
||||||
|
updatePreferences?: (updates: Partial<Preferences>) => void
|
||||||
|
toggleShowThinkingBlocks: () => void
|
||||||
|
toggleKeyboardShortcutHints: () => void
|
||||||
|
toggleShowTimelineTools: () => void
|
||||||
|
toggleUsageMetrics: () => void
|
||||||
|
toggleAutoCleanupBlankSessions: () => void
|
||||||
|
togglePromptSubmitOnEnter: () => void
|
||||||
|
toggleShowPromptVoiceInput: () => void
|
||||||
|
setDiffViewMode: (mode: "split" | "unified") => void
|
||||||
|
setToolOutputExpansion: (mode: ExpansionPreference) => void
|
||||||
|
setDiagnosticsExpansion: (mode: ExpansionPreference) => void
|
||||||
|
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
|
||||||
|
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitKeywords(key: string): string[] {
|
||||||
|
return tGlobal(key)
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBooleanByToggle(getCurrent: () => boolean, toggle: () => void, next: boolean) {
|
||||||
|
if (getCurrent() === next) return
|
||||||
|
toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorSetting[] {
|
||||||
|
const prefs = actions.preferences
|
||||||
|
const updatePreferences = actions.updatePreferences
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.keyboardShortcutHints",
|
||||||
|
titleKey: "settings.behavior.keyboardHints.title",
|
||||||
|
subtitleKey: "settings.behavior.keyboardHints.subtitle",
|
||||||
|
get: (p) => Boolean(p.showKeyboardShortcutHints ?? true),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showKeyboardShortcutHints: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showKeyboardShortcutHints ?? true),
|
||||||
|
actions.toggleKeyboardShortcutHints,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
disabled: () => runtimeEnv.host === "web",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.thinkingBlocks",
|
||||||
|
titleKey: "settings.behavior.thinking.title",
|
||||||
|
subtitleKey: "settings.behavior.thinking.subtitle",
|
||||||
|
get: (p) => Boolean(p.showThinkingBlocks),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showThinkingBlocks: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showThinkingBlocks),
|
||||||
|
actions.toggleShowThinkingBlocks,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "enum",
|
||||||
|
id: "behavior.thinkingBlocksDefault",
|
||||||
|
titleKey: "settings.behavior.thinkingDefault.title",
|
||||||
|
subtitleKey: "settings.behavior.thinkingDefault.subtitle",
|
||||||
|
get: (p) => (p.thinkingBlocksExpansion ?? "expanded") as ExpansionPreference,
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ thinkingBlocksExpansion: next as ExpansionPreference })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setThinkingBlocksExpansion(next as ExpansionPreference)
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||||
|
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.timelineToolCalls",
|
||||||
|
titleKey: "settings.behavior.timelineTools.title",
|
||||||
|
subtitleKey: "settings.behavior.timelineTools.subtitle",
|
||||||
|
get: (p) => Boolean(p.showTimelineTools),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showTimelineTools: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showTimelineTools),
|
||||||
|
actions.toggleShowTimelineTools,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "enum",
|
||||||
|
id: "behavior.diffViewMode",
|
||||||
|
titleKey: "settings.behavior.diffView.title",
|
||||||
|
subtitleKey: "settings.behavior.diffView.subtitle",
|
||||||
|
get: (p) => (p.diffViewMode ?? "split") as "split" | "unified",
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ diffViewMode: next as "split" | "unified" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setDiffViewMode(next as "split" | "unified")
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ value: "split", labelKey: "settings.behavior.diffView.option.split" },
|
||||||
|
{ value: "unified", labelKey: "settings.behavior.diffView.option.unified" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "enum",
|
||||||
|
id: "behavior.toolOutputsDefault",
|
||||||
|
titleKey: "settings.behavior.toolOutputsDefault.title",
|
||||||
|
subtitleKey: "settings.behavior.toolOutputsDefault.subtitle",
|
||||||
|
get: (p) => (p.toolOutputExpansion ?? "expanded") as ExpansionPreference,
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ toolOutputExpansion: next as ExpansionPreference })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setToolOutputExpansion(next as ExpansionPreference)
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||||
|
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "enum",
|
||||||
|
id: "behavior.diagnosticsDefault",
|
||||||
|
titleKey: "settings.behavior.diagnosticsDefault.title",
|
||||||
|
subtitleKey: "settings.behavior.diagnosticsDefault.subtitle",
|
||||||
|
get: (p) => (p.diagnosticsExpansion ?? "expanded") as ExpansionPreference,
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ diagnosticsExpansion: next as ExpansionPreference })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setDiagnosticsExpansion(next as ExpansionPreference)
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||||
|
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "enum",
|
||||||
|
id: "behavior.toolInputsVisibility",
|
||||||
|
titleKey: "settings.behavior.toolInputsVisibility.title",
|
||||||
|
subtitleKey: "settings.behavior.toolInputsVisibility.subtitle",
|
||||||
|
get: (p) => (p.toolInputsVisibility ?? "hidden") as ToolInputsVisibilityPreference,
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ toolInputsVisibility: next as ToolInputsVisibilityPreference })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actions.setToolInputsVisibility(next as ToolInputsVisibilityPreference)
|
||||||
|
},
|
||||||
|
options: [
|
||||||
|
{ value: "hidden", labelKey: "commands.common.hidden" },
|
||||||
|
{ value: "collapsed", labelKey: "commands.common.collapsed" },
|
||||||
|
{ value: "expanded", labelKey: "commands.common.expanded" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.usageMetrics",
|
||||||
|
titleKey: "settings.behavior.usageMetrics.title",
|
||||||
|
subtitleKey: "settings.behavior.usageMetrics.subtitle",
|
||||||
|
get: (p) => Boolean(p.showUsageMetrics ?? true),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showUsageMetrics: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showUsageMetrics ?? true),
|
||||||
|
actions.toggleUsageMetrics,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.autoCleanupBlankSessions",
|
||||||
|
titleKey: "settings.behavior.autoCleanup.title",
|
||||||
|
subtitleKey: "settings.behavior.autoCleanup.subtitle",
|
||||||
|
get: (p) => Boolean(p.autoCleanupBlankSessions),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ autoCleanupBlankSessions: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().autoCleanupBlankSessions),
|
||||||
|
actions.toggleAutoCleanupBlankSessions,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.promptVoiceInput",
|
||||||
|
titleKey: "settings.behavior.promptVoiceInput.title",
|
||||||
|
subtitleKey: "settings.behavior.promptVoiceInput.subtitle",
|
||||||
|
get: (p) => Boolean(p.showPromptVoiceInput ?? true),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ showPromptVoiceInput: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().showPromptVoiceInput ?? true),
|
||||||
|
actions.toggleShowPromptVoiceInput,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "toggle",
|
||||||
|
id: "behavior.promptSubmitOnEnter",
|
||||||
|
titleKey: "settings.behavior.promptSubmit.title",
|
||||||
|
subtitleKey: "settings.behavior.promptSubmit.subtitle",
|
||||||
|
get: (p) => Boolean(p.promptSubmitOnEnter),
|
||||||
|
set: (next) => {
|
||||||
|
if (updatePreferences) {
|
||||||
|
updatePreferences({ promptSubmitOnEnter: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBooleanByToggle(
|
||||||
|
() => Boolean(prefs().promptSubmitOnEnter),
|
||||||
|
actions.togglePromptSubmitOnEnter,
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "prompt-submit-shortcut",
|
||||||
|
label: () =>
|
||||||
|
actions.preferences().promptSubmitOnEnter
|
||||||
|
? tGlobal("commands.promptSubmitShortcut.label.swapped")
|
||||||
|
: tGlobal("commands.promptSubmitShortcut.label.default"),
|
||||||
|
description: () => tGlobal("commands.promptSubmitShortcut.description"),
|
||||||
|
category: "Input & Focus",
|
||||||
|
keywords: () => splitKeywords("commands.promptSubmitShortcut.keywords"),
|
||||||
|
action: actions.togglePromptSubmitOnEnter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thinking",
|
||||||
|
label: () =>
|
||||||
|
tGlobal(
|
||||||
|
actions.preferences().showThinkingBlocks
|
||||||
|
? "commands.thinkingBlocks.label.hide"
|
||||||
|
: "commands.thinkingBlocks.label.show",
|
||||||
|
),
|
||||||
|
description: () => tGlobal("commands.thinkingBlocks.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocks.keywords")],
|
||||||
|
action: actions.toggleShowThinkingBlocks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "timeline-tools",
|
||||||
|
label: () =>
|
||||||
|
tGlobal(
|
||||||
|
actions.preferences().showTimelineTools
|
||||||
|
? "commands.timelineToolCalls.label.hide"
|
||||||
|
: "commands.timelineToolCalls.label.show",
|
||||||
|
),
|
||||||
|
description: () => tGlobal("commands.timelineToolCalls.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.timelineToolCalls.keywords"),
|
||||||
|
action: actions.toggleShowTimelineTools,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keyboard-shortcut-hints",
|
||||||
|
label: () =>
|
||||||
|
tGlobal(
|
||||||
|
actions.preferences().showKeyboardShortcutHints
|
||||||
|
? "commands.keyboardShortcutHints.label.hide"
|
||||||
|
: "commands.keyboardShortcutHints.label.show",
|
||||||
|
),
|
||||||
|
description: () =>
|
||||||
|
tGlobal(
|
||||||
|
runtimeEnv.host === "web"
|
||||||
|
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||||
|
: "commands.keyboardShortcutHints.description",
|
||||||
|
),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||||
|
disabled: () => runtimeEnv.host === "web",
|
||||||
|
action: actions.toggleKeyboardShortcutHints,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "thinking-default-visibility",
|
||||||
|
label: () => {
|
||||||
|
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||||
|
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||||
|
return tGlobal("commands.thinkingBlocksDefault.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.thinkingBlocksDefault.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => ["/thinking", ...splitKeywords("commands.thinkingBlocksDefault.keywords")],
|
||||||
|
action: () => {
|
||||||
|
const mode = actions.preferences().thinkingBlocksExpansion ?? "expanded"
|
||||||
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
|
actions.setThinkingBlocksExpansion(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diff-view-split",
|
||||||
|
label: () => {
|
||||||
|
const prefix = (actions.preferences().diffViewMode || "split") === "split" ? "✓ " : ""
|
||||||
|
return `${prefix}${tGlobal("commands.diffViewSplit.label")}`
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.diffViewSplit.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.diffViewSplit.keywords"),
|
||||||
|
action: () => actions.setDiffViewMode("split"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diff-view-unified",
|
||||||
|
label: () => {
|
||||||
|
const prefix = (actions.preferences().diffViewMode || "split") === "unified" ? "✓ " : ""
|
||||||
|
return `${prefix}${tGlobal("commands.diffViewUnified.label")}`
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.diffViewUnified.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.diffViewUnified.keywords"),
|
||||||
|
action: () => actions.setDiffViewMode("unified"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tool-output-default-visibility",
|
||||||
|
label: () => {
|
||||||
|
const mode = actions.preferences().toolOutputExpansion || "expanded"
|
||||||
|
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||||
|
return tGlobal("commands.toolOutputsDefault.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.toolOutputsDefault.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.toolOutputsDefault.keywords"),
|
||||||
|
action: () => {
|
||||||
|
const mode = actions.preferences().toolOutputExpansion || "expanded"
|
||||||
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
|
actions.setToolOutputExpansion(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diagnostics-default-visibility",
|
||||||
|
label: () => {
|
||||||
|
const mode = actions.preferences().diagnosticsExpansion || "expanded"
|
||||||
|
const state = mode === "expanded" ? tGlobal("commands.common.expanded") : tGlobal("commands.common.collapsed")
|
||||||
|
return tGlobal("commands.diagnosticsDefault.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.diagnosticsDefault.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.diagnosticsDefault.keywords"),
|
||||||
|
action: () => {
|
||||||
|
const mode = actions.preferences().diagnosticsExpansion || "expanded"
|
||||||
|
const next: ExpansionPreference = mode === "expanded" ? "collapsed" : "expanded"
|
||||||
|
actions.setDiagnosticsExpansion(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tool-inputs-visibility",
|
||||||
|
label: () => {
|
||||||
|
const mode = actions.preferences().toolInputsVisibility || "hidden"
|
||||||
|
const state =
|
||||||
|
mode === "expanded"
|
||||||
|
? tGlobal("commands.common.expanded")
|
||||||
|
: mode === "collapsed"
|
||||||
|
? tGlobal("commands.common.collapsed")
|
||||||
|
: tGlobal("commands.common.hidden")
|
||||||
|
return tGlobal("commands.toolInputsVisibility.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.toolInputsVisibility.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
|
||||||
|
action: () => {
|
||||||
|
const mode = actions.preferences().toolInputsVisibility || "hidden"
|
||||||
|
const next: ToolInputsVisibilityPreference =
|
||||||
|
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
|
||||||
|
actions.setToolInputsVisibility(next)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "token-usage-visibility",
|
||||||
|
label: () => {
|
||||||
|
const visible = actions.preferences().showUsageMetrics ?? true
|
||||||
|
const state = visible ? tGlobal("commands.common.visible") : tGlobal("commands.common.hidden")
|
||||||
|
return tGlobal("commands.tokenUsageDisplay.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.tokenUsageDisplay.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.tokenUsageDisplay.keywords"),
|
||||||
|
action: actions.toggleUsageMetrics,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "auto-cleanup-blank-sessions",
|
||||||
|
label: () => {
|
||||||
|
const enabled = actions.preferences().autoCleanupBlankSessions
|
||||||
|
const state = enabled ? tGlobal("commands.common.enabled") : tGlobal("commands.common.disabled")
|
||||||
|
return tGlobal("commands.autoCleanupBlankSessions.label", { state })
|
||||||
|
},
|
||||||
|
description: () => tGlobal("commands.autoCleanupBlankSessions.description"),
|
||||||
|
category: "System",
|
||||||
|
keywords: () => splitKeywords("commands.autoCleanupBlankSessions.keywords"),
|
||||||
|
action: actions.toggleAutoCleanupBlankSessions,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBehaviorCommands(register: (command: Command) => void, actions: BehaviorRegistryActions) {
|
||||||
|
const commands = getBehaviorCommands(actions)
|
||||||
|
commands.forEach((command) => register(command))
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
updateInstanceConfig as updateInstanceData,
|
updateInstanceConfig as updateInstanceData,
|
||||||
} from "./instance-config"
|
} from "./instance-config"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
import { loadSpeechCapabilities, resetSpeechCapabilities } from "./speech"
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -27,6 +28,16 @@ export type DiffViewMode = "split" | "unified"
|
|||||||
export type ExpansionPreference = "expanded" | "collapsed"
|
export type ExpansionPreference = "expanded" | "collapsed"
|
||||||
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
export type ToolInputsVisibilityPreference = "hidden" | "collapsed" | "expanded"
|
||||||
export type ListeningMode = "local" | "all"
|
export type ListeningMode = "local" | "all"
|
||||||
|
export type SpeechProviderPreference = "openai-compatible"
|
||||||
|
|
||||||
|
export interface SpeechSettings {
|
||||||
|
provider: SpeechProviderPreference
|
||||||
|
apiKey?: string
|
||||||
|
baseUrl?: string
|
||||||
|
sttModel: string
|
||||||
|
ttsModel: string
|
||||||
|
ttsVoice: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
showThinkingBlocks: boolean
|
showThinkingBlocks: boolean
|
||||||
@@ -34,6 +45,7 @@ export interface UiSettings {
|
|||||||
thinkingBlocksExpansion: ExpansionPreference
|
thinkingBlocksExpansion: ExpansionPreference
|
||||||
showTimelineTools: boolean
|
showTimelineTools: boolean
|
||||||
promptSubmitOnEnter: boolean
|
promptSubmitOnEnter: boolean
|
||||||
|
showPromptVoiceInput: boolean
|
||||||
locale?: string
|
locale?: string
|
||||||
diffViewMode: DiffViewMode
|
diffViewMode: DiffViewMode
|
||||||
toolOutputExpansion: ExpansionPreference
|
toolOutputExpansion: ExpansionPreference
|
||||||
@@ -75,6 +87,7 @@ interface ServerConfigBucket {
|
|||||||
listeningMode?: ListeningMode
|
listeningMode?: ListeningMode
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
opencodeBinary?: string
|
opencodeBinary?: string
|
||||||
|
speech?: Partial<SpeechSettings>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UiStateBucket {
|
interface UiStateBucket {
|
||||||
@@ -107,6 +120,7 @@ const defaultUiSettings: UiSettings = {
|
|||||||
thinkingBlocksExpansion: "expanded",
|
thinkingBlocksExpansion: "expanded",
|
||||||
showTimelineTools: true,
|
showTimelineTools: true,
|
||||||
promptSubmitOnEnter: false,
|
promptSubmitOnEnter: false,
|
||||||
|
showPromptVoiceInput: true,
|
||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
toolOutputExpansion: "expanded",
|
toolOutputExpansion: "expanded",
|
||||||
diagnosticsExpansion: "expanded",
|
diagnosticsExpansion: "expanded",
|
||||||
@@ -120,6 +134,13 @@ const defaultUiSettings: UiSettings = {
|
|||||||
notifyOnIdle: true,
|
notifyOnIdle: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultSpeechSettings: SpeechSettings = {
|
||||||
|
provider: "openai-compatible",
|
||||||
|
sttModel: "gpt-4o-mini-transcribe",
|
||||||
|
ttsModel: "gpt-4o-mini-tts",
|
||||||
|
ttsVoice: "alloy",
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
||||||
const sanitized = input ?? {}
|
const sanitized = input ?? {}
|
||||||
return {
|
return {
|
||||||
@@ -129,6 +150,7 @@ function normalizeUiSettings(input?: Partial<UiSettings> | null): UiSettings {
|
|||||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultUiSettings.thinkingBlocksExpansion,
|
||||||
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
showTimelineTools: sanitized.showTimelineTools ?? defaultUiSettings.showTimelineTools,
|
||||||
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
promptSubmitOnEnter: sanitized.promptSubmitOnEnter ?? defaultUiSettings.promptSubmitOnEnter,
|
||||||
|
showPromptVoiceInput: sanitized.showPromptVoiceInput ?? defaultUiSettings.showPromptVoiceInput,
|
||||||
locale: sanitized.locale ?? defaultUiSettings.locale,
|
locale: sanitized.locale ?? defaultUiSettings.locale,
|
||||||
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
diffViewMode: sanitized.diffViewMode ?? defaultUiSettings.diffViewMode,
|
||||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
|
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultUiSettings.toolOutputExpansion,
|
||||||
@@ -156,6 +178,27 @@ function normalizeRecord(value: unknown): Record<string, string> {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSpeechSettings(input?: Partial<SpeechSettings> | null): SpeechSettings {
|
||||||
|
const sanitized = input ?? {}
|
||||||
|
return {
|
||||||
|
provider: sanitized.provider === "openai-compatible" ? sanitized.provider : defaultSpeechSettings.provider,
|
||||||
|
apiKey: typeof sanitized.apiKey === "string" && sanitized.apiKey.trim() ? sanitized.apiKey.trim() : undefined,
|
||||||
|
baseUrl: typeof sanitized.baseUrl === "string" && sanitized.baseUrl.trim() ? sanitized.baseUrl.trim() : undefined,
|
||||||
|
sttModel:
|
||||||
|
typeof sanitized.sttModel === "string" && sanitized.sttModel.trim()
|
||||||
|
? sanitized.sttModel.trim()
|
||||||
|
: defaultSpeechSettings.sttModel,
|
||||||
|
ttsModel:
|
||||||
|
typeof sanitized.ttsModel === "string" && sanitized.ttsModel.trim()
|
||||||
|
? sanitized.ttsModel.trim()
|
||||||
|
: defaultSpeechSettings.ttsModel,
|
||||||
|
ttsVoice:
|
||||||
|
typeof sanitized.ttsVoice === "string" && sanitized.ttsVoice.trim()
|
||||||
|
? sanitized.ttsVoice.trim()
|
||||||
|
: defaultSpeechSettings.ttsVoice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
|
function cloneArray<T>(value: unknown, mapper: (item: any) => T | null): T[] {
|
||||||
if (!Array.isArray(value)) return []
|
if (!Array.isArray(value)) return []
|
||||||
const out: T[] = []
|
const out: T[] = []
|
||||||
@@ -206,12 +249,15 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerConfig(input?: ServerConfigBucket | null): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> {
|
function normalizeServerConfig(
|
||||||
|
input?: ServerConfigBucket | null,
|
||||||
|
): Required<Pick<ServerConfigBucket, "listeningMode" | "environmentVariables" | "opencodeBinary">> & { speech: SpeechSettings } {
|
||||||
const source = input ?? {}
|
const source = input ?? {}
|
||||||
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
const listeningMode = source.listeningMode === "all" ? "all" : "local"
|
||||||
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode"
|
||||||
const environmentVariables = normalizeRecord(source.environmentVariables)
|
const environmentVariables = normalizeRecord(source.environmentVariables)
|
||||||
return { listeningMode, opencodeBinary, environmentVariables }
|
const speech = normalizeSpeechSettings(source.speech)
|
||||||
|
return { listeningMode, opencodeBinary, environmentVariables, speech }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||||
@@ -342,6 +388,16 @@ function updateLastUsedBinary(path: string): void {
|
|||||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to update binary list", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateSpeechSettings(updates: Partial<SpeechSettings>): Promise<void> {
|
||||||
|
const next = normalizeSpeechSettings({ ...serverSettings().speech, ...updates })
|
||||||
|
try {
|
||||||
|
await patchConfigOwner("server", { speech: next })
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Failed to update speech settings", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function addOpenCodeBinary(path: string, version?: string): void {
|
function addOpenCodeBinary(path: string, version?: string): void {
|
||||||
const nextList = buildBinaryList(path, version, opencodeBinaries())
|
const nextList = buildBinaryList(path, version, opencodeBinaries())
|
||||||
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
|
void patchStateOwner("ui", { opencodeBinaries: nextList }).catch((error) => log.error("Failed to add binary", error))
|
||||||
@@ -476,6 +532,10 @@ function togglePromptSubmitOnEnter(): void {
|
|||||||
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
updateUiSettings({ promptSubmitOnEnter: !preferences().promptSubmitOnEnter })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleShowPromptVoiceInput(): void {
|
||||||
|
updateUiSettings({ showPromptVoiceInput: !preferences().showPromptVoiceInput })
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAutoCleanupBlankSessions(): void {
|
function toggleAutoCleanupBlankSessions(): void {
|
||||||
const nextValue = !preferences().autoCleanupBlankSessions
|
const nextValue = !preferences().autoCleanupBlankSessions
|
||||||
log.info("toggle auto cleanup", { value: nextValue })
|
log.info("toggle auto cleanup", { value: nextValue })
|
||||||
@@ -521,6 +581,7 @@ interface ConfigContextValue {
|
|||||||
addEnvironmentVariable: typeof addEnvironmentVariable
|
addEnvironmentVariable: typeof addEnvironmentVariable
|
||||||
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
removeEnvironmentVariable: typeof removeEnvironmentVariable
|
||||||
updateLastUsedBinary: typeof updateLastUsedBinary
|
updateLastUsedBinary: typeof updateLastUsedBinary
|
||||||
|
updateSpeechSettings: typeof updateSpeechSettings
|
||||||
|
|
||||||
// ui-owned state
|
// ui-owned state
|
||||||
recentFolders: typeof recentFolders
|
recentFolders: typeof recentFolders
|
||||||
@@ -544,6 +605,7 @@ interface ConfigContextValue {
|
|||||||
toggleUsageMetrics: typeof toggleUsageMetrics
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
toggleAutoCleanupBlankSessions: typeof toggleAutoCleanupBlankSessions
|
||||||
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
togglePromptSubmitOnEnter: typeof togglePromptSubmitOnEnter
|
||||||
|
toggleShowPromptVoiceInput: typeof toggleShowPromptVoiceInput
|
||||||
setDiffViewMode: typeof setDiffViewMode
|
setDiffViewMode: typeof setDiffViewMode
|
||||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||||
@@ -569,6 +631,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateSpeechSettings,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
opencodeBinaries,
|
opencodeBinaries,
|
||||||
uiState,
|
uiState,
|
||||||
@@ -588,6 +651,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -610,6 +674,8 @@ export const ConfigProvider: ParentComponent = (props) => {
|
|||||||
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
|
const unsubServer = storage.onConfigOwnerChanged("server", (bucket) => {
|
||||||
setServerConfigBucket(bucket as any)
|
setServerConfigBucket(bucket as any)
|
||||||
setIsLoaded(true)
|
setIsLoaded(true)
|
||||||
|
resetSpeechCapabilities()
|
||||||
|
void loadSpeechCapabilities(true)
|
||||||
})
|
})
|
||||||
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
|
const unsubStateUi = storage.onStateOwnerChanged("ui", (bucket) => {
|
||||||
setUiStateBucket(bucket as any)
|
setUiStateBucket(bucket as any)
|
||||||
@@ -648,6 +714,7 @@ export {
|
|||||||
addEnvironmentVariable,
|
addEnvironmentVariable,
|
||||||
removeEnvironmentVariable,
|
removeEnvironmentVariable,
|
||||||
updateLastUsedBinary,
|
updateLastUsedBinary,
|
||||||
|
updateSpeechSettings,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
removeRecentFolder,
|
removeRecentFolder,
|
||||||
addOpenCodeBinary,
|
addOpenCodeBinary,
|
||||||
@@ -664,6 +731,7 @@ export {
|
|||||||
toggleUsageMetrics,
|
toggleUsageMetrics,
|
||||||
toggleAutoCleanupBlankSessions,
|
toggleAutoCleanupBlankSessions,
|
||||||
togglePromptSubmitOnEnter,
|
togglePromptSubmitOnEnter,
|
||||||
|
toggleShowPromptVoiceInput,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
17
packages/ui/src/stores/settings-screen.ts
Normal file
17
packages/ui/src/stores/settings-screen.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
|
||||||
|
export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode"
|
||||||
|
|
||||||
|
const [settingsOpen, setSettingsOpen] = createSignal(false)
|
||||||
|
const [activeSettingsSection, setActiveSettingsSection] = createSignal<SettingsSectionId>("appearance")
|
||||||
|
|
||||||
|
export function openSettings(section: SettingsSectionId = "appearance") {
|
||||||
|
setActiveSettingsSection(section)
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSettings() {
|
||||||
|
setSettingsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { settingsOpen, activeSettingsSection, setActiveSettingsSection }
|
||||||
46
packages/ui/src/stores/speech.ts
Normal file
46
packages/ui/src/stores/speech.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSignal } from "solid-js"
|
||||||
|
import type { SpeechCapabilitiesResponse } from "../../../server/src/api-types"
|
||||||
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
|
const log = getLogger("api")
|
||||||
|
|
||||||
|
const [speechCapabilities, setSpeechCapabilities] = createSignal<SpeechCapabilitiesResponse | null>(null)
|
||||||
|
const [speechCapabilitiesLoading, setSpeechCapabilitiesLoading] = createSignal(false)
|
||||||
|
const [speechCapabilitiesError, setSpeechCapabilitiesError] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
let speechCapabilitiesPromise: Promise<SpeechCapabilitiesResponse | null> | null = null
|
||||||
|
|
||||||
|
async function loadSpeechCapabilities(force = false): Promise<SpeechCapabilitiesResponse | null> {
|
||||||
|
if (!force && speechCapabilities()) return speechCapabilities()
|
||||||
|
if (speechCapabilitiesPromise) return speechCapabilitiesPromise
|
||||||
|
|
||||||
|
setSpeechCapabilitiesLoading(true)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
speechCapabilitiesPromise = serverApi
|
||||||
|
.fetchSpeechCapabilities()
|
||||||
|
.then((result) => {
|
||||||
|
setSpeechCapabilities(result)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("Failed to load speech capabilities", error)
|
||||||
|
setSpeechCapabilities(null)
|
||||||
|
setSpeechCapabilitiesError(error instanceof Error ? error.message : String(error))
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setSpeechCapabilitiesLoading(false)
|
||||||
|
speechCapabilitiesPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return speechCapabilitiesPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSpeechCapabilities(): void {
|
||||||
|
setSpeechCapabilities(null)
|
||||||
|
setSpeechCapabilitiesError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { speechCapabilities, speechCapabilitiesLoading, speechCapabilitiesError, loadSpeechCapabilities, resetSpeechCapabilities }
|
||||||
39
packages/ui/src/styles/components/folder-drop.css
Normal file
39
packages/ui/src/styles/components/folder-drop.css
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
.folder-drop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: color-mix(in srgb, var(--folder-overlay-bg) 88%, var(--accent-primary) 12%);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-drop-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
width: min(560px, 100%);
|
||||||
|
padding: 2rem;
|
||||||
|
border: 2px dashed var(--accent-primary);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background-color: color-mix(in srgb, var(--surface-base) 92%, var(--accent-primary) 8%);
|
||||||
|
box-shadow: var(--folder-card-shadow);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-drop-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-drop-subtext {
|
||||||
|
max-width: 32rem;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height-relaxed);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
ring-color: var(--accent-primary);
|
ring-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selector-trigger:disabled,
|
||||||
|
.selector-trigger[aria-disabled="true"],
|
||||||
|
.selector-trigger[data-disabled],
|
||||||
.selector-trigger-disabled {
|
.selector-trigger-disabled {
|
||||||
@apply opacity-50 cursor-not-allowed;
|
@apply opacity-50 cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|||||||
538
packages/ui/src/styles/components/settings-screen.css
Normal file
538
packages/ui/src/styles/components/settings-screen.css
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
.settings-screen-frame {
|
||||||
|
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override .modal-surface (defined later in panels.css). */
|
||||||
|
.modal-surface.settings-screen-shell {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
height: min(88vh, 920px);
|
||||||
|
max-height: none;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 32px 96px color-mix(in oklab, var(--overlay-scrim) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settings UI uses square corners (no radius). */
|
||||||
|
.modal-surface.settings-screen-shell .selector-trigger,
|
||||||
|
.modal-surface.settings-screen-shell .selector-popover,
|
||||||
|
.modal-surface.settings-screen-shell .selector-option,
|
||||||
|
.modal-surface.settings-screen-shell .selector-button,
|
||||||
|
.modal-surface.settings-screen-shell .selector-input,
|
||||||
|
.modal-surface.settings-screen-shell .selector-search-input,
|
||||||
|
.modal-surface.settings-screen-shell .remote-close,
|
||||||
|
.modal-surface.settings-screen-shell .remote-section,
|
||||||
|
.modal-surface.settings-screen-shell .remote-refresh,
|
||||||
|
.modal-surface.settings-screen-shell .remote-toggle,
|
||||||
|
.modal-surface.settings-screen-shell .remote-toggle-switch,
|
||||||
|
.modal-surface.settings-screen-shell .remote-toggle-thumb,
|
||||||
|
.modal-surface.settings-screen-shell .remote-address,
|
||||||
|
.modal-surface.settings-screen-shell .remote-pill,
|
||||||
|
.modal-surface.settings-screen-shell .remote-qr,
|
||||||
|
.modal-surface.settings-screen-shell .remote-card,
|
||||||
|
.modal-surface.settings-screen-shell .remote-error {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--surface-secondary) 92%, var(--accent-primary) 8%), var(--surface-secondary));
|
||||||
|
border-right: 1px solid var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-header {
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 82%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in oklab, var(--accent-primary) 16%, var(--surface-base));
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-icon {
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-subtitle {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.875rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button:hover {
|
||||||
|
background: color-mix(in oklab, var(--surface-base) 70%, transparent);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button[data-selected="true"] {
|
||||||
|
background: color-mix(in oklab, var(--accent-primary) 14%, var(--surface-base));
|
||||||
|
border-color: color-mix(in oklab, var(--accent-primary) 26%, var(--border-base));
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, color-mix(in oklab, var(--accent-primary) 9%, transparent), transparent 28%),
|
||||||
|
var(--surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-base);
|
||||||
|
background: color-mix(in oklab, var(--surface-base) 92%, var(--surface-secondary) 8%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content-header-title-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content-eyebrow {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content-title {
|
||||||
|
font-size: clamp(1.35rem, 2vw, 1.85rem);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section-stack,
|
||||||
|
.settings-panel-body,
|
||||||
|
.settings-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 1.25rem;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in oklab, var(--surface-base) 86%, var(--surface-secondary) 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-padless {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-content,
|
||||||
|
.settings-card-header-padded {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-content {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--border-base) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-heading-with-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-heading-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-title {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-subtitle {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-message {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed var(--border-base);
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-content {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--surface-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-help-text {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-password-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-group {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-pill-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--surface-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 140ms ease, border-color 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-pill-button:hover {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-pill-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-error-message {
|
||||||
|
margin-top: 0.625rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-critical, #e65c5c);
|
||||||
|
background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent);
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-scope-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0.6rem;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in oklab, var(--surface-secondary) 75%, var(--surface-base));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-scope-badge-server {
|
||||||
|
background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base));
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.95rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--surface-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 140ms ease, background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice:hover {
|
||||||
|
border-color: color-mix(in oklab, var(--accent-primary) 28%, var(--border-base));
|
||||||
|
background: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice:focus-visible {
|
||||||
|
outline: 2px solid var(--accent-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice[data-selected="true"] {
|
||||||
|
border-color: color-mix(in oklab, var(--accent-primary) 45%, var(--border-base));
|
||||||
|
background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base));
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--accent-primary) 20%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-icon-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
background: color-mix(in oklab, var(--surface-secondary) 76%, var(--surface-base));
|
||||||
|
color: var(--accent-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-label {
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-description {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-check {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice[data-selected="true"] .settings-choice-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.9rem 0;
|
||||||
|
border-top: 1px solid color-mix(in oklab, var(--border-base) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row:first-child {
|
||||||
|
border-top: none;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-row-compact {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-title {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toggle-caption,
|
||||||
|
.settings-inline-note {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-checkbox-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-checkbox-toggle input {
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.modal-surface.settings-screen-shell {
|
||||||
|
min-height: min(760px, calc(100vh - 1rem));
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-nav-list {
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-nav-button {
|
||||||
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.settings-screen-frame {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-surface.settings-screen-shell {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: none;
|
||||||
|
min-height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-screen-content-header,
|
||||||
|
.settings-screen-scroll {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card-header,
|
||||||
|
.settings-toggle-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-toolbar-inline {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-choice-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@import "./components/buttons.css";
|
@import "./components/buttons.css";
|
||||||
@import "./components/badges.css";
|
@import "./components/badges.css";
|
||||||
|
@import "./components/folder-drop.css";
|
||||||
@import "./components/folder-loading.css";
|
@import "./components/folder-loading.css";
|
||||||
@import "./components/dropdown.css";
|
@import "./components/dropdown.css";
|
||||||
@import "./components/selector.css";
|
@import "./components/selector.css";
|
||||||
@@ -7,3 +8,4 @@
|
|||||||
@import "./components/directory-browser.css";
|
@import "./components/directory-browser.css";
|
||||||
@import "./components/remote-access.css";
|
@import "./components/remote-access.css";
|
||||||
@import "./components/permission-notification.css";
|
@import "./components/permission-notification.css";
|
||||||
|
@import "./components/settings-screen.css";
|
||||||
|
|||||||
@@ -170,6 +170,41 @@
|
|||||||
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button {
|
||||||
|
@apply h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
background-color: color-mix(in oklab, var(--surface-secondary) 82%, var(--surface-base));
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-secondary));
|
||||||
|
@apply scale-105;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:active:not(:disabled) {
|
||||||
|
@apply scale-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button.is-recording {
|
||||||
|
min-width: 3.5rem;
|
||||||
|
background-color: color-mix(in oklab, var(--button-danger-bg, rgba(239, 68, 68, 0.85)) 88%, white 12%);
|
||||||
|
color: var(--button-danger-text, var(--text-inverted, #ffffff));
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-button:disabled {
|
||||||
|
@apply opacity-50 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-voice-timer {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
.stop-button:hover:not(:disabled) {
|
.stop-button:hover:not(:disabled) {
|
||||||
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
background-color: var(--button-danger-hover-bg, rgba(239, 68, 68, 0.9));
|
||||||
@apply opacity-95 scale-105;
|
@apply opacity-95 scale-105;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
/* Surface tokens */
|
/* Surface tokens */
|
||||||
--surface-base: #ffffff;
|
--surface-base: #ffffff;
|
||||||
|
--surface-primary: var(--surface-base);
|
||||||
--surface-secondary: #f5f5f5;
|
--surface-secondary: #f5f5f5;
|
||||||
--surface-muted: #f8fafc;
|
--surface-muted: #f8fafc;
|
||||||
--surface-code: #f1f5f9;
|
--surface-code: #f1f5f9;
|
||||||
@@ -178,6 +179,7 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
/* Surface tokens */
|
/* Surface tokens */
|
||||||
--surface-base: #1a1a1a;
|
--surface-base: #1a1a1a;
|
||||||
|
--surface-primary: var(--surface-base);
|
||||||
--surface-secondary: #2a2a2a;
|
--surface-secondary: #2a2a2a;
|
||||||
--surface-muted: #212529;
|
--surface-muted: #212529;
|
||||||
--surface-code: #1a1a1a;
|
--surface-code: #1a1a1a;
|
||||||
@@ -347,6 +349,7 @@
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
/* Surface tokens */
|
/* Surface tokens */
|
||||||
--surface-base: #1a1a1a;
|
--surface-base: #1a1a1a;
|
||||||
|
--surface-primary: var(--surface-base);
|
||||||
--surface-secondary: #2a2a2a;
|
--surface-secondary: #2a2a2a;
|
||||||
--surface-muted: #212529;
|
--surface-muted: #212529;
|
||||||
--surface-code: #1a1a1a;
|
--surface-code: #1a1a1a;
|
||||||
|
|||||||
18
packages/ui/src/types/global.d.ts
vendored
18
packages/ui/src/types/global.d.ts
vendored
@@ -27,11 +27,26 @@ declare global {
|
|||||||
getCliStatus?: () => Promise<unknown>
|
getCliStatus?: () => Promise<unknown>
|
||||||
restartCli?: () => Promise<unknown>
|
restartCli?: () => Promise<unknown>
|
||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
|
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||||
|
getPathForFile?: (file: File) => string | null
|
||||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface File {
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemEntry {
|
||||||
|
isDirectory: boolean
|
||||||
|
isFile: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTransferItem {
|
||||||
|
webkitGetAsEntry?: () => FileSystemEntry | null
|
||||||
|
}
|
||||||
|
|
||||||
interface TauriDialogModule {
|
interface TauriDialogModule {
|
||||||
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||||
save?: (options: Record<string, unknown>) => Promise<string | null>
|
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||||
@@ -40,6 +55,9 @@ declare global {
|
|||||||
interface TauriBridge {
|
interface TauriBridge {
|
||||||
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
dialog?: TauriDialogModule
|
dialog?: TauriDialogModule
|
||||||
|
event?: {
|
||||||
|
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
Reference in New Issue
Block a user