Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2284a4e6df | ||
|
|
ad852d9186 | ||
|
|
8a9b4245b4 | ||
|
|
76ac1ccb6b | ||
|
|
e71bc8c0b0 | ||
|
|
a5301e2ab7 | ||
|
|
8eac72341f | ||
|
|
bd139b4bd6 | ||
|
|
508578bf17 | ||
|
|
607d8aafb7 | ||
|
|
5843eca7d6 | ||
|
|
ff3b68bd36 | ||
|
|
474b6fd3d1 | ||
|
|
6145b197f3 | ||
|
|
918eff9233 | ||
|
|
987e444828 | ||
|
|
99633cb299 | ||
|
|
f822331eb8 | ||
|
|
0f053769db | ||
|
|
ceeaf494c4 | ||
|
|
126d887e57 | ||
|
|
e5cfc24d6b | ||
|
|
7f8d659737 | ||
|
|
4b061653f2 | ||
|
|
eeed89f985 | ||
|
|
8ab533b616 | ||
|
|
09a399d8d6 | ||
|
|
b75575884a | ||
|
|
5688c9fd61 | ||
|
|
08a075df61 | ||
|
|
a2e8737114 | ||
|
|
776a394b02 | ||
|
|
5788b33fdf | ||
|
|
0f270c3da4 | ||
|
|
376019e347 | ||
|
|
44b773a6f6 | ||
|
|
df97774f7f | ||
|
|
eeff62a912 | ||
|
|
3fc6c42f5f | ||
|
|
967d8238be | ||
|
|
bff7518a24 | ||
|
|
8eab677094 | ||
|
|
db57e7023a | ||
|
|
ede4e467db | ||
|
|
aa1c560e5e | ||
|
|
3aca9e5fa5 | ||
|
|
9e96d83164 | ||
|
|
4275907df6 | ||
|
|
6097d6af86 | ||
|
|
09d2febe27 | ||
|
|
2c5c1ecb5e | ||
|
|
99e2112807 | ||
|
|
4b6575999d | ||
|
|
1a9ee3080c | ||
|
|
f4d61be8bd | ||
|
|
8b40e38cd7 | ||
|
|
7396d495ee | ||
|
|
f9b5ce180a | ||
|
|
12ee9d51c3 | ||
|
|
2730e0c9cd | ||
|
|
d6c81d6e14 | ||
|
|
e8ac0b663b | ||
|
|
2806f240ea | ||
|
|
9898fbe8ef | ||
|
|
1bd8e61719 | ||
|
|
b6c07cb1b8 | ||
|
|
83f23817ce | ||
|
|
23b1d7c755 | ||
|
|
ef033db9c2 | ||
|
|
e30d5d8e34 | ||
|
|
698cfb57a1 | ||
|
|
27e72f2652 | ||
|
|
10eed6ee7e | ||
|
|
59b87f60f7 | ||
|
|
d10089a0bf | ||
|
|
ae7286c031 | ||
|
|
52048c327d | ||
|
|
4e1a9b6216 | ||
|
|
1995be3599 | ||
|
|
86b9b7b15a | ||
|
|
a90f2b9723 | ||
|
|
c73a17f8af | ||
|
|
48898fda07 | ||
|
|
c573732ddb | ||
|
|
ab2a6c45a3 |
@@ -6,6 +6,8 @@ You are an expert technical documentation writer
|
||||
|
||||
You are not verbose
|
||||
|
||||
Use a relaxed and friendly tone
|
||||
|
||||
The title of the page should be a word or a 2-3 word phrase
|
||||
|
||||
The description should be one short line, should not start with "The", should
|
||||
|
||||
+4
-4
@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
|
||||
|
||||
Caveats:
|
||||
|
||||
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
|
||||
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
|
||||
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
|
||||
is triggered.
|
||||
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
|
||||
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
|
||||
- If `spawn` does not work for you, you can debug the server separately:
|
||||
- Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
|
||||
then attach TUI with `opencode attach http://localhost:4096`
|
||||
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
|
||||
|
||||
Other tips and tricks:
|
||||
|
||||
|
||||
@@ -179,3 +179,4 @@
|
||||
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
|
||||
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
|
||||
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
|
||||
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -51,8 +51,8 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "0.3.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -77,7 +77,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -105,7 +105,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -132,7 +132,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -156,7 +156,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -180,7 +180,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
@@ -207,7 +207,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -236,7 +236,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -346,7 +346,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -366,7 +366,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.1",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -377,7 +377,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -390,7 +390,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -403,8 +403,8 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -425,7 +425,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -436,7 +436,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -451,8 +451,8 @@
|
||||
"js-base64": "3.7.7",
|
||||
"lang-map": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "15.0.12",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"rehype-autolink-headings": "7.1.0",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
@@ -502,6 +502,8 @@
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"remeda": "2.26.0",
|
||||
"shiki": "3.20.0",
|
||||
"solid-js": "1.9.10",
|
||||
@@ -2865,7 +2867,7 @@
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"marked": ["marked@16.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg=="],
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
|
||||
|
||||
@@ -4113,8 +4115,6 @@
|
||||
|
||||
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="],
|
||||
|
||||
"@opencode-ai/web/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1766314097,
|
||||
"narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
|
||||
"lastModified": 1766532406,
|
||||
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
|
||||
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -103,6 +103,7 @@ const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS3"),
|
||||
new sst.Secret("ZEN_MODELS4"),
|
||||
new sst.Secret("ZEN_MODELS5"),
|
||||
new sst.Secret("ZEN_MODELS6"),
|
||||
]
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { domain } from "./stage"
|
||||
|
||||
new sst.cloudflare.StaticSite("Desktop", {
|
||||
domain: "desktop." + domain,
|
||||
path: "packages/app/public",
|
||||
})
|
||||
@@ -7,7 +7,51 @@ RED='\033[0;31m'
|
||||
ORANGE='\033[38;5;214m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
OpenCode Installer
|
||||
|
||||
Usage: install.sh [options]
|
||||
|
||||
Options:
|
||||
-h, --help Display this help message
|
||||
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||
EOF
|
||||
}
|
||||
|
||||
requested_version=${VERSION:-}
|
||||
no_modify_path=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
-v|--version)
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
requested_version="$2"
|
||||
shift 2
|
||||
else
|
||||
echo -e "${RED}Error: --version requires a version argument${NC}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--no-modify-path)
|
||||
no_modify_path=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
raw_os=$(uname -s)
|
||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||
@@ -304,42 +348,42 @@ case $current_shell in
|
||||
;;
|
||||
esac
|
||||
|
||||
config_file=""
|
||||
for file in $config_files; do
|
||||
if [[ -f $file ]]; then
|
||||
config_file=$file
|
||||
break
|
||||
if [[ "$no_modify_path" != "true" ]]; then
|
||||
config_file=""
|
||||
for file in $config_files; do
|
||||
if [[ -f $file ]]; then
|
||||
config_file=$file
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $config_file ]]; then
|
||||
print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||
;;
|
||||
zsh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
bash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
ash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
sh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z $config_file ]]; then
|
||||
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
|
||||
case $current_shell in
|
||||
fish)
|
||||
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
|
||||
;;
|
||||
zsh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
bash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
ash)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
sh)
|
||||
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
*)
|
||||
export PATH=$INSTALL_DIR:$PATH
|
||||
print_message warning "Manually add the directory to $config_file (or similar):"
|
||||
print_message info " export PATH=$INSTALL_DIR:\$PATH"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg="
|
||||
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME="
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@
|
||||
"hono-openapi": "1.1.2",
|
||||
"fuzzysort": "3.1.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "17.0.1",
|
||||
"marked-shiki": "1.2.1",
|
||||
"typescript": "5.8.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<meta property="og:image" content="/social-share.png" />
|
||||
<meta property="twitter:image" content="/social-share.png" />
|
||||
</head>
|
||||
<body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<script>
|
||||
;(function () {
|
||||
const savedTheme = localStorage.getItem("theme") || "oc-1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -49,8 +49,8 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "0.3.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/assets/*.js
|
||||
Content-Type: application/javascript
|
||||
|
||||
/assets/*.mjs
|
||||
Content-Type: application/javascript
|
||||
|
||||
/assets/*.css
|
||||
Content-Type: text/css
|
||||
|
||||
/*.js
|
||||
Content-Type: application/javascript
|
||||
|
||||
/*.mjs
|
||||
Content-Type: application/javascript
|
||||
|
||||
/*.css
|
||||
Content-Type: text/css
|
||||
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
|
||||
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
|
||||
export default function FileTree(props: {
|
||||
@@ -57,14 +57,14 @@ export default function FileTree(props: {
|
||||
"text-text-muted/40": p.node.ignored,
|
||||
"text-text-muted/80": !p.node.ignored,
|
||||
// "!text-text": local.file.active()?.path === p.node.path,
|
||||
"!text-primary": local.file.changed(p.node.path),
|
||||
// "!text-primary": local.file.changed(p.node.path),
|
||||
}}
|
||||
>
|
||||
{p.node.name}
|
||||
</span>
|
||||
<Show when={local.file.changed(p.node.path)}>
|
||||
<span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
|
||||
</Show>
|
||||
{/* <Show when={local.file.changed(p.node.path)}> */}
|
||||
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
|
||||
{/* </Show> */}
|
||||
</Dynamic>
|
||||
)
|
||||
|
||||
|
||||
@@ -109,35 +109,37 @@ export function Header(props: {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<Tooltip
|
||||
class="hidden md:block shrink-0"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle review</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.review.opened() ? "layout-right-full" : "layout-right"}
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name="layout-right-partial"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
size="small"
|
||||
name={layout.review.opened() ? "layout-right" : "layout-right-full"}
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={currentSession()?.summary?.files}>
|
||||
<Tooltip
|
||||
class="hidden md:block shrink-0"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Toggle review</span>
|
||||
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
|
||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right" : "layout-left"}
|
||||
size="small"
|
||||
class="group-hover/review-toggle:hidden"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
|
||||
size="small"
|
||||
class="hidden group-hover/review-toggle:inline-block"
|
||||
/>
|
||||
<Icon
|
||||
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
|
||||
size="small"
|
||||
class="hidden group-active/review-toggle:inline-block"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip
|
||||
class="hidden md:block shrink-0"
|
||||
value={
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
type Part,
|
||||
type Config,
|
||||
type Path,
|
||||
type File,
|
||||
type FileNode,
|
||||
type Project,
|
||||
type FileDiff,
|
||||
type Todo,
|
||||
@@ -50,8 +48,6 @@ type State = {
|
||||
part: {
|
||||
[messageID: string]: Part[]
|
||||
}
|
||||
node: FileNode[]
|
||||
changes: File[]
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
@@ -92,8 +88,6 @@ function createGlobalSync() {
|
||||
limit: 5,
|
||||
message: {},
|
||||
part: {},
|
||||
node: [],
|
||||
changes: [],
|
||||
})
|
||||
children[directory] = createStore(globalStore.children[directory])
|
||||
bootstrapInstance(directory)
|
||||
@@ -136,15 +130,25 @@ function createGlobalSync() {
|
||||
})
|
||||
const load = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
session: () => loadSessions(directory),
|
||||
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
|
||||
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
|
||||
}
|
||||
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
.then(() => setStore("ready", true))
|
||||
@@ -295,7 +299,10 @@ function createGlobalSync() {
|
||||
})
|
||||
|
||||
async function bootstrap() {
|
||||
const health = await globalSDK.client.global.health().then((x) => x.data)
|
||||
const health = await globalSDK.client.global
|
||||
.health()
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!health?.healthy) {
|
||||
setGlobalStore(
|
||||
"error",
|
||||
@@ -320,7 +327,16 @@ function createGlobalSync() {
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.provider.list().then((x) => {
|
||||
setGlobalStore("provider", x.data ?? {})
|
||||
const data = x.data!
|
||||
setGlobalStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { DateTime } from "luxon"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
|
||||
export type LocalFile = FileNode &
|
||||
Partial<{
|
||||
@@ -276,11 +277,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const [store, setStore] = createStore<{
|
||||
node: Record<string, LocalFile>
|
||||
}>({
|
||||
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
|
||||
})
|
||||
|
||||
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
|
||||
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
|
||||
|
||||
// createEffect((prev: FileStatus[]) => {
|
||||
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
|
||||
@@ -308,16 +309,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
// return sync.data.changes
|
||||
// }, sync.data.changes)
|
||||
|
||||
const changed = (path: string) => {
|
||||
const node = store.node[path]
|
||||
if (node?.status) return true
|
||||
const set = changeset()
|
||||
if (set.has(path)) return true
|
||||
for (const p of set) {
|
||||
if (p.startsWith(path ? path + "/" : "")) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// const changed = (path: string) => {
|
||||
// const node = store.node[path]
|
||||
// if (node?.status) return true
|
||||
// const set = changeset()
|
||||
// if (set.has(path)) return true
|
||||
// for (const p of set) {
|
||||
// if (p.startsWith(path ? path + "/" : "")) return true
|
||||
// }
|
||||
// return false
|
||||
// }
|
||||
|
||||
// const resetNode = (path: string) => {
|
||||
// setStore("node", path, {
|
||||
@@ -336,17 +337,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const load = async (path: string) => {
|
||||
const relativePath = relative(path)
|
||||
await sdk.client.file.read({ path: relativePath }).then((x) => {
|
||||
if (!store.node[relativePath]) return
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
await sdk.client.file
|
||||
.read({ path: relativePath })
|
||||
.then((x) => {
|
||||
if (!store.node[relativePath]) return
|
||||
setStore(
|
||||
"node",
|
||||
relativePath,
|
||||
produce((draft) => {
|
||||
draft.loaded = true
|
||||
draft.content = x.data
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: "Failed to load file",
|
||||
description: e.message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const fetch = async (path: string) => {
|
||||
@@ -466,8 +476,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
setChangeIndex(path: string, index: number | undefined) {
|
||||
setStore("node", path, "selectedChange", index)
|
||||
},
|
||||
changes,
|
||||
changed,
|
||||
// changes,
|
||||
// changed,
|
||||
children(path: string) {
|
||||
return Object.values(store.node).filter(
|
||||
(x) =>
|
||||
|
||||
@@ -5,6 +5,9 @@ export type Platform = {
|
||||
/** Platform discriminator */
|
||||
platform: "web" | "tauri"
|
||||
|
||||
/** App version */
|
||||
version?: string
|
||||
|
||||
/** Open a URL in the default browser */
|
||||
openLink(url: string): void
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { render } from "solid-js/web"
|
||||
import { App } from "@/app"
|
||||
import { Platform, PlatformProvider } from "@/context/platform"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
openLink(url: string) {
|
||||
window.open(url, "_blank")
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Logo } from "@opencode-ai/ui/logo"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Component } from "solid-js"
|
||||
import { Component, Show } from "solid-js"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
|
||||
@@ -138,16 +138,21 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
|
||||
<Button size="large" onClick={platform.restart}>
|
||||
Restart
|
||||
</Button>
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Please report this error to the OpenCode team
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center text-text-interactive-base gap-1"
|
||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
>
|
||||
<div>on Discord</div>
|
||||
<Icon name="discord" class="text-text-interactive-base" />
|
||||
</button>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
Please report this error to the OpenCode team
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center text-text-interactive-base gap-1"
|
||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
>
|
||||
<div>on Discord</div>
|
||||
<Icon name="discord" class="text-text-interactive-base" />
|
||||
</button>
|
||||
</div>
|
||||
<Show when={platform.version}>
|
||||
<p class="text-xs text-text-weak">Version: {platform.version}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -116,42 +116,31 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
})
|
||||
|
||||
function flattenSessions(sessions: Session[]): Session[] {
|
||||
const childrenMap = new Map<string, Session[]>()
|
||||
for (const session of sessions) {
|
||||
if (session.parentID) {
|
||||
const children = childrenMap.get(session.parentID) ?? []
|
||||
children.push(session)
|
||||
childrenMap.set(session.parentID, children)
|
||||
}
|
||||
}
|
||||
const result: Session[] = []
|
||||
function visit(session: Session) {
|
||||
result.push(session)
|
||||
for (const child of childrenMap.get(session.id) ?? []) {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (!session.parentID) visit(session)
|
||||
}
|
||||
return result
|
||||
function sortSessions(a: Session, b: Session) {
|
||||
const now = Date.now()
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
const aUpdated = a.time.updated ?? a.time.created
|
||||
const bUpdated = b.time.updated ?? b.time.created
|
||||
const aRecent = aUpdated > oneMinuteAgo
|
||||
const bRecent = bUpdated > oneMinuteAgo
|
||||
if (aRecent && bRecent) return a.id.localeCompare(b.id)
|
||||
if (aRecent && !bRecent) return -1
|
||||
if (!aRecent && bRecent) return 1
|
||||
return bUpdated - aUpdated
|
||||
}
|
||||
|
||||
function scrollToSession(sessionId: string) {
|
||||
if (!scrollContainerRef) return
|
||||
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "center", behavior: "smooth" })
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
}
|
||||
}
|
||||
|
||||
function projectSessions(directory: string) {
|
||||
if (!directory) return []
|
||||
const sessions = globalSync
|
||||
.child(directory)[0]
|
||||
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
|
||||
return flattenSessions(sessions ?? [])
|
||||
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
|
||||
return (sessions ?? []).filter((s) => !s.parentID)
|
||||
}
|
||||
|
||||
const currentSessions = createMemo(() => {
|
||||
@@ -331,8 +320,11 @@ export default function Layout(props: ParentProps) {
|
||||
createEffect(() => {
|
||||
if (!params.dir || !params.id) return
|
||||
const directory = base64Decode(params.dir)
|
||||
setStore("lastSession", directory, params.id)
|
||||
notification.session.markViewed(params.id)
|
||||
const id = params.id
|
||||
setStore("lastSession", directory, id)
|
||||
notification.session.markViewed(id)
|
||||
layout.projects.expand(directory)
|
||||
requestAnimationFrame(() => scrollToSession(id))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -455,13 +447,9 @@ export default function Layout(props: ParentProps) {
|
||||
session: Session
|
||||
slug: string
|
||||
project: LocalProject
|
||||
depth?: number
|
||||
childrenMap: Map<string, Session[]>
|
||||
mobile?: boolean
|
||||
}): JSX.Element => {
|
||||
const notification = useNotification()
|
||||
const depth = props.depth ?? 0
|
||||
const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
|
||||
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
|
||||
const notifications = createMemo(() => notification.session.unseen(props.session.id))
|
||||
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
|
||||
@@ -476,7 +464,7 @@ export default function Layout(props: ParentProps) {
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
|
||||
style={{ "padding-left": `${16 + depth * 12}px` }}
|
||||
style={{ "padding-left": "16px" }}
|
||||
>
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
<A
|
||||
@@ -530,18 +518,6 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<For each={children()}>
|
||||
{(child) => (
|
||||
<SessionItem
|
||||
session={child}
|
||||
slug={props.slug}
|
||||
project={props.project}
|
||||
depth={depth + 1}
|
||||
childrenMap={props.childrenMap}
|
||||
mobile={props.mobile}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -552,21 +528,8 @@ export default function Layout(props: ParentProps) {
|
||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||
const name = createMemo(() => getFilename(props.project.worktree))
|
||||
const [store, setProjectStore] = globalSync.child(props.project.worktree)
|
||||
const sessions = createMemo(() =>
|
||||
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
|
||||
)
|
||||
const sessions = createMemo(() => store.session.toSorted(sortSessions))
|
||||
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
|
||||
const childSessionsByParent = createMemo(() => {
|
||||
const map = new Map<string, Session[]>()
|
||||
for (const session of sessions()) {
|
||||
if (session.parentID) {
|
||||
const children = map.get(session.parentID) ?? []
|
||||
children.push(session)
|
||||
map.set(session.parentID, children)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
|
||||
const loadMoreSessions = async () => {
|
||||
setProjectStore("limit", (limit) => limit + 5)
|
||||
@@ -624,13 +587,7 @@ export default function Layout(props: ParentProps) {
|
||||
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
|
||||
<For each={rootSessions()}>
|
||||
{(session) => (
|
||||
<SessionItem
|
||||
session={session}
|
||||
slug={slug()}
|
||||
project={props.project}
|
||||
childrenMap={childSessionsByParent()}
|
||||
mobile={props.mobile}
|
||||
/>
|
||||
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
|
||||
)}
|
||||
</For>
|
||||
<Show when={rootSessions().length === 0}>
|
||||
@@ -752,7 +709,9 @@ export default function Layout(props: ParentProps) {
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<div
|
||||
ref={sidebarProps.mobile ? undefined : scrollContainerRef}
|
||||
ref={(el) => {
|
||||
if (!sidebarProps.mobile) scrollContainerRef = el
|
||||
}}
|
||||
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
|
||||
|
||||
@@ -226,8 +226,7 @@ export default function Page() {
|
||||
title: "Toggle review",
|
||||
description: "Show or hide the review panel",
|
||||
category: "View",
|
||||
keybind: "mod+b",
|
||||
slash: "review",
|
||||
keybind: "mod+shift+r",
|
||||
onSelect: () => layout.review.toggle(),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
@@ -20,5 +21,6 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "package.json"],
|
||||
"exclude": ["dist", "ts-dist"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,14 +17,16 @@ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[
|
||||
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
|
||||
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
|
||||
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
|
||||
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
|
||||
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
||||
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
||||
if (!value3) throw new Error("ZEN_MODELS3 not found")
|
||||
if (!value4) throw new Error("ZEN_MODELS4 not found")
|
||||
if (!value5) throw new Error("ZEN_MODELS5 not found")
|
||||
if (!value6) throw new Error("ZEN_MODELS6 not found")
|
||||
|
||||
// validate value
|
||||
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5))
|
||||
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
|
||||
@@ -32,3 +34,4 @@ await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS5 ${value5} --stage ${stage}`
|
||||
await $`bun sst secret set ZEN_MODELS6 ${value6} --stage ${stage}`
|
||||
|
||||
@@ -17,14 +17,15 @@ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[
|
||||
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
|
||||
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
|
||||
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
|
||||
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
|
||||
if (!value1) throw new Error("ZEN_MODELS1 not found")
|
||||
if (!value2) throw new Error("ZEN_MODELS2 not found")
|
||||
if (!value3) throw new Error("ZEN_MODELS3 not found")
|
||||
if (!value4) throw new Error("ZEN_MODELS4 not found")
|
||||
if (!value5) throw new Error("ZEN_MODELS5 not found")
|
||||
|
||||
if (!value6) throw new Error("ZEN_MODELS6 not found")
|
||||
// validate value
|
||||
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5))
|
||||
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
|
||||
|
||||
// update the secret
|
||||
await $`bun sst secret set ZEN_MODELS1 ${value1}`
|
||||
@@ -32,3 +33,4 @@ await $`bun sst secret set ZEN_MODELS2 ${value2}`
|
||||
await $`bun sst secret set ZEN_MODELS3 ${value3}`
|
||||
await $`bun sst secret set ZEN_MODELS4 ${value4}`
|
||||
await $`bun sst secret set ZEN_MODELS5 ${value5}`
|
||||
await $`bun sst secret set ZEN_MODELS6 ${value6}`
|
||||
|
||||
@@ -15,16 +15,20 @@ const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=
|
||||
const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
|
||||
const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
|
||||
const oldValue5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
|
||||
const oldValue6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
|
||||
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
|
||||
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
|
||||
if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
|
||||
if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
|
||||
if (!oldValue5) throw new Error("ZEN_MODELS5 not found")
|
||||
if (!oldValue6) throw new Error("ZEN_MODELS6 not found")
|
||||
|
||||
// store the prettified json to a temp file
|
||||
const filename = `models-${Date.now()}.json`
|
||||
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
|
||||
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5), null, 2))
|
||||
await tempFile.write(
|
||||
JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5 + oldValue6), null, 2),
|
||||
)
|
||||
console.log("tempFile", tempFile.name)
|
||||
|
||||
// open temp file in vim and read the file on close
|
||||
@@ -33,15 +37,17 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
||||
ZenData.validate(JSON.parse(newValue))
|
||||
|
||||
// update the secret
|
||||
const chunk = Math.ceil(newValue.length / 5)
|
||||
const chunk = Math.ceil(newValue.length / 6)
|
||||
const newValue1 = newValue.slice(0, chunk)
|
||||
const newValue2 = newValue.slice(chunk, chunk * 2)
|
||||
const newValue3 = newValue.slice(chunk * 2, chunk * 3)
|
||||
const newValue4 = newValue.slice(chunk * 3, chunk * 4)
|
||||
const newValue5 = newValue.slice(chunk * 4)
|
||||
const newValue5 = newValue.slice(chunk * 4, chunk * 5)
|
||||
const newValue6 = newValue.slice(chunk * 5)
|
||||
|
||||
await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
|
||||
await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
|
||||
await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
|
||||
await $`bun sst secret set ZEN_MODELS4 ${newValue4}`
|
||||
await $`bun sst secret set ZEN_MODELS5 ${newValue5}`
|
||||
await $`bun sst secret set ZEN_MODELS6 ${newValue6}`
|
||||
|
||||
@@ -72,7 +72,8 @@ export namespace ZenData {
|
||||
Resource.ZEN_MODELS2.value +
|
||||
Resource.ZEN_MODELS3.value +
|
||||
Resource.ZEN_MODELS4.value +
|
||||
Resource.ZEN_MODELS5.value,
|
||||
Resource.ZEN_MODELS5.value +
|
||||
Resource.ZEN_MODELS6.value,
|
||||
)
|
||||
return ModelsSchema.parse(json)
|
||||
})
|
||||
|
||||
Vendored
+8
-4
@@ -42,10 +42,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -98,6 +94,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -118,6 +118,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+8
-4
@@ -42,10 +42,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -98,6 +94,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -118,6 +118,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
+8
-4
@@ -42,10 +42,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -98,6 +94,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -118,6 +118,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo -b",
|
||||
|
||||
@@ -13,6 +13,7 @@ import { createMenu } from "./menu"
|
||||
import { check, Update } from "@tauri-apps/plugin-updater"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
@@ -25,6 +26,7 @@ let update: Update | null = null
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "tauri",
|
||||
version: pkg.version,
|
||||
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
const result = await open({
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
@@ -16,5 +17,5 @@
|
||||
"outDir": "node_modules/.ts-dist"
|
||||
},
|
||||
"references": [{ "path": "../app" }],
|
||||
"include": ["src"]
|
||||
"include": ["src", "package.json"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -13,7 +13,7 @@ export default createHandler(() => (
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
{assets}
|
||||
</head>
|
||||
<body class="antialiased overscroll-none select-none text-12-regular">
|
||||
<body class="antialiased overscroll-none text-12-regular">
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
|
||||
@@ -25,7 +25,7 @@ import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/
|
||||
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
|
||||
import { clientOnly } from "@solidjs/start"
|
||||
import { type IconName } from "@opencode-ai/ui/icons/provider"
|
||||
import { Meta } from "@solidjs/meta"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { Base64 } from "js-base64"
|
||||
|
||||
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
|
||||
@@ -202,6 +202,9 @@ export default function () {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={info().title}>
|
||||
<Title>{info().title} | OpenCode</Title>
|
||||
</Show>
|
||||
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
|
||||
<Meta property="og:image" content={ogImage()} />
|
||||
<Meta name="twitter:image" content={ogImage()} />
|
||||
|
||||
Vendored
+8
-4
@@ -42,10 +42,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -98,6 +94,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -118,6 +118,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.193"
|
||||
version = "1.0.199"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/sst/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Vendored
+8
-4
@@ -42,10 +42,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -98,6 +94,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -118,6 +118,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
}
|
||||
}
|
||||
// cloudflare
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -7,7 +7,12 @@ import { graphql } from "@octokit/graphql"
|
||||
import * as core from "@actions/core"
|
||||
import * as github from "@actions/github"
|
||||
import type { Context } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent, PullRequestEvent } from "@octokit/webhooks-types"
|
||||
import type {
|
||||
IssueCommentEvent,
|
||||
PullRequestReviewCommentEvent,
|
||||
WorkflowRunEvent,
|
||||
PullRequestEvent,
|
||||
} from "@octokit/webhooks-types"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
@@ -401,10 +406,12 @@ export const GithubRunCommand = cmd({
|
||||
const oidcBaseUrl = normalizeOidcBaseUrl()
|
||||
const { owner, repo } = context.repo
|
||||
// For schedule events, payload has no issue/comment data
|
||||
const payload = isCommentEvent
|
||||
? (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
|
||||
: undefined
|
||||
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
|
||||
const payload = context.payload as
|
||||
| IssueCommentEvent
|
||||
| PullRequestReviewCommentEvent
|
||||
| WorkflowRunEvent
|
||||
| PullRequestEvent
|
||||
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
|
||||
const actor = isScheduleEvent ? undefined : context.actor
|
||||
|
||||
const issueId = isScheduleEvent
|
||||
@@ -423,7 +430,9 @@ export const GithubRunCommand = cmd({
|
||||
let shareId: string | undefined
|
||||
let exitCode = 0
|
||||
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
|
||||
const triggerCommentId = payload?.comment.id
|
||||
const triggerCommentId = isCommentEvent
|
||||
? (payload as IssueCommentEvent | PullRequestReviewCommentEvent).comment.id
|
||||
: undefined
|
||||
const useGithubToken = normalizeUseGithubToken()
|
||||
const commentType = isCommentEvent
|
||||
? context.eventName === "pull_request_review_comment"
|
||||
@@ -619,7 +628,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
|
||||
function isIssueCommentEvent(
|
||||
event: IssueCommentEvent | PullRequestReviewCommentEvent,
|
||||
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent,
|
||||
): event is IssueCommentEvent {
|
||||
return "issue" in event
|
||||
}
|
||||
@@ -664,7 +673,7 @@ export const GithubRunCommand = cmd({
|
||||
if (!isCommentEvent) {
|
||||
return "Review this pull request"
|
||||
}
|
||||
const body = payload!.comment.body.trim()
|
||||
const body = (payload as IssueCommentEvent | PullRequestReviewCommentEvent).comment.body.trim()
|
||||
const bodyLower = body.toLowerCase()
|
||||
if (mentions.some((m) => bodyLower === m)) {
|
||||
if (reviewContext) {
|
||||
|
||||
@@ -31,15 +31,15 @@ export const ImportCommand = cmd({
|
||||
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
|
||||
|
||||
if (isUrl) {
|
||||
const urlMatch = args.file.match(/https?:\/\/opencode\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (!urlMatch) {
|
||||
process.stdout.write(`Invalid URL format. Expected: https://opencode.ai/s/<slug>`)
|
||||
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/<slug>`)
|
||||
process.stdout.write(EOL)
|
||||
return
|
||||
}
|
||||
|
||||
const slug = urlMatch[1]
|
||||
const response = await fetch(`https://api.opencode.ai/share_data?id=${slug}`)
|
||||
const response = await fetch(`https://opncd.ai/api/share/${slug}`)
|
||||
|
||||
if (!response.ok) {
|
||||
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
|
||||
|
||||
@@ -65,7 +65,7 @@ export const PrCommand = cmd({
|
||||
|
||||
// Check for opencode session link in PR body
|
||||
if (prInfo && prInfo.body) {
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opencode\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
|
||||
if (sessionMatch) {
|
||||
const sessionUrl = sessionMatch[0]
|
||||
UI.println(`Found opencode session: ${sessionUrl}`)
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 { PromptStashProvider } from "./component/prompt/stash"
|
||||
import { DialogAlert } from "./ui/dialog-alert"
|
||||
import { ToastProvider, useToast } from "./ui/toast"
|
||||
import { ExitProvider, useExit } from "./context/exit"
|
||||
@@ -120,15 +121,17 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
|
||||
<ThemeProvider mode={mode}>
|
||||
<LocalProvider>
|
||||
<KeybindProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<CommandProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</CommandProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</KeybindProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
@@ -176,6 +179,20 @@ function App() {
|
||||
const exit = useExit()
|
||||
const promptRef = usePromptRef()
|
||||
|
||||
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
||||
renderer.console.onCopySelection = async (text: string) => {
|
||||
if (!text || text.length === 0) return
|
||||
|
||||
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 writeOut is not in type definitions
|
||||
renderer.writeOut(finalOsc52)
|
||||
await Clipboard.copy(text)
|
||||
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
|
||||
.catch(toast.error)
|
||||
renderer.clearSelection()
|
||||
}
|
||||
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
|
||||
|
||||
createEffect(() => {
|
||||
@@ -444,7 +461,7 @@ function App() {
|
||||
{
|
||||
title: "Toggle console",
|
||||
category: "System",
|
||||
value: "app.fps",
|
||||
value: "app.console",
|
||||
onSelect: (dialog) => {
|
||||
renderer.console.toggle()
|
||||
dialog.clear()
|
||||
|
||||
@@ -45,6 +45,7 @@ function init() {
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (suspended()) return
|
||||
if (dialog.stack.length > 0) return
|
||||
for (const option of options()) {
|
||||
if (option.keybind && keybind.match(option.keybind, evt)) {
|
||||
evt.preventDefault()
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -19,6 +20,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
|
||||
const [query, setQuery] = createSignal("")
|
||||
|
||||
const connected = useConnected()
|
||||
const providers = createDialogProviderOptions()
|
||||
@@ -30,7 +32,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const query = ref()?.filter
|
||||
const q = query()
|
||||
const favorites = showExtra() ? local.model.favorite() : []
|
||||
const recents = local.model.recent()
|
||||
|
||||
@@ -42,148 +44,151 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
.slice(0, 5)
|
||||
: []
|
||||
|
||||
const favoriteOptions = !query
|
||||
? favorites.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: {
|
||||
const favoriteOptions = favorites.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: "Favorites",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
title: model.name ?? item.modelID,
|
||||
description: provider.name,
|
||||
category: "Favorites",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const recentOptions = recentList.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",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const providerOptions = pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
||||
map(([model, info]) => {
|
||||
const value = {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
}
|
||||
return {
|
||||
value,
|
||||
title: info.name ?? model,
|
||||
description: favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
modelID: model,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
const recentOptions = !query
|
||||
? recentList.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",
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model.id,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
})
|
||||
: []
|
||||
|
||||
return [
|
||||
...favoriteOptions,
|
||||
...recentOptions,
|
||||
...pipe(
|
||||
sync.data.provider,
|
||||
sortBy(
|
||||
(provider) => provider.id !== "opencode",
|
||||
(provider) => provider.name,
|
||||
),
|
||||
flatMap((provider) =>
|
||||
pipe(
|
||||
provider.models,
|
||||
entries(),
|
||||
filter(([_, info]) => info.status !== "deprecated"),
|
||||
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
|
||||
map(([model, info]) => {
|
||||
const value = {
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
}
|
||||
return {
|
||||
value,
|
||||
title: info.name ?? model,
|
||||
description: favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
dialog.clear()
|
||||
local.model.set(
|
||||
{
|
||||
providerID: provider.id,
|
||||
modelID: model,
|
||||
},
|
||||
{ recent: true },
|
||||
)
|
||||
},
|
||||
}
|
||||
}),
|
||||
filter((x) => {
|
||||
if (query) return true
|
||||
const value = x.value
|
||||
const inFavorites = favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inFavorites) return false
|
||||
const inRecents = recents.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inRecents) return false
|
||||
return true
|
||||
}),
|
||||
sortBy(
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
}
|
||||
}),
|
||||
filter((x) => {
|
||||
const value = x.value
|
||||
const inFavorites = favorites.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inFavorites) return false
|
||||
const inRecents = recents.some(
|
||||
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
|
||||
)
|
||||
if (inRecents) return false
|
||||
return true
|
||||
}),
|
||||
sortBy(
|
||||
(x) => x.footer !== "Free",
|
||||
(x) => x.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
...(!connected()
|
||||
? pipe(
|
||||
providers(),
|
||||
map((option) => {
|
||||
return {
|
||||
...option,
|
||||
category: "Popular providers",
|
||||
}
|
||||
}),
|
||||
take(6),
|
||||
)
|
||||
: []),
|
||||
]
|
||||
)
|
||||
|
||||
const popularProviders = !connected()
|
||||
? pipe(
|
||||
providers(),
|
||||
map((option) => {
|
||||
return {
|
||||
...option,
|
||||
category: "Popular providers",
|
||||
}
|
||||
}),
|
||||
take(6),
|
||||
)
|
||||
: []
|
||||
|
||||
// Apply fuzzy filtering to each section separately, maintaining section order
|
||||
if (q) {
|
||||
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||
const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj)
|
||||
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
|
||||
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
|
||||
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
|
||||
}
|
||||
|
||||
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
|
||||
})
|
||||
|
||||
const provider = createMemo(() =>
|
||||
@@ -215,6 +220,8 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
},
|
||||
]}
|
||||
ref={setRef}
|
||||
onFilter={setQuery}
|
||||
skipFilter={true}
|
||||
title={title()}
|
||||
current={local.model.current()}
|
||||
options={options()}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { createMemo, createSignal } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { usePromptStash, type StashEntry } from "./prompt/stash"
|
||||
|
||||
function getRelativeTime(timestamp: number): string {
|
||||
const now = Date.now()
|
||||
const diff = now - timestamp
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (seconds < 60) return "just now"
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
if (days < 7) return `${days}d ago`
|
||||
return Locale.datetime(timestamp)
|
||||
}
|
||||
|
||||
function getStashPreview(input: string, maxLength: number = 50): string {
|
||||
const firstLine = input.split("\n")[0].trim()
|
||||
return Locale.truncate(firstLine, maxLength)
|
||||
}
|
||||
|
||||
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
|
||||
const dialog = useDialog()
|
||||
const stash = usePromptStash()
|
||||
const { theme } = useTheme()
|
||||
|
||||
const [toDelete, setToDelete] = createSignal<number>()
|
||||
|
||||
const options = createMemo(() => {
|
||||
const entries = stash.list()
|
||||
// Show most recent first
|
||||
return entries
|
||||
.map((entry, index) => {
|
||||
const isDeleting = toDelete() === index
|
||||
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
|
||||
return {
|
||||
title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input),
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: index,
|
||||
description: getRelativeTime(entry.timestamp),
|
||||
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
|
||||
}
|
||||
})
|
||||
.toReversed()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Stash"
|
||||
options={options()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
const entries = stash.list()
|
||||
const entry = entries[option.value]
|
||||
if (entry) {
|
||||
stash.remove(option.value)
|
||||
props.onSelect(entry)
|
||||
}
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: Keybind.parse("ctrl+d")[0],
|
||||
title: "delete",
|
||||
onTrigger: (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
stash.remove(option.value)
|
||||
setToDelete(undefined)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createMemo, createSignal, For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { TIPS } from "./tips"
|
||||
import { EmptyBorder } from "./border"
|
||||
|
||||
type TipPart = { text: string; highlight: boolean }
|
||||
|
||||
function parseTip(tip: string): TipPart[] {
|
||||
const parts: TipPart[] = []
|
||||
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(tip)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
|
||||
}
|
||||
parts.push({ text: match[1], highlight: true })
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < tip.length) {
|
||||
parts.push({ text: tip.slice(lastIndex), highlight: false })
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const [tipIndex, setTipIndex] = createSignal(Math.floor(Math.random() * TIPS.length))
|
||||
|
||||
export function randomizeTip() {
|
||||
setTipIndex(Math.floor(Math.random() * TIPS.length))
|
||||
}
|
||||
|
||||
const BOX_WIDTH = 42
|
||||
const TITLE = " 🅘 Did you know? "
|
||||
|
||||
export function DidYouKnow() {
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
|
||||
const tipParts = createMemo(() => parseTip(TIPS[tipIndex()]))
|
||||
|
||||
const dashes = createMemo(() => {
|
||||
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
|
||||
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
|
||||
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
|
||||
<text>
|
||||
<span style={{ fg: theme.border }}>╭─</span>
|
||||
<span style={{ fg: theme.text }}>{TITLE}</span>
|
||||
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}╮</span>
|
||||
</text>
|
||||
<box
|
||||
border={["left", "right", "bottom"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
vertical: "│",
|
||||
}}
|
||||
>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text>
|
||||
<For each={tipParts()}>
|
||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||
</For>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>{keybind.print("tips_toggle")}</span>
|
||||
<span style={{ fg: theme.textMuted }}> hide tips</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import { createStore, produce } from "solid-js/store"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Keybind } from "@/util/keybind"
|
||||
import { usePromptHistory, type PromptInfo } from "./history"
|
||||
import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -118,11 +120,9 @@ export function Prompt(props: PromptProps) {
|
||||
const toast = useToast()
|
||||
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
|
||||
const history = usePromptHistory()
|
||||
const stash = usePromptStash()
|
||||
const command = useCommandDialog()
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tall = createMemo(() => dimensions().height > 40)
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const { theme, syntax } = useTheme()
|
||||
|
||||
function promptModelWarning() {
|
||||
@@ -151,6 +151,37 @@ export function Prompt(props: PromptProps) {
|
||||
const pasteStyleId = syntax().getStyleId("extmark.paste")!
|
||||
let promptPartTypeId: number
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
input.insertText(evt.properties.text)
|
||||
setTimeout(() => {
|
||||
input.getLayoutNode().markDirty()
|
||||
input.gotoBufferEnd()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
interrupt: number
|
||||
placeholder: number
|
||||
}>({
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
mode: "normal",
|
||||
extmarkToPartIndex: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
|
||||
command.register(() => {
|
||||
return [
|
||||
{
|
||||
@@ -311,37 +342,6 @@ export function Prompt(props: PromptProps) {
|
||||
]
|
||||
})
|
||||
|
||||
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
|
||||
input.insertText(evt.properties.text)
|
||||
setTimeout(() => {
|
||||
input.getLayoutNode().markDirty()
|
||||
input.gotoBufferEnd()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (props.disabled) input.cursorColor = theme.backgroundElement
|
||||
if (!props.disabled) input.cursorColor = theme.text
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{
|
||||
prompt: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
extmarkToPartIndex: Map<number, number>
|
||||
interrupt: number
|
||||
placeholder: number
|
||||
}>({
|
||||
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
|
||||
prompt: {
|
||||
input: "",
|
||||
parts: [],
|
||||
},
|
||||
mode: "normal",
|
||||
extmarkToPartIndex: new Map(),
|
||||
interrupt: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.focus()
|
||||
})
|
||||
@@ -428,6 +428,61 @@ export function Prompt(props: PromptProps) {
|
||||
)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: "Stash prompt",
|
||||
value: "prompt.stash",
|
||||
category: "Prompt",
|
||||
disabled: !store.prompt.input,
|
||||
onSelect: (dialog) => {
|
||||
if (!store.prompt.input) return
|
||||
stash.push({
|
||||
input: store.prompt.input,
|
||||
parts: store.prompt.parts,
|
||||
})
|
||||
input.extmarks.clear()
|
||||
input.clear()
|
||||
setStore("prompt", { input: "", parts: [] })
|
||||
setStore("extmarkToPartIndex", new Map())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash pop",
|
||||
value: "prompt.stash.pop",
|
||||
category: "Prompt",
|
||||
disabled: stash.list().length === 0,
|
||||
onSelect: (dialog) => {
|
||||
const entry = stash.pop()
|
||||
if (entry) {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Stash list",
|
||||
value: "prompt.stash.list",
|
||||
category: "Prompt",
|
||||
disabled: stash.list().length === 0,
|
||||
onSelect: (dialog) => {
|
||||
dialog.replace(() => (
|
||||
<DialogStash
|
||||
onSelect={(entry) => {
|
||||
input.setText(entry.input)
|
||||
setStore("prompt", { input: entry.input, parts: entry.parts })
|
||||
restoreExtmarksFromParts(entry.parts)
|
||||
input.gotoBufferEnd()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
props.ref?.({
|
||||
get focused() {
|
||||
return input.focused
|
||||
@@ -872,6 +927,13 @@ export function Prompt(props: PromptProps) {
|
||||
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
|
||||
return
|
||||
}
|
||||
|
||||
// Force layout update and render for the pasted content
|
||||
setTimeout(() => {
|
||||
input.getLayoutNode().markDirty()
|
||||
input.gotoBufferEnd()
|
||||
renderer.requestRender()
|
||||
}, 0)
|
||||
}}
|
||||
ref={(r: TextareaRenderable) => {
|
||||
input = r
|
||||
@@ -884,21 +946,19 @@ export function Prompt(props: PromptProps) {
|
||||
cursorColor={theme.text}
|
||||
syntaxStyle={syntax()}
|
||||
/>
|
||||
<Show when={tall()}>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
@@ -928,123 +988,101 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<Switch>
|
||||
<Match when={status().type !== "idle"}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
|
||||
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
<Show when={status().type !== "idle"} fallback={<text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexGrow={1}
|
||||
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
|
||||
>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
|
||||
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
{(() => {
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
return s
|
||||
})
|
||||
const message = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
|
||||
return "gemini is way too hot right now"
|
||||
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
|
||||
return r.message
|
||||
})
|
||||
const isTruncated = createMemo(() => {
|
||||
const r = retry()
|
||||
if (!r) return false
|
||||
return r.message.length > 120
|
||||
})
|
||||
const [seconds, setSeconds] = createSignal(0)
|
||||
onMount(() => {
|
||||
const timer = setInterval(() => {
|
||||
const next = retry()?.next
|
||||
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
onCleanup(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
}
|
||||
})
|
||||
const handleMessageClick = () => {
|
||||
const r = retry()
|
||||
if (!r) return
|
||||
if (isTruncated()) {
|
||||
DialogAlert.show(dialog, "Retry Error", r.message)
|
||||
}
|
||||
}
|
||||
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
const retryText = () => {
|
||||
const r = retry()
|
||||
if (!r) return ""
|
||||
const baseMessage = message()
|
||||
const truncatedHint = isTruncated() ? " (click to expand)" : ""
|
||||
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
|
||||
return baseMessage + truncatedHint + retryInfo
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
return (
|
||||
<Show when={retry()}>
|
||||
<box onMouseUp={handleMessageClick}>
|
||||
<text fg={theme.error}>{retryText()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
})()}
|
||||
</box>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={!tall()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={highlight()}>
|
||||
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
|
||||
</text>
|
||||
<Show when={store.mode === "normal"}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
|
||||
{local.model.parsed().model}
|
||||
</text>
|
||||
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
<box gap={2} flexDirection="row" marginLeft="auto">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<Show when={wide()}>
|
||||
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
|
||||
esc{" "}
|
||||
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={status().type !== "retry"}>
|
||||
<box gap={2} flexDirection="row">
|
||||
<Switch>
|
||||
<Match when={store.mode === "normal"}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!wide()}>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("sidebar_toggle")} <span style={{ fg: theme.textMuted }}>sidebar</span>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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, writeFile } from "fs/promises"
|
||||
import type { PromptInfo } from "./history"
|
||||
|
||||
export type StashEntry = {
|
||||
input: string
|
||||
parts: PromptInfo["parts"]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const MAX_STASH_ENTRIES = 50
|
||||
|
||||
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
|
||||
name: "PromptStash",
|
||||
init: () => {
|
||||
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
|
||||
onMount(async () => {
|
||||
const text = await stashFile.text().catch(() => "")
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter((line): line is StashEntry => line !== null)
|
||||
.slice(-MAX_STASH_ENTRIES)
|
||||
|
||||
setStore("entries", lines)
|
||||
|
||||
// Rewrite file with only valid entries to self-heal corruption
|
||||
if (lines.length > 0) {
|
||||
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
entries: [] as StashEntry[],
|
||||
})
|
||||
|
||||
return {
|
||||
list() {
|
||||
return store.entries
|
||||
},
|
||||
push(entry: Omit<StashEntry, "timestamp">) {
|
||||
const stash = clone({ ...entry, timestamp: Date.now() })
|
||||
let trimmed = false
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.push(stash)
|
||||
if (draft.entries.length > MAX_STASH_ENTRIES) {
|
||||
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
|
||||
trimmed = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
if (trimmed) {
|
||||
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
|
||||
},
|
||||
pop() {
|
||||
if (store.entries.length === 0) return undefined
|
||||
const entry = store.entries[store.entries.length - 1]
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.pop()
|
||||
}),
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
return entry
|
||||
},
|
||||
remove(index: number) {
|
||||
if (index < 0 || index >= store.entries.length) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.entries.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
const content =
|
||||
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
|
||||
writeFile(stashFile.name!, content).catch(() => {})
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
export const TIPS = [
|
||||
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
|
||||
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
|
||||
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
|
||||
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
|
||||
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
|
||||
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
|
||||
"Drag and drop images into the terminal to add them as context for your prompts.",
|
||||
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
|
||||
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
|
||||
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
|
||||
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
|
||||
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
|
||||
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
|
||||
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
|
||||
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
|
||||
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
|
||||
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
|
||||
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
|
||||
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
|
||||
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
|
||||
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
|
||||
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
|
||||
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
|
||||
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
|
||||
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
|
||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
|
||||
"Configure {highlight}model{/highlight} in config to set your default model.",
|
||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
|
||||
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
|
||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
|
||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
|
||||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
|
||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
|
||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
|
||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
|
||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
|
||||
"Define custom formatter commands with file extensions in config.",
|
||||
"OpenCode uses LSP servers for intelligent code analysis.",
|
||||
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
|
||||
"Tool definitions can invoke scripts written in Python, Go, etc.",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
|
||||
"Use plugins to send OS notifications when sessions complete.",
|
||||
"Create a plugin to prevent OpenCode from reading sensitive files.",
|
||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
|
||||
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
|
||||
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
|
||||
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
|
||||
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
|
||||
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
|
||||
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
|
||||
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
|
||||
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
|
||||
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
|
||||
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
|
||||
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
|
||||
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
|
||||
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
|
||||
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
|
||||
"Themes support dark/light variants for both modes.",
|
||||
"Reference ANSI colors 0-255 in custom themes.",
|
||||
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
|
||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
|
||||
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
|
||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
|
||||
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
|
||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
|
||||
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
|
||||
"Override global tool settings per agent configuration.",
|
||||
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
|
||||
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
|
||||
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
|
||||
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
|
||||
"Permission {highlight}external_directory{/highlight} protects files outside project.",
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
|
||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
|
||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
|
||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
|
||||
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session.",
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
|
||||
]
|
||||
@@ -13,16 +13,16 @@ export function TodoItem(props: TodoItemProps) {
|
||||
<text
|
||||
flexShrink={0}
|
||||
style={{
|
||||
fg: props.status === "in_progress" ? theme.success : theme.textMuted,
|
||||
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
|
||||
}}
|
||||
>
|
||||
[{props.status === "completed" ? "✓" : " "}]{" "}
|
||||
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
|
||||
</text>
|
||||
<text
|
||||
flexGrow={1}
|
||||
wrapMode="word"
|
||||
style={{
|
||||
fg: props.status === "in_progress" ? theme.success : theme.textMuted,
|
||||
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
|
||||
}}
|
||||
>
|
||||
{props.content}
|
||||
|
||||
@@ -2,23 +2,28 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { Logo } from "../component/logo"
|
||||
import { DidYouKnow, randomizeTip } from "../component/did-you-know"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
import { useArgs } from "../context/args"
|
||||
import { useDirectory } from "../context/directory"
|
||||
import { useRoute, useRouteData } from "@tui/context/route"
|
||||
import { useRouteData } from "@tui/context/route"
|
||||
import { usePromptRef } from "../context/prompt"
|
||||
import { Installation } from "@/installation"
|
||||
import { useKV } from "../context/kv"
|
||||
import { useCommandDialog } from "../component/dialog-command"
|
||||
|
||||
// TODO: what is the best way to do this?
|
||||
let once = false
|
||||
|
||||
export function Home() {
|
||||
const sync = useSync()
|
||||
const kv = useKV()
|
||||
const { theme } = useTheme()
|
||||
const route = useRouteData("home")
|
||||
const promptRef = usePromptRef()
|
||||
const command = useCommandDialog()
|
||||
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
|
||||
const mcpError = createMemo(() => {
|
||||
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
|
||||
@@ -28,6 +33,28 @@ export function Home() {
|
||||
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
|
||||
})
|
||||
|
||||
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
|
||||
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
|
||||
const showTips = createMemo(() => {
|
||||
return false
|
||||
// Don't show tips for first-time users
|
||||
if (isFirstTimeUser()) return false
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
value: "tips.toggle",
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
@@ -50,6 +77,7 @@ export function Home() {
|
||||
let prompt: PromptRef
|
||||
const args = useArgs()
|
||||
onMount(() => {
|
||||
randomizeTip()
|
||||
if (once) return
|
||||
if (route.initialPrompt) {
|
||||
prompt.set(route.initialPrompt)
|
||||
@@ -77,6 +105,11 @@ export function Home() {
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={!isFirstTimeUser()}>
|
||||
<Show when={showTips()}>
|
||||
<DidYouKnow />
|
||||
</Show>
|
||||
</Show>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
|
||||
@@ -1,140 +1,124 @@
|
||||
import { type Accessor, 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 { EmptyBorder } from "@tui/component/border"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { SplitBorder, EmptyBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useDirectory } from "../../context/directory"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session>; truncate?: boolean }) => {
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<text fg={theme.text} wrapMode={props.truncate ? "none" : undefined} flexShrink={props.truncate ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.context()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.context()} ({props.cost()})
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
const session = createMemo(() => sync.session.get(route.sessionID)!)
|
||||
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showShare = createMemo(() => shareEnabled() && !session()?.share?.url)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const tall = createMemo(() => dimensions().height > 40)
|
||||
|
||||
return (
|
||||
<box flexShrink={0}>
|
||||
<box
|
||||
height={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
{...SplitBorder}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: theme.backgroundPanel.a !== 0 ? "╻" : " ",
|
||||
}}
|
||||
flexShrink={0}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<box
|
||||
height={1}
|
||||
border={["top"]}
|
||||
borderColor={theme.backgroundPanel}
|
||||
customBorderChars={
|
||||
theme.backgroundPanel.a !== 0
|
||||
? {
|
||||
...EmptyBorder,
|
||||
horizontal: "▄",
|
||||
}
|
||||
: {
|
||||
...EmptyBorder,
|
||||
horizontal: " ",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
<box
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: "┃",
|
||||
bottomLeft: "╹",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
paddingTop={tall() ? 1 : 0}
|
||||
paddingBottom={tall() ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
paddingRight={1}
|
||||
flexShrink={0}
|
||||
flexGrow={1}
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
<box flexGrow={1} flexShrink={1} />
|
||||
<Show when={showShare()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
/share{" "}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Switch>
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
|
||||
</text>
|
||||
<box flexGrow={1} flexShrink={1} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<Show when={shareEnabled()}>
|
||||
<box flexDirection="row" justifyContent="space-between" gap={1}>
|
||||
<Title session={session} truncate={!tall()} />
|
||||
<Show when={showShare()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
/share{" "}
|
||||
</text>
|
||||
</Show>
|
||||
<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 fg={theme.text} wrapMode="word">
|
||||
/share <span style={{ fg: theme.textMuted }}>copy link</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
border={["left"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
vertical: theme.backgroundPanel.a !== 0 ? "╹" : " ",
|
||||
}}
|
||||
>
|
||||
<box
|
||||
height={1}
|
||||
border={["bottom"]}
|
||||
borderColor={theme.backgroundPanel}
|
||||
customBorderChars={
|
||||
theme.backgroundPanel.a !== 0
|
||||
? {
|
||||
...EmptyBorder,
|
||||
horizontal: "▀",
|
||||
}
|
||||
: {
|
||||
...EmptyBorder,
|
||||
horizontal: " ",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
ScrollBoxRenderable,
|
||||
addDefaultParsers,
|
||||
MacOSScrollAccel,
|
||||
RGBA,
|
||||
type ScrollAcceleration,
|
||||
} from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
@@ -131,15 +130,13 @@ export function Session() {
|
||||
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
|
||||
|
||||
const wide = createMemo(() => dimensions().width > 120)
|
||||
const tall = createMemo(() => dimensions().height > 40)
|
||||
const sidebarVisible = createMemo(() => {
|
||||
if (session()?.parentID) return false
|
||||
if (sidebar() === "show") return true
|
||||
if (sidebar() === "auto" && wide()) return true
|
||||
return false
|
||||
})
|
||||
const sidebarOverlay = createMemo(() => sidebarVisible() && !wide())
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? 42 : 0) - 4)
|
||||
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
|
||||
|
||||
const scrollAcceleration = createMemo(() => {
|
||||
const tui = sync.data.config.tui
|
||||
@@ -965,7 +962,7 @@ export function Session() {
|
||||
<box flexDirection="row">
|
||||
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<Show when={session()}>
|
||||
<Show when={!sidebarVisible() || sidebarOverlay()}>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Header />
|
||||
</Show>
|
||||
<scrollbox
|
||||
@@ -1095,33 +1092,15 @@ export function Session() {
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</box>
|
||||
<Show when={(!sidebarVisible() || sidebarOverlay()) && tall()}>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Footer />
|
||||
</Show>
|
||||
</Show>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={sidebarVisible() && !sidebarOverlay()}>
|
||||
<Show when={sidebarVisible()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</Show>
|
||||
<Show when={sidebarOverlay()}>
|
||||
<box
|
||||
position="absolute"
|
||||
left={0}
|
||||
top={0}
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
||||
zIndex={100}
|
||||
flexDirection="row"
|
||||
justifyContent="flex-end"
|
||||
onMouseUp={() => setSidebar("hide")}
|
||||
>
|
||||
<box onMouseUp={(e) => e.stopPropagation()}>
|
||||
<Sidebar sessionID={route.sessionID} />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</context.Provider>
|
||||
)
|
||||
|
||||
@@ -73,7 +73,6 @@ export function Sidebar(props: { sessionID: string }) {
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface DialogSelectProps<T> {
|
||||
onMove?: (option: DialogSelectOption<T>) => void
|
||||
onFilter?: (query: string) => void
|
||||
onSelect?: (option: DialogSelectOption<T>) => void
|
||||
skipFilter?: boolean
|
||||
keybind?: {
|
||||
keybind: Keybind.Info
|
||||
title: string
|
||||
@@ -74,7 +75,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const result = pipe(
|
||||
props.options,
|
||||
filter((x) => x.disabled !== true),
|
||||
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
|
||||
(x) =>
|
||||
!needle || props.skipFilter ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
|
||||
)
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ Please analyze this codebase and create an AGENTS.md file containing:
|
||||
1. Build/lint/test commands - especially for running a single test
|
||||
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
||||
|
||||
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
|
||||
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long.
|
||||
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}
|
||||
|
||||
@@ -566,6 +566,7 @@ export namespace Config {
|
||||
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
|
||||
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
|
||||
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
||||
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Config } from "../config/config"
|
||||
import { Log } from "../util/log"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -15,6 +15,7 @@ import { withTimeout } from "@/util/timeout"
|
||||
import { McpOAuthProvider } from "./oauth-provider"
|
||||
import { McpOAuthCallback } from "./oauth-callback"
|
||||
import { McpAuth } from "./auth"
|
||||
import { BusEvent } from "../bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
@@ -22,6 +23,13 @@ import open from "open"
|
||||
export namespace MCP {
|
||||
const log = Log.create({ service: "mcp" })
|
||||
|
||||
export const ToolsChanged = BusEvent.define(
|
||||
"mcp.tools.changed",
|
||||
z.object({
|
||||
server: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const Failed = NamedError.create(
|
||||
"MCPFailed",
|
||||
z.object({
|
||||
@@ -76,6 +84,14 @@ export namespace MCP {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
// Register notification handlers for MCP client
|
||||
function registerNotificationHandlers(client: MCPClient, serverName: string) {
|
||||
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
||||
log.info("tools list changed notification received", { server: serverName })
|
||||
Bus.publish(ToolsChanged, { server: serverName })
|
||||
})
|
||||
}
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
@@ -236,6 +252,7 @@ export namespace MCP {
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await client.connect(transport)
|
||||
registerNotificationHandlers(client, key)
|
||||
mcpClient = client
|
||||
log.info("connected", { key, transport: name })
|
||||
status = { status: "connected" }
|
||||
@@ -308,6 +325,7 @@ export namespace MCP {
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await client.connect(transport)
|
||||
registerNotificationHandlers(client, key)
|
||||
mcpClient = client
|
||||
status = {
|
||||
status: "connected",
|
||||
|
||||
@@ -338,6 +338,45 @@ export namespace Provider {
|
||||
},
|
||||
}
|
||||
},
|
||||
"cloudflare-ai-gateway": async (input) => {
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||
|
||||
if (!accountId || !gateway) return { autoload: false }
|
||||
|
||||
// Get API token from env or auth prompt
|
||||
const apiToken = await (async () => {
|
||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN")
|
||||
if (envToken) return envToken
|
||||
const auth = await Auth.get(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})()
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.chat(modelID)
|
||||
},
|
||||
options: {
|
||||
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
|
||||
headers: {
|
||||
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
|
||||
// This enables Unified Billing where Cloudflare handles upstream provider auth
|
||||
...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
|
||||
"HTTP-Referer": "https://opencode.ai/",
|
||||
"X-Title": "opencode",
|
||||
},
|
||||
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
|
||||
// Sending Authorization header with invalid value causes auth errors
|
||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.delete("Authorization")
|
||||
return fetch(input, { ...init, headers })
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
cerebras: async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Storage } from "@/storage/storage"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { STATUS_CODES } from "http"
|
||||
import { iife } from "@/util/iife"
|
||||
import { type SystemError } from "bun"
|
||||
|
||||
export namespace MessageV2 {
|
||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||
@@ -31,6 +32,7 @@ export namespace MessageV2 {
|
||||
isRetryable: z.boolean(),
|
||||
responseHeaders: z.record(z.string(), z.string()).optional(),
|
||||
responseBody: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
}),
|
||||
)
|
||||
export type APIError = z.infer<typeof APIError.Schema>
|
||||
@@ -609,6 +611,19 @@ export namespace MessageV2 {
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case (e as SystemError)?.code === "ECONNRESET":
|
||||
return new MessageV2.APIError(
|
||||
{
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: (e as SystemError).code ?? "",
|
||||
syscall: (e as SystemError).syscall ?? "",
|
||||
message: (e as SystemError).message ?? "",
|
||||
},
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const message = iife(() => {
|
||||
let msg = e.message
|
||||
|
||||
@@ -59,3 +59,53 @@ describe("session.retry.delay", () => {
|
||||
expect(SessionRetry.delay(1, longError)).toBe(700000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("session.message-v2.fromError", () => {
|
||||
test.concurrent(
|
||||
"converts ECONNRESET socket errors to retryable APIError",
|
||||
async () => {
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
idleTimeout: 8,
|
||||
async fetch(req) {
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
async pull(controller) {
|
||||
controller.enqueue("Hello,")
|
||||
await Bun.sleep(10000)
|
||||
controller.enqueue(" World!")
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "Content-Type": "text/plain" } },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const error = await fetch(new URL("/", server.url.origin))
|
||||
.then((res) => res.text())
|
||||
.catch((e) => e)
|
||||
|
||||
const result = MessageV2.fromError(error, { providerID: "test" })
|
||||
|
||||
expect(MessageV2.APIError.isInstance(result)).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
|
||||
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
|
||||
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
|
||||
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
|
||||
test("ECONNRESET socket error is retryable", () => {
|
||||
const error = new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
|
||||
}).toObject() as MessageV2.APIError
|
||||
|
||||
const retryable = SessionRetry.retryable(error)
|
||||
expect(retryable).toBeDefined()
|
||||
expect(retryable).toBe("Connection reset by server")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
|
||||
@@ -131,6 +131,9 @@ export type ApiError = {
|
||||
[key: string]: string
|
||||
}
|
||||
responseBody?: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +592,13 @@ export type EventTuiToastShow = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventMcpToolsChanged = {
|
||||
type: "mcp.tools.changed"
|
||||
properties: {
|
||||
server: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventCommandExecuted = {
|
||||
type: "command.executed"
|
||||
properties: {
|
||||
@@ -755,6 +765,7 @@ export type Event =
|
||||
| EventTuiPromptAppend
|
||||
| EventTuiCommandExecute
|
||||
| EventTuiToastShow
|
||||
| EventMcpToolsChanged
|
||||
| EventCommandExecuted
|
||||
| EventSessionCreated
|
||||
| EventSessionUpdated
|
||||
@@ -1134,6 +1145,10 @@ export type KeybindsConfig = {
|
||||
* Toggle terminal title
|
||||
*/
|
||||
terminal_title_toggle?: string
|
||||
/**
|
||||
* Toggle tips on home screen
|
||||
*/
|
||||
tips_toggle?: string
|
||||
}
|
||||
|
||||
export type AgentConfig = {
|
||||
|
||||
@@ -5336,6 +5336,15 @@
|
||||
},
|
||||
"responseBody": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["message", "isRetryable"]
|
||||
@@ -6626,6 +6635,25 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.mcp.tools.changed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "mcp.tools.changed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["server"]
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.command.executed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -7123,6 +7151,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.tui.toast.show"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.mcp.tools.changed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.command.executed"
|
||||
},
|
||||
@@ -7647,6 +7678,11 @@
|
||||
"description": "Toggle terminal title",
|
||||
"default": "none",
|
||||
"type": "string"
|
||||
},
|
||||
"tips_toggle": {
|
||||
"description": "Toggle tips on home screen",
|
||||
"default": "<leader>h",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./*": "./src/components/*.tsx",
|
||||
@@ -44,8 +44,8 @@
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "16.2.0",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
|
||||
@@ -4,7 +4,6 @@ export const Favicon = () => {
|
||||
return (
|
||||
<>
|
||||
<Link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<Link rel="shortcut icon" href="/favicon.ico" />
|
||||
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<Link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
@@ -51,6 +51,24 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> [data-component="icon-button"] {
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus:not(:disabled),
|
||||
&:active:not(:disabled) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-hover);
|
||||
}
|
||||
|
||||
&:active:not(:disabled) [data-slot="icon-svg"] {
|
||||
color: var(--icon-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="list-scroll"] {
|
||||
|
||||
@@ -2,9 +2,18 @@ import { useMarked } from "../context/marked"
|
||||
import { ComponentProps, createResource, splitProps } from "solid-js"
|
||||
|
||||
function strip(text: string): string {
|
||||
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
|
||||
const match = text.match(wrappedRe)
|
||||
return match ? match[2] : text
|
||||
const trimmed = text.trim()
|
||||
const match = trimmed.match(/^<([A-Za-z]\w*)>/)
|
||||
if (!match) return text
|
||||
|
||||
const tagName = match[1]
|
||||
const closingTag = `</${tagName}>`
|
||||
if (trimmed.endsWith(closingTag)) {
|
||||
const content = trimmed.slice(match[0].length, -closingTag.length)
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
export function Markdown(
|
||||
|
||||
@@ -415,8 +415,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const data = useData()
|
||||
const part = props.part as TextPart
|
||||
const content = createMemo(() => (part.text ?? "").trim())
|
||||
const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
|
||||
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
|
||||
|
||||
return (
|
||||
<Show when={displayText()}>
|
||||
@@ -441,6 +440,9 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
||||
ToolRegistry.register({
|
||||
name: "read",
|
||||
render(props) {
|
||||
const args: string[] = []
|
||||
if (props.input.offset) args.push("offset=" + props.input.offset)
|
||||
if (props.input.limit) args.push("limit=" + props.input.limit)
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -448,6 +450,7 @@ ToolRegistry.register({
|
||||
trigger={{
|
||||
title: "Read",
|
||||
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
|
||||
args,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -60,6 +60,31 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function AssistantMessageItem(props: {
|
||||
message: AssistantMessage
|
||||
summary: string | undefined
|
||||
response: string | undefined
|
||||
lastTextPartId: string | undefined
|
||||
working: boolean
|
||||
}) {
|
||||
const data = useData()
|
||||
const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
|
||||
const lastTextPart = createMemo(() =>
|
||||
msgParts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
|
||||
const filteredParts = createMemo(() => {
|
||||
if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) {
|
||||
return msgParts().filter((p) => p?.id !== lastTextPart()?.id)
|
||||
}
|
||||
return msgParts()
|
||||
})
|
||||
|
||||
return <Message message={props.message} parts={filteredParts()} />
|
||||
}
|
||||
|
||||
export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
@@ -77,66 +102,68 @@ export function SessionTurn(
|
||||
const data = useData()
|
||||
const diffComponent = useDiffComponent()
|
||||
|
||||
const derived = createMemo(() => {
|
||||
const allMessages = data.store.message[props.sessionID] ?? []
|
||||
const userMessages = allMessages.filter((m) => m.role === "user").sort((a, b) => a.id.localeCompare(b.id))
|
||||
const lastUserMessage = userMessages.at(-1)
|
||||
const message = userMessages.find((m) => m.id === props.messageID)
|
||||
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? [])
|
||||
const userMessages = createMemo(() =>
|
||||
allMessages()
|
||||
.filter((m) => m.role === "user")
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
return {
|
||||
message: undefined,
|
||||
parts: [] as PartType[],
|
||||
assistantMessages: [] as AssistantMessage[],
|
||||
assistantParts: [] as PartType[],
|
||||
lastAssistantMessage: undefined as AssistantMessage | undefined,
|
||||
lastTextPart: undefined as PartType | undefined,
|
||||
error: undefined,
|
||||
hasSteps: false,
|
||||
isShellMode: false,
|
||||
rawStatus: undefined as string | undefined,
|
||||
isLastUserMessage: false,
|
||||
}
|
||||
}
|
||||
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID))
|
||||
const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id)
|
||||
|
||||
const parts = data.store.part[message.id] ?? []
|
||||
const assistantMessages = allMessages.filter(
|
||||
(m) => m.role === "assistant" && m.parentID === message.id,
|
||||
) as AssistantMessage[]
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return []
|
||||
return data.store.part[msg.id] ?? []
|
||||
})
|
||||
|
||||
const assistantParts: PartType[] = []
|
||||
for (const m of assistantMessages) {
|
||||
const assistantMessages = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return [] as AssistantMessage[]
|
||||
return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[]
|
||||
})
|
||||
|
||||
const assistantParts = createMemo(() => {
|
||||
const result: PartType[] = []
|
||||
for (const m of assistantMessages()) {
|
||||
const msgParts = data.store.part[m.id]
|
||||
if (msgParts) {
|
||||
for (const p of msgParts) {
|
||||
if (p) assistantParts.push(p)
|
||||
if (p) result.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const lastAssistantMessage = assistantMessages.at(-1)
|
||||
const error = assistantMessages.find((m) => m.error)?.error
|
||||
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
|
||||
|
||||
let lastTextPart: PartType | undefined
|
||||
for (let i = assistantParts.length - 1; i >= 0; i--) {
|
||||
if (assistantParts[i]?.type === "text") {
|
||||
lastTextPart = assistantParts[i]
|
||||
break
|
||||
}
|
||||
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
|
||||
|
||||
const lastTextPart = createMemo(() => {
|
||||
const ap = assistantParts()
|
||||
for (let i = ap.length - 1; i >= 0; i--) {
|
||||
if (ap[i]?.type === "text") return ap[i]
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const hasSteps = assistantParts.some((p) => p?.type === "tool")
|
||||
const hasSteps = createMemo(() => assistantParts().some((p) => p?.type === "tool"))
|
||||
|
||||
let isShellMode = false
|
||||
if (parts.every((p) => p?.type === "text" && p?.synthetic) && assistantParts.length === 1) {
|
||||
const assistantPart = assistantParts[0]
|
||||
if (assistantPart?.type === "tool" && assistantPart?.tool === "bash") {
|
||||
isShellMode = true
|
||||
}
|
||||
const isShellMode = createMemo(() => {
|
||||
const p = parts()
|
||||
const ap = assistantParts()
|
||||
if (p.every((part) => part?.type === "text" && part?.synthetic) && ap.length === 1) {
|
||||
const assistantPart = ap[0]
|
||||
if (assistantPart?.type === "tool" && assistantPart?.tool === "bash") return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
let resolvedParts = assistantParts
|
||||
const currentTask = assistantParts.findLast(
|
||||
const rawStatus = createMemo(() => {
|
||||
const ap = assistantParts()
|
||||
const currentTask = ap.findLast(
|
||||
(p) =>
|
||||
p &&
|
||||
p.type === "tool" &&
|
||||
@@ -148,6 +175,7 @@ export function SessionTurn(
|
||||
p.state.status === "running",
|
||||
) as ToolPart | undefined
|
||||
|
||||
let resolvedParts = ap
|
||||
if (currentTask?.state && "metadata" in currentTask.state && currentTask.state.metadata?.sessionId) {
|
||||
const taskMessages = data.store.message[currentTask.state.metadata.sessionId as string]?.filter(
|
||||
(m) => m.role === "assistant",
|
||||
@@ -162,48 +190,20 @@ export function SessionTurn(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (taskParts.length > 0) {
|
||||
resolvedParts = taskParts
|
||||
}
|
||||
if (taskParts.length > 0) resolvedParts = taskParts
|
||||
}
|
||||
}
|
||||
|
||||
const lastPart = resolvedParts.at(-1)
|
||||
const rawStatus = computeStatusFromPart(lastPart)
|
||||
|
||||
return {
|
||||
message,
|
||||
parts,
|
||||
assistantMessages,
|
||||
assistantParts,
|
||||
lastAssistantMessage,
|
||||
lastTextPart,
|
||||
error,
|
||||
hasSteps,
|
||||
isShellMode,
|
||||
rawStatus,
|
||||
isLastUserMessage: message.id === lastUserMessage?.id,
|
||||
}
|
||||
return computeStatusFromPart(resolvedParts.at(-1))
|
||||
})
|
||||
|
||||
const message = () => derived().message
|
||||
const parts = () => derived().parts
|
||||
const assistantMessages = () => derived().assistantMessages
|
||||
const assistantParts = () => derived().assistantParts
|
||||
const lastAssistantMessage = () => derived().lastAssistantMessage
|
||||
const lastTextPart = () => derived().lastTextPart
|
||||
const error = () => derived().error
|
||||
const hasSteps = () => derived().hasSteps
|
||||
const isShellMode = () => derived().isShellMode
|
||||
const rawStatus = () => derived().rawStatus
|
||||
|
||||
const status = createMemo(
|
||||
() =>
|
||||
data.store.session_status[props.sessionID] ?? {
|
||||
type: "idle",
|
||||
},
|
||||
)
|
||||
const working = createMemo(() => status().type !== "idle" && derived().isLastUserMessage)
|
||||
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
|
||||
const retry = createMemo(() => {
|
||||
const s = status()
|
||||
if (s.type !== "retry") return
|
||||
@@ -246,6 +246,7 @@ export function SessionTurn(
|
||||
retrySeconds: 0,
|
||||
status: rawStatus(),
|
||||
duration: duration(),
|
||||
summaryWaitTimedOut: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -286,6 +287,42 @@ export function SessionTurn(
|
||||
onCleanup(() => clearInterval(timer))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (working()) {
|
||||
setStore("summaryWaitTimedOut", false)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (working() || !isLastUserMessage()) return
|
||||
|
||||
const diffs = message()?.summary?.diffs
|
||||
if (!diffs?.length) return
|
||||
if (summary()) return
|
||||
if (store.summaryWaitTimedOut) return
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setStore("summaryWaitTimedOut", true)
|
||||
}, 6000)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
const waitingForSummary = createMemo(() => {
|
||||
if (!isLastUserMessage()) return false
|
||||
if (working()) return false
|
||||
|
||||
const diffs = message()?.summary?.diffs
|
||||
if (!diffs?.length) return false
|
||||
if (summary()) return false
|
||||
|
||||
return !store.summaryWaitTimedOut
|
||||
})
|
||||
|
||||
const showSummarySection = createMemo(() => {
|
||||
if (working()) return false
|
||||
return !waitingForSummary()
|
||||
})
|
||||
|
||||
let lastStatusChange = Date.now()
|
||||
let statusTimeout: number | undefined
|
||||
createEffect(() => {
|
||||
@@ -362,7 +399,7 @@ export function SessionTurn(
|
||||
size="small"
|
||||
onClick={props.onStepsExpandedToggle ?? (() => {})}
|
||||
>
|
||||
<Show when={working()}>
|
||||
<Show when={working() || waitingForSummary()}>
|
||||
<Spinner />
|
||||
</Show>
|
||||
<Switch>
|
||||
@@ -379,6 +416,7 @@ export function SessionTurn(
|
||||
</span>
|
||||
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
|
||||
</Match>
|
||||
<Match when={waitingForSummary()}>Generating summary</Match>
|
||||
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
|
||||
<Match when={props.stepsExpanded}>Hide steps</Match>
|
||||
<Match when={!props.stepsExpanded}>Show steps</Match>
|
||||
@@ -395,27 +433,15 @@ export function SessionTurn(
|
||||
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-collapsible-content-inner">
|
||||
<For each={assistantMessages()}>
|
||||
{(assistantMessage) => {
|
||||
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
|
||||
const last = createMemo(() =>
|
||||
parts()
|
||||
.filter((p) => p?.type === "text")
|
||||
.at(-1),
|
||||
)
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!summary() && response() && lastTextPart()?.id === last()?.id}>
|
||||
<Message
|
||||
message={assistantMessage}
|
||||
parts={parts().filter((p) => p?.id !== last()?.id)}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Message message={assistantMessage} parts={parts()} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
{(assistantMessage) => (
|
||||
<AssistantMessageItem
|
||||
message={assistantMessage}
|
||||
summary={summary()}
|
||||
response={response()}
|
||||
lastTextPartId={lastTextPart()?.id}
|
||||
working={working()}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
@@ -425,7 +451,7 @@ export function SessionTurn(
|
||||
</div>
|
||||
</Show>
|
||||
{/* Summary */}
|
||||
<Show when={!working()}>
|
||||
<Show when={showSummarySection()}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<Switch>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, createEffect } from "solid-js"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
|
||||
@@ -11,81 +11,62 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
let scrollRef: HTMLElement | undefined
|
||||
const [store, setStore] = createStore({
|
||||
contentRef: undefined as HTMLElement | undefined,
|
||||
lastScrollTop: 0,
|
||||
lastScrollHeight: 0,
|
||||
lastContentWidth: 0,
|
||||
autoScrolled: false,
|
||||
userScrolled: false,
|
||||
reflowing: false,
|
||||
})
|
||||
|
||||
let lastScrollTop = 0
|
||||
let isAutoScrolling = false
|
||||
let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
let isMouseDown = false
|
||||
let cleanupListeners: (() => void) | undefined
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!scrollRef || store.userScrolled || !options.working()) return
|
||||
setStore("autoScrolled", true)
|
||||
requestAnimationFrame(() => {
|
||||
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
|
||||
requestAnimationFrame(() => {
|
||||
batch(() => {
|
||||
setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
|
||||
setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
|
||||
setStore("autoScrolled", false)
|
||||
})
|
||||
})
|
||||
|
||||
isAutoScrolling = true
|
||||
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
|
||||
autoScrollTimeout = setTimeout(() => {
|
||||
isAutoScrolling = false
|
||||
}, 1000)
|
||||
|
||||
scrollRef.scrollTo({
|
||||
top: scrollRef.scrollHeight,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!scrollRef || store.autoScrolled) return
|
||||
if (!scrollRef) return
|
||||
|
||||
const scrollTop = scrollRef.scrollTop
|
||||
const scrollHeight = scrollRef.scrollHeight
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef
|
||||
const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
|
||||
|
||||
if (store.reflowing) {
|
||||
batch(() => {
|
||||
setStore("lastScrollTop", scrollTop)
|
||||
setStore("lastScrollHeight", scrollHeight)
|
||||
})
|
||||
if (isAutoScrolling) {
|
||||
if (atBottom) {
|
||||
isAutoScrolling = false
|
||||
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
|
||||
}
|
||||
lastScrollTop = scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
|
||||
const scrollTopDelta = scrollTop - store.lastScrollTop
|
||||
if (atBottom) {
|
||||
if (store.userScrolled) {
|
||||
setStore("userScrolled", false)
|
||||
}
|
||||
lastScrollTop = scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
// Handle reflow-caused scroll position changes
|
||||
if (scrollHeightChanged && scrollTopDelta < 0) {
|
||||
const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
|
||||
const expectedScrollTop = store.lastScrollTop * heightRatio
|
||||
if (Math.abs(scrollTop - expectedScrollTop) < 100) {
|
||||
batch(() => {
|
||||
setStore("lastScrollTop", scrollTop)
|
||||
setStore("lastScrollHeight", scrollHeight)
|
||||
})
|
||||
return
|
||||
const delta = scrollTop - lastScrollTop
|
||||
if (delta < 0) {
|
||||
if (isMouseDown && !store.userScrolled && options.working()) {
|
||||
setStore("userScrolled", true)
|
||||
options.onUserInteracted?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reset to top while working
|
||||
const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
|
||||
if (reset) {
|
||||
batch(() => {
|
||||
setStore("lastScrollTop", scrollTop)
|
||||
setStore("lastScrollHeight", scrollHeight)
|
||||
})
|
||||
requestAnimationFrame(scrollToBottom)
|
||||
return
|
||||
}
|
||||
|
||||
// Detect intentional scroll up
|
||||
const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
|
||||
if (scrolledUp && options.working()) {
|
||||
setStore("userScrolled", true)
|
||||
options.onUserInteracted?.()
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setStore("lastScrollTop", scrollTop)
|
||||
setStore("lastScrollHeight", scrollHeight)
|
||||
})
|
||||
lastScrollTop = scrollTop
|
||||
}
|
||||
|
||||
function handleInteraction() {
|
||||
@@ -95,36 +76,86 @@ export function createAutoScroll(options: AutoScrollOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (e.deltaY < 0 && !store.userScrolled && options.working()) {
|
||||
setStore("userScrolled", true)
|
||||
options.onUserInteracted?.()
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart() {
|
||||
if (!store.userScrolled && options.working()) {
|
||||
setStore("userScrolled", true)
|
||||
options.onUserInteracted?.()
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
|
||||
if (!store.userScrolled && options.working()) {
|
||||
setStore("userScrolled", true)
|
||||
options.onUserInteracted?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseDown() {
|
||||
isMouseDown = true
|
||||
window.addEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
isMouseDown = false
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
|
||||
// Reset userScrolled when work completes
|
||||
createEffect(() => {
|
||||
if (!options.working()) setStore("userScrolled", false)
|
||||
if (!options.working()) {
|
||||
setStore("userScrolled", false)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle content resize
|
||||
createResizeObserver(
|
||||
() => store.contentRef,
|
||||
({ width }) => {
|
||||
const widthChanged = Math.abs(width - store.lastContentWidth) > 5
|
||||
if (widthChanged && store.lastContentWidth > 0) {
|
||||
setStore("reflowing", true)
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setStore("reflowing", false)
|
||||
if (options.working() && !store.userScrolled) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
})
|
||||
} else if (!store.reflowing) {
|
||||
() => {
|
||||
if (options.working() && !store.userScrolled) {
|
||||
scrollToBottom()
|
||||
}
|
||||
setStore("lastContentWidth", width)
|
||||
},
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
|
||||
if (cleanupListeners) cleanupListeners()
|
||||
})
|
||||
|
||||
return {
|
||||
scrollRef: (el: HTMLElement | undefined) => {
|
||||
if (cleanupListeners) {
|
||||
cleanupListeners()
|
||||
cleanupListeners = undefined
|
||||
}
|
||||
|
||||
scrollRef = el
|
||||
if (el) {
|
||||
lastScrollTop = el.scrollTop
|
||||
el.style.overflowAnchor = "none"
|
||||
|
||||
el.addEventListener("wheel", handleWheel, { passive: true })
|
||||
el.addEventListener("touchstart", handleTouchStart, { passive: true })
|
||||
el.addEventListener("keydown", handleKeyDown)
|
||||
el.addEventListener("mousedown", handleMouseDown)
|
||||
|
||||
cleanupListeners = () => {
|
||||
el.removeEventListener("wheel", handleWheel)
|
||||
el.removeEventListener("touchstart", handleTouchStart)
|
||||
el.removeEventListener("keydown", handleKeyDown)
|
||||
el.removeEventListener("mousedown", handleMouseDown)
|
||||
window.removeEventListener("mouseup", handleMouseUp)
|
||||
}
|
||||
}
|
||||
},
|
||||
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
|
||||
handleScroll,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
@@ -24,8 +24,8 @@
|
||||
"js-base64": "3.7.7",
|
||||
"lang-map": "0.4.0",
|
||||
"luxon": "catalog:",
|
||||
"marked": "15.0.12",
|
||||
"marked-shiki": "1.2.1",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
"rehype-autolink-headings": "7.1.0",
|
||||
"remeda": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
|
||||
@@ -42,7 +42,7 @@ OpenCode comes with two built-in subagents, **General** and **Explore**. We'll l
|
||||
|
||||
## Built-in
|
||||
|
||||
OpenCode comes with two built-in primary agents and one built-in subagent.
|
||||
OpenCode comes with two built-in primary agents and two built-in subagents.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -69,6 +69,16 @@ This command will guide you through creating a new agent with a custom system pr
|
||||
|
||||
---
|
||||
|
||||
#### list
|
||||
|
||||
List all available agents.
|
||||
|
||||
```bash
|
||||
opencode agent list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### auth
|
||||
|
||||
Command to manage credentials and login for providers.
|
||||
@@ -156,6 +166,88 @@ opencode github run
|
||||
|
||||
---
|
||||
|
||||
### mcp
|
||||
|
||||
Manage Model Context Protocol servers.
|
||||
|
||||
```bash
|
||||
opencode mcp [command]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### add
|
||||
|
||||
Add an MCP server to your configuration.
|
||||
|
||||
```bash
|
||||
opencode mcp add
|
||||
```
|
||||
|
||||
This command will guide you through adding either a local or remote MCP server.
|
||||
|
||||
---
|
||||
|
||||
#### list
|
||||
|
||||
List all configured MCP servers and their connection status.
|
||||
|
||||
```bash
|
||||
opencode mcp list
|
||||
```
|
||||
|
||||
Or use the short version.
|
||||
|
||||
```bash
|
||||
opencode mcp ls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### auth
|
||||
|
||||
Authenticate with an OAuth-enabled MCP server.
|
||||
|
||||
```bash
|
||||
opencode mcp auth [name]
|
||||
```
|
||||
|
||||
If you don't provide a server name, you'll be prompted to select from available OAuth-capable servers.
|
||||
|
||||
You can also list OAuth-capable servers and their authentication status.
|
||||
|
||||
```bash
|
||||
opencode mcp auth list
|
||||
```
|
||||
|
||||
Or use the short version.
|
||||
|
||||
```bash
|
||||
opencode mcp auth ls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### logout
|
||||
|
||||
Remove OAuth credentials for an MCP server.
|
||||
|
||||
```bash
|
||||
opencode mcp logout [name]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### debug
|
||||
|
||||
Debug OAuth connection issues for an MCP server.
|
||||
|
||||
```bash
|
||||
opencode mcp debug <name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### models
|
||||
|
||||
List all available models from configured providers.
|
||||
@@ -250,6 +342,138 @@ This starts an HTTP server that provides API access to opencode functionality wi
|
||||
|
||||
---
|
||||
|
||||
### session
|
||||
|
||||
Manage OpenCode sessions.
|
||||
|
||||
```bash
|
||||
opencode session [command]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### list
|
||||
|
||||
List all OpenCode sessions.
|
||||
|
||||
```bash
|
||||
opencode session list
|
||||
```
|
||||
|
||||
##### Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ------------- | ----- | ------------------------------------ |
|
||||
| `--max-count` | `-n` | Limit to N most recent sessions |
|
||||
| `--format` | | Output format: table or json (table) |
|
||||
|
||||
---
|
||||
|
||||
### stats
|
||||
|
||||
Show token usage and cost statistics for your OpenCode sessions.
|
||||
|
||||
```bash
|
||||
opencode stats
|
||||
```
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ----------- | --------------------------------------------------------------- |
|
||||
| `--days` | Show stats for the last N days (all time) |
|
||||
| `--tools` | Number of tools to show (all) |
|
||||
| `--project` | Filter by project (all projects, empty string: current project) |
|
||||
|
||||
---
|
||||
|
||||
### export
|
||||
|
||||
Export session data as JSON.
|
||||
|
||||
```bash
|
||||
opencode export [sessionID]
|
||||
```
|
||||
|
||||
If you don't provide a session ID, you'll be prompted to select from available sessions.
|
||||
|
||||
---
|
||||
|
||||
### import
|
||||
|
||||
Import session data from a JSON file or OpenCode share URL.
|
||||
|
||||
```bash
|
||||
opencode import <file>
|
||||
```
|
||||
|
||||
You can import from a local file or an OpenCode share URL.
|
||||
|
||||
```bash
|
||||
opencode import session.json
|
||||
opencode import https://opncd.ai/s/abc123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### web
|
||||
|
||||
Start a headless OpenCode server with a web interface.
|
||||
|
||||
```bash
|
||||
opencode web
|
||||
```
|
||||
|
||||
This starts an HTTP server and opens a web browser to access OpenCode through a web interface.
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| ------------ | ----- | --------------------- |
|
||||
| `--port` | `-p` | Port to listen on |
|
||||
| `--hostname` | | Hostname to listen on |
|
||||
|
||||
---
|
||||
|
||||
### acp
|
||||
|
||||
Start an ACP (Agent Client Protocol) server.
|
||||
|
||||
```bash
|
||||
opencode acp
|
||||
```
|
||||
|
||||
This command starts an ACP server that communicates via stdin/stdout using nd-JSON.
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ------------ | --------------------- |
|
||||
| `--cwd` | Working directory |
|
||||
| `--port` | Port to listen on |
|
||||
| `--hostname` | Hostname to listen on |
|
||||
|
||||
---
|
||||
|
||||
### uninstall
|
||||
|
||||
Uninstall OpenCode and remove all related files.
|
||||
|
||||
```bash
|
||||
opencode uninstall
|
||||
```
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Short | Description |
|
||||
| --------------- | ----- | ------------------------------------------- |
|
||||
| `--keep-config` | `-c` | Keep configuration files |
|
||||
| `--keep-data` | `-d` | Keep session data and snapshots |
|
||||
| `--dry-run` | | Show what would be removed without removing |
|
||||
| `--force` | `-f` | Skip confirmation prompts |
|
||||
|
||||
---
|
||||
|
||||
### upgrade
|
||||
|
||||
Updates opencode to the latest version or a specific version.
|
||||
@@ -329,3 +553,4 @@ These environment variables enable experimental features that may change or be r
|
||||
| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | Max output tokens for LLM responses |
|
||||
| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | Enable file watcher for entire dir |
|
||||
| `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | Enable oxfmt formatter |
|
||||
| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | Enable experimental LSP tool |
|
||||
|
||||
@@ -28,11 +28,13 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
|
||||
You can place your config in a couple of different locations and they have a
|
||||
different order of precedence.
|
||||
|
||||
:::note[Config Merging]
|
||||
Configuration files are **merged together**, not replaced. Settings from all config locations are combined using a deep merge strategy, where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
|
||||
:::note
|
||||
Configuration files are **merged together**, not replaced.
|
||||
:::
|
||||
|
||||
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
|
||||
|
||||
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
@@ -58,13 +60,15 @@ This is also safe to be checked into Git and uses the same schema as the global
|
||||
|
||||
### Custom path
|
||||
|
||||
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. Settings from this config are merged with and can override the global and project configs.
|
||||
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
|
||||
|
||||
```bash
|
||||
export OPENCODE_CONFIG=/path/to/my/custom-config.json
|
||||
opencode run "Hello world"
|
||||
```
|
||||
|
||||
Settings from this config are merged with and **can override** the global and project configs.
|
||||
|
||||
---
|
||||
|
||||
### Custom directory
|
||||
@@ -79,7 +83,7 @@ export OPENCODE_CONFIG_DIR=/path/to/my/config-directory
|
||||
opencode run "Hello world"
|
||||
```
|
||||
|
||||
Note: The custom directory is loaded after the global config and `.opencode` directories, so it can override their settings.
|
||||
The custom directory is loaded after the global config and `.opencode` directories, so it **can override** their settings.
|
||||
|
||||
---
|
||||
|
||||
@@ -376,6 +380,10 @@ You can disable providers that are loaded automatically through the `disabled_pr
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
The `disabled_providers` takes priority over `enabled_providers`.
|
||||
:::
|
||||
|
||||
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
|
||||
|
||||
- It won't be loaded even if environment variables are set.
|
||||
@@ -398,9 +406,11 @@ You can specify an allowlist of providers through the `enabled_providers` option
|
||||
This is useful when you want to restrict OpenCode to only use specific providers rather than disabling them one by one.
|
||||
|
||||
:::note
|
||||
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
|
||||
The `disabled_providers` takes priority over `enabled_providers`.
|
||||
:::
|
||||
|
||||
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
|
||||
|
||||
---
|
||||
|
||||
## Variables
|
||||
|
||||
@@ -15,23 +15,24 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
|
||||
|
||||
## Plugins
|
||||
|
||||
| Name | Description |
|
||||
| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
|
||||
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
|
||||
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
|
||||
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
|
||||
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
|
||||
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
|
||||
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
|
||||
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
|
||||
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
|
||||
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
|
||||
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
|
||||
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
||||
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
|
||||
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
||||
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
|
||||
| Name | Description |
|
||||
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
|
||||
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
|
||||
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
|
||||
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
|
||||
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
|
||||
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
|
||||
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
|
||||
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
|
||||
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
|
||||
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
|
||||
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
|
||||
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
|
||||
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
|
||||
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
|
||||
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
|
||||
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Here we are using a community-created CI/CD component for OpenCode — [nagyv/gi
|
||||
|
||||
```yaml title=".gitlab-ci.yml"
|
||||
include:
|
||||
- component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@1.0.0
|
||||
- component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@2
|
||||
inputs:
|
||||
config_dir: ${CI_PROJECT_DIR}/opencode-config
|
||||
auth_json: $OPENCODE_AUTH_JSON # The variable name for your OpenCode authentication JSON
|
||||
|
||||
@@ -24,8 +24,9 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
|
||||
"session_unshare": "none",
|
||||
"session_interrupt": "escape",
|
||||
"session_compact": "<leader>c",
|
||||
"session_child_cycle": "<leader>+right",
|
||||
"session_child_cycle_reverse": "<leader>+left",
|
||||
"session_child_cycle": "<leader>right",
|
||||
"session_child_cycle_reverse": "<leader>left",
|
||||
"session_parent": "<leader>up",
|
||||
"messages_page_up": "pageup",
|
||||
"messages_page_down": "pagedown",
|
||||
"messages_half_page_up": "ctrl+alt+u",
|
||||
|
||||
@@ -3,37 +3,27 @@ title: MCP servers
|
||||
description: Add local and remote MCP tools.
|
||||
---
|
||||
|
||||
You can add external tools to OpenCode using the _Model Context Protocol_, or MCP.
|
||||
|
||||
OpenCode supports both:
|
||||
|
||||
- Local servers
|
||||
- Remote servers
|
||||
You can add external tools to OpenCode using the _Model Context Protocol_, or MCP. OpenCode supports both local and remote servers.
|
||||
|
||||
Once added, MCP tools are automatically available to the LLM alongside built-in tools.
|
||||
|
||||
---
|
||||
|
||||
## Caveats
|
||||
#### Caveats
|
||||
|
||||
When you use an MCP server, it adds to the context. This can quickly add up if
|
||||
you have a lot of tools. So we recommend being careful with which MCP servers
|
||||
you use.
|
||||
When you use an MCP server, it adds to the context. This can quickly add up if you have a lot of tools. So we recommend being careful with which MCP servers you use.
|
||||
|
||||
:::tip
|
||||
MCP servers add to your context, so you want to be careful with which
|
||||
ones you enable.
|
||||
MCP servers add to your context, so you want to be careful with which ones you enable.
|
||||
:::
|
||||
|
||||
Certain MCP servers, like the GitHub MCP server tend to add a lot of tokens and
|
||||
can easily exceed the context limit.
|
||||
Certain MCP servers, like the GitHub MCP server, tend to add a lot of tokens and can easily exceed the context limit.
|
||||
|
||||
---
|
||||
|
||||
## Configure
|
||||
## Enable
|
||||
|
||||
You can define MCP servers in your OpenCode config under `mcp`. Add each MCP
|
||||
with a unique name. You can refer to that MCP by name when prompting the LLM.
|
||||
You can define MCP servers in your OpenCode config under `mcp`. Add each MCP with a unique name. You can refer to that MCP by name when prompting the LLM.
|
||||
|
||||
```jsonc title="opencode.jsonc" {6}
|
||||
{
|
||||
@@ -54,7 +44,7 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
|
||||
|
||||
---
|
||||
|
||||
### Local
|
||||
## Local
|
||||
|
||||
Add local MCP servers using `type` to `"local"` within the MCP object.
|
||||
|
||||
@@ -77,8 +67,7 @@ Add local MCP servers using `type` to `"local"` within the MCP object.
|
||||
|
||||
The command is how the local MCP server is started. You can also pass in a list of environment variables as well.
|
||||
|
||||
For example, here's how I can add the test
|
||||
[`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP server.
|
||||
For example, here's how you can add the test [`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP server.
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
@@ -98,6 +87,8 @@ And to use it I can add `use the mcp_everything tool` to my prompts.
|
||||
use the mcp_everything tool to add the number 3 and 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### Options
|
||||
|
||||
Here are all the options for configuring a local MCP server.
|
||||
@@ -112,9 +103,9 @@ Here are all the options for configuring a local MCP server.
|
||||
|
||||
---
|
||||
|
||||
### Remote
|
||||
## Remote
|
||||
|
||||
Add remote MCP servers under by setting `type` to `"remote"`.
|
||||
Add remote MCP servers by setting `type` to `"remote"`.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
@@ -132,7 +123,9 @@ Add remote MCP servers under by setting `type` to `"remote"`.
|
||||
}
|
||||
```
|
||||
|
||||
Here the `url` is the URL of the remote MCP server and with the `headers` option you can pass in a list of headers.
|
||||
The `url` is the URL of the remote MCP server and with the `headers` option you can pass in a list of headers.
|
||||
|
||||
---
|
||||
|
||||
#### Options
|
||||
|
||||
@@ -147,7 +140,7 @@ Here the `url` is the URL of the remote MCP server and with the `headers` option
|
||||
|
||||
---
|
||||
|
||||
### OAuth
|
||||
## OAuth
|
||||
|
||||
OpenCode automatically handles OAuth authentication for remote MCP servers. When a server requires authentication, OpenCode will:
|
||||
|
||||
@@ -155,7 +148,9 @@ OpenCode automatically handles OAuth authentication for remote MCP servers. When
|
||||
2. Use **Dynamic Client Registration (RFC 7591)** if supported by the server
|
||||
3. Store tokens securely for future requests
|
||||
|
||||
#### Automatic OAuth
|
||||
---
|
||||
|
||||
### Automatic
|
||||
|
||||
For most OAuth-enabled MCP servers, no special configuration is needed. Just configure the remote server:
|
||||
|
||||
@@ -171,13 +166,15 @@ For most OAuth-enabled MCP servers, no special configuration is needed. Just con
|
||||
}
|
||||
```
|
||||
|
||||
If the server requires authentication, OpenCode will prompt you to authenticate when you first try to use it.
|
||||
If the server requires authentication, OpenCode will prompt you to authenticate when you first try to use it. If not, you can [manually trigger the flow](#authenticating) with `opencode mcp auth <server-name>`.
|
||||
|
||||
#### Pre-registered Client
|
||||
---
|
||||
|
||||
### Pre-registered
|
||||
|
||||
If you have client credentials from the MCP server provider, you can configure them:
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="opencode.json" {7-11}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
@@ -194,11 +191,39 @@ If you have client credentials from the MCP server provider, you can configure t
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Authenticating
|
||||
|
||||
You can manually trigger authentication or manage credentials.
|
||||
|
||||
Authenticate with a specific MCP server:
|
||||
|
||||
```bash
|
||||
opencode mcp auth my-oauth-server
|
||||
```
|
||||
|
||||
List all MCP servers and their auth status:
|
||||
|
||||
```bash
|
||||
opencode mcp list
|
||||
```
|
||||
|
||||
Remove stored credentials:
|
||||
|
||||
```bash
|
||||
opencode mcp logout my-oauth-server
|
||||
```
|
||||
|
||||
The `mcp auth` command will open your browser for authorization. After you authorize, OpenCode will store the tokens securely in `~/.local/share/opencode/mcp-auth.json`.
|
||||
|
||||
---
|
||||
|
||||
#### Disabling OAuth
|
||||
|
||||
If you want to disable automatic OAuth for a server (e.g., for servers that use API keys instead), set `oauth` to `false`:
|
||||
|
||||
```json title="opencode.json"
|
||||
```json title="opencode.json" {7}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
@@ -214,38 +239,36 @@ If you want to disable automatic OAuth for a server (e.g., for servers that use
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### OAuth Options
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
| -------------- | --------------- | -------- | -------------------------------------------------------------------------------- |
|
||||
| `oauth` | Object \| false | | OAuth config object, or `false` to disable OAuth auto-detection. |
|
||||
| `clientId` | String | | OAuth client ID. If not provided, dynamic client registration will be attempted. |
|
||||
| `clientSecret` | String | | OAuth client secret, if required by the authorization server. |
|
||||
| `scope` | String | | OAuth scopes to request during authorization. |
|
||||
| Option | Type | Description |
|
||||
| -------------- | --------------- | -------------------------------------------------------------------------------- |
|
||||
| `oauth` | Object \| false | OAuth config object, or `false` to disable OAuth auto-detection. |
|
||||
| `clientId` | String | OAuth client ID. If not provided, dynamic client registration will be attempted. |
|
||||
| `clientSecret` | String | OAuth client secret, if required by the authorization server. |
|
||||
| `scope` | String | OAuth scopes to request during authorization. |
|
||||
|
||||
#### Authenticating
|
||||
#### Debugging
|
||||
|
||||
You can manually trigger authentication or manage credentials:
|
||||
If a remote MCP server is failing to authenticate, you can diagnose issues with:
|
||||
|
||||
```bash
|
||||
# Authenticate with a specific MCP server
|
||||
opencode mcp auth my-oauth-server
|
||||
# View auth status for all OAuth-capable servers
|
||||
opencode mcp auth list
|
||||
|
||||
# List all MCP servers and their auth status
|
||||
opencode mcp list
|
||||
|
||||
# Remove stored credentials
|
||||
opencode mcp logout my-oauth-server
|
||||
# Debug connection and OAuth flow for a specific server
|
||||
opencode mcp debug my-oauth-server
|
||||
```
|
||||
|
||||
The `mcp auth` command will open your browser for authorization. After you authorize, OpenCode will store the tokens securely in `~/.local/share/opencode/mcp-auth.json`.
|
||||
The `mcp debug` command shows the current auth status, tests HTTP connectivity, and attempts the OAuth discovery flow.
|
||||
|
||||
---
|
||||
|
||||
## Manage
|
||||
|
||||
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you
|
||||
can manage them through the OpenCode config like any other tool.
|
||||
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you can manage them through the OpenCode config like any other tool.
|
||||
|
||||
---
|
||||
|
||||
@@ -299,11 +322,10 @@ Here we are using the glob pattern `my-mcp*` to disable all MCPs.
|
||||
|
||||
### Per agent
|
||||
|
||||
If you have a large number of MCP servers you may want to only enable them per
|
||||
agent and disable them globally. To do this:
|
||||
If you have a large number of MCP servers you may want to only enable them per agent and disable them globally. To do this:
|
||||
|
||||
1. Disable it as a tool globally.
|
||||
2. In your [agent config](/docs/agents#tools) enable the MCP server as a tool.
|
||||
2. In your [agent config](/docs/agents#tools), enable the MCP server as a tool.
|
||||
|
||||
```json title="opencode.json" {11, 14-18}
|
||||
{
|
||||
@@ -346,6 +368,39 @@ Below are examples of some common MCP servers. You can submit a PR if you want t
|
||||
|
||||
---
|
||||
|
||||
### Sentry
|
||||
|
||||
Add the [Sentry MCP server](https://mcp.sentry.dev) to interact with your Sentry projects and issues.
|
||||
|
||||
```json title="opencode.json" {4-8}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"sentry": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.sentry.dev/mcp",
|
||||
"oauth": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After adding the configuration, authenticate with Sentry:
|
||||
|
||||
```bash
|
||||
opencode mcp auth sentry
|
||||
```
|
||||
|
||||
This will open a browser window to complete the OAuth flow and connect OpenCode to your Sentry account.
|
||||
|
||||
Once authenticated, you can use Sentry tools in your prompts to query issues, projects, and error data.
|
||||
|
||||
```txt "use sentry"
|
||||
Show me the latest unresolved issues in my project. use sentry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Context7
|
||||
|
||||
Add the [Context7 MCP server](https://github.com/upstash/context7) to search through docs.
|
||||
@@ -387,8 +442,7 @@ Add `use context7` to your prompts to use Context7 MCP server.
|
||||
Configure a Cloudflare Worker script to cache JSON API responses for five minutes. use context7
|
||||
```
|
||||
|
||||
Alternatively, you can add something like this to your
|
||||
[AGENTS.md](/docs/rules/).
|
||||
Alternatively, you can add something like this to your [AGENTS.md](/docs/rules/).
|
||||
|
||||
```md title="AGENTS.md"
|
||||
When you need to search docs, use `context7` tools.
|
||||
@@ -418,9 +472,8 @@ Since we named our MCP server `gh_grep`, you can add `use the gh_grep tool` to y
|
||||
What's the right way to set a custom domain in an SST Astro component? use the gh_grep tool
|
||||
```
|
||||
|
||||
Alternatively, you can add something like this to your
|
||||
[AGENTS.md](/docs/rules/).
|
||||
Alternatively, you can add something like this to your [AGENTS.md](/docs/rules/).
|
||||
|
||||
```md title="AGENTS.md"
|
||||
If you are unsure how to do something, use `gh_grep` to search code examples from github.
|
||||
If you are unsure how to do something, use `gh_grep` to search code examples from GitHub.
|
||||
```
|
||||
|
||||
@@ -11,6 +11,7 @@ By default, OpenCode allows most operations without approval, except `doom_loop`
|
||||
"permission": {
|
||||
"edit": "allow",
|
||||
"bash": "ask",
|
||||
"skill": "ask",
|
||||
"webfetch": "deny",
|
||||
"doom_loop": "ask",
|
||||
"external_directory": "ask"
|
||||
@@ -18,7 +19,7 @@ By default, OpenCode allows most operations without approval, except `doom_loop`
|
||||
}
|
||||
```
|
||||
|
||||
This lets you configure granular controls for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools.
|
||||
This lets you configure granular controls for the `edit`, `bash`, `skill`, `webfetch`, `doom_loop`, and `external_directory` tools.
|
||||
|
||||
- `"ask"` — Prompt for approval before running the tool
|
||||
- `"allow"` — Allow all operations without approval
|
||||
@@ -28,7 +29,7 @@ This lets you configure granular controls for the `edit`, `bash`, `webfetch`, `d
|
||||
|
||||
## Tools
|
||||
|
||||
Currently, the permissions for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools can be configured through the `permission` option.
|
||||
Currently, the permissions for the `edit`, `bash`, `skill`, `webfetch`, `doom_loop`, and `external_directory` tools can be configured through the `permission` option.
|
||||
|
||||
---
|
||||
|
||||
@@ -144,6 +145,38 @@ When an agent asks for permission to run a command in a pipeline, we use tree si
|
||||
|
||||
---
|
||||
|
||||
### skill
|
||||
|
||||
Use the `permission.skill` key to control whether the model can load skills via the built-in `skill` tool.
|
||||
|
||||
You can apply a single rule to all skills:
|
||||
|
||||
```json title="opencode.json" {4}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"skill": "ask"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or configure per-skill rules (supports the same wildcard patterns as `permission.bash`):
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"permission": {
|
||||
"skill": {
|
||||
"*": "deny",
|
||||
"git-*": "allow",
|
||||
"frontend/*": "ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### webfetch
|
||||
|
||||
Use the `permission.webfetch` key to control whether the LLM can fetch web pages.
|
||||
|
||||
@@ -323,6 +323,64 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
|
||||
|
||||
---
|
||||
|
||||
### Cloudflare AI Gateway
|
||||
|
||||
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
|
||||
|
||||
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway.
|
||||
|
||||
2. Set your Account ID and Gateway ID as environment variables.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
|
||||
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
|
||||
```
|
||||
|
||||
3. Run the `/connect` command and search for **Cloudflare AI Gateway**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
4. Enter your Cloudflare API token.
|
||||
|
||||
```txt
|
||||
┌ API key
|
||||
│
|
||||
│
|
||||
└ enter
|
||||
```
|
||||
|
||||
Or set it as an environment variable.
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export CLOUDFLARE_API_TOKEN=your-api-token
|
||||
```
|
||||
|
||||
5. Run the `/models` command to select a model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
```
|
||||
|
||||
You can also add models through your opencode config.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"cloudflare-ai-gateway": {
|
||||
"models": {
|
||||
"openai/gpt-4o": {},
|
||||
"anthropic/claude-sonnet-4": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Cortecs
|
||||
|
||||
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.
|
||||
|
||||
@@ -14,7 +14,7 @@ Create one folder per skill name and put a `SKILL.md` inside it.
|
||||
OpenCode searches these locations:
|
||||
|
||||
- Project config: `.opencode/skill/<name>/SKILL.md`
|
||||
- Global config: `~/.opencode/skill/<name>/SKILL.md`
|
||||
- Global config: `~/.config/opencode/skill/<name>/SKILL.md`
|
||||
- Claude-compatible: `.claude/skills/<name>/SKILL.md`
|
||||
|
||||
---
|
||||
@@ -24,7 +24,7 @@ OpenCode searches these locations:
|
||||
For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree.
|
||||
It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way.
|
||||
|
||||
Global definitions are also loaded from `~/.opencode/skill/*/SKILL.md`.
|
||||
Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -213,6 +213,29 @@ This tool lists directory contents. It accepts glob patterns to filter results.
|
||||
|
||||
---
|
||||
|
||||
### lsp (experimental)
|
||||
|
||||
Interact with your configured LSP servers to get code intelligence features like definitions, references, hover info, and call hierarchy.
|
||||
|
||||
:::note
|
||||
This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPENCODE_EXPERIMENTAL=true`).
|
||||
:::
|
||||
|
||||
```json title="opencode.json" {4}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tools": {
|
||||
"lsp": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Supported operations include `goToDefinition`, `findReferences`, `hover`, `documentSymbol`, `workspaceSymbol`, `goToImplementation`, `prepareCallHierarchy`, `incomingCalls`, and `outgoingCalls`.
|
||||
|
||||
To configure which LSP servers are available for your project, see [LSP Servers](/docs/lsp).
|
||||
|
||||
---
|
||||
|
||||
### patch
|
||||
|
||||
Apply patches to files.
|
||||
@@ -230,6 +253,23 @@ This tool applies patch files to your codebase. Useful for applying diffs and pa
|
||||
|
||||
---
|
||||
|
||||
### skill
|
||||
|
||||
Load a [skill](/docs/skills) (a `SKILL.md` file) and return its content in the conversation.
|
||||
|
||||
```json title="opencode.json" {4}
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tools": {
|
||||
"skill": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can control approval prompts for loading skills via [permissions](/docs/permissions) using `permission.skill`.
|
||||
|
||||
---
|
||||
|
||||
### todowrite
|
||||
|
||||
Manage todo lists during coding sessions.
|
||||
|
||||
@@ -325,6 +325,7 @@ Popular editor options include:
|
||||
- `code` - Visual Studio Code
|
||||
- `cursor` - Cursor
|
||||
- `windsurf` - Windsurf
|
||||
- `nvim` - Neovim editor
|
||||
- `vim` - Vim editor
|
||||
- `nano` - Nano editor
|
||||
- `notepad` - Windows Notepad
|
||||
@@ -361,11 +362,13 @@ You can customize TUI behavior through your OpenCode config file.
|
||||
|
||||
---
|
||||
|
||||
## View customization
|
||||
## Customization
|
||||
|
||||
You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts.
|
||||
|
||||
### Username display
|
||||
---
|
||||
|
||||
#### Username display
|
||||
|
||||
Toggle whether your username appears in chat messages. Access this through:
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ const notes = [] as string[]
|
||||
console.log("=== publishing ===\n")
|
||||
|
||||
if (!Script.preview) {
|
||||
const previous = await fetch("https://registry.npmjs.org/opencode-ai/latest")
|
||||
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(res.statusText)
|
||||
return res.json()
|
||||
})
|
||||
.then((data: any) => data.version)
|
||||
.then((data: any) => data.tag_name.replace(/^v/, ""))
|
||||
|
||||
const log =
|
||||
await $`git log v${previous}..HEAD --oneline --format="%h %s" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app`.text()
|
||||
@@ -47,7 +47,7 @@ if (!Script.preview) {
|
||||
body: {
|
||||
model: {
|
||||
providerID: "opencode",
|
||||
modelID: "gemini-3-flash",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
|
||||
+9
-9
@@ -10,10 +10,10 @@ const EXTENSION_NAME = "opencode"
|
||||
|
||||
async function main() {
|
||||
const version = process.argv[2]
|
||||
if (!version) throw new Error("Version argument required: bun script/sync-zed.ts v1.0.52")
|
||||
if (!version) throw new Error("Version argument required, ex: bun script/sync-zed.ts v1.0.52")
|
||||
|
||||
const token = process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GITHUB_TOKEN environment variable required")
|
||||
const token = process.env.ZED_EXTENSIONS_PAT
|
||||
if (!token) throw new Error("ZED_EXTENSIONS_PAT environment variable required")
|
||||
|
||||
const cleanVersion = version.replace(/^v/, "")
|
||||
console.log(`📦 Syncing Zed extension for version ${cleanVersion}`)
|
||||
@@ -39,17 +39,17 @@ async function main() {
|
||||
process.chdir(workDir)
|
||||
|
||||
// Configure git identity
|
||||
await $`git config user.name "Dax Raad"`
|
||||
await $`git config user.email "d@ironbay.co"`
|
||||
await $`git config user.name "Aiden Cline"`
|
||||
await $`git config user.email "63023139+rekram1-node@users.noreply.github.com "`
|
||||
|
||||
// Sync fork with upstream
|
||||
// Sync fork with upstream (force reset to match exactly)
|
||||
console.log(`🔄 Syncing fork with upstream...`)
|
||||
await $`git remote add upstream https://github.com/${UPSTREAM_REPO}.git`
|
||||
await $`git fetch upstream`
|
||||
await $`git checkout main`
|
||||
await $`git merge upstream/main --ff-only`
|
||||
await $`git push origin main`
|
||||
console.log(`✅ Fork synced`)
|
||||
await $`git reset --hard upstream/main`
|
||||
await $`git push origin main --force`
|
||||
console.log(`✅ Fork synced (force reset to upstream)`)
|
||||
|
||||
// Create a new branch
|
||||
const branchName = `update-${EXTENSION_NAME}-${cleanVersion}`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.0.193",
|
||||
"version": "1.0.199",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Vendored
+8
-4
@@ -57,10 +57,6 @@ declare module "sst" {
|
||||
"type": "sst.sst.Linkable"
|
||||
"username": string
|
||||
}
|
||||
"Desktop": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"EMAILOCTOPUS_API_KEY": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -124,6 +120,10 @@ declare module "sst" {
|
||||
"type": "sst.cloudflare.Astro"
|
||||
"url": string
|
||||
}
|
||||
"WebApp": {
|
||||
"type": "sst.cloudflare.StaticSite"
|
||||
"url": string
|
||||
}
|
||||
"ZEN_MODELS1": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
@@ -144,6 +144,10 @@ declare module "sst" {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZEN_MODELS6": {
|
||||
"type": "sst.sst.Secret"
|
||||
"value": string
|
||||
}
|
||||
"ZenData": {
|
||||
"name": string
|
||||
"type": "sst.cloudflare.Bucket"
|
||||
|
||||
@@ -18,7 +18,6 @@ export default $config({
|
||||
async run() {
|
||||
await import("./infra/app.js")
|
||||
await import("./infra/console.js")
|
||||
await import("./infra/desktop.js")
|
||||
await import("./infra/enterprise.js")
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user