Compare commits
30 Commits
v0.2.1-dev
...
v0.2.4-dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9313b2bd6c | ||
|
|
d25cb09714 | ||
|
|
0d0d1271c3 | ||
|
|
1fd3b2e75c | ||
|
|
bf32fcf136 | ||
|
|
48eb6b8982 | ||
|
|
797fafe854 | ||
|
|
b342660ed0 | ||
|
|
169d5ddeb9 | ||
|
|
38642b60e9 | ||
|
|
01effb8924 | ||
|
|
b434bfd3e9 | ||
|
|
ed769911d6 | ||
|
|
dd6efee900 | ||
|
|
48a16a6702 | ||
|
|
841b9daa1f | ||
|
|
1741e49568 | ||
|
|
8577b3d1e6 | ||
|
|
011533b3c4 | ||
|
|
002efad9ad | ||
|
|
3ce5569b82 | ||
|
|
d7c0c225b9 | ||
|
|
f4de0103a8 | ||
|
|
0a9b7fafed | ||
|
|
073604c9f5 | ||
|
|
4062b43380 | ||
|
|
00bd9f9c1c | ||
|
|
3edb0ac09e | ||
|
|
e9f3c4ee52 | ||
|
|
92420d9e02 |
3
.github/workflows/build-and-upload.yml
vendored
3
.github/workflows/build-and-upload.yml
vendored
@@ -337,9 +337,6 @@ jobs:
|
|||||||
- name: Ensure rollup native binary
|
- name: Ensure rollup native binary
|
||||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||||
|
|
||||||
- name: Bundle server assets for Tauri
|
|
||||||
run: npm run bundle:server --workspace @codenomad/tauri-app
|
|
||||||
|
|
||||||
- name: Build Linux bundle (Tauri)
|
- name: Build Linux bundle (Tauri)
|
||||||
run: npm run build --workspace @codenomad/tauri-app
|
run: npm run build --workspace @codenomad/tauri-app
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ _Manage multiple OpenCode sessions side-by-side._
|
|||||||

|

|
||||||
_Global command palette for keyboard-first control._
|
_Global command palette for keyboard-first control._
|
||||||
|
|
||||||

|

|
||||||
_Rich media previews for images and assets._
|
_Rich media previews for images and assets._
|
||||||
|
|
||||||

|

|
||||||
_Browser support via CodeNomad Server._
|
_Browser support via CodeNomad Server._
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 845 KiB After Width: | Height: | Size: 845 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -8613,7 +8613,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -8641,7 +8641,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -8680,14 +8680,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const uiRoot = resolve(__dirname, "../ui")
|
|||||||
const uiSrc = resolve(uiRoot, "src")
|
const uiSrc = resolve(uiRoot, "src")
|
||||||
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
const uiRendererRoot = resolve(uiRoot, "src/renderer")
|
||||||
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
const uiRendererEntry = resolve(uiRendererRoot, "index.html")
|
||||||
|
const uiRendererLoadingEntry = resolve(uiRendererRoot, "loading.html")
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
@@ -54,7 +55,10 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: resolve(__dirname, "dist/renderer"),
|
outDir: resolve(__dirname, "dist/renderer"),
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: uiRendererEntry,
|
input: {
|
||||||
|
main: uiRendererEntry,
|
||||||
|
loading: uiRendererLoadingEntry,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { BrowserWindow, ipcMain } from "electron"
|
import { BrowserWindow, dialog, ipcMain, type OpenDialogOptions } from "electron"
|
||||||
import type { CliLogEntry, CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
|
interface DialogOpenRequest {
|
||||||
|
mode: "directory" | "file"
|
||||||
|
title?: string
|
||||||
|
defaultPath?: string
|
||||||
|
filters?: Array<{ name?: string; extensions: string[] }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogOpenResult {
|
||||||
|
canceled: boolean
|
||||||
|
paths: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) {
|
||||||
cliManager.on("status", (status: CliStatus) => {
|
cliManager.on("status", (status: CliStatus) => {
|
||||||
@@ -14,12 +26,6 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
cliManager.on("log", (entry: CliLogEntry) => {
|
|
||||||
if (!mainWindow.isDestroyed()) {
|
|
||||||
mainWindow.webContents.send("cli:log", entry)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
cliManager.on("error", (error: Error) => {
|
cliManager.on("error", (error: Error) => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send("cli:error", { message: error.message })
|
mainWindow.webContents.send("cli:error", { message: error.message })
|
||||||
@@ -27,4 +33,27 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
ipcMain.handle("cli:getStatus", async () => cliManager.getStatus())
|
||||||
|
|
||||||
|
ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise<DialogOpenResult> => {
|
||||||
|
const properties: OpenDialogOptions["properties"] =
|
||||||
|
request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"]
|
||||||
|
|
||||||
|
const filters = request.filters?.map((filter) => ({
|
||||||
|
name: filter.name ?? "Files",
|
||||||
|
extensions: filter.extensions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const windowTarget = mainWindow.isDestroyed() ? undefined : mainWindow
|
||||||
|
const dialogOptions: OpenDialogOptions = {
|
||||||
|
title: request.title,
|
||||||
|
defaultPath: request.defaultPath,
|
||||||
|
properties,
|
||||||
|
filters,
|
||||||
|
}
|
||||||
|
const result = windowTarget
|
||||||
|
? await dialog.showOpenDialog(windowTarget, dialogOptions)
|
||||||
|
: await dialog.showOpenDialog(dialogOptions)
|
||||||
|
|
||||||
|
return { canceled: result.canceled, paths: result.filePaths }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,22 +30,63 @@ function getIconPath() {
|
|||||||
return join(mainDirname, "../resources/icon.png")
|
return join(mainDirname, "../resources/icon.png")
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLoadingHtmlPath() {
|
type LoadingTarget =
|
||||||
|
| { type: "url"; source: string }
|
||||||
|
| { type: "file"; source: string }
|
||||||
|
|
||||||
|
function resolveDevLoadingUrl(): string | null {
|
||||||
if (app.isPackaged) {
|
if (app.isPackaged) {
|
||||||
return join(process.resourcesPath, "loading.html")
|
return null
|
||||||
|
}
|
||||||
|
const devBase = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL
|
||||||
|
if (!devBase) {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const distResources = join(mainDirname, "../resources/loading.html")
|
try {
|
||||||
if (existsSync(distResources)) {
|
const normalized = devBase.endsWith("/") ? devBase : `${devBase}/`
|
||||||
return distResources
|
return new URL("loading.html", normalized).toString()
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cli] failed to construct dev loading URL", devBase, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoadingTarget(): LoadingTarget {
|
||||||
|
const devUrl = resolveDevLoadingUrl()
|
||||||
|
if (devUrl) {
|
||||||
|
return { type: "url", source: devUrl }
|
||||||
|
}
|
||||||
|
const filePath = resolveLoadingFilePath()
|
||||||
|
return { type: "file", source: filePath }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLoadingFilePath() {
|
||||||
|
const candidates = [
|
||||||
|
join(app.getAppPath(), "dist/renderer/loading.html"),
|
||||||
|
join(process.resourcesPath, "dist/renderer/loading.html"),
|
||||||
|
join(mainDirname, "../dist/renderer/loading.html"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const devResources = join(mainDirname, "../electron/resources/loading.html")
|
return join(app.getAppPath(), "dist/renderer/loading.html")
|
||||||
if (existsSync(devResources)) {
|
}
|
||||||
return devResources
|
|
||||||
}
|
|
||||||
|
|
||||||
return join(process.cwd(), "electron/resources/loading.html")
|
function loadLoadingScreen(window: BrowserWindow) {
|
||||||
|
const target = resolveLoadingTarget()
|
||||||
|
const loader =
|
||||||
|
target.type === "url"
|
||||||
|
? window.loadURL(target.source)
|
||||||
|
: window.loadFile(target.source)
|
||||||
|
|
||||||
|
loader.catch((error) => {
|
||||||
|
console.error("[cli] failed to load loading screen:", error)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedPreloadPath: string | null = null
|
let cachedPreloadPath: string | null = null
|
||||||
@@ -116,10 +157,9 @@ function createWindow() {
|
|||||||
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
mainWindow.webContents.session.setSpellCheckerEnabled(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingHtml = getLoadingHtmlPath()
|
|
||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
loadLoadingScreen(mainWindow)
|
||||||
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === "development") {
|
||||||
mainWindow.webContents.openDevTools({ mode: "detach" })
|
mainWindow.webContents.openDevTools({ mode: "detach" })
|
||||||
@@ -156,8 +196,7 @@ function showLoadingScreen(force = false) {
|
|||||||
showingLoadingScreen = true
|
showingLoadingScreen = true
|
||||||
currentCliUrl = null
|
currentCliUrl = null
|
||||||
pendingCliUrl = null
|
pendingCliUrl = null
|
||||||
const loadingHtml = getLoadingHtmlPath()
|
loadLoadingScreen(mainWindow)
|
||||||
mainWindow.loadFile(loadingHtml).catch((error) => console.error("[cli] failed to load loading screen:", error))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCliPreload(url: string) {
|
function startCliPreload(url: string) {
|
||||||
|
|||||||
@@ -263,45 +263,30 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
private resolveCliEntry(options: StartOptions): CliEntryResolution {
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
const tsxPath = this.resolveTsx()
|
const tsxPath = this.resolveTsx()
|
||||||
const sourceCandidates = [
|
if (!tsxPath) {
|
||||||
path.resolve(app.getAppPath(), "..", "server", "src", "index.ts"),
|
throw new Error("tsx is required to run the CLI in development mode. Please install dependencies.")
|
||||||
path.resolve(app.getAppPath(), "..", "packages", "server", "src", "index.ts"),
|
|
||||||
path.resolve(process.cwd(), "packages", "server", "src", "index.ts"),
|
|
||||||
]
|
|
||||||
const sourceEntry = sourceCandidates.find((candidate) => existsSync(candidate))
|
|
||||||
if (tsxPath && sourceEntry) {
|
|
||||||
return { entry: sourceEntry, runner: "tsx", runnerPath: tsxPath }
|
|
||||||
}
|
}
|
||||||
|
const devEntry = this.resolveDevEntry()
|
||||||
|
return { entry: devEntry, runner: "tsx", runnerPath: tsxPath }
|
||||||
}
|
}
|
||||||
|
|
||||||
const dist = this.tryResolveDist()
|
const distEntry = this.resolveProdEntry()
|
||||||
if (dist) {
|
return { entry: distEntry, runner: "node" }
|
||||||
return { entry: dist, runner: "node" }
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Please build @neuralnomads/codenomad.")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTsx(): string | null {
|
private resolveTsx(): string | null {
|
||||||
try {
|
|
||||||
const resolved = nodeRequire.resolve("tsx/dist/cli.js")
|
|
||||||
if (resolved && existsSync(resolved)) {
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private tryResolveDist(): string | null {
|
|
||||||
const candidates: Array<string | (() => string)> = [
|
const candidates: Array<string | (() => string)> = [
|
||||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js"),
|
() => nodeRequire.resolve("tsx/cli"),
|
||||||
() => nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js", { paths: [app.getAppPath()] }),
|
() => nodeRequire.resolve("tsx/dist/cli.mjs"),
|
||||||
path.join(app.getAppPath(), "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
() => nodeRequire.resolve("tsx/dist/cli.cjs"),
|
||||||
path.resolve(app.getAppPath(), "..", "server", "dist", "bin.js"),
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
path.resolve(app.getAppPath(), "..", "packages", "server", "dist", "bin.js"),
|
path.resolve(process.cwd(), "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
path.join(process.resourcesPath, "app.asar.unpacked", "node_modules", "@neuralnomads", "codenomad", "dist", "bin.js"),
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(process.cwd(), "..", "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.mjs"),
|
||||||
|
path.resolve(app.getAppPath(), "..", "node_modules", "tsx", "dist", "cli.cjs"),
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
@@ -317,4 +302,25 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveDevEntry(): string {
|
||||||
|
const entry = path.resolve(process.cwd(), "..", "server", "src", "index.ts")
|
||||||
|
if (!existsSync(entry)) {
|
||||||
|
throw new Error(`Dev CLI entry not found at ${entry}. Run npm run dev:electron from the repository root after installing dependencies.`)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveProdEntry(): string {
|
||||||
|
try {
|
||||||
|
const entry = nodeRequire.resolve("@neuralnomads/codenomad/dist/bin.js")
|
||||||
|
if (existsSync(entry)) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to error below
|
||||||
|
}
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,12 @@ const electronAPI = {
|
|||||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||||
},
|
},
|
||||||
onCliLog: (callback) => {
|
|
||||||
ipcRenderer.on("cli:log", (_, data) => callback(data))
|
|
||||||
return () => ipcRenderer.removeAllListeners("cli:log")
|
|
||||||
},
|
|
||||||
onCliError: (callback) => {
|
onCliError: (callback) => {
|
||||||
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
ipcRenderer.on("cli:error", (_, data) => callback(data))
|
||||||
return () => ipcRenderer.removeAllListeners("cli:error")
|
return () => ipcRenderer.removeAllListeners("cli:error")
|
||||||
},
|
},
|
||||||
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
getCliStatus: () => ipcRenderer.invoke("cli:getStatus"),
|
||||||
|
openDialog: (options) => ipcRenderer.invoke("dialog:open", options),
|
||||||
}
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>CodeNomad</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: #cfd4dc;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 180px;
|
|
||||||
height: auto;
|
|
||||||
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 2.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #f4f6fb;
|
|
||||||
}
|
|
||||||
.loading-card {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
padding: 22px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #151a23;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #cfd4dc;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-top-color: #6ce3ff;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
.phrase-controls {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #8f96a9;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.phrase-controls button {
|
|
||||||
color: #8fb5ff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
width: 180px;
|
|
||||||
height: auto;
|
|
||||||
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.45));
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 2.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #f4f6fb;
|
|
||||||
}
|
|
||||||
.loading-card {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 22px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #0f1421;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: 0 25px 60px rgba(5, 6, 10, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 180px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 2.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.subtitle {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #aeb3c4;
|
|
||||||
}
|
|
||||||
.loading-card {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 100%;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(13, 16, 24, 0.8);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #cad0dd;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-top-color: #6ce3ff;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrapper" role="status" aria-live="polite">
|
|
||||||
<img src="./icon.png" alt="CodeNomad" class="logo" />
|
|
||||||
<div>
|
|
||||||
<h1 class="title">CodeNomad</h1>
|
|
||||||
</div>
|
|
||||||
<div class="loading-card">
|
|
||||||
<div class="loading-row">
|
|
||||||
<div class="spinner" aria-hidden="true"></div>
|
|
||||||
<span id="loading-phrase">Warming up the AI neurons…</span>
|
|
||||||
</div>
|
|
||||||
<div class="phrase-controls">
|
|
||||||
<button id="phrase-toggle" type="button">Show another</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const phrases = [
|
|
||||||
"Warming up the AI neurons…",
|
|
||||||
"Convincing the AI to stop daydreaming…",
|
|
||||||
"Polishing the AI’s code goggles…",
|
|
||||||
"Asking the AI to stop reorganizing your files…",
|
|
||||||
"Feeding the AI additional coffee…",
|
|
||||||
"Teaching the AI not to delete node_modules (again)…",
|
|
||||||
"Telling the AI to act natural before you arrive…",
|
|
||||||
"Asking the AI to please stop rewriting history…",
|
|
||||||
"Letting the AI stretch before its coding sprint…",
|
|
||||||
"Persuading the AI to give you keyboard control…"
|
|
||||||
]
|
|
||||||
|
|
||||||
const phraseEl = document.getElementById("loading-phrase")
|
|
||||||
const button = document.getElementById("phrase-toggle")
|
|
||||||
|
|
||||||
function pickPhrase() {
|
|
||||||
const next = phrases[Math.floor(Math.random() * phrases.length)]
|
|
||||||
phraseEl.textContent = next
|
|
||||||
}
|
|
||||||
|
|
||||||
pickPhrase()
|
|
||||||
button?.addEventListener("click", pickPhrase)
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ export interface InstanceData {
|
|||||||
agentModelSelections: AgentModelSelection
|
agentModelSelections: AgentModelSelection
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type InstanceStreamStatus = "connecting" | "connected" | "error" | "disconnected"
|
||||||
|
|
||||||
|
export interface InstanceStreamEvent {
|
||||||
|
type: string
|
||||||
|
properties?: Record<string, unknown>
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export interface BinaryRecord {
|
export interface BinaryRecord {
|
||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
@@ -157,6 +165,8 @@ export type WorkspaceEventType =
|
|||||||
| "config.appChanged"
|
| "config.appChanged"
|
||||||
| "config.binariesChanged"
|
| "config.binariesChanged"
|
||||||
| "instance.dataChanged"
|
| "instance.dataChanged"
|
||||||
|
| "instance.event"
|
||||||
|
| "instance.eventStatus"
|
||||||
|
|
||||||
export type WorkspaceEventPayload =
|
export type WorkspaceEventPayload =
|
||||||
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
| { type: "workspace.created"; workspace: WorkspaceDescriptor }
|
||||||
@@ -167,6 +177,8 @@ export type WorkspaceEventPayload =
|
|||||||
| { type: "config.appChanged"; config: AppConfig }
|
| { type: "config.appChanged"; config: AppConfig }
|
||||||
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
| { type: "config.binariesChanged"; binaries: BinaryRecord[] }
|
||||||
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
| { type: "instance.dataChanged"; instanceId: string; data: InstanceData }
|
||||||
|
| { type: "instance.event"; instanceId: string; event: InstanceStreamEvent }
|
||||||
|
| { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string }
|
||||||
|
|
||||||
export interface ServerMeta {
|
export interface ServerMeta {
|
||||||
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
/** Base URL clients should target for REST calls (useful for Electron embedding). */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const PreferencesSchema = z.object({
|
|||||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||||
|
showUsageMetrics: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
const RecentFolderSchema = z.object({
|
const RecentFolderSchema = z.object({
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export class EventBus extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
publish(event: WorkspaceEventPayload): boolean {
|
publish(event: WorkspaceEventPayload): boolean {
|
||||||
this.logger?.debug({ event }, "Publishing workspace event")
|
if (event.type !== "instance.event" && event.type !== "instance.eventStatus") {
|
||||||
|
this.logger?.debug({ event }, "Publishing workspace event")
|
||||||
|
}
|
||||||
return super.emit(event.type, event)
|
return super.emit(event.type, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +24,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.on("config.appChanged", handler)
|
this.on("config.appChanged", handler)
|
||||||
this.on("config.binariesChanged", handler)
|
this.on("config.binariesChanged", handler)
|
||||||
this.on("instance.dataChanged", handler)
|
this.on("instance.dataChanged", handler)
|
||||||
|
this.on("instance.event", handler)
|
||||||
|
this.on("instance.eventStatus", handler)
|
||||||
return () => {
|
return () => {
|
||||||
this.off("workspace.created", handler)
|
this.off("workspace.created", handler)
|
||||||
this.off("workspace.started", handler)
|
this.off("workspace.started", handler)
|
||||||
@@ -31,6 +35,8 @@ export class EventBus extends EventEmitter {
|
|||||||
this.off("config.appChanged", handler)
|
this.off("config.appChanged", handler)
|
||||||
this.off("config.binariesChanged", handler)
|
this.off("config.binariesChanged", handler)
|
||||||
this.off("instance.dataChanged", handler)
|
this.off("instance.dataChanged", handler)
|
||||||
|
this.off("instance.event", handler)
|
||||||
|
this.off("instance.eventStatus", handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import { FileSystemBrowser } from "./filesystem/browser"
|
|||||||
import { EventBus } from "./events/bus"
|
import { EventBus } from "./events/bus"
|
||||||
import { ServerMeta } from "./api-types"
|
import { ServerMeta } from "./api-types"
|
||||||
import { InstanceStore } from "./storage/instance-store"
|
import { InstanceStore } from "./storage/instance-store"
|
||||||
|
import { InstanceEventBridge } from "./workspaces/instance-events"
|
||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
|
|
||||||
const require = createRequire(import.meta.url)
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
const packageJson = require("../package.json") as { version: string }
|
const packageJson = require("../package.json") as { version: string }
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
const __dirname = path.dirname(__filename)
|
||||||
@@ -78,9 +80,11 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
|
|
||||||
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
|
||||||
|
|
||||||
|
const normalizedHost = resolveHost(parsed.host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: parsed.port,
|
port: parsed.port,
|
||||||
host: parsed.host,
|
host: normalizedHost,
|
||||||
rootDir: resolvedRoot,
|
rootDir: resolvedRoot,
|
||||||
configPath: parsed.config,
|
configPath: parsed.config,
|
||||||
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
|
||||||
@@ -100,6 +104,13 @@ function parsePort(input: string): number {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveHost(input: string | undefined): string {
|
||||||
|
if (input && input.trim() === "0.0.0.0") {
|
||||||
|
return "0.0.0.0"
|
||||||
|
}
|
||||||
|
return DEFAULT_HOST
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const options = parseCliOptions(process.argv.slice(2))
|
const options = parseCliOptions(process.argv.slice(2))
|
||||||
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
const logger = createLogger({ level: options.logLevel, destination: options.logDestination, component: "app" })
|
||||||
@@ -121,6 +132,11 @@ 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()
|
const instanceStore = new InstanceStore()
|
||||||
|
const instanceEventBridge = new InstanceEventBridge({
|
||||||
|
workspaceManager,
|
||||||
|
eventBus,
|
||||||
|
logger: logger.child({ component: "instance-events" }),
|
||||||
|
})
|
||||||
|
|
||||||
const serverMeta: ServerMeta = {
|
const serverMeta: ServerMeta = {
|
||||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||||
@@ -169,6 +185,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
instanceEventBridge.shutdown()
|
||||||
await workspaceManager.shutdown()
|
await workspaceManager.shutdown()
|
||||||
logger.info("Workspace manager shutdown complete")
|
logger.info("Workspace manager shutdown complete")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
|
|
||||||
app.register(replyFrom, {
|
app.register(replyFrom, {
|
||||||
contentTypesToEncode: [],
|
contentTypesToEncode: [],
|
||||||
|
undici: {
|
||||||
|
connections: 16,
|
||||||
|
pipelining: 1,
|
||||||
|
bodyTimeout: 0,
|
||||||
|
headersTimeout: 0,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager })
|
||||||
|
|||||||
190
packages/server/src/workspaces/instance-events.ts
Normal file
190
packages/server/src/workspaces/instance-events.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
import { Agent as UndiciAgent } from "undici"
|
||||||
|
import { EventBus } from "../events/bus"
|
||||||
|
import { Logger } from "../logger"
|
||||||
|
import { WorkspaceManager } from "./manager"
|
||||||
|
import { InstanceStreamEvent, InstanceStreamStatus } from "../api-types"
|
||||||
|
|
||||||
|
const INSTANCE_HOST = "127.0.0.1"
|
||||||
|
const STREAM_AGENT = new UndiciAgent({ bodyTimeout: 0, headersTimeout: 0 })
|
||||||
|
const RECONNECT_DELAY_MS = 1000
|
||||||
|
|
||||||
|
interface InstanceEventBridgeOptions {
|
||||||
|
workspaceManager: WorkspaceManager
|
||||||
|
eventBus: EventBus
|
||||||
|
logger: Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActiveStream {
|
||||||
|
controller: AbortController
|
||||||
|
task: Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InstanceEventBridge {
|
||||||
|
private readonly streams = new Map<string, ActiveStream>()
|
||||||
|
|
||||||
|
constructor(private readonly options: InstanceEventBridgeOptions) {
|
||||||
|
const bus = this.options.eventBus
|
||||||
|
bus.on("workspace.started", (event) => this.startStream(event.workspace.id))
|
||||||
|
bus.on("workspace.stopped", (event) => this.stopStream(event.workspaceId, "workspace stopped"))
|
||||||
|
bus.on("workspace.error", (event) => this.stopStream(event.workspace.id, "workspace error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
for (const [id, active] of this.streams) {
|
||||||
|
active.controller.abort()
|
||||||
|
this.publishStatus(id, "disconnected")
|
||||||
|
}
|
||||||
|
this.streams.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private startStream(workspaceId: string) {
|
||||||
|
if (this.streams.has(workspaceId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const task = this.runStream(workspaceId, controller.signal)
|
||||||
|
.catch((error) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream failed")
|
||||||
|
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const active = this.streams.get(workspaceId)
|
||||||
|
if (active?.controller === controller) {
|
||||||
|
this.streams.delete(workspaceId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.streams.set(workspaceId, { controller, task })
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopStream(workspaceId: string, reason?: string) {
|
||||||
|
const active = this.streams.get(workspaceId)
|
||||||
|
if (!active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
active.controller.abort()
|
||||||
|
this.streams.delete(workspaceId)
|
||||||
|
this.publishStatus(workspaceId, "disconnected", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runStream(workspaceId: string, signal: AbortSignal) {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const port = this.options.workspaceManager.getInstancePort(workspaceId)
|
||||||
|
if (!port) {
|
||||||
|
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus(workspaceId, "connecting")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.consumeStream(workspaceId, port, signal)
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
this.options.logger.warn({ workspaceId, err: error }, "Instance event stream disconnected")
|
||||||
|
this.publishStatus(workspaceId, "error", error instanceof Error ? error.message : String(error))
|
||||||
|
await this.delay(RECONNECT_DELAY_MS, signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeStream(workspaceId: string, port: number, signal: AbortSignal) {
|
||||||
|
const url = `http://${INSTANCE_HOST}:${port}/event`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Accept: "text/event-stream" },
|
||||||
|
signal,
|
||||||
|
dispatcher: STREAM_AGENT,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`Instance event stream unavailable (${response.status})`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.publishStatus(workspaceId, "connected")
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ""
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done || !value) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true })
|
||||||
|
buffer = this.flushEvents(buffer, workspaceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushEvents(buffer: string, workspaceId: string) {
|
||||||
|
let separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
|
||||||
|
while (separatorIndex >= 0) {
|
||||||
|
const chunk = buffer.slice(0, separatorIndex)
|
||||||
|
buffer = buffer.slice(separatorIndex + 2)
|
||||||
|
this.processChunk(chunk, workspaceId)
|
||||||
|
separatorIndex = buffer.indexOf("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
private processChunk(chunk: string, workspaceId: string) {
|
||||||
|
const lines = chunk.split(/\r?\n/)
|
||||||
|
const dataLines: string[] = []
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith(":")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line.startsWith("data:")) {
|
||||||
|
dataLines.push(line.slice(5).trimStart())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataLines.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = dataLines.join("\n").trim()
|
||||||
|
if (!payload) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(payload) as InstanceStreamEvent
|
||||||
|
this.options.eventBus.publish({ type: "instance.event", instanceId: workspaceId, event })
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ workspaceId, chunk: payload, err: error }, "Failed to parse instance SSE payload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private publishStatus(instanceId: string, status: InstanceStreamStatus, reason?: string) {
|
||||||
|
this.options.eventBus.publish({ type: "instance.eventStatus", instanceId, status, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(duration: number, signal: AbortSignal) {
|
||||||
|
if (duration <= 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
signal.removeEventListener("abort", onAbort)
|
||||||
|
resolve()
|
||||||
|
}, duration)
|
||||||
|
|
||||||
|
const onAbort = () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
import { ConfigStore } from "../config/store"
|
import { ConfigStore } from "../config/store"
|
||||||
import { BinaryRegistry } from "../config/binaries"
|
import { BinaryRegistry } from "../config/binaries"
|
||||||
@@ -65,10 +66,11 @@ export class WorkspaceManager {
|
|||||||
|
|
||||||
const id = `${Date.now().toString(36)}`
|
const id = `${Date.now().toString(36)}`
|
||||||
const binary = this.options.binaryRegistry.resolveDefault()
|
const binary = this.options.binaryRegistry.resolveDefault()
|
||||||
|
const resolvedBinaryPath = this.resolveBinaryPath(binary.path)
|
||||||
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
||||||
clearWorkspaceSearchCache(workspacePath)
|
clearWorkspaceSearchCache(workspacePath)
|
||||||
|
|
||||||
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace")
|
this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: resolvedBinaryPath }, "Creating workspace")
|
||||||
|
|
||||||
const proxyPath = `/workspaces/${id}/instance`
|
const proxyPath = `/workspaces/${id}/instance`
|
||||||
|
|
||||||
@@ -79,14 +81,20 @@ export class WorkspaceManager {
|
|||||||
name,
|
name,
|
||||||
status: "starting",
|
status: "starting",
|
||||||
proxyPath,
|
proxyPath,
|
||||||
binaryId: binary.id,
|
binaryId: resolvedBinaryPath,
|
||||||
binaryLabel: binary.label,
|
binaryLabel: binary.label,
|
||||||
binaryVersion: binary.version,
|
binaryVersion: binary.version,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!descriptor.binaryVersion) {
|
||||||
|
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
this.workspaces.set(id, descriptor)
|
this.workspaces.set(id, descriptor)
|
||||||
|
|
||||||
|
|
||||||
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor })
|
||||||
|
|
||||||
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
const environment = this.options.configStore.get().preferences.environmentVariables ?? {}
|
||||||
@@ -95,7 +103,7 @@ export class WorkspaceManager {
|
|||||||
const { pid, port } = await this.runtime.launch({
|
const { pid, port } = await this.runtime.launch({
|
||||||
workspaceId: id,
|
workspaceId: id,
|
||||||
folder: workspacePath,
|
folder: workspacePath,
|
||||||
binaryPath: binary.path,
|
binaryPath: resolvedBinaryPath,
|
||||||
environment,
|
environment,
|
||||||
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
onExit: (info) => this.handleProcessExit(info.workspaceId, info),
|
||||||
})
|
})
|
||||||
@@ -161,6 +169,70 @@ export class WorkspaceManager {
|
|||||||
return workspace
|
return workspace
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveBinaryPath(identifier: string): string {
|
||||||
|
if (!identifier) {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikePath = identifier.includes("/") || identifier.includes("\\") || identifier.startsWith(".")
|
||||||
|
if (path.isAbsolute(identifier) || looksLikePath) {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
const locator = process.platform === "win32" ? "where" : "which"
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const resolved = result.stdout
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line.length > 0)
|
||||||
|
|
||||||
|
if (resolved) {
|
||||||
|
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
this.options.logger.warn({ identifier, err: result.error }, "Failed to resolve binary path via locator command")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ identifier, err: error }, "Failed to resolve binary path from system PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||||
|
if (!resolvedPath) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
|
||||||
|
if (result.status === 0 && result.stdout) {
|
||||||
|
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
|
||||||
|
if (line) {
|
||||||
|
const normalized = line.trim()
|
||||||
|
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||||
|
if (versionMatch) {
|
||||||
|
const version = versionMatch[1]
|
||||||
|
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
} else if (result.error) {
|
||||||
|
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) {
|
||||||
const workspace = this.workspaces.get(workspaceId)
|
const workspace = this.workspaces.get(workspaceId)
|
||||||
if (!workspace) return
|
if (!workspace) return
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export class WorkspaceRuntime {
|
|||||||
const env = { ...process.env, ...(options.environment ?? {}) }
|
const env = { ...process.env, ...(options.environment ?? {}) }
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.logger.info({ workspaceId: options.workspaceId, folder: options.folder }, "Launching OpenCode process")
|
this.logger.info(
|
||||||
|
{ workspaceId: options.workspaceId, folder: options.folder, binary: options.binaryPath },
|
||||||
|
"Launching OpenCode process",
|
||||||
|
)
|
||||||
const child = spawn(options.binaryPath, args, {
|
const child = spawn(options.binaryPath, args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
|
|||||||
539
packages/tauri-app/Cargo.lock
generated
539
packages/tauri-app/Cargo.lock
generated
@@ -47,6 +47,61 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ashpd"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"raw-window-handle",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
|
"url",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-protocols",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-broadcast"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"event-listener-strategy",
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-recursion"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "async-trait"
|
||||||
|
version = "0.1.89"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atk"
|
name = "atk"
|
||||||
version = "0.18.2"
|
version = "0.18.2"
|
||||||
@@ -325,6 +380,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
|
"tauri-plugin-dialog",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"which",
|
"which",
|
||||||
]
|
]
|
||||||
@@ -339,6 +395,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "concurrent-queue"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -577,6 +642,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"libc",
|
||||||
"objc2 0.6.3",
|
"objc2 0.6.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -591,6 +658,15 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dlib"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
|
||||||
|
dependencies = [
|
||||||
|
"libloading",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dlopen2"
|
name = "dlopen2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -614,6 +690,12 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "1.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dpi"
|
name = "dpi"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -676,6 +758,33 @@ version = "1.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "endi"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||||
|
dependencies = [
|
||||||
|
"enumflags2_derive",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "enumflags2_derive"
|
||||||
|
version = "0.7.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -703,6 +812,33 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener"
|
||||||
|
version = "5.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||||
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "event-listener-strategy"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||||
|
dependencies = [
|
||||||
|
"event-listener",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -822,6 +958,19 @@ version = "0.3.31"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-lite"
|
||||||
|
version = "2.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"futures-core",
|
||||||
|
"futures-io",
|
||||||
|
"parking",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -1656,6 +1805,12 @@ version = "0.4.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -1813,6 +1968,19 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.30.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nodrop"
|
name = "nodrop"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -2132,6 +2300,16 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ordered-stream"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pango"
|
name = "pango"
|
||||||
version = "0.18.3"
|
version = "0.18.3"
|
||||||
@@ -2157,6 +2335,12 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "parking"
|
||||||
|
version = "2.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -2346,7 +2530,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.12.1",
|
"indexmap 2.12.1",
|
||||||
"quick-xml",
|
"quick-xml 0.38.4",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -2462,6 +2646,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.37.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -2511,6 +2704,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_chacha"
|
name = "rand_chacha"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2531,6 +2734,16 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -2549,6 +2762,15 @@ dependencies = [
|
|||||||
"getrandom 0.2.16",
|
"getrandom 0.2.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_hc"
|
name = "rand_hc"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -2677,6 +2899,31 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfd"
|
||||||
|
version = "0.15.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
|
||||||
|
dependencies = [
|
||||||
|
"ashpd",
|
||||||
|
"block2 0.6.2",
|
||||||
|
"dispatch2",
|
||||||
|
"glib-sys",
|
||||||
|
"gobject-sys",
|
||||||
|
"gtk-sys",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"objc2 0.6.3",
|
||||||
|
"objc2-app-kit",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"raw-window-handle",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2695,10 +2942,23 @@ dependencies = [
|
|||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys 0.11.0",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustversion"
|
name = "rustversion"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@@ -2771,6 +3031,12 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "scoped-tls"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
@@ -2992,6 +3258,15 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -3086,6 +3361,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "static_assertions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3354,6 +3635,63 @@ dependencies = [
|
|||||||
"tauri-utils",
|
"tauri-utils",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin"
|
||||||
|
version = "2.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "076c78a474a7247c90cad0b6e87e593c4c620ed4efdb79cbe0214f0021f6c39d"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"glob",
|
||||||
|
"plist",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri-utils",
|
||||||
|
"toml 0.9.8",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-dialog"
|
||||||
|
version = "2.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "313f8138692ddc4a2127c4c9607d616a46f5c042e77b3722450866da0aad2f19"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"raw-window-handle",
|
||||||
|
"rfd",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-plugin-fs",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.4.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47df422695255ecbe7bac7012440eddaeefd026656171eac9559f5243d3230d9"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars 0.8.22",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.17",
|
||||||
|
"toml 0.9.8",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.1"
|
version = "2.9.1"
|
||||||
@@ -3455,6 +3793,19 @@ dependencies = [
|
|||||||
"toml 0.9.8",
|
"toml 0.9.8",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustix 1.1.2",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -3557,7 +3908,9 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3722,9 +4075,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-attributes"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-core"
|
name = "tracing-core"
|
||||||
version = "0.1.34"
|
version = "0.1.34"
|
||||||
@@ -3774,6 +4139,17 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uds_windows"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||||
|
dependencies = [
|
||||||
|
"memoffset",
|
||||||
|
"tempfile",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unic-char-property"
|
name = "unic-char-property"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -4018,6 +4394,66 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-backend"
|
||||||
|
version = "0.3.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"downcast-rs",
|
||||||
|
"rustix 1.1.2",
|
||||||
|
"scoped-tls",
|
||||||
|
"smallvec",
|
||||||
|
"wayland-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-client"
|
||||||
|
version = "0.31.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"rustix 1.1.2",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-scanner",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-protocols"
|
||||||
|
version = "0.32.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "efa790ed75fbfd71283bd2521a1cfdc022aabcc28bdcff00851f9e4ae88d9901"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"wayland-backend",
|
||||||
|
"wayland-client",
|
||||||
|
"wayland-scanner",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-scanner"
|
||||||
|
version = "0.31.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wayland-sys"
|
||||||
|
version = "0.31.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142"
|
||||||
|
dependencies = [
|
||||||
|
"dlib",
|
||||||
|
"log",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.82"
|
version = "0.3.82"
|
||||||
@@ -4117,7 +4553,7 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
"home",
|
"home",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix 0.38.44",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4674,6 +5110,62 @@ dependencies = [
|
|||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus"
|
||||||
|
version = "5.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
|
||||||
|
dependencies = [
|
||||||
|
"async-broadcast",
|
||||||
|
"async-recursion",
|
||||||
|
"async-trait",
|
||||||
|
"enumflags2",
|
||||||
|
"event-listener",
|
||||||
|
"futures-core",
|
||||||
|
"futures-lite",
|
||||||
|
"hex",
|
||||||
|
"nix",
|
||||||
|
"ordered-stream",
|
||||||
|
"serde",
|
||||||
|
"serde_repr",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"uds_windows",
|
||||||
|
"uuid",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winnow 0.7.13",
|
||||||
|
"zbus_macros",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_macros"
|
||||||
|
version = "5.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.4.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
"zbus_names",
|
||||||
|
"zvariant",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zbus_names"
|
||||||
|
version = "4.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7be68e64bf6ce8db94f63e72f0c7eb9a60d733f7e0499e628dfab0f84d6bcb97"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"static_assertions",
|
||||||
|
"winnow 0.7.13",
|
||||||
|
"zvariant",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.28"
|
version = "0.8.28"
|
||||||
@@ -4747,3 +5239,44 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant"
|
||||||
|
version = "5.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2be61892e4f2b1772727be11630a62664a1826b62efa43a6fe7449521cb8744c"
|
||||||
|
dependencies = [
|
||||||
|
"endi",
|
||||||
|
"enumflags2",
|
||||||
|
"serde",
|
||||||
|
"url",
|
||||||
|
"winnow 0.7.13",
|
||||||
|
"zvariant_derive",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_derive"
|
||||||
|
version = "5.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da58575a1b2b20766513b1ec59d8e2e68db2745379f961f86650655e862d2006"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro-crate 3.4.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.110",
|
||||||
|
"zvariant_utils",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zvariant_utils"
|
||||||
|
version = "3.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c6949d142f89f6916deca2232cf26a8afacf2b9fdc35ce766105e104478be599"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"serde",
|
||||||
|
"syn 2.0.110",
|
||||||
|
"winnow 0.7.13",
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "npx --yes @tauri-apps/cli@^2.9.4 dev",
|
||||||
|
"dev:ui": "npm run dev --workspace @codenomad/ui",
|
||||||
|
"dev:prep": "node ./scripts/dev-prep.js",
|
||||||
|
"dev:bootstrap": "npm run dev:prep && npm run dev:ui",
|
||||||
"prebuild": "node ./scripts/prebuild.js",
|
"prebuild": "node ./scripts/prebuild.js",
|
||||||
"bundle:server": "npm --workspace @neuralnomads/codenomad run build && npm run prebuild",
|
"bundle:server": "npm run prebuild",
|
||||||
"build": "tauri build"
|
"build": "npx --yes @tauri-apps/cli@^2.9.4 build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
|
|||||||
46
packages/tauri-app/scripts/dev-prep.js
Normal file
46
packages/tauri-app/scripts/dev-prep.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs")
|
||||||
|
const path = require("path")
|
||||||
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, "..")
|
||||||
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
|
const uiRoot = path.resolve(root, "..", "ui")
|
||||||
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
|
function ensureUiBuild() {
|
||||||
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
|
if (fs.existsSync(loadingHtml)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[dev-prep] UI loader build missing; running workspace build…")
|
||||||
|
execSync("npm --workspace @codenomad/ui run build", {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingHtml)) {
|
||||||
|
throw new Error("[dev-prep] failed to produce loading.html after UI build")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUiLoadingAssets() {
|
||||||
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
|
|
||||||
|
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||||
|
|
||||||
|
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||||
|
if (fs.existsSync(assetsSource)) {
|
||||||
|
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[dev-prep] copied loader bundle from ${uiDist}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureUiBuild()
|
||||||
|
copyUiLoadingAssets()
|
||||||
@@ -1,45 +1,140 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
const fs = require("fs");
|
const fs = require("fs")
|
||||||
const path = require("path");
|
const path = require("path")
|
||||||
const { execSync } = require("child_process");
|
const { execSync } = require("child_process")
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..")
|
||||||
const workspaceRoot = path.resolve(root, "..", "..");
|
const workspaceRoot = path.resolve(root, "..", "..")
|
||||||
const serverRoot = path.resolve(root, "..", "server");
|
const serverRoot = path.resolve(root, "..", "server")
|
||||||
const dest = path.resolve(root, "src-tauri", "resources", "server");
|
const uiRoot = path.resolve(root, "..", "ui")
|
||||||
|
const uiDist = path.resolve(uiRoot, "src", "renderer", "dist")
|
||||||
|
const serverDest = path.resolve(root, "src-tauri", "resources", "server")
|
||||||
|
const uiLoadingDest = path.resolve(root, "src-tauri", "resources", "ui-loading")
|
||||||
|
|
||||||
const sources = ["dist", "public", "node_modules", "package.json"];
|
const sources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
|
||||||
|
const serverInstallCommand =
|
||||||
|
"npm install --omit=dev --ignore-scripts --workspaces=false --package-lock=false --install-strategy=shallow --fund=false --audit=false"
|
||||||
|
const serverDevInstallCommand =
|
||||||
|
"npm ci --workspace @neuralnomads/codenomad --include-workspace-root=false --install-strategy=nested --fund=false --audit=false"
|
||||||
|
|
||||||
|
const envWithRootBin = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const braceExpansionPath = path.join(
|
||||||
|
serverRoot,
|
||||||
|
"node_modules",
|
||||||
|
"@fastify",
|
||||||
|
"static",
|
||||||
|
"node_modules",
|
||||||
|
"brace-expansion",
|
||||||
|
"package.json",
|
||||||
|
)
|
||||||
|
|
||||||
function ensureServerBuild() {
|
function ensureServerBuild() {
|
||||||
const distPath = path.join(serverRoot, "dist");
|
const distPath = path.join(serverRoot, "dist")
|
||||||
const publicPath = path.join(serverRoot, "public");
|
const publicPath = path.join(serverRoot, "public")
|
||||||
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
if (fs.existsSync(distPath) && fs.existsSync(publicPath)) {
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[prebuild] server build missing; running workspace build...");
|
console.log("[prebuild] server build missing; running workspace build...")
|
||||||
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
execSync("npm --workspace @neuralnomads/codenomad run build", {
|
||||||
cwd: workspaceRoot,
|
cwd: workspaceRoot,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
});
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${path.join(workspaceRoot, "node_modules/.bin")}:${process.env.PATH}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
throw new Error("[prebuild] server artifacts still missing after build");
|
throw new Error("[prebuild] server artifacts still missing after build")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureServerBuild();
|
function ensureUiBuild() {
|
||||||
|
const loadingHtml = path.join(uiDist, "loading.html")
|
||||||
fs.rmSync(dest, { recursive: true, force: true });
|
if (fs.existsSync(loadingHtml)) {
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
return
|
||||||
|
}
|
||||||
for (const name of sources) {
|
|
||||||
const from = path.join(serverRoot, name);
|
console.log("[prebuild] ui build missing; running workspace build...")
|
||||||
const to = path.join(dest, name);
|
execSync("npm --workspace @codenomad/ui run build", {
|
||||||
if (!fs.existsSync(from)) {
|
cwd: workspaceRoot,
|
||||||
console.warn(`[prebuild] skipped missing ${from}`);
|
stdio: "inherit",
|
||||||
continue;
|
})
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingHtml)) {
|
||||||
|
throw new Error("[prebuild] ui loading assets missing after build")
|
||||||
}
|
}
|
||||||
fs.cpSync(from, to, { recursive: true });
|
|
||||||
console.log(`[prebuild] copied ${from} -> ${to}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureServerDevDependencies() {
|
||||||
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ensuring server build dependencies (with dev)...")
|
||||||
|
execSync(serverDevInstallCommand, {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: envWithRootBin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(braceExpansionPath)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[prebuild] ensuring server production dependencies...")
|
||||||
|
execSync(serverInstallCommand, {
|
||||||
|
cwd: serverRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of sources) {
|
||||||
|
const from = path.join(serverRoot, name)
|
||||||
|
const to = path.join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
console.warn(`[prebuild] skipped missing ${from}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
console.log(`[prebuild] copied ${from} -> ${to}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUiLoadingAssets() {
|
||||||
|
const loadingSource = path.join(uiDist, "loading.html")
|
||||||
|
const assetsSource = path.join(uiDist, "assets")
|
||||||
|
|
||||||
|
if (!fs.existsSync(loadingSource)) {
|
||||||
|
throw new Error("[prebuild] cannot find built loading.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.rmSync(uiLoadingDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(uiLoadingDest, { recursive: true })
|
||||||
|
|
||||||
|
fs.copyFileSync(loadingSource, path.join(uiLoadingDest, "loading.html"))
|
||||||
|
if (fs.existsSync(assetsSource)) {
|
||||||
|
fs.cpSync(assetsSource, path.join(uiLoadingDest, "assets"), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[prebuild] prepared UI loading assets from ${uiDist}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureServerDevDependencies()
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureUiBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
|
copyServerArtifacts()
|
||||||
|
copyUiLoadingAssets()
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ thiserror = "1"
|
|||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
|||||||
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
16
packages/tauri-app/src-tauri/capabilities/main-window.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/capabilities.json",
|
||||||
|
"identifier": "main-window-native-dialogs",
|
||||||
|
"description": "Grant the main window access to required core features and native dialog commands.",
|
||||||
|
"remote": {
|
||||||
|
"urls": [
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"http://localhost:*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
{}
|
{"main-window-native-dialogs":{"identifier":"main-window-native-dialogs","description":"Grant the main window access to required core features and native dialog commands.","remote":{"urls":["http://127.0.0.1:*","http://localhost:*"]},"local":true,"windows":["main"],"permissions":["core:default","dialog:allow-open"]}}
|
||||||
@@ -2143,6 +2143,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2143,6 +2143,72 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "core:window:deny-unminimize",
|
"const": "core:window:deny-unminimize",
|
||||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:default",
|
||||||
|
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the ask command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-ask",
|
||||||
|
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the confirm command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-confirm",
|
||||||
|
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-message",
|
||||||
|
"markdownDescription": "Enables the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-open",
|
||||||
|
"markdownDescription": "Enables the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Enables the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:allow-save",
|
||||||
|
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the ask command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-ask",
|
||||||
|
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the confirm command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-confirm",
|
||||||
|
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the message command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-message",
|
||||||
|
"markdownDescription": "Denies the message command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the open command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-open",
|
||||||
|
"markdownDescription": "Denies the open command without any pre-configured scope."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Denies the save command without any pre-configured scope.",
|
||||||
|
"type": "string",
|
||||||
|
"const": "dialog:deny-save",
|
||||||
|
"markdownDescription": "Denies the save command without any pre-configured scope."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ impl CliProcessManager {
|
|||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let line = buffer.trim_end();
|
let line = buffer.trim_end();
|
||||||
if !line.is_empty() {
|
if !line.is_empty() {
|
||||||
let _ = app.emit("cli:log", json!({"stream": stream, "message": line}));
|
log_line(&format!("[cli][{}] {}", stream, line));
|
||||||
|
|
||||||
if ready.load(Ordering::SeqCst) {
|
if ready.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ fn cli_get_status(state: tauri::State<AppState>) -> CliStatus {
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
manager: CliProcessManager::new(),
|
manager: CliProcessManager::new(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,17 +4,20 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"identifier": "ai.opencode.client",
|
"identifier": "ai.opencode.client",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "",
|
"beforeDevCommand": "npm run dev:bootstrap",
|
||||||
"beforeBuildCommand": "npm run bundle:server",
|
"beforeBuildCommand": "npm run bundle:server",
|
||||||
"frontendDist": "../src"
|
"frontendDist": "resources/ui-loading"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
"app": {
|
"app": {
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "CodeNomad",
|
"title": "CodeNomad",
|
||||||
"url": "index.html",
|
"url": "loading.html",
|
||||||
"width": 1400,
|
"width": 1400,
|
||||||
"height": 900,
|
"height": 900,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
@@ -22,21 +25,23 @@
|
|||||||
"center": true,
|
"center": true,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"fullscreen": false,
|
"fullscreen": false,
|
||||||
"decorations": true
|
"decorations": true,
|
||||||
|
"theme": "Dark",
|
||||||
|
"backgroundColor": "#1a1a1a"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"assetProtocol": {
|
"assetProtocol": {
|
||||||
"scope": ["**"]
|
"scope": ["**"]
|
||||||
}
|
},
|
||||||
|
"capabilities": ["main-window-native-dialogs"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
"../src/index.html",
|
"resources/server",
|
||||||
"../src/icon.png",
|
"resources/ui-loading"
|
||||||
"resources/server"
|
|
||||||
],
|
],
|
||||||
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
"icon": ["icon.icns", "icon.ico", "icon.png"],
|
||||||
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
"targets": ["app", "appimage", "deb", "rpm", "nsis"]
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -1,197 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>CodeNomad</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
min-height: 100vh;
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: #cfd4dc;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 20px;
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
.logo {
|
|
||||||
width: 180px;
|
|
||||||
height: auto;
|
|
||||||
filter: drop-shadow(0 15px 40px rgba(0, 0, 0, 0.35));
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-size: 2.7rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #f4f6fb;
|
|
||||||
}
|
|
||||||
.loading-card {
|
|
||||||
margin-top: 12px;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
padding: 22px;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: #151a23;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
.loading-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #cfd4dc;
|
|
||||||
}
|
|
||||||
.spinner {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
|
||||||
border-top-color: #6ce3ff;
|
|
||||||
animation: spin 0.9s linear infinite;
|
|
||||||
}
|
|
||||||
.phrase-controls {
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: #8f96a9;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.phrase-controls button {
|
|
||||||
color: #8fb5ff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
margin-top: 12px;
|
|
||||||
color: #ff9ea9;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
@keyframes spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrapper" role="status" aria-live="polite">
|
|
||||||
<img src="./icon.png" alt="CodeNomad" class="logo" />
|
|
||||||
<div>
|
|
||||||
<h1 class="title">CodeNomad</h1>
|
|
||||||
</div>
|
|
||||||
<div class="loading-card">
|
|
||||||
<div class="loading-row">
|
|
||||||
<div class="spinner" aria-hidden="true"></div>
|
|
||||||
<span id="loading-phrase">Warming up the AI neurons…</span>
|
|
||||||
</div>
|
|
||||||
<div class="phrase-controls">
|
|
||||||
<button id="phrase-toggle" type="button">Show another</button>
|
|
||||||
</div>
|
|
||||||
<div class="error" id="error"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
const phrases = [
|
|
||||||
"Warming up the AI neurons…",
|
|
||||||
"Convincing the AI to stop daydreaming…",
|
|
||||||
"Polishing the AI’s code goggles…",
|
|
||||||
"Asking the AI to stop reorganizing your files…",
|
|
||||||
"Feeding the AI additional coffee…",
|
|
||||||
"Teaching the AI not to delete node_modules (again)…",
|
|
||||||
"Telling the AI to act natural before you arrive…",
|
|
||||||
"Asking the AI to please stop rewriting history…",
|
|
||||||
"Letting the AI stretch before its coding sprint…",
|
|
||||||
"Persuading the AI to give you keyboard control…",
|
|
||||||
]
|
|
||||||
|
|
||||||
const phraseEl = document.getElementById("loading-phrase")
|
|
||||||
const button = document.getElementById("phrase-toggle")
|
|
||||||
const errorEl = document.getElementById("error")
|
|
||||||
|
|
||||||
function pickPhrase() {
|
|
||||||
const next = phrases[Math.floor(Math.random() * phrases.length)]
|
|
||||||
phraseEl.textContent = next
|
|
||||||
}
|
|
||||||
|
|
||||||
function setError(message) {
|
|
||||||
errorEl.textContent = message || ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(url) {
|
|
||||||
if (!url) return
|
|
||||||
window.location.replace(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap() {
|
|
||||||
pickPhrase()
|
|
||||||
button?.addEventListener("click", pickPhrase)
|
|
||||||
|
|
||||||
if (!window.__TAURI__ || !window.__TAURI__.event || !window.__TAURI__.invoke) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { listen } = window.__TAURI__.event
|
|
||||||
const invoke = window.__TAURI__.invoke
|
|
||||||
|
|
||||||
listen("cli:ready", (event) => {
|
|
||||||
const payload = event?.payload || {}
|
|
||||||
if (payload.url) {
|
|
||||||
navigateTo(payload.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
listen("cli:error", (event) => {
|
|
||||||
const payload = event?.payload || {}
|
|
||||||
if (payload.message) {
|
|
||||||
setError(payload.message)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
listen("cli:status", (event) => {
|
|
||||||
const payload = event?.payload || {}
|
|
||||||
if (payload.state !== "ready") {
|
|
||||||
setError("")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await invoke("cli_get_status")
|
|
||||||
if (status?.state === "ready" && status.url) {
|
|
||||||
navigateTo(status.url)
|
|
||||||
}
|
|
||||||
if (status?.state === "error" && status.error) {
|
|
||||||
setError(status.error)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setError(String(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bootstrap()
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.2.1",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const App: Component = () => {
|
|||||||
preferences,
|
preferences,
|
||||||
recordWorkspaceLaunch,
|
recordWorkspaceLaunch,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -205,6 +206,7 @@ const App: Component = () => {
|
|||||||
const { commands: paletteCommands, executeCommand } = useCommands({
|
const { commands: paletteCommands, executeCommand } = useCommands({
|
||||||
preferences,
|
preferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { For, Show, createEffect, createMemo } from "solid-js"
|
|||||||
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import Kbd from "./kbd"
|
|
||||||
|
|
||||||
interface AgentSelectorProps {
|
interface AgentSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -116,9 +115,6 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Portal>
|
</Select.Portal>
|
||||||
</Select>
|
</Select>
|
||||||
<span class="hint sidebar-selector-hint">
|
|
||||||
<Kbd shortcut="cmd+shift+a" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useConfig } from "../stores/preferences"
|
|||||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
|
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
const [focusMode, setFocusMode] = createSignal<"recent" | "new" | null>("recent")
|
||||||
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
const [selectedBinary, setSelectedBinary] = createSignal(preferences().lastUsedBinary || "opencode")
|
||||||
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
const [isFolderBrowserOpen, setIsFolderBrowserOpen] = createSignal(false)
|
||||||
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
let recentListRef: HTMLDivElement | undefined
|
let recentListRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
const folders = () => recentFolders()
|
const folders = () => recentFolders()
|
||||||
@@ -29,9 +31,8 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
// Update selected binary when preferences change
|
// Update selected binary when preferences change
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const lastUsed = preferences().lastUsedBinary
|
const lastUsed = preferences().lastUsedBinary
|
||||||
if (lastUsed && lastUsed !== selectedBinary()) {
|
if (!lastUsed) return
|
||||||
setSelectedBinary(lastUsed)
|
setSelectedBinary((current) => (current === lastUsed ? current : lastUsed))
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -78,7 +79,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
if (isBrowseShortcut) {
|
if (isBrowseShortcut) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleBrowse()
|
void handleBrowse()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,9 +173,20 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
props.onSelectFolder(path, selectedBinary())
|
props.onSelectFolder(path, selectedBinary())
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowse() {
|
async function handleBrowse() {
|
||||||
if (isLoading()) return
|
if (isLoading()) return
|
||||||
setFocusMode("new")
|
setFocusMode("new")
|
||||||
|
if (nativeDialogsAvailable) {
|
||||||
|
const fallbackPath = folders()[0]?.path
|
||||||
|
const selected = await openNativeFolderDialog({
|
||||||
|
title: "Select Workspace",
|
||||||
|
defaultPath: fallbackPath,
|
||||||
|
})
|
||||||
|
if (selected) {
|
||||||
|
handleFolderSelect(selected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsFolderBrowserOpen(true)
|
setIsFolderBrowserOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +231,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
>
|
>
|
||||||
<div class="mb-6 text-center shrink-0">
|
<div class="mb-6 text-center shrink-0">
|
||||||
<div class="mb-3 flex justify-center">
|
<div class="mb-3 flex justify-center">
|
||||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-48 w-auto" loading="lazy" />
|
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||||
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
<p class="text-base text-secondary">Select a folder to start coding with AI</p>
|
||||||
@@ -306,14 +318,14 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<div class="panel shrink-0">
|
<div class="panel shrink-0">
|
||||||
<div class="panel-header">
|
<div class="panel-header hidden sm:block">
|
||||||
<h2 class="panel-title">Browse for Folder</h2>
|
<h2 class="panel-title">Browse for Folder</h2>
|
||||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
<button
|
<button
|
||||||
onClick={handleBrowse}
|
onClick={() => void handleBrowse()}
|
||||||
disabled={props.isLoading}
|
disabled={props.isLoading}
|
||||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||||
onMouseEnter={() => setFocusMode("new")}
|
onMouseEnter={() => setFocusMode("new")}
|
||||||
@@ -342,7 +354,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 panel panel-footer shrink-0">
|
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<Show when={folders().length > 0}>
|
<Show when={folders().length > 0}>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
const [isLoadingMetadata, setIsLoadingMetadata] = createSignal(true)
|
||||||
|
|
||||||
const metadata = () => props.instance.metadata
|
const metadata = () => props.instance.metadata
|
||||||
|
const binaryVersion = () => props.instance.binaryVersion || metadata()?.version
|
||||||
const mcpServers = () => {
|
const mcpServers = () => {
|
||||||
const status = metadata()?.mcpStatus
|
const status = metadata()?.mcpStatus
|
||||||
return status ? parseMcpStatus(status) : []
|
return status ? parseMcpStatus(status) : []
|
||||||
@@ -104,11 +105,12 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
...(lspStatus ? { lspStatus } : {}),
|
...(lspStatus ? { lspStatus } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!nextMetadata.version) {
|
if (!nextMetadata.version && instance.binaryVersion) {
|
||||||
nextMetadata.version = "0.15.8"
|
nextMetadata.version = instance.binaryVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
updateInstance(instanceId, { metadata: nextMetadata })
|
updateInstance(instanceId, { metadata: nextMetadata })
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error("Failed to load instance metadata:", error)
|
console.error("Failed to load instance metadata:", error)
|
||||||
@@ -173,13 +175,13 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={metadata()?.version}>
|
<Show when={binaryVersion()}>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
<div class="text-xs font-medium text-muted uppercase tracking-wide mb-1">
|
||||||
OpenCode Version
|
OpenCode Version
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
<div class="text-xs px-2 py-1.5 rounded border bg-surface-secondary border-base text-primary">
|
||||||
v{metadata()?.version}
|
v{binaryVersion()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-footer">
|
<div class="panel-footer hidden sm:block">
|
||||||
<div class="panel-footer-hints">
|
<div class="panel-footer-hints">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<kbd class="kbd">↑</kbd>
|
<kbd class="kbd">↑</kbd>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import InfoView from "../info-view"
|
|||||||
import AgentSelector from "../agent-selector"
|
import AgentSelector from "../agent-selector"
|
||||||
import ModelSelector from "../model-selector"
|
import ModelSelector from "../model-selector"
|
||||||
import CommandPalette from "../command-palette"
|
import CommandPalette from "../command-palette"
|
||||||
|
import Kbd from "../kbd"
|
||||||
import ContextUsagePanel from "../session/context-usage-panel"
|
import ContextUsagePanel from "../session/context-usage-panel"
|
||||||
import SessionView from "../session/session-view"
|
import SessionView from "../session/session-view"
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ interface InstanceShellProps {
|
|||||||
onExecuteCommand: (command: Command) => void
|
onExecuteCommand: (command: Command) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SESSION_SIDEBAR_WIDTH = 280
|
const DEFAULT_SESSION_SIDEBAR_WIDTH = 350
|
||||||
|
|
||||||
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
const InstanceShell: Component<InstanceShellProps> = (props) => {
|
||||||
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
|
||||||
@@ -114,12 +115,22 @@ const InstanceShell: Component<InstanceShellProps> = (props) => {
|
|||||||
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
onAgentChange={(agent) => props.handleSidebarAgentChange(activeSession().id, agent)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="sidebar-selector-hints" aria-hidden="true">
|
||||||
|
<span class="hint sidebar-selector-hint sidebar-selector-hint--left">
|
||||||
|
<Kbd shortcut="cmd+shift+a" />
|
||||||
|
</span>
|
||||||
|
<span class="hint sidebar-selector-hint sidebar-selector-hint--right">
|
||||||
|
<Kbd shortcut="cmd+shift+m" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ModelSelector
|
<ModelSelector
|
||||||
instanceId={props.instance.id}
|
instanceId={props.instance.id}
|
||||||
sessionId={activeSession().id}
|
sessionId={activeSession().id}
|
||||||
currentModel={activeSession().model}
|
currentModel={activeSession().model}
|
||||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { For, Show } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
import type { Message, SDKPart, MessageInfo, ClientPart } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText } from "../types/message"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
|
import { preferences } from "../stores/preferences"
|
||||||
import MessagePart from "./message-part"
|
import MessagePart from "./message-part"
|
||||||
|
|
||||||
interface MessageItemProps {
|
interface MessageItemProps {
|
||||||
@@ -16,6 +18,7 @@ interface MessageItemProps {
|
|||||||
|
|
||||||
export default function MessageItem(props: MessageItemProps) {
|
export default function MessageItem(props: MessageItemProps) {
|
||||||
const isUser = () => props.message.type === "user"
|
const isUser = () => props.message.type === "user"
|
||||||
|
const showUsageMetrics = () => preferences().showUsageMetrics ?? true
|
||||||
const timestamp = () => {
|
const timestamp = () => {
|
||||||
const date = new Date(props.message.timestamp)
|
const date = new Date(props.message.timestamp)
|
||||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
@@ -139,6 +142,44 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
? "message-item-base bg-[var(--message-user-bg)] border-l-4 border-[var(--message-user-border)]"
|
||||||
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
: "message-item-base assistant-message bg-[var(--message-assistant-bg)] border-l-4 border-[var(--message-assistant-border)]"
|
||||||
|
|
||||||
|
const statChipClass =
|
||||||
|
"inline-flex items-center gap-1 rounded-full border border-[var(--border-base)] px-2 py-0.5 text-[10px]"
|
||||||
|
const statLabelClass = "uppercase text-[9px] tracking-wide text-[var(--text-muted)]"
|
||||||
|
const statValueClass = "font-semibold text-[var(--text-primary)]"
|
||||||
|
|
||||||
|
const usageStats = createMemo(() => {
|
||||||
|
const info = props.messageInfo
|
||||||
|
if (!info || info.role !== "assistant" || !info.tokens) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!showUsageMetrics()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = info.tokens
|
||||||
|
const input = tokens.input ?? 0
|
||||||
|
const output = tokens.output ?? 0
|
||||||
|
const reasoning = tokens.reasoning ?? 0
|
||||||
|
if (input === 0 && output === 0 && reasoning === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
reasoning,
|
||||||
|
cacheRead: tokens.cache?.read ?? 0,
|
||||||
|
cacheWrite: tokens.cache?.write ?? 0,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatCostValue = (value: number) => {
|
||||||
|
if (!value) return "$0.00"
|
||||||
|
if (value < 0.01) return `$${value.toPrecision(2)}`
|
||||||
|
return `$${value.toFixed(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
const agentIdentifier = () => {
|
const agentIdentifier = () => {
|
||||||
if (isUser()) return ""
|
if (isUser()) return ""
|
||||||
const info = props.messageInfo
|
const info = props.messageInfo
|
||||||
@@ -225,6 +266,7 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Show when={fileAttachments().length > 0}>
|
<Show when={fileAttachments().length > 0}>
|
||||||
<div class="message-attachments">
|
<div class="message-attachments">
|
||||||
<For each={fileAttachments()}>
|
<For each={fileAttachments()}>
|
||||||
@@ -269,8 +311,39 @@ export default function MessageItem(props: MessageItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
|
<Show when={usageStats()}>
|
||||||
|
{(usage) => (
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-1 text-[10px] text-[var(--text-muted)]">
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Input</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().input)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Output</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().output)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Reasoning</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().reasoning)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cache Read</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().cacheRead)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cache Write</span>
|
||||||
|
<span class={statValueClass}>{formatTokenTotal(usage().cacheWrite)}</span>
|
||||||
|
</div>
|
||||||
|
<div class={statChipClass}>
|
||||||
|
<span class={statLabelClass}>Cost</span>
|
||||||
|
<span class={statValueClass}>{formatCostValue(usage().cost)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={props.message.status === "sending"}>
|
<Show when={props.message.status === "sending"}>
|
||||||
|
|
||||||
|
|
||||||
<div class="message-sending">
|
<div class="message-sending">
|
||||||
<span class="generating-spinner">●</span> Sending...
|
<span class="generating-spinner">●</span> Sending...
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ import { sseManager } from "../lib/sse-manager"
|
|||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
import { getSessionInfo, computeDisplayParts, sessions, setActiveSession, setActiveParentSession } from "../stores/sessions"
|
||||||
|
import { formatTokenTotal } from "../lib/formatters"
|
||||||
import { setActiveInstanceId } from "../stores/instances"
|
import { setActiveInstanceId } from "../stores/instances"
|
||||||
|
import { showCommandPalette } from "../stores/command-palette"
|
||||||
|
|
||||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||||
const SCROLL_OFFSET = 64
|
const SCROLL_OFFSET = 64
|
||||||
@@ -71,26 +73,9 @@ function navigateToTaskSession(location: TaskSessionLocation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format tokens like TUI (e.g., "110K", "1.2M")
|
// Format tokens like session sidebar (comma-separated totals)
|
||||||
function formatTokens(tokens: number): string {
|
function formatTokens(tokens: number): string {
|
||||||
if (tokens >= 1000000) {
|
return formatTokenTotal(tokens)
|
||||||
return `${(tokens / 1000000).toFixed(1)}M`
|
|
||||||
} else if (tokens >= 1000) {
|
|
||||||
return `${(tokens / 1000).toFixed(0)}K`
|
|
||||||
}
|
|
||||||
return tokens.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format session info for the session view header
|
|
||||||
function formatSessionInfo(usageTokens: number, contextWindow: number, usagePercent: number | null): string {
|
|
||||||
if (contextWindow > 0) {
|
|
||||||
const windowStr = formatTokens(contextWindow)
|
|
||||||
const usageStr = formatTokens(usageTokens)
|
|
||||||
const percent = usagePercent ?? Math.min(100, Math.max(0, Math.round((usageTokens / contextWindow) * 100)))
|
|
||||||
return `${usageStr} of ${windowStr} (${percent}%)`
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatTokens(usageTokens)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageStreamProps {
|
interface MessageStreamProps {
|
||||||
@@ -186,6 +171,9 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
|
|
||||||
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
|
const scrollStateKey = () => makeScrollKey(props.instanceId, props.sessionId)
|
||||||
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
const connectionStatus = () => sseManager.getStatus(props.instanceId)
|
||||||
|
const handleCommandPaletteClick = () => {
|
||||||
|
showCommandPalette(props.instanceId)
|
||||||
|
}
|
||||||
|
|
||||||
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
|
function createToolSignature(message: Message, toolPart: ClientPart, toolIndex: number, messageInfo?: MessageInfo): string {
|
||||||
const messageId = message.id
|
const messageId = message.id
|
||||||
@@ -202,18 +190,27 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
|
|
||||||
const sessionInfo = createMemo(() =>
|
const sessionInfo = createMemo(() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: 0,
|
contextWindow: 0,
|
||||||
isSubscriptionModel: false,
|
isSubscriptionModel: false,
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: null,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const formattedSessionInfo = createMemo(() => {
|
const tokenStats = createMemo(() => {
|
||||||
const info = sessionInfo()
|
const info = sessionInfo()
|
||||||
return formatSessionInfo(info.contextUsageTokens, info.contextWindow, info.contextUsagePercent)
|
return {
|
||||||
|
input: info.inputTokens ?? 0,
|
||||||
|
output: info.outputTokens ?? 0,
|
||||||
|
cost: info.cost ?? 0,
|
||||||
|
used: info.actualUsageTokens ?? 0,
|
||||||
|
avail: info.contextAvailableTokens,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
function isNearBottom(element: HTMLDivElement, offset = SCROLL_OFFSET) {
|
||||||
@@ -549,14 +546,42 @@ export default function MessageStream(props: MessageStreamProps) {
|
|||||||
return (
|
return (
|
||||||
<div class="message-stream-container">
|
<div class="message-stream-container">
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
<div class="connection-status-text connection-status-info flex items-center gap-2 text-sm font-medium">
|
<div class="connection-status-text connection-status-info flex flex-wrap items-center gap-2 text-sm font-medium">
|
||||||
<span>{formattedSessionInfo()}</span>
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Used</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokens(sessionInfo().actualUsageTokens ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
|
||||||
|
<span class="uppercase text-[10px] tracking-wide text-primary/70">Avail</span>
|
||||||
|
<span class="font-semibold text-primary">
|
||||||
|
{sessionInfo().contextAvailableTokens !== null ? formatTokens(sessionInfo().contextAvailableTokens ?? 0) : "--"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="connection-status-text connection-status-shortcut flex items-center gap-2 text-sm font-medium">
|
|
||||||
<span>Command Palette</span>
|
<div class="connection-status-text connection-status-shortcut">
|
||||||
<Kbd shortcut="cmd+shift+p" />
|
|
||||||
|
<div class="connection-status-shortcut-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="connection-status-button"
|
||||||
|
onClick={handleCommandPaletteClick}
|
||||||
|
aria-label="Open command palette"
|
||||||
|
>
|
||||||
|
Command Palette
|
||||||
|
</button>
|
||||||
|
<span class="connection-status-shortcut-hint">
|
||||||
|
<Kbd shortcut="cmd+shift+p" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="connection-status-meta flex items-center justify-end gap-3">
|
<div class="connection-status-meta flex items-center justify-end gap-3">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Show when={connectionStatus() === "connected"}>
|
<Show when={connectionStatus() === "connected"}>
|
||||||
<span class="status-indicator connected">
|
<span class="status-indicator connected">
|
||||||
<span class="status-dot" />
|
<span class="status-dot" />
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { createEffect, createMemo, createSignal } from "solid-js"
|
|||||||
import { providers, fetchProviders } from "../stores/sessions"
|
import { providers, fetchProviders } from "../stores/sessions"
|
||||||
import { ChevronDown } from "lucide-solid"
|
import { ChevronDown } from "lucide-solid"
|
||||||
import type { Model } from "../types/session"
|
import type { Model } from "../types/session"
|
||||||
import Kbd from "./kbd"
|
|
||||||
|
|
||||||
interface ModelSelectorProps {
|
interface ModelSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
@@ -132,9 +131,6 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
|||||||
</Combobox.Content>
|
</Combobox.Content>
|
||||||
</Combobox.Portal>
|
</Combobox.Portal>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
<span class="hint sidebar-selector-hint">
|
|
||||||
<Kbd shortcut="cmd+shift+m" />
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
|||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||||
|
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||||
|
|
||||||
interface BinaryOption {
|
interface BinaryOption {
|
||||||
path: string
|
path: string
|
||||||
@@ -32,8 +33,10 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||||
|
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||||
|
|
||||||
const binaries = () => opencodeBinaries()
|
const binaries = () => opencodeBinaries()
|
||||||
|
|
||||||
const lastUsedBinary = () => preferences().lastUsedBinary
|
const lastUsedBinary = () => preferences().lastUsedBinary
|
||||||
|
|
||||||
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
const customBinaries = createMemo(() => binaries().filter((binary) => binary.path !== "opencode"))
|
||||||
@@ -128,9 +131,19 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBrowseBinary() {
|
async function handleBrowseBinary() {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
setValidationError(null)
|
setValidationError(null)
|
||||||
|
if (nativeDialogsAvailable) {
|
||||||
|
const selected = await openNativeFileDialog({
|
||||||
|
title: "Select OpenCode Binary",
|
||||||
|
})
|
||||||
|
if (selected) {
|
||||||
|
setCustomPath(selected)
|
||||||
|
void handleValidateAndAdd(selected)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
setIsBinaryBrowserOpen(true)
|
setIsBinaryBrowserOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +258,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleBrowseBinary}
|
onClick={() => void handleBrowseBinary()}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
class="selector-button selector-button-secondary w-full flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { createFileAttachment, createTextAttachment, createAgentAttachment } fro
|
|||||||
import type { Attachment } from "../types/attachment"
|
import type { Attachment } from "../types/attachment"
|
||||||
import type { Agent } from "../types/session"
|
import type { Agent } from "../types/session"
|
||||||
import Kbd from "./kbd"
|
import Kbd from "./kbd"
|
||||||
import HintRow from "./hint-row"
|
|
||||||
import { getActiveInstance } from "../stores/instances"
|
import { getActiveInstance } from "../stores/instances"
|
||||||
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions"
|
||||||
import { showAlertDialog } from "../stores/alerts"
|
import { showAlertDialog } from "../stores/alerts"
|
||||||
@@ -25,6 +24,7 @@ interface PromptInputProps {
|
|||||||
export default function PromptInput(props: PromptInputProps) {
|
export default function PromptInput(props: PromptInputProps) {
|
||||||
const [prompt, setPromptInternal] = createSignal("")
|
const [prompt, setPromptInternal] = createSignal("")
|
||||||
const [history, setHistory] = createSignal<string[]>([])
|
const [history, setHistory] = createSignal<string[]>([])
|
||||||
|
const HISTORY_LIMIT = 100
|
||||||
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
const [historyIndex, setHistoryIndex] = createSignal(-1)
|
||||||
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
const [historyDraft, setHistoryDraft] = createSignal<string | null>(null)
|
||||||
const [, setIsFocused] = createSignal(false)
|
const [, setIsFocused] = createSignal(false)
|
||||||
@@ -499,11 +499,27 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = prompt().trim()
|
const text = prompt().trim()
|
||||||
const currentAttachments = attachments()
|
const currentAttachments = attachments()
|
||||||
if (props.disabled || !text) return
|
if (props.disabled || (!text && currentAttachments.length === 0)) return
|
||||||
|
|
||||||
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
const resolvedPrompt = resolvePastedPlaceholders(text, currentAttachments)
|
||||||
const isShellMode = mode() === "shell"
|
const isShellMode = mode() === "shell"
|
||||||
|
|
||||||
|
const refreshHistory = async () => {
|
||||||
|
try {
|
||||||
|
await addToHistory(props.instanceFolder, resolvedPrompt)
|
||||||
|
setHistory((prev) => {
|
||||||
|
const next = [resolvedPrompt, ...prev]
|
||||||
|
if (next.length > HISTORY_LIMIT) {
|
||||||
|
next.length = HISTORY_LIMIT
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setHistoryIndex(-1)
|
||||||
|
} catch (historyError) {
|
||||||
|
console.error("Failed to update prompt history:", historyError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
clearPrompt()
|
clearPrompt()
|
||||||
clearAttachments(props.instanceId, props.sessionId)
|
clearAttachments(props.instanceId, props.sessionId)
|
||||||
setIgnoredAtPositions(new Set<number>())
|
setIgnoredAtPositions(new Set<number>())
|
||||||
@@ -512,10 +528,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
setHistoryDraft(null)
|
setHistoryDraft(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addToHistory(props.instanceFolder, resolvedPrompt)
|
|
||||||
const updated = await getHistory(props.instanceFolder)
|
|
||||||
setHistory(updated)
|
|
||||||
setHistoryIndex(-1)
|
|
||||||
if (isShellMode) {
|
if (isShellMode) {
|
||||||
if (props.onRunShell) {
|
if (props.onRunShell) {
|
||||||
await props.onRunShell(resolvedPrompt)
|
await props.onRunShell(resolvedPrompt)
|
||||||
@@ -523,8 +535,9 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
await props.onSend(resolvedPrompt, [])
|
await props.onSend(resolvedPrompt, [])
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await props.onSend(text, currentAttachments)
|
await props.onSend(resolvedPrompt, currentAttachments)
|
||||||
}
|
}
|
||||||
|
void refreshHistory()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send message:", error)
|
console.error("Failed to send message:", error)
|
||||||
showAlertDialog("Failed to send message", {
|
showAlertDialog("Failed to send message", {
|
||||||
@@ -763,6 +776,7 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
const shellHint = () => (mode() === "shell" ? { key: "Esc", text: "to exit shell mode" } : { key: "!", text: "for shell mode" })
|
||||||
|
const shouldShowOverlay = () => prompt().length === 0
|
||||||
|
|
||||||
const instance = () => getActiveInstance()
|
const instance = () => getActiveInstance()
|
||||||
|
|
||||||
@@ -870,28 +884,62 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<textarea
|
<div class="prompt-input-field">
|
||||||
ref={textareaRef}
|
<textarea
|
||||||
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
ref={textareaRef}
|
||||||
placeholder={
|
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""}`}
|
||||||
mode() === "shell"
|
placeholder={
|
||||||
? "Run a shell command (Esc to exit)..."
|
mode() === "shell"
|
||||||
: "Type your message, @file, @agent, or paste images and text..."
|
? "Run a shell command (Esc to exit)..."
|
||||||
}
|
: "Type your message, @file, @agent, or paste images and text..."
|
||||||
value={prompt()}
|
}
|
||||||
onInput={handleInput}
|
value={prompt()}
|
||||||
onKeyDown={handleKeyDown}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsFocused(true)}
|
onPaste={handlePaste}
|
||||||
onBlur={() => setIsFocused(false)}
|
onFocus={() => setIsFocused(true)}
|
||||||
disabled={props.disabled}
|
onBlur={() => setIsFocused(false)}
|
||||||
rows={4}
|
disabled={props.disabled}
|
||||||
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
rows={4}
|
||||||
spellcheck={false}
|
style={attachments().length > 0 ? { "padding-top": "8px" } : {}}
|
||||||
autocorrect="off"
|
spellcheck={false}
|
||||||
autoCapitalize="off"
|
autocorrect="off"
|
||||||
autocomplete="off"
|
autoCapitalize="off"
|
||||||
/>
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Show when={shouldShowOverlay()}>
|
||||||
|
<div class={`prompt-input-overlay ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
|
<Show
|
||||||
|
when={props.escapeInDebounce}
|
||||||
|
fallback={
|
||||||
|
<>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
||||||
|
</span>
|
||||||
|
<Show when={attachments().length > 0}>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-muted">• {attachments().length} file(s) attached</span>
|
||||||
|
</Show>
|
||||||
|
<span class="prompt-overlay-text">
|
||||||
|
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
||||||
|
</span>
|
||||||
|
<Show when={mode() === "shell"}>
|
||||||
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<span class="prompt-overlay-text prompt-overlay-warning">
|
||||||
|
Press <Kbd>Esc</Kbd> again to abort session
|
||||||
|
</span>
|
||||||
|
<Show when={mode() === "shell"}>
|
||||||
|
<span class="prompt-overlay-shell-active">Shell mode active</span>
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -911,33 +959,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</Show>
|
</Show>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="prompt-input-hints">
|
|
||||||
<div class="flex justify-between w-full gap-4">
|
|
||||||
<HintRow>
|
|
||||||
<Show
|
|
||||||
when={props.escapeInDebounce}
|
|
||||||
fallback={
|
|
||||||
<>
|
|
||||||
<Kbd>Enter</Kbd> for new line • <Kbd shortcut="cmd+enter" /> to send • <Kbd>@</Kbd> for files/agents • <Kbd>↑↓</Kbd> for history
|
|
||||||
<Show when={attachments().length > 0}>
|
|
||||||
<span class="ml-2 text-xs" style="color: var(--text-muted);">• {attachments().length} file(s) attached</span>
|
|
||||||
</Show>
|
|
||||||
<span class="ml-2">
|
|
||||||
• <Kbd>{shellHint().key}</Kbd> {shellHint().text}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span class="font-medium" style="color: var(--status-warning);">
|
|
||||||
Press <Kbd>Esc</Kbd> again to abort session
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
</HintRow>
|
|
||||||
<Show when={mode() === "shell"}>
|
|
||||||
<HintRow>Shell mode active</HintRow>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ interface SessionListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MIN_WIDTH = 200
|
const MIN_WIDTH = 200
|
||||||
const MAX_WIDTH = 500
|
const MAX_WIDTH = 520
|
||||||
const DEFAULT_WIDTH = 280
|
const DEFAULT_WIDTH = 350
|
||||||
const STORAGE_KEY = "opencode-session-sidebar-width"
|
const STORAGE_KEY = "opencode-session-sidebar-width-v7"
|
||||||
|
|
||||||
function formatSessionStatus(status: SessionStatus): string {
|
function formatSessionStatus(status: SessionStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -7,54 +7,72 @@ interface ContextUsagePanelProps {
|
|||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chipClass = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
|
||||||
|
const chipLabelClass = "uppercase text-[10px] tracking-wide text-primary/70"
|
||||||
|
const headingClass = "text-xs font-semibold text-primary/70 uppercase tracking-wide"
|
||||||
|
|
||||||
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
const ContextUsagePanel: Component<ContextUsagePanelProps> = (props) => {
|
||||||
const info = createMemo(
|
const info = createMemo(
|
||||||
() =>
|
() =>
|
||||||
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
getSessionInfo(props.instanceId, props.sessionId) ?? {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: 0,
|
contextWindow: 0,
|
||||||
isSubscriptionModel: false,
|
isSubscriptionModel: false,
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: null,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: 0,
|
||||||
|
contextAvailableTokens: null,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const tokens = createMemo(() => info().tokens)
|
const inputTokens = createMemo(() => info().inputTokens ?? 0)
|
||||||
const contextUsageTokens = createMemo(() => info().contextUsageTokens ?? 0)
|
const outputTokens = createMemo(() => info().outputTokens ?? 0)
|
||||||
const contextWindow = createMemo(() => info().contextWindow)
|
const actualUsageTokens = createMemo(() => info().actualUsageTokens ?? 0)
|
||||||
const contextUsagePercent = createMemo(() => info().contextUsagePercent)
|
const availableTokens = createMemo(() => info().contextAvailableTokens)
|
||||||
|
const outputLimit = createMemo(() => info().modelOutputLimit ?? 0)
|
||||||
const costLabel = createMemo(() => {
|
const costValue = createMemo(() => {
|
||||||
if (info().isSubscriptionModel || info().cost <= 0) return "Included in plan"
|
const value = info().isSubscriptionModel ? 0 : info().cost
|
||||||
return `$${info().cost.toFixed(2)} spent`
|
return value > 0 ? value : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const formatTokenValue = (value: number | null | undefined) => {
|
||||||
|
if (value === null || value === undefined) return "--"
|
||||||
|
return formatTokenTotal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const costDisplay = createMemo(() => `$${costValue().toFixed(2)}`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="session-context-panel border-r border-base border-b px-3 py-3">
|
<div class="session-context-panel border-r border-base border-b px-3 py-3 space-y-3">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
<div>
|
<div class={headingClass}>Tokens</div>
|
||||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Tokens (last call)</div>
|
<div class={chipClass}>
|
||||||
<div class="text-lg font-semibold text-primary">{formatTokenTotal(tokens())}</div>
|
<span class={chipLabelClass}>Input</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenTotal(inputTokens())}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-primary/70 text-right leading-tight">{costLabel()}</div>
|
<div class={chipClass}>
|
||||||
</div>
|
<span class={chipLabelClass}>Output</span>
|
||||||
<div class="mt-4">
|
<span class="font-semibold text-primary">{formatTokenTotal(outputTokens())}</span>
|
||||||
<div class="flex items-center justify-between mb-1">
|
|
||||||
<div class="text-xs font-semibold text-primary/70 uppercase tracking-wide">Context window usage</div>
|
|
||||||
<div class="text-sm font-medium text-primary">{contextUsagePercent() !== null ? `${contextUsagePercent()}%` : "--"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-primary/90">
|
<div class={chipClass}>
|
||||||
{contextWindow()
|
<span class={chipLabelClass}>Cost</span>
|
||||||
? `${formatTokenTotal(contextUsageTokens())} of ${formatTokenTotal(contextWindow())}`
|
<span class="font-semibold text-primary">{costDisplay()}</span>
|
||||||
: "Window size unavailable"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 h-1.5 rounded-full bg-base relative overflow-hidden">
|
|
||||||
<div
|
<div class="flex flex-wrap items-center gap-2 text-xs text-primary/90">
|
||||||
class="absolute inset-y-0 left-0 rounded-full bg-accent-primary transition-[width]"
|
<div class={headingClass}>Context</div>
|
||||||
style={{ width: contextUsagePercent() === null ? "0%" : `${contextUsagePercent()}%` }}
|
<div class={chipClass}>
|
||||||
/>
|
<span class={chipLabelClass}>Used</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenTotal(actualUsageTokens())}</span>
|
||||||
|
</div>
|
||||||
|
<div class={chipClass}>
|
||||||
|
<span class={chipLabelClass}>Avail</span>
|
||||||
|
<span class="font-semibold text-primary">{formatTokenValue(availableTokens())}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
export function formatTokenTotal(value: number): string {
|
export function formatTokenTotal(value: number): string {
|
||||||
|
if (value >= 1_000_000_000) {
|
||||||
|
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||||
|
}
|
||||||
if (value >= 1_000_000) {
|
if (value >= 1_000_000) {
|
||||||
return `${(value / 1_000_000).toFixed(1)}M`
|
return `${(value / 1_000_000).toFixed(1)}M`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { Instance } from "../../types/instance"
|
|||||||
export interface UseCommandsOptions {
|
export interface UseCommandsOptions {
|
||||||
preferences: Accessor<Preferences>
|
preferences: Accessor<Preferences>
|
||||||
toggleShowThinkingBlocks: () => void
|
toggleShowThinkingBlocks: () => void
|
||||||
|
toggleUsageMetrics: () => 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
|
||||||
@@ -421,9 +422,22 @@ export function useCommands(options: UseCommandsOptions) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
commandRegistry.register({
|
||||||
|
id: "token-usage-visibility",
|
||||||
|
label: () => {
|
||||||
|
const visible = options.preferences().showUsageMetrics ?? true
|
||||||
|
return `Token Usage Display · ${visible ? "Visible" : "Hidden"}`
|
||||||
|
},
|
||||||
|
description: "Show or hide token and cost stats for assistant messages",
|
||||||
|
category: "System",
|
||||||
|
keywords: ["token", "usage", "cost", "stats"],
|
||||||
|
action: options.toggleUsageMetrics,
|
||||||
|
})
|
||||||
|
|
||||||
commandRegistry.register({
|
commandRegistry.register({
|
||||||
id: "help",
|
id: "help",
|
||||||
label: "Show Help",
|
label: "Show Help",
|
||||||
|
|
||||||
description: "Display keyboard shortcuts and help",
|
description: "Display keyboard shortcuts and help",
|
||||||
category: "System",
|
category: "System",
|
||||||
keywords: ["/help", "shortcuts", "help"],
|
keywords: ["/help", "shortcuts", "help"],
|
||||||
|
|||||||
39
packages/ui/src/lib/native/electron/functions.ts
Normal file
39
packages/ui/src/lib/native/electron/functions.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { NativeDialogOptions } from "../native-functions"
|
||||||
|
|
||||||
|
interface ElectronDialogResult {
|
||||||
|
canceled?: boolean
|
||||||
|
paths?: string[]
|
||||||
|
path?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
openDialog?: (options: NativeDialogOptions) => Promise<ElectronDialogResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceFirstPath(result?: ElectronDialogResult | null): string | null {
|
||||||
|
if (!result || result.canceled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const paths = Array.isArray(result.paths) ? result.paths : result.path ? [result.path] : []
|
||||||
|
if (paths.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return paths[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openElectronNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||||
|
if (!api?.openDialog) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await api.openDialog(options)
|
||||||
|
return coerceFirstPath(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[native] electron dialog failed", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
37
packages/ui/src/lib/native/native-functions.ts
Normal file
37
packages/ui/src/lib/native/native-functions.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { runtimeEnv } from "../runtime-env"
|
||||||
|
import type { NativeDialogOptions } from "./types"
|
||||||
|
import { openElectronNativeDialog } from "./electron/functions"
|
||||||
|
import { openTauriNativeDialog } from "./tauri/functions"
|
||||||
|
|
||||||
|
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||||
|
|
||||||
|
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||||
|
switch (runtimeEnv.host) {
|
||||||
|
case "electron":
|
||||||
|
return openElectronNativeDialog
|
||||||
|
case "tauri":
|
||||||
|
return openTauriNativeDialog
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function supportsNativeDialogs(): boolean {
|
||||||
|
return resolveNativeHandler() !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
|
const handler = resolveNativeHandler()
|
||||||
|
if (!handler) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return handler(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openNativeFolderDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
|
||||||
|
return openNativeDialog({ mode: "directory", ...(options ?? {}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openNativeFileDialog(options?: Omit<NativeDialogOptions, "mode">): Promise<string | null> {
|
||||||
|
return openNativeDialog({ mode: "file", ...(options ?? {}) })
|
||||||
|
}
|
||||||
55
packages/ui/src/lib/native/tauri/functions.ts
Normal file
55
packages/ui/src/lib/native/tauri/functions.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { NativeDialogOptions } from "../native-functions"
|
||||||
|
|
||||||
|
interface TauriDialogModule {
|
||||||
|
open?: (
|
||||||
|
options: {
|
||||||
|
title?: string
|
||||||
|
defaultPath?: string
|
||||||
|
filters?: { name?: string; extensions: string[] }[]
|
||||||
|
directory?: boolean
|
||||||
|
multiple?: boolean
|
||||||
|
},
|
||||||
|
) => Promise<string | string[] | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriBridge {
|
||||||
|
dialog?: TauriDialogModule
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openTauriNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const tauriBridge = (window as Window & { __TAURI__?: TauriBridge }).__TAURI__
|
||||||
|
const dialogApi = tauriBridge?.dialog
|
||||||
|
if (!dialogApi?.open) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await dialogApi.open({
|
||||||
|
title: options.title,
|
||||||
|
defaultPath: options.defaultPath,
|
||||||
|
directory: options.mode === "directory",
|
||||||
|
multiple: false,
|
||||||
|
filters: options.filters?.map((filter) => ({
|
||||||
|
name: filter.name,
|
||||||
|
extensions: filter.extensions,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(response)) {
|
||||||
|
return response[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[native] tauri dialog failed", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/ui/src/lib/native/types.ts
Normal file
13
packages/ui/src/lib/native/types.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type NativeDialogMode = "directory" | "file"
|
||||||
|
|
||||||
|
export interface NativeDialogFilter {
|
||||||
|
name?: string
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NativeDialogOptions {
|
||||||
|
mode: NativeDialogMode
|
||||||
|
title?: string
|
||||||
|
defaultPath?: string
|
||||||
|
filters?: NativeDialogFilter[]
|
||||||
|
}
|
||||||
86
packages/ui/src/lib/runtime-env.ts
Normal file
86
packages/ui/src/lib/runtime-env.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
export type HostRuntime = "electron" | "tauri" | "web"
|
||||||
|
export type PlatformKind = "desktop" | "mobile"
|
||||||
|
|
||||||
|
export interface RuntimeEnvironment {
|
||||||
|
host: HostRuntime
|
||||||
|
platform: PlatformKind
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI?: unknown
|
||||||
|
__TAURI__?: {
|
||||||
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
|
event?: {
|
||||||
|
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||||
|
}
|
||||||
|
dialog?: {
|
||||||
|
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||||
|
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHost(): HostRuntime {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = window as Window & { electronAPI?: unknown }
|
||||||
|
if (typeof win.electronAPI !== "undefined") {
|
||||||
|
return "electron"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof win.__TAURI__ !== "undefined") {
|
||||||
|
return "tauri"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
|
||||||
|
return "tauri"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPlatform(): PlatformKind {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return "desktop"
|
||||||
|
}
|
||||||
|
|
||||||
|
const uaData = (navigator as any).userAgentData
|
||||||
|
if (uaData?.mobile) {
|
||||||
|
return "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = navigator.userAgent.toLowerCase()
|
||||||
|
if (/android|iphone|ipad|ipod|blackberry|mini|windows phone|mobile|silk/.test(ua)) {
|
||||||
|
return "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "desktop"
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedEnv: RuntimeEnvironment | null = null
|
||||||
|
|
||||||
|
export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
||||||
|
if (cachedEnv) {
|
||||||
|
return cachedEnv
|
||||||
|
}
|
||||||
|
cachedEnv = {
|
||||||
|
host: detectHost(),
|
||||||
|
platform: detectPlatform(),
|
||||||
|
}
|
||||||
|
if (typeof console !== "undefined") {
|
||||||
|
const message = `[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`
|
||||||
|
console.info(message)
|
||||||
|
}
|
||||||
|
return cachedEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runtimeEnv = detectRuntimeEnvironment()
|
||||||
|
|
||||||
|
export const isElectronHost = () => runtimeEnv.host === "electron"
|
||||||
|
export const isTauriHost = () => runtimeEnv.host === "tauri"
|
||||||
|
export const isWebHost = () => runtimeEnv.host === "web"
|
||||||
|
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
|
||||||
@@ -14,16 +14,15 @@ import type {
|
|||||||
EventSessionIdle,
|
EventSessionIdle,
|
||||||
EventSessionUpdated,
|
EventSessionUpdated,
|
||||||
} from "@opencode-ai/sdk"
|
} from "@opencode-ai/sdk"
|
||||||
import { CODENOMAD_API_BASE } from "./api-client"
|
import { serverEvents } from "./server-events"
|
||||||
|
import type {
|
||||||
|
InstanceStreamEvent,
|
||||||
|
InstanceStreamStatus,
|
||||||
|
WorkspaceEventPayload,
|
||||||
|
} from "../../../server/src/api-types"
|
||||||
|
|
||||||
interface SSEConnection {
|
type InstanceEventPayload = Extract<WorkspaceEventPayload, { type: "instance.event" }>
|
||||||
instanceId: string
|
type InstanceStatusPayload = Extract<WorkspaceEventPayload, { type: "instance.eventStatus" }>
|
||||||
proxyPath: string
|
|
||||||
eventSource: EventSource
|
|
||||||
status: "connecting" | "connected" | "disconnected" | "error"
|
|
||||||
reconnectAttempts: number
|
|
||||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TuiToastEvent {
|
interface TuiToastEvent {
|
||||||
type: "tui.toast.show"
|
type: "tui.toast.show"
|
||||||
@@ -48,73 +47,43 @@ type SSEEvent =
|
|||||||
| EventPermissionReplied
|
| EventPermissionReplied
|
||||||
| EventLspUpdated
|
| EventLspUpdated
|
||||||
| TuiToastEvent
|
| TuiToastEvent
|
||||||
| { type: string; properties?: Record<string, unknown> } // Fallback for unknown event types
|
| { type: string; properties?: Record<string, unknown> }
|
||||||
|
|
||||||
const [connectionStatus, setConnectionStatus] = createSignal<
|
type ConnectionStatus = InstanceStreamStatus
|
||||||
Map<string, "connecting" | "connected" | "disconnected" | "error">
|
|
||||||
>(new Map())
|
const [connectionStatus, setConnectionStatus] = createSignal<Map<string, ConnectionStatus>>(new Map())
|
||||||
|
|
||||||
class SSEManager {
|
class SSEManager {
|
||||||
private connections = new Map<string, SSEConnection>()
|
constructor() {
|
||||||
private static readonly MAX_RECONNECT_DELAY_MS = 5000
|
serverEvents.on("instance.eventStatus", (event) => {
|
||||||
|
const payload = event as InstanceStatusPayload
|
||||||
connect(instanceId: string, proxyPath: string, reconnectAttempts = 0): void {
|
this.updateConnectionStatus(payload.instanceId, payload.status)
|
||||||
const existing = this.connections.get(instanceId)
|
if (payload.status === "disconnected") {
|
||||||
if (existing) {
|
if (payload.reason === "workspace stopped") {
|
||||||
this.clearReconnectTimer(existing)
|
return
|
||||||
existing.eventSource.close()
|
}
|
||||||
}
|
const reason = payload.reason ?? "Instance disconnected"
|
||||||
|
void this.onConnectionLost?.(payload.instanceId, reason)
|
||||||
const url = buildInstanceEventsUrl(proxyPath)
|
|
||||||
const eventSource = new EventSource(url)
|
|
||||||
|
|
||||||
const connection: SSEConnection = {
|
|
||||||
instanceId,
|
|
||||||
proxyPath,
|
|
||||||
eventSource,
|
|
||||||
status: "connecting",
|
|
||||||
reconnectAttempts,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connections.set(instanceId, connection)
|
|
||||||
this.updateConnectionStatus(instanceId, "connecting")
|
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
|
||||||
connection.status = "connected"
|
|
||||||
connection.reconnectAttempts = 0
|
|
||||||
this.updateConnectionStatus(instanceId, "connected")
|
|
||||||
console.log(`[SSE] Connected to instance ${instanceId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data)
|
|
||||||
this.handleEvent(instanceId, data)
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[SSE] Failed to parse event:", error)
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
serverEvents.on("instance.event", (event) => {
|
||||||
connection.status = "error"
|
const payload = event as InstanceEventPayload
|
||||||
this.updateConnectionStatus(instanceId, "error")
|
this.updateConnectionStatus(payload.instanceId, "connected")
|
||||||
console.error(`[SSE] Connection error for instance ${instanceId}`)
|
this.handleEvent(payload.instanceId, payload.event as SSEEvent)
|
||||||
this.handleConnectionError(instanceId, "Connection to instance lost")
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(instanceId: string): void {
|
seedStatus(instanceId: string, status: ConnectionStatus) {
|
||||||
const connection = this.connections.get(instanceId)
|
this.updateConnectionStatus(instanceId, status)
|
||||||
if (connection) {
|
|
||||||
this.clearReconnectTimer(connection)
|
|
||||||
connection.eventSource.close()
|
|
||||||
this.connections.delete(instanceId)
|
|
||||||
this.updateConnectionStatus(instanceId, "disconnected")
|
|
||||||
console.log(`[SSE] Disconnected from instance ${instanceId}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(instanceId: string, event: SSEEvent): void {
|
private handleEvent(instanceId: string, event: SSEEvent | InstanceStreamEvent): void {
|
||||||
|
if (!event || typeof event !== "object" || typeof (event as { type?: unknown }).type !== "string") {
|
||||||
|
console.warn("[SSE] Dropping malformed event", event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log("[SSE] Received event:", event.type, event)
|
console.log("[SSE] Received event:", event.type, event)
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@@ -159,35 +128,7 @@ class SSEManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleConnectionError(instanceId: string, reason: string): void {
|
private updateConnectionStatus(instanceId: string, status: ConnectionStatus): void {
|
||||||
const connection = this.connections.get(instanceId)
|
|
||||||
if (!connection) return
|
|
||||||
|
|
||||||
connection.eventSource.close()
|
|
||||||
|
|
||||||
const nextAttempt = connection.reconnectAttempts + 1
|
|
||||||
const delay = Math.min(nextAttempt * 1000, SSEManager.MAX_RECONNECT_DELAY_MS)
|
|
||||||
|
|
||||||
connection.reconnectAttempts = nextAttempt
|
|
||||||
connection.status = "connecting"
|
|
||||||
this.updateConnectionStatus(instanceId, "connecting")
|
|
||||||
|
|
||||||
console.warn(`[SSE] Attempting reconnect ${nextAttempt} for instance ${instanceId}`)
|
|
||||||
|
|
||||||
connection.reconnectTimer = setTimeout(() => {
|
|
||||||
connection.reconnectTimer = undefined
|
|
||||||
this.connect(instanceId, connection.proxyPath, nextAttempt)
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearReconnectTimer(connection: SSEConnection): void {
|
|
||||||
if (connection.reconnectTimer) {
|
|
||||||
clearTimeout(connection.reconnectTimer)
|
|
||||||
connection.reconnectTimer = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateConnectionStatus(instanceId: string, status: SSEConnection["status"]): void {
|
|
||||||
setConnectionStatus((prev) => {
|
setConnectionStatus((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
next.set(instanceId, status)
|
next.set(instanceId, status)
|
||||||
@@ -209,7 +150,7 @@ class SSEManager {
|
|||||||
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
onLspUpdated?: (instanceId: string, event: EventLspUpdated) => void
|
||||||
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
onConnectionLost?: (instanceId: string, reason: string) => void | Promise<void>
|
||||||
|
|
||||||
getStatus(instanceId: string): "connecting" | "connected" | "disconnected" | "error" | null {
|
getStatus(instanceId: string): ConnectionStatus | null {
|
||||||
return connectionStatus().get(instanceId) ?? null
|
return connectionStatus().get(instanceId) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,19 +159,4 @@ class SSEManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInstanceEventsUrl(proxyPath: string): string {
|
|
||||||
const normalized = normalizeProxyPath(proxyPath)
|
|
||||||
const base = stripTrailingSlashes(CODENOMAD_API_BASE)
|
|
||||||
return `${base}${normalized}/event`
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProxyPath(proxyPath: string): string {
|
|
||||||
const withLeading = proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`
|
|
||||||
return withLeading.replace(/\/+/g, "/").replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripTrailingSlashes(input: string): string {
|
|
||||||
return input.replace(/\/+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sseManager = new SSEManager()
|
export const sseManager = new SSEManager()
|
||||||
|
|||||||
@@ -21,14 +21,11 @@ function applyTheme(dark: boolean) {
|
|||||||
export function ThemeProvider(props: { children: JSX.Element }) {
|
export function ThemeProvider(props: { children: JSX.Element }) {
|
||||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
const { themePreference, setThemePreference } = useConfig()
|
const { themePreference, setThemePreference } = useConfig()
|
||||||
const [isDark, setIsDarkSignal] = createSignal(false)
|
const [isDark, setIsDarkSignal] = createSignal(true)
|
||||||
|
|
||||||
const resolveDarkTheme = () => {
|
const resolveDarkTheme = () => {
|
||||||
const preference = themePreference()
|
themePreference()
|
||||||
if (preference === "system") {
|
return true
|
||||||
return systemPrefersDark.matches
|
|
||||||
}
|
|
||||||
return preference === "dark"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyResolvedTheme = () => {
|
const applyResolvedTheme = () => {
|
||||||
@@ -42,11 +39,8 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const handleSystemThemeChange = (event: MediaQueryListEvent) => {
|
const handleSystemThemeChange = () => {
|
||||||
if (themePreference() === "system") {
|
applyResolvedTheme()
|
||||||
setIsDarkSignal(event.matches)
|
|
||||||
applyTheme(event.matches)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
systemPrefersDark.addEventListener("change", handleSystemThemeChange)
|
||||||
@@ -56,12 +50,12 @@ export function ThemeProvider(props: { children: JSX.Element }) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const setTheme = (dark: boolean) => {
|
const setTheme = (_dark: boolean) => {
|
||||||
setThemePreference(dark ? "dark" : "light")
|
setThemePreference("dark")
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme(!isDark())
|
setTheme(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
return <ThemeContext.Provider value={{ isDark, toggleTheme, setTheme }}>{props.children}</ThemeContext.Provider>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import App from "./App"
|
|||||||
import { ThemeProvider } from "./lib/theme"
|
import { ThemeProvider } from "./lib/theme"
|
||||||
import { ConfigProvider } from "./stores/preferences"
|
import { ConfigProvider } from "./stores/preferences"
|
||||||
import { InstanceConfigProvider } from "./stores/instance-config"
|
import { InstanceConfigProvider } from "./stores/instance-config"
|
||||||
|
import { runtimeEnv } from "./lib/runtime-env"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
import "@git-diff-view/solid/styles/diff-view-pure.css"
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ if (!root) {
|
|||||||
throw new Error("Root element not found")
|
throw new Error("Root element not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.documentElement.dataset.runtimeHost = runtimeEnv.host
|
||||||
|
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||||
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
() => (
|
() => (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
|
|||||||
@@ -6,30 +6,18 @@
|
|||||||
<title>CodeNomad</title>
|
<title>CodeNomad</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
/* html,
|
html,
|
||||||
body {
|
body {
|
||||||
background-color: #ffffff;
|
background-color: #1a1a1a;
|
||||||
color: #1a1a1a;
|
color: #e0e0e0;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) { */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
background-color: #1a1a1a;
|
|
||||||
color: #e0e0e0;
|
|
||||||
}
|
|
||||||
/* } */
|
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
;(function () {
|
;(function () {
|
||||||
try {
|
try {
|
||||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
// if (prefersDark) {
|
|
||||||
document.documentElement.setAttribute('data-theme', 'dark')
|
|
||||||
// } else {
|
|
||||||
// document.documentElement.removeAttribute('data-theme')
|
|
||||||
// }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to apply initial theme', error)
|
console.warn('Failed to apply initial theme', error)
|
||||||
}
|
}
|
||||||
|
|||||||
21
packages/ui/src/renderer/loading.html
Normal file
21
packages/ui/src/renderer/loading.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>CodeNomad</title>
|
||||||
|
<script>
|
||||||
|
;(function () {
|
||||||
|
try {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'dark')
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to apply initial theme', error)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="loading-root"></div>
|
||||||
|
<script type="module" src="./loading/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
111
packages/ui/src/renderer/loading/loading.css
Normal file
111
packages/ui/src/renderer/loading/loading.css
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--surface-base, #0f141f);
|
||||||
|
color: var(--text-primary, #cfd4dc);
|
||||||
|
font-family: var(--font-family-sans, "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-logo {
|
||||||
|
width: 180px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 20px 60px rgba(0, 0, 0, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary, #f4f6fb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-status {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-muted, #aeb3c4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(13, 16, 24, 0.85);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-top-color: #6ce3ff;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-controls {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted, #8f96a9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-controls button {
|
||||||
|
color: #8fb5ff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #ff9ea9;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
packages/ui/src/renderer/loading/main.tsx
Normal file
166
packages/ui/src/renderer/loading/main.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Show, createSignal, onCleanup, onMount } from "solid-js"
|
||||||
|
import { render } from "solid-js/web"
|
||||||
|
import iconUrl from "../../images/CodeNomad-Icon.png"
|
||||||
|
import { runtimeEnv, isTauriHost } from "../../lib/runtime-env"
|
||||||
|
import "../../index.css"
|
||||||
|
import "./loading.css"
|
||||||
|
|
||||||
|
const phrases = [
|
||||||
|
"Warming up the AI neurons…",
|
||||||
|
"Convincing the AI to stop daydreaming…",
|
||||||
|
"Polishing the AI’s code goggles…",
|
||||||
|
"Asking the AI to stop reorganizing your files…",
|
||||||
|
"Feeding the AI additional coffee…",
|
||||||
|
"Teaching the AI not to delete node_modules (again)…",
|
||||||
|
"Telling the AI to act natural before you arrive…",
|
||||||
|
"Asking the AI to please stop rewriting history…",
|
||||||
|
"Letting the AI stretch before its coding sprint…",
|
||||||
|
"Persuading the AI to give you keyboard control…",
|
||||||
|
]
|
||||||
|
|
||||||
|
interface CliStatus {
|
||||||
|
state?: string
|
||||||
|
url?: string | null
|
||||||
|
error?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriBridge {
|
||||||
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
|
event?: {
|
||||||
|
listen: (event: string, handler: (payload: { payload: unknown }) => void) => Promise<() => void>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPhrase(previous?: string) {
|
||||||
|
const filtered = phrases.filter((phrase) => phrase !== previous)
|
||||||
|
const source = filtered.length > 0 ? filtered : phrases
|
||||||
|
const index = Math.floor(Math.random() * source.length)
|
||||||
|
return source[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateTo(url?: string | null) {
|
||||||
|
if (!url) return
|
||||||
|
window.location.replace(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTauriBridge(): TauriBridge | null {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const bridge = (window as { __TAURI__?: TauriBridge }).__TAURI__
|
||||||
|
if (!bridge || !bridge.event || !bridge.invoke) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotateDocument() {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.documentElement.dataset.runtimeHost = runtimeEnv.host
|
||||||
|
document.documentElement.dataset.runtimePlatform = runtimeEnv.platform
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingApp() {
|
||||||
|
const [phrase, setPhrase] = createSignal(pickPhrase())
|
||||||
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [status, setStatus] = createSignal<string | null>(null)
|
||||||
|
|
||||||
|
const changePhrase = () => setPhrase(pickPhrase(phrase()))
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
annotateDocument()
|
||||||
|
setPhrase(pickPhrase())
|
||||||
|
const unsubscribers: Array<() => void> = []
|
||||||
|
|
||||||
|
async function bootstrapTauri(tauriBridge: TauriBridge | null) {
|
||||||
|
if (!tauriBridge || !tauriBridge.event || !tauriBridge.invoke) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const readyUnlisten = await tauriBridge.event.listen("cli:ready", (event) => {
|
||||||
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
navigateTo(payload.url)
|
||||||
|
})
|
||||||
|
const errorUnlisten = await tauriBridge.event.listen("cli:error", (event) => {
|
||||||
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
|
if (payload.error) {
|
||||||
|
setError(payload.error)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const statusUnlisten = await tauriBridge.event.listen("cli:status", (event) => {
|
||||||
|
const payload = (event?.payload as CliStatus) || {}
|
||||||
|
if (payload.state === "error" && payload.error) {
|
||||||
|
setError(payload.error)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.state && payload.state !== "ready") {
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
unsubscribers.push(readyUnlisten, errorUnlisten, statusUnlisten)
|
||||||
|
|
||||||
|
const result = await tauriBridge.invoke<CliStatus>("cli_get_status")
|
||||||
|
if (result?.state === "ready" && result.url) {
|
||||||
|
navigateTo(result.url)
|
||||||
|
} else if (result?.state === "error" && result.error) {
|
||||||
|
setError(result.error)
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(String(err))
|
||||||
|
setStatus("Encountered an issue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTauriHost()) {
|
||||||
|
void bootstrapTauri(getTauriBridge())
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
unsubscribers.forEach((unsubscribe) => {
|
||||||
|
try {
|
||||||
|
unsubscribe()
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="loading-wrapper" role="status" aria-live="polite">
|
||||||
|
<img src={iconUrl} alt="CodeNomad" class="loading-logo" width="180" height="180" />
|
||||||
|
<div class="loading-heading">
|
||||||
|
<h1 class="loading-title">CodeNomad</h1>
|
||||||
|
<Show when={status()}>{(statusText) => <p class="loading-status">{statusText()}</p>}</Show>
|
||||||
|
</div>
|
||||||
|
<div class="loading-card">
|
||||||
|
<div class="loading-row">
|
||||||
|
<div class="spinner" aria-hidden="true" />
|
||||||
|
<span>{phrase()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="phrase-controls">
|
||||||
|
<button type="button" onClick={changePhrase}>
|
||||||
|
Show another
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error() && <div class="loading-error">{error()}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = document.getElementById("loading-root")
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error("Loading root element not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
render(() => <LoadingApp />, root)
|
||||||
@@ -52,7 +52,9 @@ function workspaceDescriptorToInstance(descriptor: WorkspaceDescriptor): Instanc
|
|||||||
error: descriptor.error,
|
error: descriptor.error,
|
||||||
client: existing?.client ?? null,
|
client: existing?.client ?? null,
|
||||||
metadata: existing?.metadata,
|
metadata: existing?.metadata,
|
||||||
binaryPath: descriptor.binaryLabel,
|
binaryPath: descriptor.binaryId ?? descriptor.binaryLabel ?? existing?.binaryPath,
|
||||||
|
binaryLabel: descriptor.binaryLabel,
|
||||||
|
binaryVersion: descriptor.binaryVersion ?? existing?.binaryVersion,
|
||||||
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
environmentVariables: existing?.environmentVariables ?? preferences().environmentVariables ?? {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +89,6 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
|||||||
|
|
||||||
if (instance.client) {
|
if (instance.client) {
|
||||||
sdkManager.destroyClient(descriptor.id)
|
sdkManager.destroyClient(descriptor.id)
|
||||||
sseManager.disconnect(descriptor.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
const client = sdkManager.createClient(descriptor.id, nextProxyPath)
|
||||||
@@ -97,7 +98,7 @@ function attachClient(descriptor: WorkspaceDescriptor) {
|
|||||||
proxyPath: nextProxyPath,
|
proxyPath: nextProxyPath,
|
||||||
status: "ready",
|
status: "ready",
|
||||||
})
|
})
|
||||||
sseManager.connect(descriptor.id, nextProxyPath)
|
sseManager.seedStatus(descriptor.id, "connecting")
|
||||||
void hydrateInstanceData(descriptor.id).catch((error) => {
|
void hydrateInstanceData(descriptor.id).catch((error) => {
|
||||||
console.error("Failed to hydrate instance data", error)
|
console.error("Failed to hydrate instance data", error)
|
||||||
})
|
})
|
||||||
@@ -110,7 +111,7 @@ function releaseInstanceResources(instanceId: string) {
|
|||||||
if (instance.client) {
|
if (instance.client) {
|
||||||
sdkManager.destroyClient(instanceId)
|
sdkManager.destroyClient(instanceId)
|
||||||
}
|
}
|
||||||
sseManager.disconnect(instanceId)
|
sseManager.seedStatus(instanceId, "disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateInstanceData(instanceId: string) {
|
async function hydrateInstanceData(instanceId: string) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface Preferences {
|
|||||||
diffViewMode: DiffViewMode
|
diffViewMode: DiffViewMode
|
||||||
toolOutputExpansion: ExpansionPreference
|
toolOutputExpansion: ExpansionPreference
|
||||||
diagnosticsExpansion: ExpansionPreference
|
diagnosticsExpansion: ExpansionPreference
|
||||||
|
showUsageMetrics: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeBinary {
|
export interface OpenCodeBinary {
|
||||||
@@ -60,6 +61,7 @@ const defaultPreferences: Preferences = {
|
|||||||
diffViewMode: "split",
|
diffViewMode: "split",
|
||||||
toolOutputExpansion: "expanded",
|
toolOutputExpansion: "expanded",
|
||||||
diagnosticsExpansion: "expanded",
|
diagnosticsExpansion: "expanded",
|
||||||
|
showUsageMetrics: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
function deepEqual(a: unknown, b: unknown): boolean {
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
@@ -92,6 +94,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
|||||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||||
|
showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +273,10 @@ function toggleShowThinkingBlocks(): void {
|
|||||||
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
updatePreferences({ showThinkingBlocks: !preferences().showThinkingBlocks })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleUsageMetrics(): void {
|
||||||
|
updatePreferences({ showUsageMetrics: !preferences().showUsageMetrics })
|
||||||
|
}
|
||||||
|
|
||||||
function addRecentFolder(path: string): void {
|
function addRecentFolder(path: string): void {
|
||||||
updateConfig((draft) => {
|
updateConfig((draft) => {
|
||||||
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
draft.recentFolders = buildRecentFolderList(path, draft.recentFolders)
|
||||||
@@ -370,6 +377,7 @@ interface ConfigContextValue {
|
|||||||
setThemePreference: typeof setThemePreference
|
setThemePreference: typeof setThemePreference
|
||||||
updateConfig: typeof updateConfig
|
updateConfig: typeof updateConfig
|
||||||
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
toggleShowThinkingBlocks: typeof toggleShowThinkingBlocks
|
||||||
|
toggleUsageMetrics: typeof toggleUsageMetrics
|
||||||
setDiffViewMode: typeof setDiffViewMode
|
setDiffViewMode: typeof setDiffViewMode
|
||||||
setToolOutputExpansion: typeof setToolOutputExpansion
|
setToolOutputExpansion: typeof setToolOutputExpansion
|
||||||
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
setDiagnosticsExpansion: typeof setDiagnosticsExpansion
|
||||||
@@ -400,6 +408,7 @@ const configContextValue: ConfigContextValue = {
|
|||||||
setThemePreference,
|
setThemePreference,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleUsageMetrics,
|
||||||
setDiffViewMode,
|
setDiffViewMode,
|
||||||
setToolOutputExpansion,
|
setToolOutputExpansion,
|
||||||
setDiagnosticsExpansion,
|
setDiagnosticsExpansion,
|
||||||
@@ -454,6 +463,7 @@ export {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updatePreferences,
|
updatePreferences,
|
||||||
toggleShowThinkingBlocks,
|
toggleShowThinkingBlocks,
|
||||||
|
toggleUsageMetrics,
|
||||||
recentFolders,
|
recentFolders,
|
||||||
addRecentFolder,
|
addRecentFolder,
|
||||||
removeRecentFolder,
|
removeRecentFolder,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
loading,
|
loading,
|
||||||
setLoading,
|
setLoading,
|
||||||
} from "./session-state"
|
} from "./session-state"
|
||||||
import { getDefaultModel, isModelValid } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT, getDefaultModel, isModelValid } from "./session-models"
|
||||||
import {
|
import {
|
||||||
computeDisplayParts,
|
computeDisplayParts,
|
||||||
clearSessionIndex,
|
clearSessionIndex,
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
initializePartVersion,
|
initializePartVersion,
|
||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
|
rebuildSessionUsage,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
|
|
||||||
@@ -212,18 +213,25 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
|
|||||||
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
const initialModel = initialProvider?.models.find((m) => m.id === session.model.modelId)
|
||||||
const initialContextWindow = initialModel?.limit?.context ?? 0
|
const initialContextWindow = initialModel?.limit?.context ?? 0
|
||||||
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
const initialSubscriptionModel = initialModel?.cost?.input === 0 && initialModel?.cost?.output === 0
|
||||||
const initialContextPercent = initialContextWindow > 0 ? 0 : null
|
const initialOutputLimit =
|
||||||
|
initialModel?.limit?.output && initialModel.limit.output > 0
|
||||||
|
? initialModel.limit.output
|
||||||
|
: DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
|
const initialContextAvailable = initialContextWindow > 0 ? initialContextWindow : null
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(session.id, {
|
instanceInfo.set(session.id, {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: initialContextWindow,
|
contextWindow: initialContextWindow,
|
||||||
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
isSubscriptionModel: Boolean(initialSubscriptionModel),
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: initialContextPercent,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: initialOutputLimit,
|
||||||
|
contextAvailableTokens: initialContextAvailable,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -310,18 +318,23 @@ async function forkSession(
|
|||||||
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
const forkModel = forkProvider?.models.find((m) => m.id === forkedSession.model.modelId)
|
||||||
const forkContextWindow = forkModel?.limit?.context ?? 0
|
const forkContextWindow = forkModel?.limit?.context ?? 0
|
||||||
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
const forkSubscriptionModel = forkModel?.cost?.input === 0 && forkModel?.cost?.output === 0
|
||||||
const forkContextPercent = forkContextWindow > 0 ? 0 : null
|
const forkOutputLimit =
|
||||||
|
forkModel?.limit?.output && forkModel.limit.output > 0 ? forkModel.limit.output : DEFAULT_MODEL_OUTPUT_LIMIT
|
||||||
|
const forkContextAvailable = forkContextWindow > 0 ? forkContextWindow : null
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(forkedSession.id, {
|
instanceInfo.set(forkedSession.id, {
|
||||||
tokens: 0,
|
|
||||||
cost: 0,
|
cost: 0,
|
||||||
contextWindow: forkContextWindow,
|
contextWindow: forkContextWindow,
|
||||||
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
isSubscriptionModel: Boolean(forkSubscriptionModel),
|
||||||
contextUsageTokens: 0,
|
inputTokens: 0,
|
||||||
contextUsagePercent: forkContextPercent,
|
outputTokens: 0,
|
||||||
|
reasoningTokens: 0,
|
||||||
|
actualUsageTokens: 0,
|
||||||
|
modelOutputLimit: forkOutputLimit,
|
||||||
|
contextAvailableTokens: forkContextAvailable,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -587,6 +600,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
})
|
})
|
||||||
|
|
||||||
rebuildSessionIndex(instanceId, sessionId, messages)
|
rebuildSessionIndex(instanceId, sessionId, messages)
|
||||||
|
rebuildSessionUsage(instanceId, sessionId, messagesInfo)
|
||||||
|
|
||||||
setMessagesLoaded((prev) => {
|
setMessagesLoaded((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
@@ -595,6 +609,7 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
next.set(instanceId, loadedSet)
|
next.set(instanceId, loadedSet)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load messages:", error)
|
console.error("Failed to load messages:", error)
|
||||||
throw error
|
throw error
|
||||||
@@ -609,16 +624,16 @@ async function loadMessages(instanceId: string, sessionId: string, force = false
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSessionInfo(instanceId, sessionId)
|
updateSessionInfo(instanceId, sessionId)
|
||||||
refreshPermissionsForSession(instanceId, sessionId)
|
refreshPermissionsForSession(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
fetchAgents,
|
fetchAgents,
|
||||||
fetchProviders,
|
fetchProviders,
|
||||||
|
|
||||||
fetchSessions,
|
fetchSessions,
|
||||||
forkSession,
|
forkSession,
|
||||||
loadMessages,
|
loadMessages,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
|
updateUsageFromMessageInfo,
|
||||||
} from "./session-messages"
|
} from "./session-messages"
|
||||||
import { loadMessages } from "./session-api"
|
import { loadMessages } from "./session-api"
|
||||||
import { setSessionCompactionState } from "./session-compaction"
|
import { setSessionCompactionState } from "./session-compaction"
|
||||||
@@ -305,6 +306,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.messagesInfo.set(info.id, info)
|
session.messagesInfo.set(info.id, info)
|
||||||
|
updateUsageFromMessageInfo(instanceId, info.sessionID, info)
|
||||||
withSession(instanceId, info.sessionID, () => {
|
withSession(instanceId, info.sessionID, () => {
|
||||||
/* ensure reactivity */
|
/* ensure reactivity */
|
||||||
})
|
})
|
||||||
@@ -314,6 +316,7 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): void {
|
||||||
const info = event.properties?.info
|
const info = event.properties?.info
|
||||||
if (!info) return
|
if (!info) return
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { Message, MessageDisplayParts } from "../types/message"
|
import type { Message, MessageDisplayParts } from "../types/message"
|
||||||
import { partHasRenderableText } from "../types/message"
|
import { partHasRenderableText, type MessageInfo } from "../types/message"
|
||||||
import type { Provider } from "../types/session"
|
import type { Provider } from "../types/session"
|
||||||
|
|
||||||
import { decodeHtmlEntities } from "../lib/markdown"
|
import { decodeHtmlEntities } from "../lib/markdown"
|
||||||
import { providers, sessions, setSessionInfoByInstance } from "./session-state"
|
import { providers, sessions, sessionInfoByInstance, setSessionInfoByInstance } from "./session-state"
|
||||||
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
import { DEFAULT_MODEL_OUTPUT_LIMIT } from "./session-models"
|
||||||
|
|
||||||
interface SessionIndexCache {
|
interface SessionIndexCache {
|
||||||
@@ -11,7 +11,153 @@ interface SessionIndexCache {
|
|||||||
partIndex: Map<string, Map<string, number>>
|
partIndex: Map<string, Map<string, number>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssistantUsageEntry {
|
||||||
|
info: MessageInfo
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
reasoningTokens: number
|
||||||
|
combinedTokens: number
|
||||||
|
cost: number
|
||||||
|
hasContextUsage: boolean
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionUsageState {
|
||||||
|
entries: Map<string, AssistantUsageEntry>
|
||||||
|
totalInputTokens: number
|
||||||
|
totalOutputTokens: number
|
||||||
|
totalReasoningTokens: number
|
||||||
|
totalCost: number
|
||||||
|
latestEntry: AssistantUsageEntry | null
|
||||||
|
}
|
||||||
|
|
||||||
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
const sessionIndexes = new Map<string, Map<string, SessionIndexCache>>()
|
||||||
|
const sessionUsageStates = new Map<string, Map<string, SessionUsageState>>()
|
||||||
|
|
||||||
|
function createEmptyUsageState(): SessionUsageState {
|
||||||
|
return {
|
||||||
|
entries: new Map(),
|
||||||
|
totalInputTokens: 0,
|
||||||
|
totalOutputTokens: 0,
|
||||||
|
totalReasoningTokens: 0,
|
||||||
|
totalCost: 0,
|
||||||
|
latestEntry: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsageInstance(instanceId: string): Map<string, SessionUsageState> {
|
||||||
|
let usageMap = sessionUsageStates.get(instanceId)
|
||||||
|
if (!usageMap) {
|
||||||
|
usageMap = new Map()
|
||||||
|
sessionUsageStates.set(instanceId, usageMap)
|
||||||
|
}
|
||||||
|
return usageMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSessionUsageState(instanceId: string, sessionId: string): SessionUsageState {
|
||||||
|
const usageMap = getUsageInstance(instanceId)
|
||||||
|
let state = usageMap.get(sessionId)
|
||||||
|
if (!state) {
|
||||||
|
state = createEmptyUsageState()
|
||||||
|
usageMap.set(sessionId, state)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeLatestEntry(state: SessionUsageState) {
|
||||||
|
state.latestEntry = null
|
||||||
|
for (const entry of state.entries.values()) {
|
||||||
|
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
|
||||||
|
state.latestEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantUsage(info: MessageInfo): AssistantUsageEntry | null {
|
||||||
|
if (!info || info.role !== "assistant") return null
|
||||||
|
if (!info.tokens) return null
|
||||||
|
const tokens = info.tokens
|
||||||
|
const inputTokens = tokens.input ?? 0
|
||||||
|
const outputTokens = tokens.output ?? 0
|
||||||
|
const reasoningTokens = tokens.reasoning ?? 0
|
||||||
|
if (inputTokens === 0 && outputTokens === 0 && reasoningTokens === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const cacheReadTokens = tokens.cache?.read ?? 0
|
||||||
|
const cacheWriteTokens = tokens.cache?.write ?? 0
|
||||||
|
const combinedTokens = info.summary
|
||||||
|
? outputTokens
|
||||||
|
: inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
||||||
|
const cost = info.cost ?? 0
|
||||||
|
const hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
inputTokens,
|
||||||
|
outputTokens,
|
||||||
|
reasoningTokens,
|
||||||
|
combinedTokens,
|
||||||
|
cost,
|
||||||
|
hasContextUsage,
|
||||||
|
timestamp: info.time?.created ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUsageEntry(state: SessionUsageState, messageId: string | undefined) {
|
||||||
|
if (!messageId) return
|
||||||
|
const existing = state.entries.get(messageId)
|
||||||
|
if (!existing) return
|
||||||
|
state.entries.delete(messageId)
|
||||||
|
state.totalInputTokens -= existing.inputTokens
|
||||||
|
state.totalOutputTokens -= existing.outputTokens
|
||||||
|
state.totalReasoningTokens -= existing.reasoningTokens
|
||||||
|
state.totalCost -= existing.cost
|
||||||
|
if (state.latestEntry?.info.id === messageId) {
|
||||||
|
recomputeLatestEntry(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUsageEntry(state: SessionUsageState, entry: AssistantUsageEntry) {
|
||||||
|
state.entries.set(entry.info.id, entry)
|
||||||
|
state.totalInputTokens += entry.inputTokens
|
||||||
|
state.totalOutputTokens += entry.outputTokens
|
||||||
|
state.totalReasoningTokens += entry.reasoningTokens
|
||||||
|
state.totalCost += entry.cost
|
||||||
|
if (!state.latestEntry || entry.timestamp >= state.latestEntry.timestamp) {
|
||||||
|
state.latestEntry = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUsageFromMessageInfo(instanceId: string, sessionId: string, info: MessageInfo) {
|
||||||
|
const messageId = typeof info.id === "string" ? info.id : undefined
|
||||||
|
if (!messageId) return
|
||||||
|
const state = getSessionUsageState(instanceId, sessionId)
|
||||||
|
removeUsageEntry(state, messageId)
|
||||||
|
const entry = extractAssistantUsage(info)
|
||||||
|
if (entry) {
|
||||||
|
addUsageEntry(state, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildSessionUsage(instanceId: string, sessionId: string, messagesInfo: Map<string, MessageInfo>) {
|
||||||
|
const usageMap = getUsageInstance(instanceId)
|
||||||
|
const nextState = createEmptyUsageState()
|
||||||
|
for (const info of messagesInfo.values()) {
|
||||||
|
const entry = extractAssistantUsage(info)
|
||||||
|
if (entry) {
|
||||||
|
addUsageEntry(nextState, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usageMap.set(sessionId, nextState)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionUsage(instanceId: string, sessionId: string) {
|
||||||
|
const usageMap = sessionUsageStates.get(instanceId)
|
||||||
|
if (!usageMap) return
|
||||||
|
usageMap.delete(sessionId)
|
||||||
|
if (usageMap.size === 0) {
|
||||||
|
sessionUsageStates.delete(instanceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function decodeTextSegment(segment: any): any {
|
function decodeTextSegment(segment: any): any {
|
||||||
if (typeof segment === "string") {
|
if (typeof segment === "string") {
|
||||||
@@ -163,10 +309,12 @@ function clearSessionIndex(instanceId: string, sessionId: string) {
|
|||||||
sessionIndexes.delete(instanceId)
|
sessionIndexes.delete(instanceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
clearSessionUsage(instanceId, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeSessionIndexes(instanceId: string) {
|
function removeSessionIndexes(instanceId: string) {
|
||||||
sessionIndexes.delete(instanceId)
|
sessionIndexes.delete(instanceId)
|
||||||
|
sessionUsageStates.delete(instanceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSessionInfo(instanceId: string, sessionId: string) {
|
function updateSessionInfo(instanceId: string, sessionId: string) {
|
||||||
@@ -176,52 +324,67 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
|
|||||||
const session = instanceSessions.get(sessionId)
|
const session = instanceSessions.get(sessionId)
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
let tokens = 0
|
|
||||||
let cost = 0
|
|
||||||
let contextWindow = 0
|
let contextWindow = 0
|
||||||
let isSubscriptionModel = false
|
let isSubscriptionModel = false
|
||||||
let modelID = ""
|
let modelID = ""
|
||||||
let providerID = ""
|
let providerID = ""
|
||||||
let actualUsageTokens = 0
|
let actualUsageTokens = 0
|
||||||
let contextUsagePercent: number | null = null
|
|
||||||
let hasContextUsage = false
|
|
||||||
|
|
||||||
if (session.messagesInfo.size > 0) {
|
const usageState = getSessionUsageState(instanceId, sessionId)
|
||||||
const messageArray = Array.from(session.messagesInfo.values()).reverse()
|
const hasUsageEntries = usageState.entries.size > 0
|
||||||
|
|
||||||
for (const info of messageArray) {
|
let totalInputTokens = hasUsageEntries ? usageState.totalInputTokens : 0
|
||||||
if (info.role === "assistant" && info.tokens) {
|
let totalOutputTokens = hasUsageEntries ? usageState.totalOutputTokens : 0
|
||||||
const usage = info.tokens
|
let totalReasoningTokens = hasUsageEntries ? usageState.totalReasoningTokens : 0
|
||||||
|
let totalCost = hasUsageEntries ? usageState.totalCost : 0
|
||||||
|
|
||||||
if (usage.output > 0) {
|
let latestAssistantInfo: MessageInfo | null = usageState.latestEntry?.info ?? null
|
||||||
const inputTokens = usage.input || 0
|
let latestHasContextUsage = usageState.latestEntry?.hasContextUsage ?? false
|
||||||
const reasoningTokens = usage.reasoning || 0
|
const previousInfo = sessionInfoByInstance().get(instanceId)?.get(sessionId)
|
||||||
const cacheReadTokens = usage.cache?.read || 0
|
let contextAvailableTokens: number | null = null
|
||||||
const cacheWriteTokens = usage.cache?.write || 0
|
let contextAvailableFromPrevious = false
|
||||||
const outputTokens = usage.output || 0
|
|
||||||
|
|
||||||
if (info.summary) {
|
if (latestAssistantInfo) {
|
||||||
tokens = outputTokens
|
const infoAny = latestAssistantInfo as any
|
||||||
} else {
|
actualUsageTokens = usageState.latestEntry?.combinedTokens ?? 0
|
||||||
tokens = inputTokens + cacheReadTokens + cacheWriteTokens + outputTokens + reasoningTokens
|
modelID = infoAny.modelID || ""
|
||||||
}
|
providerID = infoAny.providerID || ""
|
||||||
|
} else if (previousInfo) {
|
||||||
|
totalInputTokens = previousInfo.inputTokens
|
||||||
|
totalOutputTokens = previousInfo.outputTokens
|
||||||
|
totalReasoningTokens = previousInfo.reasoningTokens
|
||||||
|
totalCost = previousInfo.cost
|
||||||
|
actualUsageTokens = previousInfo.actualUsageTokens
|
||||||
|
|
||||||
cost = info.cost || 0
|
const previousContextWindow = previousInfo.contextWindow
|
||||||
actualUsageTokens = tokens
|
const previousContextAvailable = previousInfo.contextAvailableTokens ?? null
|
||||||
hasContextUsage = inputTokens + cacheReadTokens + cacheWriteTokens > 0
|
const previousHasContextUsage =
|
||||||
|
previousContextAvailable !== null && previousContextWindow > 0
|
||||||
|
? previousContextAvailable < previousContextWindow
|
||||||
|
: false
|
||||||
|
|
||||||
modelID = info.modelID || ""
|
if (contextWindow === 0) {
|
||||||
providerID = info.providerID || ""
|
contextWindow = previousContextWindow
|
||||||
isSubscriptionModel = cost === 0
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (contextWindow !== previousContextWindow) {
|
||||||
|
contextAvailableTokens = null
|
||||||
|
contextAvailableFromPrevious = false
|
||||||
|
latestHasContextUsage = previousHasContextUsage
|
||||||
|
} else {
|
||||||
|
contextAvailableTokens = previousContextAvailable
|
||||||
|
contextAvailableFromPrevious = true
|
||||||
|
latestHasContextUsage = previousHasContextUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubscriptionModel = previousInfo.isSubscriptionModel
|
||||||
}
|
}
|
||||||
|
|
||||||
const instanceProviders = providers().get(instanceId) || []
|
const instanceProviders = providers().get(instanceId) || []
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sessionModel = session.model
|
const sessionModel = session.model
|
||||||
let selectedModel: Provider["models"][number] | undefined
|
let selectedModel: Provider["models"][number] | undefined
|
||||||
|
|
||||||
@@ -252,30 +415,32 @@ function updateSessionInfo(instanceId: string, sessionId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
const outputBudget = Math.min(modelOutputLimit, DEFAULT_MODEL_OUTPUT_LIMIT)
|
||||||
let contextUsageTokens = 0
|
|
||||||
|
|
||||||
if (hasContextUsage && actualUsageTokens > 0) {
|
if (!contextAvailableFromPrevious) {
|
||||||
contextUsageTokens = actualUsageTokens + outputBudget
|
|
||||||
if (contextWindow > 0) {
|
if (contextWindow > 0) {
|
||||||
const percent = Math.round((contextUsageTokens / contextWindow) * 100)
|
if (latestHasContextUsage && actualUsageTokens > 0) {
|
||||||
contextUsagePercent = Math.min(100, Math.max(0, percent))
|
contextAvailableTokens = Math.max(contextWindow - (actualUsageTokens + outputBudget), 0)
|
||||||
|
} else {
|
||||||
|
contextAvailableTokens = contextWindow
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
contextUsagePercent = null
|
contextAvailableTokens = null
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
contextUsagePercent = contextWindow > 0 ? 0 : null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionInfoByInstance((prev) => {
|
setSessionInfoByInstance((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const instanceInfo = new Map(prev.get(instanceId))
|
const instanceInfo = new Map(prev.get(instanceId))
|
||||||
instanceInfo.set(sessionId, {
|
instanceInfo.set(sessionId, {
|
||||||
tokens,
|
cost: totalCost,
|
||||||
cost,
|
|
||||||
contextWindow,
|
contextWindow,
|
||||||
isSubscriptionModel,
|
isSubscriptionModel,
|
||||||
contextUsageTokens,
|
inputTokens: totalInputTokens,
|
||||||
contextUsagePercent,
|
outputTokens: totalOutputTokens,
|
||||||
|
reasoningTokens: totalReasoningTokens,
|
||||||
|
actualUsageTokens,
|
||||||
|
modelOutputLimit,
|
||||||
|
contextAvailableTokens,
|
||||||
})
|
})
|
||||||
next.set(instanceId, instanceInfo)
|
next.set(instanceId, instanceInfo)
|
||||||
return next
|
return next
|
||||||
@@ -290,6 +455,8 @@ export {
|
|||||||
initializePartVersion,
|
initializePartVersion,
|
||||||
normalizeMessagePart,
|
normalizeMessagePart,
|
||||||
rebuildSessionIndex,
|
rebuildSessionIndex,
|
||||||
|
rebuildSessionUsage,
|
||||||
removeSessionIndexes,
|
removeSessionIndexes,
|
||||||
updateSessionInfo,
|
updateSessionInfo,
|
||||||
|
updateUsageFromMessageInfo,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { createSignal } from "solid-js"
|
|||||||
import type { Session, Agent, Provider } from "../types/session"
|
import type { Session, Agent, Provider } from "../types/session"
|
||||||
|
|
||||||
export interface SessionInfo {
|
export interface SessionInfo {
|
||||||
tokens: number
|
|
||||||
cost: number
|
cost: number
|
||||||
contextWindow: number
|
contextWindow: number
|
||||||
isSubscriptionModel: boolean
|
isSubscriptionModel: boolean
|
||||||
contextUsageTokens: number
|
inputTokens: number
|
||||||
contextUsagePercent: number | null
|
outputTokens: number
|
||||||
|
reasoningTokens: number
|
||||||
|
actualUsageTokens: number
|
||||||
|
modelOutputLimit: number
|
||||||
|
contextAvailableTokens: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
const [sessions, setSessions] = createSignal<Map<string, Map<string, Session>>>(new Map())
|
||||||
|
|||||||
@@ -27,6 +27,43 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.connection-status-shortcut-action {
|
||||||
|
@apply flex items-center justify-center gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-button {
|
||||||
|
@apply inline-flex items-center gap-2 px-3 py-1 text-sm font-medium border rounded-md transition-colors;
|
||||||
|
border-color: var(--border-base);
|
||||||
|
background-color: var(--surface-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-button:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-button:focus-visible {
|
||||||
|
@apply ring-2 ring-offset-1;
|
||||||
|
ring-color: var(--accent-primary);
|
||||||
|
ring-offset-color: var(--surface-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-shortcut-hint {
|
||||||
|
@apply inline-flex items-center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.connection-status-shortcut-hint {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-status-button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.message-stream {
|
.message-stream {
|
||||||
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
@apply flex-1 min-h-0 overflow-y-auto flex flex-col gap-1;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
@apply flex items-end gap-2 p-3;
|
@apply flex items-end gap-2 p-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-input-field {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-input {
|
.prompt-input {
|
||||||
@apply flex-1 min-h-[96px] max-h-[200px] p-2.5 border rounded-md text-sm resize-none outline-none transition-colors;
|
@apply flex-1 w-full min-h-[56px] max-h-[96px] px-3 pt-2.5 pb-12 border rounded-md text-sm resize-none outline-none transition-colors;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -19,6 +23,45 @@
|
|||||||
line-height: var(--line-height-normal);
|
line-height: var(--line-height-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-input-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input-overlay.shell-mode {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-overlay-text {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-overlay-warning {
|
||||||
|
color: var(--status-warning);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-overlay-shell-active {
|
||||||
|
color: var(--status-success);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-overlay-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.prompt-input.shell-mode {
|
.prompt-input.shell-mode {
|
||||||
border-color: var(--status-success);
|
border-color: var(--status-success);
|
||||||
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
box-shadow: inset 0 0 0 1px rgba(76, 175, 80, 0.4);
|
||||||
@@ -80,9 +123,6 @@
|
|||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-input-hints {
|
|
||||||
@apply px-4 pb-2 flex justify-between items-center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
.hint {
|
||||||
@apply text-xs;
|
@apply text-xs;
|
||||||
@@ -141,3 +181,26 @@
|
|||||||
.attachment-download:hover {
|
.attachment-download:hover {
|
||||||
background-color: var(--attachment-chip-ring);
|
background-color: var(--attachment-chip-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.prompt-input-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input {
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.prompt-input {
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
padding-bottom: 2.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-input-wrapper {
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ session-sidebar-controls .selector-trigger-primary {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-selector-hints {
|
||||||
|
@apply flex items-center gap-2 w-full;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-selector-hint--left,
|
||||||
|
.sidebar-selector-hint--right {
|
||||||
|
@apply flex-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-selector-hint--left {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-selector-hint--right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
.session-header-hints {
|
.session-header-hints {
|
||||||
@apply flex-shrink-0;
|
@apply flex-shrink-0;
|
||||||
}
|
}
|
||||||
|
|||||||
38
packages/ui/src/types/global.d.ts
vendored
38
packages/ui/src/types/global.d.ts
vendored
@@ -1,8 +1,46 @@
|
|||||||
export {}
|
export {}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
interface ElectronDialogFilter {
|
||||||
|
name?: string
|
||||||
|
extensions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDialogOptions {
|
||||||
|
mode: "directory" | "file"
|
||||||
|
title?: string
|
||||||
|
defaultPath?: string
|
||||||
|
filters?: ElectronDialogFilter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronDialogResult {
|
||||||
|
canceled?: boolean
|
||||||
|
paths?: string[]
|
||||||
|
path?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElectronAPI {
|
||||||
|
onCliStatus?: (callback: (data: unknown) => void) => () => void
|
||||||
|
onCliError?: (callback: (data: unknown) => void) => () => void
|
||||||
|
getCliStatus?: () => Promise<unknown>
|
||||||
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriDialogModule {
|
||||||
|
open?: (options: Record<string, unknown>) => Promise<string | string[] | null>
|
||||||
|
save?: (options: Record<string, unknown>) => Promise<string | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TauriBridge {
|
||||||
|
invoke?: <T = unknown>(cmd: string, args?: Record<string, unknown>) => Promise<T>
|
||||||
|
dialog?: TauriDialogModule
|
||||||
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
__CODENOMAD_API_BASE__?: string
|
__CODENOMAD_API_BASE__?: string
|
||||||
__CODENOMAD_EVENTS_URL__?: string
|
__CODENOMAD_EVENTS_URL__?: string
|
||||||
|
electronAPI?: ElectronAPI
|
||||||
|
__TAURI__?: TauriBridge
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,5 +39,7 @@ export interface Instance {
|
|||||||
client: OpencodeClient | null
|
client: OpencodeClient | null
|
||||||
metadata?: InstanceMetadata
|
metadata?: InstanceMetadata
|
||||||
binaryPath?: string
|
binaryPath?: string
|
||||||
|
binaryLabel?: string
|
||||||
|
binaryVersion?: string
|
||||||
environmentVariables?: Record<string, string>
|
environmentVariables?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|||||||
37
packages/ui/vite.config.js
Normal file
37
packages/ui/vite.config.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
import { dirname, resolve } from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: "./src/renderer",
|
||||||
|
plugins: [solid()],
|
||||||
|
css: {
|
||||||
|
postcss: "./postcss.config.js",
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ["lucide-solid"],
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: ["lucide-solid"],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||||
|
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -24,5 +24,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: "dist",
|
outDir: "dist",
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "./src/renderer/index.html"),
|
||||||
|
loading: resolve(__dirname, "./src/renderer/loading.html"),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user