mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f1d001cc5 | |||
| 65d0b3ed6d | |||
| 22a34d7958 | |||
| cb4401ec92 | |||
| febf467b03 | |||
| d55a2fd56c | |||
| 40f577e5e7 | |||
| 9e49870118 | |||
| fe38e3ab02 | |||
| 0170577743 | |||
| 7de6ea5922 | |||
| 2fe7d13e69 | |||
| 1bc3c98ae7 | |||
| 55787f2caa | |||
| 7df61a74a0 | |||
| 4f23110880 | |||
| 041353f4ff | |||
| c72f8b17c6 | |||
| eb304f4115 | |||
| 5565f14ef5 | |||
| 10a4455c6f | |||
| 5ded6d6ad7 | |||
| 849a38c30c | |||
| 68050ab802 | |||
| 91d01fd4cc | |||
| 9beb0f8512 | |||
| d4cb47eadc | |||
| 261ff416a9 | |||
| d0a70cb217 | |||
| 20fc56d020 | |||
| a57ae3ec93 | |||
| 30f9fa12d9 | |||
| d473d4ffc8 | |||
| af50596529 | |||
| 3823d8d50e | |||
| 7a926b32ce | |||
| a5ede68241 | |||
| 60dc38050d | |||
| 31d0caee38 | |||
| 2a7ab45605 | |||
| 019054dd1e | |||
| a018a15f32 | |||
| e630d680dd | |||
| 9e392f25a6 | |||
| 2cc4e6ad7c | |||
| 70d8d1ab1e | |||
| 342aa27e03 | |||
| e1aed0cd01 | |||
| c8ea2c5ce0 | |||
| 5e8309a353 | |||
| aae0ce9921 | |||
| 81b94d84dc | |||
| ceab70f8d9 | |||
| afe8cecc2b | |||
| 4a292bf977 | |||
| e249b41513 | |||
| 9021dd60a1 | |||
| b9a39b816c | |||
| 1eeba770b1 | |||
| 6cff306be1 | |||
| 96bdeb3c7b |
@@ -0,0 +1,40 @@
|
||||
name: Auto-label TUI Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add opentui label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const title = issue.title;
|
||||
const description = issue.body || '';
|
||||
|
||||
// Check for version patterns like v1.0.x or 1.0.x
|
||||
const versionPattern = /\b[v]?1\.0\.[x\d]\b/i;
|
||||
|
||||
if (versionPattern.test(title) || versionPattern.test(description)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
labels: ['opentui']
|
||||
});
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
assignees: ['thdxr']
|
||||
});
|
||||
}
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
with:
|
||||
model: opencode/kimi-k2
|
||||
model: opencode/glm-4.6
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
bun run typecheck
|
||||
bun typecheck
|
||||
|
||||
@@ -18,3 +18,6 @@ For anything in the packages/app use the ignore: prefix.
|
||||
|
||||
prefer to explain WHY something was done from an end user perspective instead of
|
||||
WHAT was done.
|
||||
|
||||
do not do generic messages like "improved agent experience" be very specific
|
||||
about what user facing changes were made
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: hello world
|
||||
description: hello world iaosd ioasjdoiasjd oisadjoisajd osiajd oisaj dosaij dsoajsajdaijdoisa jdoias jdoias jdoia jois jo jdois jdoias jdoias j djoasdj
|
||||
---
|
||||
|
||||
hey there $ARGUMENTS
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
|
||||
## Developing OpenCode
|
||||
|
||||
- Requirements: Bun 1.3+, Go 1.24.x.
|
||||
- Requirements: Bun 1.3+
|
||||
- Install dependencies and start the dev server from the repo root:
|
||||
|
||||
```bash
|
||||
@@ -36,11 +36,11 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
|
||||
|
||||
- Core pieces:
|
||||
- `packages/opencode`: OpenCode core business logic & server.
|
||||
- `packages/tui`: The TUI code, written in Go (will be removed soon in favor of [opentui](https://github.com/sst/opentui))
|
||||
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
|
||||
- `packages/plugin`: Source for `@opencode-ai/plugin`
|
||||
|
||||
> [!NOTE]
|
||||
> After touching `packages/opencode/src/server/server.ts`, the OpenCode team must regenerate the Stainless SDK before any client updates merge.
|
||||
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
|
||||
@@ -126,3 +126,4 @@
|
||||
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
|
||||
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
|
||||
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
|
||||
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"keep": {
|
||||
"days": true,
|
||||
"amount": 14
|
||||
},
|
||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
||||
"files": [
|
||||
{
|
||||
"date": 1759827172859,
|
||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
||||
}
|
||||
],
|
||||
"hashType": "sha256"
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
||||
+15
-1
@@ -1,3 +1,17 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json"
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
"mcp": {
|
||||
"weather": {
|
||||
"type": "local",
|
||||
"command": ["bun", "x", "@h1deya/mcp-server-weather"]
|
||||
},
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
"headers": {
|
||||
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.3.0",
|
||||
"scripts": {
|
||||
"dev": "bun run packages/opencode/src/index.ts",
|
||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -19,6 +21,7 @@
|
||||
"catalog": {
|
||||
"@types/bun": "1.3.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/node": "22.13.9",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
|
||||
@@ -7,13 +7,15 @@
|
||||
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
|
||||
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
|
||||
"start": "vinxi start",
|
||||
"version": "0.15.31"
|
||||
"version": "1.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
"@opencode-ai/console-mail": "workspace:*",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
"@kobalte/core": "catalog:",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
"@opencode-ai/console-resource": "workspace:*",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
|
||||
@@ -97,8 +97,6 @@ export async function POST(input: APIEvent) {
|
||||
? {}
|
||||
: {
|
||||
reload: true,
|
||||
reloadTrigger: Billing.CHARGE_THRESHOLD_DOLLAR,
|
||||
reloadAmount: Billing.CHARGE_AMOUNT_DOLLAR,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
}),
|
||||
|
||||
@@ -28,8 +28,6 @@ const setReload = action(async (form: FormData) => {
|
||||
reload: reloadValue,
|
||||
...(reloadValue
|
||||
? {
|
||||
reloadTrigger: Billing.CHARGE_THRESHOLD_DOLLAR,
|
||||
reloadAmount: Billing.CHARGE_AMOUNT_DOLLAR,
|
||||
reloadError: null,
|
||||
timeReloadError: null,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -14,7 +14,7 @@
|
||||
"drizzle-orm": "0.41.0",
|
||||
"postgres": "3.4.7",
|
||||
"stripe": "18.0.0",
|
||||
"ulid": "3.0.0",
|
||||
"ulid": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,34 +1,64 @@
|
||||
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Part } from "@opencode-ai/ui"
|
||||
import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||
import { Markdown, Part } from "@opencode-ai/ui"
|
||||
import { useSync } from "@/context/sync"
|
||||
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||
import type { AssistantMessage as AssistantMessageType, Part as PartType, ToolPart } from "@opencode-ai/sdk"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
|
||||
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
|
||||
const sync = useSync()
|
||||
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
||||
const parts = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
||||
const done = createMemo(() => props.done ?? false)
|
||||
const currentTask = createMemo(
|
||||
() =>
|
||||
parts().findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
p.tool === "task" &&
|
||||
p.state &&
|
||||
"metadata" in p.state &&
|
||||
p.state.metadata &&
|
||||
p.state.metadata.sessionId &&
|
||||
p.state.status === "running",
|
||||
) as ToolPart,
|
||||
)
|
||||
|
||||
const finishedItems = createMemo(() => [
|
||||
"",
|
||||
"",
|
||||
"Loading...",
|
||||
...items().filter(
|
||||
(p) =>
|
||||
p?.type === "text" ||
|
||||
(p?.type === "reasoning" && p.time?.end) ||
|
||||
(p?.type === "tool" && p.state.status === "completed"),
|
||||
),
|
||||
"",
|
||||
const resolvedParts = createMemo(() => {
|
||||
let resolved = parts()
|
||||
const task = currentTask()
|
||||
if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
||||
const messages = sync.data.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
|
||||
resolved = messages?.flatMap((m) => sync.data.part[m.id]) ?? parts()
|
||||
}
|
||||
return resolved
|
||||
})
|
||||
const currentText = createMemo(
|
||||
() =>
|
||||
resolvedParts().findLast((p) => p?.type === "text")?.text ||
|
||||
resolvedParts().findLast((p) => p?.type === "reasoning")?.text,
|
||||
)
|
||||
const eligibleItems = createMemo(() => {
|
||||
return resolvedParts().filter((p) => p?.type === "tool" && p.state.status === "completed")
|
||||
})
|
||||
const finishedItems = createMemo<(JSXElement | PartType)[]>(() => [
|
||||
<div class="h-8 w-full" />,
|
||||
<div class="h-8 w-full" />,
|
||||
<div class="flex items-center gap-x-5 pl-3 text-text-base">
|
||||
<Spinner /> <span class="text-12-medium">Thinking...</span>
|
||||
</div>,
|
||||
...eligibleItems(),
|
||||
...(done() ? [<div class="h-8 w-full" />, <div class="h-8 w-full" />, <div class="h-8 w-full" />] : []),
|
||||
])
|
||||
|
||||
const MINIMUM_DELAY = 400
|
||||
const [visibleCount, setVisibleCount] = createSignal(1)
|
||||
const delay = createMemo(() => (done() ? 220 : 400))
|
||||
const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
|
||||
|
||||
createEffect(() => {
|
||||
const total = finishedItems().length
|
||||
if (total > visibleCount()) {
|
||||
const timer = setTimeout(() => {
|
||||
setVisibleCount((prev) => prev + 1)
|
||||
}, MINIMUM_DELAY)
|
||||
}, delay())
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
} else if (total < visibleCount()) {
|
||||
setVisibleCount(total)
|
||||
@@ -42,41 +72,57 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-30 overflow-hidden pointer-events-none
|
||||
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
||||
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{ transform: `translateY(${translateY()})` }}
|
||||
class="h-30 overflow-hidden pointer-events-none pb-1
|
||||
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
||||
mask-b-from-95% mask-b-from-background-base mask-b-to-transparent"
|
||||
>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => {
|
||||
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
|
||||
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
|
||||
return (
|
||||
<div class="h-8 flex items-center w-full">
|
||||
<Switch>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(p) => (
|
||||
<div
|
||||
textContent={p().text}
|
||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div
|
||||
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{ transform: `translateY(${translateY()})` }}
|
||||
>
|
||||
<For each={finishedItems()}>
|
||||
{(part) => {
|
||||
if (part && typeof part === "object" && "type" in part) {
|
||||
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
|
||||
return (
|
||||
<div class="h-8 flex items-center w-full">
|
||||
<Switch>
|
||||
<Match when={part.type === "text" && part}>
|
||||
{(p) => (
|
||||
<div
|
||||
textContent={p().text}
|
||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={part.type === "reasoning" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
<Match when={part.type === "tool" && part}>
|
||||
{(p) => <Part message={message()!} part={p()} />}
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <div class="h-8 flex items-center w-full">{part}</div>
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={currentText()}>
|
||||
{(text) => (
|
||||
<div
|
||||
class="max-h-36 flex flex-col justify-end overflow-hidden py-3
|
||||
mask-alpha mask-t-from-80% mask-t-from-background-base mask-t-to-transparent"
|
||||
>
|
||||
<Markdown text={text()} class="w-full shrink-0 overflow-visible" />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"bg-surface-raised-stronger-non-alpha border border-border-strong-base": true,
|
||||
"rounded-2xl overflow-clip focus-within:shadow-xs-border-selected": true,
|
||||
"rounded-2xl overflow-clip focus-within:border-transparent focus-within:shadow-xs-border-select": true,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -162,10 +162,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const recentList = recent()
|
||||
const current = currentModel()
|
||||
if (!current) return
|
||||
|
||||
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
|
||||
if (index === -1) return
|
||||
|
||||
let next = index + direction
|
||||
if (next < 0) next = recentList.length - 1
|
||||
if (next >= recentList.length) next = 0
|
||||
|
||||
const val = recentList[next]
|
||||
if (!val) return
|
||||
|
||||
model.set({
|
||||
providerID: val.provider.id,
|
||||
modelID: val.id,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
recent,
|
||||
list,
|
||||
cycle,
|
||||
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
setStore("model", agent.current().name, model ?? fallbackModel())
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
|
||||
import type {
|
||||
Message,
|
||||
Agent,
|
||||
Provider,
|
||||
Session,
|
||||
Part,
|
||||
Config,
|
||||
Path,
|
||||
File,
|
||||
FileNode,
|
||||
Project,
|
||||
Command,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createMemo } from "solid-js"
|
||||
import { Binary } from "@/utils/binary"
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Accordion,
|
||||
Diff,
|
||||
Collapsible,
|
||||
Part,
|
||||
DiffChanges,
|
||||
ProgressCircle,
|
||||
Message,
|
||||
@@ -548,7 +547,79 @@ export default function Page() {
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||
const working = createMemo(() => !message.summary?.title)
|
||||
const working = createMemo(() => !message.summary?.body)
|
||||
const assistantMessages = createMemo(() => {
|
||||
return sync.data.message[activeSession().id]?.filter(
|
||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||
) as AssistantMessageType[]
|
||||
})
|
||||
const parts = createMemo(() =>
|
||||
assistantMessages().flatMap((m) => sync.data.part[m.id]),
|
||||
)
|
||||
const lastPart = createMemo(() => parts().slice(-1)?.at(0))
|
||||
const rawStatus = createMemo(() => {
|
||||
const defaultStatus = "Working..."
|
||||
const last = lastPart()
|
||||
if (!last) return defaultStatus
|
||||
|
||||
if (last.type === "tool") {
|
||||
switch (last.tool) {
|
||||
case "task":
|
||||
return "Delegating work..."
|
||||
case "todowrite":
|
||||
case "todoread":
|
||||
return "Planning next steps..."
|
||||
case "read":
|
||||
return "Gathering context..."
|
||||
case "list":
|
||||
case "grep":
|
||||
case "glob":
|
||||
return "Searching the codebase..."
|
||||
case "webfetch":
|
||||
return "Searching the web..."
|
||||
case "edit":
|
||||
case "write":
|
||||
return "Making edits..."
|
||||
case "bash":
|
||||
return "Running commands..."
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else if (last.type === "reasoning") {
|
||||
return "Thinking..."
|
||||
} else if (last.type === "text") {
|
||||
return "Gathering thoughts..."
|
||||
}
|
||||
return defaultStatus
|
||||
})
|
||||
|
||||
const [status, setStatus] = createSignal(rawStatus())
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
|
||||
createEffect(() => {
|
||||
const newStatus = rawStatus()
|
||||
if (newStatus === status()) return
|
||||
|
||||
const timeSinceLastChange = Date.now() - lastStatusChange
|
||||
|
||||
if (timeSinceLastChange >= 1000) {
|
||||
setStatus(newStatus)
|
||||
lastStatusChange = Date.now()
|
||||
if (statusTimeout) {
|
||||
clearTimeout(statusTimeout)
|
||||
statusTimeout = undefined
|
||||
}
|
||||
} else {
|
||||
if (statusTimeout) clearTimeout(statusTimeout)
|
||||
statusTimeout = setTimeout(() => {
|
||||
setStatus(rawStatus())
|
||||
lastStatusChange = Date.now()
|
||||
statusTimeout = undefined
|
||||
}, 1000 - timeSinceLastChange) as unknown as number
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<li class="group/li flex items-center self-stretch">
|
||||
<button
|
||||
@@ -570,7 +641,10 @@ export default function Page() {
|
||||
"text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
|
||||
}}
|
||||
>
|
||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||
<Switch>
|
||||
<Match when={working()}>{status()}</Match>
|
||||
<Match when={true}>{message.summary?.title}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -583,7 +657,8 @@ export default function Page() {
|
||||
<For each={local.session.userMessages()}>
|
||||
{(message) => {
|
||||
const isActive = createMemo(() => local.session.activeMessage()?.id === message.id)
|
||||
const [initialized, setInitialized] = createSignal(!!message.summary?.title)
|
||||
const [titled, setTitled] = createSignal(!!message.summary?.title)
|
||||
const [completed, setCompleted] = createSignal(!!message.summary?.body)
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const parts = createMemo(() => sync.data.part[message.id])
|
||||
const title = createMemo(() => message.summary?.title)
|
||||
@@ -597,11 +672,18 @@ export default function Page() {
|
||||
const hasToolPart = createMemo(() =>
|
||||
assistantMessages()
|
||||
?.flatMap((m) => sync.data.part[m.id])
|
||||
.some((p) => p.type === "tool"),
|
||||
.some((p) => p?.type === "tool"),
|
||||
)
|
||||
const working = createMemo(() => !summary())
|
||||
|
||||
// allowing time for the animations to finish
|
||||
createEffect(() => {
|
||||
setTimeout(() => setInitialized(!!title()), 10_000)
|
||||
title()
|
||||
setTimeout(() => setTitled(!!title()), 10_000)
|
||||
})
|
||||
createEffect(() => {
|
||||
summary()
|
||||
setTimeout(() => setCompleted(!!summary()), 1200)
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -612,9 +694,18 @@ export default function Page() {
|
||||
>
|
||||
{/* Title */}
|
||||
<div class="py-2 flex flex-col items-start gap-2 self-stretch sticky top-0 bg-background-stronger z-10">
|
||||
<div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
|
||||
<Show when={initialized()} fallback={<Typewriter as="h1" text={title()} />}>
|
||||
<h1>{title()}</h1>
|
||||
<div class="w-full text-14-medium text-text-strong">
|
||||
<Show
|
||||
when={titled()}
|
||||
fallback={
|
||||
<Typewriter
|
||||
as="h1"
|
||||
text={title()}
|
||||
class="overflow-hidden text-ellipsis min-w-0 text-nowrap"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<h1 class="overflow-hidden text-ellipsis min-w-0 text-nowrap">{title()}</h1>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,7 +713,7 @@ export default function Page() {
|
||||
<Message message={message} parts={parts()} />
|
||||
</div>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<Show when={completed()}>
|
||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||
<h2 class="text-12-medium text-text-weak">
|
||||
@@ -631,7 +722,14 @@ export default function Page() {
|
||||
<Match when={true}>Response</Match>
|
||||
</Switch>
|
||||
</h2>
|
||||
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
|
||||
<Show when={summary()}>
|
||||
{(summary) => (
|
||||
<Markdown
|
||||
classList={{ "[&>*]:fade-up-text": !diffs().length }}
|
||||
text={summary()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Accordion class="w-full" multiple>
|
||||
<For each={diffs()}>
|
||||
@@ -684,17 +782,17 @@ export default function Page() {
|
||||
{/* Response */}
|
||||
<div class="w-full">
|
||||
<Switch>
|
||||
<Match when={working()}>
|
||||
<MessageProgress assistantMessages={assistantMessages} />
|
||||
<Match when={!completed()}>
|
||||
<MessageProgress assistantMessages={assistantMessages} done={!working()} />
|
||||
</Match>
|
||||
<Match when={!working() && hasToolPart()}>
|
||||
<Match when={completed() && hasToolPart()}>
|
||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||
<div class="flex items-center gap-1 self-stretch">
|
||||
<div class="text-12-medium">
|
||||
<Switch>
|
||||
<Match when={expanded()}>Hide steps</Match>
|
||||
<Match when={!expanded()}>Show steps</Match>
|
||||
<Match when={expanded()}>Hide details</Match>
|
||||
<Match when={!expanded()}>Show details</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<Collapsible.Arrow />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -238,10 +238,16 @@ export default new Hono<{ Bindings: Env }>()
|
||||
|
||||
// Lookup installation
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({ owner, repo })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({ type: "installation", installationId: installation.id })
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
})
|
||||
@@ -274,10 +280,16 @@ export default new Hono<{ Bindings: Env }>()
|
||||
|
||||
// Lookup installation
|
||||
const appClient = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await appClient.apps.getRepoInstallation({ owner, repo })
|
||||
const { data: installation } = await appClient.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({ type: "installation", installationId: installation.id })
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
preload = ["@opentui/solid/preload"]
|
||||
|
||||
[test]
|
||||
preload = ["./test/preload.ts"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.15.31",
|
||||
"version": "1.0.9",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
@@ -8,7 +8,8 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "bun test",
|
||||
"build": "./script/build.ts",
|
||||
"dev": "bun run ./src/index.ts"
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)'"
|
||||
},
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
@@ -19,6 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@ai-sdk/amazon-bedrock": "2.2.10",
|
||||
"@ai-sdk/google-vertex": "3.0.16",
|
||||
"@babel/core": "7.28.4",
|
||||
"@octokit/webhooks-types": "7.6.1",
|
||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||
@@ -27,12 +29,15 @@
|
||||
"@parcel/watcher-win32-x64": "2.5.1",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/babel__core": "7.20.5",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/yargs": "17.0.33",
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vscode-languageserver-types": "3.17.5",
|
||||
"why-is-node-running": "3.2.2",
|
||||
"zod-to-json-schema": "3.24.5",
|
||||
"@opencode-ai/script": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -49,12 +54,16 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opentui/core": "0.0.0-20251031-fc297165",
|
||||
"@opentui/solid": "0.0.0-20251031-fc297165",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@solid-primitives/event-bus": "1.1.2",
|
||||
"@pierre/precision-diffs": "catalog:",
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
"diff": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
@@ -65,13 +74,14 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
"tree-sitter": "0.22.4",
|
||||
"tree-sitter-bash": "0.23.3",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"turndown": "7.2.0",
|
||||
"ulid": "3.0.1",
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.22.6",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
export default {
|
||||
// NOTE: FOR markdown, javascript and typescript, we use the opentui built-in parsers
|
||||
// Warn: when taking queries from the nvim-treesitter repo, make sure to include the query dependencies as well
|
||||
// marked with for example `; inherits: ecma` at the top of the file. Just put the dependencies before the actual query.
|
||||
// ALSO: Some queries use breaking changes in the nvim-treesitter repo, that are not compatible with the (web-)tree-sitter parser.
|
||||
parsers: [
|
||||
{
|
||||
filetype: "python",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-python/releases/download/v0.23.6/tree-sitter-python.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// it is using "except" nodes that the parser is complaining about, but it has been in the query for 3+ years.
|
||||
// Unclear.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-python/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/python/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "rust",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-rust/releases/download/v0.24.0/tree-sitter-rust.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/rust/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "go",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-go/releases/download/v0.25.0/tree-sitter-go.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/go/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "cpp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-cpp/releases/download/v0.23.4/tree-sitter-cpp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/cpp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "csharp",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c-sharp/releases/download/v0.23.1/tree-sitter-c_sharp.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c_sharp/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "bash",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-bash/releases/download/v0.25.0/tree-sitter-bash.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/bash/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "c",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-c/releases/download/v0.24.1/tree-sitter-c.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/c/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "java",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-java/releases/download/v0.23.5/tree-sitter-java.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/java/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ruby",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/highlights.scm",
|
||||
],
|
||||
locals: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ruby/locals.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "php",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-php/releases/download/v0.24.2/tree-sitter-php.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/php/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-php/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "scala",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-scala/releases/download/v0.24.0/tree-sitter-scala.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/scala/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "html",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-html/releases/download/v0.23.2/tree-sitter-html.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
// NOTE: This nvim-treesitter query is currently broken, because the parser is not compatible with the query apparently.
|
||||
// "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/html/highlights.scm",
|
||||
"https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/highlights.scm",
|
||||
],
|
||||
// TODO: Injections not working for some reason
|
||||
// injections: [
|
||||
// "https://github.com/tree-sitter/tree-sitter-html/raw/refs/heads/master/queries/injections.scm",
|
||||
// ],
|
||||
},
|
||||
// injectionMapping: {
|
||||
// nodeTypes: {
|
||||
// script_element: "javascript",
|
||||
// style_element: "css",
|
||||
// },
|
||||
// infoStringMap: {
|
||||
// javascript: "javascript",
|
||||
// css: "css",
|
||||
// },
|
||||
// },
|
||||
},
|
||||
{
|
||||
filetype: "json",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/json/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "haskell",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-haskell/releases/download/v0.23.1/tree-sitter-haskell.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/haskell/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "css",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-css/releases/download/v0.25.0/tree-sitter-css.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/css/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "julia",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-julia/releases/download/v0.23.1/tree-sitter-julia.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/julia/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
filetype: "ocaml",
|
||||
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
|
||||
queries: {
|
||||
highlights: [
|
||||
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/ocaml/highlights.scm",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { $ } from "bun"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
@@ -7,18 +11,13 @@ const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
import { $ } from "bun"
|
||||
|
||||
import pkg from "../package.json"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
|
||||
const GOARCH: Record<string, string> = {
|
||||
arm64: "arm64",
|
||||
x64: "amd64",
|
||||
"x64-baseline": "amd64",
|
||||
}
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
|
||||
const targets = [
|
||||
const allTargets = [
|
||||
["windows", "x64"],
|
||||
["linux", "arm64"],
|
||||
["linux", "x64"],
|
||||
@@ -28,6 +27,10 @@ const targets = [
|
||||
["darwin", "arm64"],
|
||||
]
|
||||
|
||||
const targets = singleFlag
|
||||
? allTargets.filter(([os, arch]) => os === process.platform && arch === process.arch)
|
||||
: allTargets
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
@@ -35,16 +38,22 @@ for (const [os, arch] of targets) {
|
||||
console.log(`building ${os}-${arch}`)
|
||||
const name = `${pkg.name}-${os}-${arch}`
|
||||
await $`mkdir -p dist/${name}/bin`
|
||||
await $`CGO_ENABLED=0 GOOS=${os} GOARCH=${GOARCH[arch]} go build -ldflags="-s -w -X main.Version=${Script.version}" -o ../opencode/dist/${name}/bin/tui ../tui/cmd/opencode/main.go`
|
||||
.cwd("../tui")
|
||||
.quiet()
|
||||
|
||||
const opentui = `@opentui/core-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}`
|
||||
await $`mkdir -p ../../node_modules/${opentui}`
|
||||
await $`npm pack ${opentui}@${pkg.dependencies["@opentui/core"]}`.cwd(path.join(dir, "../../node_modules"))
|
||||
await $`tar -xf ../../node_modules/${opentui.replace("@opentui/", "opentui-")}-*.tgz -C ../../node_modules/${opentui} --strip-components=1`
|
||||
|
||||
const watcher = `@parcel/watcher-${os === "windows" ? "win32" : os}-${arch.replace("-baseline", "")}${os === "linux" ? "-glibc" : ""}`
|
||||
await $`mkdir -p ../../node_modules/${watcher}`
|
||||
await $`npm pack npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
||||
await $`npm pack ${watcher}`.cwd(path.join(dir, "../../node_modules")).quiet()
|
||||
await $`tar -xf ../../node_modules/${watcher.replace("@parcel/", "parcel-")}-*.tgz -C ../../node_modules/${watcher} --strip-components=1`
|
||||
|
||||
const parserWorker = fs.realpathSync(path.resolve(dir, "./node_modules/@opentui/core/parser.worker.js"))
|
||||
await Bun.build({
|
||||
conditions: ["browser"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [solidPlugin],
|
||||
sourcemap: "external",
|
||||
compile: {
|
||||
target: `bun-${os}-${arch}` as any,
|
||||
@@ -52,13 +61,14 @@ for (const [os, arch] of targets) {
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, `--env-file=""`, `--`],
|
||||
windows: {},
|
||||
},
|
||||
entrypoints: ["./src/index.ts"],
|
||||
entrypoints: ["./src/index.ts", parserWorker, "./src/cli/cmd/tui/worker.ts"],
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(dir, parserWorker),
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_TUI_PATH: `'../../../dist/${name}/bin/tui'`,
|
||||
},
|
||||
})
|
||||
|
||||
await $`rm -rf ./dist/${name}/bin/tui`
|
||||
await Bun.file(`dist/${name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
|
||||
@@ -25,8 +25,8 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
[pkg.name]: `./bin/${pkg.name}`,
|
||||
},
|
||||
scripts: {
|
||||
preinstall: "node ./preinstall.mjs",
|
||||
postinstall: "node ./postinstall.mjs",
|
||||
preinstall: "bun ./preinstall.mjs || node ./preinstall.mjs",
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
},
|
||||
version: Script.version,
|
||||
optionalDependencies: binaries,
|
||||
|
||||
@@ -28,8 +28,6 @@ import { Storage } from "@/storage/storage"
|
||||
import { Command } from "@/command"
|
||||
import { Agent as Agents } from "@/agent/agent"
|
||||
import { Permission } from "@/permission"
|
||||
import { Session } from "@/session"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { SessionCompaction } from "@/session/compaction"
|
||||
import type { Config } from "@/config/config"
|
||||
import { MCP } from "@/mcp"
|
||||
@@ -89,7 +87,11 @@ export namespace ACP {
|
||||
})
|
||||
if (!res) return
|
||||
if (res.outcome.outcome !== "selected") {
|
||||
Permission.respond({ sessionID: permission.sessionID, permissionID: permission.id, response: "reject" })
|
||||
Permission.respond({
|
||||
sessionID: permission.sessionID,
|
||||
permissionID: permission.id,
|
||||
response: "reject",
|
||||
})
|
||||
return
|
||||
}
|
||||
Permission.respond({
|
||||
@@ -111,9 +113,11 @@ export namespace ACP {
|
||||
const acpSession = this.sessionManager.get(part.sessionID)
|
||||
if (!acpSession) return
|
||||
|
||||
const message = await Storage.read<MessageV2.Info>(["message", part.sessionID, part.messageID]).catch(
|
||||
() => undefined,
|
||||
)
|
||||
const message = await Storage.read<MessageV2.Info>([
|
||||
"message",
|
||||
part.sessionID,
|
||||
part.messageID,
|
||||
]).catch(() => undefined)
|
||||
if (!message || message.role !== "assistant") return
|
||||
|
||||
if (part.type === "tool") {
|
||||
@@ -192,7 +196,9 @@ export namespace ACP {
|
||||
sessionUpdate: "plan",
|
||||
entries: parsedTodos.data.map((todo) => {
|
||||
const status: PlanEntry["status"] =
|
||||
todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
|
||||
todo.status === "cancelled"
|
||||
? "completed"
|
||||
: (todo.status as PlanEntry["status"])
|
||||
return {
|
||||
priority: "medium",
|
||||
status,
|
||||
@@ -375,11 +381,6 @@ export namespace ACP {
|
||||
description: command.description ?? "",
|
||||
}))
|
||||
const names = new Set(availableCommands.map((c) => c.name))
|
||||
if (!names.has("init"))
|
||||
availableCommands.push({
|
||||
name: "init",
|
||||
description: "create/update a AGENTS.md",
|
||||
})
|
||||
if (!names.has("compact"))
|
||||
availableCommands.push({
|
||||
name: "compact",
|
||||
@@ -404,7 +405,8 @@ export namespace ACP {
|
||||
description: agent.description,
|
||||
}))
|
||||
|
||||
const currentModeId = availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
const currentModeId =
|
||||
availableModes.find((m) => m.name === "build")?.id ?? availableModes[0].id
|
||||
|
||||
const mcpServers: Record<string, Config.Mcp> = {}
|
||||
for (const server of params.mcpServers) {
|
||||
@@ -585,14 +587,6 @@ export namespace ACP {
|
||||
}
|
||||
|
||||
switch (cmd.name) {
|
||||
case "init":
|
||||
await Session.initialize({
|
||||
sessionID,
|
||||
messageID: Identifier.ascending("message"),
|
||||
providerID: model.providerID,
|
||||
modelID: model.modelID,
|
||||
})
|
||||
break
|
||||
case "compact":
|
||||
await SessionCompaction.run({
|
||||
sessionID,
|
||||
@@ -665,7 +659,9 @@ export namespace ACP {
|
||||
|
||||
function parseUri(
|
||||
uri: string,
|
||||
): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
|
||||
):
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
| { type: "text"; text: string } {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
const path = uri.slice(7)
|
||||
|
||||
@@ -74,7 +74,10 @@ export namespace BunProc {
|
||||
// - If .npmrc files exist, Bun will use them automatically
|
||||
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
|
||||
// - No need to pass --registry flag
|
||||
log.info("installing package using Bun's default registry resolution", { pkg, version })
|
||||
log.info("installing package using Bun's default registry resolution", {
|
||||
pkg,
|
||||
version,
|
||||
})
|
||||
|
||||
await BunProc.run(args, {
|
||||
cwd: Global.Path.cache,
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Global } from "../../global"
|
||||
import { cmd } from "./cmd"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Log } from "../../util/log"
|
||||
|
||||
import { $ } from "bun"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <server>",
|
||||
describe: "attach to a running opencode server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("server", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
let cmd = [] as string[]
|
||||
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
|
||||
if (tui) {
|
||||
let binaryName = tui.name
|
||||
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
|
||||
binaryName += ".exe"
|
||||
}
|
||||
const binary = path.join(Global.Path.cache, "tui", binaryName)
|
||||
const file = Bun.file(binary)
|
||||
if (!(await file.exists())) {
|
||||
await Bun.write(file, tui, { mode: 0o755 })
|
||||
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
|
||||
}
|
||||
cmd = [binary]
|
||||
}
|
||||
if (!tui) {
|
||||
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
|
||||
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
|
||||
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
|
||||
cmd = [path.join(dir, binaryName)]
|
||||
}
|
||||
if (args.session) {
|
||||
cmd.push("--session", args.session)
|
||||
}
|
||||
Log.Default.info("tui", {
|
||||
cmd,
|
||||
})
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
CGO_ENABLED: "0",
|
||||
OPENCODE_SERVER: args.server,
|
||||
},
|
||||
})
|
||||
|
||||
await proc.exited
|
||||
},
|
||||
})
|
||||
@@ -80,7 +80,7 @@ export const AuthLoginCommand = cmd({
|
||||
UI.empty()
|
||||
prompts.intro("Add credential")
|
||||
if (args.url) {
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json())
|
||||
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
|
||||
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
|
||||
const proc = Bun.spawn({
|
||||
cmd: wellknown.auth.command,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "path"
|
||||
import { $ } from "bun"
|
||||
import { exec } from "child_process"
|
||||
import * as prompts from "@clack/prompts"
|
||||
import { map, pipe, sortBy, values } from "remeda"
|
||||
@@ -20,6 +19,7 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
|
||||
@@ -12,7 +12,6 @@ export const ServeCommand = cmd({
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
|
||||
@@ -65,7 +65,6 @@ export const TuiCommand = cmd({
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
alias: ["h"],
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { RouteProvider, useRoute, type Route } from "@tui/context/route"
|
||||
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal } from "solid-js"
|
||||
import { Installation } from "@/installation"
|
||||
import { Global } from "@/global"
|
||||
import { DialogProvider, useDialog } from "@tui/ui/dialog"
|
||||
import { SDKProvider, useSDK } from "@tui/context/sdk"
|
||||
import { SyncProvider, useSync } from "@tui/context/sync"
|
||||
import { LocalProvider, useLocal } from "@tui/context/local"
|
||||
import { DialogModel } from "@tui/component/dialog-model"
|
||||
import { DialogStatus } from "@tui/component/dialog-status"
|
||||
import { DialogThemeList } from "@tui/component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { DialogAgent } from "@tui/component/dialog-agent"
|
||||
import { DialogSessionList } from "@tui/component/dialog-session-list"
|
||||
import { KeybindProvider } from "@tui/context/keybind"
|
||||
import { ThemeProvider, useTheme } from "@tui/context/theme"
|
||||
import { Home } from "@tui/routes/home"
|
||||
import { Session } from "@tui/routes/session"
|
||||
import { PromptHistoryProvider } from "./component/prompt/history"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
import type { SessionRoute } from "./context/route"
|
||||
import { Session as SessionApi } from "@/session"
|
||||
import { TuiEvent } from "./event"
|
||||
import { KVProvider, useKV } from "./context/kv"
|
||||
|
||||
export function tui(input: {
|
||||
url: string
|
||||
sessionID?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
onExit?: () => Promise<void>
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
return new Promise<void>((resolve) => {
|
||||
const routeData: Route | undefined = input.sessionID
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: input.sessionID,
|
||||
}
|
||||
: undefined
|
||||
|
||||
const onExit = async () => {
|
||||
await input.onExit?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
render(
|
||||
() => {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<ErrorComponent error={error} reset={reset} onExit={onExit} />
|
||||
)}
|
||||
>
|
||||
<ExitProvider onExit={onExit}>
|
||||
<KVProvider>
|
||||
<ToastProvider>
|
||||
<RouteProvider data={routeData}>
|
||||
<SDKProvider url={input.url}>
|
||||
<SyncProvider>
|
||||
<ThemeProvider>
|
||||
<LocalProvider initialModel={input.model} initialAgent={input.agent} initialPrompt={input.prompt}>
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<App />
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SyncProvider>
|
||||
</SDKProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</KVProvider>
|
||||
</ExitProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
},
|
||||
{
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
const kv = useKV()
|
||||
const command = useCommandDialog()
|
||||
const { event } = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const [sessionExists, setSessionExists] = createSignal(false)
|
||||
const { theme } = useTheme()
|
||||
const exit = useExit()
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (evt.meta && evt.name === "t") {
|
||||
renderer.toggleDebugOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
if (evt.meta && evt.name === "d") {
|
||||
renderer.console.toggle()
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
// Make sure session is valid, otherwise redirect to home
|
||||
createEffect(async () => {
|
||||
if (route.data.type === "session") {
|
||||
const data = route.data as SessionRoute
|
||||
await sync.session.sync(data.sessionID).catch(() => {
|
||||
toast.show({
|
||||
message: `Session not found: ${data.sessionID}`,
|
||||
variant: "error",
|
||||
})
|
||||
return route.navigate({ type: "home" })
|
||||
})
|
||||
setSessionExists(true)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
console.log(JSON.stringify(route.data))
|
||||
})
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: "Switch session",
|
||||
value: "session.list",
|
||||
keybind: "session_list",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "New session",
|
||||
value: "session.new",
|
||||
keybind: "session_new",
|
||||
category: "Session",
|
||||
onSelect: () => {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch model",
|
||||
value: "model.list",
|
||||
keybind: "model_list",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogModel />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle",
|
||||
value: "model.cycle_recent",
|
||||
keybind: "model_cycle_recent",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.cycle(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Model cycle reverse",
|
||||
value: "model.cycle_recent_reverse",
|
||||
keybind: "model_cycle_recent_reverse",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
local.model.cycle(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Switch agent",
|
||||
value: "agent.list",
|
||||
keybind: "agent_list",
|
||||
category: "Agent",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogAgent />)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle",
|
||||
value: "agent.cycle",
|
||||
keybind: "agent_cycle",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Agent cycle reverse",
|
||||
value: "agent.cycle.reverse",
|
||||
keybind: "agent_cycle_reverse",
|
||||
category: "Agent",
|
||||
disabled: true,
|
||||
onSelect: () => {
|
||||
local.agent.move(-1)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "View status",
|
||||
keybind: "status_view",
|
||||
value: "opencode.status",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogStatus />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Switch theme",
|
||||
value: "theme.switch",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogThemeList />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Help",
|
||||
value: "help.show",
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogHelp />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
{
|
||||
title: "Exit the app",
|
||||
value: "app.exit",
|
||||
onSelect: exit,
|
||||
category: "System",
|
||||
}
|
||||
])
|
||||
|
||||
createEffect(() => {
|
||||
const providerID = local.model.current().providerID
|
||||
if (providerID === "openrouter" && !kv.get("openrouter_warning", false)) {
|
||||
untrack(() => {
|
||||
DialogAlert.show(
|
||||
dialog,
|
||||
"Warning",
|
||||
"While openrouter is a convenient way to access LLMs your request will often be routed to subpar providers that do not work well in our testing.\n\nFor reliable access to models check out OpenCode Zen\nhttps://opencode.ai/zen",
|
||||
).then(() => kv.set("openrouter_warning", true))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
event.on(TuiEvent.CommandExecute.type, (evt) => {
|
||||
command.trigger(evt.properties.command)
|
||||
})
|
||||
|
||||
event.on(TuiEvent.ToastShow.type, (evt) => {
|
||||
toast.show({
|
||||
title: evt.properties.title,
|
||||
message: evt.properties.message,
|
||||
variant: evt.properties.variant,
|
||||
duration: evt.properties.duration,
|
||||
})
|
||||
})
|
||||
|
||||
event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: "The current session was deleted",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={theme.background}
|
||||
onMouseUp={async () => {
|
||||
const text = renderer.getSelection()?.getSelectedText()
|
||||
if (text && text.length > 0) {
|
||||
const base64 = Buffer.from(text).toString("base64")
|
||||
const osc52 = `\x1b]52;c;${base64}\x07`
|
||||
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
|
||||
/* @ts-expect-error */
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
</Match>
|
||||
<Match when={route.data.type === "session" && sessionExists()}>
|
||||
<Session />
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={theme.backgroundElement}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<text fg={theme.textMuted}>open</text>
|
||||
<text attributes={TextAttributes.BOLD}>code </text>
|
||||
<text fg={theme.textMuted}>v{Installation.VERSION}</text>
|
||||
</box>
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted}>{process.cwd().replace(Global.Path.home, "~")}</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" flexShrink={0}>
|
||||
<text fg={theme.textMuted} paddingRight={1}>
|
||||
tab
|
||||
</text>
|
||||
<text fg={local.agent.color(local.agent.current().name)}>{""}</text>
|
||||
<text
|
||||
bg={local.agent.color(local.agent.current().name)}
|
||||
fg={theme.background}
|
||||
wrapMode={undefined}
|
||||
>
|
||||
<span style={{ bold: true }}> {local.agent.current().name.toUpperCase()}</span>
|
||||
<span> AGENT </span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorComponent(props: { error: Error; reset: () => void; onExit: () => Promise<void> }) {
|
||||
const term = useTerminalDimensions()
|
||||
useKeyboard((evt) => {
|
||||
if (evt.ctrl && evt.name === "c") {
|
||||
props.onExit()
|
||||
}
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const issueURL = new URL("https://github.com/sst/opencode/issues/new?template=bug-report.yml")
|
||||
|
||||
if (props.error.message) {
|
||||
issueURL.searchParams.set("title", `opentui: fatal: ${props.error.message}`)
|
||||
}
|
||||
|
||||
if (props.error.stack) {
|
||||
issueURL.searchParams.set(
|
||||
"description",
|
||||
"```\n" + props.error.stack.substring(0, 6000 - issueURL.toString().length) + "...\n```",
|
||||
)
|
||||
}
|
||||
|
||||
const copyIssueURL = () => {
|
||||
Clipboard.copy(issueURL.toString()).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
<text attributes={TextAttributes.BOLD}>Please report an issue.</text>
|
||||
<box onMouseUp={copyIssueURL} backgroundColor="#565f89" padding={1}>
|
||||
<text attributes={TextAttributes.BOLD}>Copy issue URL (exception info pre-filled)</text>
|
||||
</box>
|
||||
{copied() && <text>Successfully copied</text>}
|
||||
</box>
|
||||
<box flexDirection="row" gap={2} alignItems="center">
|
||||
<text>A fatal error occurred!</text>
|
||||
<box onMouseUp={props.reset} backgroundColor="#565f89" padding={1}>
|
||||
<text>Reset TUI</text>
|
||||
</box>
|
||||
<box onMouseUp={props.onExit} backgroundColor="#565f89" padding={1}>
|
||||
<text>Exit</text>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox height={Math.floor(term().height * 0.7)}>
|
||||
<text>{props.error.stack}</text>
|
||||
</scrollbox>
|
||||
<text>{props.error.message}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cmd } from "../cmd"
|
||||
import { tui } from "./app"
|
||||
|
||||
export const AttachCommand = cmd({
|
||||
command: "attach <url>",
|
||||
describe: "attach to a running opencode server",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("url", {
|
||||
type: "string",
|
||||
describe: "http://localhost:4096",
|
||||
demandOption: true,
|
||||
})
|
||||
.option("dir", {
|
||||
type: "string",
|
||||
description: "directory to run in",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.dir) process.chdir(args.dir)
|
||||
await tui(args)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
export const SplitBorder = {
|
||||
border: ["left" as const, "right" as const],
|
||||
customBorderChars: {
|
||||
topLeft: "",
|
||||
bottomLeft: "",
|
||||
vertical: "┃",
|
||||
topRight: "",
|
||||
bottomRight: "",
|
||||
horizontal: "",
|
||||
bottomT: "",
|
||||
topT: "",
|
||||
cross: "",
|
||||
leftT: "",
|
||||
rightT: "",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
|
||||
export function DialogAgent() {
|
||||
const local = useLocal()
|
||||
const dialog = useDialog()
|
||||
|
||||
const options = createMemo(() =>
|
||||
local.agent.list().map((item) => {
|
||||
return {
|
||||
value: item.name,
|
||||
title: item.name,
|
||||
description: item.builtIn ? "native" : item.description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Select agent"
|
||||
current={local.agent.current().name}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
local.agent.set(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import {
|
||||
createContext,
|
||||
createMemo,
|
||||
createSignal,
|
||||
onCleanup,
|
||||
useContext,
|
||||
type Accessor,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
|
||||
type Context = ReturnType<typeof init>
|
||||
const ctx = createContext<Context>()
|
||||
|
||||
export type CommandOption = DialogSelectOption & {
|
||||
keybind?: keyof KeybindsConfig
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
const options = createMemo(() => {
|
||||
return registrations().flatMap((x) => x())
|
||||
})
|
||||
|
||||
let keybinds = true
|
||||
useKeyboard((evt) => {
|
||||
if (!keybinds) return
|
||||
for (const option of options()) {
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
option.onSelect?.(dialog)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
trigger(name: string, source?: "prompt") {
|
||||
for (const option of options()) {
|
||||
if (option.value === name) {
|
||||
option.onSelect?.(dialog, source)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
keybinds(enabled: boolean) {
|
||||
keybinds = enabled
|
||||
},
|
||||
show() {
|
||||
dialog.replace(() => <DialogCommand options={options()} />)
|
||||
},
|
||||
register(cb: () => CommandOption[]) {
|
||||
const results = createMemo(cb)
|
||||
setRegistrations((arr) => [results, ...arr])
|
||||
onCleanup(() => {
|
||||
setRegistrations((arr) => arr.filter((x) => x !== results))
|
||||
})
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function useCommandDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useCommandDialog must be used within a CommandProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function CommandProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
const dialog = useDialog()
|
||||
const keybind = useKeybind()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (keybind.match("command_list", evt) && dialog.stack.length === 0) {
|
||||
evt.preventDefault()
|
||||
dialog.replace(() => <DialogCommand options={value.options} />)
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const keybind = useKeybind()
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Commands"
|
||||
options={props.options.map((x) => ({
|
||||
...x,
|
||||
footer: x.keybind ? keybind.print(x.keybind) : undefined,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { map, pipe, flatMap, entries, filter, isDeepEqual, sortBy } from "remeda"
|
||||
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
|
||||
export function DialogModel() {
|
||||
const local = useLocal()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
return [
|
||||
...(!ref()?.filter
|
||||
? local.model.recent().flatMap((item) => {
|
||||
const provider = sync.data.provider.find((x) => x.id === item.providerID)!
|
||||
if (!provider) return []
|
||||
const model = provider.models[item.modelID]
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
key: item,
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Recent",
|
||||
},
|
||||
]
|
||||
})
|
||||
: []),
|
||||
...pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
map(([model, info]) => ({
|
||||
value: {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
title: info.name ?? model,
|
||||
description: provider.name,
|
||||
category: provider.name,
|
||||
})),
|
||||
filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
ref={setRef}
|
||||
title="Select model"
|
||||
current={local.model.current()}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
dialog.clear()
|
||||
local.model.set(option.value, { recent: true })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, onMount } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
|
||||
export function DialogSessionList() {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const route = useRoute()
|
||||
const sdk = useSDK()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sync.data.session
|
||||
.filter((x) => x.parentID === undefined)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
return {
|
||||
title: isDeleting ? "Press delete again to confirm" : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Sessions"
|
||||
options={options()}
|
||||
limit={50}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+d")[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
sdk.client.session.delete({
|
||||
path: {
|
||||
id: option.value,
|
||||
},
|
||||
})
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+r")[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { DialogPrompt } from "@tui/ui/dialog-prompt"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSDK } from "../context/sdk"
|
||||
|
||||
interface DialogSessionRenameProps {
|
||||
session: string
|
||||
}
|
||||
|
||||
export function DialogSessionRename(props: DialogSessionRenameProps) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const session = createMemo(() => sync.session.get(props.session))
|
||||
|
||||
return (
|
||||
<DialogPrompt
|
||||
title="Rename Session"
|
||||
value={session()?.title}
|
||||
onConfirm={(value) => {
|
||||
sdk.client.session.update({
|
||||
path: {
|
||||
id: props.session,
|
||||
},
|
||||
body: {
|
||||
title: value,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
onCancel={() => dialog.clear()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { For, Match, Switch, Show, createMemo } from "solid-js"
|
||||
|
||||
export type DialogStatusProps = {}
|
||||
|
||||
export function DialogStatus() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>Status</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0} fallback={<text>No MCP Servers</text>}>
|
||||
<box>
|
||||
<text>{Object.keys(sync.data.mcp).length} MCP Servers</text>
|
||||
<For each={Object.entries(sync.data.mcp)}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word">
|
||||
<b>{key}</b>{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>{(val) => val().error}</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
{sync.data.lsp.length > 0 && (
|
||||
<box>
|
||||
<text>{sync.data.lsp.length} LSP Servers</text>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word">
|
||||
<b>{item.id}</b> <span style={{ fg: theme.textMuted }}>{item.root}</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
<Show when={enabledFormatters().length > 0} fallback={<text>No Formatters</text>}>
|
||||
<box>
|
||||
<text>{enabledFormatters().length} Formatters</text>
|
||||
<For each={enabledFormatters()}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: theme.success,
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word">
|
||||
<b>{item.name}</b>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createMemo, createResource } from "solid-js"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||
const sdk = useSDK()
|
||||
const dialog = useDialog()
|
||||
|
||||
const [store] = createStore({
|
||||
filter: "",
|
||||
})
|
||||
|
||||
const [files] = createResource(
|
||||
() => [store.filter],
|
||||
async () => {
|
||||
const result = await sdk.client.find.files({
|
||||
query: {
|
||||
query: store.filter,
|
||||
},
|
||||
})
|
||||
if (result.error) return []
|
||||
const sliced = (result.data ?? []).slice(0, 5)
|
||||
return sliced
|
||||
},
|
||||
)
|
||||
|
||||
const options = createMemo(() =>
|
||||
(files() ?? []).map((file) => ({
|
||||
value: file,
|
||||
title: file,
|
||||
})),
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Autocomplete"
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
props.onSelect?.(option.value)
|
||||
dialog.clear()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { THEMES, useTheme } from "../context/theme"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
|
||||
export function DialogThemeList() {
|
||||
const theme = useTheme()
|
||||
const options = Object.keys(THEMES).map((value) => ({
|
||||
title: value,
|
||||
value: value as keyof typeof THEMES,
|
||||
}))
|
||||
const dialog = useDialog()
|
||||
let confirmed = false
|
||||
let ref: DialogSelectRef<keyof typeof THEMES>
|
||||
const initial = theme.selected
|
||||
|
||||
onMount(() => {
|
||||
// highlight the first theme in the list when we open it for UX
|
||||
theme.set(Object.keys(THEMES)[0] as keyof typeof THEMES)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
// if we close the dialog without confirming, reset back to the initial theme
|
||||
if (!confirmed) theme.set(initial)
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Themes"
|
||||
options={options}
|
||||
onMove={(opt) => {
|
||||
theme.set(opt.value)
|
||||
}}
|
||||
onSelect={(opt) => {
|
||||
theme.set(opt.value)
|
||||
confirmed = true
|
||||
dialog.clear()
|
||||
}}
|
||||
ref={(r) => {
|
||||
ref = r
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
if (query.length === 0) {
|
||||
theme.set(initial)
|
||||
return
|
||||
}
|
||||
|
||||
const first = ref.filtered[0]
|
||||
if (first) theme.set(first.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
|
||||
const LOGO_LEFT = [` `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█░░█ █░░█ █▀▀▀ █░░█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀`]
|
||||
|
||||
const LOGO_RIGHT = [` ▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█░░░ █░░█ █░░█ █▀▀▀`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
|
||||
|
||||
export function Logo() {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box>
|
||||
<For each={LOGO_LEFT}>
|
||||
{(line, index) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{line}</text>
|
||||
<text fg={theme.text} attributes={TextAttributes.BOLD}>
|
||||
{LOGO_RIGHT[index()]}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text fg={theme.textMuted}>{Installation.VERSION}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { BoxRenderable, TextareaRenderable, KeyEvent } from "@opentui/core"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { firstBy } from "remeda"
|
||||
import { createMemo, createResource, createEffect, onMount, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
export type AutocompleteRef = {
|
||||
onInput: (value: string) => void
|
||||
onKeyDown: (e: KeyEvent) => void
|
||||
visible: false | "@" | "/"
|
||||
}
|
||||
|
||||
export type AutocompleteOption = {
|
||||
display: string
|
||||
aliases?: string[]
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
export function Autocomplete(props: {
|
||||
value: string
|
||||
sessionID?: string
|
||||
setPrompt: (input: (prompt: PromptInfo) => void) => void
|
||||
setExtmark: (partIndex: number, extmarkId: number) => void
|
||||
anchor: () => BoxRenderable
|
||||
input: () => TextareaRenderable
|
||||
ref: (ref: AutocompleteRef) => void
|
||||
fileStyleId: number
|
||||
agentStyleId: number
|
||||
promptPartTypeId: () => number
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const command = useCommandDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
position: { x: 0, y: 0, width: 0 },
|
||||
})
|
||||
const filter = createMemo(() => {
|
||||
if (!store.visible) return
|
||||
return props.value.substring(store.index + 1).split(" ")[0]
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.visualCursor.offset
|
||||
|
||||
const charAfterCursor = props.value.at(currentCursorOffset)
|
||||
const needsSpace = charAfterCursor !== " "
|
||||
const append = "@" + text + (needsSpace ? " " : "")
|
||||
|
||||
input.cursorOffset = store.index
|
||||
const startCursor = input.logicalCursor
|
||||
input.cursorOffset = currentCursorOffset
|
||||
const endCursor = input.logicalCursor
|
||||
|
||||
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
|
||||
input.insertText(append)
|
||||
|
||||
const virtualText = "@" + text
|
||||
const extmarkStart = store.index
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
|
||||
const styleId =
|
||||
part.type === "file"
|
||||
? props.fileStyleId
|
||||
: part.type === "agent"
|
||||
? props.agentStyleId
|
||||
: undefined
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: props.promptPartTypeId(),
|
||||
})
|
||||
|
||||
props.setPrompt((draft) => {
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmarkStart
|
||||
part.source.text.end = extmarkEnd
|
||||
part.source.text.value = virtualText
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmarkStart
|
||||
part.source.end = extmarkEnd
|
||||
part.source.value = virtualText
|
||||
}
|
||||
const partIndex = draft.parts.length
|
||||
draft.parts.push(part)
|
||||
props.setExtmark(partIndex, extmarkId)
|
||||
})
|
||||
}
|
||||
|
||||
const [files] = createResource(
|
||||
() => filter(),
|
||||
async (query) => {
|
||||
if (!store.visible || store.visible === "/") return []
|
||||
|
||||
// Get files from SDK
|
||||
const result = await sdk.client.find.files({
|
||||
query: {
|
||||
query: query ?? "",
|
||||
},
|
||||
})
|
||||
|
||||
const options: AutocompleteOption[] = []
|
||||
|
||||
// Add file options
|
||||
if (!result.error && result.data) {
|
||||
options.push(
|
||||
...result.data.map(
|
||||
(item): AutocompleteOption => ({
|
||||
display: item,
|
||||
onSelect: () => {
|
||||
insertPart(item, {
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: item,
|
||||
url: `file://${process.cwd()}/${item}`,
|
||||
source: {
|
||||
type: "file",
|
||||
text: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
path: item,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return options
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
},
|
||||
)
|
||||
|
||||
const agents = createMemo(() => {
|
||||
if (store.index !== 0) return []
|
||||
const agents = sync.data.agent
|
||||
return agents
|
||||
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
|
||||
.map(
|
||||
(agent): AutocompleteOption => ({
|
||||
display: "@" + agent.name,
|
||||
onSelect: () => {
|
||||
insertPart(agent.name, {
|
||||
type: "agent",
|
||||
name: agent.name,
|
||||
source: {
|
||||
start: 0,
|
||||
end: 0,
|
||||
value: "",
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const session = createMemo(() =>
|
||||
props.sessionID ? sync.session.get(props.sessionID) : undefined,
|
||||
)
|
||||
const commands = createMemo((): AutocompleteOption[] => {
|
||||
const results: AutocompleteOption[] = []
|
||||
const s = session()
|
||||
for (const command of sync.data.command) {
|
||||
results.push({
|
||||
display: "/" + command.name,
|
||||
description: command.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + command.name + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
props.input().cursorOffset = Bun.stringWidth(newText)
|
||||
},
|
||||
})
|
||||
}
|
||||
if (s) {
|
||||
results.push(
|
||||
{
|
||||
display: "/undo",
|
||||
description: "undo the last message",
|
||||
onSelect: () => command.trigger("session.undo"),
|
||||
},
|
||||
{
|
||||
display: "/redo",
|
||||
description: "redo the last message",
|
||||
onSelect: () => command.trigger("session.redo"),
|
||||
},
|
||||
{
|
||||
display: "/compact",
|
||||
aliases: ["/summarize"],
|
||||
description: "compact the session",
|
||||
onSelect: () => command.trigger("session.compact"),
|
||||
},
|
||||
{
|
||||
display: "/share",
|
||||
disabled: !!s.share?.url,
|
||||
description: "share a session",
|
||||
onSelect: () => command.trigger("session.share"),
|
||||
},
|
||||
{
|
||||
display: "/unshare",
|
||||
disabled: !s.share,
|
||||
description: "unshare a session",
|
||||
onSelect: () => command.trigger("session.unshare"),
|
||||
},
|
||||
{
|
||||
display: "/rename",
|
||||
description: "rename session",
|
||||
onSelect: () => command.trigger("session.rename"),
|
||||
},
|
||||
)
|
||||
}
|
||||
results.push(
|
||||
{
|
||||
display: "/new",
|
||||
aliases: ["/clear"],
|
||||
description: "create a new session",
|
||||
onSelect: () => command.trigger("session.new"),
|
||||
},
|
||||
{
|
||||
display: "/models",
|
||||
description: "list models",
|
||||
onSelect: () => command.trigger("model.list"),
|
||||
},
|
||||
{
|
||||
display: "/agents",
|
||||
description: "list agents",
|
||||
onSelect: () => command.trigger("agent.list"),
|
||||
},
|
||||
{
|
||||
display: "/session",
|
||||
aliases: ["/resume", "/continue"],
|
||||
description: "list sessions",
|
||||
onSelect: () => command.trigger("session.list"),
|
||||
},
|
||||
{
|
||||
display: "/status",
|
||||
description: "show status",
|
||||
onSelect: () => command.trigger("opencode.status"),
|
||||
},
|
||||
{
|
||||
display: "/theme",
|
||||
description: "toggle theme",
|
||||
onSelect: () => command.trigger("theme.switch"),
|
||||
},
|
||||
{
|
||||
display: "/editor",
|
||||
description: "open editor",
|
||||
onSelect: () => command.trigger("prompt.editor", "prompt"),
|
||||
},
|
||||
{
|
||||
display: "/help",
|
||||
description: "show help",
|
||||
onSelect: () => command.trigger("help.show"),
|
||||
},
|
||||
{
|
||||
display: "/commands",
|
||||
description: "show all commands",
|
||||
onSelect: () => command.show(),
|
||||
},
|
||||
{
|
||||
display: "/exit",
|
||||
aliases: ["/quit", "/q"],
|
||||
description: "exit the app",
|
||||
onSelect: () => command.trigger("app.exit"),
|
||||
},
|
||||
)
|
||||
const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
|
||||
if (!max) return results
|
||||
return results.map((item) => ({
|
||||
...item,
|
||||
display: item.display.padEnd(max + 2),
|
||||
}))
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const mixed: AutocompleteOption[] = (
|
||||
store.visible === "@"
|
||||
? [...agents(), ...(files.loading ? files.latest || [] : files())]
|
||||
: [...commands()]
|
||||
).filter((x) => x.disabled !== true)
|
||||
const currentFilter = filter()
|
||||
if (!currentFilter) return mixed.slice(0, 10)
|
||||
const result = fuzzysort.go(currentFilter, mixed, {
|
||||
keys: ["display", "description", (obj) => obj.aliases?.join(" ") ?? ""],
|
||||
limit: 10,
|
||||
})
|
||||
return result.map((arr) => arr.obj)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("selected", 0)
|
||||
})
|
||||
|
||||
function move(direction: -1 | 1) {
|
||||
if (!store.visible) return
|
||||
if (!options().length) return
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = options().length - 1
|
||||
if (next >= options().length) next = 0
|
||||
setStore("selected", next)
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selected = options()[store.selected]
|
||||
if (!selected) return
|
||||
selected.onSelect?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function show(mode: "@" | "/") {
|
||||
command.keybinds(false)
|
||||
setStore({
|
||||
visible: mode,
|
||||
index: props.input().visualCursor.offset,
|
||||
position: {
|
||||
x: props.anchor().x,
|
||||
y: props.anchor().y,
|
||||
width: props.anchor().width,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
const text = props.input().plainText
|
||||
if (store.visible === "/" && !text.endsWith(" ")) {
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
}
|
||||
command.keybinds(true)
|
||||
setStore("visible", false)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
props.ref({
|
||||
get visible() {
|
||||
return store.visible
|
||||
},
|
||||
onInput(value: string) {
|
||||
if (store.visible && value.length <= store.index) hide()
|
||||
},
|
||||
onKeyDown(e: KeyEvent) {
|
||||
if (store.visible) {
|
||||
if (e.name === "up") move(-1)
|
||||
if (e.name === "down") move(1)
|
||||
if (e.name === "escape") hide()
|
||||
if (e.name === "return" || e.name === "tab") select()
|
||||
if (["up", "down", "return", "tab", "escape"].includes(e.name)) e.preventDefault()
|
||||
}
|
||||
if (!store.visible) {
|
||||
if (e.name === "@") {
|
||||
const cursorOffset = props.input().visualCursor.offset
|
||||
const charBeforeCursor =
|
||||
cursorOffset === 0 ? undefined : props.value.at(cursorOffset - 1)
|
||||
if (
|
||||
charBeforeCursor === " " ||
|
||||
charBeforeCursor === "\n" ||
|
||||
charBeforeCursor === undefined
|
||||
) {
|
||||
show("@")
|
||||
}
|
||||
}
|
||||
|
||||
if (e.name === "/") {
|
||||
if (props.input().visualCursor.offset === 0) show("/")
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const height = createMemo(() => {
|
||||
if (options().length) return Math.min(10, options().length)
|
||||
return 1
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
visible={store.visible !== false}
|
||||
position="absolute"
|
||||
top={store.position.y - height()}
|
||||
left={store.position.x}
|
||||
width={store.position.width}
|
||||
zIndex={100}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.border}
|
||||
>
|
||||
<box backgroundColor={theme.backgroundElement} height={height()}>
|
||||
<For
|
||||
each={options()}
|
||||
fallback={
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text>No matching items</text>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(option, index) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={index() === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={index() === store.selected ? theme.background : theme.text} flexShrink={0}>
|
||||
{option.display}
|
||||
</text>
|
||||
<Show when={option.description}>
|
||||
<text
|
||||
fg={index() === store.selected ? theme.background : theme.textMuted}
|
||||
wrapMode="none"
|
||||
>
|
||||
{option.description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { onMount } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { clone } from "remeda"
|
||||
import { createSimpleContext } from "../../context/helper"
|
||||
import { appendFile } from "fs/promises"
|
||||
import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk"
|
||||
|
||||
export type PromptInfo = {
|
||||
input: string
|
||||
parts: (
|
||||
| Omit<FilePart, "id" | "messageID" | "sessionID">
|
||||
| Omit<AgentPart, "id" | "messageID" | "sessionID">
|
||||
| (Omit<TextPart, "id" | "messageID" | "sessionID"> & {
|
||||
source?: {
|
||||
text: {
|
||||
start: number
|
||||
end: number
|
||||
value: string
|
||||
}
|
||||
}
|
||||
})
|
||||
)[]
|
||||
}
|
||||
|
||||
export const { use: usePromptHistory, provider: PromptHistoryProvider } = createSimpleContext({
|
||||
name: "PromptHistory",
|
||||
init: () => {
|
||||
const historyFile = Bun.file(path.join(Global.Path.state, "prompt-history.jsonl"))
|
||||
onMount(async () => {
|
||||
const text = await historyFile.text().catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
setStore("history", lines as PromptInfo[])
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
index: 0,
|
||||
history: [] as PromptInfo[],
|
||||
})
|
||||
|
||||
return {
|
||||
move(direction: 1 | -1, input: string) {
|
||||
if (!store.history.length) return undefined
|
||||
const current = store.history.at(store.index)
|
||||
if (!current) return undefined
|
||||
if (current.input !== input && input.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const next = store.index + direction
|
||||
if (Math.abs(next) > store.history.length) return
|
||||
if (next > 0) return
|
||||
draft.index = next
|
||||
}),
|
||||
)
|
||||
if (store.index === 0)
|
||||
return {
|
||||
input: "",
|
||||
parts: [],
|
||||
}
|
||||
return store.history.at(store.index)
|
||||
},
|
||||
append(item: PromptInfo) {
|
||||
item = clone(item)
|
||||
appendFile(historyFile.name!, JSON.stringify(item) + "\n")
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.history.push(item)
|
||||
draft.index = 0
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,720 @@
|
||||
import {
|
||||
TextAttributes,
|
||||
BoxRenderable,
|
||||
TextareaRenderable,
|
||||
MouseEvent,
|
||||
KeyEvent,
|
||||
PasteEvent,
|
||||
t,
|
||||
dim,
|
||||
fg,
|
||||
} from "@opentui/core"
|
||||
import { createEffect, createMemo, Match, Switch, type JSX, onMount, batch } from "solid-js"
|
||||
import { useLocal } from "@tui/context/local"
|
||||
import { SyntaxTheme, useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Identifier } from "@/id/id"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
import type { FilePart } from "@opencode-ai/sdk"
|
||||
import { TuiEvent } from "../../event"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
disabled?: boolean
|
||||
onSubmit?: () => void
|
||||
ref?: (ref: PromptRef) => void
|
||||
hint?: JSX.Element
|
||||
showPlaceholder?: boolean
|
||||
}
|
||||
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
set(prompt: PromptInfo): void
|
||||
reset(): void
|
||||
blur(): void
|
||||
focus(): void
|
||||
}
|
||||
|
||||
export function Prompt(props: PromptProps) {
|
||||
let input: TextareaRenderable
|
||||
let anchor: BoxRenderable
|
||||
let autocomplete: AutocompleteRef
|
||||
|
||||
const keybind = useKeybind()
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const status = createMemo(() => (props.sessionID ? sync.session.status(props.sessionID) : "idle"))
|
||||
const history = usePromptHistory()
|
||||
const command = useCommandDialog()
|
||||
const renderer = useRenderer()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const textareaKeybindings = createMemo(() => {
|
||||
const newlineBindings = keybind.all.input_newline || []
|
||||
const submitBindings = keybind.all.input_submit || []
|
||||
|
||||
return [
|
||||
{ name: "return", action: "submit" },
|
||||
{ name: "return", meta: true, action: "newline" },
|
||||
...newlineBindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
action: "newline" as const,
|
||||
})),
|
||||
...submitBindings.map((binding) => ({
|
||||
name: binding.name,
|
||||
ctrl: binding.ctrl || undefined,
|
||||
meta: binding.meta || undefined,
|
||||
shift: binding.shift || undefined,
|
||||
action: "submit" as const,
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
const fileStyleId = SyntaxTheme.getStyleId("extmark.file")!
|
||||
const agentStyleId = SyntaxTheme.getStyleId("extmark.agent")!
|
||||
const pasteStyleId = SyntaxTheme.getStyleId("extmark.paste")!
|
||||
let promptPartTypeId: number
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
title: "Open editor",
|
||||
category: "Session",
|
||||
keybind: "editor_open",
|
||||
value: "prompt.editor",
|
||||
onSelect: async (dialog, trigger) => {
|
||||
dialog.clear()
|
||||
const value = trigger === "prompt" ? "" : input.plainText
|
||||
const content = await Editor.open({ value, renderer })
|
||||
if (content) {
|
||||
input.setText(content, { history: false })
|
||||
setStore("prompt", {
|
||||
input: content,
|
||||
parts: [],
|
||||
})
|
||||
input.cursorOffset = Bun.stringWidth(content)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Clear prompt",
|
||||
value: "prompt.clear",
|
||||
disabled: true,
|
||||
category: "Prompt",
|
||||
onSelect: (dialog) => {
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Submit prompt",
|
||||
value: "prompt.submit",
|
||||
disabled: true,
|
||||
keybind: "input_submit",
|
||||
category: "Prompt",
|
||||
onSelect: (dialog) => {
|
||||
submit()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Paste",
|
||||
value: "prompt.paste",
|
||||
disabled: true,
|
||||
keybind: "input_paste",
|
||||
category: "Prompt",
|
||||
onSelect: async () => {
|
||||
const content = await Clipboard.read()
|
||||
if (content?.mime.startsWith("image/")) {
|
||||
await pasteImage({
|
||||
filename: "clipboard",
|
||||
mime: content.mime,
|
||||
content: content.data,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
setStore(
|
||||
"prompt",
|
||||
produce((draft) => {
|
||||
draft.input += evt.properties.text
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.primary
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
}>({
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
mode: "normal",
|
||||
extmarkToPartIndex: new Map(),
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.focus()
|
||||
})
|
||||
|
||||
local.setInitialPrompt.listen((initialPrompt) => {
|
||||
batch(() => {
|
||||
setStore("prompt", {
|
||||
input: initialPrompt,
|
||||
parts: [],
|
||||
})
|
||||
input.insertText(initialPrompt)
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
promptPartTypeId = input.extmarks.registerType("prompt-part")
|
||||
})
|
||||
|
||||
function restoreExtmarksFromParts(parts: PromptInfo["parts"]) {
|
||||
input.extmarks.clear()
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
|
||||
parts.forEach((part, partIndex) => {
|
||||
let start = 0
|
||||
let end = 0
|
||||
let virtualText = ""
|
||||
let styleId: number | undefined
|
||||
|
||||
if (part.type === "file" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = fileStyleId
|
||||
} else if (part.type === "agent" && part.source) {
|
||||
start = part.source.start
|
||||
end = part.source.end
|
||||
virtualText = part.source.value
|
||||
styleId = agentStyleId
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
start = part.source.text.start
|
||||
end = part.source.text.end
|
||||
virtualText = part.source.text.value
|
||||
styleId = pasteStyleId
|
||||
}
|
||||
|
||||
if (virtualText) {
|
||||
const extmarkId = input.extmarks.create({
|
||||
start,
|
||||
end,
|
||||
virtual: true,
|
||||
styleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function syncExtmarksWithPromptParts() {
|
||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const newMap = new Map<number, number>()
|
||||
const newParts: typeof draft.prompt.parts = []
|
||||
|
||||
for (const extmark of allExtmarks) {
|
||||
const partIndex = draft.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = draft.prompt.parts[partIndex]
|
||||
if (part) {
|
||||
if (part.type === "agent" && part.source) {
|
||||
part.source.start = extmark.start
|
||||
part.source.end = extmark.end
|
||||
} else if (part.type === "file" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
} else if (part.type === "text" && part.source?.text) {
|
||||
part.source.text.start = extmark.start
|
||||
part.source.text.end = extmark.end
|
||||
}
|
||||
newMap.set(extmark.id, newParts.length)
|
||||
newParts.push(part)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draft.extmarkToPartIndex = newMap
|
||||
draft.prompt.parts = newParts
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
props.ref?.({
|
||||
get focused() {
|
||||
return input.focused
|
||||
},
|
||||
focus() {
|
||||
input.focus()
|
||||
},
|
||||
blur() {
|
||||
input.blur()
|
||||
},
|
||||
set(prompt) {
|
||||
input.setText(prompt.input, { history: false })
|
||||
setStore("prompt", prompt)
|
||||
restoreExtmarksFromParts(prompt.parts)
|
||||
input.gotoBufferEnd()
|
||||
},
|
||||
reset() {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
},
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (props.disabled) return
|
||||
if (autocomplete.visible) return
|
||||
if (!store.prompt.input) return
|
||||
const sessionID = props.sessionID
|
||||
? props.sessionID
|
||||
: await (async () => {
|
||||
const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id)
|
||||
return sessionID
|
||||
})()
|
||||
const messageID = Identifier.ascending("message")
|
||||
let inputText = store.prompt.input
|
||||
|
||||
// Expand pasted text inline before submitting
|
||||
const allExtmarks = input.extmarks.getAllForTypeId(promptPartTypeId)
|
||||
const sortedExtmarks = allExtmarks.sort(
|
||||
(a: { start: number }, b: { start: number }) => b.start - a.start,
|
||||
)
|
||||
|
||||
for (const extmark of sortedExtmarks) {
|
||||
const partIndex = store.extmarkToPartIndex.get(extmark.id)
|
||||
if (partIndex !== undefined) {
|
||||
const part = store.prompt.parts[partIndex]
|
||||
if (part?.type === "text" && part.text) {
|
||||
const before = inputText.slice(0, extmark.start)
|
||||
const after = inputText.slice(extmark.end)
|
||||
inputText = before + part.text + after
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out text parts (pasted content) since they're now expanded inline
|
||||
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
|
||||
|
||||
if (store.mode === "shell") {
|
||||
sdk.client.session.shell({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
agent: local.agent.current().name,
|
||||
command: inputText,
|
||||
},
|
||||
})
|
||||
setStore("mode", "normal")
|
||||
} else if (inputText.startsWith("/")) {
|
||||
const [command, ...args] = inputText.split(" ")
|
||||
sdk.client.session.command({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
command: command.slice(1),
|
||||
arguments: args.join(" "),
|
||||
agent: local.agent.current().name,
|
||||
model: `${local.model.current().providerID}/${local.model.current().modelID}`,
|
||||
messageID,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
sdk.client.session.prompt({
|
||||
path: {
|
||||
id: sessionID,
|
||||
},
|
||||
body: {
|
||||
...local.model.current(),
|
||||
messageID,
|
||||
agent: local.agent.current().name,
|
||||
model: local.model.current(),
|
||||
parts: [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "text",
|
||||
text: inputText,
|
||||
},
|
||||
...nonTextParts.map((x) => ({
|
||||
id: Identifier.ascending("part"),
|
||||
...x,
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
history.append(store.prompt)
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
props.onSubmit?.()
|
||||
|
||||
// temporary hack to make sure the message is sent
|
||||
if (!props.sessionID)
|
||||
setTimeout(() => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID,
|
||||
})
|
||||
}, 50)
|
||||
input.clear()
|
||||
}
|
||||
const exit = useExit()
|
||||
|
||||
async function pasteImage(file: { filename?: string; content: string; mime: string }) {
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const extmarkStart = currentOffset
|
||||
const count = store.prompt.parts.filter((x) => x.type === "file").length
|
||||
const virtualText = `[Image ${count + 1}]`
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
const textToInsert = virtualText + " "
|
||||
|
||||
input.insertText(textToInsert)
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId: pasteStyleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
|
||||
type: "file" as const,
|
||||
mime: file.mime,
|
||||
filename: file.filename,
|
||||
url: `data:${file.mime};base64,${file.content}`,
|
||||
source: {
|
||||
type: "file",
|
||||
path: file.filename ?? "",
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
},
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push(part)
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
sessionID={props.sessionID}
|
||||
ref={(r) => (autocomplete = r)}
|
||||
anchor={() => anchor}
|
||||
input={() => input}
|
||||
setPrompt={(cb) => {
|
||||
setStore("prompt", produce(cb))
|
||||
}}
|
||||
setExtmark={(partIndex, extmarkId) => {
|
||||
setStore("extmarkToPartIndex", (map: Map<number, number>) => {
|
||||
const newMap = new Map(map)
|
||||
newMap.set(extmarkId, partIndex)
|
||||
return newMap
|
||||
})
|
||||
}}
|
||||
value={store.prompt.input}
|
||||
fileStyleId={fileStyleId}
|
||||
agentStyleId={agentStyleId}
|
||||
promptPartTypeId={() => promptPartTypeId}
|
||||
/>
|
||||
<box ref={(r) => (anchor = r)}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
{...SplitBorder}
|
||||
borderColor={
|
||||
keybind.leader ? theme.accent : store.mode === "shell" ? theme.secondary : theme.border
|
||||
}
|
||||
justifyContent="space-evenly"
|
||||
>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={3}
|
||||
height="100%"
|
||||
alignItems="center"
|
||||
paddingTop={1}
|
||||
>
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.primary}>
|
||||
{store.mode === "normal" ? ">" : "!"}
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundElement}
|
||||
flexGrow={1}
|
||||
>
|
||||
<textarea
|
||||
placeholder={
|
||||
props.showPlaceholder
|
||||
? t`${dim(fg(theme.primary)(" → up/down"))} ${dim(fg("#64748b")("history"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_newline")))} ${dim(fg("#64748b")("newline"))} ${dim(fg("#a78bfa")("•"))} ${dim(fg(theme.primary)(keybind.print("input_submit")))} ${dim(fg("#64748b")("submit"))}`
|
||||
: undefined
|
||||
}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
onContentChange={() => {
|
||||
const value = input.plainText
|
||||
setStore("prompt", "input", value)
|
||||
autocomplete.onInput(value)
|
||||
syncExtmarksWithPromptParts()
|
||||
}}
|
||||
keyBindings={textareaKeybindings()}
|
||||
onKeyDown={async (e: KeyEvent) => {
|
||||
if (props.disabled) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
|
||||
input.clear()
|
||||
input.extmarks.clear()
|
||||
setStore("prompt", {
|
||||
input: "",
|
||||
parts: [],
|
||||
})
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
return
|
||||
}
|
||||
if (keybind.match("input_forward_delete", e) && store.prompt.input !== "") {
|
||||
const cursorOffset = input.cursorOffset
|
||||
if (cursorOffset < input.plainText.length) {
|
||||
const text = input.plainText
|
||||
const newText = text.slice(0, cursorOffset) + text.slice(cursorOffset + 1)
|
||||
input.setText(newText)
|
||||
input.cursorOffset = cursorOffset
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (keybind.match("app_exit", e)) {
|
||||
await exit()
|
||||
return
|
||||
}
|
||||
if (e.name === "!" && input.visualCursor.offset === 0) {
|
||||
setStore("mode", "shell")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (store.mode === "shell") {
|
||||
if (
|
||||
(e.name === "backspace" && input.visualCursor.offset === 0) ||
|
||||
e.name === "escape"
|
||||
) {
|
||||
setStore("mode", "normal")
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
if (store.mode === "normal") autocomplete.onKeyDown(e)
|
||||
if (!autocomplete.visible) {
|
||||
if (
|
||||
(keybind.match("history_previous", e) && input.cursorOffset === 0) ||
|
||||
(keybind.match("history_next", e) &&
|
||||
input.cursorOffset === input.plainText.length)
|
||||
) {
|
||||
const direction = keybind.match("history_previous", e) ? -1 : 1
|
||||
const item = history.move(direction, input.plainText)
|
||||
|
||||
if (item) {
|
||||
input.setText(item.input, { history: false })
|
||||
setStore("prompt", item)
|
||||
restoreExtmarksFromParts(item.parts)
|
||||
e.preventDefault()
|
||||
if (direction === -1) input.cursorOffset = 0
|
||||
if (direction === 1) input.cursorOffset = input.plainText.length
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (e.name === "up" && input.visualCursor.visualRow === 0) input.cursorOffset = 0
|
||||
if (e.name === "down" && input.visualCursor.visualRow === input.height - 1)
|
||||
input.cursorOffset = input.plainText.length
|
||||
}
|
||||
if (!autocomplete.visible) {
|
||||
if (keybind.match("session_interrupt", e) && props.sessionID) {
|
||||
sdk.client.session.abort({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={submit}
|
||||
onPaste={async (event: PasteEvent) => {
|
||||
if (props.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const pastedContent = event.text.trim()
|
||||
if (!pastedContent) {
|
||||
command.trigger("prompt.paste")
|
||||
return
|
||||
}
|
||||
|
||||
// trim ' from the beginning and end of the pasted content. just
|
||||
// ' and nothing else
|
||||
const filepath = pastedContent.replace(/^'+|'+$/g, "")
|
||||
try {
|
||||
const file = Bun.file(filepath)
|
||||
if (file.type.startsWith("image/")) {
|
||||
const content = await file
|
||||
.arrayBuffer()
|
||||
.then((buffer) => Buffer.from(buffer).toString("base64"))
|
||||
.catch(() => {})
|
||||
if (content) {
|
||||
await pasteImage({
|
||||
filename: file.name,
|
||||
mime: file.type,
|
||||
content,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
|
||||
if (lineCount >= 5) {
|
||||
event.preventDefault()
|
||||
const currentOffset = input.visualCursor.offset
|
||||
const virtualText = `[Pasted ~${lineCount} lines]`
|
||||
const textToInsert = virtualText + " "
|
||||
const extmarkStart = currentOffset
|
||||
const extmarkEnd = extmarkStart + virtualText.length
|
||||
|
||||
input.insertText(textToInsert)
|
||||
|
||||
const extmarkId = input.extmarks.create({
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
virtual: true,
|
||||
styleId: pasteStyleId,
|
||||
typeId: promptPartTypeId,
|
||||
})
|
||||
|
||||
const part = {
|
||||
type: "text" as const,
|
||||
text: pastedContent,
|
||||
source: {
|
||||
text: {
|
||||
start: extmarkStart,
|
||||
end: extmarkEnd,
|
||||
value: virtualText,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const partIndex = draft.prompt.parts.length
|
||||
draft.prompt.parts.push(part)
|
||||
draft.extmarkToPartIndex.set(extmarkId, partIndex)
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => (input = r)}
|
||||
onMouseDown={(r: MouseEvent) => r.target?.focus()}
|
||||
focusedBackgroundColor={theme.backgroundElement}
|
||||
cursorColor={theme.primary}
|
||||
syntaxStyle={SyntaxTheme}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
backgroundColor={theme.backgroundElement}
|
||||
width={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
></box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
<span style={{ fg: theme.textMuted }}>{local.model.parsed().provider}</span>{" "}
|
||||
<span style={{ bold: true }}>{local.model.parsed().model}</span>
|
||||
</text>
|
||||
<Switch>
|
||||
<Match when={status() === "compacting"}>
|
||||
<text fg={theme.textMuted}>compacting...</text>
|
||||
</Match>
|
||||
<Match when={status() === "working"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text>
|
||||
esc <span style={{ fg: theme.textMuted }}>interrupt</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.hint}>{props.hint!}</Match>
|
||||
<Match when={true}>
|
||||
<text>
|
||||
ctrl+p <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
name: "Exit",
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
return async () => {
|
||||
renderer.destroy()
|
||||
await input.onExit?.()
|
||||
process.exit(0)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext, Show, useContext, type ParentProps } from "solid-js"
|
||||
|
||||
export function createSimpleContext<T, Props extends Record<string, any>>(input: {
|
||||
name: string
|
||||
init: ((input: Props) => T) | (() => T)
|
||||
}) {
|
||||
const ctx = createContext<T>()
|
||||
|
||||
return {
|
||||
provider: (props: ParentProps<Props>) => {
|
||||
const init = input.init(props)
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Show when={init.ready === undefined || init.ready === true}>
|
||||
<ctx.Provider value={init}>{props.children}</ctx.Provider>
|
||||
</Show>
|
||||
)
|
||||
},
|
||||
use() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) throw new Error(`${input.name} context must be used within a context provider`)
|
||||
return value
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { pipe, mapValues } from "remeda"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
import type { ParsedKey, Renderable } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useRenderer } from "@opentui/solid"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
|
||||
name: "Keybind",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const keybinds = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.config.keybinds ?? {},
|
||||
mapValues((value) => Keybind.parse(value)),
|
||||
)
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
leader: false,
|
||||
})
|
||||
const renderer = useRenderer()
|
||||
|
||||
let focus: Renderable | null
|
||||
let timeout: NodeJS.Timeout
|
||||
function leader(active: boolean) {
|
||||
if (active) {
|
||||
setStore("leader", true)
|
||||
focus = renderer.currentFocusedRenderable
|
||||
focus?.blur()
|
||||
if (timeout) clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
if (!store.leader) return
|
||||
leader(false)
|
||||
if (focus) {
|
||||
focus.focus()
|
||||
}
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
if (focus && !renderer.currentFocusedRenderable) {
|
||||
focus.focus()
|
||||
}
|
||||
setStore("leader", false)
|
||||
}
|
||||
}
|
||||
|
||||
useKeyboard(async (evt) => {
|
||||
if (!store.leader && result.match("leader", evt)) {
|
||||
leader(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (store.leader && evt.name) {
|
||||
setImmediate(() => {
|
||||
if (focus && renderer.currentFocusedRenderable === focus) {
|
||||
focus.focus()
|
||||
}
|
||||
leader(false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
get all() {
|
||||
return keybinds()
|
||||
},
|
||||
get leader() {
|
||||
return store.leader
|
||||
},
|
||||
parse(evt: ParsedKey): Keybind.Info {
|
||||
return {
|
||||
ctrl: evt.ctrl,
|
||||
name: evt.name,
|
||||
shift: evt.shift,
|
||||
leader: store.leader,
|
||||
meta: evt.meta,
|
||||
}
|
||||
},
|
||||
match(key: keyof KeybindsConfig, evt: ParsedKey) {
|
||||
const keybind = keybinds()[key]
|
||||
if (!keybind) return false
|
||||
const parsed: Keybind.Info = result.parse(evt)
|
||||
for (const key of keybind) {
|
||||
if (Keybind.match(key, parsed)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
print(key: keyof KeybindsConfig) {
|
||||
const first = keybinds()[key]?.at(0)
|
||||
if (!first) return ""
|
||||
const result = Keybind.toString(first)
|
||||
return result.replace("<leader>", Keybind.toString(keybinds().leader![0]!))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Global } from "@/global"
|
||||
import { createSignal, type Setter } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import path from "path"
|
||||
|
||||
export const { use: useKV, provider: KVProvider } = createSimpleContext({
|
||||
name: "KV",
|
||||
init: () => {
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [kvStore, setKvStore] = createStore<Record<string, any>>()
|
||||
const file = Bun.file(path.join(Global.Path.state, "kv.json"))
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setKvStore(x)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setReady(true)
|
||||
})
|
||||
|
||||
const result = {
|
||||
get ready() {
|
||||
return ready()
|
||||
},
|
||||
signal<T>(name: string, defaultValue: T) {
|
||||
if (!kvStore[name]) setKvStore(name, defaultValue)
|
||||
return [
|
||||
function () {
|
||||
return result.get(name)
|
||||
},
|
||||
function setter(next: Setter<T>) {
|
||||
result.set(name, next)
|
||||
},
|
||||
] as const
|
||||
},
|
||||
get(key: string, defaultValue?: any) {
|
||||
return kvStore[key] ?? defaultValue
|
||||
},
|
||||
set(key: string, value: any) {
|
||||
setKvStore(key, value)
|
||||
Bun.write(file, JSON.stringify(kvStore, null, 2))
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { uniqueBy } from "remeda"
|
||||
import path from "path"
|
||||
import { Global } from "@/global"
|
||||
import { iife } from "@/util/iife"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
|
||||
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
name: "Local",
|
||||
init: (props: { initialModel?: string; initialAgent?: string; initialPrompt?: string }) => {
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
|
||||
function isModelValid(model: { providerID: string; modelID: string }) {
|
||||
const provider = sync.data.provider.find((x) => x.id === model.providerID)
|
||||
return !!provider?.models[model.modelID]
|
||||
}
|
||||
|
||||
function getFirstValidModel(
|
||||
...modelFns: (() => { providerID: string; modelID: string } | undefined)[]
|
||||
) {
|
||||
for (const modelFn of modelFns) {
|
||||
const model = modelFn()
|
||||
if (!model) continue
|
||||
if (isModelValid(model)) return model
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial model if provided
|
||||
onMount(() => {
|
||||
batch(() => {
|
||||
if (props.initialAgent) {
|
||||
agent.set(props.initialAgent)
|
||||
}
|
||||
if (props.initialModel) {
|
||||
const [providerID, modelID] = props.initialModel.split("/")
|
||||
if (!providerID || !modelID)
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Invalid model format: ${props.initialModel}`,
|
||||
duration: 3000,
|
||||
})
|
||||
model.set({ providerID, modelID }, { recent: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Automatically update model when agent changes
|
||||
createEffect(() => {
|
||||
const value = agent.current()
|
||||
if (value.model) {
|
||||
if (isModelValid(value.model))
|
||||
model.set({
|
||||
providerID: value.model.providerID,
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
else
|
||||
toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
|
||||
duration: 3000,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const agent = iife(() => {
|
||||
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
|
||||
const [agentStore, setAgentStore] = createStore<{
|
||||
current: string
|
||||
}>({
|
||||
current: agents()[0].name,
|
||||
})
|
||||
const { theme } = useTheme()
|
||||
const colors = createMemo(() => [
|
||||
theme.secondary,
|
||||
theme.accent,
|
||||
theme.success,
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
])
|
||||
return {
|
||||
list() {
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current)!
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
return toast.show({
|
||||
variant: "warning",
|
||||
message: `Agent not found: ${name}`,
|
||||
duration: 3000,
|
||||
})
|
||||
setAgentStore("current", name)
|
||||
},
|
||||
move(direction: 1 | -1) {
|
||||
batch(() => {
|
||||
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
|
||||
if (next < 0) next = agents().length - 1
|
||||
if (next >= agents().length) next = 0
|
||||
const value = agents()[next]
|
||||
setAgentStore("current", value.name)
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const index = agents().findIndex((x) => x.name === name)
|
||||
return colors()[index % colors().length]
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const model = iife(() => {
|
||||
const [modelStore, setModelStore] = createStore<{
|
||||
ready: boolean
|
||||
model: Record<
|
||||
string,
|
||||
{
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
>
|
||||
recent: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}[]
|
||||
}>({
|
||||
ready: false,
|
||||
model: {},
|
||||
recent: [],
|
||||
})
|
||||
|
||||
const file = Bun.file(path.join(Global.Path.state, "model.json"))
|
||||
|
||||
file
|
||||
.json()
|
||||
.then((x) => {
|
||||
setModelStore("recent", x.recent)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setModelStore("ready", true)
|
||||
})
|
||||
|
||||
const fallbackModel = createMemo(() => {
|
||||
if (sync.data.config.model) {
|
||||
const [providerID, modelID] = sync.data.config.model.split("/")
|
||||
if (isModelValid({ providerID, modelID })) {
|
||||
return {
|
||||
providerID,
|
||||
modelID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of modelStore.recent) {
|
||||
if (isModelValid(item)) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
const provider = sync.data.provider[0]
|
||||
const model = Object.values(provider.models)[0]
|
||||
return {
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
}
|
||||
})
|
||||
|
||||
const currentModel = createMemo(() => {
|
||||
const a = agent.current()
|
||||
return getFirstValidModel(
|
||||
() => modelStore.model[a.name],
|
||||
() => a.model,
|
||||
fallbackModel,
|
||||
)!
|
||||
})
|
||||
|
||||
return {
|
||||
current: currentModel,
|
||||
get ready() {
|
||||
return modelStore.ready
|
||||
},
|
||||
recent() {
|
||||
return modelStore.recent
|
||||
},
|
||||
parsed: createMemo(() => {
|
||||
const value = currentModel()
|
||||
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
|
||||
const model = provider.models[value.modelID]
|
||||
return {
|
||||
provider: provider.name ?? value.providerID,
|
||||
model: model.name ?? value.modelID,
|
||||
}
|
||||
}),
|
||||
cycle(direction: 1 | -1) {
|
||||
const current = currentModel()
|
||||
if (!current) return
|
||||
const recent = modelStore.recent
|
||||
const index = recent.findIndex(
|
||||
(x) => x.providerID === current.providerID && x.modelID === current.modelID,
|
||||
)
|
||||
if (index === -1) return
|
||||
let next = index + direction
|
||||
if (next < 0) next = recent.length - 1
|
||||
if (next >= recent.length) next = 0
|
||||
const val = recent[next]
|
||||
if (!val) return
|
||||
setModelStore("model", agent.current().name, { ...val })
|
||||
},
|
||||
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
|
||||
batch(() => {
|
||||
if (!isModelValid(model)) {
|
||||
toast.show({
|
||||
message: `Model ${model.providerID}/${model.modelID} is not valid`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
setModelStore("model", agent.current().name, model)
|
||||
if (options?.recent) {
|
||||
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
|
||||
if (uniq.length > 5) uniq.pop()
|
||||
setModelStore("recent", uniq)
|
||||
Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
recent: modelStore.recent,
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const setInitialPrompt = createEventBus<string>()
|
||||
|
||||
onMount(() => {
|
||||
if (props.initialPrompt)
|
||||
setInitialPrompt.emit(props.initialPrompt)
|
||||
})
|
||||
|
||||
const result = {
|
||||
model,
|
||||
agent,
|
||||
get setInitialPrompt() {
|
||||
return setInitialPrompt
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export type HomeRoute = {
|
||||
type: "home"
|
||||
}
|
||||
|
||||
export type SessionRoute = {
|
||||
type: "session"
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export type Route = HomeRoute | SessionRoute
|
||||
|
||||
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
|
||||
name: "Route",
|
||||
init: (props: { data?: Route }) => {
|
||||
const [store, setStore] = createStore<Route>(
|
||||
props.data ??
|
||||
(
|
||||
process.env["OPENCODE_ROUTE"]
|
||||
? JSON.parse(process.env["OPENCODE_ROUTE"])
|
||||
: {
|
||||
type: "home",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
get data() {
|
||||
return store
|
||||
},
|
||||
navigate(route: Route) {
|
||||
console.log("navigate", route)
|
||||
setStore(route)
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export type RouteContext = ReturnType<typeof useRoute>
|
||||
|
||||
export function useRouteData<T extends Route["type"]>(type: T) {
|
||||
const route = useRoute()
|
||||
return route.data as Extract<Route, { type: typeof type }>
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
name: "SDK",
|
||||
init: (props: { url: string }) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
fetch: (req) => {
|
||||
// @ts-ignore
|
||||
req.timeout = false
|
||||
return fetch(req)
|
||||
},
|
||||
})
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
}>()
|
||||
|
||||
sdk.event.subscribe().then(async (events) => {
|
||||
for await (const event of events.stream) {
|
||||
console.log("event", event.type)
|
||||
emitter.emit(event.type, event)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
})
|
||||
|
||||
return { client: sdk, event: emitter }
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import type {
|
||||
Message,
|
||||
Agent,
|
||||
Provider,
|
||||
Session,
|
||||
Part,
|
||||
Config,
|
||||
Todo,
|
||||
Command,
|
||||
Permission,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
FormatterStatus,
|
||||
} from "@opencode-ai/sdk"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { Binary } from "@/util/binary"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
init: () => {
|
||||
const [store, setStore] = createStore<{
|
||||
ready: boolean
|
||||
provider: Provider[]
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
permission: {
|
||||
[sessionID: string]: Permission[]
|
||||
}
|
||||
config: Config
|
||||
session: Session[]
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
}
|
||||
message: {
|
||||
[sessionID: string]: Message[]
|
||||
}
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
lsp: LspStatus[]
|
||||
mcp: {
|
||||
[key: string]: McpStatus
|
||||
}
|
||||
formatter: FormatterStatus[]
|
||||
}>({
|
||||
config: {},
|
||||
ready: false,
|
||||
agent: [],
|
||||
permission: {},
|
||||
command: [],
|
||||
provider: [],
|
||||
session: [],
|
||||
todo: {},
|
||||
message: {},
|
||||
part: {},
|
||||
lsp: [],
|
||||
mcp: {},
|
||||
formatter: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
case "permission.updated": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
if (!permissions) {
|
||||
setStore("permission", event.properties.sessionID, [event.properties])
|
||||
break
|
||||
}
|
||||
const match = Binary.search(permissions, event.properties.id, (p) => p.id)
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
if (match.found) {
|
||||
draft[match.index] = event.properties
|
||||
return
|
||||
}
|
||||
draft.push(event.properties)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "permission.replied": {
|
||||
const permissions = store.permission[event.properties.sessionID]
|
||||
const match = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
|
||||
if (!match.found) break
|
||||
setStore(
|
||||
"permission",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(match.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "todo.updated":
|
||||
setStore("todo", event.properties.sessionID, event.properties.todos)
|
||||
break
|
||||
|
||||
case "session.deleted": {
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "session.updated":
|
||||
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
|
||||
if (result.found) {
|
||||
setStore("session", result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
case "message.updated": {
|
||||
const messages = store.message[event.properties.info.sessionID]
|
||||
if (!messages) {
|
||||
setStore("message", event.properties.info.sessionID, [event.properties.info])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
const messages = store.message[event.properties.sessionID]
|
||||
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
|
||||
if (result.found) {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.part.updated": {
|
||||
const parts = store.part[event.properties.part.messageID]
|
||||
if (!parts) {
|
||||
setStore("part", event.properties.part.messageID, [event.properties.part])
|
||||
break
|
||||
}
|
||||
const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
|
||||
if (result.found) {
|
||||
setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.part.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.part)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "message.part.removed": {
|
||||
const parts = store.part[event.properties.messageID]
|
||||
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
|
||||
if (result.found)
|
||||
setStore(
|
||||
"part",
|
||||
event.properties.messageID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "lsp.updated": {
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// blocking
|
||||
Promise.all([
|
||||
sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
|
||||
sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
sdk.client.config.get().then((x) => setStore("config", x.data!)),
|
||||
]).then(() => setStore("ready", true))
|
||||
|
||||
// non-blocking
|
||||
Promise.all([
|
||||
sdk.client.session.list().then((x) =>
|
||||
setStore(
|
||||
"session",
|
||||
(x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)),
|
||||
),
|
||||
),
|
||||
sdk.client.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||
sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
|
||||
])
|
||||
|
||||
const result = {
|
||||
data: store,
|
||||
set: setStore,
|
||||
get ready() {
|
||||
return store.ready
|
||||
},
|
||||
session: {
|
||||
get(sessionID: string) {
|
||||
const match = Binary.search(store.session, sessionID, (s) => s.id)
|
||||
if (match.found) return store.session[match.index]
|
||||
return undefined
|
||||
},
|
||||
status(sessionID: string) {
|
||||
const session = result.session.get(sessionID)
|
||||
if (!session) return "idle"
|
||||
if (session.time.compacting) return "compacting"
|
||||
const messages = store.message[sessionID] ?? []
|
||||
const last = messages.at(-1)
|
||||
if (!last) return "idle"
|
||||
if (last.role === "user") return "working"
|
||||
return last.time.completed ? "idle" : "working"
|
||||
},
|
||||
async sync(sessionID: string) {
|
||||
const [session, messages, todo] = await Promise.all([
|
||||
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
|
||||
sdk.client.session.messages({ path: { id: sessionID } }),
|
||||
sdk.client.session.todo({ path: { id: sessionID } }),
|
||||
])
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const match = Binary.search(draft.session, sessionID, (s) => s.id)
|
||||
if (match.found) draft.session[match.index] = session.data!
|
||||
if (!match.found) draft.session.splice(match.index, 0, session.data!)
|
||||
draft.todo[sessionID] = todo.data ?? []
|
||||
draft.message[sessionID] = messages.data!.map((x) => x.info)
|
||||
for (const message of messages.data!) {
|
||||
draft.part[message.info.id] = message.parts
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,658 @@
|
||||
import { SyntaxStyle, RGBA } from "@opentui/core"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import aura from "../../../../../../tui/internal/theme/themes/aura.json" with { type: "json" }
|
||||
import ayu from "../../../../../../tui/internal/theme/themes/ayu.json" with { type: "json" }
|
||||
import catppuccin from "../../../../../../tui/internal/theme/themes/catppuccin.json" with { type: "json" }
|
||||
import cobalt2 from "../../../../../../tui/internal/theme/themes/cobalt2.json" with { type: "json" }
|
||||
import dracula from "../../../../../../tui/internal/theme/themes/dracula.json" with { type: "json" }
|
||||
import everforest from "../../../../../../tui/internal/theme/themes/everforest.json" with { type: "json" }
|
||||
import github from "../../../../../../tui/internal/theme/themes/github.json" with { type: "json" }
|
||||
import gruvbox from "../../../../../../tui/internal/theme/themes/gruvbox.json" with { type: "json" }
|
||||
import kanagawa from "../../../../../../tui/internal/theme/themes/kanagawa.json" with { type: "json" }
|
||||
import material from "../../../../../../tui/internal/theme/themes/material.json" with { type: "json" }
|
||||
import matrix from "../../../../../../tui/internal/theme/themes/matrix.json" with { type: "json" }
|
||||
import monokai from "../../../../../../tui/internal/theme/themes/monokai.json" with { type: "json" }
|
||||
import nord from "../../../../../../tui/internal/theme/themes/nord.json" with { type: "json" }
|
||||
import onedark from "../../../../../../tui/internal/theme/themes/one-dark.json" with { type: "json" }
|
||||
import opencode from "../../../../../../tui/internal/theme/themes/opencode.json" with { type: "json" }
|
||||
import palenight from "../../../../../../tui/internal/theme/themes/palenight.json" with { type: "json" }
|
||||
import rosepine from "../../../../../../tui/internal/theme/themes/rosepine.json" with { type: "json" }
|
||||
import solarized from "../../../../../../tui/internal/theme/themes/solarized.json" with { type: "json" }
|
||||
import synthwave84 from "../../../../../../tui/internal/theme/themes/synthwave84.json" with { type: "json" }
|
||||
import tokyonight from "../../../../../../tui/internal/theme/themes/tokyonight.json" with { type: "json" }
|
||||
import vesper from "../../../../../../tui/internal/theme/themes/vesper.json" with { type: "json" }
|
||||
import zenburn from "../../../../../../tui/internal/theme/themes/zenburn.json" with { type: "json" }
|
||||
import { useKV } from "./kv"
|
||||
|
||||
type Theme = {
|
||||
primary: RGBA
|
||||
secondary: RGBA
|
||||
accent: RGBA
|
||||
error: RGBA
|
||||
warning: RGBA
|
||||
success: RGBA
|
||||
info: RGBA
|
||||
text: RGBA
|
||||
textMuted: RGBA
|
||||
background: RGBA
|
||||
backgroundPanel: RGBA
|
||||
backgroundElement: RGBA
|
||||
border: RGBA
|
||||
borderActive: RGBA
|
||||
borderSubtle: RGBA
|
||||
diffAdded: RGBA
|
||||
diffRemoved: RGBA
|
||||
diffContext: RGBA
|
||||
diffHunkHeader: RGBA
|
||||
diffHighlightAdded: RGBA
|
||||
diffHighlightRemoved: RGBA
|
||||
diffAddedBg: RGBA
|
||||
diffRemovedBg: RGBA
|
||||
diffContextBg: RGBA
|
||||
diffLineNumber: RGBA
|
||||
diffAddedLineNumberBg: RGBA
|
||||
diffRemovedLineNumberBg: RGBA
|
||||
markdownText: RGBA
|
||||
markdownHeading: RGBA
|
||||
markdownLink: RGBA
|
||||
markdownLinkText: RGBA
|
||||
markdownCode: RGBA
|
||||
markdownBlockQuote: RGBA
|
||||
markdownEmph: RGBA
|
||||
markdownStrong: RGBA
|
||||
markdownHorizontalRule: RGBA
|
||||
markdownListItem: RGBA
|
||||
markdownListEnumeration: RGBA
|
||||
markdownImage: RGBA
|
||||
markdownImageText: RGBA
|
||||
markdownCodeBlock: RGBA
|
||||
}
|
||||
|
||||
type HexColor = `#${string}`
|
||||
type RefName = string
|
||||
type Variant = {
|
||||
dark: HexColor | RefName
|
||||
light: HexColor | RefName
|
||||
}
|
||||
type ColorValue = HexColor | RefName | Variant
|
||||
type ThemeJson = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Record<keyof Theme, ColorValue>
|
||||
}
|
||||
|
||||
export const THEMES: Record<string, Theme> = {
|
||||
aura: resolveTheme(aura),
|
||||
ayu: resolveTheme(ayu),
|
||||
catppuccin: resolveTheme(catppuccin),
|
||||
cobalt2: resolveTheme(cobalt2),
|
||||
dracula: resolveTheme(dracula),
|
||||
everforest: resolveTheme(everforest),
|
||||
github: resolveTheme(github),
|
||||
gruvbox: resolveTheme(gruvbox),
|
||||
kanagawa: resolveTheme(kanagawa),
|
||||
material: resolveTheme(material),
|
||||
matrix: resolveTheme(matrix),
|
||||
monokai: resolveTheme(monokai),
|
||||
nord: resolveTheme(nord),
|
||||
["one-dark"]: resolveTheme(onedark),
|
||||
opencode: resolveTheme(opencode),
|
||||
palenight: resolveTheme(palenight),
|
||||
rosepine: resolveTheme(rosepine),
|
||||
solarized: resolveTheme(solarized),
|
||||
synthwave84: resolveTheme(synthwave84),
|
||||
tokyonight: resolveTheme(tokyonight),
|
||||
vesper: resolveTheme(vesper),
|
||||
zenburn: resolveTheme(zenburn),
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeJson) {
|
||||
const defs = theme.defs ?? {}
|
||||
function resolveColor(c: ColorValue): RGBA {
|
||||
if (typeof c === "string") return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
|
||||
// TODO: support light theme when opentui has the equivalent of lipgloss.AdaptiveColor
|
||||
return resolveColor(c.dark)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(theme.theme).map(([key, value]) => {
|
||||
return [key, resolveColor(value)]
|
||||
}),
|
||||
) as Theme
|
||||
}
|
||||
|
||||
const syntaxThemeDark = [
|
||||
{
|
||||
scope: ["prompt"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["extmark.file"],
|
||||
style: {
|
||||
foreground: "#ff9e64",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["extmark.agent"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["extmark.paste"],
|
||||
style: {
|
||||
foreground: "#1a1b26",
|
||||
background: "#ff9e64",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment.documentation"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string", "symbol"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["number", "boolean"],
|
||||
style: {
|
||||
foreground: "#ff9e64",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["character.special"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.type"],
|
||||
style: {
|
||||
foreground: "#2ac3de",
|
||||
bold: true,
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.function", "function.method"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.import"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["operator", "keyword.operator", "punctuation.delimiter"],
|
||||
style: {
|
||||
foreground: "#89ddff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.conditional.ternary"],
|
||||
style: {
|
||||
foreground: "#89ddff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.parameter", "function.method.call", "function.call"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.member", "function", "constructor"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["type", "module"],
|
||||
style: {
|
||||
foreground: "#2ac3de",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["constant"],
|
||||
style: {
|
||||
foreground: "#ff9e64",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["property"],
|
||||
style: {
|
||||
foreground: "#73daca",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["class"],
|
||||
style: {
|
||||
foreground: "#2ac3de",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["parameter"],
|
||||
style: {
|
||||
foreground: "#e0af68",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "punctuation.bracket"],
|
||||
style: {
|
||||
foreground: "#89ddff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"variable.builtin",
|
||||
"type.builtin",
|
||||
"function.builtin",
|
||||
"module.builtin",
|
||||
"constant.builtin",
|
||||
],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["variable.super"],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["string.escape", "string.regexp"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.directive"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["punctuation.special"],
|
||||
style: {
|
||||
foreground: "#89ddff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.modifier"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.exception"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
// Markdown specific styles
|
||||
{
|
||||
scope: ["markup.heading"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.1"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.2"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.3"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.4"],
|
||||
style: {
|
||||
foreground: "#73daca",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.5"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.heading.6"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.bold", "markup.strong"],
|
||||
style: {
|
||||
foreground: "#e6edf3",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.italic"],
|
||||
style: {
|
||||
foreground: "#e6edf3",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.list"],
|
||||
style: {
|
||||
foreground: "#ff9e64",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.quote"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
italic: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.raw", "markup.raw.block"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.raw.inline"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
background: "#1a1b26",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.link"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.link.label"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.link.url"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["label"],
|
||||
style: {
|
||||
foreground: "#73daca",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["spell", "nospell"],
|
||||
style: {
|
||||
foreground: "#e6edf3",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["conceal"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
},
|
||||
},
|
||||
// Additional common highlight groups
|
||||
{
|
||||
scope: ["string.special", "string.special.url"],
|
||||
style: {
|
||||
foreground: "#73daca",
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["character"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["float"],
|
||||
style: {
|
||||
foreground: "#ff9e64",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment.error"],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
italic: true,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment.warning"],
|
||||
style: {
|
||||
foreground: "#e0af68",
|
||||
italic: true,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["comment.todo", "comment.note"],
|
||||
style: {
|
||||
foreground: "#7aa2f7",
|
||||
italic: true,
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["namespace"],
|
||||
style: {
|
||||
foreground: "#2ac3de",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["field"],
|
||||
style: {
|
||||
foreground: "#73daca",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["type.definition"],
|
||||
style: {
|
||||
foreground: "#2ac3de",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["keyword.export"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["attribute", "annotation"],
|
||||
style: {
|
||||
foreground: "#e0af68",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["tag"],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["tag.attribute"],
|
||||
style: {
|
||||
foreground: "#bb9af7",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["tag.delimiter"],
|
||||
style: {
|
||||
foreground: "#89ddff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.strikethrough"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.underline"],
|
||||
style: {
|
||||
foreground: "#e6edf3",
|
||||
underline: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.list.checked"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["markup.list.unchecked"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["diff.plus"],
|
||||
style: {
|
||||
foreground: "#9ece6a",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["diff.minus"],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["diff.delta"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["error"],
|
||||
style: {
|
||||
foreground: "#f7768e",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["warning"],
|
||||
style: {
|
||||
foreground: "#e0af68",
|
||||
bold: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["info"],
|
||||
style: {
|
||||
foreground: "#7dcfff",
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: ["debug"],
|
||||
style: {
|
||||
foreground: "#565f89",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export const SyntaxTheme = SyntaxStyle.fromTheme(syntaxThemeDark)
|
||||
|
||||
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
name: "Theme",
|
||||
init: () => {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
|
||||
const [theme, setTheme] = createSignal(sync.data.config.theme ?? kv.get("theme", "opencode"))
|
||||
|
||||
const values = createMemo(() => {
|
||||
return THEMES[theme()] ?? THEMES.opencode
|
||||
})
|
||||
|
||||
return {
|
||||
theme: new Proxy(values(), {
|
||||
get(_target, prop) {
|
||||
// @ts-expect-error
|
||||
return values()[prop]
|
||||
},
|
||||
}),
|
||||
get selected() {
|
||||
return theme()
|
||||
},
|
||||
set(theme: string) {
|
||||
if (!THEMES[theme]) return
|
||||
setTheme(theme)
|
||||
kv.set("theme", theme)
|
||||
},
|
||||
get ready() {
|
||||
return sync.ready
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Bus } from "@/bus"
|
||||
import z from "zod"
|
||||
|
||||
export const TuiEvent = {
|
||||
PromptAppend: Bus.event("tui.prompt.append", z.object({ text: z.string() })),
|
||||
CommandExecute: Bus.event(
|
||||
"tui.command.execute",
|
||||
z.object({
|
||||
command: z.union([
|
||||
z.enum([
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.share",
|
||||
"session.interrupt",
|
||||
"session.compact",
|
||||
"session.page.up",
|
||||
"session.page.down",
|
||||
"session.half.page.up",
|
||||
"session.half.page.down",
|
||||
"session.first",
|
||||
"session.last",
|
||||
"prompt.clear",
|
||||
"prompt.submit",
|
||||
"agent.cycle",
|
||||
]),
|
||||
z.string(),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
ToastShow: Bus.event(
|
||||
"tui.toast.show",
|
||||
z.object({
|
||||
title: z.string().optional(),
|
||||
message: z.string(),
|
||||
variant: z.enum(["info", "success", "warning", "error"]),
|
||||
duration: z.number().default(5000).optional().describe("Duration in milliseconds"),
|
||||
}),
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createEffect, createMemo, Match, Show, Switch, type ParentProps } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import type { KeybindsConfig } from "@opencode-ai/sdk"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const dialog = useDialog()
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
})
|
||||
let promptRef: PromptRef | undefined = undefined
|
||||
|
||||
createEffect(() => {
|
||||
dialog.allClosedEvent.listen(() => {
|
||||
promptRef?.focus()
|
||||
})
|
||||
})
|
||||
|
||||
const Hint = (
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
<text>
|
||||
<Switch>
|
||||
<Match when={mcpError()}>
|
||||
<span style={{ fg: theme.error }}>•</span> mcp errors{" "}
|
||||
<span style={{ fg: theme.textMuted }}>ctrl+x s</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<span style={{ fg: theme.success }}>•</span>{" "}
|
||||
{Locale.pluralize(
|
||||
Object.values(sync.data.mcp).length,
|
||||
"{} mcp server",
|
||||
"{} mcp servers",
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
|
||||
return (
|
||||
<box
|
||||
flexGrow={1}
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
gap={1}
|
||||
>
|
||||
<Logo />
|
||||
<box width={39}>
|
||||
<HelpRow keybind="command_list">Commands</HelpRow>
|
||||
<HelpRow keybind="session_list">List sessions</HelpRow>
|
||||
<HelpRow keybind="model_list">Switch model</HelpRow>
|
||||
<HelpRow keybind="agent_cycle">Switch agent</HelpRow>
|
||||
</box>
|
||||
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1}>
|
||||
<Prompt hint={Hint} ref={(r) => (promptRef = r)} />
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function HelpRow(props: ParentProps<{ keybind: keyof KeybindsConfig }>) {
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="space-between" width="100%">
|
||||
<text>{props.children}</text>
|
||||
<text fg={theme.primary}>{keybind.print(props.keybind)}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useSDK } from "@tui/context/sdk"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
|
||||
export function DialogMessage(props: { messageID: string; sessionID: string }) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const message = createMemo(() => sync.data.message[props.sessionID]?.find((x) => x.id === props.messageID))
|
||||
const route = useRoute()
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Message Actions"
|
||||
options={[
|
||||
{
|
||||
title: "Revert",
|
||||
value: "session.revert",
|
||||
description: "undo messages and file changes",
|
||||
onSelect: (dialog) => {
|
||||
sdk.client.session.revert({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
body: {
|
||||
messageID: message()!.id,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Fork",
|
||||
value: "session.fork",
|
||||
description: "create a new session",
|
||||
onSelect: async (dialog) => {
|
||||
const result = await sdk.client.session.fork({
|
||||
path: {
|
||||
id: props.sessionID,
|
||||
},
|
||||
body: {
|
||||
messageID: props.messageID,
|
||||
},
|
||||
})
|
||||
route.navigate({
|
||||
sessionID: result.data!.id,
|
||||
type: "session",
|
||||
})
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createMemo, onMount } from "solid-js"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
|
||||
import type { TextPart } from "@opencode-ai/sdk"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { DialogMessage } from "./dialog-message"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
|
||||
export function DialogTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
||||
const messages = sync.data.message[props.sessionID] ?? []
|
||||
const result = [] as DialogSelectOption<string>[]
|
||||
for (const message of messages) {
|
||||
if (message.role !== "user") continue
|
||||
const part = (sync.data.part[message.id] ?? []).find((x) => x.type === "text" && !x.synthetic) as TextPart
|
||||
if (!part) continue
|
||||
result.push({
|
||||
title: part.text.replace(/\n/g, " "),
|
||||
value: message.id,
|
||||
footer: Locale.time(message.time.created),
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => <DialogMessage messageID={message.id} sessionID={props.sessionID} />)
|
||||
},
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Timeline" options={options()} />
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { createMemo, Match, Show, Switch } from "solid-js"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { pipe, sumBy } from "remeda"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = pipe(
|
||||
messages(),
|
||||
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
|
||||
)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast(
|
||||
(x) => x.role === "assistant" && x.tokens.output > 0,
|
||||
) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
let result = total.toLocaleString()
|
||||
if (model?.limit.context) {
|
||||
result += "/" + Math.round((total / model.limit.context) * 100) + "%"
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
borderColor={theme.backgroundElement}
|
||||
flexShrink={0}
|
||||
>
|
||||
<text>
|
||||
<span style={{ bold: true, fg: theme.accent }}>#</span>{" "}
|
||||
<span style={{ bold: true }}>{session().title}</span>
|
||||
</text>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<Switch>
|
||||
<Match when={session().share?.url}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{session().share!.url}
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text wrapMode="word">
|
||||
/share <span style={{ fg: theme.textMuted }}>to create a shareable link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={context()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{context()} ({cost()})
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, For, Show, Switch, Match } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "@/util/locale"
|
||||
import path from "path"
|
||||
import type { AssistantMessage } from "@opencode-ai/sdk"
|
||||
|
||||
export function Sidebar(props: { sessionID: string }) {
|
||||
const sync = useSync()
|
||||
const { theme } = useTheme()
|
||||
const session = createMemo(() => sync.session.get(props.sessionID)!)
|
||||
const todo = createMemo(() => sync.data.todo[props.sessionID] ?? [])
|
||||
const messages = createMemo(() => sync.data.message[props.sessionID] ?? [])
|
||||
|
||||
const cost = createMemo(() => {
|
||||
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(total)
|
||||
})
|
||||
|
||||
const context = createMemo(() => {
|
||||
const last = messages().findLast(
|
||||
(x) => x.role === "assistant" && x.tokens.output > 0,
|
||||
) as AssistantMessage
|
||||
if (!last) return
|
||||
const total =
|
||||
last.tokens.input +
|
||||
last.tokens.output +
|
||||
last.tokens.reasoning +
|
||||
last.tokens.cache.read +
|
||||
last.tokens.cache.write
|
||||
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
|
||||
return {
|
||||
tokens: total.toLocaleString(),
|
||||
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={session()}>
|
||||
<box flexShrink={0} gap={1} width={40}>
|
||||
<box>
|
||||
<text>
|
||||
<b>{session().title}</b>
|
||||
</text>
|
||||
<Show when={session().share?.url}>
|
||||
<text fg={theme.textMuted}>{session().share!.url}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box>
|
||||
<text>
|
||||
<b>Context</b>
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{context()?.tokens ?? 0} tokens</text>
|
||||
<text fg={theme.textMuted}>{context()?.percentage ?? 0}% used</text>
|
||||
<text fg={theme.textMuted}>{cost()} spent</text>
|
||||
</box>
|
||||
<Show when={Object.keys(sync.data.mcp).length > 0}>
|
||||
<box>
|
||||
<text>
|
||||
<b>MCP</b>
|
||||
</text>
|
||||
<For each={Object.entries(sync.data.mcp)}>
|
||||
{([key, item]) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
failed: theme.error,
|
||||
disabled: theme.textMuted,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text wrapMode="word">
|
||||
{key}{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
<Switch>
|
||||
<Match when={item.status === "connected"}>Connected</Match>
|
||||
<Match when={item.status === "failed" && item}>
|
||||
{(val) => <i>{val().error}</i>}
|
||||
</Match>
|
||||
<Match when={item.status === "disabled"}>Disabled in configuration</Match>
|
||||
</Switch>
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={sync.data.lsp.length > 0}>
|
||||
<box>
|
||||
<text>
|
||||
<b>LSP</b>
|
||||
</text>
|
||||
<For each={sync.data.lsp}>
|
||||
{(item) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: {
|
||||
connected: theme.success,
|
||||
error: theme.error,
|
||||
}[item.status],
|
||||
}}
|
||||
>
|
||||
•
|
||||
</text>
|
||||
<text fg={theme.textMuted}>
|
||||
{item.id} {item.root}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={session().summary?.diffs}>
|
||||
<box>
|
||||
<text>
|
||||
<b>Modified Files</b>
|
||||
</text>
|
||||
<For each={session().summary?.diffs || []}>
|
||||
{(item) => {
|
||||
const file = createMemo(() => {
|
||||
const splits = item.file.split(path.sep).filter(Boolean)
|
||||
const last = splits.at(-1)!
|
||||
const rest = splits.slice(0, -1).join(path.sep)
|
||||
return Locale.truncateMiddle(rest, 30 - last.length) + "/" + last
|
||||
})
|
||||
return (
|
||||
<box flexDirection="row" gap={1} justifyContent="space-between">
|
||||
<text fg={theme.textMuted} wrapMode="char">
|
||||
{file()}
|
||||
</text>
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<Show when={item.additions}>
|
||||
<text fg={theme.diffAdded}>+{item.additions}</text>
|
||||
</Show>
|
||||
<Show when={item.deletions}>
|
||||
<text fg={theme.diffRemoved}>-{item.deletions}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={todo().length > 0}>
|
||||
<box>
|
||||
<text>
|
||||
<b>Todo</b>
|
||||
</text>
|
||||
<For each={todo()}>
|
||||
{(todo) => (
|
||||
<text
|
||||
style={{ fg: todo.status === "in_progress" ? theme.success : theme.textMuted }}
|
||||
>
|
||||
[{todo.status === "completed" ? "✓" : " "}] {todo.content}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { Instance } from "@/project/instance"
|
||||
import path from "path"
|
||||
import { Server } from "@/server/server"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
|
||||
export const TuiSpawnCommand = cmd({
|
||||
command: "spawn [project]",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
upgrade()
|
||||
const server = Server.listen({
|
||||
port: args.port,
|
||||
hostname: "127.0.0.1",
|
||||
})
|
||||
const bin = process.execPath
|
||||
const cmd = []
|
||||
let cwd = process.cwd()
|
||||
if (bin.endsWith("bun")) {
|
||||
cmd.push(
|
||||
process.execPath,
|
||||
"run",
|
||||
"--conditions",
|
||||
"browser",
|
||||
new URL("../../../index.ts", import.meta.url).pathname,
|
||||
)
|
||||
cwd = new URL("../../../../", import.meta.url).pathname
|
||||
} else cmd.push(process.execPath)
|
||||
cmd.push("attach", server.url.toString(), "--dir", args.project ? path.resolve(args.project) : process.cwd())
|
||||
const proc = Bun.spawn({
|
||||
cmd,
|
||||
cwd,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
stdin: "inherit",
|
||||
})
|
||||
await proc.exited
|
||||
await Instance.disposeAll()
|
||||
await server.stop(true)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cmd } from "@/cli/cmd/cmd"
|
||||
import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Session } from "@/session"
|
||||
import { bootstrap } from "@/cli/bootstrap"
|
||||
import path from "path"
|
||||
import { UI } from "@/cli/ui"
|
||||
|
||||
export const TuiThreadCommand = cmd({
|
||||
command: "$0 [project]",
|
||||
describe: "start opencode tui",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.positional("project", {
|
||||
type: "string",
|
||||
describe: "path to start opencode in",
|
||||
})
|
||||
.option("model", {
|
||||
type: "string",
|
||||
alias: ["m"],
|
||||
describe: "model to use in the format of provider/model",
|
||||
})
|
||||
.option("continue", {
|
||||
alias: ["c"],
|
||||
describe: "continue the last session",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("session", {
|
||||
alias: ["s"],
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("prompt", {
|
||||
alias: ["p"],
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
})
|
||||
.option("agent", {
|
||||
type: "string",
|
||||
describe: "agent to use",
|
||||
})
|
||||
.option("port", {
|
||||
type: "number",
|
||||
describe: "port to listen on",
|
||||
default: 0,
|
||||
})
|
||||
.option("hostname", {
|
||||
type: "string",
|
||||
describe: "hostname to listen on",
|
||||
default: "127.0.0.1",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
}
|
||||
await bootstrap(cwd, async () => {
|
||||
upgrade()
|
||||
|
||||
const sessionID = await (async () => {
|
||||
if (args.continue) {
|
||||
const it = Session.list()
|
||||
try {
|
||||
for await (const s of it) {
|
||||
if (s.parentID === undefined) {
|
||||
return s.id
|
||||
}
|
||||
}
|
||||
return
|
||||
} finally {
|
||||
await it.return()
|
||||
}
|
||||
}
|
||||
if (args.session) {
|
||||
return args.session
|
||||
}
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const worker = new Worker("./src/cli/cmd/tui/worker.ts")
|
||||
worker.onerror = console.error
|
||||
const client = Rpc.client<typeof rpc>(worker)
|
||||
process.on("uncaughtException", (e) => {
|
||||
console.error(e)
|
||||
})
|
||||
process.on("unhandledRejection", (e) => {
|
||||
console.error(e)
|
||||
})
|
||||
const server = await client.call("server", {
|
||||
port: args.port,
|
||||
hostname: args.hostname,
|
||||
})
|
||||
await tui({
|
||||
url: server.url,
|
||||
sessionID,
|
||||
model: args.model,
|
||||
agent: args.agent,
|
||||
prompt: args.prompt,
|
||||
onExit: async () => {
|
||||
await client.call("shutdown", undefined)
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
export type DialogAlertProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export function DialogAlert(props: DialogAlertProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.primary}
|
||||
onMouseUp={() => {
|
||||
props.onConfirm?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={theme.background}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogAlert.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogAlert title={title} message={message} onConfirm={() => resolve()} />,
|
||||
() => resolve(),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { For } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Locale } from "@/util/locale"
|
||||
|
||||
export type DialogConfirmProps = {
|
||||
title: string
|
||||
message: string
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function DialogConfirm(props: DialogConfirmProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
active: "confirm" as "confirm" | "cancel",
|
||||
})
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return") {
|
||||
if (store.active === "confirm") props.onConfirm?.()
|
||||
if (store.active === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
if (evt.name === "left" || evt.name === "right") {
|
||||
setStore("active", store.active === "confirm" ? "cancel" : "confirm")
|
||||
}
|
||||
})
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>{props.message}</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<For each={["cancel", "confirm"]}>
|
||||
{(key) => (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={key === store.active ? theme.primary : undefined}
|
||||
onMouseUp={(evt) => {
|
||||
if (key === "confirm") props.onConfirm?.()
|
||||
if (key === "cancel") props.onCancel?.()
|
||||
dialog.clear()
|
||||
}}
|
||||
>
|
||||
<text fg={key === store.active ? theme.background : theme.textMuted}>{Locale.titlecase(key)}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogConfirm
|
||||
title={title}
|
||||
message={message}
|
||||
onConfirm={() => resolve(true)}
|
||||
onCancel={() => resolve(false)}
|
||||
/>
|
||||
),
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useDialog } from "./dialog"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
export function DialogHelp() {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "return" || evt.name === "escape") {
|
||||
dialog.clear()
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>Help</text>
|
||||
<text fg={theme.textMuted}>esc/enter</text>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Press Ctrl+P to see all available actions and commands in any context.
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={theme.primary}
|
||||
onMouseUp={() => dialog.clear()}
|
||||
>
|
||||
<text fg={theme.background}>ok</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { onMount } from "solid-js"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function DialogPrompt(props: DialogPromptProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
let textarea: TextareaRenderable
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
setTimeout(() => {
|
||||
textarea.focus()
|
||||
}, 1)
|
||||
textarea.gotoLineEnd()
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box>
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
dialog.clear()
|
||||
}}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder="Enter text"
|
||||
/>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={title}
|
||||
value={value}
|
||||
onConfirm={(value) => resolve(value)}
|
||||
onCancel={() => resolve(null)}
|
||||
/>
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
|
||||
import { batch, createEffect, createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { Locale } from "@/util/locale"
|
||||
|
||||
export interface DialogSelectProps<T> {
|
||||
title: string
|
||||
options: DialogSelectOption<T>[]
|
||||
ref?: (ref: DialogSelectRef<T>) => void
|
||||
onMove?: (option: DialogSelectOption<T>) => void
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
keybind?: {
|
||||
keybind: Keybind.Info
|
||||
title: string
|
||||
onTrigger: (option: DialogSelectOption<T>) => void
|
||||
}[]
|
||||
limit?: number
|
||||
current?: T
|
||||
}
|
||||
|
||||
export interface DialogSelectOption<T = any> {
|
||||
title: string
|
||||
value: T
|
||||
description?: string
|
||||
footer?: string
|
||||
category?: string
|
||||
disabled?: boolean
|
||||
bg?: RGBA
|
||||
onSelect?: (ctx: DialogContext, trigger?: "prompt") => void
|
||||
}
|
||||
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
}
|
||||
|
||||
export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
})
|
||||
|
||||
let input: InputRenderable
|
||||
|
||||
const filtered = createMemo(() => {
|
||||
const needle = store.filter.toLowerCase()
|
||||
const result = pipe(
|
||||
props.options,
|
||||
filter((x) => x.disabled !== true),
|
||||
take(props.limit ?? Infinity),
|
||||
(x) =>
|
||||
!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
groupBy((x) => x.category ?? ""),
|
||||
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
|
||||
entries(),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
const flat = createMemo(() => {
|
||||
return pipe(
|
||||
grouped(),
|
||||
flatMap(([_, options]) => options),
|
||||
)
|
||||
})
|
||||
|
||||
const dimensions = useTerminalDimensions()
|
||||
const height = createMemo(() =>
|
||||
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
|
||||
)
|
||||
|
||||
const selected = createMemo(() => flat()[store.selected])
|
||||
|
||||
createEffect(() => {
|
||||
store.filter
|
||||
setStore("selected", 0)
|
||||
scroll.scrollTo(0)
|
||||
})
|
||||
|
||||
function move(direction: number) {
|
||||
let next = store.selected + direction
|
||||
if (next < 0) next = flat().length - 1
|
||||
if (next >= flat().length) next = 0
|
||||
moveTo(next)
|
||||
}
|
||||
|
||||
function moveTo(next: number) {
|
||||
setStore("selected", next)
|
||||
props.onMove?.(selected()!)
|
||||
const target = scroll.getChildren().find((child) => {
|
||||
return child.id === JSON.stringify(selected()?.value)
|
||||
})
|
||||
if (!target) return
|
||||
const y = target.y - scroll.y
|
||||
if (y >= scroll.height) {
|
||||
scroll.scrollBy(y - scroll.height + 1)
|
||||
}
|
||||
if (y < 0) {
|
||||
scroll.scrollBy(y)
|
||||
if (isDeepEqual(flat()[0].value, selected()?.value)) {
|
||||
scroll.scrollTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const keybind = useKeybind()
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option) {
|
||||
if (option.onSelect) option.onSelect(dialog)
|
||||
props.onSelect?.(option)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of props.keybind ?? []) {
|
||||
if (Keybind.match(item.keybind, keybind.parse(evt))) {
|
||||
const s = selected()
|
||||
if (s) {
|
||||
evt.preventDefault()
|
||||
item.onTrigger(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
const ref: DialogSelectRef<T> = {
|
||||
get filter() {
|
||||
return store.filter
|
||||
},
|
||||
get filtered() {
|
||||
return filtered()
|
||||
},
|
||||
}
|
||||
props.ref?.(ref)
|
||||
|
||||
return (
|
||||
<box gap={1}>
|
||||
<box paddingLeft={3} paddingRight={2}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box paddingTop={1} paddingBottom={1}>
|
||||
<input
|
||||
onInput={(e) => {
|
||||
batch(() => {
|
||||
setStore("filter", e)
|
||||
props.onFilter?.(e)
|
||||
})
|
||||
}}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.textMuted}
|
||||
ref={(r) => {
|
||||
input = r
|
||||
input.focus()
|
||||
}}
|
||||
placeholder="Enter search term"
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<scrollbox
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
ref={(r: ScrollBoxRenderable) => (scroll = r)}
|
||||
maxHeight={height()}
|
||||
>
|
||||
<For each={grouped()}>
|
||||
{([category, options], index) => (
|
||||
<>
|
||||
<Show when={category}>
|
||||
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={1}>
|
||||
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
|
||||
{category}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<For each={options}>
|
||||
{(option) => {
|
||||
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
|
||||
return (
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
onMouseUp={() => {
|
||||
option.onSelect?.(dialog)
|
||||
props.onSelect?.(option)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
const index = filtered().findIndex((x) =>
|
||||
isDeepEqual(x.value, option.value),
|
||||
)
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
backgroundColor={
|
||||
active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
|
||||
}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
gap={1}
|
||||
>
|
||||
<Option
|
||||
title={option.title}
|
||||
footer={option.footer}
|
||||
description={
|
||||
option.description !== category ? option.description : undefined
|
||||
}
|
||||
active={active()}
|
||||
current={isDeepEqual(option.value, props.current)}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<For each={props.keybind ?? []}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>
|
||||
{Keybind.toString(item.keybind)}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function Option(props: {
|
||||
title: string
|
||||
description?: string
|
||||
active?: boolean
|
||||
current?: boolean
|
||||
footer?: string
|
||||
onMouseOver?: () => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<text
|
||||
flexGrow={1}
|
||||
fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
>
|
||||
{Locale.truncate(props.title, 62)}
|
||||
<span style={{ fg: props.active ? theme.background : theme.textMuted }}>
|
||||
{" "}
|
||||
{props.description}
|
||||
</span>
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active ? theme.background : theme.textMuted}>{props.footer}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Renderable, RGBA } from "@opentui/core"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createEventBus } from "@solid-primitives/event-bus"
|
||||
|
||||
const Border = {
|
||||
topLeft: "┃",
|
||||
topRight: "┃",
|
||||
bottomLeft: "┃",
|
||||
bottomRight: "┃",
|
||||
horizontal: "",
|
||||
vertical: "┃",
|
||||
topT: "+",
|
||||
bottomT: "+",
|
||||
leftT: "+",
|
||||
rightT: "+",
|
||||
cross: "+",
|
||||
}
|
||||
export function Dialog(
|
||||
props: ParentProps<{
|
||||
size?: "medium" | "large"
|
||||
onClose: () => void
|
||||
}>,
|
||||
) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box
|
||||
onMouseUp={async () => {
|
||||
props.onClose?.()
|
||||
}}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
alignItems="center"
|
||||
position="absolute"
|
||||
paddingTop={dimensions().height / 4}
|
||||
left={0}
|
||||
top={0}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
>
|
||||
<box
|
||||
onMouseUp={async (e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
customBorderChars={Border}
|
||||
width={props.size === "large" ? 80 : 60}
|
||||
maxWidth={dimensions().width - 2}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme.border}
|
||||
paddingTop={1}
|
||||
>
|
||||
{props.children}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
stack: [] as {
|
||||
element: JSX.Element
|
||||
onClose?: () => void
|
||||
}[],
|
||||
size: "medium" as "medium" | "large",
|
||||
})
|
||||
const allClosedEvent = createEventBus<void>()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "escape" && store.stack.length > 0) {
|
||||
const current = store.stack.at(-1)!
|
||||
current.onClose?.()
|
||||
setStore("stack", store.stack.slice(0, -1))
|
||||
evt.preventDefault()
|
||||
refocus()
|
||||
}
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
let focus: Renderable | null
|
||||
function refocus() {
|
||||
setTimeout(() => {
|
||||
if (!focus) return
|
||||
if (focus.isDestroyed) return
|
||||
function find(item: Renderable) {
|
||||
for (const child of item.getChildren()) {
|
||||
if (child === focus) return true
|
||||
if (find(child)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const found = find(renderer.root)
|
||||
if (!found) return
|
||||
focus.focus()
|
||||
}, 1)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.stack.length === 0) {
|
||||
allClosedEvent.emit()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
clear() {
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
batch(() => {
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [])
|
||||
})
|
||||
refocus()
|
||||
},
|
||||
replace(input: any, onClose?: () => void) {
|
||||
if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
|
||||
for (const item of store.stack) {
|
||||
if (item.onClose) item.onClose()
|
||||
}
|
||||
setStore("size", "medium")
|
||||
setStore("stack", [
|
||||
{
|
||||
element: input,
|
||||
onClose,
|
||||
},
|
||||
])
|
||||
},
|
||||
get stack() {
|
||||
return store.stack
|
||||
},
|
||||
get size() {
|
||||
return store.size
|
||||
},
|
||||
setSize(size: "medium" | "large") {
|
||||
setStore("size", size)
|
||||
},
|
||||
get allClosedEvent() {
|
||||
return allClosedEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DialogContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<DialogContext>()
|
||||
|
||||
export function DialogProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return (
|
||||
<ctx.Provider value={value}>
|
||||
{props.children}
|
||||
<box position="absolute">
|
||||
<Show when={value.stack.length}>
|
||||
<Dialog onClose={() => value.clear()} size={value.size}>
|
||||
{value.stack.at(-1)!.element}
|
||||
</Dialog>
|
||||
</Show>
|
||||
</box>
|
||||
</ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useDialog must be used within a DialogProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { useTimeline } from "@opentui/solid"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
|
||||
export type ShimmerProps = {
|
||||
text: string
|
||||
color: RGBA
|
||||
}
|
||||
|
||||
const DURATION = 2_500
|
||||
|
||||
export function Shimmer(props: ShimmerProps) {
|
||||
const timeline = useTimeline({
|
||||
duration: DURATION,
|
||||
loop: true,
|
||||
})
|
||||
const characters = props.text.split("")
|
||||
const color = props.color
|
||||
|
||||
const shimmerSignals = characters.map((_, i) => {
|
||||
const [shimmer, setShimmer] = createSignal(0.4)
|
||||
const target = {
|
||||
shimmer: shimmer(),
|
||||
setShimmer,
|
||||
}
|
||||
|
||||
timeline!.add(
|
||||
target,
|
||||
{
|
||||
shimmer: 1,
|
||||
duration: DURATION / (props.text.length + 1),
|
||||
ease: "linear",
|
||||
alternate: true,
|
||||
loop: 2,
|
||||
onUpdate: () => {
|
||||
target.setShimmer(target.shimmer)
|
||||
},
|
||||
},
|
||||
(i * (DURATION / (props.text.length + 1))) / 2,
|
||||
)
|
||||
|
||||
return shimmer
|
||||
})
|
||||
|
||||
return (
|
||||
<text>
|
||||
{(() => {
|
||||
return characters.map((ch, i) => {
|
||||
const shimmer = shimmerSignals[i]
|
||||
const fg = RGBA.fromInts(color.r * 255, color.g * 255, color.b * 255, shimmer() * 255)
|
||||
return <span style={{ fg }}>{ch}</span>
|
||||
})
|
||||
})()}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createContext, useContext, type ParentProps, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { SplitBorder } from "../component/border"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import z from "zod"
|
||||
import { TuiEvent } from "../event"
|
||||
|
||||
export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
|
||||
|
||||
export function Toast() {
|
||||
const toast = useToast()
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<Show when={toast.currentToast}>
|
||||
{(current) => (
|
||||
<box
|
||||
position="absolute"
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
top={2}
|
||||
right={2}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
borderColor={theme[current().variant]}
|
||||
border={["left", "right"]}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<Show when={current().title}>
|
||||
<text attributes={TextAttributes.BOLD} marginBottom={1}>
|
||||
{current().title}
|
||||
</text>
|
||||
</Show>
|
||||
<text>{current().message}</text>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function init() {
|
||||
const [store, setStore] = createStore({
|
||||
currentToast: null as ToastOptions | null,
|
||||
})
|
||||
|
||||
let timeoutHandle: NodeJS.Timeout | null = null
|
||||
|
||||
const toast = {
|
||||
show(options: ToastOptions) {
|
||||
const parsedOptions = TuiEvent.ToastShow.properties.parse(options)
|
||||
const { duration, ...currentToast } = parsedOptions
|
||||
setStore("currentToast", currentToast)
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
timeoutHandle = setTimeout(() => {
|
||||
setStore("currentToast", null)
|
||||
}, duration).unref()
|
||||
},
|
||||
error: (err: any) => {
|
||||
if (err instanceof Error)
|
||||
return toast.show({
|
||||
variant: "error",
|
||||
message: err.message,
|
||||
})
|
||||
toast.show({
|
||||
variant: "error",
|
||||
message: "An unknown error has occurred",
|
||||
})
|
||||
},
|
||||
get currentToast(): ToastOptions | null {
|
||||
return store.currentToast
|
||||
},
|
||||
}
|
||||
return toast
|
||||
}
|
||||
|
||||
export type ToastContext = ReturnType<typeof init>
|
||||
|
||||
const ctx = createContext<ToastContext>()
|
||||
|
||||
export function ToastProvider(props: ParentProps) {
|
||||
const value = init()
|
||||
return <ctx.Provider value={value}>{props.children}</ctx.Provider>
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const value = useContext(ctx)
|
||||
if (!value) {
|
||||
throw new Error("useToast must be used within a ToastProvider")
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { $ } from "bun"
|
||||
import { platform } from "os"
|
||||
import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
import path from "path"
|
||||
|
||||
export namespace Clipboard {
|
||||
export interface Content {
|
||||
data: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin") {
|
||||
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
|
||||
try {
|
||||
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
|
||||
.nothrow()
|
||||
.quiet()
|
||||
const file = Bun.file(tmpfile)
|
||||
const buffer = await file.arrayBuffer()
|
||||
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
|
||||
} catch {
|
||||
} finally {
|
||||
await $`rm -f "${tmpfile}"`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
const wayland = await $`wl-paste -t image/png`.nothrow().text()
|
||||
if (wayland) {
|
||||
return { data: Buffer.from(wayland).toString("base64url"), mime: "image/png" }
|
||||
}
|
||||
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().text()
|
||||
if (x11) {
|
||||
return { data: Buffer.from(x11).toString("base64url"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
const script =
|
||||
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
|
||||
const base64 = await $`powershell -command "${script}"`.nothrow().text()
|
||||
if (base64) {
|
||||
const imageBuffer = Buffer.from(base64.trim(), "base64")
|
||||
if (imageBuffer.length > 0) {
|
||||
return { data: imageBuffer.toString("base64url"), mime: "image/png" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = await clipboardy.read().catch(() => {})
|
||||
if (text) {
|
||||
return { data: text, mime: "text/plain" }
|
||||
}
|
||||
}
|
||||
|
||||
const getCopyMethod = lazy(() => {
|
||||
const os = platform()
|
||||
|
||||
if (os === "darwin" && Bun.which("oascript")) {
|
||||
console.log("clipboard: using osascript")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "linux") {
|
||||
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
|
||||
console.log("clipboard: using wl-copy")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xclip")) {
|
||||
console.log("clipboard: using xclip")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
if (Bun.which("xsel")) {
|
||||
console.log("clipboard: using xsel")
|
||||
return async (text: string) => {
|
||||
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
|
||||
stdin: "pipe",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
proc.stdin.write(text)
|
||||
proc.stdin.end()
|
||||
await proc.exited.catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "win32") {
|
||||
console.log("clipboard: using powershell")
|
||||
return async (text: string) => {
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
await $`powershell -command "Set-Clipboard -Value \"${escaped}\""`.nothrow().quiet()
|
||||
}
|
||||
}
|
||||
|
||||
console.log("clipboard: no native support")
|
||||
return async (text: string) => {
|
||||
await clipboardy.write(text).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defer } from "@/util/defer"
|
||||
import { rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CliRenderer } from "@opentui/core"
|
||||
|
||||
export namespace Editor {
|
||||
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
|
||||
const editor = process.env["EDITOR"]
|
||||
if (!editor) return
|
||||
|
||||
const filepath = join(tmpdir(), `${Date.now()}.md`)
|
||||
await using _ = defer(async () => rm(filepath, { force: true }))
|
||||
|
||||
await Bun.write(filepath, opts.value)
|
||||
opts.renderer.suspend()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
const parts = editor.split(" ")
|
||||
const proc = Bun.spawn({
|
||||
cmd: [...parts, filepath],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
await proc.exited
|
||||
const content = await Bun.file(filepath).text()
|
||||
opts.renderer.currentRenderBuffer.clear()
|
||||
opts.renderer.resume()
|
||||
opts.renderer.requestRender()
|
||||
return content || undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Installation } from "@/installation"
|
||||
import { Server } from "@/server/server"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
dev: Installation.isLocal(),
|
||||
level: (() => {
|
||||
if (Installation.isLocal()) return "DEBUG"
|
||||
return "INFO"
|
||||
})(),
|
||||
})
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error("exception", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
let server: Bun.Server<undefined>
|
||||
export const rpc = {
|
||||
async server(input: { port: number; hostname: string }) {
|
||||
if (server) await server.stop(true)
|
||||
try {
|
||||
server = Server.listen(input)
|
||||
return {
|
||||
url: server.url.toString(),
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
async shutdown() {
|
||||
await Instance.disposeAll()
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
Rpc.listen(rpc)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Bus } from "@/bus"
|
||||
import { Config } from "@/config/config"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await Config.global()
|
||||
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
|
||||
const latest = await Installation.latest().catch(() => {})
|
||||
if (!latest) return
|
||||
if (Installation.VERSION === latest) return
|
||||
const method = await Installation.method()
|
||||
if (method === "unknown") return
|
||||
await Installation.upgrade(method, latest)
|
||||
.then(() => Bus.publish(Installation.Event.Updated, { version: latest }))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -1,8 +1,27 @@
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { Instance } from "../project/instance"
|
||||
import PROMPT_INITIALIZE from "./template/initialize.txt"
|
||||
import { Bus } from "../bus"
|
||||
import { Identifier } from "../id/id"
|
||||
|
||||
export namespace Command {
|
||||
export const Default = {
|
||||
INIT: "init",
|
||||
} as const
|
||||
|
||||
export const Event = {
|
||||
Executed: Bus.event(
|
||||
"command.executed",
|
||||
z.object({
|
||||
name: z.string(),
|
||||
sessionID: Identifier.schema("session"),
|
||||
arguments: z.string(),
|
||||
messageID: Identifier.schema("message"),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
@@ -33,6 +52,14 @@ export namespace Command {
|
||||
}
|
||||
}
|
||||
|
||||
if (result[Default.INIT] === undefined) {
|
||||
result[Default.INIT] = {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
|
||||
+2
@@ -6,3 +6,5 @@ The file you create will be given to agentic coding agents (such as yourself) th
|
||||
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
|
||||
|
||||
If there's already an AGENTS.md, improve it if it's located in ${path}
|
||||
|
||||
$ARGUMENTS
|
||||
@@ -49,7 +49,7 @@ export namespace Config {
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
process.env[value.key] = value.token
|
||||
const wellknown = await fetch(`${key}/.well-known/opencode`).then((x) => x.json())
|
||||
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
|
||||
result = mergeDeep(
|
||||
result,
|
||||
await load(JSON.stringify(wellknown.config ?? {}), process.cwd()),
|
||||
@@ -108,29 +108,13 @@ export namespace Config {
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
|
||||
result.keybinds.messages_undo = result.keybinds.messages_revert
|
||||
}
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
if (result.keybinds?.messages_revert && !result.keybinds.messages_undo) {
|
||||
result.keybinds.messages_undo = result.keybinds.messages_revert
|
||||
}
|
||||
if (result.keybinds?.switch_mode && !result.keybinds.switch_agent) {
|
||||
result.keybinds.switch_agent = result.keybinds.switch_mode
|
||||
}
|
||||
if (result.keybinds?.switch_mode_reverse && !result.keybinds.switch_agent_reverse) {
|
||||
result.keybinds.switch_agent_reverse = result.keybinds.switch_mode_reverse
|
||||
}
|
||||
if (result.keybinds?.switch_agent && !result.keybinds.agent_cycle) {
|
||||
result.keybinds.agent_cycle = result.keybinds.switch_agent
|
||||
}
|
||||
if (result.keybinds?.switch_agent_reverse && !result.keybinds.agent_cycle_reverse) {
|
||||
result.keybinds.agent_cycle_reverse = result.keybinds.switch_agent_reverse
|
||||
}
|
||||
|
||||
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
|
||||
|
||||
return {
|
||||
config: result,
|
||||
@@ -181,7 +165,7 @@ export namespace Config {
|
||||
{
|
||||
cwd: dir,
|
||||
},
|
||||
)
|
||||
).catch(() => {})
|
||||
}
|
||||
|
||||
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
|
||||
@@ -401,17 +385,15 @@ export namespace Config {
|
||||
.optional()
|
||||
.default("ctrl+x")
|
||||
.describe("Leader key for keybind combinations"),
|
||||
app_help: z.string().optional().default("<leader>h").describe("Show help dialog"),
|
||||
app_exit: z.string().optional().default("ctrl+c,<leader>q").describe("Exit the application"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
project_init: z.string().optional().default("<leader>i").describe("Create/update AGENTS.md"),
|
||||
tool_details: z.string().optional().default("<leader>d").describe("Toggle tool details"),
|
||||
thinking_blocks: z
|
||||
app_exit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("<leader>b")
|
||||
.describe("Toggle thinking blocks"),
|
||||
.default("ctrl+c,ctrl+d,<leader>q")
|
||||
.describe("Exit the application"),
|
||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
||||
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
|
||||
status_view: z.string().optional().default("<leader>s").describe("View status"),
|
||||
session_export: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -424,29 +406,23 @@ export namespace Config {
|
||||
.optional()
|
||||
.default("<leader>g")
|
||||
.describe("Show session timeline"),
|
||||
session_share: z.string().optional().default("<leader>s").describe("Share current session"),
|
||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
||||
session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
|
||||
session_interrupt: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("escape")
|
||||
.describe("Interrupt current session"),
|
||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
||||
session_child_cycle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+right")
|
||||
.describe("Cycle to next child session"),
|
||||
session_child_cycle_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+left")
|
||||
.describe("Cycle to previous child session"),
|
||||
messages_page_up: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("pgup")
|
||||
.default("pageup")
|
||||
.describe("Scroll messages up by one page"),
|
||||
messages_page_down: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("pgdown")
|
||||
.default("pagedown")
|
||||
.describe("Scroll messages down by one page"),
|
||||
messages_half_page_up: z
|
||||
.string()
|
||||
@@ -458,26 +434,37 @@ export namespace Config {
|
||||
.optional()
|
||||
.default("ctrl+alt+d")
|
||||
.describe("Scroll messages down by half page"),
|
||||
messages_first: z.string().optional().default("ctrl+g").describe("Navigate to first message"),
|
||||
messages_first: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+g,home")
|
||||
.describe("Navigate to first message"),
|
||||
messages_last: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("ctrl+alt+g")
|
||||
.default("ctrl+alt+g,end")
|
||||
.describe("Navigate to last message"),
|
||||
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
||||
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
|
||||
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
|
||||
messages_toggle_conceal: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("<leader>h")
|
||||
.describe("Toggle code block concealment in messages"),
|
||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
||||
model_cycle_recent: z.string().optional().default("f2").describe("Next recent model"),
|
||||
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
|
||||
model_cycle_recent_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+f2")
|
||||
.describe("Previous recent model"),
|
||||
.describe("Previous recently used model"),
|
||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
||||
input_forward_delete: z.string().optional().default("ctrl+d").describe("Forward delete"),
|
||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
||||
input_submit: z.string().optional().default("enter").describe("Submit input"),
|
||||
input_newline: z
|
||||
@@ -485,59 +472,8 @@ export namespace Config {
|
||||
.optional()
|
||||
.default("shift+enter,ctrl+j")
|
||||
.describe("Insert newline in input"),
|
||||
// Deprecated commands
|
||||
switch_mode: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated use agent_cycle. Next mode"),
|
||||
switch_mode_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated use agent_cycle_reverse. Previous mode"),
|
||||
switch_agent: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("tab")
|
||||
.describe("@deprecated use agent_cycle. Next agent"),
|
||||
switch_agent_reverse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("shift+tab")
|
||||
.describe("@deprecated use agent_cycle_reverse. Previous agent"),
|
||||
file_list: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated Currently not available. List files"),
|
||||
file_close: z.string().optional().default("none").describe("@deprecated Close file"),
|
||||
file_search: z.string().optional().default("none").describe("@deprecated Search file"),
|
||||
file_diff_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated Split/unified diff"),
|
||||
messages_previous: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated Navigate to previous message"),
|
||||
messages_next: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated Navigate to next message"),
|
||||
messages_layout_toggle: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated Toggle layout"),
|
||||
messages_revert: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("none")
|
||||
.describe("@deprecated use messages_undo. Revert message"),
|
||||
history_previous: z.string().optional().default("up").describe("Previous history item"),
|
||||
history_next: z.string().optional().default("down").describe("Previous history item"),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
@@ -820,7 +756,10 @@ export namespace Config {
|
||||
const errMsg = `bad file reference: "${match}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{ path: configFilepath, message: errMsg + ` ${resolvedPath} does not exist` },
|
||||
{
|
||||
path: configFilepath,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
@@ -874,7 +813,10 @@ export namespace Config {
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
export const JsonError = NamedError.create(
|
||||
"ConfigJsonError",
|
||||
|
||||
@@ -284,7 +284,9 @@ export namespace File {
|
||||
}
|
||||
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
|
||||
const nodes: Node[] = []
|
||||
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
|
||||
for (const entry of await fs.promises.readdir(resolved, {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (exclude.includes(entry.name)) continue
|
||||
const fullPath = path.join(resolved, entry.name)
|
||||
const relativePath = path.relative(Instance.directory, fullPath)
|
||||
|
||||
@@ -367,6 +367,7 @@ export namespace Ripgrep {
|
||||
args.push(`--max-count=${input.limit}`)
|
||||
}
|
||||
|
||||
args.push("--")
|
||||
args.push(input.pattern)
|
||||
|
||||
const command = args.join(" ")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Bus } from "../bus"
|
||||
import { File } from "../file"
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
|
||||
import * as Formatter from "./formatter"
|
||||
import { Config } from "../config/config"
|
||||
@@ -11,6 +12,17 @@ import { Instance } from "../project/instance"
|
||||
export namespace Format {
|
||||
const log = Log.create({ service: "format" })
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
extensions: z.string().array(),
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
ref: "FormatterStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
const state = Instance.state(async () => {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const cfg = await Config.get()
|
||||
@@ -62,6 +74,20 @@ export namespace Format {
|
||||
return result
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
const s = await state()
|
||||
const result: Status[] = []
|
||||
for (const formatter of Object.values(s.formatters)) {
|
||||
const enabled = await isEnabled(formatter)
|
||||
result.push({
|
||||
name: formatter.name,
|
||||
extensions: formatter.extensions,
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function init() {
|
||||
log.info("init")
|
||||
Bus.subscribe(File.Event.Edited, async (payload) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
const app = "opencode"
|
||||
|
||||
@@ -11,6 +12,7 @@ const state = path.join(xdgState!, app)
|
||||
|
||||
export namespace Global {
|
||||
export const Path = {
|
||||
home: os.homedir(),
|
||||
data,
|
||||
bin: path.join(data, "bin"),
|
||||
log: path.join(data, "log"),
|
||||
@@ -38,7 +40,12 @@ if (version !== CACHE_VERSION) {
|
||||
try {
|
||||
const contents = await fs.readdir(Global.Path.cache)
|
||||
await Promise.all(
|
||||
contents.map((item) => fs.rm(path.join(Global.Path.cache, item), { recursive: true, force: true })),
|
||||
contents.map((item) =>
|
||||
fs.rm(path.join(Global.Path.cache, item), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
} catch (e) {}
|
||||
await Bun.file(path.join(Global.Path.cache, "version")).write(CACHE_VERSION)
|
||||
|
||||
@@ -12,13 +12,14 @@ import { Installation } from "./installation"
|
||||
import { NamedError } from "./util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { TuiCommand } from "./cli/cmd/tui"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
import { McpCommand } from "./cli/cmd/mcp"
|
||||
import { GithubCommand } from "./cli/cmd/github"
|
||||
import { ExportCommand } from "./cli/cmd/export"
|
||||
import { AttachCommand } from "./cli/cmd/attach"
|
||||
import { AttachCommand } from "./cli/cmd/tui/attach"
|
||||
import { TuiThreadCommand } from "./cli/cmd/tui/thread"
|
||||
import { TuiSpawnCommand } from "./cli/cmd/tui/spawn"
|
||||
import { AcpCommand } from "./cli/cmd/acp"
|
||||
import { EOL } from "os"
|
||||
|
||||
@@ -37,6 +38,7 @@ process.on("uncaughtException", (e) => {
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
.help("help", "show help")
|
||||
.alias("help", "h")
|
||||
.version("version", "show version number", Installation.VERSION)
|
||||
.alias("version", "v")
|
||||
.option("print-logs", {
|
||||
@@ -69,7 +71,8 @@ const cli = yargs(hideBin(process.argv))
|
||||
.usage("\n" + UI.logo())
|
||||
.command(AcpCommand)
|
||||
.command(McpCommand)
|
||||
.command(TuiCommand)
|
||||
.command(TuiThreadCommand)
|
||||
.command(TuiSpawnCommand)
|
||||
.command(AttachCommand)
|
||||
.command(RunCommand)
|
||||
.command(GenerateCommand)
|
||||
@@ -138,5 +141,5 @@ try {
|
||||
// Most notably, some docker-container-based MCP servers don't handle such signals unless
|
||||
// run using `docker run --init`.
|
||||
// Explicitly exit to avoid any hanging subprocesses.
|
||||
process.exit();
|
||||
process.exit()
|
||||
}
|
||||
|
||||
@@ -139,7 +139,10 @@ export namespace LSPClient {
|
||||
if (version !== undefined) {
|
||||
const next = version + 1
|
||||
files[input.path] = next
|
||||
log.info("textDocument/didChange", { path: input.path, version: next })
|
||||
log.info("textDocument/didChange", {
|
||||
path: input.path,
|
||||
version: next,
|
||||
})
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
|
||||
@@ -6,10 +6,15 @@ import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
import { spawn } from "child_process"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
export const Event = {
|
||||
Updated: Bus.event("lsp.updated", z.object({})),
|
||||
}
|
||||
|
||||
export const Range = z
|
||||
.object({
|
||||
start: z.object({
|
||||
@@ -109,6 +114,33 @@ export namespace LSP {
|
||||
return state()
|
||||
}
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
root: z.string(),
|
||||
status: z.union([z.literal("connected"), z.literal("error")]),
|
||||
})
|
||||
.meta({
|
||||
ref: "LSPStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export async function status() {
|
||||
return state().then((x) => {
|
||||
const result: Status[] = []
|
||||
for (const client of x.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: x.servers[client.serverID].id,
|
||||
root: path.relative(Instance.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
const extension = path.parse(file).ext || file
|
||||
@@ -147,12 +179,15 @@ export namespace LSP {
|
||||
}).catch((err) => {
|
||||
s.broken.add(root + server.id)
|
||||
handle.process.kill()
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, {
|
||||
error: err,
|
||||
})
|
||||
return undefined
|
||||
})
|
||||
if (!client) continue
|
||||
s.clients.push(client)
|
||||
result.push(client)
|
||||
Bus.publish(Event.Updated, {})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -467,7 +467,7 @@ export namespace LSPServer {
|
||||
return
|
||||
}
|
||||
|
||||
const release = await releaseResponse.json()
|
||||
const release = (await releaseResponse.json()) as any
|
||||
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
@@ -660,7 +660,7 @@ export namespace LSPServer {
|
||||
return
|
||||
}
|
||||
|
||||
const release = await releaseResponse.json()
|
||||
const release = (await releaseResponse.json()) as any
|
||||
|
||||
const platform = process.platform
|
||||
let assetName = ""
|
||||
|
||||
@@ -5,9 +5,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "../util/error"
|
||||
import z from "zod"
|
||||
import { Session } from "../session"
|
||||
import { Bus } from "../bus"
|
||||
import z from "zod/v4"
|
||||
import { Instance } from "../project/instance"
|
||||
import { withTimeout } from "@/util/timeout"
|
||||
|
||||
@@ -21,27 +19,61 @@ export namespace MCP {
|
||||
}),
|
||||
)
|
||||
|
||||
type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
|
||||
export const Status = z
|
||||
.discriminatedUnion("status", [
|
||||
z
|
||||
.object({
|
||||
status: z.literal("connected"),
|
||||
})
|
||||
.meta({
|
||||
ref: "MCPStatusConnected",
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
status: z.literal("disabled"),
|
||||
})
|
||||
.meta({
|
||||
ref: "MCPStatusDisabled",
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
status: z.literal("failed"),
|
||||
error: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "MCPStatusFailed",
|
||||
}),
|
||||
])
|
||||
.meta({
|
||||
ref: "MCPStatus",
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const cfg = await Config.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const clients: {
|
||||
[name: string]: MCPClient
|
||||
} = {}
|
||||
const clients: Record<string, Client> = {}
|
||||
const status: Record<string, Status> = {}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(config).map(async ([key, mcp]) => {
|
||||
const result = await create(key, mcp).catch(() => undefined)
|
||||
if (!result) return
|
||||
clients[key] = result.client
|
||||
|
||||
status[key] = result.status
|
||||
|
||||
if (result.mcpClient) {
|
||||
clients[key] = result.mcpClient
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
status,
|
||||
clients,
|
||||
config,
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
@@ -53,17 +85,22 @@ export namespace MCP {
|
||||
const s = await state()
|
||||
const result = await create(name, mcp)
|
||||
if (!result) return
|
||||
s.clients[name] = result.client
|
||||
}
|
||||
|
||||
async function create(name: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { name })
|
||||
if (!result.mcpClient) {
|
||||
s.status[name] = result.status
|
||||
return
|
||||
}
|
||||
log.info("found", { name, type: mcp.type })
|
||||
s.clients[name] = result.mcpClient
|
||||
s.status[name] = result.status
|
||||
}
|
||||
|
||||
async function create(key: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
return
|
||||
}
|
||||
log.info("found", { key, type: mcp.type })
|
||||
let mcpClient: MCPClient | undefined
|
||||
let status: Status | undefined
|
||||
|
||||
if (mcp.type === "remote") {
|
||||
const transports = [
|
||||
@@ -86,44 +123,37 @@ export namespace MCP {
|
||||
]
|
||||
let lastError: Error | undefined
|
||||
for (const { name, transport } of transports) {
|
||||
const client = await experimental_createMCPClient({
|
||||
const result = await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport,
|
||||
}).catch((error) => {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
log.debug("transport connection failed", {
|
||||
name,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
.then((client) => {
|
||||
log.info("connected", { key, transport: name })
|
||||
mcpClient = client
|
||||
status = { status: "connected" }
|
||||
return true
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
log.debug("transport connection succeeded", { name, transport: name })
|
||||
mcpClient = client
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!mcpClient) {
|
||||
const errorMessage = lastError
|
||||
? `MCP server ${name} failed to connect: ${lastError.message}`
|
||||
: `MCP server ${name} failed to connect to ${mcp.url}`
|
||||
log.error("remote mcp connection failed", { name, url: mcp.url, error: lastError?.message })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
status = {
|
||||
status: "failed",
|
||||
error: lastError.message,
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (result) break
|
||||
}
|
||||
}
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
const client = await experimental_createMCPClient({
|
||||
await experimental_createMCPClient({
|
||||
name: "opencode",
|
||||
transport: new StdioClientTransport({
|
||||
stderr: "ignore",
|
||||
@@ -135,63 +165,61 @@ export namespace MCP {
|
||||
...mcp.environment,
|
||||
},
|
||||
}),
|
||||
}).catch((error) => {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? `MCP server ${name} failed to start: ${error.message}`
|
||||
: `MCP server ${name} failed to start`
|
||||
log.error("local mcp startup failed", {
|
||||
name,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
Bus.publish(Session.Event.Error, {
|
||||
error: {
|
||||
name: "UnknownError",
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
})
|
||||
return null
|
||||
})
|
||||
if (client) {
|
||||
mcpClient = client
|
||||
.then((client) => {
|
||||
mcpClient = client
|
||||
status = {
|
||||
status: "connected",
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
log.error("local mcp startup failed", {
|
||||
key,
|
||||
command: mcp.command,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
status = {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
status = {
|
||||
status: "failed",
|
||||
error: "Unknown error",
|
||||
}
|
||||
}
|
||||
|
||||
if (!mcpClient) {
|
||||
log.warn("mcp client not initialized", { name })
|
||||
return
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => { })
|
||||
const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch(() => {})
|
||||
if (!result) {
|
||||
log.warn("mcp client verification failed, dropping client", { name })
|
||||
return
|
||||
await mcpClient.close()
|
||||
status = {
|
||||
status: "failed",
|
||||
error: "Failed to get tools",
|
||||
}
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
client: mcpClient,
|
||||
mcpClient,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return state().then((state) => {
|
||||
const result: Record<string, "connected" | "failed" | "disabled"> = {}
|
||||
for (const [key, client] of Object.entries(state.config)) {
|
||||
if (client.enabled === false) {
|
||||
result[key] = "disabled"
|
||||
continue
|
||||
}
|
||||
if (state.clients[key]) {
|
||||
result[key] = "connected"
|
||||
continue
|
||||
}
|
||||
result[key] = "failed"
|
||||
}
|
||||
return result
|
||||
})
|
||||
return state().then((state) => state.status)
|
||||
}
|
||||
|
||||
export async function clients() {
|
||||
|
||||
@@ -41,7 +41,11 @@ export namespace Permission {
|
||||
Updated: Bus.event("permission.updated", Info),
|
||||
Replied: Bus.event(
|
||||
"permission.replied",
|
||||
z.object({ sessionID: z.string(), permissionID: z.string(), response: z.string() }),
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
permissionID: z.string(),
|
||||
response: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -141,16 +145,16 @@ export namespace Permission {
|
||||
const match = pending[input.sessionID]?.[input.permissionID]
|
||||
if (!match) return
|
||||
delete pending[input.sessionID][input.permissionID]
|
||||
if (input.response === "reject") {
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
|
||||
return
|
||||
}
|
||||
match.resolve()
|
||||
Bus.publish(Event.Replied, {
|
||||
sessionID: input.sessionID,
|
||||
permissionID: input.permissionID,
|
||||
response: input.response,
|
||||
})
|
||||
if (input.response === "reject") {
|
||||
match.reject(new RejectedError(input.sessionID, input.permissionID, match.info.callID, match.info.metadata))
|
||||
return
|
||||
}
|
||||
match.resolve()
|
||||
if (input.response === "always") {
|
||||
approved[input.sessionID] = approved[input.sessionID] || {}
|
||||
const approveKeys = toKeys(match.info.pattern, match.info.type)
|
||||
|
||||
@@ -14,6 +14,7 @@ export namespace Plugin {
|
||||
const state = Instance.state(async () => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
// @ts-ignore - fetch type incompatibility
|
||||
fetch: async (...args) => Server.App().fetch(...args),
|
||||
})
|
||||
const config = await Config.get()
|
||||
|
||||
@@ -5,6 +5,10 @@ import { LSP } from "../lsp"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { File } from "../file"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Project } from "./project"
|
||||
import { Bus } from "../bus"
|
||||
import { Command } from "../command"
|
||||
import { Instance } from "./instance"
|
||||
|
||||
export async function InstanceBootstrap() {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_NO_BOOTSTRAP) return
|
||||
@@ -14,4 +18,10 @@ export async function InstanceBootstrap() {
|
||||
await LSP.init()
|
||||
FileWatcher.init()
|
||||
File.init()
|
||||
|
||||
Bus.subscribe(Command.Event.Executed, async (payload) => {
|
||||
if (payload.properties.name === Command.Default.INIT) {
|
||||
await Project.setInitialized(Instance.project.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Log } from "@/util/log"
|
||||
import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
@@ -42,6 +43,15 @@ export const Instance = {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
async dispose() {
|
||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||
await State.dispose(Instance.directory)
|
||||
},
|
||||
async disposeAll() {
|
||||
for (const [_key, value] of cache) {
|
||||
await context.provide(value, async () => {
|
||||
await Instance.dispose()
|
||||
})
|
||||
}
|
||||
cache.clear()
|
||||
},
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user