Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6428702b | ||
|
|
e08ebb2057 | ||
|
|
9683f90f7e | ||
|
|
06cb986aa6 | ||
|
|
a85c2f1700 | ||
|
|
bd2a0d1bec | ||
|
|
df9722cd16 | ||
|
|
dffa4907ec | ||
|
|
e567d35438 | ||
|
|
62f52fc534 | ||
|
|
69f221942c | ||
|
|
7749225f71 | ||
|
|
ae322c53cc |
12
package-lock.json
generated
12
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"7zip-bin": "^5.2.0",
|
||||
"google-auth-library": "^10.5.0"
|
||||
@@ -7389,7 +7389,7 @@
|
||||
},
|
||||
"packages/electron-app": {
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@codenomad/ui": "file:../ui",
|
||||
"@neuralnomads/codenomad": "file:../server"
|
||||
@@ -7423,7 +7423,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"@fastify/reply-from": "^9.8.0",
|
||||
@@ -7458,14 +7458,14 @@
|
||||
},
|
||||
"packages/tauri-app": {
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@git-diff-view/solid": "^0.0.8",
|
||||
"@kobalte/core": "0.13.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codenomad-workspace",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"private": true,
|
||||
"description": "CodeNomad monorepo workspace",
|
||||
"workspaces": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad-electron-app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"description": "CodeNomad - AI coding assistant",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.1.12"
|
||||
"@opencode-ai/plugin": "1.1.16"
|
||||
}
|
||||
}
|
||||
|
||||
4
packages/server/package-lock.json
generated
4
packages/server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^8.5.0",
|
||||
"commander": "^12.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@neuralnomads/codenomad",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"description": "CodeNomad Server",
|
||||
"author": {
|
||||
"name": "Neural Nomads",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||
import { createWriteStream, existsSync, promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { randomBytes } from "crypto"
|
||||
@@ -60,10 +60,13 @@ export class BackgroundProcessManager {
|
||||
|
||||
const outputStream = createWriteStream(outputPath, { flags: "a" })
|
||||
|
||||
const child = spawn("bash", ["-c", command], {
|
||||
const { shellCommand, shellArgs, spawnOptions } = this.buildShellSpawn(command)
|
||||
|
||||
const child = spawn(shellCommand, shellArgs, {
|
||||
cwd: workspace.path,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
...spawnOptions,
|
||||
})
|
||||
|
||||
child.on("exit", () => {
|
||||
@@ -274,7 +277,15 @@ export class BackgroundProcessManager {
|
||||
const pid = child.pid
|
||||
if (!pid) return
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
if (process.platform === "win32") {
|
||||
const args = this.buildWindowsTaskkillArgs(pid, signal)
|
||||
try {
|
||||
spawnSync("taskkill", args, { stdio: "ignore" })
|
||||
return
|
||||
} catch {
|
||||
// Fall back to killing the direct child.
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
@@ -321,6 +332,30 @@ export class BackgroundProcessManager {
|
||||
}
|
||||
|
||||
|
||||
private buildShellSpawn(command: string): { shellCommand: string; shellArgs: string[]; spawnOptions?: Record<string, unknown> } {
|
||||
if (process.platform === "win32") {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
return {
|
||||
shellCommand: comspec,
|
||||
shellArgs: ["/d", "/s", "/c", command],
|
||||
spawnOptions: { windowsVerbatimArguments: true },
|
||||
}
|
||||
}
|
||||
|
||||
// Keep bash for macOS/Linux.
|
||||
return { shellCommand: "bash", shellArgs: ["-c", command] }
|
||||
}
|
||||
|
||||
private buildWindowsTaskkillArgs(pid: number, signal: NodeJS.Signals): string[] {
|
||||
// Default to graceful termination (no /F), then force kill when we escalate.
|
||||
const force = signal === "SIGKILL"
|
||||
const args = ["/PID", String(pid), "/T"]
|
||||
if (force) {
|
||||
args.push("/F")
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private statusFromExit(code: number | null): BackgroundProcessStatus {
|
||||
if (code === null) return "stopped"
|
||||
if (code === 0) return "stopped"
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
BinaryUpdateRequest,
|
||||
BinaryValidationResult,
|
||||
} from "../api-types"
|
||||
import { spawnSync } from "child_process"
|
||||
import { ConfigStore } from "./store"
|
||||
import { EventBus } from "../events/bus"
|
||||
import type { ConfigFile } from "./schema"
|
||||
import { Logger } from "../logger"
|
||||
import { buildSpawnSpec } from "../workspaces/runtime"
|
||||
|
||||
export class BinaryRegistry {
|
||||
constructor(
|
||||
@@ -135,8 +137,42 @@ export class BinaryRegistry {
|
||||
}
|
||||
|
||||
private validateRecord(record: BinaryRecord): BinaryValidationResult {
|
||||
// TODO: call actual binary -v check.
|
||||
return { valid: true, version: record.version }
|
||||
const inputPath = record.path
|
||||
if (!inputPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(inputPath, ["--version"])
|
||||
|
||||
try {
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdout = (result.stdout ?? "").trim()
|
||||
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
|
||||
const normalized = firstLine?.trim()
|
||||
|
||||
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
|
||||
const version = versionMatch?.[1]
|
||||
|
||||
return { valid: true, version }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private buildFallbackRecord(path: string): BinaryRecord {
|
||||
|
||||
@@ -127,10 +127,18 @@ function parsePort(input: string): number {
|
||||
}
|
||||
|
||||
function resolveHost(input: string | undefined): string {
|
||||
if (input && input.trim() === "0.0.0.0") {
|
||||
const trimmed = input?.trim()
|
||||
if (!trimmed) return DEFAULT_HOST
|
||||
|
||||
if (trimmed === "0.0.0.0") {
|
||||
return "0.0.0.0"
|
||||
}
|
||||
return DEFAULT_HOST
|
||||
|
||||
if (trimmed === "localhost") {
|
||||
return DEFAULT_HOST
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -149,11 +157,13 @@ async function main() {
|
||||
|
||||
const eventBus = new EventBus(eventLogger)
|
||||
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
const serverMeta: ServerMeta = {
|
||||
httpBaseUrl: `http://${options.host}:${options.port}`,
|
||||
eventsUrl: `/api/events`,
|
||||
host: options.host,
|
||||
listeningMode: options.host === "0.0.0.0" ? "all" : "local",
|
||||
listeningMode: isLoopbackHost(options.host) ? "local" : "all",
|
||||
port: options.port,
|
||||
hostLabel: options.host,
|
||||
workspaceRoot: options.rootDir,
|
||||
|
||||
@@ -93,6 +93,7 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
})
|
||||
|
||||
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"])
|
||||
const isLoopbackHost = (host: string) => host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
|
||||
app.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
@@ -113,10 +114,17 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (allowedDevOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
if (allowedDevOrigins.has(origin)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
// When we bind to a non-loopback host (e.g., 0.0.0.0 or LAN IP), allow cross-origin UI access.
|
||||
if (deps.host === "0.0.0.0" || !isLoopbackHost(deps.host)) {
|
||||
cb(null, true)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
cb(null, false)
|
||||
},
|
||||
@@ -275,13 +283,13 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const displayHost = deps.host === "0.0.0.0" ? "127.0.0.1" : deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const displayHost = deps.host === "127.0.0.1" ? "localhost" : deps.host
|
||||
const serverUrl = `http://${displayHost}:${actualPort}`
|
||||
|
||||
deps.serverMeta.httpBaseUrl = serverUrl
|
||||
deps.serverMeta.host = deps.host
|
||||
deps.serverMeta.port = actualPort
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local"
|
||||
deps.serverMeta.listeningMode = deps.host === "0.0.0.0" || !isLoopbackHost(deps.host) ? "all" : "local"
|
||||
deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening")
|
||||
console.log(`CodeNomad Server is ready at ${serverUrl}`)
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||
return {
|
||||
...meta,
|
||||
port,
|
||||
listeningMode: meta.host === "0.0.0.0" ? "all" : "local",
|
||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||
addresses,
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,10 @@ function resolvePort(meta: ServerMeta): number {
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopbackHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "::1" || host.startsWith("127.")
|
||||
}
|
||||
|
||||
function resolveAddresses(port: number, host: string): NetworkAddress[] {
|
||||
const interfaces = os.networkInterfaces()
|
||||
const seen = new Set<string>()
|
||||
|
||||
@@ -225,13 +225,15 @@ export class WorkspaceManager {
|
||||
try {
|
||||
const result = spawnSync(locator, [identifier], { encoding: "utf8" })
|
||||
if (result.status === 0 && result.stdout) {
|
||||
const resolved = result.stdout
|
||||
const candidates = result.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0)
|
||||
.filter((line) => line.length > 0)
|
||||
.filter((line) => !/^INFO:/i.test(line))
|
||||
|
||||
if (resolved) {
|
||||
this.options.logger.debug({ identifier, resolved }, "Resolved binary path from system PATH")
|
||||
if (candidates.length > 0) {
|
||||
const resolved = this.pickBinaryCandidate(candidates)
|
||||
this.options.logger.debug({ identifier, resolved, candidates }, "Resolved binary path from system PATH")
|
||||
return resolved
|
||||
}
|
||||
} else if (result.error) {
|
||||
@@ -244,6 +246,23 @@ export class WorkspaceManager {
|
||||
return identifier
|
||||
}
|
||||
|
||||
private pickBinaryCandidate(candidates: string[]): string {
|
||||
if (process.platform !== "win32") {
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
const extensionPreference = [".exe", ".cmd", ".bat", ".ps1"]
|
||||
|
||||
for (const ext of extensionPreference) {
|
||||
const match = candidates.find((candidate) => candidate.toLowerCase().endsWith(ext))
|
||||
if (match) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
|
||||
return candidates[0] ?? ""
|
||||
}
|
||||
|
||||
private detectBinaryVersion(resolvedPath: string): string | undefined {
|
||||
if (!resolvedPath) {
|
||||
return undefined
|
||||
|
||||
@@ -5,6 +5,41 @@ import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true } as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {} as const,
|
||||
}
|
||||
}
|
||||
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
@@ -73,22 +108,25 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const commandLine = [options.binaryPath, ...args].join(" ")
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
workspaceId: options.workspaceId,
|
||||
folder: options.folder,
|
||||
binary: options.binaryPath,
|
||||
args,
|
||||
spawnCommand: spec.command,
|
||||
spawnArgs: spec.args,
|
||||
commandLine,
|
||||
env: redactEnvironment(env),
|
||||
},
|
||||
"Launching OpenCode process",
|
||||
)
|
||||
const child = spawn(options.binaryPath, args, {
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/tauri-app",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tauri dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@codenomad/ui",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -82,8 +82,20 @@ interface TaskSessionLocation {
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
function findTaskSessionLocation(sessionId: string): TaskSessionLocation | null {
|
||||
function findTaskSessionLocation(sessionId: string, preferredInstanceId?: string): TaskSessionLocation | null {
|
||||
if (!sessionId) return null
|
||||
|
||||
if (preferredInstanceId) {
|
||||
const session = sessions().get(preferredInstanceId)?.get(sessionId)
|
||||
if (session) {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
instanceId: preferredInstanceId,
|
||||
parentId: session.parentId ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allSessions = sessions()
|
||||
for (const [instanceId, sessionMap] of allSessions) {
|
||||
const session = sessionMap?.get(sessionId)
|
||||
@@ -440,7 +452,7 @@ export default function MessageBlock(props: MessageBlockProps) {
|
||||
const hasToolState =
|
||||
Boolean(toolState) && (isToolStateRunning(toolState) || isToolStateCompleted(toolState) || isToolStateError(toolState))
|
||||
const taskSessionId = hasToolState ? extractTaskSessionId(toolState) : ""
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId) : null
|
||||
const taskLocation = taskSessionId ? findTaskSessionLocation(taskSessionId, props.instanceId) : null
|
||||
const handleGoToTaskSession = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
setActivePermissionIdForInstance,
|
||||
setActiveQuestionIdForInstance,
|
||||
} from "../stores/instances"
|
||||
import { loadMessages, setActiveSession } from "../stores/sessions"
|
||||
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
|
||||
import { messageStoreBus } from "../stores/message-v2/bus"
|
||||
import ToolCall from "./tool-call"
|
||||
|
||||
@@ -201,7 +201,14 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
|
||||
|
||||
function handleGoToSession(sessionId: string) {
|
||||
if (!sessionId) return
|
||||
setActiveSession(props.instanceId, sessionId)
|
||||
|
||||
const session = sessionStateSessions().get(props.instanceId)?.get(sessionId)
|
||||
const parentId = session?.parentId ?? session?.id
|
||||
if (parentId) {
|
||||
ensureSessionParentExpanded(props.instanceId, parentId)
|
||||
}
|
||||
|
||||
setActiveSessionFromList(props.instanceId, sessionId)
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
|
||||
@@ -604,6 +604,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
}
|
||||
}
|
||||
|
||||
setExpandState("normal")
|
||||
clearPrompt()
|
||||
|
||||
// Ignore attachments for slash commands, but keep them for next prompt.
|
||||
@@ -843,7 +844,10 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
const currentPrompt = prompt()
|
||||
const pos = atPosition()
|
||||
const cursorPos = textareaRef?.selectionStart || 0
|
||||
const folderMention = relativePath === "." || relativePath === "" ? "/" : displayPath
|
||||
const folderMention =
|
||||
relativePath === "." || relativePath === ""
|
||||
? "/"
|
||||
: relativePath.replace(/\/+$/, "") + "/"
|
||||
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos + 1)
|
||||
@@ -887,7 +891,7 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
if (pos !== null) {
|
||||
const before = currentPrompt.substring(0, pos)
|
||||
const after = currentPrompt.substring(cursorPos)
|
||||
const attachmentText = `@${filename}`
|
||||
const attachmentText = `@${normalizedPath}`
|
||||
const newPrompt = before + attachmentText + " " + after
|
||||
setPrompt(newPrompt)
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Show, For, createMemo, createEffect, type Component } from "solid-js"
|
||||
import { Expand } from "lucide-solid"
|
||||
import type { Session } from "../../types/session"
|
||||
import type { Attachment } from "../../types/attachment"
|
||||
import type { ClientPart } from "../../types/message"
|
||||
import MessageSection from "../message-section"
|
||||
import { messageStoreBus } from "../../stores/message-v2/bus"
|
||||
import PromptInput from "../prompt-input"
|
||||
import AttachmentChip from "../attachment-chip"
|
||||
import type { Attachment as PromptAttachment } from "../../types/attachment"
|
||||
import { getAttachments, removeAttachment } from "../../stores/attachments"
|
||||
import { instances } from "../../stores/instances"
|
||||
import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
|
||||
@@ -49,6 +50,54 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
})
|
||||
|
||||
const attachments = createMemo(() => getAttachments(props.instanceId, props.sessionId))
|
||||
|
||||
function handleExpandTextAttachment(attachment: PromptAttachment) {
|
||||
if (attachment.source.type !== "text") return
|
||||
|
||||
const textarea = rootRef?.querySelector(".prompt-input") as HTMLTextAreaElement | null
|
||||
const value = attachment.source.value
|
||||
const match = attachment.display.match(/pasted #(\d+)/)
|
||||
const placeholder = match ? `[pasted #${match[1]}]` : null
|
||||
|
||||
const currentText = textarea?.value ?? ""
|
||||
|
||||
let nextText = currentText
|
||||
let selectionTarget: number | null = null
|
||||
|
||||
if (placeholder) {
|
||||
const placeholderIndex = currentText.indexOf(placeholder)
|
||||
if (placeholderIndex !== -1) {
|
||||
nextText =
|
||||
currentText.substring(0, placeholderIndex) +
|
||||
value +
|
||||
currentText.substring(placeholderIndex + placeholder.length)
|
||||
selectionTarget = placeholderIndex + value.length
|
||||
}
|
||||
}
|
||||
|
||||
if (nextText === currentText) {
|
||||
if (textarea) {
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
nextText = currentText.substring(0, start) + value + currentText.substring(end)
|
||||
selectionTarget = start + value.length
|
||||
} else {
|
||||
nextText = currentText + value
|
||||
}
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.value = nextText
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }))
|
||||
textarea.focus()
|
||||
if (selectionTarget !== null) {
|
||||
textarea.setSelectionRange(selectionTarget, selectionTarget)
|
||||
}
|
||||
}
|
||||
|
||||
removeAttachment(props.instanceId, props.sessionId, attachment.id)
|
||||
}
|
||||
|
||||
let scrollToBottomHandle: (() => void) | undefined
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
function scheduleScrollToBottom() {
|
||||
@@ -235,14 +284,35 @@ export const SessionView: Component<SessionViewProps> = (props) => {
|
||||
|
||||
|
||||
<Show when={attachments().length > 0}>
|
||||
<div class="flex flex-wrap gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<div class="flex flex-wrap items-center gap-1.5 border-t px-3 py-2" style="border-color: var(--border-base);">
|
||||
<For each={attachments()}>
|
||||
{(attachment) => (
|
||||
<AttachmentChip
|
||||
attachment={attachment}
|
||||
onRemove={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
/>
|
||||
)}
|
||||
{(attachment) => {
|
||||
const isText = attachment.source.type === "text"
|
||||
return (
|
||||
<div class="attachment-chip" title={attachment.source.type === "file" ? attachment.source.path : undefined}>
|
||||
<span class="font-mono">{attachment.display}</span>
|
||||
<Show when={isText}>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-expand"
|
||||
onClick={() => handleExpandTextAttachment(attachment)}
|
||||
aria-label="Expand pasted text"
|
||||
title="Insert pasted text"
|
||||
>
|
||||
<Expand class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="attachment-remove"
|
||||
onClick={() => removeAttachment(props.instanceId, props.sessionId, attachment.id)}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -339,7 +339,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
scrollToSelected()
|
||||
} else if (e.key === "Enter") {
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault()
|
||||
const selected = items[selectedIndex()]
|
||||
if (selected) {
|
||||
@@ -534,7 +534,7 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
|
||||
|
||||
<div class="dropdown-footer">
|
||||
<div>
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Enter</span> select •{" "}
|
||||
<span class="font-medium">↑↓</span> navigate • <span class="font-medium">Tab/Enter</span> select •{" "}
|
||||
<span class="font-medium">Esc</span> close
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user