Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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:
|
on:
|
||||||
push:
|
workflow_call: {}
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -12,7 +12,8 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-ui:
|
release-ui:
|
||||||
if: ${{ github.ref == 'refs/heads/main' }}
|
# Automated via reusable call (main releases); manual runs allowed on dev.
|
||||||
|
if: ${{ github.event_name == 'workflow_call' || github.ref == 'refs/heads/dev' }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -27,6 +28,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci --workspaces --include=optional
|
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
|
- name: Install Cloudflare worker deps
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: packages/cloudflare
|
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 }}
|
release_name: ${{ needs.prepare-release.outputs.release_name }}
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
release-ui:
|
||||||
|
needs: prepare-release
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
uses: ./.github/workflows/release-ui.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish-server:
|
publish-server:
|
||||||
needs:
|
needs:
|
||||||
- prepare-release
|
- prepare-release
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"7zip-bin": "^5.2.0",
|
"7zip-bin": "^5.2.0",
|
||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
@@ -7384,7 +7384,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron-app": {
|
"packages/electron-app": {
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codenomad/ui": "file:../ui",
|
"@codenomad/ui": "file:../ui",
|
||||||
"@neuralnomads/codenomad": "file:../server"
|
"@neuralnomads/codenomad": "file:../server"
|
||||||
@@ -7418,7 +7418,7 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
@@ -7455,14 +7455,14 @@
|
|||||||
},
|
},
|
||||||
"packages/tauri-app": {
|
"packages/tauri-app": {
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4"
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@git-diff-view/solid": "^0.0.8",
|
"@git-diff-view/solid": "^0.0.8",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "codenomad-workspace",
|
"name": "codenomad-workspace",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "CodeNomad monorepo workspace",
|
"description": "CodeNomad monorepo workspace",
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minServerVersion": "0.7.5",
|
"minServerVersion": "0.8.1",
|
||||||
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,12 @@ const manifest = {
|
|||||||
fs.mkdirSync(distDir, { recursive: true })
|
fs.mkdirSync(distDir, { recursive: true })
|
||||||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8")
|
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 ${manifestPath}`)
|
||||||
|
console.log(`Wrote ${headersPath}`)
|
||||||
|
|||||||
@@ -2,28 +2,8 @@ export interface Env {
|
|||||||
ASSETS: { fetch: (request: Request) => Promise<Response> }
|
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 {
|
export default {
|
||||||
async fetch(request: Request, env: Env): Promise<Response> {
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
const url = new URL(request.url)
|
return env.ASSETS.fetch(request)
|
||||||
|
|
||||||
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 })
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,3 @@ custom_domain = true
|
|||||||
directory = "./dist"
|
directory = "./dist"
|
||||||
binding = "ASSETS"
|
binding = "ASSETS"
|
||||||
not_found_handling = "404-page"
|
not_found_handling = "404-page"
|
||||||
run_worker_first = ["/version.json"]
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad-electron-app",
|
"name": "@neuralnomads/codenomad-electron-app",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"description": "CodeNomad - AI coding assistant",
|
"description": "CodeNomad - AI coding assistant",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"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",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^8.5.0",
|
"@fastify/cors": "^8.5.0",
|
||||||
"@fastify/reply-from": "^9.8.0",
|
"@fastify/reply-from": "^9.8.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@neuralnomads/codenomad",
|
"name": "@neuralnomads/codenomad",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"description": "CodeNomad Server",
|
"description": "CodeNomad Server",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Neural Nomads",
|
"name": "Neural Nomads",
|
||||||
|
|||||||
@@ -95,6 +95,26 @@ export interface FileSystemListResponse {
|
|||||||
metadata: FileSystemListingMetadata
|
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 const WINDOWS_DRIVES_ROOT = "__drives__"
|
||||||
|
|
||||||
export interface WorkspaceFileResponse {
|
export interface WorkspaceFileResponse {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "fs"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import {
|
import {
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
FileSystemListingMetadata,
|
FileSystemListingMetadata,
|
||||||
@@ -56,6 +57,30 @@ export class FileSystemBrowser {
|
|||||||
return this.listRestrictedWithMetadata(targetPath, includeFiles)
|
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 {
|
readFile(relativePath: string): string {
|
||||||
if (this.unrestricted) {
|
if (this.unrestricted) {
|
||||||
throw new Error("readFile is not available in unrestricted mode")
|
throw new Error("readFile is not available in unrestricted mode")
|
||||||
@@ -157,6 +182,41 @@ export class FileSystemBrowser {
|
|||||||
return { entries, metadata }
|
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[] {
|
private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] {
|
||||||
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
const dirents = fs.readdirSync(directory, { withFileTypes: true })
|
||||||
const results: FileSystemEntry[] = []
|
const results: FileSystemEntry[] = []
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const FilesystemQuerySchema = z.object({
|
|||||||
includeFiles: z.coerce.boolean().optional(),
|
includeFiles: z.coerce.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const FilesystemCreateFolderSchema = z.object({
|
||||||
|
parentPath: z.string().optional(),
|
||||||
|
name: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.get("/api/filesystem", async (request, reply) => {
|
app.get("/api/filesystem", async (request, reply) => {
|
||||||
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
const query = FilesystemQuerySchema.parse(request.query ?? {})
|
||||||
@@ -24,4 +29,26 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
|||||||
return { error: (error as Error).message }
|
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")
|
const previousDir = path.join(uiRoot, "previous")
|
||||||
|
|
||||||
if (!options.autoUpdate) {
|
if (!options.autoUpdate) {
|
||||||
const local = await resolveStaticUiDir(currentDir)
|
return await resolveFromCacheOrBundled({
|
||||||
if (local) {
|
logger: options.logger,
|
||||||
return {
|
bundledUiDir: options.bundledUiDir,
|
||||||
uiStaticDir: local,
|
currentDir,
|
||||||
source: "downloaded",
|
previousDir,
|
||||||
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,
|
|
||||||
supported: true,
|
supported: true,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let manifest: RemoteUiManifest | null = null
|
let manifest: RemoteUiManifest | null = null
|
||||||
@@ -125,20 +115,28 @@ export async function resolveUi(options: RemoteUiOptions): Promise<UiResolution>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentVersion = await readUiVersion(currentDir)
|
const bestLocal = await pickBestLocalUi({
|
||||||
if (currentVersion && currentVersion === manifest.latestUIVersion) {
|
logger: options.logger,
|
||||||
const currentResolved = await resolveStaticUiDir(currentDir)
|
bundledUiDir: options.bundledUiDir,
|
||||||
if (currentResolved) {
|
currentDir,
|
||||||
return {
|
previousDir,
|
||||||
uiStaticDir: currentResolved,
|
})
|
||||||
source: "downloaded",
|
|
||||||
uiVersion: currentVersion,
|
const remoteIsNewer =
|
||||||
supported: true,
|
!bestLocal ||
|
||||||
latestServerVersion: manifest.latestServerVersion,
|
compareSemverMaybe(manifest.latestUIVersion, bestLocal.uiVersion) > 0
|
||||||
latestServerUrl: manifest.latestServerUrl,
|
|
||||||
minServerVersion: manifest.minServerVersion,
|
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 {
|
try {
|
||||||
@@ -206,40 +204,18 @@ async function resolveFromCacheOrBundled(args: {
|
|||||||
latestServerUrl?: string
|
latestServerUrl?: string
|
||||||
minServerVersion?: string
|
minServerVersion?: string
|
||||||
}): Promise<UiResolution> {
|
}): Promise<UiResolution> {
|
||||||
const currentResolved = await resolveStaticUiDir(args.currentDir)
|
const bestLocal = await pickBestLocalUi({
|
||||||
if (currentResolved) {
|
logger: args.logger,
|
||||||
return {
|
bundledUiDir: args.bundledUiDir,
|
||||||
uiStaticDir: currentResolved,
|
currentDir: args.currentDir,
|
||||||
source: "downloaded",
|
previousDir: args.previousDir,
|
||||||
uiVersion: await readUiVersion(currentResolved),
|
})
|
||||||
supported: args.supported,
|
|
||||||
message: args.message,
|
|
||||||
latestServerVersion: args.latestServerVersion,
|
|
||||||
latestServerUrl: args.latestServerUrl,
|
|
||||||
minServerVersion: args.minServerVersion,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousResolved = await resolveStaticUiDir(args.previousDir)
|
if (bestLocal) {
|
||||||
if (previousResolved) {
|
|
||||||
return {
|
return {
|
||||||
uiStaticDir: previousResolved,
|
uiStaticDir: bestLocal.uiStaticDir,
|
||||||
source: "previous",
|
source: bestLocal.source,
|
||||||
uiVersion: await readUiVersion(previousResolved),
|
uiVersion: bestLocal.uiVersion,
|
||||||
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),
|
|
||||||
supported: args.supported,
|
supported: args.supported,
|
||||||
message: args.message,
|
message: args.message,
|
||||||
latestServerVersion: args.latestServerVersion,
|
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> {
|
async function resolveStaticUiDir(uiDir: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const indexPath = path.join(uiDir, "index.html")
|
const indexPath = path.join(uiDir, "index.html")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChildProcess, spawn } from "child_process"
|
import { ChildProcess, spawn, spawnSync } from "child_process"
|
||||||
import { existsSync, statSync } from "fs"
|
import { existsSync, statSync } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { EventBus } from "../events/bus"
|
import { EventBus } from "../events/bus"
|
||||||
@@ -122,10 +122,12 @@ export class WorkspaceRuntime {
|
|||||||
},
|
},
|
||||||
"Launching OpenCode process",
|
"Launching OpenCode process",
|
||||||
)
|
)
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
const child = spawn(spec.command, spec.args, {
|
const child = spawn(spec.command, spec.args, {
|
||||||
cwd: options.folder,
|
cwd: options.folder,
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
detached,
|
||||||
...spec.options,
|
...spec.options,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -259,10 +261,96 @@ export class WorkspaceRuntime {
|
|||||||
const child = managed.child
|
const child = managed.child
|
||||||
this.logger.info({ workspaceId }, "Stopping OpenCode process")
|
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) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let escalationTimer: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
child.removeListener("exit", onExit)
|
child.removeListener("exit", onExit)
|
||||||
child.removeListener("error", onError)
|
child.removeListener("error", onError)
|
||||||
|
if (escalationTimer) {
|
||||||
|
clearTimeout(escalationTimer)
|
||||||
|
escalationTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onExit = () => {
|
const onExit = () => {
|
||||||
@@ -274,32 +362,30 @@ export class WorkspaceRuntime {
|
|||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveIfAlreadyExited = () => {
|
if (isAlreadyExited()) {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) {
|
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
||||||
this.logger.debug({ workspaceId, exitCode: child.exitCode, signal: child.signalCode }, "Process already exited")
|
cleanup()
|
||||||
cleanup()
|
resolve()
|
||||||
resolve()
|
return
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
child.once("exit", onExit)
|
child.once("exit", onExit)
|
||||||
child.once("error", onError)
|
child.once("error", onError)
|
||||||
|
|
||||||
if (resolveIfAlreadyExited()) {
|
this.logger.debug(
|
||||||
return
|
{ workspaceId, pid, detached: process.platform !== "win32" },
|
||||||
}
|
"Sending SIGTERM to workspace process (tree/group)",
|
||||||
|
)
|
||||||
|
sendStopSignal("SIGTERM")
|
||||||
|
|
||||||
this.logger.debug({ workspaceId }, "Sending SIGTERM to workspace process")
|
escalationTimer = setTimeout(() => {
|
||||||
child.kill("SIGTERM")
|
escalationTimer = null
|
||||||
setTimeout(() => {
|
if (isAlreadyExited()) {
|
||||||
if (!child.killed) {
|
this.logger.debug({ workspaceId, pid }, "Workspace exited before SIGKILL escalation")
|
||||||
this.logger.warn({ workspaceId }, "Process did not stop after SIGTERM, force killing")
|
return
|
||||||
child.kill("SIGKILL")
|
|
||||||
} else {
|
|
||||||
this.logger.debug({ workspaceId }, "Workspace process stopped gracefully before SIGKILL timeout")
|
|
||||||
}
|
}
|
||||||
|
this.logger.warn({ workspaceId, pid }, "Process did not stop after SIGTERM, escalating")
|
||||||
|
sendStopSignal("SIGKILL")
|
||||||
}, 2000)
|
}, 2000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/tauri-app",
|
"name": "@codenomad/tauri-app",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tauri dev",
|
"dev": "tauri dev",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@codenomad/ui",
|
"name": "@codenomad/ui",
|
||||||
"version": "0.7.6",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -61,13 +61,20 @@ function dismiss(confirmed: boolean, payload?: AlertDialogState | null, promptVa
|
|||||||
|
|
||||||
const AlertDialog: Component = () => {
|
const AlertDialog: Component = () => {
|
||||||
let primaryButtonRef: HTMLButtonElement | undefined
|
let primaryButtonRef: HTMLButtonElement | undefined
|
||||||
|
let promptInputRef: HTMLInputElement | undefined
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (alertDialogState()) {
|
const state = alertDialogState()
|
||||||
queueMicrotask(() => {
|
if (!state) return
|
||||||
primaryButtonRef?.focus()
|
|
||||||
})
|
queueMicrotask(() => {
|
||||||
}
|
if (state.type === "prompt") {
|
||||||
|
promptInputRef?.focus()
|
||||||
|
promptInputRef?.select()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
primaryButtonRef?.focus()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -118,25 +125,29 @@ const AlertDialog: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={isPrompt}>
|
<Show when={isPrompt}>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<label class="text-xs font-medium text-muted uppercase tracking-wide">
|
<label class="text-sm font-medium text-secondary">{payload.inputLabel || "Input"}</label>
|
||||||
{payload.inputLabel || "Arguments"}
|
<input
|
||||||
</label>
|
ref={(el) => {
|
||||||
<input
|
promptInputRef = el
|
||||||
class="modal-search-input mt-2"
|
}}
|
||||||
value={inputValue()}
|
class="form-input mt-2"
|
||||||
placeholder={payload.inputPlaceholder || ""}
|
value={inputValue()}
|
||||||
onInput={(e) => setInputValue(e.currentTarget.value)}
|
placeholder={payload.inputPlaceholder || ""}
|
||||||
onKeyDown={(e) => {
|
autocapitalize="off"
|
||||||
if (e.key === "Enter") {
|
autocorrect="off"
|
||||||
e.preventDefault()
|
spellcheck={false}
|
||||||
dismiss(true, payload, inputValue())
|
onInput={(e) => setInputValue(e.currentTarget.value)}
|
||||||
}
|
onKeyDown={(e) => {
|
||||||
}}
|
if (e.key === "Enter") {
|
||||||
/>
|
e.preventDefault()
|
||||||
</div>
|
dismiss(true, payload, inputValue())
|
||||||
</Show>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
{(isConfirm || isPrompt) && (
|
{(isConfirm || isPrompt) && (
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
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 type { FileSystemEntry, FileSystemListingMetadata } from "../../../server/src/api-types"
|
||||||
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
import { WINDOWS_DRIVES_ROOT } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
|
import { showAlertDialog, showPromptDialog } from "../stores/alerts"
|
||||||
|
|
||||||
function normalizePathKey(input?: string | null) {
|
function normalizePathKey(input?: string | null) {
|
||||||
if (!input || input === "." || input === "./") {
|
if (!input || input === "." || input === "./") {
|
||||||
@@ -64,6 +65,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
const [rootPath, setRootPath] = createSignal("")
|
const [rootPath, setRootPath] = createSignal("")
|
||||||
const [loading, setLoading] = createSignal(false)
|
const [loading, setLoading] = createSignal(false)
|
||||||
const [error, setError] = createSignal<string | null>(null)
|
const [error, setError] = createSignal<string | null>(null)
|
||||||
|
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||||
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
|
||||||
@@ -256,6 +258,52 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
|||||||
props.onSelect(absolutePath)
|
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) {
|
function isPathLoading(path: string) {
|
||||||
return loadingPaths().has(normalizePathKey(path))
|
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-label">Current folder</span>
|
||||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="directory-browser-current-actions">
|
||||||
type="button"
|
<button
|
||||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
type="button"
|
||||||
disabled={!canSelectCurrent()}
|
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||||
onClick={() => {
|
disabled={!canSelectCurrent() || creatingFolder()}
|
||||||
const absolute = currentAbsolutePath()
|
onClick={() => {
|
||||||
if (absolute) {
|
const absolute = currentAbsolutePath()
|
||||||
props.onSelect(absolute)
|
if (absolute) {
|
||||||
}
|
props.onSelect(absolute)
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
Select Current
|
>
|
||||||
</button>
|
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>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show
|
<Show
|
||||||
|
|||||||
@@ -57,6 +57,19 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
|
|
||||||
|
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
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 normalizedKey = e.key.toLowerCase()
|
||||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||||
const blockedKeys = [
|
const blockedKeys = [
|
||||||
|
|||||||
@@ -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 { messageStoreBus } from "../stores/message-v2/bus"
|
||||||
import { Markdown } from "./markdown"
|
|
||||||
import { ToolCallDiffViewer } from "./diff-viewer"
|
|
||||||
import { useTheme } from "../lib/theme"
|
import { useTheme } from "../lib/theme"
|
||||||
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
import { useGlobalCache } from "../lib/hooks/use-global-cache"
|
||||||
import { useConfig } from "../stores/preferences"
|
import { useConfig } from "../stores/preferences"
|
||||||
import type { DiffViewMode } from "../stores/preferences"
|
|
||||||
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
import { activeInterruption, sendPermissionResponse, sendQuestionReject, sendQuestionReply } from "../stores/instances"
|
||||||
import type { PermissionRequestLike } from "../types/permission"
|
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 { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import type { TextPart, RenderCache } from "../types/message"
|
|
||||||
import { resolveToolRenderer } from "./tool-call/renderers"
|
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 {
|
import type {
|
||||||
DiffPayload,
|
DiffPayload,
|
||||||
DiffRenderOptions,
|
DiffRenderOptions,
|
||||||
@@ -24,38 +27,11 @@ import type {
|
|||||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||||
import { resolveTitleForTool } from "./tool-call/tool-title"
|
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { ansiToHtml, createAnsiStreamRenderer, hasAnsi } from "../lib/ansi"
|
|
||||||
import { escapeHtml } from "../lib/markdown"
|
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
|
|
||||||
type ToolState = import("@opencode-ai/sdk").ToolState
|
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_CALL_CACHE_SCOPE = "tool-call"
|
||||||
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
const TOOL_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const TOOL_SCROLL_INTENT_WINDOW_MS = 600
|
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) {
|
export default function ToolCall(props: ToolCallProps) {
|
||||||
const { preferences, setDiffViewMode } = useConfig()
|
const { preferences, setDiffViewMode } = useConfig()
|
||||||
@@ -561,6 +97,9 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return "noversion"
|
return "noversion"
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const messageVersionAccessor = createMemo(() => props.messageVersion)
|
||||||
|
const partVersionAccessor = createMemo(() => props.partVersion)
|
||||||
|
|
||||||
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
const createVariantCache = (variant: string | (() => string), version?: () => string) =>
|
||||||
useGlobalCache({
|
useGlobalCache({
|
||||||
instanceId: () => props.instanceId,
|
instanceId: () => props.instanceId,
|
||||||
@@ -578,8 +117,6 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
const permissionDiffCache = createVariantCache("permission-diff")
|
const permissionDiffCache = createVariantCache("permission-diff")
|
||||||
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
const ansiRunningCache = createVariantCache("ansi-running", () => "running")
|
||||||
const ansiFinalCache = createVariantCache("ansi-final")
|
const ansiFinalCache = createVariantCache("ansi-final")
|
||||||
const runningAnsiRenderer = createAnsiStreamRenderer()
|
|
||||||
let runningAnsiSource = ""
|
|
||||||
|
|
||||||
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
const permissionState = createMemo(() => store().getPermissionState(props.messageId, toolCallIdentifier()))
|
||||||
const pendingPermission = createMemo(() => {
|
const pendingPermission = createMemo(() => {
|
||||||
@@ -997,191 +534,35 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
const renderer = createMemo(() => resolveToolRenderer(toolName()))
|
||||||
|
|
||||||
function renderDiffContent(payload: DiffPayload, options?: DiffRenderOptions) {
|
const { renderAnsiContent } = createAnsiContentRenderer({
|
||||||
const relativePath = payload.filePath ? getRelativePath(payload.filePath) : ""
|
ansiRunningCache,
|
||||||
const toolbarLabel = options?.label || (relativePath ? `Diff · ${relativePath}` : "Diff")
|
ansiFinalCache,
|
||||||
const selectedVariant = options?.variant === "permission-diff" ? "permission-diff" : "diff"
|
scrollHelpers,
|
||||||
const cacheHandle = selectedVariant === "permission-diff" ? permissionDiffCache : diffCache
|
partVersion: partVersionAccessor,
|
||||||
const diffMode = () => (preferences().diffViewMode || "split") as DiffViewMode
|
})
|
||||||
const themeKey = isDark() ? "dark" : "light"
|
|
||||||
|
|
||||||
let cachedHtml: string | undefined
|
const { renderDiffContent } = createDiffContentRenderer({
|
||||||
const cached = cacheHandle.get<RenderCache>()
|
preferences,
|
||||||
const currentMode = diffMode()
|
setDiffViewMode,
|
||||||
if (cached && cached.text === payload.diffText && cached.theme === themeKey && cached.mode === currentMode) {
|
isDark,
|
||||||
cachedHtml = cached.html
|
diffCache,
|
||||||
}
|
permissionDiffCache,
|
||||||
|
scrollHelpers,
|
||||||
|
handleScrollRendered,
|
||||||
|
onContentRendered: props.onContentRendered,
|
||||||
|
})
|
||||||
|
|
||||||
const handleModeChange = (mode: DiffViewMode) => {
|
const { renderMarkdownContent } = createMarkdownContentRenderer({
|
||||||
setDiffViewMode(mode)
|
toolState,
|
||||||
}
|
partId: toolCallIdentifier,
|
||||||
|
partVersion: partVersionAccessor,
|
||||||
const handleDiffRendered = () => {
|
instanceId: props.instanceId,
|
||||||
if (!options?.disableScrollTracking) {
|
sessionId: props.sessionId,
|
||||||
handleScrollRendered()
|
isDark,
|
||||||
}
|
scrollHelpers,
|
||||||
props.onContentRendered?.()
|
handleScrollRendered,
|
||||||
}
|
onContentRendered: 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 rendererContext: ToolRendererContext = {
|
const rendererContext: ToolRendererContext = {
|
||||||
toolCall: toolCallMemo,
|
toolCall: toolCallMemo,
|
||||||
@@ -1278,92 +659,17 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const renderPermissionBlock = () => {
|
const renderPermissionBlock = () => (
|
||||||
const permission = permissionDetails()
|
<PermissionToolBlock
|
||||||
if (!permission) return null
|
permission={permissionDetails}
|
||||||
const active = isPermissionActive()
|
active={isPermissionActive}
|
||||||
const metadata = (permission.metadata ?? {}) as Record<string, unknown>
|
submitting={permissionSubmitting}
|
||||||
const diffValue = typeof metadata.diff === "string" ? (metadata.diff as string) : null
|
error={permissionError}
|
||||||
const diffPathRaw = (() => {
|
renderDiff={renderDiffContent}
|
||||||
if (typeof metadata.filePath === "string") {
|
fallbackSessionId={() => props.sessionId}
|
||||||
return metadata.filePath as string
|
onRespond={(permission, sessionId, response) => void handlePermissionResponse(permission, response)}
|
||||||
}
|
/>
|
||||||
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 renderQuestionBlock = () => (
|
const renderQuestionBlock = () => (
|
||||||
<QuestionToolBlock
|
<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 { bashRenderer } from "./bash"
|
||||||
import { defaultRenderer } from "./default"
|
import { defaultRenderer } from "./default"
|
||||||
import { editRenderer } from "./edit"
|
import { editRenderer } from "./edit"
|
||||||
|
import { applyPatchRenderer } from "./apply-patch"
|
||||||
import { patchRenderer } from "./patch"
|
import { patchRenderer } from "./patch"
|
||||||
import { readRenderer } from "./read"
|
import { readRenderer } from "./read"
|
||||||
import { taskRenderer } from "./task"
|
import { taskRenderer } from "./task"
|
||||||
@@ -16,6 +17,7 @@ const TOOL_RENDERERS: ToolRenderer[] = [
|
|||||||
readRenderer,
|
readRenderer,
|
||||||
writeRenderer,
|
writeRenderer,
|
||||||
editRenderer,
|
editRenderer,
|
||||||
|
applyPatchRenderer,
|
||||||
patchRenderer,
|
patchRenderer,
|
||||||
webfetchRenderer,
|
webfetchRenderer,
|
||||||
todoRenderer,
|
todoRenderer,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { For, Show, createMemo } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
import type { ToolState } from "@opencode-ai/sdk"
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
import { getTodoTitle } from "./todo"
|
|
||||||
import { resolveTitleForTool } from "../tool-title"
|
import { resolveTitleForTool } from "../tool-title"
|
||||||
|
|
||||||
interface TaskSummaryItem {
|
interface TaskSummaryItem {
|
||||||
@@ -90,7 +89,51 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
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(() => {
|
const items = createMemo(() => {
|
||||||
// Track the reactive change points so we only recompute when the part/message changes
|
// Track the reactive change points so we only recompute when the part/message changes
|
||||||
messageVersion?.()
|
messageVersion?.()
|
||||||
@@ -114,41 +157,90 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (items().length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div class="tool-call-task-sections">
|
||||||
class="message-text tool-call-markdown tool-call-task-container"
|
<Show when={promptContent()}>
|
||||||
ref={(element) => scrollHelpers?.registerContainer(element)}
|
<section class="tool-call-task-section">
|
||||||
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined}
|
<header class="tool-call-task-section-header">
|
||||||
>
|
<span class="tool-call-task-section-title">Prompt</span>
|
||||||
<div class="tool-call-task-summary">
|
<Show when={headerMeta()}>
|
||||||
<For each={items()}>
|
<span class="tool-call-task-section-meta">{headerMeta()}</span>
|
||||||
{(item) => {
|
</Show>
|
||||||
const icon = getToolIcon(item.tool)
|
</header>
|
||||||
const description = describeToolTitle(item)
|
<div class="tool-call-task-section-body">
|
||||||
const toolLabel = getToolName(item.tool)
|
{renderMarkdown({
|
||||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
content: promptContent()!,
|
||||||
const statusIcon = summarizeStatusIcon(status)
|
cacheKey: "task:prompt",
|
||||||
const statusLabel = summarizeStatusLabel(status)
|
disableScrollTracking: true,
|
||||||
const statusAttr = status ?? "pending"
|
disableHighlight: true,
|
||||||
return (
|
})}
|
||||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
</div>
|
||||||
<span class="tool-call-task-icon">{icon}</span>
|
</section>
|
||||||
<span class="tool-call-task-label">{toolLabel}</span>
|
</Show>
|
||||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
|
||||||
<span class="tool-call-task-text">{description}</span>
|
<Show when={items().length > 0}>
|
||||||
<Show when={statusIcon}>
|
<section class="tool-call-task-section">
|
||||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
<header class="tool-call-task-section-header">
|
||||||
{statusIcon}
|
<span class="tool-call-task-section-title">Steps</span>
|
||||||
</span>
|
<span class="tool-call-task-section-meta">{items().length} steps</span>
|
||||||
</Show>
|
</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>
|
</div>
|
||||||
)
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
}}
|
</div>
|
||||||
</For>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { bashRenderer } from "./renderers/bash"
|
|||||||
import { readRenderer } from "./renderers/read"
|
import { readRenderer } from "./renderers/read"
|
||||||
import { writeRenderer } from "./renderers/write"
|
import { writeRenderer } from "./renderers/write"
|
||||||
import { editRenderer } from "./renderers/edit"
|
import { editRenderer } from "./renderers/edit"
|
||||||
|
import { applyPatchRenderer } from "./renderers/apply-patch"
|
||||||
import { patchRenderer } from "./renderers/patch"
|
import { patchRenderer } from "./renderers/patch"
|
||||||
import { webfetchRenderer } from "./renderers/webfetch"
|
import { webfetchRenderer } from "./renderers/webfetch"
|
||||||
import { todoRenderer } from "./renderers/todo"
|
import { todoRenderer } from "./renderers/todo"
|
||||||
@@ -16,6 +17,7 @@ const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
|||||||
read: readRenderer,
|
read: readRenderer,
|
||||||
write: writeRenderer,
|
write: writeRenderer,
|
||||||
edit: editRenderer,
|
edit: editRenderer,
|
||||||
|
apply_patch: applyPatchRenderer,
|
||||||
patch: patchRenderer,
|
patch: patchRenderer,
|
||||||
webfetch: webfetchRenderer,
|
webfetch: webfetchRenderer,
|
||||||
todowrite: todoRenderer,
|
todowrite: todoRenderer,
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export interface MarkdownRenderOptions {
|
|||||||
content: string
|
content: string
|
||||||
size?: "default" | "large"
|
size?: "default" | "large"
|
||||||
disableHighlight?: boolean
|
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 {
|
export interface AnsiRenderOptions {
|
||||||
@@ -26,6 +36,11 @@ export interface DiffRenderOptions {
|
|||||||
variant?: string
|
variant?: string
|
||||||
disableScrollTracking?: boolean
|
disableScrollTracking?: boolean
|
||||||
label?: string
|
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 {
|
export interface ToolScrollHelpers {
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export function getToolIcon(tool: string): string {
|
|||||||
return "📁"
|
return "📁"
|
||||||
case "patch":
|
case "patch":
|
||||||
return "🔧"
|
return "🔧"
|
||||||
|
case "apply_patch":
|
||||||
|
return "🔧"
|
||||||
default:
|
default:
|
||||||
return "🔧"
|
return "🔧"
|
||||||
}
|
}
|
||||||
@@ -67,6 +69,8 @@ export function getToolName(tool: string): string {
|
|||||||
case "todowrite":
|
case "todowrite":
|
||||||
case "todoread":
|
case "todoread":
|
||||||
return "Plan"
|
return "Plan"
|
||||||
|
case "apply_patch":
|
||||||
|
return "Apply patch"
|
||||||
default: {
|
default: {
|
||||||
const normalized = tool.replace(/^opencode_/, "")
|
const normalized = tool.replace(/^opencode_/, "")
|
||||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1)
|
||||||
@@ -220,6 +224,8 @@ export function getDefaultToolAction(toolName: string) {
|
|||||||
return "Planning..."
|
return "Planning..."
|
||||||
case "patch":
|
case "patch":
|
||||||
return "Preparing patch..."
|
return "Preparing patch..."
|
||||||
|
case "apply_patch":
|
||||||
|
return "Preparing apply_patch..."
|
||||||
default:
|
default:
|
||||||
return "Working..."
|
return "Working..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
BinaryUpdateRequest,
|
BinaryUpdateRequest,
|
||||||
BinaryValidationResult,
|
BinaryValidationResult,
|
||||||
FileSystemEntry,
|
FileSystemEntry,
|
||||||
|
FileSystemCreateFolderResponse,
|
||||||
FileSystemListResponse,
|
FileSystemListResponse,
|
||||||
InstanceData,
|
InstanceData,
|
||||||
ServerMeta,
|
ServerMeta,
|
||||||
@@ -224,6 +225,13 @@ export const serverApi = {
|
|||||||
const query = params.toString()
|
const query = params.toString()
|
||||||
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
|
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> {
|
readInstanceData(id: string): Promise<InstanceData> {
|
||||||
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,6 +81,14 @@
|
|||||||
width: auto;
|
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 {
|
.directory-browser-close {
|
||||||
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -217,6 +217,16 @@
|
|||||||
@apply flex items-center justify-between gap-3 px-3 py-2;
|
@apply flex items-center justify-between gap-3 px-3 py-2;
|
||||||
background-color: var(--surface-secondary);
|
background-color: var(--surface-secondary);
|
||||||
border-bottom: 1px solid var(--border-base);
|
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 {
|
.tool-call-diff-toolbar-label {
|
||||||
@@ -423,6 +433,19 @@
|
|||||||
background-clip: padding-box;
|
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 {
|
.tool-call-section h4 {
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
font-weight: var(--font-weight-semibold);
|
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;
|
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 {
|
.tool-call-task-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -103,6 +103,25 @@
|
|||||||
box-shadow: 0 0 0 2px var(--accent-primary);
|
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 */
|
/* Shared animations */
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user