Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f5a0ab44 | ||
|
|
d9f7735c94 | ||
|
|
4aae8ab720 | ||
|
|
b83c69f002 | ||
|
|
c74e0b89f7 | ||
|
|
9ee7ff9509 | ||
|
|
74a21d6418 | ||
|
|
15f390ade7 | ||
|
|
bb4e3815d1 | ||
|
|
8fa0175b98 | ||
|
|
ee59622b98 | ||
|
|
a1452ad353 | ||
|
|
0c9284e57e | ||
|
|
0766185ff6 | ||
|
|
effb30d98e | ||
|
|
4da69b5a20 | ||
|
|
3d3337c7b8 | ||
|
|
f0b43dbc68 | ||
|
|
b0eb9aec64 | ||
|
|
8c48455ae5 | ||
|
|
292f695395 | ||
|
|
4ea710c735 | ||
|
|
f5d4cb6917 | ||
|
|
1e53e06424 | ||
|
|
2530cd4fc8 | ||
|
|
b25fb0073e | ||
|
|
c01846f7fd |
12
.github/workflows/release-ui.yml
vendored
12
.github/workflows/release-ui.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Release UI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_call: {}
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
@@ -12,7 +12,8 @@ env:
|
||||
|
||||
jobs:
|
||||
release-ui:
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Automated via reusable call (main releases); manual runs allowed on dev/main.
|
||||
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -27,6 +28,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspaces --include=optional
|
||||
|
||||
- name: Ensure rollup native binary
|
||||
run: npm install @rollup/rollup-linux-x64-gnu --no-save
|
||||
|
||||
- name: Install Cloudflare worker deps
|
||||
run: npm ci
|
||||
working-directory: packages/cloudflare
|
||||
|
||||
7
.github/workflows/reusable-release.yml
vendored
7
.github/workflows/reusable-release.yml
vendored
@@ -69,6 +69,13 @@ jobs:
|
||||
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||
secrets: inherit
|
||||
|
||||
release-ui:
|
||||
needs: prepare-release
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/release-ui.yml
|
||||
secrets: inherit
|
||||
|
||||
publish-server:
|
||||
needs:
|
||||
- prepare-release
|
||||
|
||||
7
.opencode/commands/release-notes.md
Normal file
7
.opencode/commands/release-notes.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description: Creates release notes
|
||||
agent: build
|
||||
---
|
||||
|
||||
Check how I do prepare release notes here - https://github.com/NeuralNomadsAI/CodeNomad/releases/tag/v0.7.0
|
||||
Use the same format to create release notes from users perspective for new release by looking at changes from last tagged release to tip of branch
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -7384,7 +7384,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7418,7 +7418,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -7455,14 +7455,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"minServerVersion": "0.7.5",
|
||||
"minServerVersion": "0.9.1",
|
||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||
}
|
||||
|
||||
@@ -72,4 +72,12 @@ const manifest = {
|
||||
fs.mkdirSync(distDir, { recursive: true })
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
||||
|
||||
const headersPath = path.join(distDir, "_headers")
|
||||
fs.writeFileSync(
|
||||
headersPath,
|
||||
"/version.json\n Cache-Control: no-cache\n Content-Type: application/json; charset=utf-8\n",
|
||||
"utf-8",
|
||||
)
|
||||
|
||||
console.log(`Wrote ${manifestPath}`)
|
||||
console.log(`Wrote ${headersPath}`)
|
||||
|
||||
@@ -2,28 +2,8 @@ export interface Env {
|
||||
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
||||
}
|
||||
|
||||
function withHeader(response: Response, key: string, value: string): Response {
|
||||
const headers = new Headers(response.headers)
|
||||
headers.set(key, value)
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
|
||||
if (url.pathname === "/version.json") {
|
||||
const assetResponse = await env.ASSETS.fetch(request)
|
||||
|
||||
// Ensure this stays fresh; the server uses it on startup.
|
||||
const withCache = withHeader(assetResponse, "Cache-Control", "no-cache")
|
||||
return withHeader(withCache, "Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
return new Response("Not found", { status: 404 })
|
||||
return env.ASSETS.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,4 +12,3 @@ custom_domain = true
|
||||
directory = "./dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "404-page"
|
||||
run_worker_first = ["/version.json"]
|
||||
|
||||
@@ -177,8 +177,11 @@ export class CliProcessManager extends EventEmitter {
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const killTimeout = setTimeout(() => {
|
||||
console.warn(
|
||||
`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${child.pid ?? "unknown"})`,
|
||||
)
|
||||
child.kill("SIGKILL")
|
||||
}, 4000)
|
||||
}, 30000)
|
||||
|
||||
child.on("exit", () => {
|
||||
clearTimeout(killTimeout)
|
||||
@@ -376,4 +379,3 @@ export class CliProcessManager extends EventEmitter {
|
||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.16"
|
||||
"@opencode-ai/plugin": "1.1.30"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
|
||||
metadata: FileSystemListingMetadata
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderRequest {
|
||||
/**
|
||||
* Path identifier for the currently browsed directory.
|
||||
* Matches the `path` parameter used for `/api/filesystem`.
|
||||
*/
|
||||
parentPath?: string
|
||||
/** Single folder name (no separators). */
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface FileSystemCreateFolderResponse {
|
||||
/**
|
||||
* Path identifier that can be passed back to `/api/filesystem` to browse the new folder.
|
||||
* Relative for restricted listings, absolute for unrestricted.
|
||||
*/
|
||||
path: string
|
||||
/** Absolute folder path on the server host. */
|
||||
absolutePath: string
|
||||
}
|
||||
|
||||
export const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||
|
||||
export interface WorkspaceFileResponse {
|
||||
|
||||
@@ -15,6 +15,7 @@ const PreferencesSchema = z.object({
|
||||
lastUsedBinary: z.string().optional(),
|
||||
environmentVariables: z.record(z.string()).default({}),
|
||||
modelRecents: z.array(ModelPreferenceSchema).default([]),
|
||||
modelThinkingSelections: z.record(z.string(), z.string()).default({}),
|
||||
diffViewMode: z.enum(["split", "unified"]).default("split"),
|
||||
toolOutputExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"),
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import {
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemEntry,
|
||||
FileSystemListResponse,
|
||||
FileSystemListingMetadata,
|
||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
||||
}
|
||||
|
||||
createFolder(parentPath: string | undefined, folderName: string): FileSystemCreateFolderResponse {
|
||||
const name = this.normalizeFolderName(folderName)
|
||||
|
||||
if (this.unrestricted) {
|
||||
const resolvedParent = this.resolveUnrestrictedPath(parentPath)
|
||||
if (this.isWindows && resolvedParent === WINDOWS_DRIVES_ROOT) {
|
||||
throw new Error("Cannot create folders at drive root")
|
||||
}
|
||||
this.assertDirectoryExists(resolvedParent)
|
||||
const absolutePath = this.resolveAbsoluteChild(resolvedParent, name)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: absolutePath, absolutePath }
|
||||
}
|
||||
|
||||
const normalizedParent = this.normalizeRelativePath(parentPath)
|
||||
const parentAbsolute = this.toRestrictedAbsolute(normalizedParent)
|
||||
this.assertDirectoryExists(parentAbsolute)
|
||||
|
||||
const relativePath = this.buildRelativePath(normalizedParent, name)
|
||||
const absolutePath = this.toRestrictedAbsolute(relativePath)
|
||||
fs.mkdirSync(absolutePath)
|
||||
return { path: relativePath, absolutePath }
|
||||
}
|
||||
|
||||
readFile(relativePath: string): string {
|
||||
if (this.unrestricted) {
|
||||
throw new Error("readFile is not available in unrestricted mode")
|
||||
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
|
||||
return { entries, metadata }
|
||||
}
|
||||
|
||||
private normalizeFolderName(input: string): string {
|
||||
const name = input.trim()
|
||||
if (!name) {
|
||||
throw new Error("Folder name is required")
|
||||
}
|
||||
|
||||
if (name === "." || name === "..") {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.startsWith("~")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
throw new Error("Folder name must not include path separators")
|
||||
}
|
||||
|
||||
if (name.includes("\u0000")) {
|
||||
throw new Error("Invalid folder name")
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
private assertDirectoryExists(directory: string) {
|
||||
if (!fs.existsSync(directory)) {
|
||||
throw new Error(`Directory does not exist: ${directory}`)
|
||||
}
|
||||
const stats = fs.statSync(directory)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Path is not a directory: ${directory}`)
|
||||
}
|
||||
}
|
||||
|
||||
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||
const results: FileSystemEntry[] = []
|
||||
|
||||
@@ -286,21 +286,33 @@ async function main() {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
logger.info("Received shutdown signal, closing server")
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
logger.info("Received shutdown signal, stopping workspaces and server")
|
||||
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
const shutdownWorkspaces = (async () => {
|
||||
try {
|
||||
instanceEventBridge.shutdown()
|
||||
} catch (error) {
|
||||
logger.warn({ err: error }, "Instance event bridge shutdown failed")
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceManager.shutdown()
|
||||
logger.info("Workspace manager shutdown complete")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Workspace manager shutdown failed")
|
||||
}
|
||||
})()
|
||||
|
||||
const shutdownHttp = (async () => {
|
||||
try {
|
||||
await server.stop()
|
||||
logger.info("HTTP server stopped")
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Failed to stop HTTP server")
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.allSettled([shutdownWorkspaces, shutdownHttp])
|
||||
|
||||
// no-op: remote UI manifest replaces GitHub release monitor
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
||||
includeFiles: z.coerce.boolean().optional(),
|
||||
})
|
||||
|
||||
const FilesystemCreateFolderSchema = z.object({
|
||||
parentPath: z.string().optional(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.get("/api/filesystem", async (request, reply) => {
|
||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
||||
return { error: (error as Error).message }
|
||||
}
|
||||
})
|
||||
|
||||
app.post("/api/filesystem/folders", async (request, reply) => {
|
||||
const body = FilesystemCreateFolderSchema.parse(request.body ?? {})
|
||||
|
||||
try {
|
||||
const created = deps.fileSystemBrowser.createFolder(body.parentPath, body.name)
|
||||
reply.code(201)
|
||||
return created
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "EEXIST") {
|
||||
reply.code(409).type("text/plain").send("Folder already exists")
|
||||
return
|
||||
}
|
||||
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
||||
reply.code(403).type("text/plain").send("Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
reply.code(400).type("text/plain").send((error as Error).message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
58
packages/server/src/ui/__tests__/remote-ui.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { afterEach, beforeEach, describe, it } from "node:test"
|
||||
|
||||
import type { Logger } from "../../logger"
|
||||
import { resolveUi } from "../remote-ui"
|
||||
|
||||
const noopLogger: Logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
trace: () => {},
|
||||
child: () => noopLogger,
|
||||
isLevelEnabled: () => false,
|
||||
} as any
|
||||
|
||||
let tempRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoot = mkdtempSync(path.join(os.tmpdir(), "codenomad-ui-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("resolveUi local version preference", () => {
|
||||
it("prefers bundled when bundled version is higher", async () => {
|
||||
const bundledDir = path.join(tempRoot, "bundled")
|
||||
const configDir = path.join(tempRoot, "config")
|
||||
const currentDir = path.join(configDir, "ui", "current")
|
||||
|
||||
await mkdir(bundledDir, { recursive: true })
|
||||
await mkdir(currentDir, { recursive: true })
|
||||
|
||||
writeFileSync(path.join(bundledDir, "index.html"), "<html>bundled</html>")
|
||||
writeFileSync(path.join(bundledDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.1" }))
|
||||
|
||||
writeFileSync(path.join(currentDir, "index.html"), "<html>current</html>")
|
||||
writeFileSync(path.join(currentDir, "ui-version.json"), JSON.stringify({ uiVersion: "0.8.0" }))
|
||||
|
||||
const result = await resolveUi({
|
||||
serverVersion: "0.8.1",
|
||||
bundledUiDir: bundledDir,
|
||||
autoUpdate: false,
|
||||
configDir,
|
||||
logger: noopLogger,
|
||||
})
|
||||
|
||||
assert.equal(result.source, "bundled")
|
||||
assert.equal(result.uiStaticDir, bundledDir)
|
||||
assert.equal(result.uiVersion, "0.8.1")
|
||||
})
|
||||
})
|
||||
@@ -73,23 +73,13 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
|
||||
const previousDir = path.join(uiRoot, "previous")
|
||||
|
||||
if (!options.autoUpdate) {
|
||||
const local = await resolveStaticUiDir(currentDir)
|
||||
if (local) {
|
||||
return {
|
||||
uiStaticDir: local,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(local),
|
||||
supported: true,
|
||||
}
|
||||
}
|
||||
|
||||
const bundled = await resolveStaticUiDir(options.bundledUiDir)
|
||||
return {
|
||||
uiStaticDir: bundled ?? options.bundledUiDir,
|
||||
source: bundled ? "bundled" : "missing",
|
||||
uiVersion: bundled ? await readUiVersion(bundled) : undefined,
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let manifest: RemoteUiManifest | null = null
|
||||
@@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
|
||||
})
|
||||
}
|
||||
|
||||
const currentVersion = await readUiVersion(currentDir)
|
||||
if (currentVersion && currentVersion === manifest.latestUIVersion) {
|
||||
const currentResolved = await resolveStaticUiDir(currentDir)
|
||||
if (currentResolved) {
|
||||
return {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: currentVersion,
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
}
|
||||
}
|
||||
const bestLocal = await pickBestLocalUi({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
})
|
||||
|
||||
const remoteIsNewer =
|
||||
!bestLocal ||
|
||||
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||
|
||||
if (!remoteIsNewer) {
|
||||
return await resolveFromCacheOrBundled({
|
||||
logger: options.logger,
|
||||
bundledUiDir: options.bundledUiDir,
|
||||
currentDir,
|
||||
previousDir,
|
||||
supported: true,
|
||||
latestServerVersion: manifest.latestServerVersion,
|
||||
latestServerUrl: manifest.latestServerUrl,
|
||||
minServerVersion: manifest.minServerVersion,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: {
|
||||
latestServerUrl?: string
|
||||
minServerVersion?: string
|
||||
}): Promise<UiResolution> {
|
||||
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||
if (currentResolved) {
|
||||
return {
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
supported: args.supported,
|
||||
message: args.message,
|
||||
latestServerVersion: args.latestServerVersion,
|
||||
latestServerUrl: args.latestServerUrl,
|
||||
minServerVersion: args.minServerVersion,
|
||||
}
|
||||
}
|
||||
const bestLocal = await pickBestLocalUi({
|
||||
logger: args.logger,
|
||||
bundledUiDir: args.bundledUiDir,
|
||||
currentDir: args.currentDir,
|
||||
previousDir: args.previousDir,
|
||||
})
|
||||
|
||||
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||
if (previousResolved) {
|
||||
if (bestLocal) {
|
||||
return {
|
||||
uiStaticDir: previousResolved,
|
||||
source: "previous",
|
||||
uiVersion: await readUiVersion(previousResolved),
|
||||
supported: args.supported,
|
||||
message: args.message,
|
||||
latestServerVersion: args.latestServerVersion,
|
||||
latestServerUrl: args.latestServerUrl,
|
||||
minServerVersion: args.minServerVersion,
|
||||
}
|
||||
}
|
||||
|
||||
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||
if (bundledResolved) {
|
||||
return {
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
uiStaticDir: bestLocal.uiStaticDir,
|
||||
source: bestLocal.source,
|
||||
uiVersion: bestLocal.uiVersion,
|
||||
supported: args.supported,
|
||||
message: args.message,
|
||||
latestServerVersion: args.latestServerVersion,
|
||||
@@ -260,6 +236,66 @@ async function resolveFromCacheOrBundled(args: {
|
||||
}
|
||||
}
|
||||
|
||||
async function pickBestLocalUi(args: {
|
||||
logger: Logger
|
||||
bundledUiDir: string
|
||||
currentDir: string
|
||||
previousDir: string
|
||||
}): Promise<{ uiStaticDir: string; source: UiSource; uiVersion?: string } | null> {
|
||||
const candidates: Array<{ uiStaticDir: string; source: UiSource; uiVersion?: string; priority: number }> = []
|
||||
|
||||
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
||||
if (currentResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: currentResolved,
|
||||
source: "downloaded",
|
||||
uiVersion: await readUiVersion(currentResolved),
|
||||
priority: 2,
|
||||
})
|
||||
}
|
||||
|
||||
const bundledResolved = await resolveStaticUiDir(args.bundledUiDir)
|
||||
if (bundledResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: bundledResolved,
|
||||
source: "bundled",
|
||||
uiVersion: await readUiVersion(bundledResolved),
|
||||
priority: 1,
|
||||
})
|
||||
}
|
||||
|
||||
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
||||
if (previousResolved) {
|
||||
candidates.push({
|
||||
uiStaticDir: previousResolved,
|
||||
source: "previous",
|
||||
uiVersion: await readUiVersion(previousResolved),
|
||||
priority: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
const versionCmp = compareSemverMaybe(a.uiVersion, b.uiVersion)
|
||||
if (versionCmp !== 0) return -versionCmp
|
||||
return b.priority - a.priority
|
||||
})
|
||||
|
||||
const best = candidates[0]
|
||||
if (!best) return null
|
||||
return { uiStaticDir: best.uiStaticDir, source: best.source, uiVersion: best.uiVersion }
|
||||
}
|
||||
|
||||
function compareSemverMaybe(a: string | undefined, b: string | undefined): number {
|
||||
if (!a && !b) return 0
|
||||
if (!a) return -1
|
||||
if (!b) return 1
|
||||
return compareSemverCore(a, b)
|
||||
}
|
||||
|
||||
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||
try {
|
||||
const indexPath = path.join(uiDir, "index.html")
|
||||
|
||||
@@ -187,16 +187,27 @@ export class WorkspaceManager {
|
||||
|
||||
async shutdown() {
|
||||
this.options.logger.info("Shutting down all workspaces")
|
||||
|
||||
const stopTasks: Array<Promise<void>> = []
|
||||
|
||||
for (const [id, workspace] of this.workspaces) {
|
||||
if (workspace.pid) {
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
await this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
})
|
||||
} else {
|
||||
if (!workspace.pid) {
|
||||
this.options.logger.debug({ workspaceId: id }, "Workspace already stopped")
|
||||
continue
|
||||
}
|
||||
|
||||
this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown")
|
||||
stopTasks.push(
|
||||
this.runtime.stop(id).catch((error) => {
|
||||
this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (stopTasks.length > 0) {
|
||||
await Promise.allSettled(stopTasks)
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.opencodeAuth.clear()
|
||||
this.options.logger.info("All workspaces cleared")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { ChildProcess, spawn, spawnSync } from "child_process"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
@@ -122,10 +122,12 @@ export class WorkspaceRuntime {
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
@@ -259,10 +261,96 @@ export class WorkspaceRuntime {
|
||||
const child = managed.child
|
||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
||||
|
||||
const pid = child.pid
|
||||
if (!pid) {
|
||||
this.logger.warn({ workspaceId }, "Workspace process missing PID; cannot stop")
|
||||
return
|
||||
}
|
||||
|
||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
||||
|
||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
// Negative PID targets the process group (POSIX).
|
||||
process.kill(-pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, err }, "Failed to signal POSIX process group")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryKillSinglePid = (signal: NodeJS.Signals) => {
|
||||
try {
|
||||
process.kill(pid, signal)
|
||||
return true
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err?.code === "ESRCH") {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, err }, "Failed to signal workspace PID")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const tryTaskkill = (force: boolean) => {
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync("taskkill", args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
// If the PID is already gone, treat it as success.
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("not found") || combined.includes("no running instance") || combined.includes("process") && combined.includes("not")) {
|
||||
return true
|
||||
}
|
||||
this.logger.debug({ workspaceId, pid, exitCode, stderr: result.stderr, stdout: result.stdout }, "taskkill failed")
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, err: error }, "taskkill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer process-group signaling so wrapper launchers (bun/node) don't orphan the real server.
|
||||
const groupOk = tryKillPosixGroup(signal)
|
||||
if (!groupOk) {
|
||||
// Fallback to direct PID kill.
|
||||
tryKillSinglePid(signal)
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let escalationTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const cleanup = () => {
|
||||
child.removeListener("exit", onExit)
|
||||
child.removeListener("error", onError)
|
||||
if (escalationTimer) {
|
||||
clearTimeout(escalationTimer)
|
||||
escalationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const onExit = () => {
|
||||
@@ -274,32 +362,30 @@ export class WorkspaceRuntime {
|
||||
reject(error)
|
||||
}
|
||||
|
||||
const resolveIfAlreadyExited = () => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
if (isAlreadyExited()) {
|
||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||
cleanup()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
child.once("exit", onExit)
|
||||
child.once("error", onError)
|
||||
|
||||
if (resolveIfAlreadyExited()) {
|
||||
return
|
||||
}
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, detached: process.platform !== "win32" },
|
||||
"Sending SIGTERM to workspace process (tree/group)",
|
||||
)
|
||||
sendStopSignal("SIGTERM")
|
||||
|
||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
||||
child.kill("SIGTERM")
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
||||
child.kill("SIGKILL")
|
||||
} else {
|
||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
||||
escalationTimer = setTimeout(() => {
|
||||
escalationTimer = null
|
||||
if (isAlreadyExited()) {
|
||||
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
|
||||
return
|
||||
}
|
||||
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
|
||||
sendStopSignal("SIGKILL")
|
||||
}, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -34,6 +34,8 @@ fn workspace_root() -> Option<PathBuf> {
|
||||
|
||||
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||
|
||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||
|
||||
fn navigate_main(app: &AppHandle, url: &str) {
|
||||
if let Some(win) = app.webview_windows().get("main") {
|
||||
let mut display = url.to_string();
|
||||
@@ -276,6 +278,7 @@ impl CliProcessManager {
|
||||
pub fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut child_opt = self.child.lock();
|
||||
if let Some(mut child) = child_opt.take() {
|
||||
log_line(&format!("stopping CLI pid={}", child.id()));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGTERM);
|
||||
@@ -290,7 +293,12 @@ impl CliProcessManager {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(4) {
|
||||
if start.elapsed() > Duration::from_secs(CLI_STOP_GRACE_SECS) {
|
||||
log_line(&format!(
|
||||
"stop timed out after {}s; sending SIGKILL pid={}",
|
||||
CLI_STOP_GRACE_SECS,
|
||||
child.id()
|
||||
));
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
libc::kill(child.id() as i32, libc::SIGKILL);
|
||||
|
||||
@@ -163,7 +163,8 @@ fn main() {
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
tauri::RunEvent::ExitRequested { api, .. } => {
|
||||
api.prevent_exit();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
@@ -173,18 +174,18 @@ fn main() {
|
||||
});
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Destroyed,
|
||||
event: tauri::WindowEvent::CloseRequested { api, .. },
|
||||
..
|
||||
} => {
|
||||
if app_handle.webview_windows().len() <= 1 {
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
// Ensure we have time to stop the CLI process before the app exits.
|
||||
api.prevent_close();
|
||||
let app = app_handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
let _ = state.manager.stop();
|
||||
}
|
||||
app.exit(0);
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.6",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import InstanceShell from "./components/instance/instance-shell2"
|
||||
import { RemoteAccessOverlay } from "./components/remote-access-overlay"
|
||||
import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context"
|
||||
import { initMarkdown } from "./lib/markdown"
|
||||
import { initGithubStars } from "./stores/github-stars"
|
||||
|
||||
import { useTheme } from "./lib/theme"
|
||||
import { useCommands } from "./lib/hooks/use-commands"
|
||||
@@ -94,6 +95,7 @@ const App: Component = () => {
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
void initGithubStars()
|
||||
updateInstanceTabBarHeight()
|
||||
const handleResize = () => updateInstanceTabBarHeight()
|
||||
window.addEventListener("resize", handleResize)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { agents, fetchAgents, sessions } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Agent } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -99,15 +100,20 @@ export default function AgentSelector(props: AgentSelectorProps) {
|
||||
data-agent-selector
|
||||
class="selector-trigger"
|
||||
>
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label">
|
||||
<span class="selector-trigger-primary">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
<div class="flex-1 min-w-0">
|
||||
<Select.Value<Agent>>
|
||||
{(state) => (
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Agent: {state.selectedOption()?.name ?? "None"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Select.Value>
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+a" />
|
||||
</span>
|
||||
<Select.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Select.Icon>
|
||||
|
||||
@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
||||
|
||||
const AlertDialog: Component = () => {
|
||||
let primaryButtonRef: HTMLButtonElement | undefined
|
||||
let promptInputRef: HTMLInputElement | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (alertDialogState()) {
|
||||
queueMicrotask(() => {
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
}
|
||||
const state = alertDialogState()
|
||||
if (!state) return
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (state.type === "prompt") {
|
||||
promptInputRef?.focus()
|
||||
promptInputRef?.select()
|
||||
return
|
||||
}
|
||||
primaryButtonRef?.focus()
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
||||
{payload.inputLabel || "Arguments"}
|
||||
</label>
|
||||
<input
|
||||
class="modal-search-input mt-2"
|
||||
value={inputValue()}
|
||||
placeholder={payload.inputPlaceholder || ""}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
dismiss(true, payload, inputValue())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isPrompt}>
|
||||
<div class="mt-4">
|
||||
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
|
||||
<input
|
||||
ref={(el) => {
|
||||
promptInputRef = el
|
||||
}}
|
||||
class="form-input mt-2"
|
||||
value={inputValue()}
|
||||
placeholder={payload.inputPlaceholder || ""}
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck={false}
|
||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
dismiss(true, payload, inputValue())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
{(isConfirm || isPrompt) && (
|
||||
|
||||
38
packages/ui/src/components/brand-icons.tsx
Normal file
38
packages/ui/src/components/brand-icons.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { Component } from "solid-js"
|
||||
|
||||
type BrandIconProps = {
|
||||
class?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export const GitHubMarkIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 98 96"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M41.4395 69.3848C28.8066 67.8535 19.9062 58.7617 19.9062 46.9902C19.9062 42.2051 21.6289 37.0371 24.5 33.5918C23.2559 30.4336 23.4473 23.7344 24.8828 20.959C28.7109 20.4805 33.8789 22.4902 36.9414 25.2656C40.5781 24.1172 44.4062 23.543 49.0957 23.543C53.7852 23.543 57.6133 24.1172 61.0586 25.1699C64.0254 22.4902 69.2891 20.4805 73.1172 20.959C74.457 23.543 74.6484 30.2422 73.4043 33.4961C76.4668 37.1328 78.0937 42.0137 78.0937 46.9902C78.0937 58.7617 69.1934 67.6621 56.3691 69.2891C59.623 71.3945 61.8242 75.9883 61.8242 81.252L61.8242 91.2051C61.8242 94.0762 64.2168 95.7031 67.0879 94.5547C84.4102 87.9512 98 70.6289 98 49.1914C98 22.1074 75.9883 0 48.9043 0C21.8203 0 0 22.1074 0 49.1914C0 70.4375 13.4941 88.0469 31.6777 94.6504C34.2617 95.6074 36.75 93.8848 36.75 91.3008L36.75 83.6445C35.4102 84.2188 33.6875 84.6016 32.1562 84.6016C25.8398 84.6016 22.1074 81.1563 19.4277 74.7441C18.375 72.1602 17.2266 70.6289 15.0254 70.3418C13.877 70.2461 13.4941 69.7676 13.4941 69.1934C13.4941 68.0449 15.4082 67.1836 17.3223 67.1836C20.0977 67.1836 22.4902 68.9063 24.9785 72.4473C26.8926 75.2227 28.9023 76.4668 31.2949 76.4668C33.6875 76.4668 35.2187 75.6055 37.4199 73.4043C39.0469 71.7773 40.291 70.3418 41.4395 69.3848Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const DiscordSymbolIcon: Component<BrandIconProps> = (props) => (
|
||||
<svg
|
||||
viewBox="0 0 64 48"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={props.title ? undefined : "true"}
|
||||
role={props.title ? "img" : "presentation"}
|
||||
class={props.class}
|
||||
>
|
||||
{props.title ? <title>{props.title}</title> : null}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M40.575 0C39.9562 1.09866 39.4006 2.2352 38.8954 3.397C34.0967 2.67719 29.2096 2.67719 24.3982 3.397C23.9057 2.2352 23.3374 1.09866 22.7186 0C18.2104 0.770324 13.8157 2.12155 9.64839 4.02841C1.38951 16.2652 -0.845688 28.1863 0.265599 39.9432C5.10222 43.517 10.5197 46.2447 16.2909 47.9874C17.5916 46.2447 18.7407 44.3883 19.7257 42.4562C17.8568 41.7616 16.0509 40.8903 14.3208 39.88C14.7755 39.5517 15.2175 39.2107 15.6468 38.8824C25.7873 43.6559 37.5316 43.6559 47.6847 38.8824C48.1141 39.236 48.5561 39.577 49.0107 39.88C47.2806 40.9029 45.4748 41.7616 43.5931 42.4688C44.5781 44.4009 45.7273 46.2573 47.028 48C52.7991 46.2573 58.2167 43.5422 63.0533 39.9684C64.3666 26.3299 60.8055 14.5099 53.6452 4.04104C49.4905 2.13418 45.0959 0.782952 40.5876 0.0252565L40.575 0ZM21.1401 32.7072C18.0209 32.7072 15.4321 29.8785 15.4321 26.3804C15.4321 22.8824 17.9199 20.041 21.1275 20.041C24.3351 20.041 26.886 22.895 26.8354 26.3804C26.7849 29.8658 24.3224 32.7072 21.1401 32.7072ZM42.1788 32.7072C39.047 32.7072 36.4834 29.8785 36.4834 26.3804C36.4834 22.8824 38.9712 20.041 42.1788 20.041C45.3864 20.041 47.9246 22.895 47.8741 26.3804C47.8236 29.8658 45.3611 32.7072 42.1788 32.7072Z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid"
|
||||
import { ArrowUpLeft, Folder as FolderIcon, FolderPlus, Loader2, X } from "lucide-solid"
|
||||
import type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||
|
||||
function normalizePathKey(input?: string | null) {
|
||||
if (!input || input === "." || input === "./") {
|
||||
@@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
props.onSelect(absolutePath)
|
||||
}
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
|
||||
const name =
|
||||
(await showPromptDialog("Create a new folder in the current directory.", {
|
||||
title: "New Folder",
|
||||
inputLabel: "Folder name",
|
||||
inputPlaceholder: "e.g. my-new-project",
|
||||
confirmLabel: "Create",
|
||||
cancelLabel: "Cancel",
|
||||
}))?.trim() ?? ""
|
||||
if (!name) return
|
||||
|
||||
if (name === "." || name === ".." || name.startsWith("~") || name.includes("/") || name.includes("\\")) {
|
||||
showAlertDialog("Please enter a single folder name.", {
|
||||
variant: "warning",
|
||||
detail: "Folder names cannot include slashes, '..', or '~'.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setCreatingFolder(true)
|
||||
try {
|
||||
const parentKey = normalizePathKey(metadata.currentPath)
|
||||
metadataCache.delete(parentKey)
|
||||
inFlightRequests.delete(parentKey)
|
||||
setDirectoryChildren((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(parentKey)
|
||||
return next
|
||||
})
|
||||
|
||||
const created = await serverApi.createFileSystemFolder(metadata.currentPath, name)
|
||||
await navigateTo(created.path)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to create folder"
|
||||
showAlertDialog(message, { variant: "error", title: "Unable to create folder" })
|
||||
} finally {
|
||||
setCreatingFolder(false)
|
||||
}
|
||||
}
|
||||
|
||||
function isPathLoading(path: string) {
|
||||
return loadingPaths().has(normalizePathKey(path))
|
||||
}
|
||||
@@ -290,19 +338,32 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<span class="directory-browser-current-label">Current folder</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Select Current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => void handleCreateFolder()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
{creatingFolder() ? "Creating…" : "New Folder"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } from "solid-js"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp } from "lucide-solid"
|
||||
import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, Star } from "lucide-solid"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import AdvancedSettingsModal from "./advanced-settings-modal"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
import { githubStars } from "../stores/github-stars"
|
||||
import { formatCompactCount } from "../lib/formatters"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
|
||||
@@ -57,6 +60,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
let activeElement: HTMLElement | null = null
|
||||
if (typeof document !== "undefined") {
|
||||
activeElement = document.activeElement as HTMLElement | null
|
||||
}
|
||||
const insideModal = activeElement?.closest(".modal-surface") || activeElement?.closest("[role='dialog']")
|
||||
const isEditingField =
|
||||
activeElement &&
|
||||
(["INPUT", "TEXTAREA", "SELECT"].includes(activeElement.tagName) || activeElement.isContentEditable || Boolean(insideModal))
|
||||
|
||||
if (isEditingField) {
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedKey = e.key.toLowerCase()
|
||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||
const blockedKeys = [
|
||||
@@ -175,6 +191,11 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
if (isLoading()) return
|
||||
props.onSelectFolder(path, selectedBinary())
|
||||
}
|
||||
|
||||
const openExternalLink = (url: string) => {
|
||||
if (typeof window === "undefined") return
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
@@ -229,170 +250,228 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
style="background-color: var(--surface-secondary)"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-3xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
class="w-full max-w-5xl h-full px-4 sm:px-8 pb-2 flex flex-col overflow-hidden"
|
||||
aria-busy={isLoading() ? "true" : "false"}
|
||||
>
|
||||
<Show when={props.onOpenRemoteAccess}>
|
||||
<div class="absolute top-4 right-6">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary inline-flex items-center justify-center"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => props.onOpenRemoteAccess?.()}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<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>
|
||||
<div class="mt-2 flex justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="space-y-4 flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||
|
||||
<Show
|
||||
|
||||
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto" ref={(el) => (recentListRef = el)}>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button
|
||||
onClick={() => props.onAdvancedSettingsOpen?.()}
|
||||
class="panel-section-header w-full justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mb-6 text-center shrink-0">
|
||||
<div class="mb-3 flex justify-center">
|
||||
<img src={codeNomadLogo} alt="CodeNomad logo" class="h-32 w-auto sm:h-48" loading="lazy" />
|
||||
</div>
|
||||
<h1 class="mb-2 text-3xl font-semibold text-primary">CodeNomad</h1>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad GitHub"
|
||||
title="CodeNomad GitHub"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<GitHubMarkIcon class="w-4 h-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/NeuralNomadsAI/CodeNomad"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto px-3 py-1.5 inline-flex items-center justify-center gap-1.5"
|
||||
aria-label="CodeNomad GitHub Stars"
|
||||
title="CodeNomad GitHub Stars"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink("https://github.com/NeuralNomadsAI/CodeNomad")
|
||||
}}
|
||||
>
|
||||
<Star class="w-4 h-4" />
|
||||
<Show when={githubStars() !== null}>
|
||||
<span class="text-xs font-medium">{formatCompactCount(githubStars()!)}</span>
|
||||
</Show>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
aria-label="CodeNomad Discord"
|
||||
title="CodeNomad Discord"
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
openExternalLink(
|
||||
"https://discord.com/channels/1391832426048651334/1458412028325793887/1464701235683917945",
|
||||
)
|
||||
}}
|
||||
>
|
||||
<DiscordSymbolIcon class="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-3 text-base text-secondary">Select a folder to start coding with AI</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col gap-4">
|
||||
<div class="flex-1 min-h-0 overflow-hidden flex flex-col lg:flex-row gap-4">
|
||||
{/* Right column: recent folders */}
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<Show
|
||||
when={folders().length > 0}
|
||||
fallback={
|
||||
<div class="panel panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Clock class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">No Recent Folders</p>
|
||||
<p class="panel-empty-state-description">Browse for a folder to get started</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header">
|
||||
<h2 class="panel-title">Recent Folders</h2>
|
||||
<p class="panel-subtitle">
|
||||
{folders().length} {folders().length === 1 ? "folder" : "folders"} available
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="panel-list panel-list--fill flex-1 min-h-0 overflow-auto"
|
||||
ref={(el) => (recentListRef = el)}
|
||||
>
|
||||
<For each={folders()}>
|
||||
{(folder, index) => (
|
||||
<div
|
||||
class="panel-list-item"
|
||||
classList={{
|
||||
"panel-list-item-highlight": focusMode() === "recent" && selectedIndex() === index(),
|
||||
"panel-list-item-disabled": isLoading(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full px-1">
|
||||
<button
|
||||
data-folder-index={index()}
|
||||
class="panel-list-item-content flex-1"
|
||||
disabled={isLoading()}
|
||||
onClick={() => handleFolderSelect(folder.path)}
|
||||
onMouseEnter={() => {
|
||||
if (isLoading()) return
|
||||
setFocusMode("recent")
|
||||
setSelectedIndex(index())
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3 w-full">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Folder class="w-4 h-4 flex-shrink-0 icon-muted" />
|
||||
<span class="text-sm font-medium truncate text-primary">
|
||||
{folder.path.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs font-mono truncate pl-6 text-muted">
|
||||
{getDisplayPath(folder.path)}
|
||||
</div>
|
||||
<div class="text-xs mt-1 pl-6 text-muted">
|
||||
{formatRelativeTime(folder.lastAccessed)}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={focusMode() === "recent" && selectedIndex() === index()}>
|
||||
<kbd class="kbd">↵</kbd>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleRemove(folder.path, e)}
|
||||
disabled={isLoading()}
|
||||
class="p-2 transition-all hover:bg-red-100 dark:hover:bg-red-900/30 opacity-70 hover:opacity-100 rounded"
|
||||
title="Remove from recent"
|
||||
>
|
||||
<Trash2 class="w-3.5 h-3.5 transition-colors icon-muted hover:text-red-600 dark:hover:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Left column: version + browse + advanced settings */}
|
||||
<div class="order-2 lg:order-1 flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-header hidden sm:block">
|
||||
<h2 class="panel-title">Browse for Folder</h2>
|
||||
<p class="panel-subtitle">Select any folder on your computer</p>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<button
|
||||
onClick={() => void handleBrowse()}
|
||||
disabled={props.isLoading}
|
||||
class="button-primary w-full flex items-center justify-center text-sm disabled:cursor-not-allowed"
|
||||
onMouseEnter={() => setFocusMode("new")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FolderPlus class="w-4 h-4" />
|
||||
<span>{props.isLoading ? "Opening..." : "Browse Folders"}</span>
|
||||
</div>
|
||||
<Kbd shortcut="cmd+n" class="ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Advanced settings section */}
|
||||
<div class="panel-section w-full">
|
||||
<button onClick={() => props.onAdvancedSettingsOpen?.()} class="panel-section-header w-full justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="w-4 h-4 icon-muted" />
|
||||
<span class="text-sm font-medium text-secondary">Advanced Settings</span>
|
||||
</div>
|
||||
<ChevronRight class="w-4 h-4 icon-muted" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel shrink-0">
|
||||
<div class="panel-body flex items-center justify-center">
|
||||
<VersionPill />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="panel panel-footer shrink-0 hidden sm:block">
|
||||
<div class="panel-footer-hints">
|
||||
<Show when={folders().length > 0}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">↑</kbd>
|
||||
<kbd class="kbd">↓</kbd>
|
||||
<span>Navigate</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Select</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>Remove</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" />
|
||||
<span>Browse</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,15 +48,16 @@ import { clearSessionRenderCache } from "../message-block"
|
||||
import { isOpen as isCommandPaletteOpen, hideCommandPalette, showCommandPalette } from "../../stores/command-palette"
|
||||
import SessionList from "../session-list"
|
||||
import KeyboardHint from "../keyboard-hint"
|
||||
import Kbd from "../kbd"
|
||||
import InstanceWelcomeView from "../instance-welcome-view"
|
||||
import InfoView from "../info-view"
|
||||
import InstanceServiceStatus from "../instance-service-status"
|
||||
import AgentSelector from "../agent-selector"
|
||||
import ModelSelector from "../model-selector"
|
||||
import ThinkingSelector from "../thinking-selector"
|
||||
import CommandPalette from "../command-palette"
|
||||
import PermissionNotificationBanner from "../permission-notification-banner"
|
||||
import PermissionApprovalModal from "../permission-approval-modal"
|
||||
import Kbd from "../kbd"
|
||||
import { TodoListView } from "../tool-call/renderers/todo"
|
||||
import ContextUsagePanel from "../session/context-usage-panel"
|
||||
import SessionView from "../session/session-view"
|
||||
@@ -432,6 +433,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const focusVariantSelectorControl = () => {
|
||||
const input = leftDrawerContentEl()?.querySelector<HTMLInputElement>("[data-thinking-selector]")
|
||||
if (!input) return false
|
||||
input.focus()
|
||||
setTimeout(() => triggerKeyboardEvent(input, { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }), 10)
|
||||
return true
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingSidebarAction()
|
||||
if (!pending) return
|
||||
@@ -444,7 +453,12 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
setPendingSidebarAction(null)
|
||||
return
|
||||
}
|
||||
const handled = action === "focus-agent-selector" ? focusAgentSelectorControl() : focusModelSelectorControl()
|
||||
const handled =
|
||||
action === "focus-agent-selector"
|
||||
? focusAgentSelectorControl()
|
||||
: action === "focus-model-selector"
|
||||
? focusModelSelectorControl()
|
||||
: focusVariantSelectorControl()
|
||||
if (handled) {
|
||||
setPendingSidebarAction(null)
|
||||
}
|
||||
@@ -901,21 +915,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
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
|
||||
instanceId={props.instance.id}
|
||||
sessionId={activeSession().id}
|
||||
currentModel={activeSession().model}
|
||||
onModelChange={(model) => props.handleSidebarModelChange(activeSession().id, model)}
|
||||
/>
|
||||
|
||||
<ThinkingSelector instanceId={props.instance.id} currentModel={activeSession().model} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import type { Model } from "../types/session"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import Kbd from "./kbd"
|
||||
const log = getLogger("session")
|
||||
|
||||
|
||||
@@ -105,7 +106,7 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
ref={triggerRef}
|
||||
class="selector-trigger"
|
||||
>
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">
|
||||
Model: {currentModelValue()?.name ?? "None"}
|
||||
</span>
|
||||
@@ -115,6 +116,9 @@ export default function ModelSelector(props: ModelSelectorProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span class="selector-trigger-hint selector-trigger-hint--top" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+m" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
|
||||
107
packages/ui/src/components/thinking-selector.tsx
Normal file
107
packages/ui/src/components/thinking-selector.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Combobox } from "@kobalte/core/combobox"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { providers, fetchProviders } from "../stores/sessions"
|
||||
import { ChevronDown } from "lucide-solid"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { getModelThinkingSelection, setModelThinkingSelection } from "../stores/preferences"
|
||||
import Kbd from "./kbd"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
interface ThinkingSelectorProps {
|
||||
instanceId: string
|
||||
currentModel: { providerId: string; modelId: string }
|
||||
}
|
||||
|
||||
type ThinkingOption = {
|
||||
key: string
|
||||
label: string
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
export default function ThinkingSelector(props: ThinkingSelectorProps) {
|
||||
const instanceProviders = () => providers().get(props.instanceId) || []
|
||||
|
||||
createEffect(() => {
|
||||
if (instanceProviders().length === 0) {
|
||||
fetchProviders(props.instanceId).catch((error) => log.error("Failed to fetch providers", error))
|
||||
}
|
||||
})
|
||||
|
||||
const variantKeys = createMemo(() => {
|
||||
const { providerId, modelId } = props.currentModel
|
||||
const provider = instanceProviders().find((p) => p.id === providerId)
|
||||
const model = provider?.models.find((m) => m.id === modelId)
|
||||
return model?.variantKeys ?? []
|
||||
})
|
||||
|
||||
const options = createMemo<ThinkingOption[]>(() => {
|
||||
const keys = variantKeys()
|
||||
return [{ key: "__default__", label: "Default", value: undefined }, ...keys.map((k) => ({ key: k, label: k, value: k }))]
|
||||
})
|
||||
|
||||
const currentValue = createMemo(() => {
|
||||
const selected = getModelThinkingSelection(props.currentModel)
|
||||
const keys = variantKeys()
|
||||
if (selected && keys.includes(selected)) {
|
||||
return options().find((opt) => opt.value === selected)
|
||||
}
|
||||
return options()[0]
|
||||
})
|
||||
|
||||
const handleChange = (value: ThinkingOption | null) => {
|
||||
if (!value) return
|
||||
setModelThinkingSelection(props.currentModel, value.value)
|
||||
}
|
||||
|
||||
const triggerPrimary = createMemo(() => {
|
||||
const selected = currentValue()?.value
|
||||
return selected ? `Thinking: ${selected}` : "Thinking: Default"
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="sidebar-selector">
|
||||
<Combobox<ThinkingOption>
|
||||
value={currentValue()}
|
||||
onChange={handleChange}
|
||||
options={options()}
|
||||
optionValue="key"
|
||||
optionLabel="label"
|
||||
placeholder="Thinking: Default"
|
||||
itemComponent={(itemProps) => (
|
||||
<Combobox.Item item={itemProps.item} class="selector-option">
|
||||
<div class="selector-option-content">
|
||||
<Combobox.ItemLabel class="selector-option-label">{itemProps.item.rawValue.label}</Combobox.ItemLabel>
|
||||
</div>
|
||||
<Combobox.ItemIndicator class="selector-option-indicator">
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</Combobox.ItemIndicator>
|
||||
</Combobox.Item>
|
||||
)}
|
||||
>
|
||||
<Combobox.Control class="relative w-full" data-thinking-selector-control>
|
||||
<Combobox.Input class="sr-only" data-thinking-selector />
|
||||
<Combobox.Trigger class="selector-trigger">
|
||||
<div class="selector-trigger-label selector-trigger-label--stacked flex-1 min-w-0">
|
||||
<span class="selector-trigger-primary selector-trigger-primary--align-left">{triggerPrimary()}</span>
|
||||
</div>
|
||||
<span class="selector-trigger-hint" aria-hidden="true">
|
||||
<Kbd shortcut="cmd+shift+t" />
|
||||
</span>
|
||||
<Combobox.Icon class="selector-trigger-icon">
|
||||
<ChevronDown class="w-3 h-3" />
|
||||
</Combobox.Icon>
|
||||
</Combobox.Trigger>
|
||||
</Combobox.Control>
|
||||
|
||||
<Combobox.Portal>
|
||||
<Combobox.Content class="selector-popover">
|
||||
<Combobox.Listbox class="selector-listbox" />
|
||||
</Combobox.Content>
|
||||
</Combobox.Portal>
|
||||
</Combobox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { createSignal, Show, For, createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { createSignal, Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import { Markdown } from "./markdown"
|
||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
||||
import { useTheme } from "../lib/theme"
|
||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import type { DiffViewMode } from "../stores/preferences"
|
||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||
import type { PermissionRequestLike } from "../types/permission"
|
||||
import { getPermissionDisplayTitle, getPermissionKind, getPermissionSessionId } from "../types/permission"
|
||||
import { getPermissionSessionId } from "../types/permission"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { TextPart, RenderCache } from "../types/message"
|
||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
||||
import { QuestionToolBlock } from "./tool-call/question-block"
|
||||
import { PermissionToolBlock } from "./tool-call/permission-block"
|
||||
import { createAnsiContentRenderer } from "./tool-call/ansi-render"
|
||||
import { createDiffContentRenderer } from "./tool-call/diff-render"
|
||||
import { createMarkdownContentRenderer } from "./tool-call/markdown-render"
|
||||
import { extractDiagnostics, diagnosticFileName } from "./tool-call/diagnostics"
|
||||
import { renderDiagnosticsSection } from "./tool-call/diagnostics-section"
|
||||
import type {
|
||||
DiffPayload,
|
||||
DiffRenderOptions,
|
||||
@@ -24,38 +27,11 @@ import type {
|
||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||
import { getLogger } from "../lib/logger"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
||||
import { escapeHtml } from "../lib/markdown"
|
||||
|
||||
const log = getLogger("session")
|
||||
|
||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
|
||||
type QuestionOption = { label: string; description: string }
|
||||
|
||||
type QuestionPrompt = {
|
||||
header: string
|
||||
question: string
|
||||
options: QuestionOption[]
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
type QuestionToolBlockProps = {
|
||||
toolName: Accessor<string>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolCallId: Accessor<string>
|
||||
request: Accessor<QuestionRequest | undefined>
|
||||
active: Accessor<boolean>
|
||||
submitting: Accessor<boolean>
|
||||
error: Accessor<string | null>
|
||||
draftAnswers: Accessor<Record<string, string[][]>>
|
||||
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
||||
onSubmit: () => void | Promise<void>
|
||||
onDismiss: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const TOOL_CALL_CACHE_SCOPE = "tool-call"
|
||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
||||
@@ -86,447 +62,7 @@ interface ToolCallProps {
|
||||
|
||||
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const requestId = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}`
|
||||
})
|
||||
|
||||
const questions = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
if (!request && !isQuestionTool) return [] as QuestionPrompt[]
|
||||
|
||||
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||
const list = Array.isArray(questionsSource) ? questionsSource : []
|
||||
return list as QuestionPrompt[]
|
||||
})
|
||||
|
||||
const isVisible = createMemo(() => {
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
return Boolean(request) || isQuestionTool
|
||||
})
|
||||
|
||||
const answers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
|
||||
const completedAnswers =
|
||||
(state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers)
|
||||
? ((state as any).metadata.answers as string[][])
|
||||
: undefined
|
||||
|
||||
if (completedAnswers) return completedAnswers
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines)
|
||||
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) {
|
||||
return requestAnswers as string[][]
|
||||
}
|
||||
|
||||
const draft = props.draftAnswers()[requestId()] ?? []
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
const current = prev[requestId()] ?? []
|
||||
const updated = [...current]
|
||||
updated[questionIndex] = next
|
||||
return { ...prev, [requestId()]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOption = (questionIndex: number, label: string) => {
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
if (multi) {
|
||||
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||
updateAnswer(questionIndex, next)
|
||||
return
|
||||
}
|
||||
updateAnswer(questionIndex, [label])
|
||||
}
|
||||
|
||||
const submitDisabled = () => {
|
||||
if (!props.active()) return true
|
||||
if (props.submitting()) return true
|
||||
return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0)
|
||||
}
|
||||
|
||||
const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => {
|
||||
if (!props.active()) return
|
||||
const rawValue = input?.value ?? ""
|
||||
const value = rawValue
|
||||
if (value.trim().length === 0) return
|
||||
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
if (!multi) {
|
||||
// When switching a radio to custom, clear existing selection first.
|
||||
updateAnswer(questionIndex, [])
|
||||
}
|
||||
|
||||
toggleOption(questionIndex, value)
|
||||
}
|
||||
|
||||
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||
if (!props.active()) return
|
||||
if (valuesToRemove.length === 0) return
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const next = existing.filter((value) => !valuesToRemove.includes(value))
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => {
|
||||
if (!props.active()) return
|
||||
|
||||
const value = input.value
|
||||
const trimmed = value.trim()
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
|
||||
if (!multi) {
|
||||
updateAnswer(questionIndex, trimmed.length > 0 ? [value] : [])
|
||||
return
|
||||
}
|
||||
|
||||
const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label))
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const last = input.dataset.lastValue ?? ""
|
||||
|
||||
let next = existing.filter((item) => item !== last)
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
// Only treat it as custom if it doesn't match an existing option label.
|
||||
if (!optionLabels.has(trimmed) && !next.includes(value)) {
|
||||
next = [...next, value]
|
||||
} else if (optionLabels.has(trimmed)) {
|
||||
// If they typed an existing option label, don't treat it as custom.
|
||||
} else if (!next.includes(value)) {
|
||||
next = [...next, value]
|
||||
}
|
||||
input.dataset.lastValue = value
|
||||
} else {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
const multi = () => q?.multiple === true
|
||||
const selected = () => answers()[i()] ?? []
|
||||
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||
const groupName = () => `question-${requestId()}-${i()}`
|
||||
const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label))
|
||||
const customSelected = () => selected().filter((value) => !optionLabels().has(value))
|
||||
const customValue = () => customSelected()[0] ?? ""
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
<div class="text-xs text-muted">Multiple</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
{(opt) => {
|
||||
const checked = () => selected().includes(opt.label)
|
||||
return (
|
||||
<label
|
||||
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<label
|
||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title="Type a custom answer"
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
const container = e.currentTarget.closest("label")
|
||||
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
||||
if (!props.active()) return
|
||||
if (customChecked()) {
|
||||
clearCustomAnswer(i(), customSelected())
|
||||
if (input) {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
return
|
||||
}
|
||||
toggleFromCustomInput(i(), input)
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">Custom answer</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
type="text"
|
||||
placeholder="Type your own answer"
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
toggleFromCustomInput(i(), e.currentTarget)
|
||||
}}
|
||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={submitDisabled()}
|
||||
onClick={() => props.onSubmit()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => props.onDismiss()}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Submit</span>
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Dismiss</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.error()}>
|
||||
<div class="tool-call-permission-error">{props.error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [
|
||||
input.filePath,
|
||||
metadata.filePath,
|
||||
metadata.filepath,
|
||||
input.path,
|
||||
].find((value) => typeof value === "string" && value.length > 0) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
|
||||
function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">🛠</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>{fileLabel}</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">
|
||||
:L{entry.line || "-"}:C{entry.column || "-"}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ToolCall(props: ToolCallProps) {
|
||||
const { preferences, setDiffViewMode } = useConfig()
|
||||
@@ -561,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
return "noversion"
|
||||
})
|
||||
|
||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||
|
||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||
useGlobalCache({
|
||||
instanceId: () => props.instanceId,
|
||||
@@ -578,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
const permissionDiffCache = createVariantCache("permission-diff")
|
||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||
const ansiFinalCache = createVariantCache("ansi-final")
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||
const pendingPermission = createMemo(() => {
|
||||
@@ -997,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
|
||||
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = isDark() ? "dark" : "light"
|
||||
const { renderAnsiContent } = createAnsiContentRenderer({
|
||||
ansiRunningCache,
|
||||
ansiFinalCache,
|
||||
scrollHelpers,
|
||||
partVersion: partVersionAccessor,
|
||||
})
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = cacheHandle.get<RenderCache>()
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
const { renderDiffContent } = createDiffContentRenderer({
|
||||
preferences,
|
||||
setDiffViewMode,
|
||||
isDark,
|
||||
diffCache,
|
||||
permissionDiffCache,
|
||||
scrollHelpers,
|
||||
handleScrollRendered,
|
||||
onContentRendered: props.onContentRendered,
|
||||
})
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
setDiffViewMode(mode)
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
handleScrollRendered()
|
||||
}
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheHandle.params()}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderAnsiContent(options: AnsiRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const cacheHandle = options.variant === "running" ? ansiRunningCache : ansiFinalCache
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = typeof props.partVersion === "number" ? String(props.partVersion) : undefined
|
||||
const isRunningVariant = options.variant === "running"
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
if (isRunningVariant) {
|
||||
const content = options.content
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else {
|
||||
runningAnsiRenderer.reset()
|
||||
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
|
||||
}
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
|
||||
runningAnsiSource = nextCache.text
|
||||
cacheHandle.set(nextCache)
|
||||
} else {
|
||||
if (cached && cached.text === options.content) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else {
|
||||
const detectedAnsi = hasAnsi(options.content)
|
||||
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
|
||||
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
|
||||
cacheHandle.set(nextCache)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireAnsi && !nextCache.hasAnsi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions) {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
|
||||
const state = toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const partId = toolCallMemo()?.id
|
||||
if (!partId) {
|
||||
throw new Error("Tool call markdown requires a part id")
|
||||
}
|
||||
const markdownPart: TextPart = { id: partId, type: "text", text: options.content, version: props.partVersion }
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
handleScrollRendered()
|
||||
props.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => scrollHelpers.registerContainer(element)} onScroll={scrollHelpers.handleScroll}>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={props.sessionId}
|
||||
isDark={isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
{scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||
const { renderMarkdownContent } = createMarkdownContentRenderer({
|
||||
toolState,
|
||||
partId: toolCallIdentifier,
|
||||
partVersion: partVersionAccessor,
|
||||
instanceId: props.instanceId,
|
||||
sessionId: props.sessionId,
|
||||
isDark,
|
||||
scrollHelpers,
|
||||
handleScrollRendered,
|
||||
onContentRendered: props.onContentRendered,
|
||||
})
|
||||
|
||||
const rendererContext: ToolRendererContext = {
|
||||
toolCall: toolCallMemo,
|
||||
@@ -1278,92 +659,17 @@ export default function ToolCall(props: ToolCallProps) {
|
||||
}
|
||||
|
||||
|
||||
const renderPermissionBlock = () => {
|
||||
const permission = permissionDetails()
|
||||
if (!permission) return null
|
||||
const active = isPermissionActive()
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
const diffPayload = diffValue && diffValue.trim().length > 0 ? { diffText: diffValue, filePath: diffPathRaw } : null
|
||||
|
||||
return (
|
||||
<div class={`tool-call-permission ${active ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{active ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission)}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{getPermissionDisplayTitle(permission)}</code>
|
||||
</div>
|
||||
<Show when={diffPayload}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{renderDiffContent(payload(), {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath ? `Requested diff · ${getRelativePath(payload().filePath || "")}` : "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!active}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||
</Show>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => void handlePermissionResponse(permission, "once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => void handlePermissionResponse(permission, "always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={permissionSubmitting()}
|
||||
onClick={() => void handlePermissionResponse(permission, "reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<Show when={active}>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={permissionError()}>
|
||||
<div class="tool-call-permission-error">{permissionError()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const renderPermissionBlock = () => (
|
||||
<PermissionToolBlock
|
||||
permission={permissionDetails}
|
||||
active={isPermissionActive}
|
||||
submitting={permissionSubmitting}
|
||||
error={permissionError}
|
||||
renderDiff={renderDiffContent}
|
||||
fallbackSessionId={() => props.sessionId}
|
||||
onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
|
||||
/>
|
||||
)
|
||||
|
||||
const renderQuestionBlock = () => (
|
||||
<QuestionToolBlock
|
||||
|
||||
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
98
packages/ui/src/components/tool-call/ansi-render.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../../lib/ansi"
|
||||
import { escapeHtml } from "../../lib/markdown"
|
||||
import type { AnsiRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
type AnsiRenderCache = RenderCache & { hasAnsi: boolean }
|
||||
|
||||
type CacheHandle = {
|
||||
get<T>(): T | undefined
|
||||
set(value: unknown): void
|
||||
}
|
||||
|
||||
export function createAnsiContentRenderer(params: {
|
||||
ansiRunningCache: CacheHandle
|
||||
ansiFinalCache: CacheHandle
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
partVersion?: Accessor<number | undefined>
|
||||
}) {
|
||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
||||
let runningAnsiSource = ""
|
||||
|
||||
const getMode = () => {
|
||||
const version = params.partVersion?.()
|
||||
return typeof version === "number" ? String(version) : undefined
|
||||
}
|
||||
|
||||
function renderAnsiContent(options: AnsiRenderOptions): JSXElement | null {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const cacheHandle = options.variant === "running" ? params.ansiRunningCache : params.ansiFinalCache
|
||||
const cached = cacheHandle.get<AnsiRenderCache>()
|
||||
const mode = getMode()
|
||||
const isRunningVariant = options.variant === "running"
|
||||
|
||||
let nextCache: AnsiRenderCache
|
||||
|
||||
if (isRunningVariant) {
|
||||
const content = options.content
|
||||
const resetStreaming = !cached || !cached.text || !content.startsWith(cached.text) || cached.text !== runningAnsiSource
|
||||
|
||||
if (resetStreaming) {
|
||||
const detectedAnsi = hasAnsi(content)
|
||||
if (detectedAnsi) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else {
|
||||
runningAnsiRenderer.reset()
|
||||
nextCache = { text: content, html: escapeHtml(content), mode, hasAnsi: false }
|
||||
}
|
||||
} else {
|
||||
const delta = content.slice(cached.text.length)
|
||||
if (delta.length === 0) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else if (!cached.hasAnsi && hasAnsi(delta)) {
|
||||
runningAnsiRenderer.reset()
|
||||
const html = runningAnsiRenderer.render(content)
|
||||
nextCache = { text: content, html, mode, hasAnsi: true }
|
||||
} else if (cached.hasAnsi) {
|
||||
const htmlChunk = runningAnsiRenderer.render(delta)
|
||||
nextCache = { text: content, html: `${cached.html}${htmlChunk}`, mode, hasAnsi: true }
|
||||
} else {
|
||||
nextCache = { text: content, html: `${cached.html}${escapeHtml(delta)}`, mode, hasAnsi: false }
|
||||
}
|
||||
}
|
||||
|
||||
runningAnsiSource = nextCache.text
|
||||
cacheHandle.set(nextCache)
|
||||
} else {
|
||||
if (cached && cached.text === options.content) {
|
||||
nextCache = { ...cached, mode }
|
||||
} else {
|
||||
const detectedAnsi = hasAnsi(options.content)
|
||||
const html = detectedAnsi ? ansiToHtml(options.content) : escapeHtml(options.content)
|
||||
nextCache = { text: options.content, html, mode, hasAnsi: detectedAnsi }
|
||||
cacheHandle.set(nextCache)
|
||||
}
|
||||
}
|
||||
|
||||
if (options.requireAnsi && !nextCache.hasAnsi) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}>
|
||||
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} />
|
||||
{params.scrollHelpers.renderSentinel()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderAnsiContent }
|
||||
}
|
||||
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
53
packages/ui/src/components/tool-call/diagnostics-section.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { DiagnosticEntry } from "./diagnostics"
|
||||
|
||||
export function renderDiagnosticsSection(
|
||||
entries: DiagnosticEntry[],
|
||||
expanded: boolean,
|
||||
toggle: () => void,
|
||||
fileLabel: string,
|
||||
) {
|
||||
if (entries.length === 0) return null
|
||||
return (
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-diagnostics-heading"
|
||||
aria-expanded={expanded}
|
||||
onClick={toggle}
|
||||
>
|
||||
<span class="tool-call-icon" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
<span class="tool-call-emoji" aria-hidden="true">
|
||||
🛠
|
||||
</span>
|
||||
<span class="tool-call-summary">Diagnostics</span>
|
||||
<span class="tool-call-diagnostics-file" title={fileLabel}>
|
||||
{fileLabel}
|
||||
</span>
|
||||
</button>
|
||||
<Show when={expanded}>
|
||||
<div class="tool-call-diagnostics" role="region" aria-label="Diagnostics">
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
106
packages/ui/src/components/tool-call/diagnostics.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
|
||||
|
||||
interface LspRangePosition {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
interface LspRange {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
interface LspDiagnostic {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
export interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
tone: "error" | "warning" | "info"
|
||||
label: string
|
||||
icon: string
|
||||
message: string
|
||||
filePath: string
|
||||
displayPath: string
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntry[] {
|
||||
if (!state) return []
|
||||
const supportsMetadata = isToolStateRunning(state) || isToolStateCompleted(state) || isToolStateError(state)
|
||||
if (!supportsMetadata) return []
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
||||
(value) => typeof value === "string" && value.length > 0,
|
||||
) as string | undefined
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
export function diagnosticFileName(entries: DiagnosticEntry[]) {
|
||||
const first = entries[0]
|
||||
return first ? first.displayPath : ""
|
||||
}
|
||||
106
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
106
packages/ui/src/components/tool-call/diff-render.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { RenderCache } from "../../types/message"
|
||||
import type { DiffViewMode } from "../../stores/preferences"
|
||||
import { ToolCallDiffViewer } from "../diff-viewer"
|
||||
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
import { getCacheEntry } from "../../lib/global-cache"
|
||||
|
||||
type CacheHandle = {
|
||||
get<T>(): T | undefined
|
||||
params(): unknown
|
||||
}
|
||||
|
||||
type DiffPrefs = {
|
||||
diffViewMode?: DiffViewMode
|
||||
}
|
||||
|
||||
export function createDiffContentRenderer(params: {
|
||||
preferences: Accessor<DiffPrefs>
|
||||
setDiffViewMode: (mode: DiffViewMode) => void
|
||||
isDark: Accessor<boolean>
|
||||
diffCache: CacheHandle
|
||||
permissionDiffCache: CacheHandle
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null {
|
||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
||||
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
|
||||
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
|
||||
const themeKey = params.isDark() ? "dark" : "light"
|
||||
|
||||
const baseEntryParams = cacheHandle.params() as any
|
||||
const cacheEntryParams = (() => {
|
||||
const suffix = typeof options?.cacheKey === "string" ? options.cacheKey.trim() : ""
|
||||
if (!suffix) return baseEntryParams
|
||||
return {
|
||||
...baseEntryParams,
|
||||
cacheId: `${baseEntryParams.cacheId}:${suffix}`,
|
||||
}
|
||||
})()
|
||||
|
||||
let cachedHtml: string | undefined
|
||||
const cached = getCacheEntry<RenderCache>(cacheEntryParams)
|
||||
const currentMode = diffMode()
|
||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
||||
cachedHtml = cached.html
|
||||
}
|
||||
|
||||
const handleModeChange = (mode: DiffViewMode) => {
|
||||
params.setDiffViewMode(mode)
|
||||
}
|
||||
|
||||
const handleDiffRendered = () => {
|
||||
if (!options?.disableScrollTracking) {
|
||||
params.handleScrollRendered()
|
||||
}
|
||||
params.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-markdown-large tool-call-diff-shell"
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: options?.disableScrollTracking })}
|
||||
onScroll={options?.disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<div class="tool-call-diff-toolbar" role="group" aria-label="Diff view mode">
|
||||
<span class="tool-call-diff-toolbar-label">{toolbarLabel}</span>
|
||||
<div class="tool-call-diff-toggle">
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "split" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "split"}
|
||||
onClick={() => handleModeChange("split")}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tool-call-diff-mode-button${diffMode() === "unified" ? " active" : ""}`}
|
||||
aria-pressed={diffMode() === "unified"}
|
||||
onClick={() => handleModeChange("unified")}
|
||||
>
|
||||
Unified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ToolCallDiffViewer
|
||||
diffText={payload.diffText}
|
||||
filePath={payload.filePath}
|
||||
theme={themeKey}
|
||||
mode={diffMode()}
|
||||
cachedHtml={cachedHtml}
|
||||
cacheEntryParams={cacheEntryParams as any}
|
||||
onRendered={handleDiffRendered}
|
||||
/>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: options?.disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderDiffContent }
|
||||
}
|
||||
76
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
76
packages/ui/src/components/tool-call/markdown-render.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Accessor, JSXElement } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { TextPart } from "../../types/message"
|
||||
import { Markdown } from "../markdown"
|
||||
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"
|
||||
|
||||
export function createMarkdownContentRenderer(params: {
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
partId: Accessor<string>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
isDark: Accessor<boolean>
|
||||
scrollHelpers: ToolScrollHelpers
|
||||
handleScrollRendered: () => void
|
||||
onContentRendered?: () => void
|
||||
}) {
|
||||
function renderMarkdownContent(options: MarkdownRenderOptions): JSXElement | null {
|
||||
if (!options.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
const size = options.size || "default"
|
||||
const disableHighlight = options.disableHighlight || false
|
||||
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
|
||||
const disableScrollTracking = options.disableScrollTracking || false
|
||||
|
||||
const state = params.toolState()
|
||||
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
|
||||
if (shouldDeferMarkdown) {
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheKey = typeof options.cacheKey === "string" && options.cacheKey.length > 0 ? options.cacheKey : undefined
|
||||
const markdownPart: TextPart = {
|
||||
id: cacheKey ? `${params.partId()}:${cacheKey}` : params.partId(),
|
||||
type: "text",
|
||||
text: options.content,
|
||||
version: params.partVersion?.(),
|
||||
}
|
||||
|
||||
const handleMarkdownRendered = () => {
|
||||
params.handleScrollRendered()
|
||||
params.onContentRendered?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={messageClass}
|
||||
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
|
||||
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
|
||||
>
|
||||
<Markdown
|
||||
part={markdownPart}
|
||||
instanceId={params.instanceId}
|
||||
sessionId={params.sessionId}
|
||||
isDark={params.isDark()}
|
||||
disableHighlight={disableHighlight}
|
||||
onRendered={handleMarkdownRendered}
|
||||
/>
|
||||
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return { renderMarkdownContent }
|
||||
}
|
||||
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
120
packages/ui/src/components/tool-call/permission-block.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Show, type Accessor, type JSXElement } from "solid-js"
|
||||
import type { PermissionRequestLike } from "../../types/permission"
|
||||
import { getPermissionDisplayTitle, getPermissionKind } from "../../types/permission"
|
||||
import { getPermissionSessionId } from "../../types/permission"
|
||||
import type { DiffPayload, DiffRenderOptions } from "./types"
|
||||
import { getRelativePath } from "./utils"
|
||||
|
||||
type PermissionResponse = "once" | "always" | "reject"
|
||||
|
||||
export type PermissionToolBlockProps = {
|
||||
permission: Accessor<PermissionRequestLike | undefined>
|
||||
active: Accessor<boolean>
|
||||
submitting: Accessor<boolean>
|
||||
error: Accessor<string | null>
|
||||
onRespond: (permission: PermissionRequestLike, sessionId: string, response: PermissionResponse) => void | Promise<void>
|
||||
renderDiff: (payload: DiffPayload, options?: DiffRenderOptions) => JSXElement | null
|
||||
fallbackSessionId: Accessor<string>
|
||||
}
|
||||
|
||||
export function PermissionToolBlock(props: PermissionToolBlockProps) {
|
||||
const diffPayload = () => {
|
||||
const permission = props.permission()
|
||||
if (!permission) return null
|
||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
||||
const diffPathRaw = (() => {
|
||||
if (typeof metadata.filePath === "string") {
|
||||
return metadata.filePath as string
|
||||
}
|
||||
if (typeof metadata.path === "string") {
|
||||
return metadata.path as string
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
if (!diffValue || diffValue.trim().length === 0) return null
|
||||
return { diffText: diffValue, filePath: diffPathRaw } satisfies DiffPayload
|
||||
}
|
||||
|
||||
const respond = (response: PermissionResponse) => {
|
||||
const permission = props.permission()
|
||||
if (!permission) return
|
||||
const sessionId = getPermissionSessionId(permission) || props.fallbackSessionId()
|
||||
props.onRespond(permission, sessionId, response)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.permission()}>
|
||||
{(permission) => (
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">{props.active() ? "Permission Required" : "Permission Queued"}</span>
|
||||
<span class="tool-call-permission-type">{getPermissionKind(permission())}</span>
|
||||
</div>
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="tool-call-permission-title">
|
||||
<code>{getPermissionDisplayTitle(permission())}</code>
|
||||
</div>
|
||||
<Show when={diffPayload()}>
|
||||
{(payload) => (
|
||||
<div class="tool-call-permission-diff">
|
||||
{props.renderDiff(payload(), {
|
||||
variant: "permission-diff",
|
||||
disableScrollTracking: true,
|
||||
label: payload().filePath
|
||||
? `Requested diff · ${getRelativePath(payload().filePath || "")}`
|
||||
: "Requested diff",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!props.active()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier permission responses.</p>
|
||||
</Show>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("once")}
|
||||
>
|
||||
Allow Once
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("always")}
|
||||
>
|
||||
Always Allow
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => respond("reject")}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Allow once</span>
|
||||
<kbd class="kbd">A</kbd>
|
||||
<span>Always allow</span>
|
||||
<kbd class="kbd">D</kbd>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.error()}>
|
||||
<div class="tool-call-permission-error">{props.error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
311
packages/ui/src/components/tool-call/question-block.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { createMemo, Show, For, type Accessor } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type QuestionOption = { label: string; description: string }
|
||||
|
||||
type QuestionPrompt = {
|
||||
header: string
|
||||
question: string
|
||||
options: QuestionOption[]
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export type QuestionToolBlockProps = {
|
||||
toolName: Accessor<string>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolCallId: Accessor<string>
|
||||
request: Accessor<QuestionRequest | undefined>
|
||||
active: Accessor<boolean>
|
||||
submitting: Accessor<boolean>
|
||||
error: Accessor<string | null>
|
||||
draftAnswers: Accessor<Record<string, string[][]>>
|
||||
setDraftAnswers: (updater: (prev: Record<string, string[][]>) => Record<string, string[][]>) => void
|
||||
onSubmit: () => void | Promise<void>
|
||||
onDismiss: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export function QuestionToolBlock(props: QuestionToolBlockProps) {
|
||||
const requestId = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
return request?.id ?? (state as any)?.input?.requestID ?? `question-${props.toolCallId()}`
|
||||
})
|
||||
|
||||
const questions = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
if (!request && !isQuestionTool) return [] as QuestionPrompt[]
|
||||
|
||||
const questionsSource = request?.questions ?? ((state as any)?.input?.questions as any[] | undefined) ?? []
|
||||
const list = Array.isArray(questionsSource) ? questionsSource : []
|
||||
return list as QuestionPrompt[]
|
||||
})
|
||||
|
||||
const isVisible = createMemo(() => {
|
||||
const request = props.request()
|
||||
const isQuestionTool = props.toolName() === "question"
|
||||
return Boolean(request) || isQuestionTool
|
||||
})
|
||||
|
||||
const answers = createMemo(() => {
|
||||
const state = props.toolState()
|
||||
|
||||
const completedAnswers =
|
||||
(state as any)?.status === "completed" && Array.isArray((state as any)?.metadata?.answers)
|
||||
? ((state as any).metadata.answers as string[][])
|
||||
: undefined
|
||||
|
||||
if (completedAnswers) return completedAnswers
|
||||
|
||||
const request = props.request()
|
||||
const requestAnswers = request?.questions?.map((q) => (q as any)?.answer) // defensive (if server ever inlines)
|
||||
|
||||
if (Array.isArray(requestAnswers) && requestAnswers.some((row) => Array.isArray(row) && row.length > 0)) {
|
||||
return requestAnswers as string[][]
|
||||
}
|
||||
|
||||
const draft = props.draftAnswers()[requestId()] ?? []
|
||||
return Array.isArray(draft) ? draft : []
|
||||
})
|
||||
|
||||
const updateAnswer = (questionIndex: number, next: string[]) => {
|
||||
if (!props.active()) return
|
||||
props.setDraftAnswers((prev) => {
|
||||
const current = prev[requestId()] ?? []
|
||||
const updated = [...current]
|
||||
updated[questionIndex] = next
|
||||
return { ...prev, [requestId()]: updated }
|
||||
})
|
||||
}
|
||||
|
||||
const toggleOption = (questionIndex: number, label: string) => {
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
if (multi) {
|
||||
const next = existing.includes(label) ? existing.filter((x) => x !== label) : [...existing, label]
|
||||
updateAnswer(questionIndex, next)
|
||||
return
|
||||
}
|
||||
updateAnswer(questionIndex, [label])
|
||||
}
|
||||
|
||||
const submitDisabled = () => {
|
||||
if (!props.active()) return true
|
||||
if (props.submitting()) return true
|
||||
return questions().some((_, index) => (answers()[index]?.length ?? 0) === 0)
|
||||
}
|
||||
|
||||
const toggleFromCustomInput = (questionIndex: number, input: HTMLInputElement | null) => {
|
||||
if (!props.active()) return
|
||||
const rawValue = input?.value ?? ""
|
||||
const value = rawValue
|
||||
if (value.trim().length === 0) return
|
||||
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
if (!multi) {
|
||||
// When switching a radio to custom, clear existing selection first.
|
||||
updateAnswer(questionIndex, [])
|
||||
}
|
||||
|
||||
toggleOption(questionIndex, value)
|
||||
}
|
||||
|
||||
const clearCustomAnswer = (questionIndex: number, valuesToRemove: string[]) => {
|
||||
if (!props.active()) return
|
||||
if (valuesToRemove.length === 0) return
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const next = existing.filter((value) => !valuesToRemove.includes(value))
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
const handleCustomTyping = (questionIndex: number, input: HTMLInputElement) => {
|
||||
if (!props.active()) return
|
||||
|
||||
const value = input.value
|
||||
const trimmed = value.trim()
|
||||
const info = questions()[questionIndex]
|
||||
const multi = info?.multiple === true
|
||||
|
||||
if (!multi) {
|
||||
updateAnswer(questionIndex, trimmed.length > 0 ? [value] : [])
|
||||
return
|
||||
}
|
||||
|
||||
const optionLabels = new Set((info?.options ?? []).map((opt) => opt.label))
|
||||
const existing = answers()[questionIndex] ?? []
|
||||
const last = input.dataset.lastValue ?? ""
|
||||
|
||||
let next = existing.filter((item) => item !== last)
|
||||
|
||||
if (trimmed.length > 0) {
|
||||
// Only treat it as custom if it doesn't match an existing option label.
|
||||
if (!optionLabels.has(trimmed) && !next.includes(value)) {
|
||||
next = [...next, value]
|
||||
} else if (optionLabels.has(trimmed)) {
|
||||
// If they typed an existing option label, don't treat it as custom.
|
||||
} else if (!next.includes(value)) {
|
||||
next = [...next, value]
|
||||
}
|
||||
input.dataset.lastValue = value
|
||||
} else {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
|
||||
updateAnswer(questionIndex, next)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={isVisible() && questions().length > 0}>
|
||||
<div class={`tool-call-permission ${props.active() ? "tool-call-permission-active" : "tool-call-permission-queued"}`}>
|
||||
<div class="tool-call-permission-header">
|
||||
<span class="tool-call-permission-label">
|
||||
{props.active() ? "Question Required" : props.request() ? "Question Queued" : "Questions"}
|
||||
</span>
|
||||
<span class="tool-call-permission-type">{questions().length === 1 ? "Question" : "Questions"}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-body">
|
||||
<div class="flex flex-col gap-4">
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const i = () => index()
|
||||
const multi = () => q?.multiple === true
|
||||
const selected = () => answers()[i()] ?? []
|
||||
const inputType = () => (multi() ? "checkbox" : "radio")
|
||||
const groupName = () => `question-${requestId()}-${i()}`
|
||||
const optionLabels = () => new Set((q?.options ?? []).map((opt) => opt.label))
|
||||
const customSelected = () => selected().filter((value) => !optionLabels().has(value))
|
||||
const customValue = () => customSelected()[0] ?? ""
|
||||
const customChecked = () => customValue().length > 0
|
||||
|
||||
return (
|
||||
<div class="rounded-md border border-base/60 bg-surface/30 p-3">
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<div class="text-xs">
|
||||
Q{i() + 1}: <span class="font-semibold">{q?.header}</span>
|
||||
</div>
|
||||
<Show when={multi()}>
|
||||
<div class="text-xs text-muted">Multiple</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 text-sm font-medium">{q?.question}</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1">
|
||||
<For each={q?.options ?? []}>
|
||||
{(opt) => {
|
||||
const checked = () => selected().includes(opt.label)
|
||||
return (
|
||||
<label
|
||||
class={`flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title={opt.description}
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={checked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={() => toggleOption(i(), opt.label)}
|
||||
/>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-sm leading-tight">{opt.label}</div>
|
||||
<div class="text-xs text-muted leading-tight">{opt.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<label
|
||||
class={`mt-2 flex items-start gap-2 py-1 ${props.active() ? "cursor-pointer" : props.request() ? "opacity-80" : ""}`}
|
||||
title="Type a custom answer"
|
||||
>
|
||||
<input
|
||||
type={inputType()}
|
||||
name={groupName()}
|
||||
checked={customChecked()}
|
||||
disabled={!props.active() || props.submitting()}
|
||||
onChange={(e) => {
|
||||
const container = e.currentTarget.closest("label")
|
||||
const input = container?.querySelector("input[type='text']") as HTMLInputElement | null
|
||||
if (!props.active()) return
|
||||
if (customChecked()) {
|
||||
clearCustomAnswer(i(), customSelected())
|
||||
if (input) {
|
||||
delete input.dataset.lastValue
|
||||
}
|
||||
return
|
||||
}
|
||||
toggleFromCustomInput(i(), input)
|
||||
}}
|
||||
/>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="text-sm leading-tight">Custom answer</div>
|
||||
<input
|
||||
class="w-full rounded-md border border-base/50 bg-surface px-2 py-1 text-sm"
|
||||
type="text"
|
||||
placeholder="Type your own answer"
|
||||
disabled={!props.active() || props.submitting()}
|
||||
value={customValue()}
|
||||
onFocus={(e) => {
|
||||
if (!props.active()) return
|
||||
// Keep the radio/checkbox selected while editing.
|
||||
toggleFromCustomInput(i(), e.currentTarget)
|
||||
}}
|
||||
onInput={(e) => handleCustomTyping(i(), e.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={props.active()}>
|
||||
<div class="tool-call-permission-actions">
|
||||
<div class="tool-call-permission-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={submitDisabled()}
|
||||
onClick={() => props.onSubmit()}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="tool-call-permission-button"
|
||||
disabled={props.submitting()}
|
||||
onClick={() => props.onDismiss()}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-call-permission-shortcuts">
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>Submit</span>
|
||||
<kbd class="kbd">Esc</kbd>
|
||||
<span>Dismiss</span>
|
||||
</div>
|
||||
|
||||
<Show when={props.error()}>
|
||||
<div class="tool-call-permission-error">{props.error()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!props.active() && props.request()}>
|
||||
<p class="tool-call-permission-queued-text">Waiting for earlier responses.</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal file
197
packages/ui/src/components/tool-call/renderers/apply-patch.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import type { DiagnosticEntry } from "../diagnostics"
|
||||
|
||||
type LspRangePosition = {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
type LspRange = {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
type LspDiagnostic = {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
type ApplyPatchFile = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: string
|
||||
diff?: string
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"]) {
|
||||
if (tone === "error") return { label: "ERR", icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: "WARN", icon: "!", rank: 1 }
|
||||
return { label: "INFO", icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function resolveDiagnosticsKey(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): string | undefined {
|
||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
||||
if (absolute && diagnostics[absolute]) return absolute
|
||||
if (relative && diagnostics[relative]) return relative
|
||||
|
||||
if (absolute) {
|
||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
||||
const normalized = normalizePath(key)
|
||||
return normalized === relative || normalized.endsWith("/" + relative)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||
if (!key) return []
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const normalizedKey = normalizePath(key)
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
|
||||
entries.push({
|
||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedKey,
|
||||
displayPath: getRelativePath(normalizedKey),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
}
|
||||
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string }) {
|
||||
return (
|
||||
<Show when={props.entries.length > 0}>
|
||||
<div class="tool-call-diagnostics-wrapper">
|
||||
<div class="tool-call-diagnostics" role="region" aria-label={`Diagnostics ${props.label}`}
|
||||
>
|
||||
<div class="tool-call-diagnostics-body" role="list">
|
||||
<For each={props.entries}>
|
||||
{(entry) => (
|
||||
<div class="tool-call-diagnostic-row" role="listitem">
|
||||
<span class={`tool-call-diagnostic-chip tool-call-diagnostic-${entry.tone}`}>
|
||||
<span class="tool-call-diagnostic-chip-icon">{entry.icon}</span>
|
||||
<span>{entry.label}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-path" title={entry.filePath}>
|
||||
{entry.displayPath}
|
||||
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
|
||||
</span>
|
||||
<span class="tool-call-diagnostic-message">{entry.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export const applyPatchRenderer: ToolRenderer = {
|
||||
tools: ["apply_patch"],
|
||||
getAction: () => "Preparing apply_patch...",
|
||||
getTitle({ toolState }) {
|
||||
const state = toolState()
|
||||
if (!state) return undefined
|
||||
if (state.status === "pending") return getToolName("apply_patch")
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const files = Array.isArray((metadata as any).files) ? ((metadata as any).files as ApplyPatchFile[]) : []
|
||||
if (files.length > 0) {
|
||||
return `${getToolName("apply_patch")} (${files.length} file${files.length === 1 ? "" : "s"})`
|
||||
}
|
||||
return getToolName("apply_patch")
|
||||
},
|
||||
renderBody({ toolState, renderDiff, renderMarkdown }) {
|
||||
const state = toolState()
|
||||
if (!state || state.status === "pending") return null
|
||||
|
||||
const payload = readToolStatePayload(state)
|
||||
const files = createMemo(() => {
|
||||
const list = (payload.metadata as any).files
|
||||
return Array.isArray(list) ? (list as ApplyPatchFile[]) : []
|
||||
})
|
||||
const diagnosticsMap = createMemo(() => {
|
||||
const value = (payload.metadata as any).diagnostics
|
||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
||||
})
|
||||
|
||||
if (files().length === 0) {
|
||||
const fallback = isToolStateCompleted(state) && typeof state.output === "string" ? state.output : null
|
||||
if (!fallback) return null
|
||||
return renderMarkdown({ content: fallback, size: "large", disableHighlight: state.status === "running" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch">
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const labelBase = file.relativePath || file.filePath || `File ${index() + 1}`
|
||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file))
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch-file">
|
||||
<Show when={diffText.trim().length > 0}>
|
||||
{renderDiff(
|
||||
{ diffText, filePath },
|
||||
{
|
||||
label: `Diff · ${getRelativePath(labelBase)}`,
|
||||
cacheKey: `apply_patch:${labelBase}:${index()}`,
|
||||
},
|
||||
)}
|
||||
</Show>
|
||||
<DiagnosticsInline entries={entries()} label={labelBase} />
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { ToolRenderer } from "../types"
|
||||
import { bashRenderer } from "./bash"
|
||||
import { defaultRenderer } from "./default"
|
||||
import { editRenderer } from "./edit"
|
||||
import { applyPatchRenderer } from "./apply-patch"
|
||||
import { patchRenderer } from "./patch"
|
||||
import { readRenderer } from "./read"
|
||||
import { taskRenderer } from "./task"
|
||||
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
||||
readRenderer,
|
||||
writeRenderer,
|
||||
editRenderer,
|
||||
applyPatchRenderer,
|
||||
patchRenderer,
|
||||
webfetchRenderer,
|
||||
todoRenderer,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolState } from "@opencode-ai/sdk"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { getTodoTitle } from "./todo"
|
||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||
import { resolveTitleForTool } from "../tool-title"
|
||||
|
||||
interface TaskSummaryItem {
|
||||
@@ -90,7 +89,51 @@ export const taskRenderer: ToolRenderer = {
|
||||
const { input } = readToolStatePayload(state)
|
||||
return describeTaskTitle(input)
|
||||
},
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||
renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
|
||||
const promptContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { input } = readToolStatePayload(state)
|
||||
const prompt = typeof input.prompt === "string" ? input.prompt : null
|
||||
return ensureMarkdownContent(prompt, undefined, false)
|
||||
})
|
||||
|
||||
const outputContent = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null
|
||||
return ensureMarkdownContent(output, undefined, false)
|
||||
})
|
||||
|
||||
const agentLabel = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { input } = readToolStatePayload(state)
|
||||
return typeof input.subagent_type === "string" ? input.subagent_type : null
|
||||
})
|
||||
|
||||
const modelLabel = createMemo(() => {
|
||||
const state = toolState()
|
||||
if (!state) return null
|
||||
const { metadata } = readToolStatePayload(state)
|
||||
const model = (metadata as any).model
|
||||
if (!model || typeof model !== "object") return null
|
||||
const providerId = typeof model.providerID === "string" ? model.providerID : null
|
||||
const modelId = typeof model.modelID === "string" ? model.modelID : null
|
||||
if (!providerId && !modelId) return null
|
||||
if (providerId && modelId) return `${providerId}/${modelId}`
|
||||
return providerId ?? modelId
|
||||
})
|
||||
|
||||
const headerMeta = createMemo(() => {
|
||||
const agent = agentLabel()
|
||||
const model = modelLabel()
|
||||
if (agent && model) return `Agent: ${agent} • Model: ${model}`
|
||||
if (agent) return `Agent: ${agent}`
|
||||
if (model) return `Model: ${model}`
|
||||
return null
|
||||
})
|
||||
|
||||
const items = createMemo(() => {
|
||||
// Track the reactive change points so we only recompute when the part/message changes
|
||||
messageVersion?.()
|
||||
@@ -114,41 +157,90 @@ export const taskRenderer: ToolRenderer = {
|
||||
})
|
||||
})
|
||||
|
||||
if (items().length === 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
<div class="tool-call-task-sections">
|
||||
<Show when={promptContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Prompt</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
{renderMarkdown({
|
||||
content: promptContent()!,
|
||||
cacheKey: "task:prompt",
|
||||
disableScrollTracking: true,
|
||||
disableHighlight: true,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Steps</span>
|
||||
<span class="tool-call-task-section-meta">{items().length} steps</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(item) => {
|
||||
const icon = getToolIcon(item.tool)
|
||||
const description = describeToolTitle(item)
|
||||
const toolLabel = getToolName(item.tool)
|
||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
||||
const statusIcon = summarizeStatusIcon(status)
|
||||
const statusLabel = summarizeStatusLabel(status)
|
||||
const statusAttr = status ?? "pending"
|
||||
return (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={outputContent()}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">Output</span>
|
||||
<Show when={headerMeta()}>
|
||||
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||
</Show>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
{renderMarkdown({
|
||||
content: outputContent()!,
|
||||
cacheKey: "task:output",
|
||||
disableScrollTracking: true,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
|
||||
import { readRenderer } from "./renderers/read"
|
||||
import { writeRenderer } from "./renderers/write"
|
||||
import { editRenderer } from "./renderers/edit"
|
||||
import { applyPatchRenderer } from "./renderers/apply-patch"
|
||||
import { patchRenderer } from "./renderers/patch"
|
||||
import { webfetchRenderer } from "./renderers/webfetch"
|
||||
import { todoRenderer } from "./renderers/todo"
|
||||
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||
read: readRenderer,
|
||||
write: writeRenderer,
|
||||
edit: editRenderer,
|
||||
apply_patch: applyPatchRenderer,
|
||||
patch: patchRenderer,
|
||||
webfetch: webfetchRenderer,
|
||||
todowrite: todoRenderer,
|
||||
|
||||
@@ -13,6 +13,16 @@ export interface MarkdownRenderOptions {
|
||||
content: string
|
||||
size?: "default" | "large"
|
||||
disableHighlight?: boolean
|
||||
/**
|
||||
* Optional suffix to avoid render-cache collisions when a tool call renders
|
||||
* multiple markdown regions (e.g. task prompt vs task output).
|
||||
*/
|
||||
cacheKey?: string
|
||||
/**
|
||||
* When true, do not register this markdown region with tool-call scroll
|
||||
* tracking (avoids nested scroll + autoscroll interactions).
|
||||
*/
|
||||
disableScrollTracking?: boolean
|
||||
}
|
||||
|
||||
export interface AnsiRenderOptions {
|
||||
@@ -26,6 +36,11 @@ export interface DiffRenderOptions {
|
||||
variant?: string
|
||||
disableScrollTracking?: boolean
|
||||
label?: string
|
||||
/**
|
||||
* Optional cache key suffix to avoid collisions when rendering multiple diffs
|
||||
* within the same tool call (e.g. apply_patch).
|
||||
*/
|
||||
cacheKey?: string
|
||||
}
|
||||
|
||||
export interface ToolScrollHelpers {
|
||||
|
||||
@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
|
||||
return "📁"
|
||||
case "patch":
|
||||
return "🔧"
|
||||
case "apply_patch":
|
||||
return "🔧"
|
||||
default:
|
||||
return "🔧"
|
||||
}
|
||||
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Plan"
|
||||
case "apply_patch":
|
||||
return "Apply patch"
|
||||
default: {
|
||||
const normalized = tool.replace(/^opencode_/, "")
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
|
||||
return "Planning..."
|
||||
case "patch":
|
||||
return "Preparing patch..."
|
||||
case "apply_patch":
|
||||
return "Preparing apply_patch..."
|
||||
default:
|
||||
return "Working..."
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
FileSystemEntry,
|
||||
FileSystemCreateFolderResponse,
|
||||
FileSystemListResponse,
|
||||
InstanceData,
|
||||
ServerMeta,
|
||||
@@ -224,6 +225,13 @@ export const serverApi = {
|
||||
const query = params.toString()
|
||||
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
||||
},
|
||||
|
||||
createFileSystemFolder(parentPath: string | undefined, name: string): Promise<FileSystemCreateFolderResponse> {
|
||||
return request<FileSystemCreateFolderResponse>("/api/filesystem/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parentPath, name }),
|
||||
})
|
||||
},
|
||||
readInstanceData(id: string): Promise<InstanceData> {
|
||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||
},
|
||||
|
||||
@@ -10,3 +10,20 @@ export function formatTokenTotal(value: number): string {
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCompactCount(value: number): string {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(1)}B`
|
||||
}
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 10_000) {
|
||||
return `${Math.round(value / 1_000)}K`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const label = `${(value / 1_000).toFixed(1)}K`
|
||||
return label.replace(/\.0K$/, "K")
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ export function useAppLifecycle(options: UseAppLifecycleOptions) {
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-agent-selector" })
|
||||
},
|
||||
() => {
|
||||
const instance = options.getActiveInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
)
|
||||
|
||||
registerEscapeShortcut(
|
||||
|
||||
@@ -374,6 +374,20 @@ export function useCommands(options: UseCommandsOptions) {
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-variant-selector",
|
||||
label: "Select Model Variant",
|
||||
description: "Choose a thinking effort for the current model",
|
||||
category: "Agent & Model",
|
||||
keywords: ["variant", "thinking", "reasoning", "effort"],
|
||||
shortcut: { key: "T", meta: true, shift: true },
|
||||
action: () => {
|
||||
const instance = activeInstance()
|
||||
if (!instance) return
|
||||
emitSessionSidebarRequest({ instanceId: instance.id, action: "focus-variant-selector" })
|
||||
},
|
||||
})
|
||||
|
||||
commandRegistry.register({
|
||||
id: "open-agent-selector",
|
||||
label: "Open Agent Selector",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type SessionSidebarRequestAction = "focus-agent-selector" | "focus-model-selector" | "show-session-list"
|
||||
export type SessionSidebarRequestAction =
|
||||
| "focus-agent-selector"
|
||||
| "focus-model-selector"
|
||||
| "focus-variant-selector"
|
||||
| "show-session-list"
|
||||
|
||||
export interface SessionSidebarRequestDetail {
|
||||
instanceId: string
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { keyboardRegistry } from "../keyboard-registry"
|
||||
|
||||
export function registerAgentShortcuts(focusModelSelector: () => void, openAgentSelector: () => void) {
|
||||
export function registerAgentShortcuts(
|
||||
focusModelSelector: () => void,
|
||||
openAgentSelector: () => void,
|
||||
focusVariantSelector: () => void,
|
||||
) {
|
||||
const isMac = () => navigator.platform.toLowerCase().includes("mac")
|
||||
|
||||
keyboardRegistry.register({
|
||||
@@ -20,4 +24,13 @@ export function registerAgentShortcuts(focusModelSelector: () => void, openAgent
|
||||
description: "open agent",
|
||||
context: "global",
|
||||
})
|
||||
|
||||
keyboardRegistry.register({
|
||||
id: "focus-variant",
|
||||
key: "T",
|
||||
modifiers: { ctrl: !isMac(), meta: isMac(), shift: true },
|
||||
handler: focusVariantSelector,
|
||||
description: "focus thinking",
|
||||
context: "global",
|
||||
})
|
||||
}
|
||||
|
||||
54
packages/ui/src/stores/github-stars.ts
Normal file
54
packages/ui/src/stores/github-stars.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createSignal } from "solid-js"
|
||||
import { getLogger } from "../lib/logger"
|
||||
|
||||
const log = getLogger("api")
|
||||
|
||||
const STORAGE_KEY = "codenomad:github:stars"
|
||||
const REPO_API_URL = "https://api.github.com/repos/NeuralNomadsAI/CodeNomad"
|
||||
|
||||
function readStoredStars(): number | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value < 0) return null
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
function storeStars(value: number): void {
|
||||
if (typeof window === "undefined") return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(value))
|
||||
}
|
||||
|
||||
const [githubStars, setGithubStars] = createSignal<number | null>(readStoredStars())
|
||||
|
||||
let initialized = false
|
||||
|
||||
export async function initGithubStars(): Promise<void> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
const response = await fetch(REPO_API_URL, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { stargazers_count?: unknown }
|
||||
const next = typeof data.stargazers_count === "number" ? data.stargazers_count : null
|
||||
if (next === null || !Number.isFinite(next) || next < 0) {
|
||||
return
|
||||
}
|
||||
const normalized = Math.floor(next)
|
||||
setGithubStars(normalized)
|
||||
storeStars(normalized)
|
||||
} catch (error) {
|
||||
log.warn("Failed to fetch GitHub stars", error)
|
||||
}
|
||||
}
|
||||
|
||||
export { githubStars }
|
||||
@@ -39,6 +39,7 @@ export interface Preferences {
|
||||
lastUsedBinary?: string
|
||||
environmentVariables: Record<string, string>
|
||||
modelRecents: ModelPreference[]
|
||||
modelThinkingSelections: Record<string, string>
|
||||
diffViewMode: DiffViewMode
|
||||
toolOutputExpansion: ExpansionPreference
|
||||
diagnosticsExpansion: ExpansionPreference
|
||||
@@ -71,6 +72,7 @@ const defaultPreferences: Preferences = {
|
||||
showTimelineTools: true,
|
||||
environmentVariables: {},
|
||||
modelRecents: [],
|
||||
modelThinkingSelections: {},
|
||||
diffViewMode: "split",
|
||||
toolOutputExpansion: "expanded",
|
||||
diagnosticsExpansion: "expanded",
|
||||
@@ -102,6 +104,11 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
const sourceModelRecents = sanitized.modelRecents ?? defaultPreferences.modelRecents
|
||||
const modelRecents = sourceModelRecents.map((item) => ({ ...item }))
|
||||
|
||||
const modelThinkingSelections = {
|
||||
...defaultPreferences.modelThinkingSelections,
|
||||
...(sanitized.modelThinkingSelections ?? {}),
|
||||
}
|
||||
|
||||
return {
|
||||
showThinkingBlocks: sanitized.showThinkingBlocks ?? defaultPreferences.showThinkingBlocks,
|
||||
thinkingBlocksExpansion: sanitized.thinkingBlocksExpansion ?? defaultPreferences.thinkingBlocksExpansion,
|
||||
@@ -109,6 +116,7 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
lastUsedBinary: sanitized.lastUsedBinary ?? defaultPreferences.lastUsedBinary,
|
||||
environmentVariables,
|
||||
modelRecents,
|
||||
modelThinkingSelections,
|
||||
diffViewMode: sanitized.diffViewMode ?? defaultPreferences.diffViewMode,
|
||||
toolOutputExpansion: sanitized.toolOutputExpansion ?? defaultPreferences.toolOutputExpansion,
|
||||
diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion,
|
||||
@@ -118,6 +126,35 @@ function normalizePreferences(pref?: Partial<Preferences> & { agentModelSelectio
|
||||
}
|
||||
}
|
||||
|
||||
function getModelKey(model: { providerId: string; modelId: string }): string {
|
||||
return `${model.providerId}/${model.modelId}`
|
||||
}
|
||||
|
||||
function getModelThinkingSelection(model: { providerId: string; modelId: string }): string | undefined {
|
||||
if (!model.providerId || !model.modelId) return undefined
|
||||
return preferences().modelThinkingSelections?.[getModelKey(model)]
|
||||
}
|
||||
|
||||
function setModelThinkingSelection(model: { providerId: string; modelId: string }, value: string | undefined): void {
|
||||
if (!model.providerId || !model.modelId) return
|
||||
const key = getModelKey(model)
|
||||
const current = preferences().modelThinkingSelections?.[key]
|
||||
if (current === value) return
|
||||
|
||||
updateConfig((draft) => {
|
||||
const selections = { ...(draft.preferences.modelThinkingSelections ?? {}) }
|
||||
if (!value) {
|
||||
delete selections[key]
|
||||
} else {
|
||||
selections[key] = value
|
||||
}
|
||||
draft.preferences = normalizePreferences({
|
||||
...draft.preferences,
|
||||
modelThinkingSelections: selections,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const [internalConfig, setInternalConfig] = createSignal<ConfigData>(buildFallbackConfig())
|
||||
|
||||
const config = createMemo<DeepReadonly<ConfigData>>(() => internalConfig())
|
||||
@@ -527,6 +564,8 @@ export {
|
||||
addEnvironmentVariable,
|
||||
removeEnvironmentVariable,
|
||||
addRecentModelPreference,
|
||||
getModelThinkingSelection,
|
||||
setModelThinkingSelection,
|
||||
setAgentModelPreference,
|
||||
getAgentModelPreference,
|
||||
setDiffViewMode,
|
||||
@@ -540,4 +579,3 @@ export {
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
|
||||
import { instances } from "./instances"
|
||||
|
||||
import { addRecentModelPreference, setAgentModelPreference } from "./preferences"
|
||||
import { sessions, withSession } from "./session-state"
|
||||
import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences"
|
||||
import { providers, sessions, withSession } from "./session-state"
|
||||
import { getDefaultModel, isModelValid } from "./session-models"
|
||||
import { updateSessionInfo } from "./message-v2/session-info"
|
||||
import { messageStoreBus } from "./message-v2/bus"
|
||||
@@ -11,6 +11,22 @@ import { requestData } from "../lib/opencode-api"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] {
|
||||
if (!model.providerId || !model.modelId) return []
|
||||
const instanceProviders = providers().get(instanceId) || []
|
||||
const provider = instanceProviders.find((p) => p.id === model.providerId)
|
||||
const match = provider?.models.find((m) => m.id === model.modelId)
|
||||
return match?.variantKeys ?? []
|
||||
}
|
||||
|
||||
function getThinkingVariantToSend(instanceId: string, model: { providerId: string; modelId: string }): string | undefined {
|
||||
const selected = getModelThinkingSelection(model)
|
||||
if (!selected) return undefined
|
||||
const keys = getVariantKeysForModel(instanceId, model)
|
||||
if (keys.length === 0) return undefined
|
||||
return keys.includes(selected) ? selected : undefined
|
||||
}
|
||||
|
||||
const ID_LENGTH = 26
|
||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
|
||||
@@ -170,6 +186,12 @@ async function sendMessage(
|
||||
modelID: session.model.modelId,
|
||||
},
|
||||
}),
|
||||
...(session.model.providerId &&
|
||||
session.model.modelId &&
|
||||
(() => {
|
||||
const variant = getThinkingVariantToSend(instanceId, session.model)
|
||||
return variant ? { variant } : {}
|
||||
})()),
|
||||
}
|
||||
|
||||
log.info("sendMessage", {
|
||||
@@ -215,6 +237,7 @@ async function executeCustomCommand(
|
||||
messageID: string
|
||||
agent?: string
|
||||
model?: string
|
||||
variant?: string
|
||||
} = {
|
||||
command: commandName,
|
||||
arguments: args,
|
||||
@@ -227,6 +250,8 @@ async function executeCustomCommand(
|
||||
|
||||
if (session.model.providerId && session.model.modelId) {
|
||||
body.model = `${session.model.providerId}/${session.model.modelId}`
|
||||
const variant = getThinkingVariantToSend(instanceId, session.model)
|
||||
if (variant) body.variant = variant
|
||||
}
|
||||
|
||||
await requestData(
|
||||
|
||||
@@ -483,6 +483,7 @@ async function fetchProviders(instanceId: string): Promise<void> {
|
||||
providerId: provider.id,
|
||||
limit: model.limit,
|
||||
cost: model.cost,
|
||||
variantKeys: Object.keys(model.variants ?? {}),
|
||||
})),
|
||||
}))
|
||||
|
||||
|
||||
@@ -81,6 +81,14 @@
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.directory-browser-current-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.directory-browser-close {
|
||||
|
||||
display: inline-flex;
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
@apply items-start;
|
||||
}
|
||||
|
||||
.selector-trigger-label--top {
|
||||
@apply self-start;
|
||||
}
|
||||
|
||||
.selector-trigger-primary {
|
||||
@apply text-sm font-medium truncate;
|
||||
color: var(--text-primary);
|
||||
@@ -46,6 +50,15 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-hint {
|
||||
@apply flex-shrink-0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.selector-trigger-hint--top {
|
||||
@apply self-start mt-0.5;
|
||||
}
|
||||
|
||||
.selector-popover {
|
||||
@apply rounded-md shadow-lg overflow-hidden min-w-[300px];
|
||||
background-color: var(--surface-base);
|
||||
|
||||
@@ -217,6 +217,16 @@
|
||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Diff shell already provides the scroll container.
|
||||
Avoid nested scroll areas inside the diff viewer. */
|
||||
.tool-call-diff-shell .tool-call-diff-viewer {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-call-diff-toolbar-label {
|
||||
@@ -423,6 +433,19 @@
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* apply_patch multi-file layout */
|
||||
.tool-call-apply-patch {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.tool-call-apply-patch-file {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.tool-call-apply-patch-file:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tool-call-section h4 {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
|
||||
@@ -1,7 +1,74 @@
|
||||
.tool-call-task-container {
|
||||
.tool-call-task-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tool-call-task-section {
|
||||
border: 1px solid var(--border-base);
|
||||
overflow: hidden;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.tool-call-task-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--surface-secondary);
|
||||
border-bottom: 1px solid var(--border-base);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tool-call-task-section-title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.tool-call-task-section-meta {
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tool-call-task-section-body {
|
||||
background-color: var(--surface-code);
|
||||
}
|
||||
|
||||
.tool-call-task-section-body .tool-call-markdown {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.tool-call-task-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Steps list should be flush (no inset padding). */
|
||||
.tool-call-task-section-body .tool-call-task-container.tool-call-markdown {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Keep task lists compact vs prompt/output panes. */
|
||||
.tool-call-task-container.tool-call-markdown {
|
||||
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) / 2);
|
||||
}
|
||||
|
||||
/* Prompt + output panes: slightly taller than tasks. */
|
||||
.tool-call-task-section-body > .tool-call-markdown:not(.tool-call-task-container) {
|
||||
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) * 2 / 3);
|
||||
}
|
||||
|
||||
.tool-call-task-empty {
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-tight);
|
||||
color: var(--text-muted);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-call-task-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -115,29 +115,6 @@ session-sidebar-controls .selector-trigger-primary {
|
||||
@apply flex flex-col gap-1 w-full;
|
||||
}
|
||||
|
||||
.sidebar-selector-hint {
|
||||
@apply flex justify-center text-xs w-full;
|
||||
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 {
|
||||
@apply flex-shrink-0;
|
||||
}
|
||||
|
||||
@@ -103,6 +103,25 @@
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 text-sm;
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: transparent;
|
||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
||||
}
|
||||
|
||||
/* Shared animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface Model {
|
||||
id: string
|
||||
name: string
|
||||
providerId: string
|
||||
variantKeys?: string[]
|
||||
limit?: {
|
||||
context?: number
|
||||
output?: number
|
||||
|
||||
Reference in New Issue
Block a user