mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into mark/inline-tool-registry-overrides
This commit is contained in:
@@ -1,5 +1,25 @@
|
||||
# @kilocode/cli
|
||||
|
||||
## 7.2.42
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#9909](https://github.com/Kilo-Org/kilocode/pull/9909) [`9ffd047`](https://github.com/Kilo-Org/kilocode/commit/9ffd047962039d6b73d301d5d4e67560cd501c4f) - Detect and preserve UTF-32 (LE and BE) with BOM when reading and editing files. UTF-16 and UTF-32 without a BOM remain unsupported.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#9887](https://github.com/Kilo-Org/kilocode/pull/9887) [`d9453f0`](https://github.com/Kilo-Org/kilocode/commit/d9453f0da2b063041f6f98235220cde9129e162d) - Fix queued-turn auto-compaction so overflow recovery runs instead of exhausting compaction attempts.
|
||||
|
||||
- [#9855](https://github.com/Kilo-Org/kilocode/pull/9855) [`59e8eff`](https://github.com/Kilo-Org/kilocode/commit/59e8effc3df8a03146f5ceddf95f79989b813417) - Respect project-specific semantic indexing decisions instead of enabling indexing globally across workspaces.
|
||||
|
||||
- [#9928](https://github.com/Kilo-Org/kilocode/pull/9928) [`520922f`](https://github.com/Kilo-Org/kilocode/commit/520922ff39354c2df72317dee0f70035c52c24c5) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Prevent VS Code empty windows from starting codebase indexing against the home directory.
|
||||
|
||||
- [#9843](https://github.com/Kilo-Org/kilocode/pull/9843) [`27d14d4`](https://github.com/Kilo-Org/kilocode/commit/27d14d432c33051e4bdd5863ea14b207758e9234) - Prompt before reading `.env` files even after broad read permissions were previously approved.
|
||||
|
||||
- [#9924](https://github.com/Kilo-Org/kilocode/pull/9924) [`914bbdf`](https://github.com/Kilo-Org/kilocode/commit/914bbdfd0575e40554c39c6691e4264a63109953) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Restore Skill tool access for Plan, Ask, Explore, and other non-system agents so skill workflows are available by default.
|
||||
|
||||
- [#9907](https://github.com/Kilo-Org/kilocode/pull/9907) [`d9d4dcd`](https://github.com/Kilo-Org/kilocode/commit/d9d4dcd37c6719652252da66b6a1ce27049beb47) - Recover sessions left unable to continue after an assistant turn was created but never started.
|
||||
|
||||
## 7.2.39
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "7.2.40",
|
||||
"version": "7.2.42",
|
||||
"name": "@kilocode/cli",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"prepare": "effect-language-service patch || true",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun run script/test-runner.ts",
|
||||
"test:ci": "bun run script/test-runner.ts --ci",
|
||||
@@ -142,6 +141,7 @@
|
||||
"ai-gateway-provider": "3.1.2",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chardet": "2.1.1",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-sound": "1.1.3",
|
||||
"clipboardy": "4.0.0",
|
||||
@@ -160,7 +160,6 @@
|
||||
"iconv-lite": "0.7.2",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jschardet": "3.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
@@ -176,7 +175,6 @@
|
||||
"semver": "^7.6.3",
|
||||
"simple-git": "3.35.2",
|
||||
"solid-js": "catalog:",
|
||||
"stream-chat": "9.38.0",
|
||||
"strip-ansi": "7.1.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -53,6 +53,7 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const sourcemapsFlag = process.argv.includes("--sourcemaps")
|
||||
const plugin = createSolidTransformPlugin()
|
||||
// kilocode_change - packages/app was removed; the web UI embed step is no longer applicable
|
||||
|
||||
|
||||
@@ -25,9 +25,10 @@ if (argv.includes("--help") || argv.includes("-h")) {
|
||||
"",
|
||||
"Options:",
|
||||
" --ci Enable JUnit XML output to .artifacts/unit/junit.xml",
|
||||
" --concurrency <N> Max parallel processes (default: CPU count)",
|
||||
" --timeout <ms> Per-test timeout passed to bun test (default: 30000)",
|
||||
" --concurrency <N> Max parallel processes (default: min(4, CPU count))",
|
||||
" --timeout <ms> Per-test timeout passed to bun test (default: 60000)",
|
||||
" --file-timeout <ms> Per-file process timeout (default: 300000)",
|
||||
" --retries <N> Extra attempts for failing files (default: 1)",
|
||||
" --bail Stop on first failure",
|
||||
" --verbose Show full output for every file",
|
||||
" -h, --help Show this help",
|
||||
@@ -52,11 +53,15 @@ function opt(name: string, fallback: number) {
|
||||
const ci = argv.includes("--ci")
|
||||
const bail = argv.includes("--bail")
|
||||
const verbose = argv.includes("--verbose")
|
||||
const concurrency = opt("concurrency", os.cpus().length)
|
||||
const timeout = opt("timeout", 30000)
|
||||
// Cap concurrency at 4 even on bigger runners: the bottleneck is shared
|
||||
// resources (ports, global filesystem like ~/.local/share/kilo), not CPU.
|
||||
// Eight parallel processes was triggering port/FS races, not going faster.
|
||||
const concurrency = opt("concurrency", Math.min(4, os.cpus().length))
|
||||
const timeout = opt("timeout", 60000)
|
||||
const deadline = opt("file-timeout", 300000)
|
||||
const retries = opt("retries", 1)
|
||||
|
||||
const valued = new Set(["--concurrency", "--timeout", "--file-timeout"])
|
||||
const valued = new Set(["--concurrency", "--timeout", "--file-timeout", "--retries"])
|
||||
const patterns = argv.filter((arg, i) => {
|
||||
if (arg.startsWith("-")) return false
|
||||
if (i > 0 && valued.has(argv[i - 1])) return false
|
||||
@@ -70,6 +75,7 @@ const patterns = argv.filter((arg, i) => {
|
||||
const tty = !!process.stdout.isTTY
|
||||
const green = (s: string) => (tty ? `\x1b[32m${s}\x1b[0m` : s)
|
||||
const red = (s: string) => (tty ? `\x1b[31m${s}\x1b[0m` : s)
|
||||
const yellow = (s: string) => (tty ? `\x1b[33m${s}\x1b[0m` : s)
|
||||
const dim = (s: string) => (tty ? `\x1b[2m${s}\x1b[0m` : s)
|
||||
const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s)
|
||||
|
||||
@@ -80,8 +86,15 @@ const bold = (s: string) => (tty ? `\x1b[1m${s}\x1b[0m` : s)
|
||||
const glob = new Bun.Glob("**/*.test.{ts,tsx}")
|
||||
const all = (await Array.fromAsync(glob.scan({ cwd: path.join(root, "test") }))).sort()
|
||||
|
||||
const files =
|
||||
export const skipped = new Set([
|
||||
// Upstream browser OAuth integration tests bind the fixed callback port and
|
||||
// race with other parallel OAuth tests in CI.
|
||||
"mcp/oauth-browser.test.ts",
|
||||
])
|
||||
|
||||
const matched =
|
||||
patterns.length > 0 ? all.filter((f) => patterns.some((p) => f.includes(p) || path.join("test", f).includes(p))) : all
|
||||
const files = patterns.length > 0 ? matched : matched.filter((f) => !skipped.has(f)) // kilocode_change
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log("No test files found")
|
||||
@@ -100,6 +113,7 @@ type Result = {
|
||||
stderr: string
|
||||
duration: number
|
||||
timedout: boolean
|
||||
attempts: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -155,6 +169,7 @@ async function run(file: string): Promise<Result> {
|
||||
stderr,
|
||||
duration: performance.now() - start,
|
||||
timedout: killed.value,
|
||||
attempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,21 +181,28 @@ function report(result: Result) {
|
||||
counter.done++
|
||||
const idx = String(counter.done).padStart(pad)
|
||||
const secs = (result.duration / 1000).toFixed(1)
|
||||
const tries = result.attempts > 1 ? dim(` [attempt ${result.attempts}/${retries + 1}]`) : ""
|
||||
|
||||
if (result.timedout) {
|
||||
console.log(
|
||||
`[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}`,
|
||||
`[${idx}/${files.length}] ${red("TIME")} ${result.file} ${dim(`(${secs}s - exceeded ${deadline / 1000}s)`)}${tries}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.passed) {
|
||||
console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}`)
|
||||
console.log(`[${idx}/${files.length}] ${red("FAIL")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
|
||||
if (verbose && result.stderr.trim()) console.log(result.stderr)
|
||||
if (verbose && result.stdout.trim()) console.log(result.stdout)
|
||||
return
|
||||
}
|
||||
|
||||
if (result.attempts > 1) {
|
||||
console.log(`[${idx}/${files.length}] ${yellow("FLAKY")} ${result.file} ${dim(`(${secs}s)`)}${tries}`)
|
||||
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[${idx}/${files.length}] ${green("PASS")} ${result.file} ${dim(`(${secs}s)`)}`)
|
||||
if (verbose && result.stdout.trim()) console.log(dim(result.stdout))
|
||||
}
|
||||
@@ -199,7 +221,16 @@ const stopped = { value: false }
|
||||
const workers = Array.from({ length: Math.min(concurrency, files.length) }, async () => {
|
||||
while (queue.length > 0 && !stopped.value) {
|
||||
const file = queue.shift()!
|
||||
const result = await run(file)
|
||||
let result = await run(file)
|
||||
// Retry failing files up to `retries` extra times. Bugs still fail on every
|
||||
// attempt; contention-based flakes (port races, slow FS, slow spawn) recover.
|
||||
// Preserve the last attempt's stdout/stderr/duration so a truly broken file
|
||||
// still shows a useful diagnostic.
|
||||
while (!result.passed && result.attempts <= retries && !stopped.value) {
|
||||
const retry = await run(file)
|
||||
retry.attempts = result.attempts + 1
|
||||
result = retry
|
||||
}
|
||||
results.push(result)
|
||||
report(result)
|
||||
if (bail && !result.passed) stopped.value = true
|
||||
@@ -238,14 +269,51 @@ if (failures.length > 0 && !verbose) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const passed = results.filter((r) => r.passed).length
|
||||
const flaky = results.filter((r) => r.passed && r.attempts > 1)
|
||||
|
||||
console.log(
|
||||
`\n${bold(String(results.length))} files | ` +
|
||||
`${green(passed + " passed")} | ` +
|
||||
`${failures.length > 0 ? red(failures.length + " failed") : failures.length + " failed"} | ` +
|
||||
`${flaky.length > 0 ? yellow(flaky.length + " flaky") : flaky.length + " flaky"} | ` +
|
||||
`${elapsed.toFixed(1)}s\n`,
|
||||
)
|
||||
|
||||
if (flaky.length > 0) {
|
||||
const sorted = flaky.slice().sort((a, b) => a.file.localeCompare(b.file))
|
||||
|
||||
console.log(`${bold(yellow("--- FLAKY (passed on retry) ---"))}\n`)
|
||||
for (const r of sorted) {
|
||||
console.log(` ${yellow(r.file)} ${dim(`(passed on attempt ${r.attempts}/${retries + 1})`)}`)
|
||||
}
|
||||
console.log()
|
||||
|
||||
// Surface flakies to the GitHub Actions UI so reviewers don't have to scan
|
||||
// the raw log. Annotations show up on the PR; the step summary is visible at
|
||||
// the bottom of the job page and in the workflow summary email.
|
||||
if (process.env.GITHUB_ACTIONS === "true") {
|
||||
for (const r of sorted) {
|
||||
const repo = `packages/opencode/test/${r.file}`
|
||||
console.log(`::warning file=${repo},title=Flaky test file::passed on attempt ${r.attempts} of ${retries + 1}`)
|
||||
}
|
||||
|
||||
const summary = process.env.GITHUB_STEP_SUMMARY
|
||||
if (summary) {
|
||||
const md = [
|
||||
"### ⚠️ Flaky test files (passed on retry)",
|
||||
"",
|
||||
`${sorted.length} file${sorted.length === 1 ? "" : "s"} needed more than one attempt to pass.`,
|
||||
"",
|
||||
"| File | Attempts |",
|
||||
"|---|---|",
|
||||
...sorted.map((r) => `| \`${r.file}\` | ${r.attempts}/${retries + 1} |`),
|
||||
"",
|
||||
].join("\n")
|
||||
await fs.appendFile(summary, md + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JUnit XML merge (CI mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -280,11 +348,15 @@ async function merge() {
|
||||
const extracted = extract(content)
|
||||
if (extracted) {
|
||||
suites.push(extracted)
|
||||
// kilocode_change start - count nested suites without matching the root testsuites tag.
|
||||
counts.tests += sum(extracted, "tests")
|
||||
counts.failures += sum(extracted, "failures")
|
||||
counts.errors += sum(extracted, "errors")
|
||||
// kilocode_change end
|
||||
// Counts come from the outer <testsuites ...> root attributes, not from
|
||||
// regex-scanning the inner content, so nested <testsuite> blocks (bun
|
||||
// emits one per `describe`) don't get double-counted.
|
||||
const root = content.match(/<testsuites\b([^>]*)>/)
|
||||
if (root) {
|
||||
counts.tests += attr(root[1], "tests")
|
||||
counts.failures += attr(root[1], "failures")
|
||||
counts.errors += attr(root[1], "errors")
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -321,32 +393,24 @@ async function merge() {
|
||||
await Bun.write(path.join(dir, "junit.xml"), body)
|
||||
}
|
||||
|
||||
function extract(content: string, from = 0): string {
|
||||
const close = "</testsuite>"
|
||||
const s = open(content, from) // kilocode_change
|
||||
if (s === -1) return ""
|
||||
const e = content.indexOf(close, s)
|
||||
if (e === -1) return ""
|
||||
const suite = content.slice(s, e + close.length)
|
||||
const rest = extract(content, e + close.length)
|
||||
return rest ? suite + "\n" + rest : suite
|
||||
// Grab everything between the outer <testsuites ...> and </testsuites> of a
|
||||
// per-file JUnit XML. Preserves nested <testsuite> blocks verbatim — the
|
||||
// previous hand-rolled walker matched the first </testsuite> it found, which
|
||||
// closed an inner suite and left the outer one dangling in the merged output.
|
||||
function extract(content: string): string {
|
||||
const open = content.match(/<testsuites\b[^>]*>/)
|
||||
if (!open) return ""
|
||||
const start = open.index! + open[0].length
|
||||
const end = content.lastIndexOf("</testsuites>")
|
||||
if (end === -1 || end <= start) return ""
|
||||
return content.slice(start, end).trim()
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
function open(content: string, from: number): number {
|
||||
const tag = "<testsuite"
|
||||
const s = content.indexOf(tag, from)
|
||||
if (s === -1) return -1
|
||||
const ch = content[s + tag.length]
|
||||
if (ch === ">" || /\s/.test(ch)) return s
|
||||
return open(content, s + 1)
|
||||
function attr(attrs: string, name: string): number {
|
||||
const m = attrs.match(new RegExp(`\\b${name}="(\\d+)"`))
|
||||
return m ? Number(m[1]) : 0
|
||||
}
|
||||
|
||||
function sum(content: string, name: string): number {
|
||||
return Array.from(content.matchAll(new RegExp(`${name}="(\\d+)"`, "g"))).reduce((n, m) => n + Number(m[1]), 0)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
# Compare SDK types generated from Hono vs HttpApi specs.
|
||||
# Sorts types alphabetically so only meaningful body differences show.
|
||||
#
|
||||
# Usage: ./scripts/diff-sdk-types.sh # full diff
|
||||
# ./scripts/diff-sdk-types.sh --stat # summary only
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SDK="$(cd "$DIR/../sdk/js" && pwd)"
|
||||
|
||||
normalize() {
|
||||
python3 -c "
|
||||
import re, sys
|
||||
content = open(sys.argv[1]).read()
|
||||
blocks = re.split(r'(?=^export (?:type|function|const) )', content, flags=re.MULTILINE)
|
||||
header, body = blocks[0], blocks[1:]
|
||||
body.sort(key=lambda b: m.group(1) if (m := re.match(r'export \w+ (\w+)', b)) else '')
|
||||
sys.stdout.write(header + ''.join(body))
|
||||
" "$1"
|
||||
}
|
||||
|
||||
echo "Generating Hono SDK..." >&2
|
||||
(cd "$SDK" && bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-hono.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "Generating HttpApi SDK..." >&2
|
||||
(cd "$SDK" && OPENCODE_SDK_OPENAPI=httpapi bun run script/build.ts >/dev/null 2>&1)
|
||||
normalize "$SDK/src/v2/gen/types.gen.ts" > /tmp/sdk-types-httpapi.ts
|
||||
git -C "$SDK" checkout -- src/ 2>/dev/null
|
||||
|
||||
echo "" >&2
|
||||
if [[ "${1:-}" == "--stat" ]]; then
|
||||
diff_output=$(diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true)
|
||||
honly=$(printf "%s\n" "$diff_output" | grep -c '^< export type' || true)
|
||||
aonly=$(printf "%s\n" "$diff_output" | grep -c '^> export type' || true)
|
||||
total=$(printf "%s\n" "$diff_output" | wc -l | tr -d ' ')
|
||||
echo "Hono-only: $honly types HttpApi-only: $aonly types Diff lines: $total"
|
||||
echo ""
|
||||
if [[ $honly -gt 0 ]]; then
|
||||
echo "=== Hono-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^< export type' | sed 's/< export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
echo ""
|
||||
fi
|
||||
if [[ $aonly -gt 0 ]]; then
|
||||
echo "=== HttpApi-only types ==="
|
||||
printf "%s\n" "$diff_output" | grep '^> export type' | sed 's/> export type //' | sed 's/[ =].*//' | sed 's/^/ /'
|
||||
fi
|
||||
else
|
||||
diff /tmp/sdk-types-hono.ts /tmp/sdk-types-httpapi.ts || true
|
||||
fi
|
||||
@@ -12,14 +12,16 @@ Plan for replacing instance Hono route implementations with Effect `HttpApi` whi
|
||||
|
||||
## Current State
|
||||
|
||||
- `KILO_EXPERIMENTAL_HTTPAPI` gates the bridge. Default behavior still uses Hono.
|
||||
- The bridge mounts selected paths in `server/routes/instance/index.ts` before legacy Hono routes.
|
||||
- Legacy Hono routes remain for default behavior and for `hono-openapi` SDK generation.
|
||||
- `HttpApi` auth is independent of Hono auth.
|
||||
- `Authorization` is attached in each route module, not centrally wrapped in `server.ts`.
|
||||
- `KILO_EXPERIMENTAL_HTTPAPI` selects the backend at server startup. Default is still `hono`.
|
||||
- `server/backend.ts` picks one of `effect-httpapi` or `hono`; `server.ts` builds either a pure Effect `HttpApi` web handler or the legacy Hono app accordingly. The earlier in-Hono "bridge" model has been replaced by this fork-at-startup.
|
||||
- Legacy Hono routes remain mounted for the `hono` backend and remain the source for `hono-openapi` SDK generation.
|
||||
- An Effect `HttpApi` OpenAPI surface exists (`OpenApi.fromApi(PublicApi)` in `cli/cmd/generate.ts --httpapi`, `KILO_SDK_OPENAPI=httpapi` in `packages/sdk/js/script/build.ts`) but is opt-in. The default SDK generation is still Hono.
|
||||
- `httpapi/public.ts` carries the Hono-compat normalization for the Effect-generated OpenAPI surface (auth scheme strip, request-body required flag, optional `null` arms, `BadRequestError` / `NotFoundError` remap, `$ref` self-cycle fix, `auth_token` query injection). Today's Effect-generated SDK is not byte-identical to the Hono-generated SDK — see Phase 4.
|
||||
- Auth is centrally configured for the Effect backend via Effect `Config` (`refactor: use Effect config for HttpApi authorization`, `Fix HttpApi raw route authorization`) rather than re-attached in each route module.
|
||||
- Auth supports Basic auth and the legacy `auth_token` query parameter through `HttpApiSecurity.apiKey`.
|
||||
- Instance context is provided by `httpapi/server.ts` using `directory`, `workspace`, and `x-kilo-directory`.
|
||||
- `Observability.layer` is provided in the Effect route layer and deduplicated through the shared `memoMap`.
|
||||
- CORS middleware is wired into both backends (`feat(httpapi): add CORS middleware to instance routes`).
|
||||
|
||||
## Migration Rules
|
||||
|
||||
@@ -122,13 +124,30 @@ Keep large or stateful groups for later:
|
||||
|
||||
Hono routes cannot be deleted while `hono-openapi` is the source of SDK generation.
|
||||
|
||||
Status: the Effect `HttpApi` OpenAPI surface is **implemented and opt-in** (`bun dev generate --httpapi`, `KILO_SDK_OPENAPI=httpapi`). Default SDK generation still uses Hono. `httpapi/public.ts` applies the Hono-compat normalization layer to the Effect output. Diff against the Hono-generated spec still shows real gaps that must be closed before the SDK can flip:
|
||||
|
||||
- Branded-type `pattern` constraints on ID schemas are not propagated to the Effect output (~169 missing).
|
||||
- Per-property `description` annotations are not propagated through `Schema.Struct` to the Effect output (~107 missing).
|
||||
- `Event.*` and `SyncEvent.*` component names use dotted form in Hono and PascalCase in Effect (~50 differences, breaks SDK type names).
|
||||
- Effect's component deduper emits numbered duplicates (`Session9`, `SyncEvent.session.updated.11`) that need a name-collision fix.
|
||||
- Cosmetic-only diffs (`additionalProperties: false`, `const` vs `enum`, MAX_SAFE_INTEGER `maximum`, `propertyNames`) can be normalized in `public.ts` if they would otherwise change SDK output.
|
||||
|
||||
Required before route deletion:
|
||||
|
||||
- Generate the public OpenAPI surface from Effect `HttpApi` for ported routes.
|
||||
- Close the diff above so Effect-generated SDK output matches the Hono-generated SDK output for every retained path.
|
||||
- Keep operation IDs, schemas, status codes, and SDK type names stable unless the change is intentional.
|
||||
- Flip `packages/sdk/js/script/build.ts` default to `httpapi` and regenerate.
|
||||
- Compare generated SDK output against `dev` for every route group deletion.
|
||||
- Remove Hono OpenAPI stubs only after Effect OpenAPI is the SDK source for those paths.
|
||||
|
||||
V2 cleanup once SDK compatibility no longer needs the legacy Hono contract:
|
||||
|
||||
- Remove `public.ts` compatibility transforms that hide honest `HttpApi` metadata, including auth `securitySchemes`, per-route `security`, and generated `401` responses.
|
||||
- Stop remapping built-in `HttpApi` error schemas back to legacy Hono `BadRequestError` / `NotFoundError` components if V2 clients can consume the actual Effect error shape.
|
||||
- Prefer the direct `HttpApi` OpenAPI output for request/response bodies and named component schemas instead of rewriting it to match Hono generator quirks.
|
||||
- Keep schema fixes that describe the actual wire format, but delete transforms that only preserve legacy SDK type names or inline-vs-ref shape.
|
||||
- Re-evaluate `auth_token` as an OpenAPI security scheme rather than a hand-injected query parameter once clients can consume the V2 spec.
|
||||
|
||||
### 5. Make HttpApi Default For JSON Routes
|
||||
|
||||
After JSON parity and SDK generation are covered:
|
||||
@@ -179,7 +198,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
||||
| `project` | `bridged` | list, current, git init, update |
|
||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
||||
| `workspace` | `bridged` | adapter/list/status/create/remove/session-restore |
|
||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
||||
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||
| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
|
||||
@@ -271,7 +290,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
||||
|
||||
### Workspace Routes
|
||||
|
||||
- [x] `GET /experimental/workspace/adaptor` - list workspace adaptors.
|
||||
- [x] `GET /experimental/workspace/adapter` - list workspace adapters.
|
||||
- [x] `POST /experimental/workspace` - create workspace.
|
||||
- [x] `GET /experimental/workspace` - list workspaces.
|
||||
- [x] `GET /experimental/workspace/status` - workspace status.
|
||||
@@ -357,25 +376,26 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
|
||||
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
|
||||
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||
10. [x] Bridge remaining session mutation and prompt routes.
|
||||
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
||||
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP.
|
||||
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
|
||||
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output.
|
||||
15. [ ] Flip ported JSON routes default-on, keep a short fallback, then delete replaced Hono route files.
|
||||
11. [ ] Replace event SSE with non-Hono Effect HTTP. The Effect backend has a raw Effect HTTP `httpapi/event.ts`; the Hono backend still uses `hono/streaming` `streamSSE`. Either port Hono `/event` to raw Effect HTTP for the fallback window, or skip and delete it together with Hono in step 15.
|
||||
12. [x] Replace pty websocket/control routes with non-Hono Effect HTTP for the Effect backend. Hono `pty.ts` remains in the Hono backend.
|
||||
13. [x] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer for the Effect backend. Hono `tui.ts` remains in the Hono backend.
|
||||
14. [ ] Switch OpenAPI/SDK generation to Effect routes and compare SDK output. Effect path is implemented and opt-in via `--httpapi` / `KILO_SDK_OPENAPI=httpapi`. Close the schema-shape gaps in `public.ts` (branded `pattern`, per-property `description`, `Event.*` / `SyncEvent.*` naming, dedup collisions), then flip `packages/sdk/js/script/build.ts` default.
|
||||
15. [ ] Flip `backend.ts` default from `hono` to `effect-httpapi`, keep `KILO_EXPERIMENTAL_HTTPAPI` (or its inverse) as a short fallback flag, then delete replaced Hono route files.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Add first `HttpApi` JSON route slices.
|
||||
- [x] Bridge selected `HttpApi` routes into Hono behind `KILO_EXPERIMENTAL_HTTPAPI`.
|
||||
- [x] Bridge selected `HttpApi` routes behind `KILO_EXPERIMENTAL_HTTPAPI`. (Now backend-fork-at-startup rather than in-Hono path mounting.)
|
||||
- [x] Reuse existing Effect services in handlers.
|
||||
- [x] Provide auth, instance lookup, and observability in the Effect route layer.
|
||||
- [x] Attach auth middleware in route modules.
|
||||
- [x] Centralize auth via Effect `Config` for the Effect backend.
|
||||
- [x] Support `auth_token` as a query security scheme.
|
||||
- [x] Add bridge-level auth and instance tests.
|
||||
- [x] Complete exact Hono route inventory.
|
||||
- [x] Resolve implemented-but-unmounted route groups.
|
||||
- [x] Port remaining top-level JSON reads.
|
||||
- [ ] Generate SDK/OpenAPI from Effect routes.
|
||||
- [ ] Flip ported JSON routes to default-on with fallback.
|
||||
- [x] Implement Effect `HttpApi` OpenAPI generation behind `--httpapi` / `KILO_SDK_OPENAPI=httpapi`.
|
||||
- [ ] Close Effect-vs-Hono OpenAPI schema-shape gaps and flip the SDK generator default.
|
||||
- [ ] Flip the runtime backend default from `hono` to `effect-httpapi`, with a short fallback flag.
|
||||
- [ ] Delete replaced Hono route implementations.
|
||||
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations.
|
||||
- [ ] Replace SSE/websocket/streaming Hono routes with non-Hono implementations (or remove with the rest of Hono).
|
||||
|
||||
@@ -286,7 +286,6 @@ emitted JSON Schema must stay byte-identical.
|
||||
|
||||
- [x] `src/tool/apply_patch.ts`
|
||||
- [x] `src/tool/bash.ts`
|
||||
- [x] `src/tool/codesearch.ts`
|
||||
- [x] `src/tool/edit.ts`
|
||||
- [x] `src/tool/glob.ts`
|
||||
- [x] `src/tool/grep.ts`
|
||||
@@ -354,7 +353,7 @@ piecewise.
|
||||
- [ ] `src/cli/cmd/tui/event.ts`
|
||||
- [ ] `src/cli/ui.ts`
|
||||
- [ ] `src/command/index.ts`
|
||||
- [x] `src/control-plane/adaptors/worktree.ts`
|
||||
- [x] `src/control-plane/adapters/worktree.ts`
|
||||
- [x] `src/control-plane/types.ts`
|
||||
- [x] `src/control-plane/workspace.ts`
|
||||
- [ ] `src/file/index.ts`
|
||||
|
||||
@@ -40,7 +40,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
|
||||
|
||||
- [x] `apply_patch.ts`
|
||||
- [x] `bash.ts`
|
||||
- [x] `codesearch.ts`
|
||||
- [x] `edit.ts`
|
||||
- [x] `glob.ts`
|
||||
- [x] `grep.ts`
|
||||
@@ -79,7 +78,6 @@ Notable items that are already effectively on the target path and do not need se
|
||||
- `apply_patch.ts`
|
||||
- `grep.ts`
|
||||
- `write.ts`
|
||||
- `codesearch.ts`
|
||||
- `websearch.ts`
|
||||
- `edit.ts`
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { OpenCode } from "@opencode-ai/core"
|
||||
import { ReadTool } from "@opencode-ai/core/tools"
|
||||
|
||||
const opencode = OpenCode.make({})
|
||||
|
||||
opencode.tool.add(ReadTool)
|
||||
|
||||
opencode.tool.add({
|
||||
name: "bash",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description: "The command to run.",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
execute(input, ctx) {},
|
||||
})
|
||||
|
||||
opencode.auth.add({
|
||||
provider: "openai",
|
||||
type: "api",
|
||||
value: process.env.OPENAI_API_KEY,
|
||||
})
|
||||
|
||||
opencode.agent.add({
|
||||
name: "build",
|
||||
permissions: [],
|
||||
model: {
|
||||
id: "gpt-5-5",
|
||||
provider: "openai",
|
||||
variant: "xhigh",
|
||||
},
|
||||
})
|
||||
|
||||
const sessionID = await opencode.session.create({
|
||||
agent: "build",
|
||||
})
|
||||
|
||||
opencode.subscribe((event) => {
|
||||
console.log(event)
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "hey what is up",
|
||||
})
|
||||
|
||||
await opencode.session.prompt({
|
||||
sessionID,
|
||||
text: "what is up with this",
|
||||
files: [
|
||||
{
|
||||
mime: "image/png",
|
||||
uri: "data:image/png;base64,xxxx",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await opencode.session.wait()
|
||||
|
||||
console.log(await opencode.session.messages(sessionID))
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
|
||||
import type { ACPSessionState } from "./types"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2"
|
||||
|
||||
const log = Log.create({ service: "acp-session-manager" })
|
||||
|
||||
|
||||
@@ -34,8 +34,8 @@ export const Info = Schema.Struct({
|
||||
mode: Schema.Literals(["subagent", "primary", "all"]),
|
||||
native: Schema.optional(Schema.Boolean),
|
||||
hidden: Schema.optional(Schema.Boolean),
|
||||
topP: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
topP: Schema.optional(Schema.Finite),
|
||||
temperature: Schema.optional(Schema.Finite),
|
||||
color: Schema.optional(Schema.String),
|
||||
permission: Permission.Ruleset,
|
||||
model: Schema.optional(
|
||||
@@ -47,7 +47,7 @@ export const Info = Schema.Struct({
|
||||
variant: Schema.optional(Schema.String),
|
||||
prompt: Schema.optional(Schema.String),
|
||||
options: Schema.Record(Schema.String, Schema.Unknown),
|
||||
steps: Schema.optional(Schema.Number),
|
||||
steps: Schema.optional(Schema.Finite),
|
||||
})
|
||||
.annotate({ identifier: "Agent" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -87,6 +87,7 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - include global config dirs so agents can read them without prompting
|
||||
const whitelistedDirs = [
|
||||
Truncate.GLOB,
|
||||
path.join(Global.Path.tmp, "*"),
|
||||
...skillDirs.map((dir) => path.join(dir, "*")),
|
||||
path.join(Global.Path.config, "*"),
|
||||
...KilocodePaths.globalDirs().map((dir) => path.join(dir, "*")),
|
||||
@@ -187,7 +188,6 @@ export const layer = Layer.effect(
|
||||
bash: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
@@ -251,7 +251,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
// kilocode_change start - rename build→code, add debug/orchestrator/ask, patch plan/explore
|
||||
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo)
|
||||
KiloAgent.patchAgents(agents, defaults, user, cfg, kilo, ctx.worktree, whitelistedDirs)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - preprocess config to remap "build" key → "code"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, Context } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
|
||||
@@ -16,7 +17,7 @@ export class Oauth extends Schema.Class<Oauth>("OAuth")({
|
||||
type: Schema.Literal("oauth"),
|
||||
refresh: Schema.String,
|
||||
access: Schema.String,
|
||||
expires: Schema.Number,
|
||||
expires: NonNegativeInt,
|
||||
accountId: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
@@ -34,4 +34,16 @@ export function payloads() {
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export function effectPayloads() {
|
||||
return registry
|
||||
.entries()
|
||||
.map(([type, def]) =>
|
||||
Schema.Struct({
|
||||
type: Schema.Literal(type),
|
||||
properties: def.properties,
|
||||
}).annotate({ identifier: `Event.${type}` }),
|
||||
)
|
||||
.toArray()
|
||||
}
|
||||
|
||||
export * as BusEvent from "./bus-event"
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceBootstrap } from "../project/bootstrap"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { getBootstrapRunEffect } from "../effect/app-runtime"
|
||||
|
||||
export async function bootstrap<T>(directory: string, cb: () => Promise<T>) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
try {
|
||||
const result = await cb()
|
||||
return result
|
||||
} finally {
|
||||
await Instance.dispose()
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,7 +28,6 @@ const AVAILABLE_PERMISSIONS = [
|
||||
"task",
|
||||
"todowrite",
|
||||
"websearch",
|
||||
"codesearch",
|
||||
"lsp",
|
||||
"skill",
|
||||
]
|
||||
|
||||
@@ -244,10 +244,7 @@ export const ExportCommand = cmd({
|
||||
output: process.stderr,
|
||||
})
|
||||
|
||||
const sessions = []
|
||||
for await (const session of Session.list()) {
|
||||
sessions.push(session)
|
||||
}
|
||||
const sessions = await AppRuntime.runPromise(Session.Service.use((svc) => svc.list()))
|
||||
|
||||
if (sessions.length === 0) {
|
||||
prompts.log.error("No sessions found", {
|
||||
|
||||
@@ -212,7 +212,7 @@ export const GithubInstallCommand = cmd({
|
||||
const app = await getAppInfo()
|
||||
await installGitHubApp()
|
||||
|
||||
const providers = await ModelsDev.get().then((p) => {
|
||||
const providers = await AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get())).then((p) => {
|
||||
// TODO: add guide for copilot, for now just hide it
|
||||
delete p["github-copilot"]
|
||||
return p
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Instance } from "../../project/instance"
|
||||
|
||||
import { EOL } from "os"
|
||||
import { Effect } from "effect"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "../../provider/schema"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { cmd } from "./cmd"
|
||||
import { effectCmd, fail } from "../effect-cmd"
|
||||
import { UI } from "../ui"
|
||||
import { EOL } from "os"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export const ModelsCommand = cmd({
|
||||
export const ModelsCommand = effectCmd({
|
||||
command: "models [provider]",
|
||||
describe: "list all available models",
|
||||
builder: (yargs: Argv) => {
|
||||
return yargs
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("provider", {
|
||||
describe: "provider ID to filter models by",
|
||||
type: "string",
|
||||
@@ -26,65 +24,46 @@ export const ModelsCommand = cmd({
|
||||
.option("refresh", {
|
||||
describe: "refresh the models cache from models.dev",
|
||||
type: "boolean",
|
||||
})
|
||||
},
|
||||
handler: async (args) => {
|
||||
}),
|
||||
handler: Effect.fn("Cli.models")(function* (args) {
|
||||
if (args.refresh) {
|
||||
await ModelsDev.refresh(true)
|
||||
yield* ModelsDev.Service.use((s) => s.refresh(true))
|
||||
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
|
||||
}
|
||||
|
||||
await Instance.provide({
|
||||
directory: process.cwd(),
|
||||
async fn() {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const providers = yield* svc.list()
|
||||
const provider = yield* Provider.Service
|
||||
const providers = yield* provider.list()
|
||||
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const provider = providers[providerID]
|
||||
const sorted = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
const print = (providerID: ProviderID, verbose?: boolean) => {
|
||||
const p = providers[providerID]
|
||||
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
|
||||
for (const [modelID, model] of sorted) {
|
||||
process.stdout.write(`${providerID}/${modelID}`)
|
||||
process.stdout.write(EOL)
|
||||
if (verbose) {
|
||||
process.stdout.write(JSON.stringify(model, null, 2))
|
||||
process.stdout.write(EOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
const provider = providers[providerID]
|
||||
if (!provider) {
|
||||
yield* Effect.sync(() => UI.error(`Provider not found: ${args.provider}`))
|
||||
return
|
||||
}
|
||||
if (args.provider) {
|
||||
const providerID = ProviderID.make(args.provider)
|
||||
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
|
||||
print(providerID, args.verbose)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.sync(() => print(providerID, args.verbose))
|
||||
return
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsKilo = a === "kilo" || a.startsWith("opencode")
|
||||
const bIsKilo = b === "kilo" || b.startsWith("opencode")
|
||||
if (aIsKilo && !bIsKilo) return -1
|
||||
if (!aIsKilo && bIsKilo) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
for (const providerID of ids) {
|
||||
print(ProviderID.make(providerID), args.verbose)
|
||||
}
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
// kilocode_change start
|
||||
const ids = Object.keys(providers).sort((a, b) => {
|
||||
const aIsKilo = a === "kilo" || a.startsWith("opencode")
|
||||
const bIsKilo = b === "kilo" || b.startsWith("opencode")
|
||||
if (aIsKilo && !bIsKilo) return -1
|
||||
if (!aIsKilo && bIsKilo) return 1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
},
|
||||
// kilocode_change end
|
||||
|
||||
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -4,6 +4,9 @@ import { cmd } from "./cmd"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { UI } from "../ui"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
|
||||
const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
|
||||
const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
@@ -156,28 +159,38 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
}
|
||||
|
||||
if (method.type === "api") {
|
||||
if (method.authorize) {
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
const key = await prompts.password({
|
||||
message: "Enter your API key",
|
||||
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
|
||||
})
|
||||
if (prompts.isCancel(key)) throw new UI.CancelledError()
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
const metadata = Object.keys(inputs).length ? { metadata: inputs } : {}
|
||||
if (!method.authorize) {
|
||||
await put(provider, {
|
||||
type: "api",
|
||||
key,
|
||||
...metadata,
|
||||
})
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
const result = await method.authorize(inputs)
|
||||
if (result.type === "failed") {
|
||||
prompts.log.error("Failed to authorize")
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
await put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? key,
|
||||
...metadata,
|
||||
})
|
||||
prompts.log.success("Login successful")
|
||||
}
|
||||
prompts.outro("Done")
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -241,7 +254,7 @@ export const ProvidersListCommand = cmd({
|
||||
return Object.entries(yield* auth.all())
|
||||
}),
|
||||
)
|
||||
const database = await ModelsDev.get()
|
||||
const database = await getModels()
|
||||
|
||||
for (const [providerID, result] of results) {
|
||||
const name = database[providerID]?.name || providerID
|
||||
@@ -334,14 +347,14 @@ export const ProvidersLoginCommand = cmd({
|
||||
prompts.outro("Done")
|
||||
return
|
||||
}
|
||||
await ModelsDev.refresh(true).catch(() => {})
|
||||
await refreshModels().catch(() => {})
|
||||
|
||||
const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
|
||||
const providers = await ModelsDev.get().then((x) => {
|
||||
const providers = await getModels().then((x) => {
|
||||
const filtered: Record<string, (typeof x)[string]> = {}
|
||||
for (const [key, value] of Object.entries(x)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
@@ -375,7 +388,9 @@ export const ProvidersLoginCommand = cmd({
|
||||
existingProviders: providers,
|
||||
disabled,
|
||||
enabled,
|
||||
providerNames: Object.fromEntries(Object.entries(config.provider ?? {}).map(([id, p]) => [id, p.name])),
|
||||
providerNames: Object.fromEntries(
|
||||
Object.entries(config.provider ?? {}).flatMap(([id, p]) => (p ? [[id, p.name]] : [])),
|
||||
), // kilocode_change
|
||||
})
|
||||
const options = [
|
||||
...pipe(
|
||||
@@ -512,7 +527,7 @@ export const ProvidersLogoutCommand = cmd({
|
||||
prompts.log.error("No credentials found")
|
||||
return
|
||||
}
|
||||
const database = await ModelsDev.get()
|
||||
const database = await getModels()
|
||||
const selected = await prompts.select({
|
||||
message: "Select provider",
|
||||
options: credentials.map(([key, value]) => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cmd } from "./cmd"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
|
||||
export const RemoteCommand = cmd({
|
||||
command: "remote",
|
||||
@@ -17,7 +18,7 @@ export const RemoteCommand = cmd({
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
KiloSessions.disableRemote()
|
||||
await Instance.dispose()
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
} finally {
|
||||
abort.abort()
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { ReadTool } from "../../tool/read"
|
||||
import { WebFetchTool } from "../../tool/webfetch"
|
||||
import { EditTool } from "../../tool/edit"
|
||||
import { WriteTool } from "../../tool/write"
|
||||
import { CodeSearchTool } from "../../tool/codesearch"
|
||||
import { WebSearchTool } from "../../tool/websearch"
|
||||
import { TaskTool } from "../../tool/task"
|
||||
import { SkillTool } from "../../tool/skill"
|
||||
@@ -147,13 +146,6 @@ function edit(info: ToolProps<typeof EditTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
|
||||
inline({
|
||||
icon: "◇",
|
||||
title: `Exa Code Search "${info.input.query}"`,
|
||||
})
|
||||
}
|
||||
|
||||
function websearch(info: ToolProps<typeof WebSearchTool>) {
|
||||
inline({
|
||||
icon: "◈",
|
||||
@@ -444,7 +436,6 @@ export const RunCommand = cmd({
|
||||
if (part.tool === "write") return write(props<typeof WriteTool>(part))
|
||||
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
|
||||
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
|
||||
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
|
||||
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
|
||||
if (part.tool === "task") return task(props<typeof TaskTool>(part))
|
||||
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Server } from "../../server/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../project/instance" // kilocode_change
|
||||
import { InstanceStore } from "../../project/instance-store" // kilocode_change
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
@@ -20,7 +20,7 @@ export const ServeCommand = cmd({
|
||||
const abort = new AbortController()
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await Instance.disposeAll()
|
||||
await InstanceStore.disposeAllInstances()
|
||||
await server.stop(true)
|
||||
} finally {
|
||||
abort.abort()
|
||||
|
||||
@@ -113,7 +113,9 @@ export const SessionListCommand = cmd({
|
||||
// kilocode_change start
|
||||
const sessions = args.all
|
||||
? [...Session.listGlobal({ roots: true, limit: args.maxCount, search: args.search })]
|
||||
: [...Session.list({ roots: true, limit: args.maxCount, search: args.search })]
|
||||
: await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) => svc.list({ roots: true, limit: args.maxCount, search: args.search })),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start
|
||||
|
||||
@@ -140,6 +140,8 @@ export function tui(input: {
|
||||
process.on("exit", resetTerminalState) // kilocode_change
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
// Prewarm palette before ThemeProvider mounts so `system` theme avoids a first-paint fallback flash.
|
||||
void renderer.getPalette({ size: 16 }).catch(() => undefined)
|
||||
const mode = (await renderer.waitForThemeMode(1000)) ?? "dark"
|
||||
|
||||
await render(() => {
|
||||
@@ -310,6 +312,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
const [pasteSummaryEnabled, setPasteSummaryEnabled] = createSignal(
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary),
|
||||
)
|
||||
|
||||
KiloApp.useSessionEffects({ route, sdk, sync }) // kilocode_change
|
||||
|
||||
@@ -763,6 +768,31 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: pasteSummaryEnabled() ? "Disable paste summary" : "Enable paste summary",
|
||||
value: "app.toggle.paste_summary",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
setPasteSummaryEnabled((prev) => {
|
||||
const next = !prev
|
||||
kv.set("paste_summary_enabled", next)
|
||||
return next
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("session_directory_filter_enabled", true)
|
||||
? "Disable session directory filtering"
|
||||
: "Enable session directory filtering",
|
||||
value: "app.toggle.session_directory_filter",
|
||||
category: "System",
|
||||
onSelect: async (dialog) => {
|
||||
kv.set("session_directory_filter_enabled", !kv.get("session_directory_filter_enabled", true))
|
||||
await sync.session.refresh()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
|
||||
value: "app.toggle.diffwrap",
|
||||
|
||||
@@ -40,7 +40,7 @@ export function createDialogProviderOptions() {
|
||||
description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
gutter: connected && onboarded() ? <text fg={theme.success}>✓</text> : undefined,
|
||||
gutter: connected && onboarded() ? () => <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ export function DialogSessionList() {
|
||||
const [global, setGlobal] = createSignal(true) // kilocode_change - show all worktrees by default
|
||||
|
||||
// kilocode_change start - always fetch from experimental endpoint (returns GlobalSession with worktree info)
|
||||
// TODO: extend /experimental/session to accept `scope`/`path` so this dialog can respect the
|
||||
// upstream `session_directory_filter_enabled` KV toggle (via sync.session.query()) while
|
||||
// keeping worktree grouping. Currently the toggle has no effect here.
|
||||
const [searchResults, searchActions] = createResource(
|
||||
() => search(),
|
||||
async (query) => {
|
||||
@@ -190,7 +193,7 @@ export function DialogSessionList() {
|
||||
value: x.id,
|
||||
category,
|
||||
footer,
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
gutter: isWorking ? () => <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ import { errorMessage } from "@/util/error"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
type Adaptor = {
|
||||
type Adapter = {
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
@@ -108,26 +108,26 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
const [adaptors, setAdaptors] = createSignal<Adaptor[]>()
|
||||
const [adapters, setAdapters] = createSignal<Adapter[]>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
void (async () => {
|
||||
const dir = sync.path.directory || sdk.directory
|
||||
const url = new URL("/experimental/workspace/adaptor", sdk.url)
|
||||
const url = new URL("/experimental/workspace/adapter", sdk.url)
|
||||
if (dir) url.searchParams.set("directory", dir)
|
||||
const res = await sdk
|
||||
.fetch(url)
|
||||
.then((x) => x.json() as Promise<Adaptor[]>)
|
||||
.then((x) => x.json() as Promise<Adapter[]>)
|
||||
.catch(() => undefined)
|
||||
if (!res) {
|
||||
toast.show({
|
||||
message: "Failed to load workspace adaptors",
|
||||
message: "Failed to load workspace adapters",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
setAdaptors(res)
|
||||
setAdapters(res)
|
||||
})()
|
||||
})
|
||||
|
||||
@@ -142,13 +142,13 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
|
||||
},
|
||||
]
|
||||
}
|
||||
const list = adaptors()
|
||||
const list = adapters()
|
||||
if (!list) {
|
||||
return [
|
||||
{
|
||||
title: "Loading workspaces...",
|
||||
value: "loading" as const,
|
||||
description: "Fetching available workspace adaptors",
|
||||
description: "Fetching available workspace adapters",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import { KiloLogo } from "./kilo-logo" // kilocode_change
|
||||
|
||||
@@ -12,11 +12,12 @@ import { useRoute } from "@tui/context/route"
|
||||
import { useProject } from "@tui/context/project"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useEvent } from "@tui/context/event"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { editorSelectionKey, useEditorContext, type EditorSelection } from "@tui/context/editor"
|
||||
import { MessageID, PartID } from "@/session/schema"
|
||||
import { createStore, produce, unwrap } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { computePromptTraits } from "./traits"
|
||||
import { assign } from "./part"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
@@ -43,6 +44,7 @@ import { DialogSkill } from "../dialog-skill"
|
||||
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
|
||||
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
|
||||
import { useArgs } from "@tui/context/args"
|
||||
import { KiloSessionTuiSync } from "@/kilocode/session/tui-sync" // kilocode_change
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -84,6 +86,32 @@ function fadeColor(color: RGBA, alpha: number) {
|
||||
return RGBA.fromValues(color.r, color.g, color.b, color.a * alpha)
|
||||
}
|
||||
|
||||
function hasEditorRangeSelection(selection: EditorSelection["ranges"][number]) {
|
||||
return (
|
||||
selection.selection.start.line !== selection.selection.end.line ||
|
||||
selection.selection.start.character !== selection.selection.end.character
|
||||
)
|
||||
}
|
||||
|
||||
function getEditorRangeLabel(selection: EditorSelection["ranges"][number]) {
|
||||
if (!hasEditorRangeSelection(selection)) return
|
||||
if (selection.selection.start.line === selection.selection.end.line) return `#${selection.selection.start.line}`
|
||||
return `#${selection.selection.start.line}-${selection.selection.end.line}`
|
||||
}
|
||||
|
||||
function formatEditorContext(selection: EditorSelection) {
|
||||
const selected = selection.ranges.filter(hasEditorRangeSelection)
|
||||
if (selected.length === 0)
|
||||
return `<system-reminder>Note: The user opened the file "${selection.filePath}". This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
|
||||
const ranges = selected.map((range, index) => {
|
||||
const prefix = selected.length > 1 ? `Selection ${index + 1}: ` : ""
|
||||
return `Note: The user selected ${prefix}${getEditorRangeLabel(range)} from "${selection.filePath}". \`\`\`${range.text}\`\`\`\n\n`
|
||||
})
|
||||
|
||||
return `<system-reminder>${ranges.join("\n")} This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
}
|
||||
|
||||
let stashed: { prompt: PromptInfo; cursor: number } | undefined
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
@@ -113,13 +141,21 @@ export function Prompt(props: PromptProps) {
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const fileContextEnabled = createMemo(() => kv.get("file_context_enabled", true))
|
||||
const editorPath = createMemo(() => (fileContextEnabled() ? editor.selection()?.filePath : undefined))
|
||||
const editorSelectionLabel = createMemo(() => {
|
||||
const selection = fileContextEnabled() ? editor.selection()?.selection : undefined
|
||||
const [dismissedEditorSelectionKey, setDismissedEditorSelectionKey] = createSignal<string>()
|
||||
const editorContext = createMemo(() => {
|
||||
const selection = fileContextEnabled() ? editor.selection() : undefined
|
||||
if (!selection) return
|
||||
if (selection.start.line === selection.end.line && selection.start.character === selection.end.character) return
|
||||
if (selection.start.line === selection.end.line) return `#${selection.start.line}`
|
||||
return `#${selection.start.line}-${selection.end.line}`
|
||||
return editorSelectionKey(selection) === dismissedEditorSelectionKey() ? undefined : selection
|
||||
})
|
||||
const editorPath = createMemo(() => editorContext()?.filePath)
|
||||
const editorSelectionLabel = createMemo(() => {
|
||||
const ranges = editorContext()?.ranges
|
||||
if (!ranges) return
|
||||
const first = ranges.find(hasEditorRangeSelection) ?? ranges[0]
|
||||
if (!first) return
|
||||
return [getEditorRangeLabel(first), ranges.length > 1 ? `+${ranges.length - 1}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
})
|
||||
const editorFileLabel = createMemo(() => {
|
||||
const value = editorPath()
|
||||
@@ -135,6 +171,8 @@ export function Prompt(props: PromptProps) {
|
||||
if (!file) return
|
||||
return Locale.truncateMiddle(file, Math.max(12, Math.min(48, Math.floor(dimensions().width / 3))))
|
||||
})
|
||||
const [editorContextHover, setEditorContextHover] = createSignal(false)
|
||||
let lastSubmittedEditorSelectionKey: string | undefined
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
@@ -150,6 +188,11 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function dismissEditorContext() {
|
||||
setDismissedEditorSelectionKey(editorSelectionKey(editorContext()))
|
||||
editor.clearSelection()
|
||||
}
|
||||
|
||||
const textareaKeybindings = useTextareaKeybindings()
|
||||
|
||||
const fileStyleId = syntax().getStyleId("extmark.file")!
|
||||
@@ -236,6 +279,11 @@ export function Prompt(props: PromptProps) {
|
||||
const sessionID = props.sessionID
|
||||
const msg = lastUserMessage()
|
||||
if (!sessionID || !msg) return
|
||||
// kilocode_change start - skip compaction messages while syncing local agent/model
|
||||
const parts = sync.data.part[msg.id]
|
||||
if (!parts) return
|
||||
if (!KiloSessionTuiSync.model({ role: msg.role, parts })) return
|
||||
// kilocode_change end
|
||||
|
||||
const key = [sessionID, msg.id].join(":")
|
||||
if (key === syncedKey) return
|
||||
@@ -281,6 +329,16 @@ export function Prompt(props: PromptProps) {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Remove editor context",
|
||||
value: "prompt.editor_context.clear",
|
||||
category: "Prompt",
|
||||
enabled: Boolean(editorContext()),
|
||||
onSelect: (dialog) => {
|
||||
dismissEditorContext()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paste",
|
||||
value: "prompt.paste",
|
||||
@@ -516,17 +574,11 @@ export function Prompt(props: PromptProps) {
|
||||
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
const capture =
|
||||
store.mode === "normal"
|
||||
? auto()?.visible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
input.traits = {
|
||||
capture,
|
||||
suspend: !!props.disabled || store.mode === "shell",
|
||||
status: store.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
input.traits = computePromptTraits({
|
||||
mode: store.mode,
|
||||
disabled: !!props.disabled,
|
||||
autocompleteVisible: !!auto()?.visible,
|
||||
})
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
@@ -757,37 +809,25 @@ export function Prompt(props: PromptProps) {
|
||||
// Capture mode before it gets reset
|
||||
const currentMode = store.mode
|
||||
const variant = local.model.variant.current()
|
||||
const editorSelection = fileContextEnabled() ? editor.selection() : undefined
|
||||
const editorParts = editorSelection
|
||||
? [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text" as const,
|
||||
text: (() => {
|
||||
const start = editorSelection.selection.start
|
||||
const end = editorSelection.selection.end
|
||||
|
||||
let text = ""
|
||||
if (start.line === end.line && start.character === end.character) {
|
||||
text = `Note: The user opened the file "${editorSelection.filePath}".`
|
||||
} else if (start.line === end.line) {
|
||||
text = `Note: The user selected line ${start.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
|
||||
} else {
|
||||
text = `Note: The user selected lines ${start.line + 1} to ${end.line + 1} from "${editorSelection.filePath}". \`\`\`${editorSelection.text}\`\`\`\n\n`
|
||||
}
|
||||
|
||||
return `<system-reminder>${text} This may or may not be relevant to the current task.</system-reminder>\n`
|
||||
})(),
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
kind: "editor_context",
|
||||
source: editorSelection.source ?? "editor",
|
||||
filePath: editorSelection.filePath,
|
||||
selection: editorSelection.selection,
|
||||
const editorSelection = editorContext()
|
||||
const currentEditorSelectionKey = editorSelectionKey(editorSelection)
|
||||
const editorParts =
|
||||
editorSelection && currentEditorSelectionKey !== lastSubmittedEditorSelectionKey
|
||||
? [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
type: "text" as const,
|
||||
text: formatEditorContext(editorSelection),
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
kind: "editor_context",
|
||||
source: editorSelection.source ?? "editor",
|
||||
filePath: editorSelection.filePath,
|
||||
ranges: editorSelection.ranges,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
]
|
||||
: []
|
||||
|
||||
if (store.mode === "shell") {
|
||||
void sdk.client.session.shell({
|
||||
@@ -850,7 +890,7 @@ export function Prompt(props: PromptProps) {
|
||||
],
|
||||
})
|
||||
.catch(() => {})
|
||||
editor.clearSelection()
|
||||
lastSubmittedEditorSelectionKey = currentEditorSelectionKey
|
||||
}
|
||||
toast.dismiss() // kilocode_change - dismiss persistent config warning on first submit
|
||||
history.append({
|
||||
@@ -1240,7 +1280,7 @@ export function Prompt(props: PromptProps) {
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (
|
||||
(lineCount >= 5 || pastedContent.length > 800) && // kilocode_change #7252 delay paste summary
|
||||
!sync.data.config.experimental?.disable_paste_summary
|
||||
kv.get("paste_summary_enabled", !sync.data.config.experimental?.disable_paste_summary)
|
||||
) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
@@ -1435,7 +1475,18 @@ export function Prompt(props: PromptProps) {
|
||||
</text>
|
||||
</Show>
|
||||
{/* kilocode_change end */}
|
||||
<Show when={editorFileLabelDisplay()}>{(file) => <text fg={theme.secondary}>{file()}</text>}</Show>
|
||||
<Show when={editorFileLabelDisplay()}>
|
||||
{(file) => (
|
||||
<text
|
||||
fg={theme.secondary}
|
||||
onMouseOver={() => setEditorContextHover(true)}
|
||||
onMouseOut={() => setEditorContextHover(false)}
|
||||
onMouseUp={dismissEditorContext}
|
||||
>
|
||||
{editorContextHover() ? `x ${file()}` : file()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Switch>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { EditorTraits } from "@opentui/core"
|
||||
|
||||
export type PromptMode = "normal" | "shell"
|
||||
|
||||
export interface PromptTraitsInput {
|
||||
mode: PromptMode
|
||||
disabled: boolean
|
||||
autocompleteVisible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the textarea editor traits for the prompt.
|
||||
*
|
||||
* `traits.suspend` gates the textarea's keybinding actions (backspace,
|
||||
* delete-word, arrow movement, undo/redo, etc.). Shell mode is an active
|
||||
* editing mode — only `disabled` should suspend the textarea, otherwise
|
||||
* users can type in shell mode but cannot delete or move the cursor.
|
||||
*/
|
||||
export function computePromptTraits(input: PromptTraitsInput): EditorTraits {
|
||||
const capture =
|
||||
input.mode === "normal"
|
||||
? input.autocompleteVisible
|
||||
? (["escape", "navigate", "submit", "tab"] as const)
|
||||
: (["tab"] as const)
|
||||
: undefined
|
||||
return {
|
||||
capture,
|
||||
suspend: input.disabled,
|
||||
status: input.mode === "shell" ? "SHELL" : undefined,
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ const ZedEditorRowSchema = z.object({
|
||||
workspace_paths: z.string().nullable(),
|
||||
timestamp: z.string(),
|
||||
buffer_path: z.string().nullable(),
|
||||
})
|
||||
|
||||
const ZedSelectionRowSchema = z.object({
|
||||
selection_start: z.number().nullable(),
|
||||
selection_end: z.number().nullable(),
|
||||
})
|
||||
@@ -20,8 +23,11 @@ const ZedEditorContentsSchema = z.object({
|
||||
contents: z.string().nullable(),
|
||||
})
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
|
||||
|
||||
export type ZedSelectionResult =
|
||||
| { type: "selection"; selection: EditorSelection }
|
||||
@@ -34,7 +40,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
|
||||
|
||||
const row = active.row
|
||||
if (!row.buffer_path) return { type: "empty" }
|
||||
if (row.selection_start == null || row.selection_end == null) return { type: "unavailable" }
|
||||
|
||||
const selections = queryZedEditorSelections(dbPath, row)
|
||||
if (selections.type !== "selections") return selections
|
||||
const byteRanges = selections.selections
|
||||
.flatMap((selection) => {
|
||||
if (selection.selection_start == null || selection.selection_end == null) return []
|
||||
return [
|
||||
{
|
||||
start: Math.min(selection.selection_start, selection.selection_end),
|
||||
end: Math.max(selection.selection_start, selection.selection_end),
|
||||
},
|
||||
]
|
||||
})
|
||||
.sort((left, right) => left.start - right.start || left.end - right.end)
|
||||
if (byteRanges.length === 0) return { type: "unavailable" }
|
||||
|
||||
const contents = queryZedEditorContents(dbPath, row)
|
||||
const text =
|
||||
@@ -45,16 +65,21 @@ export async function resolveZedSelection(dbPath: string, cwd = process.cwd()):
|
||||
.catch(() => undefined)
|
||||
if (text == null) return { type: "unavailable" }
|
||||
|
||||
const startOffset = Math.min(row.selection_start, row.selection_end)
|
||||
const endOffset = Math.max(row.selection_start, row.selection_end)
|
||||
const ranges = byteRanges.map((range) => {
|
||||
const startOffset = utf8ByteOffsetToStringIndex(text, range.start)
|
||||
const endOffset = utf8ByteOffsetToStringIndex(text, range.end)
|
||||
return {
|
||||
text: text.slice(startOffset, endOffset),
|
||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: "selection",
|
||||
selection: {
|
||||
text: text.slice(startOffset, endOffset),
|
||||
filePath: row.buffer_path,
|
||||
source: "zed",
|
||||
selection: offsetsToSelection(text, startOffset, endOffset),
|
||||
ranges,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -71,14 +96,11 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
i.workspace_id as workspace_id,
|
||||
w.paths as workspace_paths,
|
||||
w.timestamp as timestamp,
|
||||
e.buffer_path as buffer_path,
|
||||
s.start as selection_start,
|
||||
s.end as selection_end
|
||||
e.buffer_path as buffer_path
|
||||
from items i
|
||||
join panes p on p.pane_id = i.pane_id and p.workspace_id = i.workspace_id
|
||||
join workspaces w on w.workspace_id = i.workspace_id
|
||||
left join editors e on e.item_id = i.item_id and e.workspace_id = i.workspace_id
|
||||
left join editor_selections s on s.editor_id = e.item_id and s.workspace_id = e.workspace_id
|
||||
where i.active = 1 and p.active = 1
|
||||
order by w.timestamp desc`,
|
||||
)
|
||||
@@ -106,6 +128,34 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const raw = db
|
||||
.query(
|
||||
`select
|
||||
start as selection_start,
|
||||
end as selection_end
|
||||
from editor_selections
|
||||
where editor_id = $editorID and workspace_id = $workspaceID`,
|
||||
)
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = ZedSelectionRowSchema.safeParse(selection)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
||||
return { type: "selections" as const, selections }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
}
|
||||
|
||||
function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
@@ -139,13 +189,20 @@ export function resolveZedDbPath() {
|
||||
path.join(os.homedir(), ".local", "share", "zed", "db", "0-stable", "db.sqlite"),
|
||||
].filter((item): item is string => Boolean(item))
|
||||
|
||||
return candidates.find((item) => Filesystem.stat(item)?.isFile())
|
||||
return candidates.find((item) => isFile(item))
|
||||
}
|
||||
|
||||
function isFile(item: string) {
|
||||
try {
|
||||
return Filesystem.stat(item)?.isFile() === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scoreZedWorkspace(workspacePaths: string | null, cwd: string) {
|
||||
return zedWorkspacePaths(workspacePaths).reduce((score, item) => {
|
||||
if (pathContains(item, cwd)) return Math.max(score, 2)
|
||||
if (pathContains(cwd, item)) return Math.max(score, 1)
|
||||
if (pathContains(item, cwd)) return Math.max(score, path.resolve(item).length)
|
||||
return score
|
||||
}, 0)
|
||||
}
|
||||
@@ -158,7 +215,25 @@ function zedWorkspacePaths(value: string | null) {
|
||||
}
|
||||
|
||||
export function offsetToPosition(text: string, offset: number) {
|
||||
return offsetsToSelection(text, offset, offset).start
|
||||
const stringOffset = utf8ByteOffsetToStringIndex(text, offset)
|
||||
return offsetsToSelection(text, stringOffset, stringOffset).start
|
||||
}
|
||||
|
||||
function utf8ByteOffsetToStringIndex(text: string, byteOffset: number) {
|
||||
if (byteOffset <= 0) return 0
|
||||
|
||||
let bytes = 0
|
||||
for (let index = 0; index < text.length; ) {
|
||||
const codePoint = text.codePointAt(index)
|
||||
if (codePoint === undefined) return text.length
|
||||
|
||||
const nextIndex = index + (codePoint > 0xffff ? 2 : 1)
|
||||
bytes += utf8.encode(text.slice(index, nextIndex)).length
|
||||
if (bytes >= byteOffset) return nextIndex
|
||||
index = nextIndex
|
||||
}
|
||||
|
||||
return text.length
|
||||
}
|
||||
|
||||
function offsetsToSelection(text: string, startOffset: number, endOffset: number) {
|
||||
|
||||
@@ -28,16 +28,46 @@ const PositionSchema = z.object({
|
||||
character: z.number(),
|
||||
})
|
||||
|
||||
const EditorSelectionSchema = z.object({
|
||||
const EditorSelectionRangeSchema = z.object({
|
||||
text: z.string(),
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
selection: z.object({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
const EditorSelectionSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
ranges: z.array(EditorSelectionRangeSchema).min(1),
|
||||
}),
|
||||
z.object({
|
||||
text: z.string(),
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
selection: z.object({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.transform((value) =>
|
||||
"ranges" in value
|
||||
? value
|
||||
: {
|
||||
filePath: value.filePath,
|
||||
source: value.source,
|
||||
ranges: [
|
||||
{
|
||||
text: value.text,
|
||||
selection: value.selection,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const EditorMentionSchema = z.object({
|
||||
filePath: z.string(),
|
||||
lineStart: z.number(),
|
||||
@@ -75,8 +105,9 @@ type EditorLockFile = {
|
||||
|
||||
export const { use: useEditorContext, provider: EditorContextProvider } = createSimpleContext({
|
||||
name: "EditorContext",
|
||||
init: () => {
|
||||
init: (props: { WebSocketImpl?: typeof WebSocket }) => {
|
||||
const mentionListeners = new Set<(mention: EditorMention) => void>()
|
||||
const WebSocketImpl = props.WebSocketImpl ?? WebSocket
|
||||
const [store, setStore] = createStore<{
|
||||
status: "disabled" | "connecting" | "connected"
|
||||
selection: EditorSelection | undefined
|
||||
@@ -87,138 +118,160 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
server: undefined,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
let socket: WebSocket | undefined
|
||||
let closed = false
|
||||
let reconnect: ReturnType<typeof setTimeout> | undefined
|
||||
let attempt = 0
|
||||
let requestID = 0
|
||||
let zedSelection: Promise<void> | undefined
|
||||
let lastZedSelectionKey: string | undefined
|
||||
const pending = new Map<number, string>()
|
||||
let socket: WebSocket | undefined
|
||||
let closed = false
|
||||
let reconnect: ReturnType<typeof setTimeout> | undefined
|
||||
let attempt = 0
|
||||
let requestID = 0
|
||||
let zedSelection: Promise<void> | undefined
|
||||
let lastZedSelectionKey: string | undefined
|
||||
let directory = process.cwd()
|
||||
const pending = new Map<number, string>()
|
||||
|
||||
const send = (payload: JsonRpcMessage) => {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
||||
const send = (payload: JsonRpcMessage) => {
|
||||
if (!socket || socket.readyState !== 1) return
|
||||
socket.send(JSON.stringify({ jsonrpc: "2.0", ...payload }))
|
||||
}
|
||||
|
||||
const request = (method: string, params?: unknown) => {
|
||||
requestID += 1
|
||||
pending.set(requestID, method)
|
||||
send({ id: requestID, method, params })
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
|
||||
const connection = resolveEditorConnection(directory)
|
||||
if (!connection) {
|
||||
const dbPath = resolveZedDbPath()
|
||||
if (!dbPath) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
zedSelection ??= resolveZedSelection(dbPath, directory)
|
||||
.then((result) => {
|
||||
if (closed || socket) return
|
||||
if (result.type === "unavailable") return
|
||||
const selection = result.type === "selection" ? result.selection : undefined
|
||||
const key = editorSelectionKey(selection)
|
||||
if (key !== lastZedSelectionKey) {
|
||||
lastZedSelectionKey = key
|
||||
setStore("selection", selection)
|
||||
setStore("status", selection ? "connected" : "disabled")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last known Zed selection for transient polling failures.
|
||||
})
|
||||
.finally(() => {
|
||||
zedSelection = undefined
|
||||
})
|
||||
scheduleZedPoll()
|
||||
return
|
||||
}
|
||||
|
||||
const request = (method: string, params?: unknown) => {
|
||||
requestID += 1
|
||||
pending.set(requestID, method)
|
||||
send({ id: requestID, method, params })
|
||||
}
|
||||
setStore("status", "connecting")
|
||||
const current = openEditorSocket(connection, WebSocketImpl)
|
||||
socket = current
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closed) return
|
||||
if (reconnect) clearTimeout(reconnect)
|
||||
attempt += 1
|
||||
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
|
||||
reconnect = setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
const scheduleZedPoll = () => {
|
||||
if (closed) return
|
||||
if (reconnect) clearTimeout(reconnect)
|
||||
reconnect = setTimeout(connect, 1000)
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return
|
||||
|
||||
const connection = resolveEditorConnection()
|
||||
if (!connection) {
|
||||
const dbPath = resolveZedDbPath()
|
||||
if (!dbPath) {
|
||||
setStore("status", "disabled")
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
zedSelection ??= resolveZedSelection(dbPath)
|
||||
.then((result) => {
|
||||
if (closed || socket) return
|
||||
if (result.type === "unavailable") return
|
||||
const selection = result.type === "selection" ? result.selection : undefined
|
||||
const key = editorSelectionKey(selection)
|
||||
if (key !== lastZedSelectionKey) {
|
||||
lastZedSelectionKey = key
|
||||
setStore("selection", selection)
|
||||
setStore("status", selection ? "connected" : "disabled")
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last known Zed selection for transient polling failures.
|
||||
})
|
||||
.finally(() => {
|
||||
zedSelection = undefined
|
||||
})
|
||||
scheduleZedPoll()
|
||||
current.addEventListener("open", () => {
|
||||
if (socket !== current) {
|
||||
current.close()
|
||||
return
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
setStore("status", "connected")
|
||||
request("initialize", {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode", version: "0.0.0" },
|
||||
})
|
||||
})
|
||||
|
||||
current.addEventListener("message", (event) => {
|
||||
const message = parseMessage(event.data)
|
||||
if (!message) return
|
||||
|
||||
const selection =
|
||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||
if (selection?.success) {
|
||||
setStore("selection", { ...selection.data, source: "websocket" })
|
||||
return
|
||||
}
|
||||
|
||||
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
||||
if (mention?.success) {
|
||||
mentionListeners.forEach((listener) => listener(mention.data))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof message.id !== "number") return
|
||||
|
||||
const method = pending.get(message.id)
|
||||
if (!method) return
|
||||
|
||||
pending.delete(message.id)
|
||||
if (message.error) return
|
||||
|
||||
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
|
||||
if (initialize?.success) {
|
||||
setStore("server", initialize.data)
|
||||
send({ method: "notifications/initialized" })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
current.addEventListener("close", () => {
|
||||
if (socket !== current) return
|
||||
|
||||
socket = undefined
|
||||
pending.clear()
|
||||
if (closed) return
|
||||
|
||||
setStore("status", "connecting")
|
||||
const current = openEditorSocket(connection)
|
||||
socket = current
|
||||
scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
current.addEventListener("open", () => {
|
||||
if (socket !== current) {
|
||||
current.close()
|
||||
return
|
||||
}
|
||||
const scheduleReconnect = () => {
|
||||
if (closed) return
|
||||
if (reconnect) clearTimeout(reconnect)
|
||||
attempt += 1
|
||||
const delay = Math.min(1000 * 2 ** (attempt - 1), 10_000)
|
||||
reconnect = setTimeout(connect, delay)
|
||||
}
|
||||
|
||||
attempt = 0
|
||||
setStore("status", "connected")
|
||||
request("initialize", {
|
||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||
capabilities: {},
|
||||
clientInfo: { name: "opencode", version: "0.0.0" },
|
||||
})
|
||||
})
|
||||
const scheduleZedPoll = () => {
|
||||
if (closed) return
|
||||
if (reconnect) clearTimeout(reconnect)
|
||||
reconnect = setTimeout(connect, 1000)
|
||||
}
|
||||
|
||||
current.addEventListener("message", (event) => {
|
||||
const message = parseMessage(event.data)
|
||||
if (!message) return
|
||||
const reconnectWithDirectory = (nextDirectory?: string) => {
|
||||
const resolved = nextDirectory || process.cwd()
|
||||
if (directory === resolved) return
|
||||
|
||||
const selection =
|
||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||
if (selection?.success) {
|
||||
setStore("selection", { ...selection.data, source: "websocket" })
|
||||
return
|
||||
}
|
||||
|
||||
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
||||
if (mention?.success) {
|
||||
mentionListeners.forEach((listener) => listener(mention.data))
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof message.id !== "number") return
|
||||
|
||||
const method = pending.get(message.id)
|
||||
if (!method) return
|
||||
|
||||
pending.delete(message.id)
|
||||
if (message.error) return
|
||||
|
||||
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
|
||||
if (initialize?.success) {
|
||||
setStore("server", initialize.data)
|
||||
send({ method: "notifications/initialized" })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
current.addEventListener("close", () => {
|
||||
if (socket !== current) return
|
||||
|
||||
socket = undefined
|
||||
pending.clear()
|
||||
if (closed) return
|
||||
|
||||
setStore("status", "connecting")
|
||||
scheduleReconnect()
|
||||
})
|
||||
directory = resolved
|
||||
attempt = 0
|
||||
pending.clear()
|
||||
lastZedSelectionKey = undefined
|
||||
if (reconnect) clearTimeout(reconnect)
|
||||
reconnect = undefined
|
||||
if (socket) {
|
||||
const current = socket
|
||||
socket = undefined
|
||||
current.close()
|
||||
}
|
||||
setStore("status", "disabled")
|
||||
setStore("selection", undefined)
|
||||
setStore("server", undefined)
|
||||
connect()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
connect()
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -230,7 +283,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
|
||||
return {
|
||||
enabled() {
|
||||
return Boolean(resolveEditorConnection() || resolveZedDbPath())
|
||||
return Boolean(resolveEditorConnection(directory) || resolveZedDbPath())
|
||||
},
|
||||
connected() {
|
||||
return store.status === "connected"
|
||||
@@ -239,6 +292,7 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
return store.selection
|
||||
},
|
||||
clearSelection() {
|
||||
lastZedSelectionKey = undefined
|
||||
setStore("selection", undefined)
|
||||
},
|
||||
onMention(listener: (mention: EditorMention) => void) {
|
||||
@@ -248,6 +302,10 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
server() {
|
||||
return store.server
|
||||
},
|
||||
reconnect(directory?: string) {
|
||||
setStore("selection", undefined)
|
||||
reconnectWithDirectory(directory)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -260,8 +318,16 @@ function parsePort(value: string | undefined) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function resolveEditorConnection(): EditorConnection | undefined {
|
||||
const lock = resolveEditorLockFile()
|
||||
function resolveEditorConnection(directory: string): EditorConnection | undefined {
|
||||
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.KILO_EDITOR_SSE_PORT)
|
||||
if (port) {
|
||||
return {
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
source: `env:${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
const lock = resolveEditorLockFile(directory)
|
||||
if (lock) {
|
||||
return {
|
||||
url: `ws://127.0.0.1:${lock.port}`,
|
||||
@@ -269,16 +335,9 @@ function resolveEditorConnection(): EditorConnection | undefined {
|
||||
source: `lock:${lock.port}`,
|
||||
}
|
||||
}
|
||||
|
||||
const port = parsePort(process.env.CLAUDE_CODE_SSE_PORT || process.env.KILO_EDITOR_SSE_PORT)
|
||||
if (!port) return
|
||||
return {
|
||||
url: `ws://127.0.0.1:${port}`,
|
||||
source: `env:${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEditorLockFile() {
|
||||
function resolveEditorLockFile(activeDirectory: string) {
|
||||
const directory = path.join(os.homedir(), ".claude", "ide")
|
||||
let entries: string[]
|
||||
|
||||
@@ -288,10 +347,9 @@ function resolveEditorLockFile() {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = process.cwd()
|
||||
// longest workspace folder that contains cwd; 0 if none match
|
||||
// longest workspace folder that contains the active session directory; 0 if none match
|
||||
const bestMatchLength = (lock: EditorLockFile) =>
|
||||
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, cwd)))
|
||||
Math.max(0, ...lock.workspaceFolders.map((folder) => pathContainsLength(folder, activeDirectory)))
|
||||
const locks = entries
|
||||
.filter((entry) => entry.endsWith(".lock"))
|
||||
.map((entry) => readEditorLockFile(path.join(directory, entry)))
|
||||
@@ -325,15 +383,17 @@ function readEditorLockFile(filePath: string): EditorLockFile | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function editorSelectionKey(selection: EditorSelection | undefined) {
|
||||
export function editorSelectionKey(selection: EditorSelection | undefined) {
|
||||
if (!selection) return ""
|
||||
return [
|
||||
selection.filePath,
|
||||
selection.selection.start.line,
|
||||
selection.selection.start.character,
|
||||
selection.selection.end.line,
|
||||
selection.selection.end.character,
|
||||
selection.text,
|
||||
...selection.ranges.flatMap((range) => [
|
||||
range.selection.start.line,
|
||||
range.selection.start.character,
|
||||
range.selection.end.line,
|
||||
range.selection.end.character,
|
||||
range.text,
|
||||
]),
|
||||
].join("\0")
|
||||
}
|
||||
|
||||
@@ -343,10 +403,10 @@ function pathContainsLength(parent: string, child: string) {
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ? resolved.length : 0
|
||||
}
|
||||
|
||||
function openEditorSocket(connection: EditorConnection) {
|
||||
if (!connection.authToken) return new WebSocket(connection.url)
|
||||
function openEditorSocket(connection: EditorConnection, WebSocketImpl: typeof WebSocket) {
|
||||
if (!connection.authToken) return new WebSocketImpl(connection.url)
|
||||
|
||||
return new WebSocket(connection.url, {
|
||||
return new WebSocketImpl(connection.url, {
|
||||
headers: {
|
||||
"x-claude-code-ide-authorization": connection.authToken,
|
||||
},
|
||||
|
||||
@@ -188,9 +188,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
favorite: modelStore.favorite,
|
||||
variant: modelStore.variant,
|
||||
}
|
||||
state.writer = state.writer
|
||||
.then(() => Filesystem.writeJson(filePath, data))
|
||||
.catch(() => {})
|
||||
state.writer = state.writer.then(() => Filesystem.writeJson(filePath, data)).catch(() => {})
|
||||
// kilocode_change end
|
||||
}
|
||||
|
||||
@@ -272,6 +270,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return modelStore.model[name]
|
||||
},
|
||||
// kilocode_change end
|
||||
// kilocode_change start - resolve once all queued writes (atomic write+rename) have settled.
|
||||
// Used by tests to deterministically await the writer chain instead of sleeping for a fixed
|
||||
// duration, which is too slow on Windows CI where temp-file rename can exceed 50ms under AV.
|
||||
async flush() {
|
||||
const deadline = Date.now() + 5000
|
||||
while (state.pending && Date.now() < deadline) await new Promise((r) => setTimeout(r, 0))
|
||||
await state.writer
|
||||
},
|
||||
// kilocode_change end
|
||||
recent() {
|
||||
return modelStore.recent
|
||||
},
|
||||
|
||||
@@ -36,6 +36,8 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
|
||||
import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode_change
|
||||
import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change
|
||||
import path from "path"
|
||||
import { useKV } from "./kv"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -128,6 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const project = useProject()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast() // kilocode_change
|
||||
const kv = useKV()
|
||||
|
||||
// kilocode_change start
|
||||
function evict(sessionID: string) {
|
||||
@@ -164,6 +167,22 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const fullSyncedSessions = new Set<string>()
|
||||
let syncedWorkspace = project.workspace.current()
|
||||
|
||||
function sessionListQuery(): { scope?: "project"; path?: string } {
|
||||
if (!kv.get("session_directory_filter_enabled", true)) return { scope: "project" }
|
||||
if (!project.data.instance.path.worktree || !project.data.instance.path.directory) return { scope: "project" }
|
||||
return {
|
||||
path: path
|
||||
.relative(path.resolve(project.data.instance.path.worktree), project.data.instance.path.directory)
|
||||
.replaceAll("\\", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
function listSessions() {
|
||||
return sdk.client.session
|
||||
.list({ start: Date.now() - 30 * 24 * 60 * 60 * 1000, ...sessionListQuery() })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
}
|
||||
|
||||
event.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed":
|
||||
@@ -491,10 +510,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.clear()
|
||||
syncedWorkspace = workspace
|
||||
}
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const sessionListPromise = sdk.client.session
|
||||
.list({ start: start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
const projectPromise = project.sync()
|
||||
const sessionListPromise = projectPromise.then(() => listSessions())
|
||||
|
||||
// blocking - include session.list when continuing a session
|
||||
const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true })
|
||||
@@ -505,7 +522,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
.catch(() => emptyConsoleState)
|
||||
const agentsPromise = sdk.client.app.agents({ workspace }, { throwOnError: true })
|
||||
const configPromise = sdk.client.config.get({ workspace }, { throwOnError: true })
|
||||
const projectPromise = project.sync()
|
||||
const blockingRequests: Promise<unknown>[] = [
|
||||
providersPromise,
|
||||
providerListPromise,
|
||||
@@ -652,11 +668,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
query() {
|
||||
return sessionListQuery()
|
||||
},
|
||||
async refresh() {
|
||||
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||
const list = await sdk.client.session
|
||||
.list({ start })
|
||||
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
|
||||
const list = await listSessions()
|
||||
setStore("session", reconcile(list))
|
||||
},
|
||||
status(sessionID: string) {
|
||||
|
||||
@@ -430,12 +430,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
// kilocode_change start - safe fallback to kilo import if store lookup fails
|
||||
const values = createMemo(() => {
|
||||
const active = store.themes[store.active]
|
||||
if (active) return resolveTheme(active, store.mode)
|
||||
if (active) {
|
||||
return resolveTheme(active, store.mode)
|
||||
}
|
||||
|
||||
const saved = kv.get("theme")
|
||||
if (typeof saved === "string") {
|
||||
const theme = store.themes[saved]
|
||||
if (theme) return resolveTheme(theme, store.mode)
|
||||
if (theme) {
|
||||
return resolveTheme(theme, store.mode)
|
||||
}
|
||||
}
|
||||
|
||||
return resolveTheme(store.themes.kilo, store.mode) // kilocode_change
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { PositiveInt } from "@/util/schema"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
const DEFAULT_TOAST_DURATION = 5000
|
||||
@@ -38,7 +39,7 @@ export const TuiEvent = {
|
||||
title: Schema.optional(Schema.String),
|
||||
message: Schema.String,
|
||||
variant: Schema.Literals(["info", "success", "warning", "error"]),
|
||||
duration: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
|
||||
description: "Duration in milliseconds",
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -38,7 +38,6 @@ import type { GrepTool } from "@/tool/grep"
|
||||
import type { EditTool } from "@/tool/edit"
|
||||
import type { ApplyPatchTool } from "@/tool/apply_patch"
|
||||
import type { WebFetchTool } from "@/tool/webfetch"
|
||||
import type { CodeSearchTool } from "@/tool/codesearch"
|
||||
import type { WebSearchTool } from "@/tool/websearch"
|
||||
import type { TaskTool } from "@/tool/task"
|
||||
import type { QuestionTool } from "@/tool/question"
|
||||
@@ -46,6 +45,7 @@ import type { SkillTool } from "@/tool/skill"
|
||||
import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useEditorContext } from "@tui/context/editor"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
@@ -86,6 +86,7 @@ import { useTuiConfig } from "../../context/tui-config"
|
||||
import { formatMarkdownTables } from "../../util/markdown" // kilocode_change
|
||||
import { bell } from "@/kilocode/bell" // kilocode_change
|
||||
import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change
|
||||
import { submitFeedback } from "@/kilocode/cli/cmd/tui/feedback" // kilocode_change
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
||||
@@ -263,6 +264,7 @@ export function Session() {
|
||||
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
|
||||
const toast = useToast()
|
||||
const sdk = useSDK()
|
||||
const editor = useEditorContext()
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
@@ -290,6 +292,7 @@ export function Session() {
|
||||
await sync.bootstrap({ fatal: false })
|
||||
} catch {}
|
||||
}
|
||||
editor.reconnect(result.data.directory)
|
||||
await sync.session.sync(sessionID)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
@@ -947,6 +950,22 @@ export function Session() {
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
// kilocode_change start - message feedback
|
||||
{
|
||||
title: "Rate last assistant message helpful",
|
||||
value: "messages.feedback.up",
|
||||
keybind: "messages_feedback_up",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => submitFeedback("up", dialog, { toast, session, messages }),
|
||||
},
|
||||
{
|
||||
title: "Rate last assistant message not helpful",
|
||||
value: "messages.feedback.down",
|
||||
keybind: "messages_feedback_down",
|
||||
category: "Session",
|
||||
onSelect: (dialog) => submitFeedback("down", dialog, { toast, session, messages }),
|
||||
},
|
||||
// kilocode_change end
|
||||
{
|
||||
title: "Copy session transcript",
|
||||
value: "session.copy",
|
||||
@@ -1698,9 +1717,6 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
|
||||
<Match when={props.part.tool === "webfetch"}>
|
||||
<WebFetch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "codesearch"}>
|
||||
<CodeSearch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={props.part.tool === "websearch"}>
|
||||
<WebSearch {...toolprops} />
|
||||
</Match>
|
||||
@@ -2097,15 +2113,6 @@ function WebFetch(props: ToolProps<typeof WebFetchTool>) {
|
||||
)
|
||||
}
|
||||
|
||||
function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
|
||||
const metadata = props.metadata as { results?: number }
|
||||
return (
|
||||
<InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
|
||||
Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
|
||||
</InlineTool>
|
||||
)
|
||||
}
|
||||
|
||||
function WebSearch(props: ToolProps<typeof WebSearchTool>) {
|
||||
const metadata = props.metadata as { numResults?: number }
|
||||
return (
|
||||
|
||||
@@ -354,21 +354,6 @@ export function PermissionPrompt(props: { request: PermissionRequest }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "codesearch") {
|
||||
const query = typeof data.query === "string" ? data.query : ""
|
||||
return {
|
||||
icon: "◇",
|
||||
title: `Exa Code Search "${query}"`,
|
||||
body: (
|
||||
<Show when={query}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{"Query: " + query}</text>
|
||||
</box>
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
if (permission === "external_directory") {
|
||||
const meta = props.request.metadata ?? {}
|
||||
const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined
|
||||
|
||||
@@ -74,6 +74,12 @@ async function input(value?: string) {
|
||||
return piped + "\n" + value
|
||||
}
|
||||
|
||||
export function resolveThreadDirectory(project?: string, envPWD = process.env.PWD, cwd = process.cwd()) {
|
||||
const root = Filesystem.resolve(envPWD ?? cwd)
|
||||
if (project) return Filesystem.resolve(path.isAbsolute(project) ? project : path.join(root, project))
|
||||
return Filesystem.resolve(cwd)
|
||||
}
|
||||
|
||||
export const TuiThreadCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start kilo tui", // kilocode_change
|
||||
@@ -143,10 +149,7 @@ export const TuiThreadCommand = cmd({
|
||||
|
||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const next = args.project
|
||||
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
|
||||
: Filesystem.resolve(process.cwd())
|
||||
const next = resolveThreadDirectory(args.project)
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(next)
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface DialogSelectOption<T = any> {
|
||||
categoryView?: JSX.Element
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
gutter?: JSX.Element
|
||||
gutter?: () => JSX.Element
|
||||
margin?: JSX.Element
|
||||
onSelect?: (ctx: DialogContext) => void
|
||||
}
|
||||
@@ -407,7 +407,7 @@ function Option(props: {
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: JSX.Element | string
|
||||
gutter?: JSX.Element
|
||||
gutter?: () => JSX.Element
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
@@ -422,7 +422,7 @@ function Option(props: {
|
||||
</Show>
|
||||
<Show when={!props.current && props.gutter}>
|
||||
<box flexShrink={0} marginRight={0}>
|
||||
{props.gutter}
|
||||
{props.gutter?.()}
|
||||
</box>
|
||||
</Show>
|
||||
<text
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
@@ -10,7 +10,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { Heap } from "@/cli/heap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AppRuntime, getBootstrapRunEffect } from "@/effect/app-runtime"
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||
|
||||
ensureProcessMetadata("worker")
|
||||
@@ -77,7 +77,7 @@ export const rpc = {
|
||||
async checkUpgrade(input: { directory: string }) {
|
||||
await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
init: await getBootstrapRunEffect(),
|
||||
fn: async () => {
|
||||
await upgrade().catch(() => {})
|
||||
},
|
||||
@@ -89,7 +89,7 @@ export const rpc = {
|
||||
async shutdown() {
|
||||
Log.Default.info("worker shutting down")
|
||||
|
||||
await Instance.disposeAll()
|
||||
await InstanceStore.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
// kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and
|
||||
// exit naturally. Without this, the active onmessage handle keeps the
|
||||
|
||||
@@ -3,7 +3,7 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../project/instance" // kilocode_change
|
||||
import { InstanceStore } from "../../project/instance-store" // kilocode_change
|
||||
import open from "open"
|
||||
import { networkInterfaces } from "os"
|
||||
|
||||
@@ -80,7 +80,7 @@ export const WebCommand = cmd({
|
||||
const abort = new AbortController()
|
||||
const shutdown = async () => {
|
||||
try {
|
||||
await Instance.disposeAll()
|
||||
await InstanceStore.disposeAllInstances()
|
||||
await server.stop(true)
|
||||
} finally {
|
||||
abort.abort()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { AppRuntime, type AppServices } from "@/effect/app-runtime"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { cmd } from "./cmd/cmd"
|
||||
|
||||
/**
|
||||
* User-visible command failure. Throw via `fail("...")` from an effectCmd handler
|
||||
* to surface a printed message + non-zero exit. Recognised by the global error
|
||||
* formatter in `src/cli/error.ts` (FormatError), so the existing top-level
|
||||
* catch + cleanup in `src/index.ts` runs normally.
|
||||
*/
|
||||
export class CliError extends Schema.TaggedErrorClass<CliError>()("CliError", {
|
||||
message: Schema.String,
|
||||
exitCode: Schema.optional(Schema.Number),
|
||||
}) {}
|
||||
|
||||
export const fail = (message: string, exitCode = 1) => Effect.fail(new CliError({ message, exitCode }))
|
||||
|
||||
/**
|
||||
* Effect-native CLI command builder. Wraps yargs `cmd()` so the handler body is
|
||||
* an `Effect` with `InstanceRef` provided and any `AppServices` yieldable.
|
||||
*
|
||||
* Errors propagate to the existing top-level handler in `src/index.ts`; use
|
||||
* `fail("...")` for user-visible domain failures (clean exit, formatted message).
|
||||
*
|
||||
* Handlers are typically `Effect.fn("Cli.<name>")(function*(args) { ... })`,
|
||||
* which adds a named tracing span per CLI invocation. Once all commands use
|
||||
* `effectCmd`, swapping the underlying `cmd()` factory for effect/cli's
|
||||
* `Command.make(...)` won't touch any handler bodies.
|
||||
*/
|
||||
export const effectCmd = <Args, A>(opts: {
|
||||
command: string | readonly string[]
|
||||
describe: string | false
|
||||
builder?: (yargs: Argv) => Argv<Args>
|
||||
/** Defaults to process.cwd(). Override for commands that take a directory positional. */
|
||||
directory?: (args: Args) => string
|
||||
handler: (args: Args) => Effect.Effect<A, CliError, AppServices | InstanceStore.Service>
|
||||
}) =>
|
||||
cmd<{}, Args>({
|
||||
command: opts.command,
|
||||
describe: opts.describe,
|
||||
builder: opts.builder as never,
|
||||
async handler(rawArgs) {
|
||||
// yargs typing wraps Args in ArgumentsCamelCase<WithDoubleDash<...>>; cast at the boundary.
|
||||
const args = rawArgs as unknown as Args
|
||||
const directory = opts.directory?.(args) ?? process.cwd()
|
||||
await AppRuntime.runPromise(InstanceStore.Service.use((s) => s.provide({ directory }, opts.handler(args))))
|
||||
},
|
||||
})
|
||||
@@ -15,6 +15,13 @@ function isTaggedError(error: unknown, tag: string): boolean {
|
||||
}
|
||||
|
||||
export function FormatError(input: unknown) {
|
||||
// CliError: domain failure surfaced from an effectCmd handler via fail("...")
|
||||
if (isTaggedError(input, "CliError")) {
|
||||
const data = input as ErrorLike & { exitCode?: number }
|
||||
if (data.exitCode != null) process.exitCode = data.exitCode
|
||||
return data.message ?? ""
|
||||
}
|
||||
|
||||
// MCPFailed: { name: string }
|
||||
if (NamedError.hasName(input, "MCPFailed")) {
|
||||
return `MCP server "${(input as ErrorLike).data?.name}" failed. Note, opencode does not support MCP authentication yet.`
|
||||
|
||||
@@ -31,8 +31,8 @@ const AgentSchema = Schema.StructWithRest(
|
||||
variant: Schema.optional(Schema.String).annotate({
|
||||
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||
}),
|
||||
temperature: Schema.optional(Schema.NullOr(Schema.Number)), // kilocode_change - nullable for delete sentinel
|
||||
top_p: Schema.optional(Schema.NullOr(Schema.Number)), // kilocode_change - nullable for delete sentinel
|
||||
temperature: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
|
||||
top_p: Schema.optional(Schema.NullOr(Schema.Finite)), // kilocode_change - nullable for delete sentinel
|
||||
prompt: Schema.optional(Schema.NullOr(Schema.String)), // kilocode_change - nullable for delete sentinel
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||
description: "@deprecated Use 'permission' field instead",
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { mergeDeep, pipe } from "remeda"
|
||||
import { mergeDeep } from "remeda"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import fsNode from "fs/promises"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
@@ -11,7 +11,8 @@ import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, findNodeAtLocation, modify, parseTree } from "jsonc-parser" // kilocode_change - parseTree/findNodeAtLocation used in patchJsonc
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { type InstanceContext } from "../project/instance"
|
||||
import { InstanceStore } from "../project/instance-store"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { existsSync } from "fs"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
@@ -23,7 +24,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
|
||||
import { ConfigAgent } from "./agent"
|
||||
@@ -55,8 +56,13 @@ import { unique } from "remeda"
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
// Custom merge function that concatenates array fields instead of replacing them
|
||||
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
|
||||
function mergeConfig(target: Info, source: Info): Info {
|
||||
return mergeDeep(target, source) as Info
|
||||
}
|
||||
|
||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
const merged = mergeDeep(target, source)
|
||||
const merged = mergeConfig(target, source)
|
||||
if (target.instructions && source.instructions) {
|
||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||
}
|
||||
@@ -83,7 +89,7 @@ export const Warning = z.object({
|
||||
})
|
||||
export type Warning = z.infer<typeof Warning>
|
||||
|
||||
const { toWarning, caught: caughtWarning, handleInvalid } = KilocodeConfig
|
||||
const { caught: caughtWarning } = KilocodeConfig
|
||||
// kilocode_change end
|
||||
|
||||
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||
@@ -225,7 +231,8 @@ export const Info = Schema.Struct({
|
||||
[Schema.Record(Schema.String, ConfigAgent.Info)],
|
||||
),
|
||||
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
|
||||
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
|
||||
provider: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(ConfigProvider.Info))).annotate({
|
||||
// kilocode_change - nullable for delete sentinel
|
||||
description: "Custom provider configurations and model overrides",
|
||||
}),
|
||||
mcp: Schema.optional(
|
||||
@@ -474,16 +481,14 @@ export const layer = Layer.effect(
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) // kilocode_change
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
// kilocode_change start
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "kilo.jsonc"))),
|
||||
// kilocode_change end
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
let result: Info = {}
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||
// kilocode_change start
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "kilo.jsonc")))
|
||||
// kilocode_change end
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
@@ -493,7 +498,7 @@ export const layer = Layer.effect(
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://app.kilo.ai/config.json" // kilocode_change
|
||||
result = mergeDeep(result, rest)
|
||||
result = mergeConfig(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
@@ -575,7 +580,7 @@ export const layer = Layer.effect(
|
||||
const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
|
||||
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
|
||||
if (source === "KILO_CONFIG_CONTENT") return "local"
|
||||
if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
|
||||
if (containsPath(source, ctx)) return "local"
|
||||
return "global"
|
||||
})
|
||||
|
||||
@@ -600,10 +605,14 @@ export const layer = Layer.effect(
|
||||
result.plugin_origins = plugins
|
||||
})
|
||||
|
||||
const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => {
|
||||
result = mergeConfigConcatArrays(result, next)
|
||||
return mergePluginOrigins(source, next.plugin, kind)
|
||||
}
|
||||
// kilocode_change start
|
||||
const merge = Effect.fnUntraced(function* (source: string, next: Info, kind?: ConfigPlugin.Scope) {
|
||||
const scope = kind ?? (yield* pluginScopeForSource(source))
|
||||
const scoped = KilocodeConfig.scopeIndexing(next, scope)
|
||||
result = mergeConfigConcatArrays(result, scoped)
|
||||
return yield* mergePluginOrigins(source, scoped.plugin, scope)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
@@ -836,16 +845,19 @@ export const layer = Layer.effect(
|
||||
// kilocode_change end
|
||||
|
||||
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
|
||||
// kilocode_change start
|
||||
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
|
||||
if (managed) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* merge(
|
||||
managed.source,
|
||||
yield* loadConfig(managed.text, {
|
||||
dir: path.dirname(managed.source),
|
||||
source: managed.source,
|
||||
}),
|
||||
"global",
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
@@ -948,12 +960,18 @@ export const layer = Layer.effect(
|
||||
writable,
|
||||
})
|
||||
// kilocode_change end
|
||||
if (options?.dispose !== false) yield* Effect.promise(() => Instance.dispose())
|
||||
if (options?.dispose !== false) {
|
||||
// Fail loudly if no instance is bound — silently skipping would
|
||||
// mask "config update without an active instance" bugs. The throw
|
||||
// comes from `Instance.current` inside `InstanceState.context`.
|
||||
const ctx = yield* InstanceState.context
|
||||
yield* Effect.promise(() => InstanceStore.disposeInstance(ctx))
|
||||
}
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
const task = InstanceStore.disposeAllInstances()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
@@ -977,16 +995,20 @@ export const layer = Layer.effect(
|
||||
const patch = writableGlobal(config)
|
||||
|
||||
let next: Info
|
||||
let changed: boolean
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
|
||||
// kilocode_change - use `patch` (writableGlobal) so empty-string sentinels are stripped via undefined
|
||||
const merged = KilocodeConfig.mergeConfig(writable(existing), patch)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
const serialized = JSON.stringify(merged, null, 2)
|
||||
changed = serialized !== before
|
||||
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, patch)
|
||||
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
changed = updated !== before
|
||||
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
// kilocode_change start - skip dispose when caller opts out
|
||||
@@ -1006,7 +1028,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
yield* invalidate()
|
||||
// Only tear down running instances if the config actually changed.
|
||||
if (changed) yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { NonNegativeInt } from "@/util/schema"
|
||||
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
switchableOrgCount: NonNegativeInt,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,10 @@ const KeybindsSchema = Schema.Struct({
|
||||
messages_copy: keybind("<leader>y", "Copy message"),
|
||||
messages_undo: keybind("<leader>u", "Undo message"),
|
||||
messages_redo: keybind("<leader>r", "Redo message"),
|
||||
// kilocode_change start - message feedback
|
||||
messages_feedback_up: keybind("<leader>=", "Rate last assistant message helpful"),
|
||||
messages_feedback_down: keybind("<leader>-", "Rate last assistant message not helpful"),
|
||||
// kilocode_change end
|
||||
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||
tool_details: keybind("none", "Toggle tool details visibility"),
|
||||
model_list: keybind("<leader>m", "List available models"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { PositiveInt, withStatics } from "@/util/schema"
|
||||
|
||||
export const Local = Schema.Struct({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
@@ -13,7 +13,7 @@ export const Local = Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
})
|
||||
@@ -49,7 +49,7 @@ export const Remote = Schema.Struct({
|
||||
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
|
||||
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
timeout: Schema.optional(PositiveInt).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -35,7 +35,6 @@ const InputObject = Schema.StructWithRest(
|
||||
question: Schema.optional(Action),
|
||||
webfetch: Schema.optional(Action),
|
||||
websearch: Schema.optional(Action),
|
||||
codesearch: Schema.optional(Action),
|
||||
lsp: Schema.optional(Rule),
|
||||
doom_loop: Schema.optional(Action),
|
||||
skill: Schema.optional(Rule),
|
||||
|
||||
@@ -23,25 +23,25 @@ export const Model = Schema.Struct({
|
||||
),
|
||||
cost: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Number,
|
||||
output: Schema.Number,
|
||||
cache_read: Schema.optional(Schema.Number),
|
||||
cache_write: Schema.optional(Schema.Number),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
limit: Schema.optional(
|
||||
Schema.Struct({
|
||||
context: Schema.Number,
|
||||
input: Schema.optional(Schema.Number),
|
||||
output: Schema.Number,
|
||||
context: Schema.Finite,
|
||||
input: Schema.optional(Schema.Finite),
|
||||
output: Schema.Finite,
|
||||
}),
|
||||
),
|
||||
modalities: Schema.optional(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
|
||||
import { WorktreeAdapter } from "./worktree"
|
||||
|
||||
const BUILTIN: Record<string, WorkspaceAdapter> = {
|
||||
worktree: WorktreeAdapter,
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
|
||||
|
||||
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin
|
||||
|
||||
throw new Error(`Unknown workspace adapter: ${type}`)
|
||||
}
|
||||
|
||||
export async function listAdapters(projectID: ProjectID): Promise<WorkspaceAdapterEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, adapter]) => {
|
||||
return {
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adapter]) => ({
|
||||
type,
|
||||
name: adapter.name,
|
||||
description: adapter.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
|
||||
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
|
||||
adapters.set(type, adapter)
|
||||
state.set(projectID, adapters)
|
||||
}
|
||||
+14
-8
@@ -1,7 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { type WorkspaceAdaptor, WorkspaceInfo } from "../types"
|
||||
import { type WorkspaceAdapter, WorkspaceInfo } from "../types"
|
||||
|
||||
const WorktreeConfig = Schema.Struct({
|
||||
name: WorkspaceInfo.fields.name,
|
||||
@@ -10,19 +8,26 @@ const WorktreeConfig = Schema.Struct({
|
||||
})
|
||||
const decodeWorktreeConfig = Schema.decodeUnknownSync(WorktreeConfig)
|
||||
|
||||
export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
async function loadWorktree() {
|
||||
const [{ AppRuntime }, { Worktree }] = await Promise.all([import("@/effect/app-runtime"), import("@/worktree")])
|
||||
return { AppRuntime, Worktree }
|
||||
}
|
||||
|
||||
export const WorktreeAdapter: WorkspaceAdapter = {
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
async configure(info) {
|
||||
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const next = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.makeWorktreeInfo()))
|
||||
return {
|
||||
...info,
|
||||
name: worktree.name,
|
||||
branch: worktree.branch,
|
||||
directory: worktree.directory,
|
||||
name: next.name,
|
||||
branch: next.branch,
|
||||
directory: next.directory,
|
||||
}
|
||||
},
|
||||
async create(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(
|
||||
Worktree.Service.use((svc) =>
|
||||
@@ -35,6 +40,7 @@ export const WorktreeAdaptor: WorkspaceAdaptor = {
|
||||
)
|
||||
},
|
||||
async remove(info) {
|
||||
const { AppRuntime, Worktree } = await loadWorktree()
|
||||
const config = decodeWorktreeConfig(info)
|
||||
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove({ directory: config.directory })))
|
||||
},
|
||||
@@ -1,46 +0,0 @@
|
||||
import { lazy } from "@/util/lazy"
|
||||
import type { ProjectID } from "@/project/schema"
|
||||
import type { WorkspaceAdaptor, WorkspaceAdaptorEntry } from "../types"
|
||||
|
||||
const BUILTIN: Record<string, () => Promise<WorkspaceAdaptor>> = {
|
||||
worktree: lazy(async () => (await import("./worktree")).WorktreeAdaptor),
|
||||
}
|
||||
|
||||
const state = new Map<ProjectID, Map<string, WorkspaceAdaptor>>()
|
||||
|
||||
export async function getAdaptor(projectID: ProjectID, type: string): Promise<WorkspaceAdaptor> {
|
||||
const custom = state.get(projectID)?.get(type)
|
||||
if (custom) return custom
|
||||
|
||||
const builtin = BUILTIN[type]
|
||||
if (builtin) return builtin()
|
||||
|
||||
throw new Error(`Unknown workspace adaptor: ${type}`)
|
||||
}
|
||||
|
||||
export async function listAdaptors(projectID: ProjectID): Promise<WorkspaceAdaptorEntry[]> {
|
||||
const builtin = await Promise.all(
|
||||
Object.entries(BUILTIN).map(async ([type, init]) => {
|
||||
const adaptor = await init()
|
||||
return {
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const custom = [...(state.get(projectID)?.entries() ?? [])].map(([type, adaptor]) => ({
|
||||
type,
|
||||
name: adaptor.name,
|
||||
description: adaptor.description,
|
||||
}))
|
||||
return [...builtin, ...custom]
|
||||
}
|
||||
|
||||
// Plugins can be loaded per-project so we need to scope them. If you
|
||||
// want to install a global one pass `ProjectID.global`
|
||||
export function registerAdaptor(projectID: ProjectID, type: string, adaptor: WorkspaceAdaptor) {
|
||||
const adaptors = state.get(projectID) ?? new Map<string, WorkspaceAdaptor>()
|
||||
adaptors.set(type, adaptor)
|
||||
state.set(projectID, adaptors)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
This is a plugin to simulate a remote environment locally. Add this to `.opencode/opencode.jsonc`:
|
||||
|
||||
```json
|
||||
"plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"],
|
||||
```
|
||||
|
||||
In a separate terminal, run a separate OpenCode server. This will act like a remote server and the local instance will proxy all requests to it:
|
||||
|
||||
```
|
||||
./packages/opencode/script/run-workspace-server
|
||||
```
|
||||
|
||||
With the plugin install, you can now run OpenCode and create a `debug` workspace type. This will create a "remote" workspace which talks to the second workspace server started above.
|
||||
|
||||
How this works:
|
||||
|
||||
- The workspace server needs to know the workspace id and port to run. It waits for this information to be written to a file and starts the server when the data is written.
|
||||
- The debug plugin writes this information in the `create` call to the workspace. So create a `debug` workspace will always kick off a new external server.
|
||||
- The server script watches for file changes, so whenver you create a new `debug` workspace it will restart with the new information. This means that there is only ever one working `debug` workspace at a time; when you create a new one all previous sessions will show that it can't connect because previous debug workspaces do not exist.
|
||||
@@ -1,66 +0,0 @@
|
||||
export async function parseSSE(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal: AbortSignal,
|
||||
onEvent: (event: unknown) => void,
|
||||
) {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ""
|
||||
let last = ""
|
||||
let retry = 1000
|
||||
|
||||
const abort = () => {
|
||||
void reader.cancel().catch(() => undefined)
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", abort)
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
const chunk = await reader.read().catch(() => ({ done: true, value: undefined as Uint8Array | undefined }))
|
||||
if (chunk.done) break
|
||||
|
||||
buf += decoder.decode(chunk.value, { stream: true })
|
||||
buf = buf.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
|
||||
const chunks = buf.split("\n\n")
|
||||
buf = chunks.pop() ?? ""
|
||||
|
||||
chunks.forEach((chunk) => {
|
||||
const data: string[] = []
|
||||
chunk.split("\n").forEach((line) => {
|
||||
if (line.startsWith("data:")) {
|
||||
data.push(line.replace(/^data:\s*/, ""))
|
||||
return
|
||||
}
|
||||
if (line.startsWith("id:")) {
|
||||
last = line.replace(/^id:\s*/, "")
|
||||
return
|
||||
}
|
||||
if (line.startsWith("retry:")) {
|
||||
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
|
||||
if (!Number.isNaN(parsed)) retry = parsed
|
||||
}
|
||||
})
|
||||
|
||||
if (!data.length) return
|
||||
const raw = data.join("\n")
|
||||
try {
|
||||
onEvent(JSON.parse(raw))
|
||||
} catch {
|
||||
onEvent({
|
||||
type: "sse.message",
|
||||
properties: {
|
||||
data: raw,
|
||||
id: last || undefined,
|
||||
retry,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort)
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,12 @@ export const WorkspaceInfo = Schema.Struct({
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
|
||||
|
||||
export const WorkspaceAdaptorEntry = Schema.Struct({
|
||||
export const WorkspaceAdapterEntry = Schema.Struct({
|
||||
type: Schema.String,
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type WorkspaceAdaptorEntry = Schema.Schema.Type<typeof WorkspaceAdaptorEntry>
|
||||
export type WorkspaceAdapterEntry = Schema.Schema.Type<typeof WorkspaceAdapterEntry>
|
||||
|
||||
export type Target =
|
||||
| {
|
||||
@@ -35,7 +35,7 @@ export type Target =
|
||||
headers?: HeadersInit
|
||||
}
|
||||
|
||||
export type WorkspaceAdaptor = {
|
||||
export type WorkspaceAdapter = {
|
||||
name: string
|
||||
description: string
|
||||
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (event: GlobalEvent) => boolean }) {
|
||||
if (input.signal?.aborted) return Promise.reject(input.signal.reason ?? new Error("Request aborted"))
|
||||
if (input.signal?.aborted) return Effect.fail(input.signal.reason ?? new Error("Request aborted"))
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
return Effect.callback<void, unknown>((resume) => {
|
||||
const abort = () => {
|
||||
cleanup()
|
||||
reject(input.signal?.reason ?? new Error("Request aborted"))
|
||||
resume(Effect.fail(input.signal?.reason ?? new Error("Request aborted")))
|
||||
}
|
||||
|
||||
const handler = (event: GlobalEvent) => {
|
||||
try {
|
||||
if (!input.fn(event)) return
|
||||
cleanup()
|
||||
resolve()
|
||||
resume(Effect.void)
|
||||
} catch (error) {
|
||||
cleanup()
|
||||
reject(error)
|
||||
resume(Effect.fail(error))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +29,11 @@ export function waitEvent(input: { timeout: number; signal?: AbortSignal; fn: (e
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error("Timed out waiting for global event"))
|
||||
resume(Effect.fail(new Error("Timed out waiting for global event")))
|
||||
}, input.timeout)
|
||||
|
||||
GlobalBus.on("event", handler)
|
||||
input.signal?.addEventListener("abort", abort, { once: true })
|
||||
return Effect.sync(cleanup)
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { attach } from "./run-service"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FileWatcher } from "@/file/watcher"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { Agent } from "@/agent/agent"
|
||||
@@ -39,13 +40,17 @@ import { Command } from "@/command"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Format } from "@/format"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Pty } from "@/pty"
|
||||
import { Installation } from "@/installation"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
||||
|
||||
@@ -63,6 +68,7 @@ export const AppLayer = Layer.mergeAll(
|
||||
Storage.defaultLayer,
|
||||
Snapshot.defaultLayer,
|
||||
Plugin.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
ProviderAuth.defaultLayer,
|
||||
Agent.defaultLayer,
|
||||
@@ -88,17 +94,24 @@ export const AppLayer = Layer.mergeAll(
|
||||
Truncate.defaultLayer,
|
||||
ToolRegistry.defaultLayer,
|
||||
Format.defaultLayer,
|
||||
InstanceBootstrap.defaultLayer,
|
||||
InstanceStore.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
Vcs.defaultLayer,
|
||||
Workspace.defaultLayer,
|
||||
Worktree.defaultLayer,
|
||||
Pty.defaultLayer,
|
||||
Installation.defaultLayer,
|
||||
ShareNext.defaultLayer,
|
||||
SessionShare.defaultLayer,
|
||||
SyncEvent.defaultLayer,
|
||||
).pipe(Layer.provideMerge(Observability.layer))
|
||||
|
||||
const rt = ManagedRuntime.make(AppLayer, { memoMap })
|
||||
type Runtime = Pick<typeof rt, "runSync" | "runPromise" | "runPromiseExit" | "runFork" | "runCallback" | "dispose">
|
||||
|
||||
/** Services provided by AppRuntime — i.e. what an Effect run via AppRuntime.runPromise can yield. */
|
||||
export type AppServices = ManagedRuntime.ManagedRuntime.Services<typeof rt>
|
||||
const wrap = (effect: Parameters<typeof rt.runSync>[0]) => attach(effect as never) as never
|
||||
|
||||
export const AppRuntime: Runtime = {
|
||||
@@ -119,3 +132,15 @@ export const AppRuntime: Runtime = {
|
||||
},
|
||||
dispose: () => rt.dispose(),
|
||||
}
|
||||
|
||||
let bootstrapRun: Promise<Effect.Effect<void>>
|
||||
export function getBootstrapRunEffect(): Promise<Effect.Effect<void>> {
|
||||
if (!bootstrapRun) {
|
||||
bootstrapRun = AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceBootstrap.Service).run
|
||||
}),
|
||||
)
|
||||
}
|
||||
return bootstrapRun
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { Effect, Exit, Fiber } from "effect"
|
||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import type { WorkspaceID } from "@/control-plane/schema"
|
||||
@@ -9,6 +9,7 @@ import { attachWith } from "./run-service"
|
||||
export interface Shape {
|
||||
readonly promise: <A, E, R>(effect: Effect.Effect<A, E, R>) => Promise<A>
|
||||
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
|
||||
readonly run: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E>
|
||||
}
|
||||
|
||||
function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
|
||||
@@ -20,6 +21,25 @@ function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceI
|
||||
return fn()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge from Effect into a Promise-returning JS callback while installing
|
||||
* legacy `Instance.context` and `WorkspaceContext` AsyncLocalStorage for
|
||||
* the duration of the callback. Effect's `InstanceRef`/`WorkspaceRef` do
|
||||
* not propagate across async/await boundaries inside `Effect.promise(() =>
|
||||
* async fn)` callbacks that re-enter Effect via `AppRuntime.runPromise`,
|
||||
* but Node's AsyncLocalStorage does. Use this whenever an Effect crosses
|
||||
* into JS that may itself spawn new Effect runtimes (workspace adapters,
|
||||
* legacy plugins, etc.).
|
||||
*
|
||||
* Mirrors `Effect.promise` but restores legacy ALS first.
|
||||
*/
|
||||
export const fromPromise = <T>(fn: () => Promise<T> | T): Effect.Effect<T> =>
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspace = yield* WorkspaceRef
|
||||
return yield* Effect.promise(() => Promise.resolve(restore(instance, workspace, () => fn())))
|
||||
})
|
||||
|
||||
export function make(): Effect.Effect<Shape> {
|
||||
return Effect.gen(function* () {
|
||||
const ctx = yield* Effect.context()
|
||||
@@ -43,6 +63,14 @@ export function make(): Effect.Effect<Shape> {
|
||||
restore(instance, workspace, () => Effect.runPromise(wrap(effect))),
|
||||
fork: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
restore(instance, workspace, () => Effect.runFork(wrap(effect))),
|
||||
run: <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.callback<A, E>((resume) => {
|
||||
restore(instance, workspace, () =>
|
||||
Effect.runPromiseExit(wrap(effect)).then((exit) =>
|
||||
resume(Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
} satisfies Shape
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Config, Context, Effect, Layer } from "effect"
|
||||
|
||||
type ConfigMap = Record<string, Config.Config<unknown>>
|
||||
|
||||
/**
|
||||
* The service shape inferred from an object of Effect `Config` definitions.
|
||||
*/
|
||||
export type Shape<Fields extends ConfigMap> = {
|
||||
readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
|
||||
}
|
||||
|
||||
/**
|
||||
* A Context service class with generated layers for config-backed services.
|
||||
*/
|
||||
export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
|
||||
/** Provide already-parsed config, useful in tests. */
|
||||
readonly layer: (input: Service) => Layer.Layer<Self>
|
||||
/** Parse config once from the active Effect ConfigProvider and provide the service. */
|
||||
readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Context service whose implementation is derived from Effect `Config`.
|
||||
*
|
||||
* This keeps Effect `Config` as the source of truth for env names, defaults, and
|
||||
* validation while generating a typed service plus convenient production/test
|
||||
* layers.
|
||||
*
|
||||
* ```ts
|
||||
* class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
|
||||
* "@opencode/ServerAuthConfig",
|
||||
* {
|
||||
* password: Config.string("KILO_SERVER_PASSWORD").pipe(Config.option),
|
||||
* username: Config.string("KILO_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
|
||||
* },
|
||||
* ) {}
|
||||
*
|
||||
* const live = ServerAuthConfig.defaultLayer
|
||||
* const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" })
|
||||
* ```
|
||||
*/
|
||||
export const Service =
|
||||
<Self>() =>
|
||||
<const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
|
||||
class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
|
||||
static layer(input: Shape<Fields>) {
|
||||
return Layer.succeed(this, this.of(input))
|
||||
}
|
||||
|
||||
static get defaultLayer() {
|
||||
return Layer.effect(
|
||||
this,
|
||||
Config.all(fields)
|
||||
.asEffect()
|
||||
.pipe(
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs.
|
||||
Effect.map((config) => this.of(config as Shape<Fields>)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
|
||||
return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
|
||||
}
|
||||
|
||||
export * as ConfigService from "./config-service"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import { Effect, Fiber, Layer, ManagedRuntime } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { LocalContext } from "@/util/local-context"
|
||||
@@ -24,15 +24,20 @@ export function attachWith<A, E, R>(effect: Effect.Effect<A, E, R>, refs: Refs):
|
||||
}
|
||||
|
||||
export function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
return attachWith(effect, {
|
||||
instance: Instance.current,
|
||||
workspace: WorkspaceContext.workspaceID,
|
||||
})
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
return effect
|
||||
const workspace = WorkspaceContext.workspaceID
|
||||
const instance = (() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch (err) {
|
||||
if (!(err instanceof LocalContext.NotFound)) throw err
|
||||
}
|
||||
})()
|
||||
if (instance && workspace !== undefined) return attachWith(effect, { instance, workspace })
|
||||
const fiber = Fiber.getCurrent()
|
||||
return attachWith(effect, {
|
||||
instance: instance ?? (fiber ? Context.getReferenceUnsafe(fiber.context, InstanceRef) : undefined),
|
||||
workspace: workspace ?? (fiber ? Context.getReferenceUnsafe(fiber.context, WorkspaceRef) : undefined),
|
||||
})
|
||||
}
|
||||
|
||||
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
type EffectMethod = (...args: ReadonlyArray<never>) => Effect.Effect<unknown, unknown, unknown>
|
||||
|
||||
type ServiceUse<Identifier, Shape> = {
|
||||
readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends (
|
||||
...args: infer Args
|
||||
) => infer Return
|
||||
? Args extends ReadonlyArray<unknown>
|
||||
? Return extends Effect.Effect<infer A, infer E, infer R>
|
||||
? (...args: Args) => Effect.Effect<A, E, R | Identifier>
|
||||
: never
|
||||
: never
|
||||
: never
|
||||
}
|
||||
|
||||
export const serviceUse = <Identifier, Shape>(tag: Context.Service<Identifier, Shape>) => {
|
||||
// This is the only dynamic boundary: TypeScript knows the accessor shape,
|
||||
// but Proxy property names are runtime values.
|
||||
const access = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, key) => {
|
||||
if (typeof key !== "string") return undefined
|
||||
return (...args: unknown[]) =>
|
||||
tag.use((service) => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime.
|
||||
const method = service[key as keyof Shape]
|
||||
if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`))
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods.
|
||||
return (method as (...args: unknown[]) => Effect.Effect<unknown, unknown, unknown>)(...args)
|
||||
})
|
||||
},
|
||||
},
|
||||
)
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily.
|
||||
return access as ServiceUse<Identifier, Shape>
|
||||
}
|
||||
@@ -11,17 +11,17 @@ import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Instance } from "../project/instance"
|
||||
import { containsPath } from "../project/instance-context"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Protected } from "./protected"
|
||||
import { Ripgrep } from "./ripgrep"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { type DeepMutable, withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, type DeepMutable, withStatics } from "@/util/schema"
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
path: Schema.String,
|
||||
added: Schema.Int,
|
||||
removed: Schema.Int,
|
||||
added: NonNegativeInt,
|
||||
removed: NonNegativeInt,
|
||||
status: Schema.Literals(["added", "deleted", "modified"]),
|
||||
})
|
||||
.annotate({ identifier: "File" })
|
||||
@@ -40,10 +40,10 @@ export const Node = Schema.Struct({
|
||||
export type Node = DeepMutable<Schema.Schema.Type<typeof Node>>
|
||||
|
||||
const Hunk = Schema.Struct({
|
||||
oldStart: Schema.Number,
|
||||
oldLines: Schema.Number,
|
||||
newStart: Schema.Number,
|
||||
newLines: Schema.Number,
|
||||
oldStart: NonNegativeInt,
|
||||
oldLines: NonNegativeInt,
|
||||
newStart: NonNegativeInt,
|
||||
newLines: NonNegativeInt,
|
||||
lines: Schema.Array(Schema.String),
|
||||
})
|
||||
|
||||
@@ -508,7 +508,7 @@ export const layer = Layer.effect(
|
||||
const ctx = yield* InstanceState.context
|
||||
const full = path.join(ctx.directory, file)
|
||||
|
||||
if (!Instance.containsPath(full, ctx)) {
|
||||
if (!containsPath(full, ctx)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
@@ -595,7 +595,7 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
|
||||
if (!Instance.containsPath(resolved, ctx)) {
|
||||
if (!containsPath(resolved, ctx)) {
|
||||
throw new Error("Access denied: path escapes project directory")
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { which } from "@/util/which"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
|
||||
const log = Log.create({ service: "ripgrep" })
|
||||
const VERSION = "15.1.0"
|
||||
@@ -27,19 +27,19 @@ const PLATFORM = {
|
||||
} as const
|
||||
|
||||
const TimeStats = Schema.Struct({
|
||||
secs: Schema.Number,
|
||||
nanos: Schema.Number,
|
||||
secs: NonNegativeInt,
|
||||
nanos: NonNegativeInt,
|
||||
human: Schema.String,
|
||||
})
|
||||
|
||||
const Stats = Schema.Struct({
|
||||
elapsed: TimeStats,
|
||||
searches: Schema.Number,
|
||||
searches_with_match: Schema.Number,
|
||||
bytes_searched: Schema.Number,
|
||||
bytes_printed: Schema.Number,
|
||||
matched_lines: Schema.Number,
|
||||
matches: Schema.Number,
|
||||
searches: NonNegativeInt,
|
||||
searches_with_match: NonNegativeInt,
|
||||
bytes_searched: NonNegativeInt,
|
||||
bytes_printed: NonNegativeInt,
|
||||
matched_lines: NonNegativeInt,
|
||||
matches: NonNegativeInt,
|
||||
})
|
||||
|
||||
const PathText = Schema.Struct({
|
||||
@@ -58,15 +58,15 @@ export const SearchMatch = Schema.Struct({
|
||||
lines: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
line_number: Schema.Number,
|
||||
absolute_offset: Schema.Number,
|
||||
line_number: NonNegativeInt,
|
||||
absolute_offset: NonNegativeInt,
|
||||
submatches: Schema.Array(
|
||||
Schema.Struct({
|
||||
match: Schema.Struct({
|
||||
text: Schema.String,
|
||||
}),
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}),
|
||||
),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
@@ -80,7 +80,7 @@ const End = Schema.Struct({
|
||||
type: Schema.Literal("end"),
|
||||
data: Schema.Struct({
|
||||
path: PathText,
|
||||
binary_offset: Schema.NullOr(Schema.Number),
|
||||
binary_offset: Schema.NullOr(NonNegativeInt),
|
||||
stats: Stats,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "@/config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -76,25 +75,27 @@ export const layer = Layer.effect(
|
||||
function* () {
|
||||
if (yield* Flag.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER) return
|
||||
|
||||
log.info("init", { directory: Instance.directory })
|
||||
const ctx = yield* InstanceState.context
|
||||
|
||||
log.info("init", { directory: ctx.directory })
|
||||
|
||||
const backend = getBackend()
|
||||
if (!backend) {
|
||||
log.error("watcher backend not supported", { directory: Instance.directory, platform: process.platform })
|
||||
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
|
||||
return
|
||||
}
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return
|
||||
|
||||
log.info("watcher backend", { directory: Instance.directory, platform: process.platform, backend })
|
||||
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
|
||||
|
||||
const subs: ParcelWatcher.AsyncSubscription[] = []
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => Promise.allSettled(subs.map((sub) => sub.unsubscribe()))),
|
||||
)
|
||||
|
||||
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
|
||||
const cb: ParcelWatcher.SubscribeCallback = InstanceState.bind((err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
if (evt.type === "create") void Bus.publish(Event.Updated, { file: evt.path, event: "add" })
|
||||
@@ -122,19 +123,14 @@ export const layer = Layer.effect(
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.KILO_EXPERIMENTAL_FILEWATCHER) {
|
||||
yield* subscribe(Instance.directory, [
|
||||
...FileIgnore.PATTERNS,
|
||||
...cfgIgnores,
|
||||
...protecteds(Instance.directory),
|
||||
])
|
||||
yield* subscribe(ctx.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(ctx.directory)])
|
||||
}
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
if (ctx.project.vcs === "git") {
|
||||
const result = yield* git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
const vcsDir =
|
||||
result.exitCode === 0 ? path.resolve(Instance.project.worktree, result.text().trim()) : undefined
|
||||
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
|
||||
(entry) => entry !== "HEAD",
|
||||
|
||||
@@ -38,7 +38,7 @@ import { RemoteCommand } from "./cli/cmd/remote" // kilocode_change
|
||||
import { DevSetupCommand, DevAliasCommand } from "./kilocode/cli/dev-setup" // kilocode_change
|
||||
// kilocode_change start - Import telemetry, instance disposal, and legacy migration
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
import { Instance } from "./project/instance" // kilocode_change
|
||||
import { InstanceStore } from "./project/instance-store" // kilocode_change
|
||||
import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway"
|
||||
|
||||
// kilocode_change - set feature for tracking. 'serve' is spawned by other services
|
||||
@@ -319,7 +319,7 @@ try {
|
||||
await Telemetry.shutdown()
|
||||
// kilocode_change end
|
||||
|
||||
await Instance.disposeAll() // kilocode_change - safety net disposal (no-op if already disposed)
|
||||
await InstanceStore.disposeAllInstances() // kilocode_change - safety net disposal (no-op if already disposed)
|
||||
|
||||
// Some subprocesses don't react properly to SIGTERM and similar signals.
|
||||
// Most notably, some docker-container-based MCP servers don't handle such signals unless
|
||||
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
declare module "jschardet" {
|
||||
export interface Result {
|
||||
encoding?: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export function detect(input: ArrayLike<number>): Result
|
||||
|
||||
const api: {
|
||||
detect(input: ArrayLike<number>): Result
|
||||
}
|
||||
|
||||
export default api
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import * as Truncate from "../../tool/truncate"
|
||||
import { Config } from "../../config/config"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceStore } from "../../project/instance-store"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
@@ -142,6 +143,7 @@ function askGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
skill: "allow",
|
||||
question: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
@@ -155,11 +157,36 @@ function askGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
function planGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
|
||||
function denies(user: Permission.Ruleset) {
|
||||
return user.filter((rule) => rule.action === "deny")
|
||||
}
|
||||
|
||||
function askEditGuard() {
|
||||
return Permission.fromConfig({ edit: "deny" })
|
||||
}
|
||||
|
||||
// Upstream v1.14.33 builds Agent state outside the Instance ALS, so reading
|
||||
// Instance.worktree here would crash. Thread worktree through from patchAgents
|
||||
// instead.
|
||||
function planEditRules(worktree: string) {
|
||||
return {
|
||||
"*": "deny" as const,
|
||||
[path.join(".kilo", "plans", "*.md")]: "allow" as const,
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow" as const,
|
||||
[path.relative(worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow" as const,
|
||||
}
|
||||
}
|
||||
|
||||
function planEditGuard(worktree: string) {
|
||||
return Permission.fromConfig({ edit: planEditRules(worktree) })
|
||||
}
|
||||
|
||||
function planGuard(worktree: string, mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
|
||||
return Permission.fromConfig({
|
||||
"*": "deny",
|
||||
question: "allow",
|
||||
suggest: "allow",
|
||||
skill: "allow",
|
||||
plan_exit: "allow",
|
||||
bash: readOnlyBash,
|
||||
read: {
|
||||
@@ -180,12 +207,7 @@ function planGuard(mcp: Record<string, "allow" | "ask" | "deny"> = {}) {
|
||||
[Truncate.GLOB]: "allow",
|
||||
[path.join(Global.Path.data, "plans", "*")]: "allow",
|
||||
},
|
||||
edit: {
|
||||
"*": "deny",
|
||||
[path.join(".kilo", "plans", "*.md")]: "allow",
|
||||
[path.join(".opencode", "plans", "*.md")]: "allow",
|
||||
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
|
||||
},
|
||||
edit: planEditRules(worktree),
|
||||
...mcp,
|
||||
})
|
||||
}
|
||||
@@ -275,6 +297,8 @@ export function patchAgents(
|
||||
user: Permission.Ruleset,
|
||||
cfg: Config.Info,
|
||||
kilo: KiloData,
|
||||
worktree: string,
|
||||
whitelistedDirs: string[],
|
||||
) {
|
||||
// Rename "build" → "code" for backward compatibility
|
||||
if (agents.build) {
|
||||
@@ -298,9 +322,10 @@ export function patchAgents(
|
||||
description: "Plan mode. Can only edit plan files; all other filesystem mutations are denied.",
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
planGuard(worktree, kilo.mcpRules),
|
||||
user,
|
||||
planGuard(kilo.mcpRules),
|
||||
user.filter((r: Permission.Rule) => r.action === "deny"),
|
||||
planEditGuard(worktree),
|
||||
denies(user),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -317,6 +342,7 @@ export function patchAgents(
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
bash: "allow",
|
||||
skill: "allow",
|
||||
webfetch: "allow",
|
||||
websearch: "allow",
|
||||
codesearch: "allow",
|
||||
@@ -324,8 +350,13 @@ export function patchAgents(
|
||||
semantic_search: "allow",
|
||||
read: "allow",
|
||||
external_directory: {
|
||||
// Mirror upstream explore's shape: the outer "*": "deny" above wins
|
||||
// over defaults' external_directory rules via findLast, so re-apply
|
||||
// the full whitelist (Truncate.GLOB, tmp, skill, config, globalDirs)
|
||||
// here. Upstream adds these inline in agent.ts; we do the same from
|
||||
// within the patch.
|
||||
"*": "ask",
|
||||
[Truncate.GLOB]: "allow",
|
||||
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
|
||||
},
|
||||
}),
|
||||
user,
|
||||
@@ -371,6 +402,7 @@ export function patchAgents(
|
||||
glob: "allow",
|
||||
list: "allow",
|
||||
question: "allow",
|
||||
skill: "allow",
|
||||
suggest: "allow", // kilocode_change
|
||||
task: "allow",
|
||||
todoread: "allow",
|
||||
@@ -402,9 +434,10 @@ export function patchAgents(
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
user, // user before ask-specific so ask's deny+allowlist wins
|
||||
askGuard(kilo.mcpRules),
|
||||
user.filter((r: Permission.Rule) => r.action === "deny"), // re-apply user denies so explicit MCP blocks win over mcpRules
|
||||
user,
|
||||
askEditGuard(),
|
||||
denies(user),
|
||||
),
|
||||
mode: "primary",
|
||||
native: true,
|
||||
@@ -485,5 +518,5 @@ export async function remove(name: string) {
|
||||
|
||||
if (!found) throw new RemoveError({ name, message: "no agent file found on disk" })
|
||||
|
||||
await Instance.dispose()
|
||||
await InstanceStore.disposeInstance(Instance.current)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* KiloClaw chat slash-command autocomplete.
|
||||
*
|
||||
* Slim port of `cli/cmd/tui/component/prompt/autocomplete.tsx` — drops the
|
||||
* `@`-mode branches (file mentions, agents, MCP resources, frecency,
|
||||
* extmark wiring) and only handles `/` slash commands.
|
||||
*
|
||||
* Source of slash commands is supplied by the caller via the `slashes`
|
||||
* prop. We intentionally do NOT pull from `useCommandDialog().slashes()`
|
||||
* because that registry holds globally-registered commands across all
|
||||
* routes (e.g. the home route's `/new` for sessions), which would clash
|
||||
* with kiloclaw's own `/new` for conversations.
|
||||
*
|
||||
* Mounts as an absolutely-positioned popup above the chat textarea and
|
||||
* suspends the global command keybinds while open so arrow keys drive the
|
||||
* popup instead of triggering palette commands.
|
||||
*/
|
||||
|
||||
import type { BoxRenderable, KeyEvent, ScrollBoxRenderable, TextareaRenderable } from "@opentui/core"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { Index, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
|
||||
export type ClawAutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onCursorChange: () => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
dismiss: () => void
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
export type ClawSlashOption = {
|
||||
display: string
|
||||
description?: string
|
||||
aliases?: string[]
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function ClawAutocomplete(props: {
|
||||
value: string
|
||||
slashes: () => ClawSlashOption[]
|
||||
anchor: () => BoxRenderable | undefined
|
||||
input: () => TextareaRenderable | undefined
|
||||
ref: (ref: ClawAutocompleteRef) => void
|
||||
}) {
|
||||
const command = useCommandDialog()
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as boolean,
|
||||
})
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
|
||||
// Reposition the popup if the anchor moves (e.g., textarea grows).
|
||||
createEffect(() => {
|
||||
if (!store.visible) return
|
||||
let last = { x: 0, y: 0, width: 0 }
|
||||
const interval = setInterval(() => {
|
||||
const a = props.anchor()
|
||||
if (!a) return
|
||||
if (a.x !== last.x || a.y !== last.y || a.width !== last.width) {
|
||||
last = { x: a.x, y: a.y, width: a.width }
|
||||
setPositionTick((t) => t + 1)
|
||||
}
|
||||
}, 50)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const position = createMemo(() => {
|
||||
if (!store.visible) return { x: 0, y: 0, width: 0 }
|
||||
dimensions()
|
||||
positionTick()
|
||||
const anchor = props.anchor()
|
||||
if (!anchor) return { x: 0, y: 0, width: 0 }
|
||||
const parent = anchor.parent
|
||||
const parentX = parent?.x ?? 0
|
||||
const parentY = parent?.y ?? 0
|
||||
return {
|
||||
x: anchor.x - parentX,
|
||||
y: anchor.y - parentY,
|
||||
width: anchor.width,
|
||||
}
|
||||
})
|
||||
|
||||
// Filter text — everything after the trigger up to the cursor.
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return ""
|
||||
const inp = props.input()
|
||||
if (!inp) return ""
|
||||
props.value // reactive dep
|
||||
return inp.getTextRange(store.index + 1, inp.cursorOffset)
|
||||
})
|
||||
|
||||
const [search, setSearch] = createSignal("")
|
||||
createEffect(() => {
|
||||
setSearch(filter())
|
||||
})
|
||||
|
||||
const options = createMemo<ClawSlashOption[]>(() => {
|
||||
const all = props.slashes()
|
||||
const q = search()
|
||||
if (!q) return all
|
||||
const result = fuzzysort.go(q, all, {
|
||||
keys: [(o) => o.display.trimEnd(), "description", (o) => o.aliases?.join(" ") ?? ""],
|
||||
limit: 10,
|
||||
scoreFn: (results) => {
|
||||
const first = results[0]
|
||||
let score = results.score
|
||||
if (first && first.target.startsWith("/" + q)) score *= 2
|
||||
return score
|
||||
},
|
||||
})
|
||||
return result.map((r) => r.obj)
|
||||
})
|
||||
|
||||
// Reset selection when the filter changes
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
const list = options()
|
||||
if (!list.length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = list.length - 1
|
||||
if (next >= list.length) next = 0
|
||||
moveTo(next)
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
if (!scroll) return
|
||||
const viewportHeight = Math.min(height(), options().length)
|
||||
const scrollBottom = scroll.scrollTop + viewportHeight
|
||||
if (next < scroll.scrollTop) {
|
||||
scroll.scrollBy(next - scroll.scrollTop)
|
||||
} else if (next + 1 > scrollBottom) {
|
||||
scroll.scrollBy(next + 1 - scrollBottom)
|
||||
}
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
// Strip the `/<typed>` from the textarea so the dialog opens on a clean
|
||||
// slate (mirrors the main Prompt's behavior when running a slash cmd).
|
||||
const inp = props.input()
|
||||
if (inp) {
|
||||
const cursor = inp.logicalCursor
|
||||
inp.deleteRange(0, 0, cursor.row, cursor.col)
|
||||
}
|
||||
hide()
|
||||
selected.onSelect()
|
||||
}
|
||||
|
||||
function show() {
|
||||
command.keybinds(false)
|
||||
setStore({
|
||||
visible: true,
|
||||
index: props.input()?.cursorOffset ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!store.visible) return
|
||||
command.keybinds(true)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
dismiss() {
|
||||
dismiss()
|
||||
},
|
||||
onInput(value) {
|
||||
const inp = props.input()
|
||||
if (!inp) return
|
||||
if (store.visible) {
|
||||
// Hide if cursor moved before trigger, whitespace appeared between
|
||||
// trigger and cursor, or the input ceased being a single token.
|
||||
if (
|
||||
inp.cursorOffset <= store.index ||
|
||||
inp.getTextRange(store.index, inp.cursorOffset).match(/\s/) ||
|
||||
value.match(/^\S+\s+\S+\s*$/)
|
||||
) {
|
||||
hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Auto-reopen after backspace deletes a leading space, etc.
|
||||
const offset = inp.cursorOffset
|
||||
if (offset === 0) return
|
||||
if (value.startsWith("/") && !value.slice(0, offset).match(/\s/)) {
|
||||
show()
|
||||
setStore("index", 0)
|
||||
}
|
||||
},
|
||||
onCursorChange() {
|
||||
if (!store.visible) return
|
||||
const inp = props.input()
|
||||
if (!inp) return
|
||||
const cursor = inp.cursorOffset
|
||||
const value = inp.plainText
|
||||
if (
|
||||
cursor <= store.index ||
|
||||
inp.getTextRange(store.index, cursor).match(/\s/) ||
|
||||
value.match(/^\S+\s+\S+\s*$/)
|
||||
) {
|
||||
hide()
|
||||
}
|
||||
},
|
||||
onKeyDown(e: KeyEvent) {
|
||||
if (store.visible) {
|
||||
const name = e.name?.toLowerCase()
|
||||
const ctrlOnly = e.ctrl && !e.meta && !e.shift
|
||||
const isNavUp = name === "up" || (ctrlOnly && name === "p")
|
||||
const isNavDown = name === "down" || (ctrlOnly && name === "n")
|
||||
if (isNavUp) {
|
||||
move(-1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isNavDown) {
|
||||
move(1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "escape") {
|
||||
hide()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "return" || name === "tab") {
|
||||
select()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (name === "right") {
|
||||
const inp = props.input()
|
||||
if (inp && inp.cursorOffset <= store.index) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (e.name === "/") {
|
||||
const inp = props.input()
|
||||
if (inp && inp.cursorOffset === 0) show()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (store.visible) command.keybinds(true)
|
||||
})
|
||||
})
|
||||
|
||||
const height = createMemo(() => {
|
||||
const count = options().length || 1
|
||||
if (!store.visible) return Math.min(10, count)
|
||||
positionTick()
|
||||
const a = props.anchor()
|
||||
return Math.min(10, count, Math.max(1, a?.y ?? 1))
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
||||
return (
|
||||
<box
|
||||
visible={store.visible}
|
||||
position="absolute"
|
||||
top={position().y - height()}
|
||||
left={position().x}
|
||||
width={position().width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
backgroundColor={theme.backgroundMenu}
|
||||
height={height()}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<Index
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>No matching commands</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
onMouseDown={() => moveTo(index)}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
||||
{option().display}
|
||||
</text>
|
||||
<Show when={option().description}>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.textMuted} wrapMode="none">
|
||||
{" "}
|
||||
{option().description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Index>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -3,19 +3,23 @@
|
||||
/**
|
||||
* KiloClaw chat panel
|
||||
*
|
||||
* Renders a scrollable message list and a textarea input for chatting
|
||||
* with the KiloClaw bot via Stream Chat.
|
||||
* Renders a scrollable message list, a slash-command autocomplete popup,
|
||||
* and a textarea for chatting with the KiloClaw bot via Kilo Chat.
|
||||
* Conversation title and context-window usage live in the sidebar (mirrors
|
||||
* the session route's layout).
|
||||
*
|
||||
* Visual style mirrors the session chat TUI (routes/session/index.tsx).
|
||||
*/
|
||||
|
||||
import { createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { type BoxRenderable, type KeyBinding, type MouseEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||
import { useKV } from "@tui/context/kv"
|
||||
import type { ChatMessage } from "./types"
|
||||
import { Spinner } from "@tui/component/spinner"
|
||||
import type { ChatMessage, TypingMember } from "./types"
|
||||
import { ClawAutocomplete, type ClawAutocompleteRef, type ClawSlashOption } from "./autocomplete"
|
||||
|
||||
function UserMessageRow(props: { msg: ChatMessage; index: number }) {
|
||||
const { theme } = useTheme()
|
||||
@@ -64,45 +68,87 @@ export function ClawChat(props: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
disabled: boolean
|
||||
typingMembers: TypingMember[]
|
||||
slashes: () => ClawSlashOption[]
|
||||
botName: string
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const kv = useKV()
|
||||
const [showScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
let input: TextareaRenderable
|
||||
let input: TextareaRenderable | undefined
|
||||
let inputAnchor: BoxRenderable | undefined
|
||||
let auto: ClawAutocompleteRef | undefined
|
||||
const [inputValue, setInputValue] = createSignal("")
|
||||
|
||||
const active = createMemo(() => !props.disabled && props.connected)
|
||||
// Input is usable only when the instance is running, the WebSocket is
|
||||
// connected, and the bot is online. Sending while the bot is offline
|
||||
// would be silently dropped (no one to deliver to), so surface that as
|
||||
// a disabled input rather than a message that vanishes.
|
||||
const active = createMemo(() => !props.disabled && props.connected && props.online)
|
||||
|
||||
// Render the typing banner with friendly names. Bot members come in as
|
||||
// `bot:kiloclaw:{sandboxId}` — the bot is the only non-self member in 1:1
|
||||
// chats, so we resolve bot ids to the user-configured `botName` (which
|
||||
// falls back to "KiloClaw" upstream) and render any human collaborators
|
||||
// by their raw memberId.
|
||||
const typingLabel = createMemo(() => {
|
||||
const list = props.typingMembers
|
||||
if (!list.length) return null
|
||||
const names = list.map((m) => (m.memberId.startsWith("bot:") ? props.botName : m.memberId))
|
||||
if (names.length === 1) return `${names[0]} is typing`
|
||||
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`
|
||||
return `${names[0]} and ${names.length - 1} others are typing`
|
||||
})
|
||||
|
||||
const placeholder = createMemo(() => {
|
||||
if (props.error) return props.error
|
||||
if (props.loading) return "Connecting..."
|
||||
if (!props.connected) return "Chat unavailable"
|
||||
if (props.disabled) return "Instance is stopped"
|
||||
return ""
|
||||
if (!props.online) return `${props.botName} is offline`
|
||||
return "Type a message... (/ for commands)"
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (!input) return
|
||||
if (auto?.visible) return // let autocomplete consume Enter
|
||||
const text = input.plainText.trim()
|
||||
if (!text) return
|
||||
if (!active()) return
|
||||
const ok = await props.onSend(text)
|
||||
if (ok) input.clear()
|
||||
if (ok) {
|
||||
input.clear()
|
||||
setInputValue("")
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (active()) input?.focus()
|
||||
})
|
||||
|
||||
// Stream Chat WebSocket events fire outside OpenTUI's render cycle.
|
||||
// Track props driven by external callbacks and explicitly request a repaint.
|
||||
// Repaint when external state arrives (events fire outside OpenTUI's
|
||||
// render cycle).
|
||||
createEffect(() => {
|
||||
props.messages.length
|
||||
props.online
|
||||
props.typingMembers.length
|
||||
renderer.requestRender()
|
||||
})
|
||||
|
||||
// Drive the textarea's traits + focus from active() and autocomplete
|
||||
// visibility. We always render the textarea (even while loading or
|
||||
// disconnected) so the input area keeps a stable height; `traits.suspend`
|
||||
// disables interaction without changing layout. Mirrors DialogPrompt's
|
||||
// suspend-then-blur pattern so the cursor visibly leaves while inactive.
|
||||
createEffect(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
const suspend = !active()
|
||||
const status = props.loading ? "LOADING" : props.disabled ? "OFFLINE" : undefined
|
||||
input.traits = auto?.visible
|
||||
? { capture: ["escape", "navigate", "submit", "tab"] as const, suspend, status }
|
||||
: { suspend, status }
|
||||
if (suspend) input.blur()
|
||||
else input.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="column" flexGrow={1} gap={1} paddingTop={1}>
|
||||
{/* Messages */}
|
||||
@@ -143,8 +189,15 @@ export function ClawChat(props: {
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
{/* Typing indicator */}
|
||||
<Show when={typingLabel()}>
|
||||
<box flexShrink={0} paddingLeft={2}>
|
||||
<Spinner color={theme.textMuted}>{typingLabel()}</Spinner>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Input area */}
|
||||
<box flexShrink={0}>
|
||||
<box flexShrink={0} ref={(r: BoxRenderable) => (inputAnchor = r)}>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={active() ? theme.primary : theme.textMuted}
|
||||
@@ -162,26 +215,35 @@ export function ClawChat(props: {
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
>
|
||||
<Show when={active()} fallback={<text fg={theme.textMuted}>{placeholder()}</text>}>
|
||||
<textarea
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
}}
|
||||
placeholder="Type a message... (Enter to send)"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
minHeight={2}
|
||||
maxHeight={4}
|
||||
cursorColor={theme.text}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
onMouseDown={(e: MouseEvent) => e.target?.focus()}
|
||||
keyBindings={[
|
||||
{ name: "return", action: "submit" } satisfies KeyBinding,
|
||||
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
|
||||
]}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
</Show>
|
||||
<textarea
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
}}
|
||||
placeholder={placeholder()}
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={active() ? theme.text : theme.textMuted}
|
||||
focusedTextColor={active() ? theme.text : theme.textMuted}
|
||||
minHeight={2}
|
||||
maxHeight={4}
|
||||
cursorColor={active() ? theme.text : theme.backgroundElement}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
onMouseDown={(e: MouseEvent) => {
|
||||
if (active()) e.target?.focus()
|
||||
}}
|
||||
onContentChange={() => {
|
||||
if (!input) return
|
||||
const value = input.plainText
|
||||
setInputValue(value)
|
||||
auto?.onInput(value)
|
||||
}}
|
||||
onCursorChange={() => auto?.onCursorChange()}
|
||||
onKeyDown={(e) => auto?.onKeyDown(e)}
|
||||
keyBindings={[
|
||||
{ name: "return", action: "submit" } satisfies KeyBinding,
|
||||
{ name: "return", shift: true, action: "newline" } satisfies KeyBinding,
|
||||
]}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
@@ -211,6 +273,15 @@ export function ClawChat(props: {
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
{/* Slash autocomplete popup, anchored to the textarea wrapper */}
|
||||
<ClawAutocomplete
|
||||
value={inputValue()}
|
||||
slashes={props.slashes}
|
||||
anchor={() => inputAnchor}
|
||||
input={() => input}
|
||||
ref={(r) => (auto = r)}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,161 +1,492 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* KiloClaw Stream Chat client wrapper
|
||||
* KiloClaw Kilo Chat client wrapper for the TUI.
|
||||
*
|
||||
* Headless Stream Chat JS SDK integration for the TUI.
|
||||
* Uses the same channel/credentials as the web dashboard's ChatTab.
|
||||
* Hosts the HTTP and WebSocket clients, exposes a multi-conversation API
|
||||
* (list/create/select/rename/delete), and translates Kilo Chat events for
|
||||
* the active conversation into the legacy `ChatMessage` shape that the
|
||||
* existing single-pane renderer consumes.
|
||||
*
|
||||
* stream-chat is dynamically imported to avoid crashing Bun at module
|
||||
* load time (follow-redirects uses Error.captureStackTrace in a way
|
||||
* that is incompatible with Bun's runtime).
|
||||
* Switching the active conversation rotates the WebSocket subscription so
|
||||
* we only ever stream events for the conversation the user is looking at.
|
||||
*/
|
||||
|
||||
import type { ChatCredentials, ChatMessage } from "./types"
|
||||
import type {
|
||||
BotStatusEvent,
|
||||
ChatMessage,
|
||||
ChatToken,
|
||||
ContentBlock,
|
||||
ConversationActivityEvent,
|
||||
ConversationListItem,
|
||||
ConversationRenamedEvent,
|
||||
ConversationStatusEvent,
|
||||
ConversationStatusRecord,
|
||||
Message,
|
||||
MessageCreatedEvent,
|
||||
MessageUpdatedEvent,
|
||||
TypingEvent,
|
||||
TypingMember,
|
||||
} from "./types"
|
||||
import { KiloChatClient } from "./kilo-chat-client"
|
||||
import { EventServiceClient } from "./event-service-client"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "claw-chat" })
|
||||
|
||||
export type ConversationStatusListener = (status: ConversationStatusRecord | null) => void
|
||||
export type ConversationsListener = (conversations: ConversationListItem[]) => void
|
||||
export type ActiveConversationListener = (id: string | null) => void
|
||||
export type TypingMembersListener = (members: TypingMember[]) => void
|
||||
|
||||
export type ClawChatClient = {
|
||||
channel: any
|
||||
disconnect: () => Promise<void>
|
||||
|
||||
// Conversations
|
||||
listConversations: () => ConversationListItem[]
|
||||
activeConversationId: () => string | null
|
||||
selectConversation: (
|
||||
conversationId: string,
|
||||
) => Promise<{ messages: ChatMessage[]; status: ConversationStatusRecord | null }>
|
||||
createConversation: (title?: string) => Promise<string>
|
||||
renameConversation: (conversationId: string, title: string) => Promise<void>
|
||||
deleteConversation: (conversationId: string) => Promise<void>
|
||||
refreshConversations: () => Promise<ConversationListItem[]>
|
||||
|
||||
// Active conversation
|
||||
send: (text: string) => Promise<void>
|
||||
loadHistory: (conversationId?: string) => Promise<ChatMessage[]>
|
||||
initialBotOnline: () => boolean
|
||||
conversationStatus: () => ConversationStatusRecord | null
|
||||
typingMembers: () => TypingMember[]
|
||||
|
||||
// Subscriptions
|
||||
onMessage: (cb: (msg: ChatMessage) => void) => () => void
|
||||
onMessageUpdated: (cb: (msg: ChatMessage) => void) => () => void
|
||||
onPresence: (cb: (online: boolean) => void) => () => void
|
||||
onConversations: (cb: ConversationsListener) => () => void
|
||||
onActiveConversation: (cb: ActiveConversationListener) => () => void
|
||||
onConversationStatus: (cb: ConversationStatusListener) => () => void
|
||||
onTypingMembers: (cb: TypingMembersListener) => () => void
|
||||
}
|
||||
|
||||
export function botId(creds: ChatCredentials): string {
|
||||
return `bot-${creds.channelId.replace(/^default-/, "")}`
|
||||
function blocksToText(content: ContentBlock[]): string {
|
||||
let out = ""
|
||||
for (const b of content) {
|
||||
if (b.type === "text") out += b.text
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function toMessage(raw: any, bot: string): ChatMessage {
|
||||
const ULID_TIME_LEN = 10
|
||||
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
|
||||
function ulidToTimestamp(id: string): number {
|
||||
if (!id || id.length < ULID_TIME_LEN) return Date.now()
|
||||
const time = id.slice(0, ULID_TIME_LEN).toUpperCase()
|
||||
let ts = 0
|
||||
for (const ch of time) {
|
||||
const idx = ENCODING.indexOf(ch)
|
||||
if (idx === -1) return Date.now()
|
||||
ts = ts * ENCODING.length + idx
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
function toChatMessage(m: Message, currentUserId: string): ChatMessage {
|
||||
return {
|
||||
id: raw.id ?? "",
|
||||
text: raw.text ?? "",
|
||||
user: raw.user?.id ?? raw.user_id ?? "",
|
||||
created: raw.created_at ? new Date(raw.created_at) : new Date(),
|
||||
bot: (raw.user?.id ?? raw.user_id ?? "") === bot,
|
||||
id: m.id,
|
||||
text: m.deleted ? "[deleted message]" : blocksToText(m.content),
|
||||
user: m.senderId,
|
||||
created: new Date(ulidToTimestamp(m.id)),
|
||||
bot: m.senderId.startsWith("bot:") || m.senderId !== currentUserId,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Bun compatibility patches for stream-chat and its dependencies.
|
||||
*
|
||||
* 1. Error.captureStackTrace — follow-redirects passes a plain object with
|
||||
* Error.prototype in its chain which crashes Bun.
|
||||
* 2. net.Socket.prototype.destroy — Bun throws "First argument must be an
|
||||
* Error object" when destroy() receives a non-Error. The ws library and
|
||||
* stream-chat internals can trigger this during WebSocket lifecycle events.
|
||||
*
|
||||
* Both patches are applied once and remain active for the process lifetime.
|
||||
*/
|
||||
let patched = false
|
||||
function applyBunPatches() {
|
||||
if (patched) return
|
||||
patched = true
|
||||
function toChatMessageFromCreated(e: MessageCreatedEvent, currentUserId: string): ChatMessage {
|
||||
return {
|
||||
id: e.messageId,
|
||||
text: blocksToText(e.content),
|
||||
user: e.senderId,
|
||||
created: new Date(ulidToTimestamp(e.messageId)),
|
||||
bot: e.senderId.startsWith("bot:") || e.senderId !== currentUserId,
|
||||
}
|
||||
}
|
||||
|
||||
const orig = Error.captureStackTrace
|
||||
if (orig) {
|
||||
Error.captureStackTrace = function safe(target: any, ctor?: Function) {
|
||||
try {
|
||||
return orig.call(Error, target, ctor)
|
||||
} catch {
|
||||
// Bun throws when target is not a real Error (e.g. follow-redirects
|
||||
// CustomError which has Error.prototype in its chain but is not
|
||||
// constructed via `new Error()`). Silently skip.
|
||||
}
|
||||
} as typeof Error.captureStackTrace
|
||||
function toChatMessageFromUpdated(
|
||||
e: MessageUpdatedEvent,
|
||||
currentUserId: string,
|
||||
senderHint: string | null,
|
||||
): ChatMessage {
|
||||
const sender = senderHint ?? "bot"
|
||||
return {
|
||||
id: e.messageId,
|
||||
text: blocksToText(e.content),
|
||||
user: sender,
|
||||
created: new Date(ulidToTimestamp(e.messageId)),
|
||||
bot: sender.startsWith("bot:") || sender !== currentUserId,
|
||||
}
|
||||
}
|
||||
|
||||
export type ConnectInput = {
|
||||
envelope: ChatToken
|
||||
sandboxId: string
|
||||
currentUserId: string
|
||||
}
|
||||
|
||||
export async function connect(input: ConnectInput): Promise<ClawChatClient> {
|
||||
const events = new EventServiceClient({
|
||||
url: input.envelope.eventServiceUrl,
|
||||
getToken: async () => input.envelope.token,
|
||||
})
|
||||
const chat = new KiloChatClient({
|
||||
baseUrl: input.envelope.kiloChatUrl,
|
||||
getToken: async () => input.envelope.token,
|
||||
})
|
||||
|
||||
log.info("connecting to event-service")
|
||||
await events.connect()
|
||||
|
||||
// Subscribe to sandbox-level context for conversation.* + bot.status events
|
||||
const sandboxCtx = `/kiloclaw/${input.sandboxId}`
|
||||
events.subscribe([sandboxCtx])
|
||||
|
||||
// Per-message sender cache so message.updated events know who the sender
|
||||
// was (the event payload only carries messageId + content). Indexed by
|
||||
// messageId — each conversation's messages live in their own ULID space.
|
||||
const senderCache = new Map<string, string>()
|
||||
|
||||
// Conversation list state (refreshed reactively from server events).
|
||||
let conversations: ConversationListItem[] = []
|
||||
let activeId: string | null = null
|
||||
let activeStatus: ConversationStatusRecord | null = null
|
||||
let activeCtx: string | null = null
|
||||
|
||||
// Typing state for the active conversation. memberId → expiresAt epoch.
|
||||
// Entries auto-expire after TYPING_TIMEOUT_MS in case typing.stop is missed.
|
||||
const TYPING_TIMEOUT_MS = 5_000
|
||||
const typing = new Map<string, number>()
|
||||
let typingTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const messageListeners = new Set<(msg: ChatMessage) => void>()
|
||||
const messageUpdatedListeners = new Set<(msg: ChatMessage) => void>()
|
||||
const presenceListeners = new Set<(online: boolean) => void>()
|
||||
const conversationsListeners = new Set<ConversationsListener>()
|
||||
const activeListeners = new Set<ActiveConversationListener>()
|
||||
const statusListeners = new Set<ConversationStatusListener>()
|
||||
const typingListeners = new Set<TypingMembersListener>()
|
||||
|
||||
function emit<T>(set: Set<(v: T) => void>, value: T): void {
|
||||
for (const cb of set) cb(value)
|
||||
}
|
||||
|
||||
try {
|
||||
const net = require("net")
|
||||
const origDestroy = net.Socket.prototype.destroy
|
||||
net.Socket.prototype.destroy = function (err: any, cb: any) {
|
||||
if (err != null && !(err instanceof Error)) {
|
||||
err = new Error(String(err))
|
||||
}
|
||||
return origDestroy.call(this, err, cb)
|
||||
async function loadConversations(): Promise<ConversationListItem[]> {
|
||||
const res = await chat.listConversations({ sandboxId: input.sandboxId, limit: 50 })
|
||||
conversations = res.conversations
|
||||
emit(conversationsListeners, conversations)
|
||||
return conversations
|
||||
}
|
||||
|
||||
function snapshotTyping(): TypingMember[] {
|
||||
const out: TypingMember[] = []
|
||||
for (const [memberId, expiresAt] of typing) {
|
||||
out.push({ memberId, at: expiresAt - TYPING_TIMEOUT_MS })
|
||||
}
|
||||
} catch {
|
||||
// net module unavailable — skip socket patch
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStreamChat() {
|
||||
applyBunPatches()
|
||||
const mod = await import("stream-chat")
|
||||
return mod.StreamChat
|
||||
}
|
||||
|
||||
export async function connect(creds: ChatCredentials): Promise<ClawChatClient> {
|
||||
log.info("loading stream-chat")
|
||||
const StreamChat = await loadStreamChat()
|
||||
log.info("getInstance", { key: creds.apiKey?.substring(0, 8) + "..." })
|
||||
const client = StreamChat.getInstance(creds.apiKey)
|
||||
|
||||
// getInstance returns a singleton — ensure any stale connection is
|
||||
// cleaned up before (re-)connecting so navigate-away-and-back works.
|
||||
if (client.userID) {
|
||||
log.info("disconnecting stale user", { user: client.userID })
|
||||
await client.disconnectUser()
|
||||
return out
|
||||
}
|
||||
|
||||
log.info("connectUser", { user: creds.userId })
|
||||
await client.connectUser({ id: creds.userId }, creds.userToken)
|
||||
function emitTyping(): void {
|
||||
emit(typingListeners, snapshotTyping())
|
||||
}
|
||||
|
||||
log.info("watching channel", { channel: creds.channelId })
|
||||
const channel = client.channel("messaging", creds.channelId)
|
||||
await channel.watch({ presence: true })
|
||||
function clearTyping(): void {
|
||||
if (typing.size === 0) return
|
||||
typing.clear()
|
||||
emitTyping()
|
||||
}
|
||||
|
||||
log.info("connected successfully")
|
||||
const bot = botId(creds)
|
||||
function pruneTyping(): void {
|
||||
const now = Date.now()
|
||||
let removed = false
|
||||
for (const [memberId, expiresAt] of typing) {
|
||||
if (expiresAt <= now) {
|
||||
typing.delete(memberId)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) emitTyping()
|
||||
if (typing.size === 0 && typingTimer !== null) {
|
||||
clearInterval(typingTimer)
|
||||
typingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startTyping(memberId: string): void {
|
||||
typing.set(memberId, Date.now() + TYPING_TIMEOUT_MS)
|
||||
if (typingTimer === null) {
|
||||
typingTimer = setInterval(pruneTyping, 1_000)
|
||||
}
|
||||
emitTyping()
|
||||
}
|
||||
|
||||
function stopTyping(memberId: string): void {
|
||||
if (!typing.delete(memberId)) return
|
||||
emitTyping()
|
||||
if (typing.size === 0 && typingTimer !== null) {
|
||||
clearInterval(typingTimer)
|
||||
typingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function setActive(id: string | null): void {
|
||||
if (activeId === id) return
|
||||
if (activeCtx) {
|
||||
events.unsubscribe([activeCtx])
|
||||
activeCtx = null
|
||||
}
|
||||
activeId = id
|
||||
activeStatus = null
|
||||
clearTyping()
|
||||
if (id) {
|
||||
activeCtx = `/kiloclaw/${input.sandboxId}/${id}`
|
||||
events.subscribe([activeCtx])
|
||||
}
|
||||
emit(activeListeners, activeId)
|
||||
emit(statusListeners, activeStatus)
|
||||
}
|
||||
|
||||
// Initial bot status snapshot
|
||||
const initialOnline = await chat
|
||||
.getBotStatus(input.sandboxId)
|
||||
.then((s) => s.status?.online ?? false)
|
||||
.catch((err) => {
|
||||
log.warn("getBotStatus failed", { error: (err as Error)?.message ?? String(err) })
|
||||
return false
|
||||
})
|
||||
|
||||
// Initial conversation list
|
||||
await loadConversations().catch((err) => {
|
||||
log.warn("listConversations failed", { error: (err as Error)?.message ?? String(err) })
|
||||
})
|
||||
|
||||
// ── Sandbox-scoped event handlers ──────────────────────────────────
|
||||
|
||||
events.on("conversation.created", (ctx) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
void loadConversations()
|
||||
})
|
||||
|
||||
events.on("conversation.renamed", (ctx, e: ConversationRenamedEvent) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
conversations = conversations.map((c) => (c.conversationId === e.conversationId ? { ...c, title: e.title } : c))
|
||||
emit(conversationsListeners, conversations)
|
||||
})
|
||||
|
||||
events.on("conversation.left", (ctx, e) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
conversations = conversations.filter((c) => c.conversationId !== e.conversationId)
|
||||
emit(conversationsListeners, conversations)
|
||||
if (activeId === e.conversationId) setActive(null)
|
||||
})
|
||||
|
||||
events.on("conversation.activity", (ctx, e: ConversationActivityEvent) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
conversations = conversations.map((c) =>
|
||||
c.conversationId === e.conversationId ? { ...c, lastActivityAt: e.lastActivityAt } : c,
|
||||
)
|
||||
emit(conversationsListeners, conversations)
|
||||
})
|
||||
|
||||
events.on("bot.status", (ctx, e: BotStatusEvent) => {
|
||||
if (ctx !== sandboxCtx) return
|
||||
if (e.sandboxId !== input.sandboxId) return
|
||||
emit(presenceListeners, e.online)
|
||||
})
|
||||
|
||||
// ── Conversation-scoped event handlers ─────────────────────────────
|
||||
|
||||
// A fresh message implicitly stops the sender's typing indicator (the bot
|
||||
// streams via message.updated, so its typing.stop arrives when the next
|
||||
// bot message id appears). Handle both in a single listener to avoid
|
||||
// duplicating the activeCtx filter.
|
||||
events.on("message.created", (ctx, e: MessageCreatedEvent) => {
|
||||
if (ctx !== activeCtx) return
|
||||
senderCache.set(e.messageId, e.senderId)
|
||||
if (activeId) trackLastSeen(activeId, e.messageId)
|
||||
if (e.senderId !== input.currentUserId) stopTyping(e.senderId)
|
||||
emit(messageListeners, toChatMessageFromCreated(e, input.currentUserId))
|
||||
})
|
||||
|
||||
events.on("message.updated", (ctx, e: MessageUpdatedEvent) => {
|
||||
if (ctx !== activeCtx) return
|
||||
const sender = senderCache.get(e.messageId) ?? null
|
||||
emit(messageUpdatedListeners, toChatMessageFromUpdated(e, input.currentUserId, sender))
|
||||
})
|
||||
|
||||
events.on("conversation.status", (ctx, e: ConversationStatusEvent) => {
|
||||
if (ctx !== activeCtx) return
|
||||
if (e.conversationId !== activeId) return
|
||||
activeStatus = {
|
||||
conversationId: e.conversationId,
|
||||
contextTokens: e.contextTokens,
|
||||
contextWindow: e.contextWindow,
|
||||
model: e.model,
|
||||
provider: e.provider,
|
||||
at: e.at,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
emit(statusListeners, activeStatus)
|
||||
})
|
||||
|
||||
events.on("typing", (ctx, e: TypingEvent) => {
|
||||
if (ctx !== activeCtx) return
|
||||
if (e.memberId === input.currentUserId) return
|
||||
startTyping(e.memberId)
|
||||
})
|
||||
|
||||
events.on("typing.stop", (ctx, e: TypingEvent) => {
|
||||
if (ctx !== activeCtx) return
|
||||
if (e.memberId === input.currentUserId) return
|
||||
stopTyping(e.memberId)
|
||||
})
|
||||
|
||||
// Latest server-confirmed message id per conversation. Used to satisfy the
|
||||
// mark-read endpoint's `lastSeenMessageId` requirement without re-listing.
|
||||
const lastSeenByConv = new Map<string, string>()
|
||||
|
||||
function trackLastSeen(conversationId: string, messageId: string): void {
|
||||
if (!messageId || messageId.startsWith("pending-")) return
|
||||
const prev = lastSeenByConv.get(conversationId)
|
||||
if (!prev || prev < messageId) lastSeenByConv.set(conversationId, messageId)
|
||||
}
|
||||
|
||||
async function loadHistory(conversationId?: string): Promise<ChatMessage[]> {
|
||||
const id = conversationId ?? activeId
|
||||
if (!id) return []
|
||||
const res = await chat.listMessages(id, { limit: 50 })
|
||||
const ascending = [...res.messages].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
||||
for (const m of ascending) {
|
||||
senderCache.set(m.id, m.senderId)
|
||||
trackLastSeen(id, m.id)
|
||||
}
|
||||
return ascending.map((m) => toChatMessage(m, input.currentUserId))
|
||||
}
|
||||
|
||||
async function selectConversation(conversationId: string) {
|
||||
setActive(conversationId)
|
||||
const msgs = await loadHistory(conversationId)
|
||||
// The user may have switched conversations while we awaited. Only
|
||||
// publish the status via listeners if it still matches the active one,
|
||||
// but always return the status we fetched for the requested id so the
|
||||
// caller can use it if they need to.
|
||||
const status = await chat.getConversationStatus(conversationId).then(
|
||||
(res) => res.status ?? null,
|
||||
(err) => {
|
||||
log.warn("getConversationStatus failed", { error: (err as Error)?.message ?? String(err) })
|
||||
return null
|
||||
},
|
||||
)
|
||||
if (activeId === conversationId) {
|
||||
activeStatus = status
|
||||
emit(statusListeners, activeStatus)
|
||||
}
|
||||
const lastSeen = lastSeenByConv.get(conversationId)
|
||||
if (lastSeen) {
|
||||
await chat.markConversationRead(conversationId, { lastSeenMessageId: lastSeen }).catch((err) => {
|
||||
log.warn("markConversationRead failed", { error: (err as Error)?.message ?? String(err) })
|
||||
})
|
||||
}
|
||||
return { messages: msgs, status }
|
||||
}
|
||||
|
||||
async function createConversation(title?: string): Promise<string> {
|
||||
const res = await chat.createConversation({ sandboxId: input.sandboxId, title })
|
||||
await loadConversations()
|
||||
return res.conversationId
|
||||
}
|
||||
|
||||
return {
|
||||
channel,
|
||||
async disconnect() {
|
||||
await client.disconnectUser()
|
||||
if (typingTimer !== null) {
|
||||
clearInterval(typingTimer)
|
||||
typingTimer = null
|
||||
}
|
||||
typing.clear()
|
||||
events.disconnect()
|
||||
},
|
||||
listConversations() {
|
||||
return conversations
|
||||
},
|
||||
activeConversationId() {
|
||||
return activeId
|
||||
},
|
||||
typingMembers() {
|
||||
return snapshotTyping()
|
||||
},
|
||||
async refreshConversations() {
|
||||
return await loadConversations()
|
||||
},
|
||||
selectConversation,
|
||||
createConversation,
|
||||
async renameConversation(conversationId, title) {
|
||||
conversations = conversations.map((c) => (c.conversationId === conversationId ? { ...c, title } : c))
|
||||
emit(conversationsListeners, conversations)
|
||||
await chat.renameConversation(conversationId, title)
|
||||
},
|
||||
async deleteConversation(conversationId) {
|
||||
await chat.leaveConversation(conversationId)
|
||||
conversations = conversations.filter((c) => c.conversationId !== conversationId)
|
||||
emit(conversationsListeners, conversations)
|
||||
if (activeId === conversationId) setActive(null)
|
||||
},
|
||||
async send(text: string) {
|
||||
await channel.sendMessage({ text })
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
if (!activeId) {
|
||||
// Auto-create a conversation on first send if none is active.
|
||||
const id = await createConversation()
|
||||
await selectConversation(id)
|
||||
}
|
||||
if (!activeId) return
|
||||
await chat.sendMessage({
|
||||
conversationId: activeId,
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
})
|
||||
},
|
||||
loadHistory,
|
||||
initialBotOnline() {
|
||||
return initialOnline
|
||||
},
|
||||
conversationStatus() {
|
||||
return activeStatus
|
||||
},
|
||||
onMessage(cb) {
|
||||
const handler = (event: any) => {
|
||||
if (event.message) cb(toMessage(event.message, bot))
|
||||
}
|
||||
channel.on("message.new", handler)
|
||||
return () => channel.off("message.new", handler)
|
||||
messageListeners.add(cb)
|
||||
return () => messageListeners.delete(cb)
|
||||
},
|
||||
onMessageUpdated(cb) {
|
||||
const handler = (event: any) => {
|
||||
if (event.message) cb(toMessage(event.message, bot))
|
||||
}
|
||||
channel.on("message.updated", handler)
|
||||
return () => channel.off("message.updated", handler)
|
||||
messageUpdatedListeners.add(cb)
|
||||
return () => messageUpdatedListeners.delete(cb)
|
||||
},
|
||||
onPresence(cb) {
|
||||
const handler = (event: any) => {
|
||||
if (event.user?.id === bot) {
|
||||
cb(event.user.online ?? false)
|
||||
}
|
||||
}
|
||||
client.on("user.presence.changed", handler)
|
||||
return () => client.off("user.presence.changed", handler)
|
||||
presenceListeners.add(cb)
|
||||
return () => presenceListeners.delete(cb)
|
||||
},
|
||||
onConversations(cb) {
|
||||
conversationsListeners.add(cb)
|
||||
return () => conversationsListeners.delete(cb)
|
||||
},
|
||||
onActiveConversation(cb) {
|
||||
activeListeners.add(cb)
|
||||
return () => activeListeners.delete(cb)
|
||||
},
|
||||
onConversationStatus(cb) {
|
||||
statusListeners.add(cb)
|
||||
return () => statusListeners.delete(cb)
|
||||
},
|
||||
onTypingMembers(cb) {
|
||||
typingListeners.add(cb)
|
||||
return () => typingListeners.delete(cb)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function history(channel: any, bot: string): Promise<ChatMessage[]> {
|
||||
const state = channel.state.messages
|
||||
return state.map((raw: any) => toMessage(raw, bot))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bot's initial online status from channel member state.
|
||||
* Mirrors the cloud's `useBotOnlineStatus` which reads
|
||||
* `channel.state.members[botUserId]?.user?.online`.
|
||||
*/
|
||||
export function presence(channel: any, bot: string): boolean {
|
||||
const member = channel.state.members?.[bot]
|
||||
return !!member?.user?.online
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Conversation selector for the KiloClaw chat.
|
||||
*
|
||||
* Mirrors the session-list dialog (`cli/cmd/tui/component/dialog-session-list.tsx`):
|
||||
* - DialogSelect-backed list, grouped by date bucket
|
||||
* - "current" marker on the active conversation
|
||||
* - inline rename via DialogPrompt (replace pattern)
|
||||
* - two-press delete confirmation with `theme.error` background
|
||||
*/
|
||||
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { DialogPrompt } from "@tui/ui/dialog-prompt"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useToast } from "@tui/ui/toast"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import type { ClawChat } from "./hooks"
|
||||
|
||||
type Props = {
|
||||
chat: ClawChat
|
||||
}
|
||||
|
||||
export function DialogConversationList(props: Props) {
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
const list = [...props.chat.conversations()]
|
||||
list.sort((a, b) => {
|
||||
const ax = a.lastActivityAt ?? a.joinedAt
|
||||
const bx = b.lastActivityAt ?? b.joinedAt
|
||||
return bx - ax
|
||||
})
|
||||
return list.map((c) => {
|
||||
const ts = c.lastActivityAt ?? c.joinedAt
|
||||
const date = new Date(ts)
|
||||
let category = date.toDateString()
|
||||
if (category === today) category = "Today"
|
||||
const isDeleting = toDelete() === c.conversationId
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : (c.title ?? "Untitled"),
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: c.conversationId,
|
||||
category,
|
||||
footer: Locale.time(ts),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="KiloClaw Conversations"
|
||||
options={options()}
|
||||
current={props.chat.activeConversationId() ?? undefined}
|
||||
onMove={() => setToDelete(undefined)}
|
||||
onSelect={async (option) => {
|
||||
await props.chat.selectConversation(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
// `deleteConversation` already catches internally and returns
|
||||
// a boolean; its outer promise never rejects here.
|
||||
const ok = await props.chat.deleteConversation(option.value)
|
||||
if (!ok) {
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: "Failed to delete conversation",
|
||||
message: "Please try again.",
|
||||
})
|
||||
}
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
const item = props.chat.conversations().find((c) => c.conversationId === option.value)
|
||||
const list = () => dialog.replace(() => <DialogConversationList chat={props.chat} />)
|
||||
dialog.replace(() => (
|
||||
<DialogPrompt
|
||||
title="Rename Conversation"
|
||||
value={item?.title ?? ""}
|
||||
placeholder="Enter a title"
|
||||
onConfirm={async (value) => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed) {
|
||||
await props.chat.renameConversation(option.value, trimmed)
|
||||
}
|
||||
list()
|
||||
}}
|
||||
onCancel={list}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+n")[0],
|
||||
title: "new",
|
||||
side: "right",
|
||||
onTrigger: async () => {
|
||||
await props.chat.newConversation()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Event Service WebSocket client for the TUI.
|
||||
*
|
||||
* Minimal inline port of `@kilocode/event-service` (cloud monorepo).
|
||||
* Connects via a two-step ticket flow:
|
||||
* 1. POST `/connect-ticket` with `Authorization: Bearer <JWT>` to mint a
|
||||
* single-use ticket (30 s TTL).
|
||||
* 2. Open WebSocket to `/connect?ticket=<ticket>` with subprotocol
|
||||
* `kilo.events.v1`.
|
||||
*
|
||||
* Uses the global `WebSocket` constructor (Bun, Node 22+, browsers).
|
||||
*/
|
||||
|
||||
import type { KiloChatEventMap, KiloChatEventName } from "./types"
|
||||
|
||||
const WS_SUBPROTOCOL = "kilo.events.v1"
|
||||
const HANDSHAKE_TIMEOUT_MS = 10_000
|
||||
const PING_INTERVAL_MS = 15_000
|
||||
const TICKET_FETCH_TIMEOUT_MS = 10_000
|
||||
|
||||
export class WebSocketAuthError extends Error {
|
||||
constructor(message = "WebSocket authentication failed") {
|
||||
super(message)
|
||||
this.name = "WebSocketAuthError"
|
||||
}
|
||||
}
|
||||
|
||||
export class WebSocketConnectError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "WebSocketConnectError"
|
||||
}
|
||||
}
|
||||
|
||||
export class HandshakeTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("WebSocket handshake timed out")
|
||||
this.name = "HandshakeTimeoutError"
|
||||
}
|
||||
}
|
||||
|
||||
// Close codes that signal the server rejected us for auth/policy reasons
|
||||
// and reconnecting with the same token is pointless. Everything else
|
||||
// (including 1006 "abnormal closure" from flaky networks) is transient.
|
||||
function isAuthCloseCode(code: number): boolean {
|
||||
if (code === 1008) return true // Policy Violation
|
||||
if (code === 4401 || code === 4403) return true // Custom auth rejection
|
||||
return false
|
||||
}
|
||||
|
||||
export type EventHandler = (context: string, payload: unknown) => void
|
||||
|
||||
export type EventServiceConfig = {
|
||||
url: string
|
||||
getToken: () => Promise<string>
|
||||
onUnauthorized?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The event-service base URL is configured as a WebSocket URL (`wss://…` /
|
||||
* `ws://…`) but the connect-ticket endpoint is a plain HTTP request. Strip
|
||||
* the trailing slash and swap the protocol so `fetch()` accepts the URL.
|
||||
*/
|
||||
function toHttpBase(wsBase: string): string {
|
||||
const trimmed = wsBase.replace(/\/$/, "")
|
||||
if (trimmed.startsWith("wss://")) return "https://" + trimmed.slice(6)
|
||||
if (trimmed.startsWith("ws://")) return "http://" + trimmed.slice(5)
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export class EventServiceClient {
|
||||
private readonly url: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
private readonly onUnauthorized: (() => void) | undefined
|
||||
|
||||
private ws: WebSocket | null = null
|
||||
private connected = false
|
||||
private destroyed = false
|
||||
private reconnectAttempts = 0
|
||||
private hasConnectedBefore = false
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private abortHandshake: ((err: Error) => void) | null = null
|
||||
|
||||
private eventHandlers = new Map<string, Set<EventHandler>>()
|
||||
private activeContexts = new Set<string>()
|
||||
private reconnectHandlers = new Set<() => void>()
|
||||
|
||||
constructor(config: EventServiceConfig) {
|
||||
this.url = config.url
|
||||
this.getToken = config.getToken
|
||||
this.onUnauthorized = config.onUnauthorized
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.destroyed = false
|
||||
this.reconnectAttempts = 0
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
try {
|
||||
await this.connectOnce()
|
||||
} catch (err) {
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.destroyed = true
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.clearHandshakeTimer()
|
||||
if (this.abortHandshake) {
|
||||
this.abortHandshake(new Error("disconnected"))
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.stopPing()
|
||||
this.connected = false
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
subscribe(contexts: string[]): void {
|
||||
for (const ctx of contexts) this.activeContexts.add(ctx)
|
||||
if (this.isConnected()) {
|
||||
this.sendJson({ type: "context.subscribe", contexts })
|
||||
}
|
||||
}
|
||||
|
||||
unsubscribe(contexts: string[]): void {
|
||||
for (const ctx of contexts) this.activeContexts.delete(ctx)
|
||||
if (this.isConnected()) {
|
||||
this.sendJson({ type: "context.unsubscribe", contexts })
|
||||
}
|
||||
}
|
||||
|
||||
on<N extends KiloChatEventName>(event: N, handler: (ctx: string, payload: KiloChatEventMap[N]) => void): () => void {
|
||||
const set = this.eventHandlers.get(event) ?? new Set<EventHandler>()
|
||||
const wrapped: EventHandler = (ctx, payload) => handler(ctx, payload as KiloChatEventMap[N])
|
||||
set.add(wrapped)
|
||||
this.eventHandlers.set(event, set)
|
||||
return () => {
|
||||
set.delete(wrapped)
|
||||
if (set.size === 0) this.eventHandlers.delete(event)
|
||||
}
|
||||
}
|
||||
|
||||
onReconnect(handler: () => void): () => void {
|
||||
this.reconnectHandlers.add(handler)
|
||||
return () => this.reconnectHandlers.delete(handler)
|
||||
}
|
||||
|
||||
// ── private ────────────────────────────────────────────────────────
|
||||
|
||||
private handleAuthFailure(err: unknown): boolean {
|
||||
if (err instanceof WebSocketAuthError) {
|
||||
this.destroyed = true
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
this.onUnauthorized?.()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async connectOnce(): Promise<void> {
|
||||
if (this.ws) {
|
||||
const old = this.ws
|
||||
this.ws = null
|
||||
old.close()
|
||||
}
|
||||
|
||||
const token = await this.getToken()
|
||||
const ticket = await this.fetchTicket(token)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`${this.url}/connect?ticket=${encodeURIComponent(ticket)}`, [WS_SUBPROTOCOL])
|
||||
this.ws = ws
|
||||
|
||||
let settled = false
|
||||
const settleResolve = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
this.clearHandshakeTimer()
|
||||
this.abortHandshake = null
|
||||
resolve()
|
||||
}
|
||||
const settleReject = (err: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
this.clearHandshakeTimer()
|
||||
this.abortHandshake = null
|
||||
reject(err)
|
||||
}
|
||||
this.abortHandshake = settleReject
|
||||
|
||||
this.handshakeTimer = setTimeout(() => {
|
||||
this.handshakeTimer = null
|
||||
if (this.ws === ws) ws.close(1000, "handshake-timeout")
|
||||
settleReject(new HandshakeTimeoutError())
|
||||
}, HANDSHAKE_TIMEOUT_MS)
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
const isReconnect = this.hasConnectedBefore
|
||||
this.connected = true
|
||||
this.hasConnectedBefore = true
|
||||
this.reconnectAttempts = 0
|
||||
this.resubscribeContexts()
|
||||
if (isReconnect) {
|
||||
for (const h of this.reconnectHandlers) h()
|
||||
}
|
||||
settleResolve()
|
||||
this.startPing()
|
||||
})
|
||||
|
||||
ws.addEventListener("message", (event: MessageEvent) => {
|
||||
this.handleMessage(String(event.data))
|
||||
})
|
||||
|
||||
ws.addEventListener("close", (event: CloseEvent) => {
|
||||
if (this.ws !== ws) return
|
||||
const wasConnected = this.connected
|
||||
this.connected = false
|
||||
this.stopPing()
|
||||
this.clearHandshakeTimer()
|
||||
// A handshake failure always fires `close` after `error`, so we
|
||||
// settle here with a classification based on the close code:
|
||||
// explicit auth/policy codes → fatal; anything else → transient
|
||||
// and the caller (`connect`) will schedule a reconnect.
|
||||
if (!wasConnected) {
|
||||
if (isAuthCloseCode(event.code)) {
|
||||
settleReject(new WebSocketAuthError())
|
||||
} else {
|
||||
settleReject(
|
||||
new WebSocketConnectError(`WebSocket closed before open: ${event.code} ${event.reason}`, event.code),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
// Swallowed: the `close` event fires right after and carries the
|
||||
// close code we need to distinguish auth failures from network
|
||||
// blips. Settling here loses that context.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a single-use connection ticket. The event-service issues a 30 s ticket
|
||||
* scoped to the bearer JWT; the WebSocket upgrade then consumes it.
|
||||
*
|
||||
* `this.url` is the WebSocket base (`wss://…` or `ws://…`); `fetch()` only
|
||||
* accepts `http(s)`, so we rewrite the protocol before the HTTP call.
|
||||
*/
|
||||
private async fetchTicket(token: string): Promise<string> {
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), TICKET_FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const res = await fetch(toHttpBase(this.url) + "/connect-ticket", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new WebSocketAuthError(`Event-service rejected ticket request: ${res.status}`)
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new WebSocketConnectError(`Failed to mint event-service ticket: ${res.status}`, res.status)
|
||||
}
|
||||
const body = (await res.json().catch(() => null)) as { ticket?: unknown } | null
|
||||
if (!body || typeof body.ticket !== "string" || !body.ticket) {
|
||||
throw new WebSocketConnectError("Malformed event-service ticket response", 0)
|
||||
}
|
||||
return body.ticket
|
||||
} catch (err) {
|
||||
if (err instanceof WebSocketAuthError || err instanceof WebSocketConnectError) throw err
|
||||
if ((err as { name?: string })?.name === "AbortError") {
|
||||
throw new HandshakeTimeoutError()
|
||||
}
|
||||
throw new WebSocketConnectError(`Event-service ticket request failed: ${(err as Error)?.message ?? err}`, 0)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
private clearHandshakeTimer(): void {
|
||||
if (this.handshakeTimer !== null) {
|
||||
clearTimeout(this.handshakeTimer)
|
||||
this.handshakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private sendJson(msg: unknown): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg))
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(data: string): void {
|
||||
if (data === "pong") return
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(data)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return
|
||||
const m = parsed as Record<string, unknown>
|
||||
if (m.type === "event" && typeof m.context === "string" && typeof m.event === "string") {
|
||||
const handlers = this.eventHandlers.get(m.event)
|
||||
if (handlers) {
|
||||
for (const h of handlers) h(m.context, m.payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (m.type === "error") {
|
||||
console.warn("[Kilo] event-service server error", m)
|
||||
}
|
||||
}
|
||||
|
||||
private startPing(): void {
|
||||
this.stopPing()
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send("ping")
|
||||
}
|
||||
}, PING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer !== null) {
|
||||
clearInterval(this.pingTimer)
|
||||
this.pingTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private resubscribeContexts(): void {
|
||||
if (this.activeContexts.size > 0) {
|
||||
this.sendJson({
|
||||
type: "context.subscribe",
|
||||
contexts: Array.from(this.activeContexts),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer !== null) return
|
||||
const base = Math.min(30_000, 1000 * 2 ** this.reconnectAttempts)
|
||||
const delay = base * (0.5 + Math.random() * 0.5)
|
||||
this.reconnectAttempts++
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connectOnce().catch((err) => {
|
||||
if (this.handleAuthFailure(err)) return
|
||||
if (!this.destroyed) this.scheduleReconnect()
|
||||
})
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,38 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* KiloClaw SolidJS reactive helpers
|
||||
* KiloClaw SolidJS reactive helpers — Kilo Chat protocol.
|
||||
*
|
||||
* Provides reactive state management for polling instance status
|
||||
* and managing the Stream Chat connection lifecycle.
|
||||
* Exposes:
|
||||
* - `createClawStatus`: polled instance status
|
||||
* - `createClawChat`: Kilo Chat connection + active-conversation state
|
||||
*
|
||||
* `createClawChat` returns a reactive surface the view binds to: messages,
|
||||
* conversation list, active conversation id + status (token counter), bot
|
||||
* presence, and imperative actions (send / new / select / rename / delete).
|
||||
*/
|
||||
|
||||
import { createSignal, onMount, onCleanup } from "solid-js"
|
||||
import type { ClawStatus, ChatCredentials, ChatMessage } from "./types"
|
||||
import { botId, connect, history, presence, type ClawChatClient } from "./client"
|
||||
import type {
|
||||
ChatMessage,
|
||||
ChatToken,
|
||||
ClawStatus,
|
||||
ConversationListItem,
|
||||
ConversationStatusRecord,
|
||||
TypingMember,
|
||||
} from "./types"
|
||||
import { connect, type ClawChatClient } from "./client"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "claw-chat" })
|
||||
|
||||
type SDK = ReturnType<typeof useSDK>
|
||||
|
||||
/**
|
||||
* Poll the KiloClaw instance status every `interval` ms.
|
||||
* Returns a reactive signal with the latest status.
|
||||
*/
|
||||
export function createClawStatus(sdk: any, interval = 10_000) {
|
||||
export function createClawStatus(sdk: SDK, interval = 10_000) {
|
||||
const [status, setStatus] = createSignal<ClawStatus | null>(null)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
@@ -30,7 +44,10 @@ export function createClawStatus(sdk: any, interval = 10_000) {
|
||||
setStatus(res.data as ClawStatus)
|
||||
setError(null)
|
||||
} else if (res?.error) {
|
||||
setError(typeof res.error === "string" ? res.error : (res.error?.error ?? "Unknown error"))
|
||||
// Gateway error envelopes are either `{ error: "..." }` or a plain
|
||||
// string; the SDK types the error body as `unknown`.
|
||||
const err = res.error as string | { error?: string } | null
|
||||
setError(typeof err === "string" ? err : (err?.error ?? "Unknown error"))
|
||||
} else {
|
||||
setError("Network error")
|
||||
}
|
||||
@@ -44,97 +61,189 @@ export function createClawStatus(sdk: any, interval = 10_000) {
|
||||
return { status, error, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Stream Chat credentials and manage the chat connection.
|
||||
* Returns reactive signals for messages, bot presence, and connection state.
|
||||
*/
|
||||
export function createClawChat(sdk: any) {
|
||||
export type ClawChat = ReturnType<typeof createClawChat>
|
||||
|
||||
export function createClawChat(sdk: SDK) {
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([])
|
||||
const [online, setOnline] = createSignal(false)
|
||||
const [connected, setConnected] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [loading, setLoading] = createSignal(true)
|
||||
const [conversations, setConversations] = createSignal<ConversationListItem[]>([])
|
||||
const [activeConversationId, setActiveConversationId] = createSignal<string | null>(null)
|
||||
const [conversationStatus, setConversationStatus] = createSignal<ConversationStatusRecord | null>(null)
|
||||
const [typingMembers, setTypingMembers] = createSignal<TypingMember[]>([])
|
||||
|
||||
const MAX_MESSAGES = 500
|
||||
let chat: ClawChatClient | null = null
|
||||
|
||||
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err))
|
||||
|
||||
const send = async (text: string): Promise<boolean> => {
|
||||
if (!chat) return false
|
||||
try {
|
||||
await chat.send(text)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
log.error("send failed", { error: err?.message ?? String(err) })
|
||||
} catch (err) {
|
||||
log.error("send failed", { error: errText(err) })
|
||||
setError("Failed to send message")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const newConversation = async (title?: string): Promise<string | null> => {
|
||||
if (!chat) return null
|
||||
try {
|
||||
const id = await chat.createConversation(title)
|
||||
await selectConversation(id)
|
||||
return id
|
||||
} catch (err) {
|
||||
log.error("createConversation failed", { error: errText(err) })
|
||||
setError("Failed to create conversation")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const selectConversation = async (conversationId: string): Promise<boolean> => {
|
||||
if (!chat) return false
|
||||
try {
|
||||
const result = await chat.selectConversation(conversationId)
|
||||
setMessages(result.messages)
|
||||
setConversationStatus(result.status)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error("selectConversation failed", { error: errText(err) })
|
||||
setError("Failed to load conversation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const renameConversation = async (conversationId: string, title: string): Promise<boolean> => {
|
||||
if (!chat) return false
|
||||
try {
|
||||
await chat.renameConversation(conversationId, title)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error("renameConversation failed", { error: errText(err) })
|
||||
setError("Failed to rename conversation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteConversation = async (conversationId: string): Promise<boolean> => {
|
||||
if (!chat) return false
|
||||
try {
|
||||
await chat.deleteConversation(conversationId)
|
||||
return true
|
||||
} catch (err) {
|
||||
log.error("deleteConversation failed", { error: errText(err) })
|
||||
setError("Failed to delete conversation")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshConversations = async (): Promise<void> => {
|
||||
if (!chat) return
|
||||
try {
|
||||
await chat.refreshConversations()
|
||||
} catch (err) {
|
||||
log.warn("refreshConversations failed", { error: errText(err) })
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Register cleanup synchronously while the SolidJS owner scope is
|
||||
// still available. After any `await` the owner is lost, so
|
||||
// `onCleanup` calls would be silently discarded.
|
||||
const cleanup = {
|
||||
unsub: null as (() => void) | null,
|
||||
unsubUpdated: null as (() => void) | null,
|
||||
unsubPresence: null as (() => void) | null,
|
||||
unsubConvs: null as (() => void) | null,
|
||||
unsubActive: null as (() => void) | null,
|
||||
unsubStatus: null as (() => void) | null,
|
||||
unsubTyping: null as (() => void) | null,
|
||||
}
|
||||
onCleanup(() => {
|
||||
cleanup.unsub?.()
|
||||
cleanup.unsubUpdated?.()
|
||||
cleanup.unsubPresence?.()
|
||||
cleanup.unsubConvs?.()
|
||||
cleanup.unsubActive?.()
|
||||
cleanup.unsubStatus?.()
|
||||
cleanup.unsubTyping?.()
|
||||
if (chat) {
|
||||
chat.disconnect().catch((err) => {
|
||||
log.error("disconnect failed", { error: err?.message ?? String(err) })
|
||||
log.error("disconnect failed", { error: errText(err) })
|
||||
})
|
||||
}
|
||||
chat = null
|
||||
})
|
||||
|
||||
log.info("fetching credentials")
|
||||
const res = await sdk.client.kilo.claw.chatCredentials().catch((e: any) => {
|
||||
log.error("chatCredentials() threw", { error: e?.message ?? String(e) })
|
||||
return null
|
||||
})
|
||||
|
||||
log.info("credentials response", {
|
||||
hasData: String(!!res?.data),
|
||||
dataIsNull: String(res?.data === null),
|
||||
error: res?.error ? String(res.error) : "none",
|
||||
dataKeys: res?.data ? Object.keys(res.data).join(",") : "none",
|
||||
})
|
||||
|
||||
if (!res?.data || res.error) {
|
||||
setError(res?.data === null ? null : "Failed to fetch chat credentials")
|
||||
log.info("fetching status + credentials")
|
||||
const statusRes = await sdk.client.kilo.claw.status().catch(() => null)
|
||||
const statusData = statusRes?.data as (ClawStatus & { userId?: string; sandboxId?: string }) | undefined
|
||||
if (!statusRes || statusRes.error || !statusData?.userId || !statusData?.sandboxId) {
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const creds = res.data as ChatCredentials
|
||||
const credsRes = await sdk.client.kilo.claw.chatCredentials().catch((e: unknown) => {
|
||||
log.error("chatCredentials() threw", { error: errText(e) })
|
||||
return null
|
||||
})
|
||||
|
||||
if (!credsRes?.data || credsRes.error) {
|
||||
setError(credsRes?.data === null ? null : "Failed to fetch chat credentials")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const envelope = credsRes.data as ChatToken
|
||||
const missing: string[] = []
|
||||
if (!envelope.token) missing.push("token")
|
||||
if (!envelope.kiloChatUrl) missing.push("kiloChatUrl")
|
||||
if (!envelope.eventServiceUrl) missing.push("eventServiceUrl")
|
||||
if (missing.length > 0) {
|
||||
setError(`Malformed chat credentials response: missing ${missing.join(", ")}`)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
log.info("calling connect()")
|
||||
chat = await connect(creds)
|
||||
log.info("connect() succeeded")
|
||||
log.info("connecting kilo-chat")
|
||||
chat = await connect({
|
||||
envelope,
|
||||
sandboxId: statusData.sandboxId,
|
||||
currentUserId: statusData.userId,
|
||||
})
|
||||
log.info("connected")
|
||||
|
||||
// Load existing messages
|
||||
const bot = botId(creds)
|
||||
const existing = await history(chat.channel, bot)
|
||||
log.info("loaded history", { count: existing.length })
|
||||
setMessages(existing)
|
||||
setConversations(chat.listConversations())
|
||||
setOnline(chat.initialBotOnline())
|
||||
|
||||
// Read initial bot presence from channel member state
|
||||
setOnline(presence(chat.channel, bot))
|
||||
cleanup.unsubConvs = chat.onConversations((list) => {
|
||||
setConversations(list)
|
||||
})
|
||||
|
||||
cleanup.unsubActive = chat.onActiveConversation((id) => {
|
||||
setActiveConversationId(id)
|
||||
})
|
||||
|
||||
cleanup.unsubStatus = chat.onConversationStatus((status) => {
|
||||
setConversationStatus(status)
|
||||
})
|
||||
|
||||
cleanup.unsubTyping = chat.onTypingMembers((members) => {
|
||||
setTypingMembers(members)
|
||||
})
|
||||
|
||||
// Subscribe to new messages
|
||||
cleanup.unsub = chat.onMessage((msg) => {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev
|
||||
const next = [...prev, msg]
|
||||
return next.length > MAX_MESSAGES ? next.slice(-MAX_MESSAGES) : next
|
||||
})
|
||||
})
|
||||
|
||||
// Subscribe to message updates (bot streams token-by-token via message.updated)
|
||||
cleanup.unsubUpdated = chat.onMessageUpdated((msg) => {
|
||||
setMessages((prev) => {
|
||||
const idx = prev.findIndex((m) => m.id === msg.id)
|
||||
@@ -148,22 +257,44 @@ export function createClawChat(sdk: any) {
|
||||
})
|
||||
})
|
||||
|
||||
// Subscribe to bot presence changes
|
||||
cleanup.unsubPresence = chat.onPresence(setOnline)
|
||||
|
||||
// Auto-open the most recent conversation, if any.
|
||||
const list = chat.listConversations()
|
||||
if (list.length > 0) {
|
||||
await selectConversation(list[0]!.conversationId)
|
||||
}
|
||||
|
||||
setConnected(true)
|
||||
setLoading(false)
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
const e = err as { message?: string; name?: string; code?: string; stack?: string }
|
||||
log.error("connect failed", {
|
||||
error: err?.message ?? String(err),
|
||||
name: err?.name,
|
||||
code: err?.code,
|
||||
stack: err?.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
error: errText(err),
|
||||
name: e?.name,
|
||||
code: e?.code,
|
||||
stack: e?.stack?.split("\n").slice(0, 5).join("\n"),
|
||||
})
|
||||
setError(err?.message ?? "Failed to connect to chat")
|
||||
setError(e?.message ?? "Failed to connect to chat")
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return { messages, online, connected, error, loading, send }
|
||||
return {
|
||||
messages,
|
||||
online,
|
||||
connected,
|
||||
error,
|
||||
loading,
|
||||
send,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
conversationStatus,
|
||||
typingMembers,
|
||||
newConversation,
|
||||
selectConversation,
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
refreshConversations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* HTTP client for the kilo-chat Cloudflare Worker.
|
||||
*
|
||||
* Minimal inline port of `@kilocode/kilo-chat/client` (cloud monorepo)
|
||||
* tailored to what the TUI needs. The kilo-chat worker validates payloads
|
||||
* at its edge so we don't run zod here.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BotStatusRecord,
|
||||
ContentBlock,
|
||||
ConversationListItem,
|
||||
ConversationStatusRecord,
|
||||
ExecApprovalDecision,
|
||||
Message,
|
||||
} from "./types"
|
||||
|
||||
export type KiloChatClientConfig = {
|
||||
baseUrl: string
|
||||
getToken: () => Promise<string>
|
||||
onUnauthorized?: () => void
|
||||
}
|
||||
|
||||
export class KiloChatApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly body: unknown,
|
||||
) {
|
||||
super(`KiloChat request failed: ${status}${formatBodyDetail(body)}`)
|
||||
this.name = "KiloChatApiError"
|
||||
}
|
||||
}
|
||||
|
||||
function formatBodyDetail(body: unknown): string {
|
||||
if (body === null || body === undefined) return ""
|
||||
if (typeof body === "string") return ` - ${body}`
|
||||
if (typeof body === "object") {
|
||||
const err = (body as Record<string, unknown>).error
|
||||
if (typeof err === "string") return ` - ${err}`
|
||||
try {
|
||||
return ` - ${JSON.stringify(body)}`
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type HttpOpts = {
|
||||
method?: string
|
||||
body?: unknown
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
}
|
||||
|
||||
type SendQueue = Map<string, Promise<unknown>>
|
||||
|
||||
export class KiloChatClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly getToken: () => Promise<string>
|
||||
private readonly onUnauthorized: (() => void) | undefined
|
||||
private readonly sendQueues: SendQueue = new Map()
|
||||
|
||||
constructor(config: KiloChatClientConfig) {
|
||||
this.baseUrl = config.baseUrl.replace(/\/$/, "")
|
||||
this.getToken = config.getToken
|
||||
this.onUnauthorized = config.onUnauthorized
|
||||
}
|
||||
|
||||
// ── Conversations ────────────────────────────────────────────────
|
||||
|
||||
listConversations(opts?: { sandboxId?: string; limit?: number; cursor?: string | null }): Promise<{
|
||||
conversations: ConversationListItem[]
|
||||
hasMore: boolean
|
||||
nextCursor: string | null
|
||||
}> {
|
||||
return this.request("/v1/conversations", {
|
||||
query: {
|
||||
sandboxId: opts?.sandboxId,
|
||||
limit: opts?.limit,
|
||||
cursor: opts?.cursor ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createConversation(req: {
|
||||
sandboxId: string
|
||||
title?: string
|
||||
}): Promise<{ conversationId: string; conversation?: unknown }> {
|
||||
return this.request("/v1/conversations", { method: "POST", body: req })
|
||||
}
|
||||
|
||||
renameConversation(conversationId: string, title: string): Promise<{ ok: true }> {
|
||||
return this.request(`/v1/conversations/${conversationId}`, {
|
||||
method: "PATCH",
|
||||
body: { title },
|
||||
})
|
||||
}
|
||||
|
||||
async leaveConversation(conversationId: string): Promise<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/leave`, { method: "POST" })
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages up to `lastSeenMessageId` as read. The server enforces
|
||||
* monotonic `lastReadAt` and reports whether it advanced plus whether
|
||||
* the badge bucket was cleared.
|
||||
*/
|
||||
markConversationRead(
|
||||
conversationId: string,
|
||||
req: { lastSeenMessageId: string },
|
||||
): Promise<{ ok: boolean; applied: boolean; lastReadAt: number; badgeClear: boolean }> {
|
||||
return this.request(`/v1/conversations/${conversationId}/mark-read`, {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Messages ─────────────────────────────────────────────────────
|
||||
|
||||
sendMessage(req: {
|
||||
conversationId: string
|
||||
content: ContentBlock[]
|
||||
inReplyToMessageId?: string
|
||||
clientId?: string
|
||||
}): Promise<{ messageId: string; clientId?: string; message?: Message }> {
|
||||
const prev = this.sendQueues.get(req.conversationId) ?? Promise.resolve()
|
||||
const send = () =>
|
||||
this.request<{ messageId: string; clientId?: string; message?: Message }>("/v1/messages", {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
const next = prev.then(send, send)
|
||||
this.sendQueues.set(req.conversationId, next)
|
||||
const cleanup = () => {
|
||||
if (this.sendQueues.get(req.conversationId) === next) {
|
||||
this.sendQueues.delete(req.conversationId)
|
||||
}
|
||||
}
|
||||
void next.then(cleanup, cleanup)
|
||||
return next
|
||||
}
|
||||
|
||||
editMessage(
|
||||
messageId: string,
|
||||
req: { conversationId: string; content: ContentBlock[]; timestamp: number },
|
||||
): Promise<{ messageId?: string; message?: Message }> {
|
||||
return this.request(`/v1/messages/${messageId}`, { method: "PATCH", body: req })
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: string, conversationId: string): Promise<void> {
|
||||
// Returns 200 JSON with `{ ok }`-style payload; we don't need the body.
|
||||
await this.request<unknown>(`/v1/messages/${messageId}`, {
|
||||
method: "DELETE",
|
||||
query: { conversationId },
|
||||
})
|
||||
}
|
||||
|
||||
listMessages(
|
||||
conversationId: string,
|
||||
opts?: { before?: string; limit?: number },
|
||||
): Promise<{ messages: Message[]; hasMore: boolean; nextCursor: string | null }> {
|
||||
return this.request(`/v1/conversations/${conversationId}/messages`, {
|
||||
query: { before: opts?.before, limit: opts?.limit },
|
||||
})
|
||||
}
|
||||
|
||||
executeAction(
|
||||
conversationId: string,
|
||||
messageId: string,
|
||||
req: { groupId: string; value: ExecApprovalDecision },
|
||||
): Promise<{ ok?: boolean; message?: Message; content?: ContentBlock[] }> {
|
||||
return this.request(`/v1/conversations/${conversationId}/messages/${messageId}/execute-action`, {
|
||||
method: "POST",
|
||||
body: req,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Reactions ────────────────────────────────────────────────────
|
||||
|
||||
addReaction(
|
||||
messageId: string,
|
||||
req: { conversationId: string; emoji: string },
|
||||
): Promise<{ id: string; operationId?: string }> {
|
||||
return this.request(`/v1/messages/${messageId}/reactions`, { method: "POST", body: req })
|
||||
}
|
||||
|
||||
async removeReaction(
|
||||
messageId: string,
|
||||
req: { conversationId: string; emoji: string },
|
||||
): Promise<{ removed: boolean; id: string | null; operationId?: string }> {
|
||||
return this.request<{ removed: boolean; id: string | null; operationId?: string }>(
|
||||
`/v1/messages/${messageId}/reactions`,
|
||||
{
|
||||
method: "DELETE",
|
||||
query: req,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Typing ───────────────────────────────────────────────────────
|
||||
|
||||
async sendTyping(conversationId: string): Promise<void> {
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/typing`, { method: "POST" })
|
||||
}
|
||||
|
||||
async sendTypingStop(conversationId: string): Promise<void> {
|
||||
await this.request<unknown>(`/v1/conversations/${conversationId}/typing/stop`, { method: "POST" })
|
||||
}
|
||||
|
||||
// ── Bot / conversation status ────────────────────────────────────
|
||||
|
||||
getBotStatus(sandboxId: string): Promise<{ status: BotStatusRecord | null }> {
|
||||
return this.request(`/v1/sandboxes/${sandboxId}/bot-status`)
|
||||
}
|
||||
|
||||
async requestBotStatus(sandboxId: string): Promise<void> {
|
||||
await this.request<unknown>(`/v1/sandboxes/${sandboxId}/request-bot-status`, { method: "POST" })
|
||||
}
|
||||
|
||||
getConversationStatus(conversationId: string): Promise<{ status: ConversationStatusRecord | null }> {
|
||||
return this.request(`/v1/conversations/${conversationId}/conversation-status`)
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────
|
||||
|
||||
private async request<T>(path: string, opts: HttpOpts = {}): Promise<T> {
|
||||
const token = await this.getToken()
|
||||
let url = `${this.baseUrl}${path}`
|
||||
|
||||
if (opts.query) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(opts.query)) {
|
||||
if (v === undefined || v === null) continue
|
||||
params.set(k, String(v))
|
||||
}
|
||||
const qs = params.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
|
||||
if (opts.body !== undefined) headers["Content-Type"] = "application/json"
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? "GET",
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) this.onUnauthorized?.()
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
throw new KiloChatApiError(res.status, body)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as unknown as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,14 @@
|
||||
/**
|
||||
* KiloClaw status sidebar
|
||||
*
|
||||
* Displays essential instance information: status, bot presence,
|
||||
* region, sandbox ID, uptime, and version.
|
||||
*
|
||||
* Visual style mirrors the session sidebar (routes/session/sidebar.tsx).
|
||||
* Mirrors the session sidebar (routes/session/sidebar.tsx): bold
|
||||
* conversation title at the top, then context-window usage, then
|
||||
* instance/bot/details sections.
|
||||
*/
|
||||
|
||||
import { Show } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import type { ClawStatus } from "./types"
|
||||
import type { ClawStatus, ConversationListItem, ConversationStatusRecord } from "./types"
|
||||
|
||||
function dot(status: string | null | undefined, theme: any): string {
|
||||
if (!status) return theme.textMuted
|
||||
@@ -35,6 +34,12 @@ function uptime(started: string | null | undefined): string {
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
|
||||
return `${(n / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
|
||||
export function ClawSidebar(props: {
|
||||
status: ClawStatus | null
|
||||
loading: boolean
|
||||
@@ -43,9 +48,19 @@ export function ClawSidebar(props: {
|
||||
connected: boolean
|
||||
chatLoading: boolean
|
||||
chatError: string | null
|
||||
conversations: ConversationListItem[]
|
||||
activeConversationId: string | null
|
||||
conversationStatus: ConversationStatusRecord | null
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const conversationTitle = () => {
|
||||
const id = props.activeConversationId
|
||||
if (!id) return "New conversation"
|
||||
const conv = props.conversations.find((c) => c.conversationId === id)
|
||||
return conv?.title ?? "Untitled"
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
@@ -58,11 +73,81 @@ export function ClawSidebar(props: {
|
||||
>
|
||||
<scrollbox flexGrow={1}>
|
||||
<box flexShrink={0} gap={1} paddingRight={1}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text}>
|
||||
<b>KiloClaw</b>
|
||||
</text>
|
||||
</box>
|
||||
{/* Conversation title (top, like session title) */}
|
||||
<Show when={props.connected}>
|
||||
<box paddingRight={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
<b>{conversationTitle()}</b>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Bot status — same section pattern as Instance/Details */}
|
||||
<Show when={props.connected || props.chatLoading || props.chatError}>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Bot Status</b>
|
||||
</text>
|
||||
<Show when={props.chatError}>
|
||||
<text fg={theme.error}>{props.chatError}</text>
|
||||
</Show>
|
||||
<Show when={!props.chatError && props.chatLoading}>
|
||||
<text fg={theme.textMuted}>Connecting...</text>
|
||||
</Show>
|
||||
<Show when={!props.chatError && !props.chatLoading && props.connected}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} style={{ fg: props.online ? theme.success : theme.textMuted }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text}>{props.online ? "Online" : "Offline"}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!props.chatError && !props.chatLoading && !props.connected}>
|
||||
<text fg={theme.textMuted}>Unavailable</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Context window usage */}
|
||||
<Show when={props.connected && props.conversationStatus}>
|
||||
{(s) => (
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<Show when={s().contextWindow > 0}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Used</text>
|
||||
<text fg={theme.text}>
|
||||
{Math.min(100, Math.round((s().contextTokens / s().contextWindow) * 100))}%
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Tokens</text>
|
||||
<text fg={theme.text}>
|
||||
{formatTokens(s().contextTokens)} / {formatTokens(s().contextWindow)}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={s().model}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Model</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{s().model}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={s().provider}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Provider</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{s().provider}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={props.loading}>
|
||||
<text fg={theme.textMuted}>Loading...</text>
|
||||
@@ -90,42 +175,23 @@ export function ClawSidebar(props: {
|
||||
</box>
|
||||
</box>
|
||||
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Bot</b>
|
||||
</text>
|
||||
<Show when={!props.chatLoading} fallback={<text fg={theme.textMuted}>Connecting...</text>}>
|
||||
<Show when={props.chatError}>
|
||||
<text fg={theme.error}>{props.chatError}</text>
|
||||
</Show>
|
||||
<Show when={!props.chatError && props.connected}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} style={{ fg: props.online ? theme.success : theme.textMuted }}>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.text}>{props.online ? "Online" : "Offline"}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!props.chatError && !props.connected}>
|
||||
<text fg={theme.textMuted}>Unavailable</text>
|
||||
</Show>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
<b>Details</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Region <span style={{ fg: theme.text }}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</span>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
Version <span style={{ fg: theme.text }}>{props.status!.openclawVersion ?? "—"}</span>
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Region</text>
|
||||
<text fg={theme.text}>{props.status!.flyRegion?.toUpperCase() ?? "—"}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Version</text>
|
||||
<text fg={theme.text}>{props.status!.openclawVersion ?? "—"}</text>
|
||||
</box>
|
||||
<Show when={props.status!.channelCount != null && props.status!.channelCount >= 1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Channels <span style={{ fg: theme.text }}>{props.status!.channelCount}</span>
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={theme.textMuted}>Channels</text>
|
||||
<text fg={theme.text}>{props.status!.channelCount}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* KiloClaw TUI types
|
||||
* KiloClaw TUI types — Kilo Chat protocol.
|
||||
*
|
||||
* Types for the KiloClaw chat and dashboard feature in the TUI.
|
||||
* Mirrors `@kilocode/kilo-chat` from the cloud monorepo. We carry our own
|
||||
* copy here so the CLI can be built without an external dependency.
|
||||
*/
|
||||
|
||||
export type ClawStatus = {
|
||||
status: "provisioned" | "starting" | "restarting" | "running" | "stopped" | "destroying" | null
|
||||
// `recovering` and `restoring` are transitional states the worker reports
|
||||
// while bringing an instance back from an unexpected stop or snapshot
|
||||
// restore (cloud: `services/kiloclaw/src/index.ts`).
|
||||
status:
|
||||
| "provisioned"
|
||||
| "starting"
|
||||
| "restarting"
|
||||
| "recovering"
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "destroying"
|
||||
| "restoring"
|
||||
| null
|
||||
sandboxId?: string
|
||||
flyRegion?: string
|
||||
machineSize?: { cpus: number; memory_mb: number }
|
||||
@@ -17,15 +30,182 @@ export type ClawStatus = {
|
||||
channelCount?: number
|
||||
secretCount?: number
|
||||
userId?: string
|
||||
// User-chosen name for the KiloClaw bot, set during onboarding via the
|
||||
// `patchBotIdentity` mutation. May be null for fresh instances that
|
||||
// skipped the bot-identity step. The chat UI should fall back to the
|
||||
// literal string "KiloClaw" when null.
|
||||
botName?: string | null
|
||||
}
|
||||
|
||||
export type ChatCredentials = {
|
||||
apiKey: string
|
||||
userId: string
|
||||
userToken: string
|
||||
channelId: string
|
||||
// ── Kilo Chat token envelope (gateway response) ─────────────────────
|
||||
|
||||
export type ChatToken = {
|
||||
token: string
|
||||
expiresAt: string // ISO timestamp
|
||||
kiloChatUrl: string
|
||||
eventServiceUrl: string
|
||||
}
|
||||
|
||||
// ── Content blocks ──────────────────────────────────────────────────
|
||||
|
||||
export type ExecApprovalDecision = "allow-once" | "allow-always" | "deny"
|
||||
|
||||
export type TextBlock = { type: "text"; text: string }
|
||||
|
||||
export type ActionItem = {
|
||||
label: string
|
||||
style: "primary" | "danger" | "secondary"
|
||||
value: ExecApprovalDecision
|
||||
}
|
||||
|
||||
export type ActionsBlock = {
|
||||
type: "actions"
|
||||
groupId: string
|
||||
actions: ActionItem[]
|
||||
resolved?: {
|
||||
value: ExecApprovalDecision
|
||||
resolvedBy: string
|
||||
resolvedAt: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ContentBlock = TextBlock | ActionsBlock
|
||||
|
||||
// ── Reactions ───────────────────────────────────────────────────────
|
||||
|
||||
export type ReactionSummary = {
|
||||
emoji: string
|
||||
count: number
|
||||
memberIds: string[]
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────
|
||||
|
||||
export type Message = {
|
||||
id: string
|
||||
senderId: string
|
||||
content: ContentBlock[]
|
||||
inReplyToMessageId: string | null
|
||||
updatedAt: number | null
|
||||
clientUpdatedAt: number | null
|
||||
deleted: boolean
|
||||
deliveryFailed: boolean
|
||||
reactions: ReactionSummary[]
|
||||
}
|
||||
|
||||
// ── Conversations ───────────────────────────────────────────────────
|
||||
|
||||
export type ConversationListItem = {
|
||||
conversationId: string
|
||||
title: string | null
|
||||
lastActivityAt: number | null
|
||||
lastReadAt: number | null
|
||||
joinedAt: number
|
||||
}
|
||||
|
||||
// ── Bot / conversation status ───────────────────────────────────────
|
||||
|
||||
export type BotStatusRecord = {
|
||||
online: boolean
|
||||
at: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type ConversationStatusRecord = {
|
||||
conversationId: string
|
||||
contextTokens: number
|
||||
contextWindow: number
|
||||
model: string | null
|
||||
provider: string | null
|
||||
at: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
// ── Events ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Snapshot of the message that was replied to. Server includes this on
|
||||
* `message.created` so clients can render a reply preview without a follow-up
|
||||
* fetch. `deleted` mirrors the soft-deletion state at the time of replying.
|
||||
*/
|
||||
export type ReplyToSnapshot = {
|
||||
messageId: string
|
||||
senderId: string
|
||||
content: ContentBlock[]
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
export type MessageCreatedEvent = {
|
||||
messageId: string
|
||||
senderId: string
|
||||
content: ContentBlock[]
|
||||
inReplyToMessageId: string | null
|
||||
clientId?: string
|
||||
replyTo?: ReplyToSnapshot | null
|
||||
}
|
||||
|
||||
export type MessageUpdatedEvent = {
|
||||
messageId: string
|
||||
content: ContentBlock[]
|
||||
clientUpdatedAt: number | null
|
||||
}
|
||||
|
||||
export type MessageDeletedEvent = { messageId: string }
|
||||
export type MessageDeliveryFailedEvent = { messageId: string }
|
||||
export type TypingEvent = { memberId: string }
|
||||
|
||||
export type TypingMember = { memberId: string; at: number }
|
||||
export type ReactionAddedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
|
||||
export type ReactionRemovedEvent = { messageId: string; memberId: string; emoji: string; operationId?: string }
|
||||
/**
|
||||
* Server fans out the full conversation snapshot on `conversation.created` so
|
||||
* clients can append to their list without a follow-up fetch.
|
||||
*/
|
||||
export type ConversationCreatedEvent = {
|
||||
conversationId: string
|
||||
conversation?: ConversationListItem
|
||||
}
|
||||
export type ConversationRenamedEvent = { conversationId: string; title: string }
|
||||
export type ConversationLeftEvent = { conversationId: string }
|
||||
export type ConversationActivityEvent = { conversationId: string; lastActivityAt: number }
|
||||
export type ActionDeliveryFailedEvent = { conversationId: string; messageId: string; groupId: string }
|
||||
export type BotStatusEvent = { sandboxId: string; online: boolean; at: number }
|
||||
export type ConversationStatusEvent = {
|
||||
conversationId: string
|
||||
contextTokens: number
|
||||
contextWindow: number
|
||||
model: string | null
|
||||
provider: string | null
|
||||
at: number
|
||||
}
|
||||
|
||||
export type KiloChatEventMap = {
|
||||
"message.created": MessageCreatedEvent
|
||||
"message.updated": MessageUpdatedEvent
|
||||
"message.deleted": MessageDeletedEvent
|
||||
"message.delivery_failed": MessageDeliveryFailedEvent
|
||||
typing: TypingEvent
|
||||
"typing.stop": TypingEvent
|
||||
"reaction.added": ReactionAddedEvent
|
||||
"reaction.removed": ReactionRemovedEvent
|
||||
"conversation.created": ConversationCreatedEvent
|
||||
"conversation.renamed": ConversationRenamedEvent
|
||||
"conversation.left": ConversationLeftEvent
|
||||
"conversation.activity": ConversationActivityEvent
|
||||
"action.delivery_failed": ActionDeliveryFailedEvent
|
||||
"bot.status": BotStatusEvent
|
||||
"conversation.status": ConversationStatusEvent
|
||||
}
|
||||
|
||||
export type KiloChatEventName = keyof KiloChatEventMap
|
||||
|
||||
// ── Legacy display message (for CLI rendering) ──────────────────────
|
||||
|
||||
/**
|
||||
* Lightweight chat-message shape used by the existing CLI rendering layer.
|
||||
* We keep this for backwards compatibility with components that already
|
||||
* render text + bot flag. New code should prefer `Message`.
|
||||
*/
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
text: string
|
||||
|
||||
@@ -12,16 +12,20 @@ import { createMemo } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useCommandDialog, type CommandOption } from "@tui/component/dialog-command"
|
||||
import { Toast } from "@tui/ui/toast"
|
||||
import { ClawChat } from "./chat"
|
||||
import { ClawSidebar } from "./sidebar"
|
||||
import { createClawStatus, createClawChat } from "./hooks"
|
||||
import { DialogConversationList } from "./dialog-conversation-list"
|
||||
import type { ClawSlashOption } from "./autocomplete"
|
||||
|
||||
export function KiloClawView() {
|
||||
const route = useRoute()
|
||||
const sdk = useSDK()
|
||||
const command = useCommandDialog()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Poll instance status
|
||||
const { status, error: statusError, loading: statusLoading } = createClawStatus(sdk)
|
||||
@@ -35,6 +39,12 @@ export function KiloClawView() {
|
||||
return !s || s.status !== "running"
|
||||
})
|
||||
|
||||
// Bot display name — sourced from the KiloClaw platform status (set by the
|
||||
// user during onboarding via patchBotIdentity). Falls back to the literal
|
||||
// "KiloClaw" while loading or for instances that skipped onboarding,
|
||||
// matching the web UI's fallback chain.
|
||||
const botName = createMemo(() => status()?.botName ?? "KiloClaw")
|
||||
|
||||
// Register escape to navigate back
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape") {
|
||||
@@ -44,19 +54,70 @@ export function KiloClawView() {
|
||||
}
|
||||
})
|
||||
|
||||
// Register view-specific commands
|
||||
command.register(() => [
|
||||
{
|
||||
value: "kiloclaw.back",
|
||||
title: "Back",
|
||||
category: "KiloClaw",
|
||||
hidden: true,
|
||||
keybind: "escape" as any,
|
||||
onSelect: () => {
|
||||
route.back()
|
||||
// KiloClaw view commands — single source of truth for both the global
|
||||
// command palette / keybinds and the in-chat slash autocomplete.
|
||||
// The list is reactive on `chat.connected` so the slash menu only
|
||||
// exposes `/new` and `/conversations` once we're connected to kilo-chat.
|
||||
const kiloCommands = createMemo<CommandOption[]>(() => {
|
||||
const ready = chat.connected()
|
||||
return [
|
||||
{
|
||||
value: "kiloclaw.back",
|
||||
title: "Back",
|
||||
description: "Return to the previous view",
|
||||
category: "KiloClaw",
|
||||
slash: { name: "back" },
|
||||
keybind: "escape" as any,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
route.back()
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
{
|
||||
value: "kiloclaw.new",
|
||||
title: "New conversation",
|
||||
description: "Start a new KiloClaw conversation",
|
||||
category: "KiloClaw",
|
||||
slash: { name: "new" },
|
||||
enabled: ready,
|
||||
hidden: !ready,
|
||||
onSelect: async () => {
|
||||
dialog.clear()
|
||||
await chat.newConversation()
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "kiloclaw.conversations",
|
||||
title: "Conversations",
|
||||
description: "Browse, rename, and delete KiloClaw conversations",
|
||||
category: "KiloClaw",
|
||||
slash: { name: "conversations", aliases: ["chats"] },
|
||||
enabled: ready,
|
||||
hidden: !ready,
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogConversationList chat={chat} />)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
command.register(() => kiloCommands())
|
||||
|
||||
// Slashes for the in-chat autocomplete — derived from the same list so
|
||||
// renames flow through automatically. We pad displays to a common width
|
||||
// so the descriptions line up like in the main prompt's autocomplete.
|
||||
const clawSlashes = createMemo<ClawSlashOption[]>(() => {
|
||||
const visible = kiloCommands().filter((c) => c.enabled !== false && !c.hidden && c.slash)
|
||||
const items = visible.map((c) => ({
|
||||
display: "/" + c.slash!.name,
|
||||
description: c.description ?? c.title,
|
||||
aliases: c.slash!.aliases?.map((a) => "/" + a),
|
||||
onSelect: () => c.onSelect?.(dialog),
|
||||
}))
|
||||
const max = items.reduce((m, i) => Math.max(m, i.display.length), 0)
|
||||
if (!max) return items
|
||||
return items.map((i) => ({ ...i, display: i.display.padEnd(max + 2) }))
|
||||
})
|
||||
|
||||
return (
|
||||
<box flexDirection="row" flexGrow={1} paddingLeft={2} gap={1}>
|
||||
@@ -68,6 +129,9 @@ export function KiloClawView() {
|
||||
loading={chat.loading()}
|
||||
error={chat.error()}
|
||||
disabled={disabled()}
|
||||
typingMembers={chat.typingMembers()}
|
||||
slashes={clawSlashes}
|
||||
botName={botName()}
|
||||
onSend={chat.send}
|
||||
/>
|
||||
<Toast />
|
||||
@@ -80,6 +144,9 @@ export function KiloClawView() {
|
||||
connected={chat.connected()}
|
||||
chatLoading={chat.loading()}
|
||||
chatError={chat.error()}
|
||||
conversations={chat.conversations()}
|
||||
activeConversationId={chat.activeConversationId()}
|
||||
conversationStatus={chat.conversationStatus()}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// kilocode_change - new file
|
||||
/**
|
||||
* Per-message thumbs up/down feedback for the TUI.
|
||||
*
|
||||
* Wired via the `messages_feedback_up` / `messages_feedback_down` keybinds
|
||||
* in the Session route. Kept out of `routes/session/index.tsx` so the
|
||||
* upstream-shared session route stays free of Kilo telemetry plumbing.
|
||||
*/
|
||||
import { Telemetry } from "@kilocode/kilo-telemetry"
|
||||
import type { AssistantMessage, Message } from "@kilocode/sdk/v2"
|
||||
import type { DialogContext } from "@tui/ui/dialog"
|
||||
import type { ToastContext } from "@tui/ui/toast"
|
||||
|
||||
interface SessionRevert {
|
||||
revert?: { messageID: string }
|
||||
}
|
||||
|
||||
interface Context {
|
||||
toast: ToastContext
|
||||
session: () => SessionRevert | undefined
|
||||
messages: () => Message[]
|
||||
}
|
||||
|
||||
export function submitFeedback(rating: "up" | "down", dialog: DialogContext, ctx: Context): void {
|
||||
if (!Telemetry.isEnabled()) {
|
||||
ctx.toast.show({ message: "Feedback disabled: telemetry is off", variant: "info" })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const revertID = ctx.session()?.revert?.messageID
|
||||
const lastAssistant = ctx.messages().findLast(
|
||||
(msg): msg is AssistantMessage => msg.role === "assistant" && (!revertID || msg.id < revertID),
|
||||
)
|
||||
if (!lastAssistant) {
|
||||
ctx.toast.show({ message: "No assistant messages found", variant: "error" })
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const providerID = lastAssistant.providerID
|
||||
const payload: Telemetry.FeedbackProperties = {
|
||||
providerID,
|
||||
modelID: lastAssistant.modelID,
|
||||
rating,
|
||||
}
|
||||
const variant = (lastAssistant as AssistantMessage & { variant?: string }).variant
|
||||
if (variant) payload.variant = variant
|
||||
if (providerID === "kilo") {
|
||||
payload.sessionID = lastAssistant.sessionID
|
||||
payload.messageID = lastAssistant.id
|
||||
payload.parentMessageID = lastAssistant.parentID
|
||||
}
|
||||
Telemetry.trackFeedback(payload)
|
||||
ctx.toast.show({
|
||||
message: rating === "up" ? "Thanks for the feedback!" : "Thanks — we'll use this to improve.",
|
||||
variant: "success",
|
||||
})
|
||||
dialog.clear()
|
||||
}
|
||||
@@ -76,8 +76,15 @@ async function saveIndexing(
|
||||
indexing: IndexingConfig,
|
||||
toast: ReturnType<typeof useToast>,
|
||||
): Promise<boolean> {
|
||||
const response = await sdk.client.global.config.update({ config: { indexing } })
|
||||
if (response.error) {
|
||||
const global = { ...indexing }
|
||||
delete global.enabled
|
||||
const responses = await Promise.all([
|
||||
...(Object.keys(global).length > 0 ? [sdk.client.global.config.update({ config: { indexing: global } })] : []),
|
||||
...(indexing.enabled !== undefined
|
||||
? [sdk.client.config.update({ config: { indexing: { enabled: indexing.enabled } } })]
|
||||
: []),
|
||||
])
|
||||
if (responses.some((response) => response.error)) {
|
||||
toast.show({ message: "Failed to save indexing config", variant: "error" })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ export namespace KilocodeConfig {
|
||||
writable: (config: Config.Info) => Config.Info
|
||||
}) {
|
||||
const file = yield* projectConfigUpdateTarget(input)
|
||||
const before = (yield* input.read(file)) ?? "{}"
|
||||
const source = yield* input.read(file)
|
||||
const before = source ?? "{}"
|
||||
const patch = input.writable(input.config)
|
||||
|
||||
if (file.endsWith(".jsonc")) {
|
||||
@@ -106,9 +107,25 @@ export namespace KilocodeConfig {
|
||||
|
||||
const existing = input.parse(before, file)
|
||||
const merged = mergeConfig(input.writable(existing), patch)
|
||||
if (source === undefined && Object.keys(merged).length === 0) return
|
||||
yield* input.fs.writeWithDirs(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export function scopeIndexing(info: Config.Info, scope: "global" | "local"): Config.Info {
|
||||
if (scope !== "global") return info
|
||||
return stripGlobalIndexing(info)
|
||||
}
|
||||
|
||||
function stripGlobalIndexing(info: Config.Info): Config.Info {
|
||||
// Indexing provider/storage settings can be global, but enablement is a per-project decision.
|
||||
if (info.indexing?.enabled === undefined) return info
|
||||
const indexing = Object.fromEntries(Object.entries(info.indexing).filter(([key]) => key !== "enabled"))
|
||||
if (Object.keys(indexing).length > 0) return { ...info, indexing }
|
||||
const copy = { ...info }
|
||||
delete copy.indexing
|
||||
return copy
|
||||
}
|
||||
|
||||
// ── Warning helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Convert known config-loading error types into a Warning. Returns undefined for unknown errors. */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readFile, writeFile, mkdir } from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
import { dirname } from "path"
|
||||
import jschardet from "jschardet"
|
||||
import chardet from "chardet"
|
||||
import iconv from "iconv-lite"
|
||||
|
||||
/**
|
||||
@@ -9,134 +9,158 @@ import iconv from "iconv-lite"
|
||||
*
|
||||
* Supported:
|
||||
* - UTF-8 (with or without BOM)
|
||||
* - UTF-16 LE/BE with BOM (detected by jschardet)
|
||||
* - Legacy Latin and CJK encodings (detected by jschardet)
|
||||
* - UTF-16 LE/BE with BOM (detected by chardet)
|
||||
* - UTF-32 LE/BE with BOM (detected by chardet)
|
||||
* - Legacy Latin and CJK encodings (detected by chardet)
|
||||
*
|
||||
* Not supported:
|
||||
* - UTF-16 without BOM (ambiguous, rare)
|
||||
* - UTF-32 (extremely rare)
|
||||
* - UTF-16 or UTF-32 without BOM (ambiguous, rare)
|
||||
*
|
||||
* Detection strategy:
|
||||
* 1. If the bytes are valid UTF-8, treat as UTF-8 (tracking the presence of a
|
||||
* BOM so it can be written back).
|
||||
* 2. Otherwise, trust jschardet.
|
||||
* 2. Otherwise, trust chardet. chardet only reports the wide UTF variants
|
||||
* when a BOM is present, which aligns with the contract above.
|
||||
*
|
||||
* iconv-lite's UTF codecs strip BOMs on decode and do not emit them on encode,
|
||||
* so UTF BOMs are handled explicitly in {@link encode} to round-trip cleanly.
|
||||
*
|
||||
* Consumers should import this module as a namespace:
|
||||
* import * as Encoding from "../kilocode/encoding"
|
||||
*/
|
||||
export namespace Encoding {
|
||||
export const DEFAULT = "utf-8"
|
||||
/**
|
||||
* Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8
|
||||
* codec always strips BOMs on decode and never emits one on encode, so we
|
||||
* track the "with BOM" case explicitly to round-trip it faithfully.
|
||||
*/
|
||||
export const UTF8_BOM = "utf-8-bom"
|
||||
const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
|
||||
|
||||
function hasUtf8Bom(bytes: Buffer): boolean {
|
||||
return bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
export const DEFAULT = "utf-8"
|
||||
/**
|
||||
* Synthetic label for UTF-8 files that start with a BOM. iconv-lite's utf-8
|
||||
* codec always strips BOMs on decode and never emits one on encode, so we
|
||||
* track the "with BOM" case explicitly to round-trip it faithfully.
|
||||
*/
|
||||
export const UTF8_BOM = "utf-8-bom"
|
||||
const BOMS = {
|
||||
"utf-8-bom": Buffer.from([0xef, 0xbb, 0xbf]),
|
||||
"utf-16le": Buffer.from([0xff, 0xfe]),
|
||||
"utf-16be": Buffer.from([0xfe, 0xff]),
|
||||
"utf-32le": Buffer.from([0xff, 0xfe, 0x00, 0x00]),
|
||||
"utf-32be": Buffer.from([0x00, 0x00, 0xfe, 0xff]),
|
||||
}
|
||||
|
||||
function startsWith(bytes: Buffer, bom: Buffer, limit: number): boolean {
|
||||
return limit >= bom.length && bytes.subarray(0, bom.length).equals(bom)
|
||||
}
|
||||
|
||||
function hasUtf8Bom(bytes: Buffer): boolean {
|
||||
return startsWith(bytes, BOMS["utf-8-bom"], bytes.length)
|
||||
}
|
||||
|
||||
/** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */
|
||||
export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean {
|
||||
// UTF-32 LE starts with FF FE 00 00, so exclude it to avoid misclassifying as UTF-16 LE.
|
||||
if (hasUtf32Bom(bytes, limit)) return false
|
||||
return startsWith(bytes, BOMS["utf-16le"], limit) || startsWith(bytes, BOMS["utf-16be"], limit)
|
||||
}
|
||||
|
||||
/** True if `bytes[0..limit]` starts with a UTF-32 LE or BE byte-order mark. */
|
||||
export function hasUtf32Bom(bytes: Buffer, limit = bytes.length): boolean {
|
||||
return startsWith(bytes, BOMS["utf-32le"], limit) || startsWith(bytes, BOMS["utf-32be"], limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize chardet labels to a stable lowercase-hyphenated form.
|
||||
*
|
||||
* iconv-lite already accepts every label chardet emits (e.g. "UTF-16 LE",
|
||||
* "Shift_JIS", "KOI8-R"), so this map is not required to make decode/encode
|
||||
* work. Its job is to give the rest of the codebase a consistent label —
|
||||
* callers compare against `"utf-16le"`, `"windows-1251"`, etc., and should
|
||||
* not have to account for chardet's casing or whitespace conventions.
|
||||
*
|
||||
* (ISO-2022-* is the one family iconv-lite does not support under any
|
||||
* alias; those labels fall through to the `encodingExists` guard in
|
||||
* `detect()` and are rejected to UTF-8.)
|
||||
*/
|
||||
function normalize(name: string): string {
|
||||
const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
const map: Record<string, string> = {
|
||||
utf8: "utf-8",
|
||||
utf16le: "utf-16le",
|
||||
utf16be: "utf-16be",
|
||||
utf32le: "utf-32le",
|
||||
utf32be: "utf-32be",
|
||||
iso88591: "iso-8859-1",
|
||||
iso88592: "iso-8859-2",
|
||||
iso88595: "iso-8859-5",
|
||||
iso88597: "iso-8859-7",
|
||||
iso88598: "iso-8859-8",
|
||||
iso88599: "iso-8859-9",
|
||||
windows1250: "windows-1250",
|
||||
windows1251: "windows-1251",
|
||||
windows1252: "windows-1252",
|
||||
windows1253: "windows-1253",
|
||||
windows1255: "windows-1255",
|
||||
shiftjis: "Shift_JIS",
|
||||
eucjp: "euc-jp",
|
||||
iso2022jp: "iso-2022-jp",
|
||||
euckr: "euc-kr",
|
||||
iso2022kr: "iso-2022-kr",
|
||||
big5: "big5",
|
||||
gb18030: "gb18030",
|
||||
koi8r: "koi8-r",
|
||||
}
|
||||
return map[lower] ?? name
|
||||
}
|
||||
|
||||
/** True if `bytes[0..limit]` starts with a UTF-16 LE or BE byte-order mark. */
|
||||
export function hasUtf16Bom(bytes: Buffer, limit = bytes.length): boolean {
|
||||
if (limit < 2) return false
|
||||
return (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff)
|
||||
}
|
||||
|
||||
/** Remap jschardet labels to iconv-lite compatible names. */
|
||||
function normalize(name: string): string {
|
||||
const lower = name.toLowerCase().replace(/[^a-z0-9]/g, "")
|
||||
const map: Record<string, string> = {
|
||||
utf8: "utf-8",
|
||||
utf16le: "utf-16le",
|
||||
utf16be: "utf-16be",
|
||||
ascii: "utf-8",
|
||||
iso88591: "iso-8859-1",
|
||||
iso88592: "iso-8859-2",
|
||||
iso88595: "iso-8859-5",
|
||||
iso88597: "iso-8859-7",
|
||||
iso88598: "iso-8859-8",
|
||||
iso88599: "iso-8859-9",
|
||||
windows1250: "windows-1250",
|
||||
windows1251: "windows-1251",
|
||||
windows1252: "windows-1252",
|
||||
windows1253: "windows-1253",
|
||||
windows1255: "windows-1255",
|
||||
shiftjis: "Shift_JIS",
|
||||
eucjp: "euc-jp",
|
||||
iso2022jp: "iso-2022-jp",
|
||||
euckr: "euc-kr",
|
||||
iso2022kr: "iso-2022-kr",
|
||||
big5: "big5",
|
||||
gb2312: "gb2312",
|
||||
gb18030: "gb18030",
|
||||
koi8r: "koi8-r",
|
||||
maccyrillic: "x-mac-cyrillic",
|
||||
ibm855: "cp855",
|
||||
ibm866: "cp866",
|
||||
tis620: "tis-620",
|
||||
}
|
||||
return map[lower] ?? name
|
||||
}
|
||||
|
||||
function isUtf8(bytes: Buffer): boolean {
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function detect(bytes: Buffer): string {
|
||||
if (bytes.length === 0) return DEFAULT
|
||||
if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT
|
||||
const result = jschardet.detect(bytes)
|
||||
if (!result.encoding) return DEFAULT
|
||||
const enc = normalize(result.encoding)
|
||||
// Reject unsupported Unicode encodings (UTF-32 and anything iconv-lite cannot decode)
|
||||
if (enc.toLowerCase().startsWith("utf-32")) return DEFAULT
|
||||
if (!iconv.encodingExists(enc)) return DEFAULT
|
||||
return enc
|
||||
}
|
||||
|
||||
export function decode(bytes: Buffer, encoding: string): string {
|
||||
if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8")
|
||||
return iconv.decode(bytes, encoding)
|
||||
}
|
||||
|
||||
export function encode(text: string, encoding: string): Buffer {
|
||||
// iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM"
|
||||
// as a distinct variant. Prepend the BOM manually so round-tripping keeps
|
||||
// the original byte signature intact. Strip a leading U+FEFF from `text`
|
||||
// first so we never emit a double BOM when the decoded text already
|
||||
// contains one (e.g. if a tool round-trips content verbatim).
|
||||
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
||||
if (encoding === UTF8_BOM) return Buffer.concat([UTF8_BOM_BYTES, iconv.encode(body, "utf-8")])
|
||||
const lower = encoding.toLowerCase()
|
||||
if (lower === "utf-16le") return Buffer.concat([Buffer.from([0xff, 0xfe]), iconv.encode(body, encoding)])
|
||||
if (lower === "utf-16be") return Buffer.concat([Buffer.from([0xfe, 0xff]), iconv.encode(body, encoding)])
|
||||
return iconv.encode(text, encoding)
|
||||
}
|
||||
|
||||
/** Read a file, detecting its encoding. */
|
||||
export async function read(path: string): Promise<{ text: string; encoding: string }> {
|
||||
const bytes = await readFile(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Synchronous read, detecting encoding. */
|
||||
export function readSync(path: string): { text: string; encoding: string } {
|
||||
const bytes = readFileSync(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Write text, ensuring parent directory exists, using the given encoding. */
|
||||
export async function write(path: string, text: string, encoding: string = DEFAULT): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, encode(text, encoding))
|
||||
function isUtf8(bytes: Buffer): boolean {
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function detect(bytes: Buffer): string {
|
||||
if (bytes.length === 0) return DEFAULT
|
||||
if (isUtf8(bytes)) return hasUtf8Bom(bytes) ? UTF8_BOM : DEFAULT
|
||||
const result = chardet.detect(bytes)
|
||||
if (!result) return DEFAULT
|
||||
const enc = normalize(result)
|
||||
if (!iconv.encodingExists(enc)) return DEFAULT
|
||||
return enc
|
||||
}
|
||||
|
||||
export function decode(bytes: Buffer, encoding: string): string {
|
||||
if (encoding === UTF8_BOM) return iconv.decode(bytes, "utf-8")
|
||||
return iconv.decode(bytes, encoding)
|
||||
}
|
||||
|
||||
export function encode(text: string, encoding: string): Buffer {
|
||||
// iconv-lite's UTF codecs strip/ignore BOMs, but we support "UTF-X with BOM"
|
||||
// as a distinct variant. Prepend the BOM manually so round-tripping keeps
|
||||
// the original byte signature intact. Strip a leading U+FEFF from `text`
|
||||
// first so we never emit a double BOM when the decoded text already
|
||||
// contains one (e.g. if a tool round-trips content verbatim).
|
||||
const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
||||
const key = encoding === UTF8_BOM ? UTF8_BOM : encoding.toLowerCase()
|
||||
const bom = BOMS[key as keyof typeof BOMS]
|
||||
if (bom) return Buffer.concat([bom, iconv.encode(body, key === UTF8_BOM ? "utf-8" : key)])
|
||||
return iconv.encode(text, encoding)
|
||||
}
|
||||
|
||||
/** Read a file, detecting its encoding. */
|
||||
export async function read(path: string): Promise<{ text: string; encoding: string }> {
|
||||
const bytes = await readFile(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Synchronous read, detecting encoding. */
|
||||
export function readSync(path: string): { text: string; encoding: string } {
|
||||
const bytes = readFileSync(path)
|
||||
const encoding = detect(bytes)
|
||||
return { text: decode(bytes, encoding), encoding }
|
||||
}
|
||||
|
||||
/** Write text, ensuring parent directory exists, using the given encoding. */
|
||||
export async function write(path: string, text: string, encoding: string = DEFAULT): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, encode(text, encoding))
|
||||
}
|
||||
|
||||
@@ -227,7 +227,10 @@ export async function generateCommandTable(options?: { commands?: Cmd[] }) {
|
||||
})
|
||||
}
|
||||
|
||||
const lines = ["| Command | Description |", "| --- | --- |"]
|
||||
// The repo enforces unpadded markdown table separators via
|
||||
// script/check-md-table-padding.ts (see AGENTS.md). Padded `| --- | --- |`
|
||||
// separators fail CI, so emit the compact form here.
|
||||
const lines = ["| Command | Description |", "|---|---|"]
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(`| \`${row.display}\` | ${row.description} |`)
|
||||
|
||||
@@ -26,6 +26,8 @@ import { LanceDBRuntime } from "./lancedb" // kilocode_change
|
||||
|
||||
const log = Log.create({ service: "kilocode-indexing" })
|
||||
const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.")
|
||||
const noWorkspace = () =>
|
||||
disabledIndexingStatus("Codebase indexing is disabled because no workspace folder is open in VS Code.")
|
||||
|
||||
function worktreeDisabled(): z.infer<typeof IndexingStatus> {
|
||||
return {
|
||||
@@ -200,6 +202,9 @@ export namespace KiloIndexing {
|
||||
const boot = async (hit: Cache): Promise<Entry> => {
|
||||
const dir = Instance.directory
|
||||
const cfg = await Config.get()
|
||||
if (process.env["KILO_DISABLE_CODEBASE_INDEXING"] === "vscode-no-workspace") {
|
||||
return track(hit, await inert(() => noWorkspace()))
|
||||
}
|
||||
if (!hasIndexingPlugin(cfg.plugin)) {
|
||||
return track(hit, await inert(() => missing()))
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ function rules(permission: string, ruleset?: Ruleset) {
|
||||
|
||||
export namespace ExternalDirectoryPermission {
|
||||
export function evaluate(permission: string, pattern: string, ...sets: Array<Ruleset | undefined>) {
|
||||
return evalRule(
|
||||
permission,
|
||||
pattern,
|
||||
...sets.map((set) => rules(permission, set)),
|
||||
)
|
||||
return evalRule(permission, pattern, ...sets.map((set) => rules(permission, set)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ export namespace PlanFollowup {
|
||||
|
||||
// Fall back to plan file on disk
|
||||
const session = await Session.get(SessionID.make(input.sessionID))
|
||||
const file = Bun.file(Session.plan(session))
|
||||
const file = Bun.file(Session.plan(session, Instance.current))
|
||||
const plan = await file.text().catch(() => "")
|
||||
return plan.trim()
|
||||
}
|
||||
@@ -351,7 +351,7 @@ export namespace PlanFollowup {
|
||||
})
|
||||
|
||||
try {
|
||||
const file = Session.plan(session)
|
||||
const file = Session.plan(session, Instance.current)
|
||||
const todos = await PlanFollowupRuntime.todo.get(input.sessionID)
|
||||
const todoList = formatTodos(todos)
|
||||
|
||||
|
||||
@@ -169,20 +169,6 @@ export function patchCustomLoaderResult(
|
||||
if (!result.options) return
|
||||
|
||||
switch (providerID) {
|
||||
case "anthropic": {
|
||||
// Prepend claude-code beta flag to the anthropic-beta header
|
||||
// TODO: Add adaptive thinking headers when @ai-sdk/anthropic supports it:
|
||||
// adaptive-thinking-2026-01-28,effort-2025-11-24,max-effort-2026-01-24
|
||||
const existing = result.options.headers?.["anthropic-beta"] ?? ""
|
||||
const prefix = "claude-code-20250219"
|
||||
if (!existing.includes(prefix)) {
|
||||
result.options.headers = {
|
||||
...result.options.headers,
|
||||
"anthropic-beta": existing ? `${prefix},${existing}` : prefix,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case "openrouter":
|
||||
case "vercel":
|
||||
case "zenmux":
|
||||
|
||||
@@ -20,6 +20,7 @@ import { errors } from "../../server/error"
|
||||
import { ModelCache } from "../../provider/model-cache"
|
||||
import { Database } from "../../storage/db"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceStore } from "../../project/instance-store"
|
||||
import { Session } from "../../session/session"
|
||||
import { Identifier } from "../../id/id"
|
||||
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
|
||||
@@ -48,6 +49,7 @@ export function register(app: Hono): Hono {
|
||||
z,
|
||||
Database,
|
||||
Instance,
|
||||
InstanceStore,
|
||||
SessionTable,
|
||||
MessageTable,
|
||||
PartTable,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Imported by ../../server/server.ts with minimal kilocode_change markers.
|
||||
|
||||
import { ModelCache } from "../../provider/model-cache"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceStore } from "../../project/instance-store"
|
||||
|
||||
/** Extra paths to skip request logging for */
|
||||
export function skipLogging(path: string): boolean {
|
||||
@@ -21,7 +21,7 @@ export function corsOrigin(input: string): string | undefined {
|
||||
/** Invalidate model cache and provider state after auth change */
|
||||
export async function authChanged(providerID: string) {
|
||||
ModelCache.clear(providerID)
|
||||
await Instance.disposeAll()
|
||||
await InstanceStore.disposeAllInstances()
|
||||
}
|
||||
|
||||
export const DOC_TITLE = "kilo"
|
||||
|
||||
@@ -8,12 +8,37 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
|
||||
export type ReviewTelemetry = {
|
||||
mode: "review"
|
||||
feature: "code_reviews"
|
||||
command: "local-review" | "local-review-uncommitted"
|
||||
}
|
||||
|
||||
export namespace KiloSessionProcessor {
|
||||
const log = Log.create({ service: "session.processor.kilo" })
|
||||
export const OUTPUT_LENGTH_WARNING = "The model hit its output limit, so this response may be incomplete."
|
||||
export const REASONING_LENGTH_WARNING =
|
||||
"The model hit its output limit while reasoning and produced no actionable output. Try disabling reasoning or increasing the output limit."
|
||||
|
||||
export function reviewTelemetry(command: string): ReviewTelemetry | undefined {
|
||||
if (command === "local-review" || command === "local-review-uncommitted") {
|
||||
return { mode: "review", feature: "code_reviews", command }
|
||||
}
|
||||
}
|
||||
|
||||
export function extractReviewTelemetry(parts: MessageV2.Part[]): ReviewTelemetry | undefined {
|
||||
for (const part of parts) {
|
||||
if (part.type !== "text") continue
|
||||
const meta: Record<string, unknown> | undefined = part.metadata
|
||||
if (!meta) continue
|
||||
if (meta.mode !== "review") continue
|
||||
if (meta.feature !== "code_reviews") continue
|
||||
const command = meta.command
|
||||
if (command !== "local-review" && command !== "local-review-uncommitted") continue
|
||||
return { mode: "review", feature: "code_reviews", command }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track LLM completion telemetry for a finished step.
|
||||
* Only fires if at least one token bucket is non-zero.
|
||||
@@ -24,11 +49,13 @@ export namespace KiloSessionProcessor {
|
||||
tokens: { input: number; output: number; cache: { read: number; write: number } }
|
||||
cost: number
|
||||
elapsed: number
|
||||
telemetry?: ReviewTelemetry
|
||||
}) {
|
||||
const { tokens } = input
|
||||
if (tokens.input > 0 || tokens.output > 0 || tokens.cache.write > 0 || tokens.cache.read > 0) {
|
||||
Telemetry.trackLlmCompletion({
|
||||
taskId: input.sessionID,
|
||||
...(input.telemetry ?? {}),
|
||||
apiProvider: input.model.providerID,
|
||||
modelId: input.model.id,
|
||||
inputTokens: tokens.input,
|
||||
|
||||
@@ -42,8 +42,8 @@ export namespace KiloSessionPromptQueue {
|
||||
|
||||
/**
|
||||
* Exempt an injected user message from being hidden by scope().
|
||||
* Called after PlanFollowup.inject() so the injected follow-up is visible
|
||||
* without also unhiding unrelated prompts that were queued mid-turn.
|
||||
* Called after internal follow-ups or compaction markers are persisted so
|
||||
* they are visible without also unhiding unrelated prompts queued mid-turn.
|
||||
*/
|
||||
export function retarget(sessionID: SessionID, id: MessageID) {
|
||||
const current = targets.get(sessionID)
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { StringDecoder } from "string_decoder"
|
||||
import { Cause, Exit } from "effect"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { SessionID, PartID } from "@/session/schema"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import { Session } from "@/session/session"
|
||||
import { Instance } from "@/project/instance"
|
||||
import type { SessionStatus } from "@/session/status"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { PlanFollowup } from "@/kilocode/plan-followup"
|
||||
import { KiloSession } from "@/kilocode/session"
|
||||
@@ -57,6 +59,26 @@ export namespace KiloSessionPrompt {
|
||||
return PlanFollowup.abort(sessionID)
|
||||
}
|
||||
|
||||
export const recoverDanglingAssistant = Effect.fn("KiloSessionPrompt.recoverDanglingAssistant")(function* (input: {
|
||||
sessionID: SessionID
|
||||
status: Pick<SessionStatus.Interface, "get">
|
||||
sessions: Pick<Session.Interface, "messages" | "removeMessage">
|
||||
}) {
|
||||
const state = yield* input.status.get(input.sessionID)
|
||||
if (state.type !== "idle") return
|
||||
|
||||
const msgs = yield* input.sessions.messages({ sessionID: input.sessionID, limit: 2 })
|
||||
const tail = msgs.at(-1)
|
||||
if (!tail || tail.info.role !== "assistant") return
|
||||
if (tail.parts.length > 0 || tail.info.finish || tail.info.error) return
|
||||
|
||||
const prev = msgs.at(-2)
|
||||
if (!prev || prev.info.role !== "user") return
|
||||
if (tail.info.parentID !== prev.info.id) return
|
||||
|
||||
yield* input.sessions.removeMessage({ sessionID: input.sessionID, messageID: tail.info.id })
|
||||
})
|
||||
|
||||
export function guardPermissions(input: {
|
||||
agent: { name: string; permission: Permission.Ruleset }
|
||||
session: Pick<Session.Info, "permission">
|
||||
@@ -137,6 +159,16 @@ export namespace KiloSessionPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the plan file directory exists. Pre-checks with `Filesystem.isDir`
|
||||
* because `fs.mkdir(recursive: true)` still throws `EEXIST` on Windows
|
||||
* OneDrive ReparsePoint directories in some Node versions (kilocode#9755).
|
||||
*/
|
||||
export async function ensurePlanDir(dir: string) {
|
||||
if (await Filesystem.isDir(dir)) return
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects plan-specific reminders into the user message when using the plan agent.
|
||||
* Ensures the plan file directory exists and tells the agent where to write.
|
||||
@@ -147,9 +179,9 @@ export namespace KiloSessionPrompt {
|
||||
userMessage: MessageV2.WithParts
|
||||
}) {
|
||||
if (input.agent.name !== "plan") return
|
||||
const plan = Session.plan(input.session)
|
||||
const plan = Session.plan(input.session, Instance.current)
|
||||
const exists = await Filesystem.exists(plan)
|
||||
if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
|
||||
if (!exists) await ensurePlanDir(path.dirname(plan))
|
||||
const info = exists
|
||||
? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.`
|
||||
: `No plan file exists yet. You should create your plan at ${plan} using the write tool.`
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export namespace KiloSessionTuiSync {
|
||||
export function model(input: { role: string; parts?: readonly { type: string }[] }) {
|
||||
if (input.role !== "user") return false
|
||||
if (!input.parts) return false
|
||||
return !input.parts.some((part) => part.type === "compaction")
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Effect } from "effect"
|
||||
import { Encoding } from "../encoding"
|
||||
import * as Encoding from "../encoding"
|
||||
|
||||
/**
|
||||
* Effect wrappers around {@link Encoding.read} and {@link Encoding.write} so
|
||||
* tool code can preserve file encoding without leaking Node/async boilerplate
|
||||
* into each call site. Uses {@link Effect.tryPromise} so I/O failures surface
|
||||
* as typed errors that can be recovered with `.pipe(Effect.catch(...))`.
|
||||
*
|
||||
* Consumers should import this module as a namespace:
|
||||
* import * as EncodedIO from "../kilocode/tool/encoded-io"
|
||||
*/
|
||||
export namespace EncodedIO {
|
||||
const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
|
||||
|
||||
export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap })
|
||||
const wrap = (cause: unknown) => (cause instanceof Error ? cause : new Error(String(cause)))
|
||||
|
||||
export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) =>
|
||||
Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap })
|
||||
}
|
||||
export const read = (path: string) => Effect.tryPromise({ try: () => Encoding.read(path), catch: wrap })
|
||||
|
||||
export const write = (path: string, text: string, encoding: string = Encoding.DEFAULT) =>
|
||||
Effect.tryPromise({ try: () => Encoding.write(path, text, encoding), catch: wrap })
|
||||
|
||||
@@ -12,9 +12,9 @@ import { Process } from "@/util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { TsClient } from "../kilocode/ts-client" // kilocode_change
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { NonNegativeInt, withStatics } from "@/util/schema"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -24,8 +24,8 @@ export const Event = {
|
||||
}
|
||||
|
||||
const Position = Schema.Struct({
|
||||
line: Schema.Number,
|
||||
character: Schema.Number,
|
||||
line: NonNegativeInt,
|
||||
character: NonNegativeInt,
|
||||
})
|
||||
|
||||
export const Range = Schema.Struct({
|
||||
@@ -38,7 +38,7 @@ export type Range = typeof Range.Type
|
||||
|
||||
export const Symbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
kind: Schema.Number,
|
||||
kind: NonNegativeInt,
|
||||
location: Schema.Struct({
|
||||
uri: Schema.String,
|
||||
range: Range,
|
||||
@@ -51,7 +51,7 @@ export type Symbol = typeof Symbol.Type
|
||||
export const DocumentSymbol = Schema.Struct({
|
||||
name: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
kind: Schema.Number,
|
||||
kind: NonNegativeInt,
|
||||
range: Range,
|
||||
selectionRange: Range,
|
||||
})
|
||||
@@ -222,12 +222,7 @@ export const layer = Layer.effect(
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (
|
||||
!AppFileSystem.contains(ctx.directory, file) &&
|
||||
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
|
||||
) {
|
||||
return [] as LSPClient.Info[]
|
||||
}
|
||||
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
|
||||
@@ -138,6 +138,11 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
|
||||
|
||||
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
function remoteURL(key: string, value: string) {
|
||||
if (URL.canParse(value)) return new URL(value)
|
||||
log.warn("invalid remote mcp url", { key })
|
||||
}
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
@@ -275,7 +280,7 @@ export const layer = Layer.effect(
|
||||
(t) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
|
||||
return withTimeout(client.connect(t), timeout).then(() => client)
|
||||
},
|
||||
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
||||
@@ -291,6 +296,13 @@ export const layer = Layer.effect(
|
||||
) {
|
||||
const oauthDisabled = mcp.oauth === false
|
||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||
const url = remoteURL(key, mcp.url)
|
||||
if (!url) {
|
||||
return {
|
||||
client: undefined as MCPClient | undefined,
|
||||
status: { status: "failed" as const, error: `Invalid MCP URL for "${key}"` },
|
||||
}
|
||||
}
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
|
||||
if (!oauthDisabled) {
|
||||
@@ -315,14 +327,14 @@ export const layer = Layer.effect(
|
||||
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
transport: new StreamableHTTPClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
transport: new SSEClientTransport(url, {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
@@ -362,7 +374,7 @@ export const layer = Layer.effect(
|
||||
return bus
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
||||
message: `Server "${key}" requires authentication. Run: kilo mcp auth ${key}`, // kilocode_change
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
@@ -742,11 +754,15 @@ export const layer = Layer.effect(
|
||||
return mcpConfig
|
||||
})
|
||||
|
||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) { // kilocode_change
|
||||
// kilocode_change start - `opts?: { callback?: boolean }` parameter is Kilo-specific
|
||||
const startAuth = Effect.fn("MCP.startAuth")(function* (mcpName: string, opts?: { callback?: boolean }) {
|
||||
// kilocode_change end
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) throw new Error(`MCP server ${mcpName} not found or disabled`)
|
||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
const url = remoteURL(mcpName, mcpConfig.url)
|
||||
if (!url) throw new Error(`Invalid MCP URL for "${mcpName}"`)
|
||||
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
@@ -779,11 +795,11 @@ export const layer = Layer.effect(
|
||||
auth,
|
||||
)
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
||||
const transport = new StreamableHTTPClientTransport(url, { authProvider })
|
||||
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: InstallationVersion })
|
||||
const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change
|
||||
return client
|
||||
.connect(transport)
|
||||
.then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult)
|
||||
@@ -826,7 +842,8 @@ export const layer = Layer.effect(
|
||||
// kilocode_change start - bind only after redirect exists, and clean up if binding fails
|
||||
const mcpConfig = yield* getMcpConfig(mcpName)
|
||||
if (!mcpConfig) return { status: "failed", error: "MCP config not found after auth" } as Status
|
||||
if (mcpConfig.type !== "remote") return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status
|
||||
if (mcpConfig.type !== "remote")
|
||||
return { status: "failed", error: `MCP server ${mcpName} is not a remote server` } as Status
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
const err = yield* Effect.tryPromise({
|
||||
try: () => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri),
|
||||
|
||||
@@ -44,8 +44,8 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
client_name: "Kilo", // kilocode_change
|
||||
client_uri: "https://kilo.ai", // kilocode_change
|
||||
client_name: "Kilo",
|
||||
client_uri: "https://kilo.ai",
|
||||
grant_types: ["authorization_code", "refresh_token"],
|
||||
response_types: ["code"],
|
||||
token_endpoint_auth_method: this.config.clientSecret ? "client_secret_post" : "none",
|
||||
|
||||
@@ -2,7 +2,7 @@ import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Encoding } from "../kilocode/encoding" // kilocode_change
|
||||
import * as Encoding from "../kilocode/encoding" // kilocode_change
|
||||
import * as Bom from "../util/bom"
|
||||
|
||||
const log = Log.create({ service: "patch" })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user