mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-07 09:39:46 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11bd0abb68 | |||
| 30a1ef6701 | |||
| 5a355f7f48 | |||
| 0520cc88fc | |||
| 4b61fbd92d | |||
| d7651519f3 | |||
| 1eb3a43add |
@@ -17,6 +17,7 @@
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"exports": {
|
||||
"./environment": "./src/environment/index.ts",
|
||||
"./session/runner": "./src/session/runner/index.ts",
|
||||
"./instructions": "./src/instructions/index.ts",
|
||||
"./*": "./src/*.ts"
|
||||
|
||||
@@ -161,11 +161,14 @@ function isPathAction(action: string): action is PathAction {
|
||||
}
|
||||
|
||||
function expandHome(resource: string, home: string) {
|
||||
if (resource.startsWith("~/")) return home + resource.slice(1)
|
||||
if (resource === "~") return home
|
||||
if (resource === "$HOME") return home
|
||||
if (resource.startsWith("$HOME/")) return home + resource.slice(5)
|
||||
if (resource.startsWith("$HOME\\")) return home + resource.slice(5)
|
||||
const relative = resource.startsWith("~/")
|
||||
? resource.slice(2)
|
||||
: resource.startsWith("$HOME/") || resource.startsWith("$HOME\\")
|
||||
? resource.slice(6)
|
||||
: undefined
|
||||
if (relative !== undefined) return (path.posix.isAbsolute(home) ? path.posix : path.win32).join(home, relative)
|
||||
return resource
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { FilesImpl } from "./files"
|
||||
|
||||
export interface Driver {
|
||||
readonly spawner: ChildProcessSpawner["Service"]
|
||||
readonly overrides?: Partial<FilesImpl>
|
||||
}
|
||||
|
||||
export * as EnvironmentDriver from "./driver"
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Effect, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { collectStream } from "@opencode-ai/util/process"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
|
||||
|
||||
const MAX_DATA_BYTES = 64 * 1024 * 1024
|
||||
const MAX_ERROR_BYTES = 64 * 1024
|
||||
const NOT_FOUND = 44
|
||||
const WRONG_KIND = 45
|
||||
const FAILED = 46
|
||||
|
||||
const loadMetadata = (flags = "") => `
|
||||
metadata=$(stat ${flags} -c '%F\t%s\t%Y' -- "$1" 2>&1) || {
|
||||
case "$metadata" in
|
||||
*'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
|
||||
*) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
|
||||
esac
|
||||
}
|
||||
`
|
||||
|
||||
const statScript = `
|
||||
${loadMetadata()}
|
||||
printf '%s\n' "$metadata"
|
||||
`
|
||||
|
||||
const readScript = `
|
||||
${loadMetadata("-L")}
|
||||
kind=\${metadata%% *}
|
||||
if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
printf '%s\n' "$metadata"
|
||||
if [ "$2" = range ]; then
|
||||
dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
|
||||
else
|
||||
cat -- "$1"
|
||||
fi
|
||||
`
|
||||
|
||||
const listScript = `
|
||||
${loadMetadata()}
|
||||
kind=\${metadata%% *}
|
||||
if [ "$kind" != directory ]; then
|
||||
printf '%s' "$kind" >&2
|
||||
exit ${WRONG_KIND}
|
||||
fi
|
||||
find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
|
||||
`
|
||||
|
||||
interface Result {
|
||||
readonly exitCode: number
|
||||
readonly stdout: Uint8Array
|
||||
readonly stderr: Uint8Array
|
||||
}
|
||||
|
||||
export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
|
||||
const run = (
|
||||
path: string,
|
||||
script: string,
|
||||
args: ReadonlyArray<string> = [],
|
||||
stdin?: Uint8Array,
|
||||
): Effect.Effect<Result, Failed> =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
|
||||
env: { LC_ALL: "C" },
|
||||
extendEnv: true,
|
||||
stdin: stdin === undefined ? undefined : Stream.make(stdin),
|
||||
})
|
||||
const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, MAX_DATA_BYTES),
|
||||
collectStream(handle.stderr, MAX_ERROR_BYTES),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
|
||||
if (stdout.truncated || stderr.truncated) {
|
||||
return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
|
||||
}
|
||||
return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
|
||||
}),
|
||||
)
|
||||
|
||||
const classify = <A>(
|
||||
path: string,
|
||||
result: Result,
|
||||
success: (stdout: Uint8Array) => A,
|
||||
): Effect.Effect<A, NotFound | WrongKind | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
if (result.exitCode === WRONG_KIND) {
|
||||
return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
|
||||
}
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const stat: FilesImpl["stat"] = (path) =>
|
||||
run(path, statScript).pipe(Effect.flatMap((result) => classifyStat(path, result)))
|
||||
|
||||
const complete = (path: string, result: Result) =>
|
||||
result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
|
||||
|
||||
return {
|
||||
stat,
|
||||
read: (path, range) =>
|
||||
run(
|
||||
path,
|
||||
readScript,
|
||||
range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
|
||||
).pipe(
|
||||
Effect.flatMap((result) =>
|
||||
classify(path, result, (stdout) => {
|
||||
const newline = stdout.indexOf(10)
|
||||
if (newline < 0) throw new Error("Missing read metadata header")
|
||||
return {
|
||||
info: parseInfo(stdout.slice(0, newline)),
|
||||
bytes: stdout.slice(newline + 1),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
write: (path, bytes) =>
|
||||
run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
|
||||
Effect.flatMap((result) => complete(path, result)),
|
||||
),
|
||||
list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
|
||||
remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
move: (from, to) =>
|
||||
run(
|
||||
from,
|
||||
`${loadMetadata()}
|
||||
mv -- "$1" "$2"`,
|
||||
[to],
|
||||
).pipe(Effect.flatMap((result) => classifyMove(from, result))),
|
||||
mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
|
||||
}
|
||||
}
|
||||
|
||||
const classifyStat = (path: string, result: Result): Effect.Effect<FileInfo, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.sync(() => parseInfo(result.stdout))
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const classifyMove = (path: string, result: Result): Effect.Effect<void, NotFound | Failed> => {
|
||||
if (result.exitCode === 0) return Effect.void
|
||||
if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
|
||||
return Effect.fail(processFailure(path, result))
|
||||
}
|
||||
|
||||
const processFailure = (path: string, result: Result) =>
|
||||
new Failed({
|
||||
path,
|
||||
cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
|
||||
})
|
||||
|
||||
const parseInfo = (bytes: Uint8Array): FileInfo => {
|
||||
const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split("\t")
|
||||
const size = Number(rawSize)
|
||||
const mtimeMs = Number(rawMtime) * 1_000
|
||||
if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
|
||||
return { type: parseType(rawType), size, mtimeMs }
|
||||
}
|
||||
|
||||
const parseType = (value: string): FileType => {
|
||||
if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
|
||||
if (value === "directory" || value === "d") return "directory"
|
||||
if (value === "symbolic link" || value === "l") return "symlink"
|
||||
return "other"
|
||||
}
|
||||
|
||||
const parseList = (bytes: Uint8Array) => {
|
||||
const fields = new TextDecoder().decode(bytes).split("\0")
|
||||
fields.pop()
|
||||
if (fields.length % 2 !== 0) throw new Error("Invalid find output")
|
||||
return fields
|
||||
.filter((_, index) => index % 2 === 0)
|
||||
.map((type, index) => ({ name: fields[index * 2 + 1], type: parseType(type) }))
|
||||
}
|
||||
|
||||
export * as EnvironmentExecDefaults from "./exec-defaults"
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
|
||||
export type FileType = typeof FileType.Type
|
||||
|
||||
export interface FileInfo {
|
||||
readonly type: FileType
|
||||
readonly size: number
|
||||
readonly mtimeMs: number
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
readonly name: string
|
||||
readonly type: FileType
|
||||
}
|
||||
|
||||
export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
|
||||
path: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
|
||||
path: Schema.String,
|
||||
actual: FileType,
|
||||
}) {}
|
||||
|
||||
export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
|
||||
path: Schema.String,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export interface FilesImpl {
|
||||
/**
|
||||
* Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
|
||||
* The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
|
||||
* `Failed`, so callers must use ranges for larger files.
|
||||
*/
|
||||
readonly read: (
|
||||
path: string,
|
||||
range?: { readonly offset: number; readonly length: number },
|
||||
) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
|
||||
readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
|
||||
/** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
|
||||
readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
|
||||
/** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
|
||||
readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
|
||||
readonly remove: (path: string) => Effect.Effect<void, Failed>
|
||||
readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
|
||||
readonly mkdir: (path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export interface Files extends FilesImpl {}
|
||||
|
||||
export * as EnvironmentFiles from "./files"
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as Environment from "./index"
|
||||
|
||||
export { type Driver } from "./driver"
|
||||
export {
|
||||
type DirEntry,
|
||||
Failed,
|
||||
type FileInfo,
|
||||
type Files,
|
||||
type FilesImpl,
|
||||
type FileType,
|
||||
NotFound,
|
||||
WrongKind,
|
||||
} from "./files"
|
||||
export { execDefaults } from "./exec-defaults"
|
||||
export { makeMemoryDriver, type MemoryDriver } from "./memory"
|
||||
|
||||
import type { Driver } from "./driver"
|
||||
import { execDefaults } from "./exec-defaults"
|
||||
import type { Files } from "./files"
|
||||
|
||||
export const makeFiles = (driver: Driver): Files => ({
|
||||
...execDefaults(driver.spawner),
|
||||
...driver.overrides,
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import path from "node:path"
|
||||
import { Effect, PlatformError } from "effect"
|
||||
import { make } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import type { Driver } from "./driver"
|
||||
import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
|
||||
|
||||
type Node =
|
||||
| { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
|
||||
| { readonly type: "directory"; readonly mtimeMs: number }
|
||||
| { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
|
||||
|
||||
export interface MemoryDriver extends Driver {
|
||||
readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
}
|
||||
|
||||
export const makeMemoryDriver = (): MemoryDriver => {
|
||||
const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
|
||||
const key = (value: string) => path.posix.resolve("/", value)
|
||||
const info = (node: Node): FileInfo => ({
|
||||
type: node.type,
|
||||
size:
|
||||
node.type === "file"
|
||||
? node.bytes.length
|
||||
: node.type === "symlink"
|
||||
? new TextEncoder().encode(node.target).length
|
||||
: 0,
|
||||
mtimeMs: node.mtimeMs,
|
||||
})
|
||||
const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
|
||||
const normalized = key(value)
|
||||
const parts = normalized.split("/").filter(Boolean)
|
||||
const base = "/"
|
||||
const walk = (current: string, index: number): string | undefined => {
|
||||
if (index === parts.length) return current
|
||||
const part = parts[index]
|
||||
const candidate = path.posix.join(current, part)
|
||||
const node = nodes.get(candidate)
|
||||
if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
|
||||
if (seen.has(candidate)) return undefined
|
||||
seen.add(candidate)
|
||||
const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
|
||||
return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
|
||||
}
|
||||
return walk(base, 0)
|
||||
}
|
||||
const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
|
||||
const requireParent = (value: string) => {
|
||||
const parentPath = path.posix.dirname(key(value))
|
||||
const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
|
||||
if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
|
||||
if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
|
||||
}
|
||||
const mkdirSync = (value: string) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const existing = nodes.get(target)
|
||||
if (existing?.type === "directory") return
|
||||
if (existing) throw new Error(`Path is not a directory: ${value}`)
|
||||
const parent = path.posix.dirname(target)
|
||||
if (parent !== target) mkdirSync(parent)
|
||||
nodes.set(target, { type: "directory", mtimeMs: Date.now() })
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
mkdirSync(path.posix.dirname(key(value)))
|
||||
const existing = lookup(value)
|
||||
if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
|
||||
const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
|
||||
if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
|
||||
requireParent(target)
|
||||
nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
for (const entry of nodes.keys()) {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
const spawner = make((command) =>
|
||||
Effect.suspend(() => {
|
||||
const description = command._tag === "StandardCommand" ? command.command : "pipeline"
|
||||
return Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "Unknown",
|
||||
module: "EnvironmentMemory",
|
||||
method: "spawn",
|
||||
pathOrDescriptor: description,
|
||||
cause: failed(description, new Error("The memory driver cannot spawn processes")),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
spawner,
|
||||
overrides,
|
||||
symlink: (target, value) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
requireParent(value)
|
||||
nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export * as EnvironmentMemory from "./memory"
|
||||
@@ -51,6 +51,11 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
it.effect("matches Windows paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadHomePermissions("C:\\Users\\test")
|
||||
expect(permissions).toContainEqual({
|
||||
action: "external_directory",
|
||||
resource: "C:\\Users\\test\\p\\**",
|
||||
effect: "allow",
|
||||
})
|
||||
expect(
|
||||
Permission.evaluate("external_directory", "C:\\Users\\test\\p\\opencode\\src\\*", permissions).effect,
|
||||
).toBe("allow")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import fs from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { environmentConformance } from "./lib/environment-conformance"
|
||||
|
||||
environmentConformance("memory environment", () =>
|
||||
Effect.sync(() => {
|
||||
const driver = makeMemoryDriver()
|
||||
return {
|
||||
files: makeFiles(driver),
|
||||
root: `/workspace-${crypto.randomUUID()}`,
|
||||
symlink: driver.symlink,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
environmentConformance(
|
||||
"GNU exec environment",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
|
||||
return {
|
||||
files: execDefaults(spawner),
|
||||
root: tmp.path,
|
||||
symlink: (target: string, link: string) =>
|
||||
Effect.tryPromise({
|
||||
try: () => fs.symlink(target, link),
|
||||
catch: (cause) => new Failed({ path: link, cause }),
|
||||
}),
|
||||
dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
}
|
||||
}).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
|
||||
process.platform !== "linux",
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
|
||||
import { it } from "./effect"
|
||||
|
||||
export interface EnvironmentHarness {
|
||||
readonly files: Files
|
||||
readonly root: string
|
||||
readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
|
||||
readonly dispose?: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const environmentConformance = <E>(
|
||||
name: string,
|
||||
makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
|
||||
skip = false,
|
||||
) => {
|
||||
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
|
||||
it.live(title, () =>
|
||||
Effect.gen(function* () {
|
||||
const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.ignore(harness.files.remove(harness.root))
|
||||
if (harness.dispose) yield* harness.dispose
|
||||
}),
|
||||
)
|
||||
yield* harness.files.mkdir(harness.root)
|
||||
return yield* body(harness)
|
||||
}),
|
||||
)
|
||||
|
||||
const bytes = (value: string) => new TextEncoder().encode(value)
|
||||
const text = (value: Uint8Array) => new TextDecoder().decode(value)
|
||||
const suite = skip ? describe.skip : describe
|
||||
|
||||
suite(name, () => {
|
||||
check("writes, stats, and reads a file with its info", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/hello.txt`
|
||||
yield* harness.files.write(target, bytes("hello"))
|
||||
const result = yield* harness.files.read(target)
|
||||
expect(text(result.bytes)).toBe("hello")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(5)
|
||||
expect(yield* harness.files.stat(target)).toEqual(result.info)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports missing paths", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/missing`
|
||||
expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
|
||||
expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports the actual kind", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = `${harness.root}/directory`
|
||||
const file = `${harness.root}/file`
|
||||
yield* harness.files.mkdir(directory)
|
||||
yield* harness.files.write(file, bytes("data"))
|
||||
const readError = yield* Effect.flip(harness.files.read(directory))
|
||||
const listError = yield* Effect.flip(harness.files.list(file))
|
||||
expect(readError).toBeInstanceOf(WrongKind)
|
||||
expect((readError as WrongKind).actual).toBe("directory")
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("file")
|
||||
}),
|
||||
)
|
||||
|
||||
check("write creates parent directories", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/one/two/file`
|
||||
yield* harness.files.write(target, bytes("nested"))
|
||||
yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
|
||||
expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
|
||||
expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
|
||||
expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
|
||||
}),
|
||||
)
|
||||
|
||||
check("reads byte ranges", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/range`
|
||||
yield* harness.files.write(target, bytes("0123456789"))
|
||||
expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
|
||||
expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
|
||||
expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
check("lists immediate entries with their kinds", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
|
||||
const entries = yield* harness.files.list(harness.root)
|
||||
expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
|
||||
{ name: "directory", type: "directory" },
|
||||
{ name: "file name", type: "file" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports symlinks without resolving them", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target"))
|
||||
yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
|
||||
yield* harness.symlink("target", `${harness.root}/link`)
|
||||
yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
|
||||
expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
|
||||
expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
|
||||
expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
|
||||
const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
|
||||
expect(listError).toBeInstanceOf(WrongKind)
|
||||
expect((listError as WrongKind).actual).toBe("symlink")
|
||||
}),
|
||||
)
|
||||
|
||||
check("follows symlinks when reading", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
if (!harness.symlink) return
|
||||
yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
|
||||
yield* harness.files.mkdir(`${harness.root}/directory`)
|
||||
yield* harness.symlink("target", `${harness.root}/file-link`)
|
||||
yield* harness.symlink("directory", `${harness.root}/directory-link`)
|
||||
yield* harness.symlink("missing", `${harness.root}/dangling-link`)
|
||||
|
||||
const result = yield* harness.files.read(`${harness.root}/file-link`)
|
||||
expect(text(result.bytes)).toBe("target content")
|
||||
expect(result.info.type).toBe("file")
|
||||
expect(result.info.size).toBe(bytes("target content").length)
|
||||
|
||||
const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
|
||||
expect(directoryError).toBeInstanceOf(WrongKind)
|
||||
expect((directoryError as WrongKind).actual).toBe("directory")
|
||||
expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
|
||||
check("moves files and removes trees idempotently", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const source = `${harness.root}/source/file`
|
||||
const destination = `${harness.root}/destination`
|
||||
yield* harness.files.write(source, bytes("moved"))
|
||||
yield* harness.files.move(source, destination)
|
||||
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||
expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
yield* harness.files.remove(`${harness.root}/source`)
|
||||
expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -512,7 +512,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
|
||||
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
||||
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
|
||||
const tabsVertical = () => (config.data.tabs?.vertical ?? false) && sessionTabsFitVertically(dimensions().width)
|
||||
const tabsVertical = () => config.data.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
||||
const tabsVisible = () =>
|
||||
sessionTabs.enabled() && (sessionTabs.tabs().length > 0 || sessionTabs.newTab()) && route.data.type !== "plugin"
|
||||
|
||||
|
||||
@@ -101,12 +101,11 @@ export const settings: Setting[] = [
|
||||
labels: ["current directory", "global"],
|
||||
},
|
||||
{
|
||||
title: "Vertical",
|
||||
title: "Layout",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "vertical"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
path: ["tabs", "layout"],
|
||||
default: "horizontal",
|
||||
values: ["horizontal", "vertical"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -8,17 +8,21 @@ import * as fuzzysort from "fuzzysort"
|
||||
import { useConnected } from "./use-connected"
|
||||
import { useData } from "../context/data"
|
||||
import { modelPreferenceKey } from "../model-preference"
|
||||
import { useLocation } from "../context/location"
|
||||
|
||||
export function DialogModel(props: { providerID?: string }) {
|
||||
const local = useLocal()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const location = useLocation()
|
||||
const [query, setQuery] = createSignal("")
|
||||
const favoritePriority = new Set(local.model.favorite().map(modelPreferenceKey))
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
|
||||
const models = createMemo(() => data.location.model.list() ?? [])
|
||||
const providers = createMemo(
|
||||
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
|
||||
)
|
||||
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
|
||||
|
||||
const showExtra = createMemo(() => connected() && !props.providerID)
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { useClient } from "../context/client"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -21,22 +20,6 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const togglePassword = () => {
|
||||
setShowPassword((current) => !current)
|
||||
}
|
||||
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "modal",
|
||||
commands: [
|
||||
{
|
||||
id: "dialog.pair.toggle_password",
|
||||
title: showPassword() ? "Hide pairing password" : "Show pairing password",
|
||||
group: "Dialog",
|
||||
run: togglePassword,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
dialog.setSize("large")
|
||||
dialog.setCentered(true)
|
||||
@@ -78,14 +61,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
onMouseUp={togglePassword}
|
||||
onMouseUp={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword() ? value.password : "************"}
|
||||
</text>
|
||||
<text fg={theme.text.default} onMouseUp={togglePassword}>
|
||||
{shortcuts.get("dialog.pair.toggle_password")} {" "}
|
||||
<span style={{ fg: theme.text.subdued }}>{showPassword() ? "hide password" : "show password"}</span>
|
||||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<text fg={theme.text.subdued} wrapMode="word">
|
||||
|
||||
@@ -327,10 +327,6 @@ export function Prompt(props: PromptProps) {
|
||||
if (!session) return
|
||||
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
|
||||
if (agent && !args.agent) local.agent.set(agent.id)
|
||||
if (session.model) {
|
||||
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
|
||||
local.model.variant.set(session.model.variant)
|
||||
}
|
||||
syncedSessionID = sessionID
|
||||
})
|
||||
|
||||
@@ -943,15 +939,43 @@ export function Prompt(props: PromptProps) {
|
||||
await slash.command.run(slash.input)
|
||||
return true
|
||||
}
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
const slashHead = parseSlashHead(inputText, /\s/)
|
||||
const isSkill =
|
||||
slashHead !== undefined &&
|
||||
(data.location.skill.list(currentLocation.ref) ?? []).some(
|
||||
(skill) => skill.slash === true && skill.id === slashHead.name,
|
||||
)
|
||||
const isCommand =
|
||||
slashHead !== undefined &&
|
||||
(data.location.command.list(currentLocation.ref) ?? []).some((command) => command.name === slashHead.name)
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
const selection = local.model.selection()
|
||||
if (!selection) {
|
||||
void promptModelWarning()
|
||||
return false
|
||||
}
|
||||
const usesModel = !props.sessionID || (store.mode !== "shell" && !isSkill)
|
||||
if (usesModel && !local.model.available(selection)) {
|
||||
toast.show({
|
||||
title: "Model unavailable",
|
||||
message: `${selection.providerID}/${selection.modelID} is not available in this session's location`,
|
||||
variant: "warning",
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
const variant = local.model.variant.current()
|
||||
const variant = selection.variant
|
||||
let sessionID = props.sessionID
|
||||
let session = sessionID ? data.session.get(sessionID) : undefined
|
||||
let finishMoveProgress = false
|
||||
@@ -969,8 +993,8 @@ export function Prompt(props: PromptProps) {
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
providerID: selectedModel.providerID,
|
||||
id: selectedModel.modelID,
|
||||
providerID: selection.providerID,
|
||||
id: selection.modelID,
|
||||
variant,
|
||||
},
|
||||
})
|
||||
@@ -990,17 +1014,6 @@ export function Prompt(props: PromptProps) {
|
||||
session = created
|
||||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
store.prompt.text,
|
||||
input.extmarks.getAllForTypeId(promptPartTypeId).flatMap((extmark) => {
|
||||
const ref = store.extmarkToPart.get(extmark.id)
|
||||
if (ref?.type !== "pasted") return []
|
||||
const part = store.prompt.pasted[ref.index]
|
||||
if (!part) return []
|
||||
return [{ start: extmark.start, end: extmark.end, text: part.text }]
|
||||
}),
|
||||
)
|
||||
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const editorSelection = editorContext()
|
||||
@@ -1013,43 +1026,30 @@ export function Prompt(props: PromptProps) {
|
||||
command: inputText,
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.command.list(currentLocation.current) ?? []).some(
|
||||
(command) => command.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
} else if (slashHead && isCommand) {
|
||||
move.startSubmit()
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
const firstLineEnd = inputText.indexOf("\n")
|
||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
|
||||
void client.api.session
|
||||
.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
arguments: args,
|
||||
command: slashHead.name,
|
||||
arguments: slashHead.arguments,
|
||||
agent: agent.id,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
model,
|
||||
files: store.prompt.files,
|
||||
agents: store.prompt.agents,
|
||||
})
|
||||
.catch((error) => {
|
||||
cancelCommit()
|
||||
toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" })
|
||||
})
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.skill.list(currentLocation.current) ?? []).some(
|
||||
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
} else if (isSkill) {
|
||||
move.startSubmit()
|
||||
void client.api.session.skill({
|
||||
sessionID,
|
||||
skill: inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
skill: slashHead!.name,
|
||||
})
|
||||
} else {
|
||||
move.startSubmit()
|
||||
@@ -1061,13 +1061,15 @@ export function Prompt(props: PromptProps) {
|
||||
await client.api.session.switchAgent({ sessionID, agent: agent.id })
|
||||
}
|
||||
if (
|
||||
session?.model?.providerID !== selectedModel.providerID ||
|
||||
session.model.id !== selectedModel.modelID ||
|
||||
session?.model?.providerID !== selection.providerID ||
|
||||
session.model.id !== selection.modelID ||
|
||||
(session.model.variant ?? "default") !== (variant ?? "default")
|
||||
) {
|
||||
await client.api.session.switchModel({
|
||||
sessionID,
|
||||
model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant },
|
||||
const model = { providerID: selection.providerID, id: selection.modelID, variant }
|
||||
const cancelCommit = local.model.trackSessionCommit(sessionID, model)
|
||||
await client.api.session.switchModel({ sessionID, model }).catch((error) => {
|
||||
cancelCommit()
|
||||
throw error
|
||||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
@@ -1320,10 +1322,7 @@ export function Prompt(props: PromptProps) {
|
||||
return `Ask anything... "${list()[store.placeholder % list().length]}"`
|
||||
})()
|
||||
if (!value) return undefined
|
||||
const width =
|
||||
dimensions().width < 44
|
||||
? dimensions().width - 5
|
||||
: Math.min(75, dimensions().width - 4) - 5
|
||||
const width = dimensions().width < 44 ? dimensions().width - 5 : Math.min(75, dimensions().width - 4) - 5
|
||||
return Locale.takeWidth(value, Math.max(1, width)).trimEnd()
|
||||
})
|
||||
const locationLabel = createMemo(() => {
|
||||
|
||||
@@ -132,8 +132,8 @@ export const Info = Schema.Struct({
|
||||
scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({
|
||||
description: "Share tabs globally or keep a separate set for each working directory",
|
||||
}),
|
||||
vertical: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show tabs in a left sidebar instead of a horizontal strip",
|
||||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
@@ -194,7 +194,7 @@ export type Resolved = Omit<Info, "attention" | "keybinds" | "leader" | "mouse"
|
||||
tabs: {
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
vertical?: boolean
|
||||
layout: "horizontal" | "vertical"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
...input.tabs,
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
scope: input.tabs?.scope ?? "cwd",
|
||||
layout: input.tabs?.layout ?? "horizontal",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,6 @@ export const Definitions = {
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
|
||||
"dialog.move_session.refresh": keybind("ctrl+r", "Refresh project copies"),
|
||||
"dialog.pair.toggle_password": keybind("space", "Show or hide pairing password"),
|
||||
"prompt.autocomplete.prev": keybind("up,ctrl+p", "Move to previous autocomplete item"),
|
||||
"prompt.autocomplete.next": keybind("down,ctrl+n", "Move to next autocomplete item"),
|
||||
"prompt.autocomplete.hide": keybind("escape", "Hide autocomplete"),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { dedupeWith } from "effect/Array"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { batch, createMemo } from "solid-js"
|
||||
import { batch, createMemo, onCleanup } from "solid-js"
|
||||
import { useEvent } from "./event"
|
||||
import path from "path"
|
||||
import { useTuiPaths } from "./runtime"
|
||||
@@ -22,6 +22,7 @@ import { useToast } from "../ui/toast"
|
||||
import { useRoute } from "./route"
|
||||
import { useData } from "./data"
|
||||
import { usePermission } from "./permission"
|
||||
import { useLocation } from "./location"
|
||||
|
||||
export function parseModel(model: string) {
|
||||
const [providerID, ...rest] = model.split("/")
|
||||
@@ -57,26 +58,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const args = useArgs()
|
||||
const event = useEvent()
|
||||
const permission = usePermission()
|
||||
const location = useLocation()
|
||||
|
||||
const models = () => data.location.model.list(location.ref)
|
||||
const providers = () => data.location.provider.list(location.ref)
|
||||
|
||||
function isModelValid(model: ModelPreferenceModel) {
|
||||
return !!data.location.model
|
||||
.list()
|
||||
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
}
|
||||
|
||||
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
if (model && isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
function createAgent() {
|
||||
const agents = createMemo(() =>
|
||||
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
|
||||
)
|
||||
const visibleAgents = createMemo(() =>
|
||||
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
|
||||
)
|
||||
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
|
||||
const [agentStore, setAgentStore] = createStore({
|
||||
current: undefined as string | undefined,
|
||||
})
|
||||
@@ -128,35 +132,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const agent = createAgent()
|
||||
|
||||
function createModel() {
|
||||
const [modelStore, setModelStore] = createStore<
|
||||
ModelPreference & {
|
||||
ready: boolean
|
||||
model: Record<string, ModelPreferenceModel>
|
||||
}
|
||||
>({
|
||||
type ModelSelection = ModelPreferenceModel & { variant?: string }
|
||||
const [preferences, setPreferences] = createStore<ModelPreference & { ready: boolean }>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
favorite: [],
|
||||
variant: {},
|
||||
})
|
||||
const [selectionState, setSelectionState] = createStore<{
|
||||
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
|
||||
draftBySession: Record<string, ModelSelection | undefined>
|
||||
}>({
|
||||
newSessionModelByLocationAgent: {},
|
||||
draftBySession: {},
|
||||
})
|
||||
|
||||
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
|
||||
const state = {
|
||||
const pendingSelectionCommits = new Map<string, string>()
|
||||
const selectionKey = (value: ModelSelection) =>
|
||||
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
|
||||
const saveState = {
|
||||
pending: false,
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!modelStore.ready) {
|
||||
state.pending = true
|
||||
function savePreferences() {
|
||||
if (!preferences.ready) {
|
||||
saveState.pending = true
|
||||
return
|
||||
}
|
||||
state.pending = false
|
||||
saveState.pending = false
|
||||
void repository
|
||||
.patch({
|
||||
recent: modelStore.recent,
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
recent: preferences.recent,
|
||||
favorite: preferences.favorite,
|
||||
variant: preferences.variant,
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
@@ -164,14 +173,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
repository
|
||||
.load()
|
||||
.then((value) => {
|
||||
setModelStore("recent", value.recent)
|
||||
setModelStore("favorite", value.favorite)
|
||||
setModelStore("variant", value.variant)
|
||||
setPreferences("recent", value.recent)
|
||||
setPreferences("favorite", value.favorite)
|
||||
setPreferences("variant", value.variant)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setModelStore("ready", true)
|
||||
if (state.pending) save()
|
||||
setPreferences("ready", true)
|
||||
if (saveState.pending) savePreferences()
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
@@ -185,13 +194,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modelStore.recent) {
|
||||
for (const item of preferences.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
const model = data.location.model.list()?.[0]
|
||||
const model = models()?.[0]
|
||||
if (!model) return undefined
|
||||
return {
|
||||
providerID: model.providerID,
|
||||
@@ -199,30 +208,134 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const newSessionModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return (
|
||||
getFirstValidModel(
|
||||
() => a && modelStore.model[a.id],
|
||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||
fallbackModel,
|
||||
) ?? undefined
|
||||
return getFirstValidModel(
|
||||
() => a && selectionState.newSessionModelByLocationAgent[locationAgentKey(a.id)],
|
||||
() => a?.model && { providerID: a.model.providerID, modelID: a.model.id },
|
||||
fallbackModel,
|
||||
)
|
||||
})
|
||||
|
||||
const currentSelection = createMemo<ModelSelection | undefined>(() => {
|
||||
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
|
||||
const model = newSessionModel()
|
||||
if (!model) return
|
||||
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const selection = currentSelection()
|
||||
if (!selection) return
|
||||
return { providerID: selection.providerID, modelID: selection.modelID }
|
||||
})
|
||||
|
||||
function locationAgentKey(agentID: string) {
|
||||
const ref = location.ref ?? data.location.default()
|
||||
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
|
||||
}
|
||||
|
||||
function durableSelection(sessionID: string): ModelSelection | undefined {
|
||||
const model = data.session.get(sessionID)?.model
|
||||
if (!model) return
|
||||
return {
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: normalizeModelVariant(model.variant),
|
||||
}
|
||||
}
|
||||
|
||||
function sessionSelection(sessionID: string) {
|
||||
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
|
||||
}
|
||||
|
||||
function setSessionDraft(sessionID: string, selection: ModelSelection) {
|
||||
const durable = durableSelection(sessionID)
|
||||
setSelectionState(
|
||||
"draftBySession",
|
||||
sessionID,
|
||||
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
|
||||
)
|
||||
}
|
||||
|
||||
function selectModel(model: ModelPreferenceModel) {
|
||||
if (route.data.type === "session") {
|
||||
const sessionID = route.data.sessionID
|
||||
const current = sessionSelection(sessionID)
|
||||
const preferred = normalizeModelVariant(
|
||||
current?.providerID === model.providerID && current.modelID === model.modelID
|
||||
? current.variant
|
||||
: preferences.variant[modelPreferenceKey(model)],
|
||||
)
|
||||
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
|
||||
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
|
||||
setSessionDraft(sessionID, { ...model, variant })
|
||||
return true
|
||||
}
|
||||
const current = agent.current()
|
||||
if (!current) return false
|
||||
setSelectionState("newSessionModelByLocationAgent", locationAgentKey(current.id), model)
|
||||
return true
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
event.on("session.model.selected", (evt) => {
|
||||
const expected = pendingSelectionCommits.get(evt.data.sessionID)
|
||||
if (!expected) return
|
||||
const committed = selectionKey({
|
||||
providerID: evt.data.model.providerID,
|
||||
modelID: evt.data.model.id,
|
||||
variant: evt.data.model.variant,
|
||||
})
|
||||
if (committed !== expected) return
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
const draft = selectionState.draftBySession[evt.data.sessionID]
|
||||
if (draft && selectionKey(draft) === committed)
|
||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
event.on("session.deleted", (evt) => {
|
||||
pendingSelectionCommits.delete(evt.data.sessionID)
|
||||
setSelectionState("draftBySession", evt.data.sessionID, undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
selection: currentSelection,
|
||||
available(model = currentModel()) {
|
||||
return model ? isModelValid(model) : false
|
||||
},
|
||||
trackSessionCommit(
|
||||
sessionID: string,
|
||||
value: {
|
||||
providerID: string
|
||||
id: string
|
||||
variant?: string
|
||||
},
|
||||
) {
|
||||
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
|
||||
pendingSelectionCommits.set(sessionID, committed)
|
||||
return () => {
|
||||
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
|
||||
}
|
||||
},
|
||||
get ready() {
|
||||
return modelStore.ready
|
||||
return preferences.ready
|
||||
},
|
||||
get catalogReady() {
|
||||
return models() !== undefined
|
||||
},
|
||||
recent() {
|
||||
return modelStore.recent
|
||||
return preferences.recent
|
||||
},
|
||||
favorite() {
|
||||
return modelStore.favorite
|
||||
return preferences.favorite
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = currentModel()
|
||||
const value = currentSelection()
|
||||
if (!value) {
|
||||
return {
|
||||
provider: "Connect a provider",
|
||||
@@ -230,33 +343,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
reasoning: false,
|
||||
}
|
||||
}
|
||||
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
|
||||
const info = data.location.model
|
||||
.list()
|
||||
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||
const provider = providers()?.find((item) => item.id === value.providerID)
|
||||
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
|
||||
return {
|
||||
provider: provider?.name ?? value.providerID,
|
||||
model: info?.name ?? value.modelID,
|
||||
model: info?.name ?? `${value.modelID} (unavailable)`,
|
||||
reasoning: (info?.variants?.length ?? 0) !== 0,
|
||||
}
|
||||
}),
|
||||
cycle(direction: 1 | -1) {
|
||||
const current = currentModel()
|
||||
const current = currentSelection()
|
||||
if (!current) return
|
||||
const recent = modelStore.recent
|
||||
const recent = recentModels(current, preferences.recent).filter(isModelValid)
|
||||
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||
if (index === -1) return
|
||||
let next = index + direction
|
||||
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
|
||||
if (next < 0) next = recent.length - 1
|
||||
if (next >= recent.length) next = 0
|
||||
const val = recent[next]
|
||||
if (!val) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.id, { ...val })
|
||||
selectModel({ ...val })
|
||||
},
|
||||
cycleFavorite(direction: 1 | -1) {
|
||||
const favorites = modelStore.favorite.filter((item) => isModelValid(item))
|
||||
const favorites = preferences.favorite.filter((item) => isModelValid(item))
|
||||
if (!favorites.length) {
|
||||
toast.show({
|
||||
variant: "info",
|
||||
@@ -265,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
return
|
||||
}
|
||||
const current = currentModel()
|
||||
const current = currentSelection()
|
||||
let index = -1
|
||||
if (current) {
|
||||
index = favorites.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
|
||||
@@ -279,45 +387,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
const next = favorites[index]
|
||||
if (!next) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.id, { ...next })
|
||||
setModelStore("recent", recentModels(next, modelStore.recent))
|
||||
save()
|
||||
if (!selectModel({ ...next })) return
|
||||
setPreferences("recent", recentModels(next, preferences.recent))
|
||||
savePreferences()
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) return
|
||||
const a = agent.current()
|
||||
if (!a) return
|
||||
setModelStore("model", a.id, model)
|
||||
if (!selectModel(model)) return
|
||||
if (options?.recent) {
|
||||
setModelStore("recent", recentModels(model, modelStore.recent))
|
||||
save()
|
||||
setPreferences("recent", recentModels(model, preferences.recent))
|
||||
savePreferences()
|
||||
}
|
||||
})
|
||||
},
|
||||
toggleFavorite(model: { providerID: string; modelID: string }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) return
|
||||
const exists = modelStore.favorite.some(
|
||||
const exists = preferences.favorite.some(
|
||||
(x) => x.providerID === model.providerID && x.modelID === model.modelID,
|
||||
)
|
||||
const next = exists
|
||||
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||
: [model, ...modelStore.favorite]
|
||||
setModelStore(
|
||||
? preferences.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
|
||||
: [model, ...preferences.favorite]
|
||||
setPreferences(
|
||||
"favorite",
|
||||
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
|
||||
)
|
||||
save()
|
||||
savePreferences()
|
||||
})
|
||||
},
|
||||
variant: {
|
||||
selected() {
|
||||
const m = currentModel()
|
||||
if (!m) return undefined
|
||||
return normalizeModelVariant(modelStore.variant[modelPreferenceKey(m)])
|
||||
return currentSelection()?.variant
|
||||
},
|
||||
current() {
|
||||
const v = this.selected()
|
||||
@@ -325,18 +427,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return undefined
|
||||
},
|
||||
list() {
|
||||
const m = currentModel()
|
||||
const m = currentSelection()
|
||||
if (!m) return []
|
||||
const info = data.location.model
|
||||
.list()
|
||||
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
|
||||
return info?.variants?.map((variant) => variant.id) ?? []
|
||||
},
|
||||
set(value: string | undefined) {
|
||||
const m = currentModel()
|
||||
const m = currentSelection()
|
||||
if (!m) return
|
||||
setModelStore("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
save()
|
||||
if (route.data.type === "session") {
|
||||
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
|
||||
return
|
||||
}
|
||||
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
|
||||
savePreferences()
|
||||
},
|
||||
cycle() {
|
||||
const variants = this.list()
|
||||
|
||||
@@ -204,7 +204,7 @@ export function Session() {
|
||||
const availableWidth = createMemo(
|
||||
() =>
|
||||
dimensions().width -
|
||||
(config.tabs?.enabled && config.tabs.vertical && sessionTabsFitVertically(dimensions().width)
|
||||
(config.tabs?.enabled && config.tabs.layout === "vertical" && sessionTabsFitVertically(dimensions().width)
|
||||
? SESSION_SIDEBAR_WIDTH
|
||||
: 0),
|
||||
)
|
||||
@@ -361,7 +361,7 @@ export function Session() {
|
||||
|
||||
createEffect(() => {
|
||||
const current = prompt()
|
||||
if (sent || !current || !synced() || !local.model.ready) return
|
||||
if (sent || !current || !synced() || !local.model.ready || !local.model.catalogReady) return
|
||||
if (!local.agent.current() || !local.model.current()) return
|
||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||
sent = true
|
||||
|
||||
@@ -18,7 +18,10 @@ test("validates mini replay settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
})
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
})
|
||||
|
||||
@@ -39,12 +42,13 @@ test("resolves nested config and keybind defaults", () => {
|
||||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd" })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
})
|
||||
|
||||
test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
})
|
||||
|
||||
test("provides config and its host interface", async () => {
|
||||
|
||||
@@ -5,7 +5,3 @@ test("binds agent cycling only to shift+tab by default", () => {
|
||||
expect(TuiKeybind.Definitions.agent_cycle.default).toBe("shift+tab")
|
||||
expect(TuiKeybind.Definitions.agent_cycle_reverse.default).toBe("none")
|
||||
})
|
||||
|
||||
test("binds pairing password visibility to space by default", () => {
|
||||
expect(TuiKeybind.Definitions["dialog.pair.toggle_password"].default).toBe("space")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user